MTK平台端侧部署Qwen2.5大模型:从转换量化到推理调优全流程
1. 缘起为什么要在MTK平台上折腾大模型1.1 一个真实的项目需求去年底接到一个需求客户希望在基于MTK平台的边缘设备上跑一个本地化的大语言模型用来做离线场景下的智能问答和文本摘要。设备端的算力有限内存也不宽裕但客户明确要求不能依赖云端推理所有数据必须留在本地。这个需求其实挺典型的——现在越来越多的行业场景对数据隐私和响应延迟有硬性要求云端方案虽然省事但网络抖动、数据合规、调用成本这三座大山压下来本地部署就成了绕不开的选择。选型阶段我对比了几个主流的小尺寸模型最终锁定Qwen2.5系列。原因很直接Qwen2.5在中文理解和生成上的表现确实扎实而且官方放出了多个参数规模的版本从0.5B到72B都有给边缘设备留足了选择空间。MTK平台这边我手上用的是天玑系列的一款中高端芯片CPU和APU的算力在移动端算是第一梯队但要把一个几亿参数的大模型塞进去跑起来中间要过的坎比想象中多得多。这篇文章就是把这几个月踩过的坑、试过的方案、最终跑通的流程完整记录下来。如果你也在做类似的事情——不管是MTK平台还是其他嵌入式平台不管跑的是Qwen2.5还是别的模型——这里面的思路和操作细节应该都能直接参考。1.2 整体流程概览先给一个全局视角整个部署链路可以拆成四个阶段模型获取与格式转换拿到原始权重转成MTK平台能吃的格式量化与图优化压缩模型体积适配端侧算力推理引擎集成把模型塞进MTK的推理框架里端到端调优跑通之后调性能、调精度、调内存这四个阶段不是线性的实际做的时候经常要来回跳。比如量化之后精度掉得厉害就得回头调整转换参数推理引擎报错可能要重新检查图优化的步骤。下面我按这个脉络展开每个环节都会说清楚为什么这么做、怎么做、以及我实际遇到的情况。2. 模型转换从原始权重到MTK可识别格式2.1 原始模型的获取与检查Qwen2.5的权重从官方渠道下载下来之后第一件事不是急着转换而是先做一次完整的体检。我见过太多人跳过这一步结果转换到一半报错回头查半天发现是权重文件本身有问题。检查清单如下文件完整性确认所有分片文件都在model.safetensors.index.json里的映射关系能对上配置一致性config.json里的num_hidden_layers、hidden_size、num_attention_heads这些关键参数要和权重文件的实际shape匹配分词器可用性tokenizer.json和vocab.json能正常加载随便拿一段中文测一下编解码是否正常我习惯用一段Python脚本快速过一遍from transformers import AutoModelForCausalLM, AutoTokenizer import torch model_path ./Qwen2.5-1.5B-Instruct tokenizer AutoTokenizer.from_pretrained(model_path, trust_remote_codeTrue) model AutoModelForCausalLM.from_pretrained(model_path, torch_dtypetorch.float16, trust_remote_codeTrue) # 快速推理测试 inputs tokenizer(你好请介绍一下你自己。, return_tensorspt) outputs model.generate(**inputs, max_new_tokens50) print(tokenizer.decode(outputs[0], skip_special_tokensTrue))这一步能跑通说明原始权重没问题可以进入转换环节。如果这一步就报错先解决环境依赖和权重完整性的问题别往下走。2.2 转换工具链的选择与配置MTK平台有自己的模型转换工具链核心是把PyTorch或ONNX格式的模型转成MTK推理引擎能加载的格式。这里有个关键决策点走ONNX中转还是直接用MTK的原生转换器。我两种都试过说下实际感受。走ONNX中转的好处是通用性强ONNX生态成熟调试工具多中间出了问题容易定位。缺点是转换链路长PyTorch到ONNX、ONNX再到MTK格式每一步都可能引入精度损失或算子不兼容。直接用MTK原生转换器链路短但工具链的文档和社区支持相对薄弱遇到问题排查起来费劲。最终我选了ONNX中转方案原因是Qwen2.5的模型结构里有几个算子MTK原生转换器支持得不够好走ONNX可以手动干预这些算子的映射关系。转换到ONNX的具体操作python -m transformers.onnx \ --model./Qwen2.5-1.5B-Instruct \ --featurecausal-lm \ --opset14 \ ./onnx_output/这里有几个参数需要特别注意opset版本选14而不是最新的因为MTK的ONNX解析器对14的支持最稳定更高的版本反而容易出兼容性问题feature类型必须是causal-lm这是Qwen2.5的架构决定的输出目录确保有足够的磁盘空间1.5B的模型转成ONNX大概要占3-4GB转换完成后用ONNX Runtime做一次推理验证import onnxruntime as ort import numpy as np session ort.InferenceSession(./onnx_output/model.onnx) input_ids np.array([[1, 2, 3, 4, 5]], dtypenp.int64) outputs session.run(None, {input_ids: input_ids}) print(outputs[0].shape)这一步的输出shape应该和原始PyTorch模型一致如果对不上说明转换过程中有算子被错误处理了。2.3 算子兼容性处理Qwen2.5用到了RoPE旋转位置编码、SwiGLU激活函数、RMSNorm归一化这几个比较新的结构。ONNX标准算子集里没有直接对应的实现需要做算子替换或自定义。RoPE的处理是最麻烦的。ONNX没有原生的旋转位置编码算子常见的做法是用一系列基础算子拼出来。我在转换脚本里加了一段自定义映射class RoPEModule(torch.nn.Module): def forward(self, x, cos, sin): # 手动实现旋转位置编码 x1, x2 x[..., :x.shape[-1]//2], x[..., x.shape[-1]//2:] return torch.cat([x1 * cos - x2 * sin, x1 * sin x2 * cos], dim-1)然后在导出ONNX时把这个模块注册进去。实测下来这种手动展开的方式虽然增加了图的大小但推理结果的精度和原始模型几乎一致误差在1e-5以内。SwiGLU和RMSNorm相对好处理ONNX有对应的基础算子可以组合出来。关键是转换后要用onnxruntime的GraphOptimizationLevel开到最高让运行时自动做算子融合。注意算子替换之后一定要做数值对比。我一般会跑100条测试样本逐token对比原始模型和ONNX模型的输出logits确保最大绝对误差不超过1e-4。超过这个阈值后面量化之后精度会崩得更厉害。3. 量化压缩让模型在端侧跑得动3.1 量化方案选型1.5B的模型用FP16存储大概要3GB内存MTK设备上根本放不下。量化是必须的问题是选哪种量化方案。我对比了三种主流方案量化方案模型体积推理速度精度损失MTK支持度INT8动态量化~1.5GB中等较小好INT8静态量化~1.5GB快中等好INT4分组量化~0.8GB快较大一般最终选了INT8静态量化。理由是INT4虽然体积最小但Qwen2.5的注意力层对权重的数值范围比较敏感INT4量化后生成质量下降明显尤其是长文本生成时容易出现重复和逻辑断裂。INT8静态量化在精度和体积之间取得了比较好的平衡而且MTK的APU对INT8算子的硬件加速支持最完善。3.2 校准数据集的准备静态量化的关键是校准数据集。校准集的质量直接决定了量化参数的合理性。我的做法是从实际业务场景里抽500-1000条文本覆盖不同的长度和主题分布。校准集的构建原则长度分布短文本10-50 token占30%中等长度50-200 token占50%长文本200-512 token占20%主题覆盖如果业务场景是客服问答校准集里就要有足够多的问答对如果是文档摘要就要有长文档样本语言分布中英文混合的场景校准集里两种语言都要有from neural_compressor import quantization from neural_compressor.config import PostTrainingQuantConfig calib_data load_calibration_data(./calib_dataset.json, tokenizer, max_length512) quant_config PostTrainingQuantConfig( approachstatic, calibration_sampling_size500, op_type_dict{ .*: {weight: {dtype: [int8]}, activation: {dtype: [int8]}}, .*attention.*: {weight: {dtype: [int8]}, activation: {dtype: [fp32]}} } ) quantized_model quantization.fit( modelonnx_model, quant_configquant_config, calib_dataloadercalib_data )注意上面配置里对attention层的特殊处理注意力层的激活值保持FP32只量化权重。这是因为注意力分数经过softmax之后数值范围很小INT8的表示精度不够强行量化会导致注意力分布偏移。3.3 量化后的精度验证量化完成之后必须做严格的精度验证。我设计了一套评估流程困惑度对比在验证集上分别计算原始模型和量化模型的困惑度差距控制在5%以内算合格生成质量人工评估随机抽50条prompt人工对比生成结果看是否有明显的质量下降任务指标验证如果模型用于特定任务如分类、抽取要在任务测试集上跑一遍指标实测数据Qwen2.5-1.5B在INT8静态量化后困惑度从8.32上升到8.71涨幅4.7%在可接受范围内。生成质量方面短文本生成几乎无差异长文本生成偶尔会出现轻微的重复但整体可用。实操心得量化后的模型一定要在实际设备上跑一遍再下结论。我在PC上验证精度合格但部署到MTK设备后发现某些层的量化参数在APU上执行时会有额外的精度损失。后来在转换脚本里加了逐层的数值dump对比才发现是APU对某些INT8算子的累加精度和PC端不一致。解决办法是把这些层的累加器位宽从INT32提到INT64代价是推理速度慢了约8%但精度回来了。4. 推理引擎集成把模型塞进MTK框架4.1 MTK推理框架的架构理解MTK平台的推理框架是分层设计的从上到下大致是应用层你的业务代码调用推理API框架层模型加载、内存管理、任务调度算子层具体的计算算子实现硬件层CPU、APU、GPU的驱动和指令集模型部署的核心工作是把量化后的模型映射到算子层并确保框架层能正确调度。MTK的推理框架支持动态加载模型但要求模型的图结构符合它的规范。4.2 模型图的重写与适配ONNX转过来的图不能直接喂给MTK框架需要做几项重写第一算子替换。MTK框架对某些ONNX算子的支持不完整需要用它的原生算子重新表达。比如ONNX的LayerNormalization在MTK框架里要拆成ReduceMean、Sub、Pow、ReduceMean、Add、Sqrt、Div、Mul、Add这一串基础算子。第二内存布局调整。MTK的APU对NHWC布局的卷积有硬件加速但ONNX默认是NCHW。虽然Qwen2.5主要是矩阵运算不涉及卷积但注意力层的reshape操作会受布局影响。我在图重写阶段统一把张量布局转成APU友好的格式。第三常量折叠。把推理过程中不变的子图提前算出来减少运行时的计算量。比如RoPE的cos/sin表可以在加载阶段就预计算好。import mtk_inference as mtk # 加载ONNX模型 graph mtk.Graph() graph.load_onnx(./quantized_model.onnx) # 算子替换 graph.replace_op(LayerNormalization, MTK_LayerNorm) graph.replace_op(Gelu, MTK_Gelu) # 布局转换 graph.convert_layout(NCHW, NHWC) # 常量折叠 graph.constant_folding() # 保存适配后的模型 graph.save(./mtk_model.bin)4.3 内存管理与分配策略MTK设备的内存是稀缺资源推理过程中的内存分配策略直接影响能不能跑起来。我的做法是权重内存量化后的权重约1.5GB用mmap方式加载避免一次性拷贝到堆内存激活内存预分配一块固定大小的内存池推理时复用避免频繁malloc/freeKV CacheQwen2.5是自回归生成KV Cache会随生成长度增长。我设置了最大生成长度512预分配对应的KV Cache空间# 内存池配置 memory_pool mtk.MemoryPool( weight_size1.5 * 1024**3, # 1.5GB activation_size256 * 1024**2, # 256MB kv_cache_size128 * 1024**2, # 128MB max_seq_length512 ) # 模型加载时绑定内存池 model mtk.load_model(./mtk_model.bin, memory_poolmemory_pool)注意KV Cache的大小要按最坏情况预估。Qwen2.5-1.5B有28层每层KV Cache的维度是[batch, num_heads, seq_len, head_dim]。按batch1、num_heads12、head_dim128、seq_len512算单层KV Cache是1.5MB28层就是42MB。我预留128MB是留了余量防止多轮对话时缓存溢出。4.4 推理接口的封装模型加载好之后需要封装一个干净的推理接口给上层业务调用。我设计的接口长这样class QwenInference: def __init__(self, model_path, tokenizer_path): self.model mtk.load_model(model_path) self.tokenizer AutoTokenizer.from_pretrained(tokenizer_path) self.max_new_tokens 512 self.temperature 0.7 self.top_p 0.9 def generate(self, prompt, max_new_tokensNone): input_ids self.tokenizer.encode(prompt, return_tensorsnp) output_ids self.model.generate( input_ids, max_new_tokensmax_new_tokens or self.max_new_tokens, temperatureself.temperature, top_pself.top_p ) return self.tokenizer.decode(output_ids[0], skip_special_tokensTrue) def stream_generate(self, prompt): # 流式生成逐token返回 input_ids self.tokenizer.encode(prompt, return_tensorsnp) for token_id in self.model.stream_generate(input_ids): yield self.tokenizer.decode([token_id], skip_special_tokensTrue)流式生成在端侧场景里特别重要。用户等一个完整回答可能要好几秒但流式输出可以让首token延迟降到几百毫秒体验上差别很大。5. 性能调优与问题排查实录5.1 首token延迟优化首token延迟是端侧大模型最关键的体验指标。我实测下来Qwen2.5-1.5B在MTK设备上的首token延迟从最初的2.3秒优化到了680毫秒主要做了这几件事预填充优化。prompt的prefill阶段是计算密集型的MTK的APU对矩阵乘有硬件加速但需要把输入padding到对齐的长度。我把padding策略从动态改为固定长度256虽然浪费了一点计算但避免了动态shape带来的调度开销。算子融合。把连续的MatMul Add Gelu融合成一个算子减少kernel launch次数。MTK框架支持自定义算子融合规则我在图优化阶段加了一条规则graph.fuse_pattern( pattern[MatMul, Add, Gelu], fused_nameMTK_FusedMLP )权重预加载。第一次推理时权重从存储加载到内存有IO开销我在应用启动时就触发一次预热推理把权重全部加载到内存。5.2 生成阶段的吞吐优化生成阶段是逐token的瓶颈在内存带宽而不是算力。优化手段KV Cache量化把KV Cache也量化到INT8内存占用减半带宽压力降低投机采样用一个更小的草稿模型比如Qwen2.5-0.5B先生成几个候选token再用主模型验证。实测能提升1.8倍的生成速度批处理如果业务场景支持把多个请求拼成一个batch提高APU利用率投机采样的实现稍微复杂一点核心逻辑def speculative_generate(draft_model, target_model, prompt, gamma4): input_ids tokenizer.encode(prompt) while len(input_ids) max_length: # 草稿模型生成gamma个候选 draft_tokens draft_model.generate(input_ids, max_new_tokensgamma) # 主模型验证 target_logits target_model.forward(input_ids draft_tokens) # 接受或拒绝 accepted verify_tokens(draft_tokens, target_logits) input_ids.extend(accepted) if len(accepted) gamma: break return input_ids5.3 常见问题速查表问题现象可能原因排查方法解决方案模型加载失败格式不兼容检查MTK框架版本和模型格式重新转换或升级框架推理结果乱码分词器不匹配对比tokenizer输出使用配套的分词器首token延迟高prefill未优化profile prefill阶段固定padding长度、算子融合生成重复量化精度损失对比量化前后logits调整量化配置或换方案内存溢出KV Cache过大监控内存使用限制生成长度或量化KV CacheAPU利用率低算子未映射到APU查看算子执行设备替换为APU支持的算子多轮对话崩溃KV Cache管理bug检查缓存复用逻辑修复缓存索引或重置逻辑5.4 几个踩过的坑坑一量化后的模型在PC上正常在设备上输出乱码。排查了两天才发现是MTK的APU对INT8的饱和运算和PC端不一致。某些层的激活值在PC上刚好没溢出在APU上因为累加顺序不同溢出了。解决办法是在量化配置里对这些层加clip限制激活值的范围。坑二流式生成时首token之后卡顿。原因是KV Cache在第一次生成后没有正确复用每次生成新token都重新计算了整个序列的KV。修复方法是确保KV Cache的索引正确递增。坑三模型在低电量模式下推理速度骤降。MTK的电源管理策略会在低电量时限制APU频率。如果业务场景对性能有硬性要求需要在应用层申请性能模式或者引导用户充电。实操心得端侧部署一定要在真实设备上做长时间稳定性测试。我遇到过连续推理2小时后内存泄漏的问题原因是KV Cache的释放逻辑有bug每次对话结束后没有完全释放。这种问题在短时间测试里根本发现不了。6. 实际效果与后续扩展方向6.1 最终的性能数据经过完整优化后Qwen2.5-1.5B在MTK设备上的表现模型体积1.5GBINT8量化后内存占用峰值约2.1GB含KV Cache和运行时开销首token延迟680msprompt长度128生成速度18 tokens/s连续推理稳定性8小时无崩溃、无内存泄漏这个数据对于端侧场景来说已经可用了。离线问答、文本摘要、简单的对话交互都能撑住。6.2 还能怎么优化如果对性能有更高要求还有几个方向可以挖模型蒸馏。用Qwen2.5-7B作为教师模型蒸馏一个更小的学生模型专门针对业务场景优化。蒸馏后的模型可能只有0.5B但特定任务上的表现接近1.5B。混合精度量化。对不同层用不同的量化精度敏感的层用INT8不敏感的层用INT4。这样能在精度和体积之间找到更优的平衡点。硬件协同设计。如果设备是自研的可以在芯片设计阶段就考虑大模型推理的需求比如增加专用的矩阵乘单元、扩大片上缓存。动态推理。根据输入复杂度动态选择模型大小简单问题用0.5B复杂问题用1.5B。这样平均延迟和功耗都能降下来。6.3 一些个人体会做端侧大模型部署这几个月最大的感受是端侧和云端的优化思路完全不同。云端可以堆算力、堆内存优化重点是吞吐和成本端侧资源受限优化重点是延迟和内存很多时候要在精度上做妥协。另一个体会是工具链的成熟度决定效率。MTK的推理框架相比一些主流方案还有差距文档不够全、调试工具不够好用很多问题要靠自己摸索。但这也意味着先跑通的人有先发优势踩过的坑都是壁垒。最后说一个实际建议如果你刚开始做端侧大模型部署不要一上来就追求大模型。先从0.5B的小模型跑通全流程把转换、量化、集成、调优的每个环节都摸一遍再往上换大模型。这样遇到问题的时候你能快速定位是模型本身的问题还是流程的问题。