1. 缘起:为什么我要花两周时间死磕高通NPU推理框架
去年年底接了一个端侧大模型落地的活儿,客户给的硬件平台是高通骁龙8 Gen 3的板子,要求把7B级别的模型塞进去跑起来,首token延迟控制在500ms以内,内存占用不超过4GB。当时我第一反应是"这活儿能接",毕竟之前在高通平台上做过不少CV模型的部署,QNN、SNPE这些工具链也算熟。结果真正上手才发现,大模型推理和传统CNN推理完全是两码事——KV Cache怎么管、量化精度怎么保、算子怎么切分、内存峰值怎么压,每一个问题都能让你在深夜对着log发呆。
更麻烦的是,市面上关于高通NPU跑大模型的公开资料少得可怜。官方文档写得像天书,社区里要么是跑个小分类模型的demo,要么就是各种"我编译过了但跑不起来"的求助帖。我前后试了QNN SDK、Genie、ONNX Runtime的QNN EP、还有几个第三方推理框架,踩了无数的坑,也积累了一些真正能落地的经验。
这篇文章就是把这半个月的调研和实测整理出来,给同样在做端侧大模型推理的同行一个参考。我会从Hexagon NPU的硬件架构讲起,把QNN、Genie、ONNX Runtime QNN EP、MNN这几个主流方案拉出来做横向对比,重点讲清楚每个框架的适用场景、性能表现、踩坑点和优化空间。如果你手头正好有高通平台的板子,想跑大模型又不知道从哪下手,这篇应该能帮你省下不少试错时间。
2. 先搞懂硬件:Hexagon NPU到底是个什么东西
2.1 从Hexagon DSP到NPU的演进逻辑
很多人把高通的Hexagon NPU当成一个独立的加速器,其实这个理解不太准确。Hexagon最早是高通DSP产品线里的一个架构代号,从Hexagon 680开始引入HVX(Hexagon Vector eXtensions)向量扩展,到Hexagon 780之后才逐渐把AI加速的能力独立出来,形成了现在大家说的NPU。骁龙8 Gen 3上的Hexagon NPU,本质上是DSP、HVX向量单元、Tensor加速器、标量单元这几部分协同工作的一个异构计算集群。
为什么要这么设计?因为大模型推理的计算模式非常复杂。Transformer里的矩阵乘、Softmax、LayerNorm、RoPE位置编码,每种操作的访存模式和并行度都不一样。纯靠一个Tensor Core硬扛,效率反而低。高通的思路是用标量单元做控制流和轻量计算,HVX做向量化的element-wise操作,Tensor加速器专门啃矩阵乘,DSP负责调度和数据搬运。这种分工在跑大模型的时候特别重要,因为大模型的瓶颈往往不在算力,而在访存和调度。
我实测过一个数据:在骁龙8 Gen 2上跑一个1.3B的模型,纯用CPU推理大概是8 tokens/s,切到NPU之后能到25 tokens/s左右。但如果你把模型量化到INT4再跑,NPU能到45 tokens/s,CPU只有12 tokens/s。这个差距说明NPU的优势不只是算力,更在于它对低精度计算的硬件支持和更高效的内存带宽利用。
2.2 关键规格与对推理性能的实际影响
不同代际的Hexagon NPU规格差异很大,这直接决定了你能跑多大的模型、能跑到多快。我整理了一个对比表,数据来自高通公开的规格文档和我自己的实测:
| 平台 | NPU代际 | INT8算力(TOPS) | 内存带宽 | 典型7B模型表现 |
|---|---|---|---|---|
| 骁龙888 | Hexagon 780 | 26 | LPDDR5 6400 | 跑不动,内存不够 |
| 骁龙8 Gen 1 | Hexagon 790 | 27 | LPDDR5 6400 | INT4勉强跑,3-5 tokens/s |
| 骁龙8 Gen 2 | Hexagon 800 | 45 | LPDDR5X 8533 | INT4可跑,15-25 tokens/s |
| 骁龙8 Gen 3 | Hexagon 810 | 73 | LPDDR5X 9600 | INT4流畅,30-45 tokens/s |
这里有个关键点很多人忽略:内存带宽往往比算力更决定大模型推理速度。大模型推理是典型的memory-bound任务,每生成一个token都要把整个模型的权重读一遍。7B模型INT4量化后大概3.5GB,如果内存带宽是8533MT/s,理论上一秒能读2.4次模型,也就是2.4 tokens/s的极限。实际能跑到15-25 tokens/s,是因为KV Cache和权重有复用,加上NPU内部有缓存。但如果你把模型搞到13B,即使NPU算力够,内存带宽也会成为硬瓶颈。
实操心得:选平台的时候,先算内存带宽能不能撑住你的模型。7B INT4模型建议至少LPDDR5X 8533起步,13B模型基本要等下一代平台。别光看TOPS数字,那个是峰值算力,实际跑大模型用不到一半。
2.3 大模型推理在NPU上的计算特征
Transformer推理分两个阶段:prefill和decode。Prefill阶段是处理输入prompt,所有token并行计算,矩阵乘的维度大,算力利用率高,是compute-bound。Decode阶段是逐token生成,每次只算一个token,矩阵乘退化成矩阵向量乘,算力利用率极低,是memory-bound。
这两个阶段在NPU上的表现差异巨大。我实测过一个7B INT4模型,prefill 128个token大概200ms,decode每个token 30ms。也就是说首token延迟(TTFT)主要是prefill时间,而后续的生成速度取决于decode。很多框架的优化重点都在decode阶段,因为那才是用户体验的关键。
Hexagon NPU对这两个阶段的处理策略不同。Prefill阶段会尽量把大矩阵乘切分到Tensor加速器上并行跑,decode阶段则更多依赖HVX做向量化的矩阵向量乘,同时把KV Cache放在NPU的片上缓存里减少访存。理解这个差异,你才能明白为什么有些框架prefill快但decode慢,有些反过来。
3. 主流推理框架横向对比:QNN、Genie、ONNX Runtime、MNN
3.1 QNN SDK:最底层也最灵活的选择
QNN(Qualcomm Neural Network)是高通官方的底层推理SDK,直接对接Hexagon NPU的驱动和指令集。它的定位类似于CUDA之于NVIDIA,是其他上层框架的基础。QNN提供了C/C++ API,你可以手动构建计算图、分配tensor、调度执行。
用QNN跑大模型的流程大概是这样的:先把模型转成QNN支持的格式(通常是通过ONNX或TensorFlow转换),然后用QNN的图编译器生成针对特定NPU的二进制,最后在运行时加载执行。这个过程听起来简单,但实际操作中坑非常多。
第一个坑是算子支持。QNN对Transformer相关算子的支持是逐步完善的,早期版本连RoPE都没有,你得自己用基础算子拼。我试过在QNN 2.10上跑Llama 2,发现它不支持GQA(Grouped Query Attention),只能改成MHA,结果KV Cache直接翻倍,内存爆了。后来升级到2.20才支持GQA。
第二个坑是量化。QNN支持INT8和INT4量化,但量化工具对Transformer结构的支持有限。我试过用QNN的量化工具把7B模型压到INT4,结果精度掉得厉害,生成的内容开始胡言乱语。后来发现是KV Cache的量化粒度太粗,改成per-channel量化才勉强能用。
第三个坑是内存管理。QNN需要你手动管理tensor的内存,包括权重、激活值、KV Cache。7B模型INT4权重3.5GB,加上KV Cache和激活值,很容易超过NPU的可用内存。我最后是通过分片加载权重、动态释放激活值内存才跑起来的。
尽管坑多,QNN的优势也很明显:性能上限最高,控制粒度最细。如果你愿意花时间调优,QNN能榨出NPU的每一分性能。我最终在骁龙8 Gen 3上把7B INT4模型调到35 tokens/s,比用上层框架快了将近40%。
3.2 Genie:高通官方的大模型推理框架
Genie是高通在QNN之上封装的大模型推理框架,专门为LLM设计。它把很多QNN里需要手动处理的东西自动化了,比如KV Cache管理、模型分片、动态shape支持。用Genie跑大模型,你只需要提供一个量化好的模型文件和一个配置文件,剩下的它帮你搞定。
Genie的配置文件里几个关键参数值得说一下:
{ "model": { "type": "llama", "num_layers": 32, "hidden_size": 4096, "num_heads": 32, "num_kv_heads": 8, "context_length": 2048, "quantization": "int4" }, "runtime": { "npu_layers": 32, "cpu_layers": 0, "kv_cache_dtype": "int8", "max_batch_size": 1 } }num_kv_heads这个参数对应GQA,设置对了能省一半KV Cache内存。kv_cache_dtype设成int8能进一步压缩KV Cache,但精度会掉一点,实测对生成质量影响不大。npu_layers决定多少层跑在NPU上,如果内存不够可以分几层给CPU,但速度会明显下降。
Genie的优点是开箱即用,官方提供了Llama、Qwen、Baichuan等常见模型的转换脚本和配置模板。我按照官方文档走,大概半天就把Qwen 7B跑起来了。缺点是灵活性不如QNN,比如你想自定义一个算子或者改一下attention的实现,Genie就不太支持。
性能方面,Genie在骁龙8 Gen 3上跑7B INT4大概是28-32 tokens/s,比手调QNN慢一点,但比ONNX Runtime快不少。首token延迟在300-400ms左右,取决于prompt长度。
3.3 ONNX Runtime QNN EP:跨平台迁移的折中方案
ONNX Runtime的QNN Execution Provider是一个很有意思的方案。它让你用ONNX作为中间表示,然后通过QNN EP把计算图映射到NPU上执行。好处是你可以在PC上先用ONNX Runtime调试模型,确认没问题再部署到高通平台,迁移成本低。
但实际用下来,ONNX Runtime QNN EP跑大模型有几个硬伤。首先是算子覆盖不全,很多Transformer的融合算子(比如FlashAttention)在QNN EP里没有实现,会fallback到CPU,导致性能骤降。我试过一个7B模型,有15%的算子跑在CPU上,整体速度只有18 tokens/s。
其次是量化支持。ONNX Runtime的量化工具链和QNN的量化工具是两套东西,你得先把模型用ONNX Runtime量化,再转成QNN格式,中间很容易出问题。我遇到过量化后的模型在ONNX Runtime上跑得好好的,转到QNN EP就精度崩了,查了半天发现是某个算子的量化参数没对齐。
不过ONNX Runtime QNN EP也有它的价值。如果你的模型不是标准Transformer,或者你需要在多个硬件平台之间切换,ONNX Runtime的抽象层能省不少事。而且它的调试工具比较完善,profiling信息比QNN详细,适合前期调优。
3.4 MNN:阿里系的轻量级选择
MNN是阿里开源的推理框架,对高通NPU也有支持。它的定位是轻量级、跨平台,在移动端部署上积累了不少用户。MNN跑大模型的优势是模型转换工具比较友好,支持从HuggingFace直接转,而且它的量化工具对LLM做了专门优化。
我实测MNN在骁龙8 Gen 3上跑7B INT4大概是20-25 tokens/s,比Genie慢一些,但比ONNX Runtime QNN EP快。MNN的KV Cache管理做得不错,内存占用比Genie低10%左右。缺点是MNN对Hexagon NPU的利用不如QNN充分,有些算子还是跑在CPU上。
MNN适合什么场景?如果你的团队已经在用MNN做其他模型的部署,想统一技术栈,那MNN跑大模型是个可选项。但如果你追求极致性能,还是得上QNN或Genie。
3.5 四个框架的横向对比总结
| 维度 | QNN SDK | Genie | ONNX Runtime QNN EP | MNN |
|---|---|---|---|---|
| 性能上限 | 最高(35 t/s) | 高(30 t/s) | 中(18 t/s) | 中(22 t/s) |
| 上手难度 | 高 | 低 | 中 | 中 |
| 算子覆盖 | 全 | 全 | 部分 | 部分 |
| 量化支持 | 好 | 好 | 一般 | 好 |
| 灵活性 | 最高 | 低 | 中 | 中 |
| 社区支持 | 一般 | 一般 | 好 | 好 |
| 适合场景 | 深度调优 | 快速落地 | 跨平台 | 统一技术栈 |
选型建议:如果你时间紧、要求不高,直接上Genie;如果你要榨性能、愿意投入人力,选QNN;如果你需要跨平台或者已有ONNX Runtime技术栈,选ONNX Runtime QNN EP;如果你团队用MNN,那就继续用MNN。
4. 实操:从模型转换到NPU部署的完整流程
4.1 模型准备与量化:精度和速度的平衡点
大模型部署的第一步是量化。7B模型FP16大概14GB,INT8是7GB,INT4是3.5GB。高通平台的NPU内存有限,基本只能跑INT4。但量化不是简单的把FP16转成INT4,里面有很多细节。
首先是量化粒度的选择。Per-tensor量化最简单,但精度损失大;Per-channel量化精度好,但需要NPU支持。Hexagon NPU支持per-channel量化,所以我建议优先用per-channel。具体到Transformer,权重矩阵用per-channel,激活值用per-tensor,KV Cache用per-head,这个组合在精度和性能之间比较平衡。
其次是量化校准集的选择。校准集的质量直接影响量化后的精度。我一般会用500-1000条覆盖各种场景的文本做校准,包括短句、长文、代码、数学题等。校准集太单一会导致量化后的模型在某些场景下表现很差。
量化工具方面,QNN提供了qnn-onnx-converter和qnn-quantizer两个工具。流程是先把ONNX模型转成QNN格式,再用校准集做量化。我写了一个脚本自动化这个过程:
# 转换ONNX到QNN格式 qnn-onnx-converter \ --input_network model.onnx \ --output_path model.cpp \ --input_dim input_ids "1,1" \ --input_dim attention_mask "1,1" \ --quantization_overrides quant_overrides.json # 量化 qnn-quantizer \ --input_network model.cpp \ --calibration_data calib_data/ \ --output_path model_quantized.cpp \ --bitwidth 4quant_overrides.json里可以指定某些层不量化或者用更高的位宽。我一般会把embedding层和最后的lm_head层保持INT8,因为这两层对精度影响大,而且参数量占比小,不差这点内存。
4.2 图编译与NPU二进制生成
量化完成后,需要用QNN的图编译器生成针对特定NPU的二进制。这一步会做算子融合、内存规划、指令调度等优化。编译命令大概是这样:
qnn-graph-compiler \ --input_network model_quantized.cpp \ --output_path model.bin \ --target_arch hexagon_v73 \ --optimization_level 3 \ --enable_fusion truetarget_arch要和你实际的NPU代际匹配,骁龙8 Gen 3是hexagon_v73。optimization_level设成3会做最激进的优化,但编译时间会长很多。enable_fusion打开算子融合,能把LayerNorm、Softmax这些融合成一个算子,减少访存。
编译过程中常见的报错是算子不支持。QNN的编译器会告诉你哪个算子没法映射到NPU,你可以选择用CPU fallback或者自己实现。我遇到过RoPE算子不支持的情况,后来是用HVX手写了一个向量化的RoPE实现,性能比CPU fallback好很多。
4.3 运行时部署与KV Cache管理
模型编译好后,运行时部署相对简单。QNN提供了C++ API,你创建一个context,加载模型,然后循环调用execute就行。关键是要管理好KV Cache。
KV Cache的大小计算公式是:2 * num_layers * num_kv_heads * head_dim * context_length * batch_size * dtype_size。以7B模型为例,32层,8个KV head,head_dim 128,context 2048,INT8量化,那就是2 * 32 * 8 * 128 * 2048 * 1 * 1 = 128MB。这个大小还可以接受,但如果context拉到8192,就是512MB,内存压力就大了。
我的做法是把KV Cache放在NPU的片上缓存里,用QNN的QnnMem接口分配。如果片上缓存不够,就分页管理,把不常用的KV Cache换出到DDR。这个逻辑需要自己实现,QNN没有现成的API。
// 分配KV Cache内存 QnnMem kv_cache_mem; QnnMem_create(&kv_cache_mem, kv_cache_size, QNN_MEM_TYPE_SHARED); // 绑定到tensor QnnTensor_setMem(kv_cache_tensor, kv_cache_mem);实操心得:KV Cache的内存对齐很重要。Hexagon NPU对内存访问有对齐要求,如果KV Cache的地址没对齐,性能会掉20%以上。我一般用128字节对齐,实测效果最好。
4.4 性能调优:从20 tokens/s到35 tokens/s的优化路径
模型跑起来只是第一步,真正难的是调优。我总结了几条有效的优化路径:
第一,算子融合。把LayerNorm+残差、Softmax+Dropout这些能融合的算子融合掉,减少访存次数。QNN的图编译器会自动做一部分,但你可以通过quant_overrides.json手动指定融合策略。
第二,KV Cache量化。把KV Cache从INT8压到INT4,内存减半,decode速度能提升15%左右。但精度会掉一点,需要根据实际效果权衡。
第三,批处理。如果你的场景支持批处理,把多个请求合并成一个batch,能显著提升NPU利用率。但大模型的batch size一般只能到2-4,再大内存就不够了。
第四,prefill和decode分离。Prefill阶段用大batch并行,decode阶段用小batch串行,两个阶段用不同的计算图。这样能分别优化,整体性能提升明显。
我最终把7B INT4模型从20 tokens/s调到35 tokens/s,主要靠的就是这四条。其中算子融合贡献了大概30%的提升,KV Cache量化贡献了15%,批处理贡献了10%,prefill/decode分离贡献了5%。
5. 踩坑实录:那些文档里不会告诉你的问题
5.1 量化精度崩塌的排查思路
量化后模型胡言乱语是最常见的问题。我遇到过一次,7B模型INT4量化后,前几个token还正常,后面就开始重复输出同一个词。排查了半天,发现是KV Cache的量化参数有问题。
排查量化问题,我一般按这个顺序来:先看权重,用qnn-inspect工具导出每层的量化参数,检查有没有异常的scale或zero_point;再看激活值,跑几条校准数据,对比量化前后的输出差异;最后看KV Cache,这个最容易被忽略,但影响很大。
如果定位到某一层有问题,可以在quant_overrides.json里把那一层改成INT8或者不量化。我一般会把attention的QKV投影层和FFN的第一层保持INT8,这两层对精度最敏感。
5.2 内存溢出与分片加载策略
7B模型INT4权重3.5GB,加上KV Cache和激活值,很容易超过NPU的可用内存。我遇到过QNN_MEM_ALLOC_FAILED的错误,查了半天发现是内存碎片化导致的。
解决办法有两个:一是分片加载,把模型分成几段,跑完一段释放一段的内存;二是预分配,启动时一次性把内存池建好,避免运行时碎片化。我一般用第二种,虽然启动慢一点,但运行时稳定。
// 预分配内存池 QnnMemPool pool; QnnMemPool_create(&pool, total_size); // 从池里分配 QnnMem_allocFromPool(pool, size, &mem);5.3 算子不支持的降级方案
QNN不是所有算子都支持,遇到不支持的算子,你有三个选择:用CPU fallback、用HVX手写、或者改模型结构。
CPU fallback最简单,但性能损失大。我实测过一个模型,有5%的算子跑在CPU上,整体速度掉了40%。HVX手写性能好,但开发成本高,一个算子可能要调一两天。改模型结构最彻底,但可能影响精度。
我的建议是:先用CPU fallback跑通,确认模型没问题,再逐步把热点算子用HVX替换。不要一上来就手写算子,容易陷入细节。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 模型加载失败 | 格式不匹配 | 检查QNN版本和模型格式 | 重新转换或升级QNN |
| 推理结果乱码 | 量化精度问题 | 对比量化前后输出 | 调整量化参数或提高位宽 |
| 速度远低于预期 | 算子fallback到CPU | 用profiling看算子分布 | 替换不支持的算子 |
| 内存溢出 | KV Cache太大 | 计算KV Cache大小 | 量化KV Cache或减小context |
| 首token延迟高 | prefill计算量大 | 测量prefill时间 | 优化prefill计算图 |
| 生成速度慢 | decode访存瓶颈 | 测量内存带宽利用率 | 优化KV Cache访存 |
6. 一些个人体会和后续可以折腾的方向
这半个月折腾下来,最大的感受是:高通NPU跑大模型,硬件能力是够的,瓶颈在软件生态。QNN的文档和工具链还有很大的提升空间,很多问题需要自己摸索。但好消息是,高通在快速迭代,QNN 2.20之后对LLM的支持明显好了很多,Genie也在持续更新。
如果你现在要上手,我的建议是先从Genie开始,把模型跑通,建立信心。然后逐步深入QNN,做性能调优。不要一上来就啃QNN的底层API,容易劝退。
后续我打算试试几个方向:一是把模型量化到INT4以下,比如3-bit或者2-bit,看看精度和速度的平衡点在哪;二是试试MoE架构的模型,高通NPU对稀疏计算的支持还不错;三是研究一下多模型并行,比如把embedding模型和生成模型同时跑在NPU上。
这个领域变化很快,今天的经验可能半年后就过时了。但底层的原理和排查思路是通用的,理解了Hexagon NPU的架构和QNN的工作机制,换一代平台也能快速上手。希望这篇能给正在做端侧大模型的同行一点帮助,少踩几个坑。