1. 为什么偏偏是InternVL?端侧多模态推理的选型逻辑
1.1 从"能跑大模型"到"能跑多模态大模型"的门槛跃迁
先聊一个很实际的问题:端侧跑LLM这件事,2024年之后已经不新鲜了。高通、联发科、苹果的方案一堆,QNN、MLC、llama.cpp、executorch这些推理框架也都把LLM支持做得相当成熟。但一轮到"多模态大模型",事情立刻变得不一样。
多模态意味着什么?意味着你不光要处理文本序列,还要把视觉编码器(Vision Encoder)跑起来,把图像切patch、做位置编码、过Transformer层,再把视觉token和文本token拼在一起送进语言模型。这中间多出来的环节,恰恰是端侧推理最头疼的部分。视觉编码器的输入是动态尺寸的图片,patch embedding的分辨率不固定,QNN后端对动态shape的支持又普遍不如静态shape成熟,再加上多模态模型里常见的cross-attention结构,每一步都可能在模型转换阶段爆出一个莫名其妙的错误。
我最初选型时对比过好几个候选。LLaVA的结构相对简单,但1.5版本的视觉编码器用了CLIP ViT-L/14,输入分辨率336x336,放到手机NPU上倒不是跑不动,而是精度损失的问题比较难控制。Qwen-VL系列能力很强,但模型体积和部署复杂度对端侧来说偏重。最后落到InternVL上,原因有三条:
第一,InternVL的视觉编码器InternViT-6B在开源多模态模型里属于"重编码器"路线,视觉理解能力扎实,但6B的视觉塔直接搬到高通NPU上不现实,所以实际部署通常会搭配量化、蒸馏或只取部分层。这个"重编码器"路线恰恰给了我们在端侧做精度和算力权衡的空间。
第二,InternVL的语言模型部分可以替换成更小的基座,比如InternLM2-1.8B甚至更小的Qwen2-1.5B,整体参数量能压到2B级别,量化到INT8之后在手机NPU上才有实际落地可能。这种"视觉塔固定、语言模型可选"的灵活性,在开源社区里非常稀缺。
第三,也是最重要的,QNN官方对HTP(Hexagon Tensor Processor)后端支持Vision Transformer类结构的成熟度,在2024年下半年之后有了明显提升。InternVL的视觉塔结构恰好是标准的ViT-LLaMA混合结构,和QNN能高效映射的算子集合匹配度较高。反过来,如果你的模型结构包含大量自定义算子、动态控制流,QNN转换工具再强也只能干瞪眼。
1.2 高通QNN这套工具链到底在做什么
聊QNN之前,得先把名字理清楚。QNN全称Qualcomm Neural Network,是高通统一的人工智能推理框架,它不是单一个东西,而是一整套工具链,覆盖了模型转换、量化、编译、运行时推理的全流程。和高通之前的SNPE相比,QNN在算子覆盖、性能调度、多后端支持上都先进得多。
核心组件有这几块:
qnn-onnx-converter:把ONNX模型转成QNN的图表示(QNN Model)。这个转换器会做算子映射、图优化、常量折叠,RGBA布局转换等。qnn-tvm-converter:针对TVM生态的模型转换入口,适合从PyTorch/TensorFlow导出的模型。qnn-context-binary-generator:把转换好的模型编译成串行化的上下文二进制(serialized context binary),这个二进制文件包含模型图、权重和运行时调度信息,是最终部署到设备上的核心产物。- HTP后端:Hexagon Tensor Processor的运行时,负责在NPU上执行量化后的算子图。HTP后端通常配合HVX(Hexagon Vector eXtensions)向量扩展和HMX(Hexagon Matrix eXtensions)矩阵扩展使用。
- CPU后端:一个兜底方案,遇到NSP(Non-supported Pattern,不支持的算子模式)时可以把子图回退到CPU执行。
这套设计里最值得注意的概念是"子图切分"。QNN转换器不会因为模型里有一个算子不支持就把整个模型拒之门外,而是会做图分割:把能映射到NPU的算子打包成一个子图,把不支持的算子单独划出去交给CPU执行。这个机制在混合精度部署时特别有用,但也会带来一个隐患——如果子图切分得太碎,NPU和CPU之间的tensor搬运开销会吃掉大部分性能收益。后面讲部署调试的时候,我会专门展开这个话题。
2. InternVL源码里那三个绕不开的目录:model、modeling、tools
2.1 先看仓库结构,别急着追代码
从GitHub拉下InternVL仓库之后,第一件事千万别一头扎进某个Python文件里读。先花十分钟把目录结构过一遍,搞清楚每条代码路径的归属。InternVL的仓库结构经过多个版本迭代,不同分支差异很大,我这里以实际部署最常用的1.5/2.0分支为例说明。
internvl/model/: 这是InternVL核心的模型定义目录。里面会看到internvl_chat这个子目录,对应的是带chat能力的模型封装。internvl/model/internvl_chat/intern_vit.py: 视觉编码器InternViT的定义。这里重点看forward函数里对图像的分patch处理、位置编码注入方式、以及和语言模型交互的接口设计。internvl/model/internvl_chat/llm.py: 语言模型部分的定义,通常是InternLM2或Qwen的封装。internvl/model/internvl_chat/internvl_chat.py: 整个多模态模型的装配逻辑。InternVLChatModel这个类把视觉塔、投影层、语言模型统一组织起来,是理解数据流的最关键入口。internvl/model/internvl_chat/configuration_internvl_chat.py: 模型配置类,定义了InternVLChatConfig。所有可调的超参数——视觉塔是否冻结、投影层维度、语言模型名称——都在这里声明。internvl/model/internvl_chat/modeling_internvl_chat.py: 模型前向传播的具体实现。只看这一个文件,你就能把整个InternVL-1.5/2.0的数据流彻底串起来。internvl/patch/: 这个目录在端侧部署时非常关键。它存放的是对transformers库的一些补丁代码。为什么要打补丁?因为InternVL的视觉塔结构不完全符合HuggingFace标准的CLIP约定,需要对attention实现做一些hack才能让HF的AutoModel机制正常加载。这个目录的具体代码在不同版本里差异较大,后面讲ONNX导出时会专门提到。
还有面向训练和推理的工具目录,比如internvl/train/是微调训练脚本的入口,internvl/tools/则包含了模型转换、权重合并等实用工具,其中tools/export_onnx.py是我们这次端侧部署的起点。
2.2 InternVLChatModel的forward里藏着数据流的全貌
理解了目录结构,下一步就是把模型的前向传播数据流彻底搞清楚。以InternVL-1.5的InternVLChatModel为例,它的forward逻辑大致是这么几步:
输入预处理:接收到
pixel_values(图像张量)和input_ids(文本token ids)之后,先检查图像是否存在。如果没有图像输入,模型退化为纯文本LLM,直接走语言模型。这个分支在端侧做纯文本场景时能省掉视觉塔的全部计算。视觉特征提取:如果有图像输入,把
pixel_values丢给extract_feature方法。这个方法内部会依次完成layer norm、patch embedding、Multi-Head Attention、MLP等Transformer层的前向计算,最终拿到vision_features。投影对齐:视觉特征不是直接拼到文本embedding上的。它先经过一个MLP投影层(
mlp1),把视觉特征的维度映射到语言模型的hidden size。这个投影层的具体结构在代码里能看到,通常是一层或两层MLP。InternVL-1.5里用的是两层MLP,中间带一个GELU激活。序列拼接:把投影后的视觉特征和文本embedding拼接成完整的输入序列。这里有个细节——InternVL在拼接时会保证视觉token排在文本token前面,这和LLaVA的做法一致。前端调用的时候,模型内部会根据图像数量自动构造attention mask,所以外部不用关心这个拼接过程。
语言模型前向:拼好的序列交给
language_model,走完整个Transformer层,输出logits。
结构上就是这个逻辑。理解这条数据流的意义在于,当你把这个模型往ONNX导出时,你要考虑的不是"把整个模型当成一个黑盒导出去",而是"把视觉塔单独导出一个ONNX,把语言模型单独导出一个ONNX,分别处理再做统一调度"。为什么会这样?因为QNN转换器对单个模型的大小和算子复杂度有限制,而且分开转换能更容易定位哪一个子模块出了问题。
我在实际调试中强烈建议再走一遍单步推理,用一个单张图片加一段短文本跑一次InternVLChatModel的forward,把每一层输出的shape打出来。这个过程虽然繁琐,但它会给你一个清晰的"shape清单",这份清单在后面校正QNN模型输出时是救命稻草。
2.3 投影层,这个最容易在转换时被忽略的"咽喉"
很多人第一次把InternVL导出到ONNX时,会紧盯InternViT的attention结构,担心多头注意力的reshape操作在QNN里映射不高效。但实际上真正容易出问题的是投影层。
InternVL-1.5的投影层代码是这样的结构:
self.mlp1 = nn.Sequential( nn.Linear(vision_hidden_size, hidden_size * 4), nn.GELU(), nn.Linear(hidden_size * 4, hidden_size) )一个Linear、GELU、再一个Linear。这三个算子单独看都极其简单,ONNX导出毫无压力。但问题是hidden_size * 4这个中间维度在量化的时候容易被忽略。如果你是直接对整个模型做PTQ(Post-Training Quantization,训练后量化),QNN的量化校准过程会统计每一层的激活值范围,如果校准数据集里没有覆盖到投影层的典型输入分布,量化精度损失就会在这里被放大。
我踩过的坑是这样的:第一次量化后,只做了视觉塔和语言模型的精度对比,投影层没有单独验证。结果跑实际测试集时,模型输出的中文文本明显变得语无伦次,尤其是涉及颜色、空间关系描述时错误率高得离谱。后来逐层对比ONNX和QNN模型的中间tensor,才发现投影层的输出误差均值高达5%以上。解决办法是在准备校准数据集时,专门收集一批和实际应用场景分布一致的图片和文本对,别只用COCO这种通用数据集糊弄过去。
3. 从PyTorch到ONNX再到QNN的完整转换路径
3.1 为什么不直接对整个InternVL转ONNX、转QNN
好,现在把目标明确一下:我们要把InternVL-1.5-2B这个规模的模型部署到高通平台上,利用HTP硬件加速,实现图像理解加对话的能力。
很多人第一个想法是,把PyTorch模型用torch.onnx.export整个导出去,再丢给qnn-onnx-converter。这个思路对纯文本LLM有时候能碰巧成功,但对InternVL这种多模态结构,几乎一定会失败。
原因有两层。第一层,整个模型包含视觉塔和语言模型两部分,这俩部分的输入输出shape差异巨大。视觉塔吃的是[batch, num_channels, height, width]的图像张量,语言模型吃的是[batch, seq_len]的token ids。除非你把它们包装成统一的接口,否则一个静态ONNX图没法同时处理这两个完全不同的输入。而一旦你为它们设置动态shape,QNN对动态shape的支持又非常有限,转换时会有大量算子回退到CPU,性能大打折扣。
第二层,从调试的角度看,整个模型一起转,出错了你都不知道错在哪个环节。是视觉塔的attention计算精度掉了?还是投影层的量化误差被放大了?单独转换、单独验证,才能精准定位问题。所以正确做法是拆成三个子模型分别导出。
- 子模型A:InternViT视觉塔,输入
pixel_values,输出vision_features。 - 子模型B:投影层MLP,输入
vision_features,输出投影后的视觉特征。 - 子模型C:语言模型,输入拼接后的
input_ids和投影视觉特征,输出logits。
有些部署方案会把投影层合并到视觉塔的ONNX里一起导出,减少一次中间tensor的搬运。这个做法可行,但要求你对模型代码做微调,把extract_feature和mlp1合并成一个nn.Module再导出。个人建议第一次跑通流程时不要合并,先保证每个子模型都能正确转换和推理,再考虑性能优化。
3.2 视觉塔的ONNX导出:注意function/dynamic shape这些坑
以InternViT-6B为例(实际部署时会换小视觉塔或截断层数,但导出逻辑一致),核心导出代码可以这样写:
import torch from internvl.model.internvl_chat.intern_vit import InternVisionModel # 初始化模型 model = InternVisionModel.from_pretrained("path/to/vision_model") model.eval() # 构造输入:假设输入是 224x224 的图片,单张 dummy_pixel_values = torch.randn(1, 3, 224, 224).float() # 动态轴:batch维和分辨率维设为动态,方便后续灵活使用 dynamic_axes = { "pixel_values": {0: "batch", 2: "height", 3: "width"}, "vision_features": {0: "batch", 1: "seq_len"}, } torch.onnx.export( model, dummy_pixel_values, "internvit.onnx", input_names=["pixel_values"], output_names=["vision_features"], dynamic_axes=dynamic_axes, opset_version=17, )导出过程中最常冒出来的错误有两类。第一类是TracerWarning,这通常是因为模型里有和输入tensor相关的Python控制流或shape操作。InternViT的position_embedding部分在内部会做多次view/reshape操作,容易触发warning。我的处理方式是给InternVisionModel的forward函数里相关的view调用加上torch.jit.script或重构成固定的reshape,避免动态计算。
第二类是position embedding的interpolation问题。InternViT的position embedding在训练时是固定分辨率的,实际推理时如果把输入分辨率改大或改小,需要在代码里做interpolation。这个interpolation过程在导出ONNX时涉及到torch.nn.functional.interpolate,它本身支持导出,但会引入额外的opset版本要求。我自己遇到的情况是,用opset 17能顺利导出,改成opset 11就报不支持。所以建议直接固定用较新的opset。
导出的ONNX模型用ONNX Runtime跑一遍,用同一张测试图对比PyTorch模型的输出。模型本身是fp32的话,ONNX Runtime输出的误差应该在1e-5以下。如果误差大,先检查有没有用到64位浮点的中间计算,QNN对fp64支持很差,导出时确保权重和中间激活都是fp32。
3.3 语言模型部分:KV Cache和attention mask的处理
语言模型的导出比视觉塔复杂一个量级,因为你要考虑的不只是单个前向,还有自回归生成过程中KV Cache的增量计算。这里得先做一次技术决策:导出ONNX时,把LLM导出成一个完整的前向包括KV Cache的全量计算图,还是导出成预填充(prefill)和解码(decode)两个阶段分开的图?
个人经验:分成prefill/decode两个图,在QNN部署时会省非常多的事。原因是HTP执行时,显式地控制输入tensor shape变化,远比在同一个图里用动态分支靠谱。你可以在PyTorch里手动控制KV Cache的传参,把第一次prefill的KV Cache初始化为全零,之后decode阶段每次传入上一次的KV Cache。
实现上我推荐把InternLM2ForCausalLM或Qwen2ForCausalLM的forward函数做一次轻量改造,增加两个参数:past_key_values和use_cache。具体做法是,从internvl_chat.py的forward里抽出language_model部分,构造一个只接受下列输入的新模型:
input_idsattention_maskpast_key_valuesposition_ids
对于QNN部署的简化场景,你可以把past_key_values合并成一个形状为[num_layers, 2, batch, num_heads, seq_len, head_dim]的tensor传进去,而不是像HF那样传一个元组。这样导出ONNX时的接口会干净很多。
实际调试时还有一个细节容易被忽略:InternVL语言模型部分的eos_token_id和pad_token_id配置。QNN执行时不会管你HF的generation_config里的配置,你需要在前端调度器里自己实现采样逻辑,所以导出ONNX时只关心模型前向的计算图即可。
4. QNN转换、量化校准与HTP上的实际运行调试
4.1 qnn-onnx-converter的常用配置与子图检查
ONNX模型准备好了之后,进入QNN工具链。假设你在x86主机上装了qnn 2.3以上版本,环境变量设置好之后,命令行的核心参数大致如下:
qnn-onnx-converter \ -i internvit_fp32.onnx \ -o intermediate \ --input_list input_list.txt \ --quantization_overrides quantization_overrides.json \ --enable_htp_precision \ --algo_masking \ --batch_quantization \ --inputs_dtype float \ --outputs_dtype float这里逐项解释:
-i指定输入的ONNX模型。-o指定输出目录。QNN转换器会在这个目录下生成一系列中间文件,包括转换后的QNN模型表示、量化参数、调试信息等。--input_list指向一个文本文件,里面每一行是一个实际输入tensor的数据保存路径(通常是.raw或.bin文件)。这个文件是量化校准的核心,决定权重的量化范围。--quantization_overrides指定每层的量化精度覆盖策略。比如你可以指定某些层量化到INT16,某些敏感层保持FP16。--enable_htp_precision是告诉转换器为HTP后端做精度优化,允许它使用16位浮点或混合精度。--algo_masking和--batch_quantization出现在较新的QNN版本里,algo_masking用于某些层保留更高精度,batch_quantization用于多输入模型的批量量化。
转换完成之后,先别急着做context binary。用qnn-model-tool检查转换后的模型图,确认关键算子的映射情况。我自己的检查顺序是:
- 统计NPU支持的算子数量和不支持的算子数量。
- 查看是否有算子被标记为"partition"或者"CPU fallback"。
- 检查是否有重量级算子(比如attention中的MatMul、Softmax)被映射到了CPU子图。
如果发现Softmax被分到了CPU子图,那性能基本不可能好。Softmax在QNN HTP后端有专门的高效实现,只要模型结构不是特别奇怪,不应该回退。遇到这种情况,先确认ONNX里softmax的版本是否兼容,或者试着把softmax的opset版本改成13。
4.2 量化校准数据的准备:这一步的质量决定端侧效果的上限
再强调一次:量化校准数据集的质量,直接决定端侧效果的上限。模型转换之后你做再多优化,也弥补不了校准数据分布和实际使用分布偏差过大带来的精度损失。
InternVL这种多模态模型,校准数据要覆盖两个维度:
- 图像维度:确保图像内容覆盖自然场景、文档截图、物体特写、相似颜色区域等多种情况。我通常会准备100~200张图片,尽量贴近实际业务场景。
- 文本维度:校准过程中,文本输入会经过视觉特征拼接后再进入语言模型,所以文本内容也要多样化,至少包含中文和英文的混合表述。
input_list.txt的格式大概是这样的:
path/to/pixel_values_0.raw path/to/input_ids_0.raw path/to/pixel_values_1.raw path/to/input_ids_1.raw ...每个.raw文件是二进制浮点数组,需要按照模型输入tensor的维度顺序展开存储。假如模型输入是[1, 3, 224, 224],那raw文件里就是1 * 3 * 224 * 224个fp32数值,按C顺序连续排列。
校准数据生成好之后,跑量化转换。转换完成后,用QNN模型跑几个典型样例,对比ONNX Runtime的输出。如果最大误差绝对值小于0.02,说明量化质量基本可用;如果到了0.1以上,那就要返工校准数据,或者考虑对关键层做更高精度的量化覆盖。
4.3 Context Binary生成和HTP运行时那点事
转换、量化都跑通后,最后一步是把QNN模型编译成可在目标设备上直接加载的context binary。命令行大概是:
qnn-context-binary-generator \ --model qnn_model.serialized \ --backend libQnnHtpV75Stub.so \ --binary_file internvl_vision.serialized \ --high_precision_mode这里--backend指定目标设备对应的HTP stub库。不同芯片型号对应不同的库,8 Gen 1是V73,8 Gen 2是V73/V75,8 Gen 3是V75/V77,具体以手头设备为准。--high_precision_mode可以让部分算子使用fp16执行以保留精度,但会影响性能。实际项目里这个选项是打开还是关闭,得靠实测权衡——模型精度踩线了就打开,性能不够了就得想办法在其他环节省回来。
生成context binary之后,在设备端的C++/Python运行时加载它,做好输入输出的内存分配和调度。C++端的QNN运行时API大致长这样:
#include "QnnInterface.h" #include "QnnContext.h" // 加载backend const QnnInterface_t* qnnInterface = nullptr; QnnInterface_getProviders(&providers, &numProviders); // ... 初始化 // 创建context QnnContext_handle_t contextHandle = nullptr; qnnInterface->contextCreate(profileHandle, &contextHandle); // 创建tensor QnnTensor_t inputTensor = QNN_TENSOR_INIT; inputTensor.v2.name = "pixel_values"; inputTensor.v2.type = QNN_TENSOR_TYPE_APP_READ; // 设置shape、quantizeParams等 // 执行 qnnInterface->tensorCreateOp(contextHandle, &opHandle); // ... 绑定输入输出,executePython端则可以用qnn_wrapper或pyruntime这类封装层,快速验证功能。我自己的习惯是:先用Python端把整个推理流程跑通,确认量化精度没问题后,再用C++端去做最终的性能优化和集成。
调试HTP运行时,有个指标必须盯紧:算子吞吐量和总延迟。QNN提供了profiling工具,输出每个算子的执行时间。如果发现某个算子独占了大半时间,就要考虑是不是子图切分给它安排了太多内存拷贝工作。比如视觉塔里的Patch Embedding如果落到CPU上执行,和HTP之间的数据搬运可能比实际计算还耗时。
4.4 端侧真实部署:一次卡了两个星期的解码精度问题
下面分享一个具体的坑,这是我在一次实际部署中遇到的,花了两周才把根因找到。
部署场景是:InternVL-1.5模型在8 Gen 2平台上跑图像问答,视觉塔和语言模型分别量化到INT8,整体效果基本达标。但在长对话场景中,模型生成到第二三轮时,输出开始出现重复字符和乱码。
一开始我怀疑是KV Cache的量化问题,于是把KV Cache单独做了一层量化质控——保留fp16。测下来问题没有消失。接着我怀疑是attention mask在自回归解码时算错了,回到PyTorch模型上去复现,却完全没有这个问题。这就把矛头指向了QNN运行时的执行路径差异。
后来用QNN的profiling日志对照,发现在decode阶段的position_ids计算上出了偏差。InternVL的language_model在decode阶段需要根据当前序列长度生成对应的position_ids,而我在C++调度器里写的是固定从0开始递增,忽略了prefill阶段已经处理过的token数量。换句话说,模型以为自己还在处理第2个token,实际上已经走到第50个token了。位置编码错位,生成内容自然就崩了。
修复其实就一行代码:把position_ids的起始值从0改成prefill_seq_len + current_decode_step。但这种问题如果不对照源码、不走单步调试,很难浮出水面。
这个案例想说明的核心点就是:把模型从PyTorch迁到QNN,关键计算图逻辑必须由你自己在调度器里重新实现一遍。HF的generate函数替你干了太多隐式操作,一旦脱离HF环境,每一个细节都得亲自面对。
5. 性能调优的三个方向:算子级优化、子图融合、内存复用
5.1 算子级优化:把注意力里的Softmax和Reshape单独测一遍
QNN运行时的性能瓶颈,经常不在MatMul这种公认的重计算上,而是一些"看起来不起眼"的算子。
以InternViT为例,它的Multi-Head Attention里有这样几步操作:
reshapeQ/K/V矩阵,从[batch, seq_len, num_heads * head_dim]变成[batch, num_heads, seq_len, head_dim]。- 计算attention score = Q * K^T / sqrt(head_dim)。
- Softmax。
- attention score * V。
- reshape回
[batch, seq_len, num_heads * head_dim]。
这些reshape操作在PyTorch里几乎零成本,因为内存没有实际移动,只是改了张量的元数据视图。但在QNN的图表示里,一次显式的reshape可能要触发一次内存重排。如果你在模型定义里嵌套了很多view/transpose/permute操作,转换到QNN图时可能生成大量额外的Transpose或Reshape算子,这些算子在HTP上一样消耗周期。
优化手段是"算子融合"。QNN转换器和context生成器本身会做一定程度的算子融合,但改模型结构能帮它做得更彻底。比如:
- 把attention里的QKV计算合并成一个大的
MatMul,然后一次split成三份,减少单独的MatMul次数。 - 把BatchNorm和卷积融合(这里对视觉塔的特定结构不一定适用,但思路一致)。
- 在导出ONNX前,手动把连续的
Reshape+Transpose+Reshape模式合并成一次Permute。
如果你有权限修改模型源码,强烈建议在源码层面做这些优化,而不是寄托于转换工具自动帮你做。转换工具能做的融合有限,源码层面的修改能精准减少算子数量。
5.2 子图融合:别让NPU和CPU反复搬运数据
之前提过子图切分,这里再深入一点。假设视觉塔的self-attention里有一个LayerNorm算子,QNN转换器判断HTP后端的LayerNorm实现精度不满足要求,于是把它单独划到CPU子图执行。那整个forward流程就变成:
NPU执行前面几个算子 -> 把中间tensor从NPU内存拷到CPU内存 -> CPU执行LayerNorm -> 再拷回NPU内存 -> NPU继续执行。
如果一张图里有四五个这样的回退点,内存搬运的耗时可能比实际计算高出一两倍。这个问题在集成调试时极难发现,因为功能测出来结果是正常的,只是速度慢。要怎么定位呢?QNN的profiler输出里会包含每个子图的执行时间,以及子图之间数据传输的耗时。把每次执行日志过一遍,重点找既有NPU子图、又有CPU子图的模型,逐一确认回退的算子。
优化手段按优先级排列:
- 如果能修改模型结构,把LayerNorm换成HTP后端支持的参数化形式,直接消除回退。
- 如果不能改模型结构,试试在量化时对该层使用更高的量化精度(fp16),看能不能让HTP接受它。
- 如果仍然不行,考虑把多个回退算子合并到同一个CPU子图里,减少搬运次数。这背后的原理是:CPU子图越大,NPU和CPU间的搬运次数越少,吞吐量反而可能上升。
5.3 内存复用:HTP运行时最常见的内存泄漏陷阱
HTP执行推理时,输入输出tensor的内存分配通常需要调用rpc相关的接口做rpc-aware malloc,因为NPU和CPU不共享物理内存。每次推理都重新分配和释放rpc内存,会引入不小的延迟。
高性能部署的做法是:在进程启动时预分配一块足够大的rpc内存池,把输入输出tensor的内存地址固定下来,推理时直接复用。修改输入数据时,直接往预分配的内存地址里memcpy数据,然后触发NPU执行。整个周期里不涉及任何新的rpc内存申请和释放。
有人可能会问,如果输入图片的尺寸不固定怎么办?这确实是一个矛盾点。如果你允许动态分辨率,那每次输入尺寸变化都可能要求重新分配内存。所以实际项目里,为了性能稳定,我会选择固定输入分辨率,比如把图片统一resize到224x224或336x336再送入模型。输入端多一次resize的CPU开销,换来的是整个推理管线的内存稳定性和延迟可预测性。
6. 从源码级部署回看InternVL的设计哲学
6.1 视觉塔的"重"和语言模型的"轻"
InternVL的架构设计在同行里一直有争议。和LLaVA那种"轻视觉塔、重语言模型"的路线不同,InternVL走的是"重视觉塔、轻语言模型"的路。视觉塔参数占比高达60%以上,视觉理解能力确实是强,但这也决定了它先天不适合直接往端侧搬。
然而换个角度看,这个"重"反而给了端侧部署更大的自由度。如果你只做纯视觉任务,比如图像分类、图生文本描述,在模型结构上可以只保留视觉塔和投影层,彻底砍掉语言模型。这种灵活性是LLaVA那种把视觉特征投影进语言embedding空间、强耦合的设计所不具备的。部署InternVL时,你可以把"视觉塔+投影层"当成一个独立的视觉特征提取器来用,语言模型的部分灵活替换。这一点在模型结构优化层面非常宝贵。
实际动手时,建议先从官方权重里剥离出视觉塔部分的参数,单独加载到自定义的InternVisionModel里做ONNX导出。这样能避免在导出时加载无用参数,减小模型体积。
6.2 动态分辨率的诱惑与陷阱
InternVL官方在训练时支持变分辨率,最多支持几千像素的拼图式输入。这种能力在多模态理解任务里很重要,比如文档里混排的图文,局部文字很小,直接resize会丢失信息。但在端侧部署时,这个能力变成了一把双刃剑。
一方面,动态分辨率能显著提升图文混排场景下的OCR效果;另一方面,QNN对动态shape支持的算子覆盖范围远小于固定shape。我做过一次实测,把视觉塔的输入从固定336x336改成动态的1~512像素范围,QNN转换时算子回退数量从4个上升到22个,HTP上的端到端延迟增加了约60%。
所以我的建议是:如果项目对动态分辨率没有强需求,直接固定分辨率,换取部署的稳定性和性能。如果确实需要动态分辨率,可以采用"分档"策略——预定义几个分辨率档位(比如224、336、448、672),每个档位单独导出一个context binary,根据实际输入图片的长宽比选择最接近的档位,然后resize输入图片。这样既能保留一定的动态能力,又能让QNN在固定shape下高效执行。
7. 一次完整的InternVL-on-QNN部署流程清单
最后把整个流程串成一份可执行的清单,方便大家按图索骥。这份清单基于InternVL-1.5-2B规模模型,和8 Gen 2平台,各位可以按手头设备型号和模型规模微调。
7.1 部署前置检查
- 确认Pytorch模型在GPU/CPU上能正常跑通,且输出符合预期。
- 确认ONNX Runtime推理结果和PyTorch差异在1e-5以内。
- 确认QNN SDK版本在2.3以上,且目标设备对应的HTP stub库存在。
- 准备至少100张覆盖实际场景的校准图片。
- 固定输入分辨率(推荐先用224x224跑通全流程)。
7.2 关键操作顺序
- 从InternVL官方权重中提取视觉塔和语言模型权重。
- 分别导出视觉塔ONNX和语言模型ONNX(建议prefill/decode分开导出)。
- 用ONNX Runtime验证两个ONNX的推理结果。
- 用qnn-onnx-converter分别转换,先用fp32跑通功能验证。
- 加入量化校准,检查量化后精度,不达标就调整校准集或量化策略。
- 用qnn-context-binary-generator生成context binary。
- 在设备端加载context binary,单独验证视觉塔输出。
- 验证语言模型的prefill阶段和decode阶段。
- 将视觉塔、投影层、语言模型的调用封装成统一调度器。
- 整体性能测试,定位瓶颈,按需优化。
7.3 常见异常的排查方向
| 现象 | 可能原因 | 检查手段 |
|---|---|---|
| 转换时报算子不支持 | 模型里有自定义算子或QNN不支持的op组合 | 用qnn-model-tool检查具体算子,考虑重写该层 |
| 量化后精度严重下降 | 校准数据分布不匹配、关键层精度设置过低 | 逐层对比tensor误差,对敏感层单独提高精度 |
| 生成结果乱码 | position_ids计算错误、attention mask构造错误 | 对照源码检查C++调度器里的隐式逻辑 |
| 推理速度慢 | 子图回退CPU、内存搬运频繁、算子融合不足 | 用profiler查看子图耗时和传输耗时 |
| 内存持续增长 | rpc内存未复用、每次推理都新分配内存 | 预分配rpc内存池,固定tensor地址 |
7.4 端侧部署之外还能延伸什么
InternVL的源码读透之后,你会发现这套"重视觉塔+轻语言模型"的结构,在端侧部署之外还有不少衍生玩法。比如用视觉塔做embedding提取,无需语言模型,直接用余弦相似度做图像检索;又比如把视觉特征和向量数据库衔接,做成端侧多模态RAG;再比如在投影层后面接入极小的语言模型,做实时OCR摘要。
我在实际项目中还试过另一条路:不部署语言模型,只把视觉塔+投影层部署到QNN上,然后通过QNN输出的视觉特征去驱动一个本地的规则引擎。效果是,延迟从几百毫秒降到了几十毫秒,且无需担心语言模型生成内容不受控的问题。这个方案的本质是,把InternVL当成一个强大的"视觉特征解码器",而不是一个完整的对话模型来用。对有明确业务规则、不需要开放性生成的场景,这是更务实的落地方式。
回到这次源码解读的起点——当你第一次跑通InternVL的QNN部署时,那种"这么大一个多模态模型真的在手机上跑起来了"的成就感,是任何模拟器里的demo都给不了的。但更重要的是,你在这个过程中被迫把模型结构、数据流、量化原理、运行时调度全部过了一遍,这些经验迁移到任何其他模型上都有用。希望这篇记录能帮你少踩一些我已踩过的坑,让你更快抵达那一步。