1. 为什么要在手机端折腾大模型推理
把Qwen这类模型塞进手机里跑,最早我是拒绝的。2023年那会儿试过用CPU硬扛7B模型,结果就是手机烫得能煎蛋,输出速度慢到像在用2G网络刷视频。后来换到GPU,速度上来了,但功耗依然感人,打两局游戏的功夫电量就掉一大截。直到我开始认真研究骁龙芯片里的Hexagon NPU,才发现这条路其实早就铺好了,只是坑比较多,文档散落在各个角落。
这篇内容就是把我从Qwen模型微调、量化、转换到最终部署到骁龙Hexagon NPU的完整流程梳理一遍。涉及的核心关键词包括Qwen、骁龙Hexagon NPU、QNN、NPU、量化。适合谁看?如果你手上有搭载骁龙8 Gen 2/Gen 3或者骁龙X Elite的设备,想让大模型在本地跑起来,又不想被CPU和GPU的功耗问题折磨,那这篇就是写给你的。如果你只是想了解NPU部署的基本原理,也能从中拿到可复现的步骤。
先说清楚一个前提:手机端部署大模型,核心矛盾永远是内存带宽和功耗。NPU之所以值得折腾,是因为它在处理矩阵乘加这类操作时,能效比远超CPU和GPU。但代价是工具链复杂、算子支持有限、量化要求苛刻。下面我按实际操作的顺序,从模型准备到最终跑通,一步步拆开讲。
2. 整体方案设计与技术选型考量
2.1 为什么选Qwen而不是其他模型
Qwen系列在开源社区里的生态算是比较完整的。我选它主要看中三点:第一,官方提供了不同参数规模的版本,从0.5B到72B都有,手机端部署通常选1.8B或4B这个量级,参数量再大内存扛不住;第二,Qwen的tokenizer和模型结构相对规整,没有太多花哨的自定义算子,这对NPU部署很关键;第三,社区里已经有Qwen转ONNX再转QNN的案例,虽然不完整,但至少证明这条路走得通。
对比过Llama和Phi系列,Llama的算子兼容性在QNN上问题更多,Phi虽然小但中文能力偏弱。Qwen在中文场景下的表现明显更稳,尤其是Qwen2.5系列之后,1.8B的模型在常识问答和简单代码生成上已经能用了。
2.2 骁龙Hexagon NPU的能力边界
骁龙8 Gen 3里的Hexagon NPU,官方标称算力是45 TOPS(INT8)。这个数字看着漂亮,但实际能跑什么模型、跑多快,取决于几个硬约束:
- 内存带宽:NPU和CPU/GPU共享LPDDR5X内存,带宽大概在60-70 GB/s。大模型推理是典型的memory-bound任务,权重加载速度直接决定token生成速度。
- 算子支持:QNN SDK对Transformer结构的支持在逐步完善,但像RoPE旋转位置编码、KV Cache的动态更新这些,早期版本需要手动拆解或替换。
- 量化精度:NPU对INT8支持最好,INT4也能跑但精度损失需要评估。FP16在部分骁龙芯片上支持,但功耗和带宽占用会翻倍。
我实测下来,1.8B的Qwen模型经过INT8量化后,模型文件大概1.8GB左右,推理时内存占用峰值在2.5GB上下。这个量级在12GB内存的手机上跑是安全的,8GB内存的设备会有点紧张。
2.3 工具链选型:QNN还是ONNX Runtime
高通官方主推的是QNN SDK,它能把模型编译成NPU能执行的二进制。但QNN的模型转换链路比较长:PyTorch → ONNX → QNN。每一步都可能出问题。
另一条路是ONNX Runtime with QNN Execution Provider,它允许你直接加载ONNX模型,由Runtime自动切分哪些算子给NPU、哪些给CPU。这种方式上手快,但性能不如纯QNN编译的版本,因为算子切分和内存拷贝有额外开销。
我的建议是:如果你只是想快速验证效果,用ONNX Runtime + QNN EP;如果要追求极致性能和功耗,老老实实走QNN SDK的完整编译流程。下面我主要讲QNN这条线,因为这才是真正发挥NPU能力的方式。
3. 模型微调与量化实操细节
3.1 LoRA微调:让Qwen适应你的场景
直接拿Qwen的预训练权重去部署也能用,但如果你想让它干特定的事,比如客服问答、代码补全或者特定领域的文本生成,微调是绕不开的。全量微调1.8B模型需要至少24GB显存,普通开发者没这个条件。LoRA是更现实的选择。
LoRA的核心思路是在原始权重旁边挂两个低秩矩阵,训练时只更新这两个小矩阵。以Qwen2.5-1.8B为例,我用的配置是:
from peft import LoraConfig, get_peft_model lora_config = LoraConfig( r=8, lora_alpha=16, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM" )r=8是秩的大小,这个值越大,LoRA矩阵的表达能力越强,但参数量也越多。1.8B模型用r=8到r=16就够了,再大容易过拟合。target_modules选的是注意力层的四个投影矩阵,这是最常见的做法。如果你想让模型学得更细,可以把MLP层的gate_proj、up_proj、down_proj也加进去,但训练时间会明显增加。
训练数据我用的格式是Alpaca风格的JSON,每条包含instruction、input、output三个字段。数据量不用太大,500到1000条高质量样本就能看到明显效果。训练参数方面,batch size设4,gradient accumulation steps设8,学习率2e-4,跑3个epoch。在单张RTX 4090上,1.8B模型的LoRA微调大概2小时能完成。
注意:LoRA微调后的权重是单独保存的,部署前需要和原始模型合并。合并命令用
peft库的merge_and_unload()就行,合并后的模型结构和原始Qwen完全一致,方便后续转换。
3.2 量化:从FP16到INT8的关键一步
量化是NPU部署的核心环节。FP16的1.8B模型大概3.6GB,INT8量化后直接砍半到1.8GB,内存带宽压力小了一半,推理速度也能提升30%到50%。但量化不是简单的数据类型转换,它涉及到校准和精度补偿。
我用的量化工具是llm-awq和auto-gptq,两者各有优劣。AWQ(Activation-aware Weight Quantization)在保护重要权重通道方面做得更好,精度损失更小;GPTQ的量化速度更快,适合快速迭代。对于Qwen系列,我推荐用AWQ,因为它的per-channel scaling策略对中文token的激活值分布更友好。
量化命令示例:
python -m awq.entry --model_path ./qwen2.5-1.8b-merged \ --w_bit 8 --q_group_size 128 \ --calib_data ./calib_data.json \ --output_path ./qwen2.5-1.8b-int8q_group_size设128是常见选择,它决定了量化时每组权重的粒度。设得太小,量化参数增多,模型文件变大;设得太大,精度损失明显。128是在精度和体积之间比较平衡的值。
校准数据很关键。我用的是从训练集里随机抽的128条样本,覆盖了模型实际会遇到的输入分布。校准数据太少会导致量化后的模型在某些输入上表现异常,太多则浪费时间。128到256条是比较合理的范围。
量化完成后,一定要做精度对比。我通常用困惑度(Perplexity)和实际生成样例来评估。INT8量化后的Qwen2.5-1.8B,困惑度上升通常在0.1到0.3之间,实际生成质量肉眼几乎看不出差异。但如果困惑度上升超过0.5,就要检查校准数据是否覆盖了足够的场景。
3.3 量化后的模型结构检查
量化完的模型不能直接扔给QNN,需要先转成ONNX格式,再检查算子兼容性。这一步最容易出问题。
import torch from transformers import AutoModelForCausalLM model = AutoModelForCausalLM.from_pretrained("./qwen2.5-1.8b-int8") dummy_input = torch.randint(0, 32000, (1, 128)) torch.onnx.export( model, dummy_input, "qwen2.5-1.8b.onnx", opset_version=17, input_names=["input_ids", "attention_mask"], output_names=["logits"], dynamic_axes={ "input_ids": {0: "batch", 1: "sequence"}, "attention_mask": {0: "batch", 1: "sequence"}, "logits": {0: "batch", 1: "sequence"} } )导出ONNX时,opset_version建议用17或更高,因为QNN对高版本opset的支持更好。dynamic_axes必须设置,否则模型只能处理固定长度的输入,实际使用中完全没法用。
导出完成后,用onnxruntime加载模型跑一遍,确认输出和PyTorch版本一致。然后重点检查ONNX图里有没有QNN不支持的算子。常见的坑包括:
RotaryEmbedding:QNN早期版本不支持,需要手动拆解成Sin、Cos、Mul、Add等基础算子。DynamicQuantizeLinear:如果量化时用了动态量化,这个算子可能不被支持,需要改成静态量化。LayerNormalization:部分QNN版本对LayerNorm的epsilon参数有要求,需要确认是否匹配。
我一般用onnxruntime的get_available_providers()和session.get_providers()来确认QNN EP是否加载成功,再用Netron可视化ONNX图,逐个检查可疑算子。
4. QNN转换与NPU部署全流程
4.1 QNN SDK环境搭建
QNN SDK的安装是个体力活。高通官网下载需要注册账号,下载下来的包大概2GB左右。解压后设置环境变量:
export QNN_SDK_ROOT=/path/to/qnn-sdk export PATH=$QNN_SDK_ROOT/bin/x86_64-linux-clang:$PATH export LD_LIBRARY_PATH=$QNN_SDK_ROOT/lib/x86_64-linux-clang:$LD_LIBRARY_PATH注意,QNN SDK分x86和ARM两个版本。模型转换在x86主机上做,最终部署到手机时需要ARM版本的库。两个版本都要下载,别搞混了。
环境搭好后,用qnn-onnx-converter把ONNX转成QNN的模型文件:
qnn-onnx-converter \ --input_network qwen2.5-1.8b.onnx \ --output_path qwen2.5-1.8b.cpp \ --input_dim input_ids "1,128" \ --input_dim attention_mask "1,128" \ --out_node logits \ --quantization_overrides quant_overrides.jsonquant_overrides.json是量化覆盖文件,用来指定哪些层用INT8、哪些层保持FP16。对于Qwen模型,我通常把注意力层的q_proj和v_proj保持FP16,因为这两个投影对精度更敏感,其他层用INT8。这个策略能把精度损失控制在可接受范围内,同时模型体积只增加10%左右。
转换完成后会生成一个.cpp文件和一个.bin文件。.cpp文件里是模型结构的描述,.bin是权重数据。接下来用qnn-model-lib-generator编译成共享库:
qnn-model-lib-generator \ -c qwen2.5-1.8b.cpp \ -b qwen2.5-1.8b.bin \ -o libqwen2.5-1.8b.so \ -t x86_64-linux-clang这一步会调用Clang编译器,把模型编译成能在x86上跑的共享库。编译过程可能报错,常见原因是算子不支持或维度不匹配。报错信息会指出具体是哪个算子,回到ONNX图里修改后重新导出。
4.2 在手机上加载和运行QNN模型
x86上验证通过后,把.so文件和QNN的ARM库一起推到手机上。Android设备上,QNN库通常放在/vendor/lib64/或/data/local/tmp/下。我一般用adb push推到/data/local/tmp/qnn/,然后设置LD_LIBRARY_PATH。
加载模型的代码用C++写,核心逻辑是:
QnnBackend_registerOpPackage(); QnnDevice_create(); QnnContext_create(); QnnGraph_create(); QnnGraph_addNode(); QnnGraph_finalize(); QnnGraph_execute();每一步都有对应的错误码,出错时用QnnBackend_getError查具体原因。我遇到最多的问题是QNN_GRAPH_ERROR_UNSUPPORTED_OP,说明某个算子在当前NPU上不支持。解决办法是在ONNX阶段就把这个算子替换成等价的基础算子组合。
推理时的输入输出用Qnn_Tensor_t结构体管理。输入是token IDs和attention mask,输出是logits。注意NPU的输入输出内存需要对齐,通常要求128字节对齐。不对齐会导致推理结果错误或者直接崩溃。
4.3 性能实测与调优
在骁龙8 Gen 3上跑Qwen2.5-1.8B INT8,我实测的数据是:
| 指标 | 数值 |
|---|---|
| 首token延迟 | 180-220ms |
| 后续token生成速度 | 12-15 tokens/s |
| 推理功耗 | 2.8-3.2W |
| 内存占用峰值 | 2.3GB |
这个速度比CPU快3倍左右,比GPU快1.5倍,功耗只有GPU的60%。对于手机端对话场景,12 tokens/s已经能用了,基本感觉不到明显卡顿。
调优的方向主要有两个:一是调整KV Cache的管理策略,减少重复计算;二是优化内存布局,让权重加载更连续。QNN提供了QnnContext_setConfig接口,可以设置QNN_CONTEXT_CONFIG_OPTIMIZATION_LEVEL,我一般设成QNN_CONTEXT_OPTIMIZATION_LEVEL_HIGH,编译时间会长一些,但推理速度能提升10%左右。
5. 常见问题与排查技巧实录
5.1 模型转换阶段的典型报错
问题一:ONNX导出时提示Unsupported operator: RotaryEmbedding
这是Qwen模型转换最常见的坑。QNN的ONNX解析器不认识RotaryEmbedding这个复合算子。解决办法是在导出ONNX之前,把模型里的RotaryEmbedding模块替换成手动实现的版本:
class ManualRotaryEmbedding(nn.Module): def forward(self, x, position_ids): # 手动计算sin和cos inv_freq = 1.0 / (10000 ** (torch.arange(0, dim, 2) / dim)) freqs = torch.outer(position_ids, inv_freq) emb = torch.cat((freqs, freqs), dim=-1) return x * emb.cos() + rotate_half(x) * emb.sin()替换后重新导出,ONNX图里就只有Sin、Cos、Mul、Add这些基础算子了。
问题二:量化后模型输出乱码
这通常是校准数据的问题。校准数据如果只覆盖了英文,中文输入的激活值分布就会偏离,导致量化参数不准确。解决办法是校准数据里中英文比例至少1:1,并且要包含实际使用场景的典型输入。我一般会从训练集里分层抽样,确保各种类型的输入都有代表。
问题三:QNN编译时报QNN_GRAPH_ERROR_INVALID_TENSOR_DIM
这是维度不匹配导致的。QNN对动态维度的支持有限,如果ONNX里某个算子的输入维度是动态的,QNN可能推断不出来。解决办法是在导出ONNX时尽量固定维度,或者用--input_dim参数显式指定所有输入的形状。
5.2 部署运行阶段的排查思路
问题:模型加载成功但推理结果全为0
先检查输入数据是否正确。NPU对输入数据的类型和布局有严格要求,比如INT8输入必须是int8类型,不能是uint8。另外检查内存对齐,QNN要求输入输出buffer按128字节对齐,不对齐会导致数据读取错误。
问题:推理速度远低于预期
用qnn-profile-viewer工具分析每一层的耗时。如果发现某一层特别慢,可能是这个层被切到了CPU上执行。检查QNN日志里的QNN_OP_PACKAGE信息,确认所有层都在NPU上。如果有层回退到CPU,需要调整量化策略或替换算子。
问题:手机发热严重
NPU虽然能效比高,但持续满载运行还是会发热。我通常会在推理循环里加一个小的sleep,比如每生成10个token休息5ms,让NPU有机会降频。另外,把KV Cache放在NPU的专用内存里,减少和CPU之间的数据拷贝,也能降低功耗。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| ONNX导出失败 | 不支持的算子 | 查看报错信息中的算子名 | 替换为等价基础算子 |
| 量化后精度下降明显 | 校准数据不匹配 | 对比量化前后的困惑度 | 增加校准数据多样性 |
| QNN编译报错 | 维度不匹配 | 检查ONNX图的输入维度 | 固定维度或显式指定 |
| 推理结果异常 | 内存未对齐 | 检查buffer地址 | 按128字节对齐 |
| 速度慢 | 算子回退CPU | 查看QNN日志 | 调整量化策略 |
| 发热严重 | 持续满载 | 监控NPU频率 | 加入推理间隔 |
提示:QNN SDK的版本更新比较频繁,不同版本对算子的支持差异很大。建议锁定一个稳定版本,不要频繁升级。我目前用的是2.24版本,对Transformer结构的支持比较完善。
6. 个人实操体会与后续扩展方向
这套流程走下来,最深的体会是:手机端NPU部署大模型,难点不在模型本身,而在工具链的成熟度。QNN SDK的文档比较零散,很多问题需要靠日志和实验来定位。但一旦跑通,收益是明显的——功耗和速度的平衡是CPU和GPU方案给不了的。
后续如果想进一步优化,有两个方向值得尝试。一是用QNN的混合精度模式,把部分层保持FP16,在精度和速度之间找更好的平衡点。二是探索多模型并行,比如把Qwen和一个小型的embedding模型同时部署到NPU上,利用NPU的多核并行能力。不过这些都需要对QNN的底层接口有更深入的了解,我目前还在摸索阶段。
另外提醒一句,不同骁龙芯片的NPU架构差异不小。骁龙8 Gen 2和Gen 3的Hexagon NPU在算子支持和内存布局上就有区别,8 Gen 3的NPU对Transformer结构的优化更好。如果你用的是更早的芯片,可能需要额外处理一些兼容性问题。