高通QNN平台部署InternVL:模型转换与源码级优化实践
2026/9/17 4:39:09 网站建设 项目流程

在端侧芯片上跑多模态大模型,这两年已经从“能不能跑”变成“怎么跑得高效”的阶段了。InternVL作为国内比较有代表性的开源多模态模型,视觉理解和语言生成都能兼顾,很多人想把它搬到高通平台上,而高通官方的QNN(Qualcomm Neural Network)运行时就是绕不开的一环。网上聊InternVL训练、微调的文章不少,但真正逐行讲QNN侧源码、讲转换和推理链路怎么串起来的,确实不多。这篇我结合自己实际迁移InternVL到高通平台的经验,把整个流程拆开讲清楚,尤其是源码层面那些坑和关键调用,希望能让后面接手的朋友少走弯路。

1. 项目背景与整体思路拆解

1.1 为什么要在高通平台上跑InternVL

先说动机。端侧多模态推理的价值在于低延迟、隐私可控、不依赖云端网络。高通的QNN体系是面向骁龙系列芯片的统一推理框架,从手机、物联网设备到智能座舱都在用。InternVL这种十亿参数级别的多模态模型,经过量化之后放到高端骁龙平台上,已经能跑到“可用”的时延区间,这让很多做边缘智能产品的团队把目光投向了它。

但在动手之前要清楚一件事:InternVL本身是一个大模型体系,不是单一模型。它通常包含一个视觉编码器(ViT类结构)、一个MLP投影层和一个LLM主干(如InternLM系列)。整个推理链路是“图像输入 + 文本输入 -> 视觉特征 -> 投影对齐 -> LLM生成”。在高通QNN上跑InternVL,本质上是把这条链路拆成几个子图,分别完成转换、量化、加载和执行。源码解读的重点也在这里:视觉塔、投影层、LLM主干在QNN里到底是怎么被转换为可执行的计算图的。

我建议初学者先把目标定小一点:先跑通一个固定尺寸的InternVL2-2B(或同量级模型),再谈优化。上来就想直接部署8B或更大的模型,工具链报错会让你怀疑人生。

1.2 端侧多模态大模型部署的整体链路

部署的完整链路大致如下:PyTorch模型权重 -> ONNX导出 -> QNN工具链转换(ONNX转QNN模型,量化) -> 生成context binary文件 -> 在设备上用QNN Runtime加载执行。源码解读需要覆盖这四段中的核心环节。

很多人在第一步就卡住:PyTorch里一切正常,但导出的ONNX里动态shape、自定义算子一大堆,QNN转换工具根本不认。所以我的建议是,转换之前先对模型结构“瘦身”,把不确定的算子全部替换成ONNX标准算子或可拆解的基础算子。InternVL里比较典型的问题包括FlashAttention、RoPE旋转位置编码的自定义实现、视觉塔里的window attention等,这些在后文会单独展开。

整个链路中,我认为最关键的一点是:最终在QNN上执行的不再是“模型”,而是一个编译好的context binary。它相当于把计算图、权重、量化信息全部打包,运行时只需要加载这个二进制文件即可。这个设计大大减少了设备端加载时间,但同时带来的约束是:如果模型结构有任何改动,整个二进制都得重新生成,无法热更新。理解了这一点,后续看源码的时候就清楚为什么QNN Runtime侧代码看起来那么“简单”了——真正复杂的工作都在转换期完成了。

2. 准备工作:环境与工具链

2.1 QNN SDK版本选择和依赖确认

QNN SDK不同版本的算子支持、量化工具行为差异很大。以我实际经验,InternVL这类Transformer模型,建议优先选择较新的QNN SDK版本(2.x且越新越好),因为新版本对attention类算子的支持更完善,且附带的高通专用HTP后端优化更多。

环境准备阶段要做三件事:第一,在x86主机上安装QNN SDK,确保qnn-onnx-converterqnn-context-binary-generator命令可用;第二,确认目标设备上带的是哪种NPU(通常是HTP),因为context binary生成时指定了target,交叉编译和运行时库都要与之匹配;第三,准备好Python推理脚本,用于验证ONNX导出的正确性,这一步千万别跳。

实际工作中我踩过的最典型问题是SDK版本和手机端HTP固件版本不匹配,导致context binary在设备上加载时直接报Failed to initialize HTP。排查方法很简单:先用SDK自带的示例在设备上跑通,确认环境OK,再上自己的模型。不建议一上来就挑战困难模式。

2.2 InternVL模型结构与导出要点

以InternVL2-2B为例,模型结构大致是:视觉编码器InternViT(约3亿参数,输出patch embedding维度为1024,图像通常是448x448分辨率,patch size 14,所以token数为32x32=1024个)、MLP投影层(把1024维映射到LLM的hidden size)、LLM主干InternLM2(约1.8B,hidden size 2048,24层Transformer)。整体大约21亿参数。

导出ONNX时,需要分别导出三个模块,也可以整体导出,但建议分开。原因是视觉塔和LLM的输入输出差异大,分开导出、分开转换,后面量化策略也能灵活调整。实际操作中,我用的是torch.onnx.export,设置opset_version=13或更高,dynamic_axes一般不设置,直接把图像和文本序列的尺寸固定。固定shape虽然牺牲了灵活性,但在端侧推理中这是最稳妥的做法,因为HTP后端对静态shape的执行效率远好于动态shape。

还有一个细节:InternVL的对话模板里,图像patch embedding在输入LLM之前往往和文本token embedding拼接在一起,并带有特殊的<image>token。这个拼接逻辑如果在PyTorch里是动态的,导出的ONNX里可能非常复杂。我的做法是让LLM部分的ONNX输入直接是整个embedding序列,而不是原始的token id序列。这样把embedding处理留在Python侧,模型本身只负责纯Transformer推理,转换难度直线下降。当然,这要求设备端有对应的embedding查询逻辑,后面会提到。

3. 模型转换:从PyTorch到QNN Quantized

3.1 ONNX导出阶段的算子清洗

QNN转换工具对ONNX算子的支持虽然越来越广,但和GPU/CUDA生态还是没法比。InternVL里最常见的拦路虎有几个:

  • FlashAttention:ONNX导出时如果保留了FlashAttention算子,QNN基本不支持。方案是换成标准attention组合(QK^T * V),虽然计算量略高,但兼容性好。实际转换时,我是在模型的forward函数里加了分支,检测到导出模式就切换到标准attention,训练保持FlashAttention不变。
  • RoPE实现:InternVL的RoPE是二维旋转位置编码(视觉塔)和一维旋转位置编码(LLM)混合使用。有些实现里用了复数运算,导出时容易产生complex算子。我的做法是现展开成cos/sin表格,用muladd组合实现,转换起来没有任何问题。
  • LayerNorm/RMSNorm:这两个问题不大,注意导出的ONNX里 reduce_mean、pow、rsqrt 等算子在新版ONNX中是标准的,QNN可以处理。

这里有一个心得:做任何导出前,先用ONNX Runtime跑一次导出的模型,确认输出和PyTorch一致(误差在1e-4级别),再进入QNN阶段。如果ONNX阶段就不正确,后面所有排查都是浪费生命。

3.2 QNN工具链转换实操:量化与context binary生成

转换命令大致长这样(以QNN SDK 2.x为例):

# 1. ONNX转QNN模型,配置量化 qnn-onnx-converter \ -i internvl_llm.onnx \ -o internvl_llm.qnn \ -b act_data_list.txt \ --quantization_overrides quantization_overrides.json \ --input_list input_list.txt # 2. 生成context binary,指定target qnn-context-binary-generator \ --model internvl_llm.qnn \ --backend libQnnHtp.so \ --binary_file internvl_llm_htp.serialized \ --target libqnnhtpv73.so

第一步中的act_data_list.txt是校准数据列表,每行是一个执行激活量化的样本路径。校准数据的选取非常关键:对InternVL这种多模态模型,不能用纯文本数据或纯图像数据,最好是图文混合样本,否则某个模态的激活范围会严重失真,导致量化后精度崩掉。我实测下来,用几十张从验证集抽样的图文对就能把激活范围估得七七八八,不用太多。

第二步中的libQnnHtp.so是HTP后端,运行时会加载。target name要根据设备上的HTP架构选择,比如libqnnhtpv73.so对应某代骁龙平台。如果选错,生成的binary在设备上加载会报“unsupported device”。

量化策略方面,LLM主干我倾向于使用int8或混合精度。QNN转换工具提供了--quantization_overrides参数,可以对指定tensor覆盖默认量化配置。实际实验发现,InternVL里LLM的某些层(尤其是最后的lm_head)对量化非常敏感,单独把这部分设成fp16,整体精度就能回来一大截。我的经验是量化配置文件里显式把lm_head和最后几层self-attention的线性层跳过量化,效果比全量量化好很多。

3.3 ONNX整体导出还是分模块导出

这个问题我单独拿出来说。InternVL整体导出最后会得到一个超大的ONNX,中间夹着视觉塔和LLM,里边的张量形状差异大,QNN转换时经常出现内存分配问题。我强烈建议按模块拆分:

  • 模块A:视觉塔 + MLP投影层,输入是预处理后的图像张量,输出是visual embedding。
  • 模块B:LLM主干,输入是拼接后的embedding序列,输出是logits。

如果后续要做流式生成,模块B最好再拆成“Prefill”和“Decode”两种状态。QNN的graph是静态的,Prefill阶段序列长度较长,Decode阶段序列长度固定为1,两种状态分开部署效率最高。如果不拆,你只能把最大长度定死,解码阶段也会按最大长度计算,浪费算力和内存。这是我在源码级优化中收获最大的一步:InternVL的对话式生成,在QNN上跑两个graph,Decode graph的时延能比单一长序列graph快好几倍。

4. 推理源码:Runtime侧如何加载并运行

4.1 Context Binary加载与Graph准备

设备端推理一般用C++或Python绑定。这里以C++为例,核心调用流程源码解读如下:

// 1. 加载QNN HTP后端 QnnBackend_Config_t backendConfig[] = { {QNN_BACKEND_CONFIG_OPTION_VTCM, ...}, nullptr }; QnnBackend_Initialize(backendHandle, backendConfig);

注意这里的QNN_BACKEND_CONFIG_OPTION_VTCM,VTCM(Vector Tensor Memory)是高通HTP上非常关键的片上内存配置。对于大模型推理,VTCM分配好了,某些算子能直接用片上内存,避免DDR带宽瓶颈。源码里常见的错误是忽略这个配置,导致性能差了几倍。

加载context binary的典型调用:

// 2. 创建context并从binary文件加载 QnnContext_Config_t ctxConfig = { ... }; QnnContext_CreateFromBinary(backendHandle, binaryBuffer, binarySize, &ctxConfig, &contextHandle);

源码层面注意一点:QnnContext_CreateFromBinaryQnnContext_Create是两个不同的入口,前者直接加载编译好的context binary,后者是逐层创建graph。在生产代码里99%的场景都用前者,因为后者耗时太长且内存碎片化严重。

从context中拿到graph句柄:

// 3. 获取图和输入输出张量 QnnContext_GetGraphs(contextHandle, &graphHandle, &numGraphs); QnnGraph_GetInputTensors(graphHandle, &inputTensors, &numInputs); QnnGraph_GetOutputTensors(graphHandle, &outputTensors, &numOutputs);

InternVL部署时如果按前文方案拆了Prefill和Decode两个graph,则需要重复加载同一份context,分别取出两个graph的输入输出tensor。初始化阶段就把它们缓存到结构体里,运行时直接查表,不要每次推理都调一遍GetGraphs,否则延迟会加好几毫秒。

4.2 输入预处理与Tensor填充

QNN的输入tensor类型常见的有QNN_TENSOR_TYPE_APP_WRITEQNN_TENSOR_TYPE_STATIC。对于动态输入(图像、文本embedding),必须在代码里创建APP_WRITE类型的tensor,并把指针指向我们自己分配的内存。

源码逻辑通常是这样的:

// 为输入tensor创建内存 void* inputMem = (void*)allocator->allocate(inputTensor->memSize, align); // 填充输入数据 memcpy(inputMem, imageData, imageBytes); // 设置tensor大小并关联内存 QnnTensor_SetMemHandle(inputTensor[i], memHandle);

这里最容易翻车的是“对齐”和“内存生命周期”问题。QNN要求内存指针按一定字节对齐(通常16字节或更高),如果你用一个普通malloc分配的缓冲区,很可能触发HTP DMA拷贝失败或性能暴跌。正确做法是用QNN提供的QnnMem_Register接口将一块已分配内存注册到后端,再把MemHandle绑定到tensor上。

图像预处理方面,InternVL用的是双线性插值resize到448x448,然后像素归一化到ImageNet均值和方差,再变换成CHW或NHWC布局。这里由于QNN HTP尤其擅长NHWC布局,如果你的图已经量化到int8,尽量把输入搞成NHWC,能省一次转置的算子开销。

文本部分,如果你采用了“输入embedding而非token id”的方案,前端要有一个embedding查询表。这个embedding查询表可以放在Python侧,把整个上下文embedding先拼好,再一起喂给QNN。如果纯C++侧,可以先自己实现一条简单的embedding lookup,不复杂,但要注意半精度/整型的匹配。

4.3 推理执行与输出后处理

推理执行的核心源码很简单:

QnnProfile_Create(profileHandle); QnnGraph_Execute(graphHandle, inputHandles, outputs, profileHandle); QnnProfile_GetEvents(profileHandle, &events);

但要特别注意:QnnGraph_Execute在HTP上通常是非阻塞的,它会把工作提交给硬件后端后立即返回,而结果是否会写入输出缓冲区取决于当前线程是否等待。源码里必须显式调用QnnGraph_RetrieveOutputQnnGraph_WaitForCompletion来同步。否则你从输出tensor里读到的可能是上一帧的脏数据。

我用InternVL做视觉问答时的输出后处理链路是:拿到LLM输出的logits(通常是[1, seq_len, vocab_size][seq_len, vocab_size]),做贪心或beam search解码,找到下一个token id,再拼到输入序列里,进入下一轮推理。这套循环用朴素的while结构就能实现,关键是每一步的输入tensor都要重新memcpy,因为graph执行的输入缓冲区默认不会被后端修改,只读。

在源码层面有一个优化点:Prefill完成之后,以KV cache形式缓存住计算好的历史状态。不过QNN HTP后端对KV cache的支持在不同SDK版本里差异很大。早期版本不支持跨次graph执行的KV cache保持,只能通过“把历史embedding全部重新输入”的方式做自回归,这样延迟会随序列长度线性增长。后续版本支持了graph state,就可以把KV cache以额外的state tensor传入。这块建议直接查看你手里的QNN SDK文档里对“graph state”的说明,一旦支持,自解码效率能翻倍。

5. 算子兼容性、量化与内存踩坑实录

5.1 算子不支持时的处理思路

虽然QNN对Transformer类模型的支持越来越好,但你总会碰到某些算子不支持或性能极差的情况。我的排查顺序是这样的:

第一,看QNN的OpDef文档,找到对应算子是否在HTP后端支持列表。支持列表里会标注“仅供CPU”或“仅供GPU”或者“HTP支持”,直接查表。

第二,如果不支持,优先考虑替换算子而不是硬刚。替换思路是把这个复杂算子拆解成“基本算子组合”,比如把softmax里自定义的rescale操作拆成mul/add/sub。QNN的图优化器对基本算子组合的优化效果很好,拆开后性能不一定比原算子差。

第三,如果实在拆不开,就用分段执行方案:把ONNX切成多个子图,QNN处理大部分,剩下不支持的算子用CPU或GPU后端执行。QNN支持在同一context里存在多个graph,而且不同graph可以用不同后端,只要最终结果拼起来正确即可。虽然这种异构执行会引入跨后端的拷贝开销,但能保证功能上线。

我遇到比较典型的案例是InternVL视觉塔的二维RoPE。QNN SDK较老版本对gather类算子处理效率不高,而RoPE恰好大量依赖gatherscatter操作。新版本虽然支持,但性能一般。后来我把RoPE的cos/sin预计算做成常量折叠进graph,让每个位置直接查表乘法,省掉运行时gather逻辑,视觉塔单次前向延迟直接降了约30%。

5.2 量化精度下降与校准集

量化是大模型部署里最头疼的环节。InternVL这种多模态模型量化之后,常见症状是:模型能跑,但回答质量明显下降,尤其是涉及OCR、细粒度图像理解的任务。

我总结的排查步骤是:

  • 先怀疑校准集。不要用一个几百张图的数据集就草草了事。至少准备几百到上千张和实际应用场景接近的图文样本。
  • 分层观察精度。在QNN工具链中可以设置调试模式,输出每个tensor的量化误差。如果某个layer的误差特别大,就单独对这个layer做精度保护(设为fp16或不量化)。
  • 注意lm_head和embedding。这两个地方的量化误差会被自回归生成过程逐token放大。实测中,把lm_head设为fp16,对于回答质量的提升是立竿见影的。

我用的量化覆写文件大概长这样:

{ "tensor_quantization_overrides": { "lm_head.weight": {"quantize": false}, "model.embed_tokens.weight": {"quantize": false}, "layers.20.self_attn.q_proj.weight": {"quantize": false} } }

不要盲目全部量化。端侧大模型部署的最高原则是“精度换速度,但要控制在可接受范围内”。

5.3 内存与性能优化小技巧

QNN上跑大模型,内存占用和高通NPU的带宽利用是两大瓶颈。我在源码层面做过的有效优化包括:

  • 输入tensor复用:推理循环中不要每次分配新的输入缓冲区。在初始化时分配好固定大小(最大支持序列长度 + 图像tokens),运行时只更新数据,不再重新注册内存。实测能减少约20%的延迟波动。
  • VTCM配置调优:HTP有片上内存VTCM,对attention这类访存密集算子很友好。在backend配置里显式设置VTCM大小,并按社区推荐的占比调整。不同芯片策略不同,需要按情报一点一点调。
  • 减少算子间拷贝:QNN的graph内部是有内存池管理的,但如果你插入了CPU/GPU后端,就会引入跨后端的拷贝。尽量让整张graph尽量都跑在HTP上,哪怕某个算子在HTP上不如CPU快,算上拷贝的代价,整体还是HTP更优。
  • 多batch与小batch的权衡:在对话生成场景,decode阶段batch通常只有1,但QNN的HTP在较小batch下可能无法充分发挥并行能力。可以利用batch direction(多候选beam并行)来做beam search,从而提升HTP利用率。

性能数据方面,以我实测的某骁龙8系列平台为例,InternVL2-2B int8量化后,Prefill一张448x448图片的延时可到亚秒级,Decode步延迟在几十毫秒到一百多毫秒之间,具体跟芯片型号、SDK版本关系很大。这个数据仅供参考,毕竟高通每个平台的HTP算力差异明显,而且SDK的小版本更新也会带来几个百分点的浮动。

6. 问题速查表与排错经验

6.1 典型错误对照表

现象常见原因处理方式
context binary加载失败target选错或SDK与设备固件不匹配用设备支持的HTP版本重新生成binary
首次推理输出全0输入tensor没绑定或绑定错误检查每条tensor的memHandle是否注册到backend
量化后模型胡言乱语校准集不合适或lm_head被量化增加图文校准样本,对lm_head/embedding做fp16覆盖
推理延迟不稳定输入内存反复分配释放初始化阶段统一分配内存,复用buffer
图像特征和文本特征错位视觉塔输出和LLM输入对齐出错检查token数量是否等于视觉塔输出patch数
动态shape导致转换报错ONNX里有动态维度导出时固定图像分辨率和最大文本长度

6.2 源码调试的三板斧

遇到问题我不会只看报错信息,通常按下面顺序定位:

  • 第一板斧:在HTP上取消profile,跑通后再开启。profile会影响时序,先拿到“能跑”的结果。
  • 第二板斧:输出每个graph的输入输出tensor尺寸和数据类型,确认和onnx模型一致。QNN工具的--debug选项可以dump中间tensor。
  • 第三板斧:用QNN提供的libQnnHtp的verbose日志,打开后能看到每个算子的执行耗时和内存分配情况。很多问题从日志里能直接定位到具体算子。

有一次我遇到模型输出第一个token正常、后续token开始乱码,排查半天,发现是KV cache graph state配置错误,每个token都复制了旧的cache数据。日志里显示state tensor的“current batch”信息不对,顺藤摸瓜改好配置就恢复了。所以日志一定要看,不要只看错误码。

7. 实测调优中的一点个人心得

文章写到这里,分享几个我个人反复试验后的感悟,不一定每条都适用于你的场景,但可以作为借鉴。

第一,工具链版本真的非常关键。QNN SDK更新很快,两三个月一个大版本,算子支持和生成binary的格式都在变。如果条件允许,尽量跟着新SDK走,但要注意一次只升一个版本,实测后再决定是否升下一个。我有一次跨了两个大版本升级,结果所有旧的context binary都不能用,全部重新生成,相当折腾。

第二,多模态大模型在端侧的落地,工程难度远超纯文本模型。除了模型本身的处理,图像预处理、token拼接、多模态对齐这些“杂活”才是真正决定体验的地方。QNN源码本身往往不是瓶颈,瓶颈在模型结构是否能适配端侧计算范式。

第三,不要迷信“一键转换”。QNN工具链确实提供了从ONNX到量化binary的流畅路径,但那是针对理想情况。InternVL这种模型,视觉塔、投影层、LLM主干、KV cache都要逐个攻破。真正高效的方案,一定是“按需拆图、按图量化、分别优化”。

如果这个项目后续还想深挖,我觉得可以重点研究两个方向:一个是visual encoder的动态分辨率支持,让模型能适配非方形图像;另一个是更多QNN图级优化,比如算子融合和内存规划。这两块做好了,端侧多模态体验还能再上一个台阶。最后说一句:跑这种模型一定要有耐心,把每个环节的验证都做扎实,大模型部署的成功率高不高,往往就取决于这些枯燥但必要的检查步骤。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询