高通平台 Qwen3-VL 多图推理时图像 tokens 膨胀问题
Qwen3-VL-4B 多图推理 num-prompt-tokens 膨胀问题分析
平台:QC 8775 / 8797(Qwen3-VL-4B,genie-app CLI,pipeline 模式)
1. 问题
事情是这样的,为了对比几个平台跑多模态模型的性能差异,我们编写测试脚本(后文简称为“脚本”)对高通 8775 和 8797 平台也进行了性能摸底。
表格中的 image length 来源于理论计算(分辨率 640x512,单张图的 tokens 数 == 320);
而 prompt len 来源于离线分词器统计;
input len 则来源于 sdk 统计。像这样:
1 | |
理论上来说,input len 应该 ≈ image len + prompt len,但实测却发现,第四列 Input Len(SDK 实测)与第二、三列(Image Len + Prompt Len)始终对不上,且差距随图片数非线性增大:
| 用例 | Image Len(理论) | Prompt Len(文本计数) | Input Len(SDK 实测) | 差值 |
|---|---|---|---|---|
| 1pics | 320 | 284 | 607 | +3 |
| 2pics | 640 | 295 | 1258 | +323 |
| 3pics | 960 | 306 | 2229 | +963 |
| 4pics | 1280 | 317 | 3520 | +1923 |
| 5pics | 1600 | 328 | 5131 | +3203 |
| 6pics | 1920 | 339 | 7062 | +4803 |
差值本身按 320 等差递增(+3 → +323 → +963 → +1923 → +3203 → +4803,边际 +320/+640/+960/+1280/+1600),提示图像侧存在某种”逐图放大”机制。
这是为什么呢?
— 摘要 —
SDK 实测 num-prompt-tokens 的图像侧计数 = 单图 tok × k(k+1)/2(三角数),而非理论值 k × 单图 tok。
根因是 SDK 源码中两处行为叠加:
- 图像编码引擎(
QnnNspImageModel::runInference)的输出缓冲从未清空,每次 encode 以”前置插入”方式追加,导致第 i 张图 encode 后缓冲里装着前 i 张图的全部数据; - pipeline 层的
ImageEncoder::setImageInputData每次把整个缓冲(而非本张图的增量)append 进 embedding 累加器。
结果:第 i 次 node set image 实际注入 i×320 个 token,k 张图累计 320×k(k+1)/2。
这是真实的数据冗余注入(prefill 真的处理了这些 token),不是统计口径问题;单图(k=1)不受影响。
两个衍生问题的结论:
- 用例脚本写法无问题:”多次
node set image“是 SDK 唯一支持的多图输入方式,本用例写法与官方命令语法、官方 LMM 示例、SDK 源码设计完全一致,脚本侧无需也无法改写规避(第 7 节)。 - SDK 2.46.0 未修复:两个根因点的代码在 2.46 中逐字保留,ReleaseNotes 无相关修复条目(第 11 节)。
下文为详细排查结果
2. 三列 token 的口径
| 列 | 来源 | 口径 |
|---|---|---|
| Image Len | 理论标称 | k × (W/32) × (H/32) = k × 320(640×512,patch16 + 2×2 merge) |
| Prompt Len | 离线分词 | HF tokenizer 对脚本文本计数(仅文本,不含图像、不含图像 token) |
| Input Len | SDK 实测 | profile 中 GeniePipeline_execute.num-prompt-tokens 原值直取 |
三列口径不同,本身就不可直接相加;但 Input Len 与”文本+图像”的差距规律(三角数)需要解释。
3. 数据
3.1 SDK profile 原始字段(8775 / 6pics)
数据文件:logs_8775/6pics.profile.json(L266-294,GeniePipeline_execute 事件):
1 | |
num-prompt-tokens = 7062,而 TTFT = 25,001.6 ms,prefill 速率 282.5 tok/s,三者自洽(7062 / 25.0s ≈ 282.5)——说明 7062 是真实被 prefill 处理的 token 数。- 8797 同一用例(
logs/6pics.profile.json):num-prompt-tokens同为 7062,TTFT 6,122.7 ms,decode 14.5 tok/s。两平台 token 数一致,说明该现象由脚本结构 + 模型分辨率决定,与硬件无关。
3.2 排除脚本侧重复:profile 事件计数
6pics 的 profile 各组件事件明细(logs/6pics.profile.json):
| 组件 | 事件 | 次数 | 说明 |
|---|---|---|---|
| node0(imageEncoder) | GenieNode_setData | 18 | = 6 张图 × 3 个输入文件(pixel_values / pos_cos / pos_sin),与脚本行数一致 |
| node2(lutEncoder) | GenieNode_setData | 14 | = 脚本 14 条 set text(1 前缀 + 12 个标记 + 1 尾部) |
| pipeline0 | GeniePipeline_execute | 1 | 脚本只调用一次 execute |
结论:输入端(脚本 → setData)没有重复,18 条 set 就是 6 张图的正常数据。展开 100% 发生在 SDK 内部。
3.3 数学规律
对 n 张图定义:第 i 张图的”输入侧贡献”为 i×320,则累计 = 320 × (1+2+…+k) = 320 × k(k+1)/2。
精确对账(文本侧以 SDK 口径计,比离线计数固定多 3):
| 用例 | 文本(离线) | +3 | 图像侧 Σ(i×320) | 合计 | SDK 实测 |
|---|---|---|---|---|---|
| 1pics | 284 | 3 | 320 | 607 | 607 ✓ |
| 2pics | 295 | 3 | 960 | 1258 | 1258 ✓ |
| 3pics | 306 | 3 | 1920 | 2229 | 2229 ✓ |
| 4pics | 317 | 3 | 3200 | 3520 | 3520 ✓ |
| 5pics | 328 | 3 | 4800 | 5131 | 5131 ✓ |
| 6pics | 339 | 3 | 6720 | 7062 | 7062 ✓ |
六行全部严丝合缝。
4. 排查过程
- 从总览表发现 Input ≠ Image + Prompt,确认三列口径不同(理论 / 离线分词 / SDK 实测),但差距规律未解。
- 尝试线性解释(k×320 + 文本)失败:2pics 实测 960 ≠ 640;观测到边际增量按 320 等差递增。
- 拟合出三角数公式:图像侧 = 320×k(k+1)/2。
- 用 profile 事件明细排除脚本侧重复(见 3.2)——输入侧干净,问题在 SDK 内部。
- 获取 SDK 源码(QAIRT 2.45.0
examples/Genie/Genie/src,即 libGenie 源码),沿数据流逐层追查。 - 定位到图像编码引擎
runInference的outputs.insert(...)(不清空)与 pipeline 层setImageInputData的全量 append 两处叠加,数字与实测完全吻合。 - 复核”脚本写法”假设:对照官方命令语法(一次一个文件)、官方 LMM 示例(glm-4v)与 SDK 源码设计(凑齐一组输入即 encode),确认多次 set 是唯一正确姿势(第 7 节)。
5. 数据流全链路
1 | |
6. 根本原因:两处 SDK 行为叠加
6.1 位置①(根因):图像编码引擎输出缓冲不清空
{project_path}\QNN SDK\2.45.0.260326\examples\Genie\Genie\src\qualla\engines\qnn-htp\nsp-image-model.cppQnnNspImageModel::runInference(L536)结尾,L587-596:
1 | |
- 被注释的旧代码
outputs.resize(...)是覆盖语义(每次重置为本图数据)。 - 现版本改为
insert(begin):既不清空、又插到开头。于是每 encode 一张图,outputs里就多一份数据(新图在最前,旧图保留)。 - 调用链上游(
qualla::ImageEncoder::process、NspEngine::process)均不清空outputs;它正是pipeline::ImageEncoder的成员m_data。
6.2 位置②(放大器):pipeline 层全量 append
{project_path}\...\src\pipeline\ImageEncoder.cpp,setImageInputData(L84)L103-145:
1 | |
每次追加的不是”本张图的 320 token”,而是当前 m_data 的全部。
6.3 清空动作都在”迟到”的位置
| 清空动作 | 位置 | 时机与效果 |
|---|---|---|
m_input.clear() |
ImageEncoder.cpp L105 | 每次 encode 后清输入映射(正常轮换,与本问题无关) |
m_data.clear() |
ImageEncoder.cpp L164(execute()) |
pipeline execute 阶段才清——6 次 append 早已完成,为时已晚 |
m_accumulator->flush() |
TextGenerator.cpp L172 | execute 末尾清累加器——同样是事后 |
outputs 清空 |
runInference 内 | 不存在(注释掉的 resize 本来承担该职责) |
6.4 逐次累积推导
第 i 次 node set image(凑齐一张图的 3 个文件)时:
| i | encode 后 m_data 内容 | m_data 大小(tok) | append 到 accumulator | accumulator 累计 |
|---|---|---|---|---|
| 1 | [图1] | 320 | 320 | 320 |
| 2 | [图2, 图1] | 640 | 640 | 960 |
| 3 | [图3, 图2, 图1] | 960 | 960 | 1920 |
| 4 | [图4 … 图1] | 1280 | 1280 | 3200 |
| 5 | [图5 … 图1] | 1600 | 1600 | 4800 |
| 6 | [图6 … 图1] | 1920 | 1920 | 6720 |
等价描述:图 1 的 embedding 被注入 6 次、图 2 五次、……、图 6 一次(总 (1+2+…+6)×320 = 6720)。
6.5 对照:为什么文本侧没有膨胀
{project_path}\...\src\qualla\encoders\text-encoders\LUT.cpp 的 encode 使用 覆盖 语义:
1 | |
所以 TextEncoder::setTextInputData 每次 append 的是当次文本的真实 token 量,文本侧无累积——与实测(SDK 文本侧 ≈ 离线计数 + 3)一致。
同一套 pipeline 机制下,只有图像编码器(NSP 引擎)的输出是累积语义。
7. 脚本写法正确性论证:为什么可以排除”用例脚本问题”
问题缘起:既然膨胀规律出现在图像侧,先要排除一种可能——是不是脚本里”多张图”的 set 方式写错了(例如 SDK 期望用别的方式塞多图)。
结论:脚本没有任何问题。从官方工具语法、官方示例、SDK 源码设计、实测数据四个层面都能确认——node set image 连续多次(每张图 3 次)就是 SDK 唯一且被代码明确设计支持的多图输入方式。
7.1 官方命令语法:一次一个文件,无批量语法
genie-app 官方帮助(examples\Genie\genie-app\main.cpp L1247-1251;docs\QAIRT-Docs\Genie\general\tools\genie-app.html 命令表同):
1 | |
SDK 没有”一次 set 多个文件”或”IO 数组”的语法。多图 = 多次 set,只有这一条路。
7.2 官方 LMM 示例:本质与用法一致
SDK 中唯一的多模态示例脚本 examples\Genie\genie-app\scripts\glm-4v(配套教程 docs\...\tutorials\pipeline\LMM\glm-4v\glm-4v.html):
1 | |
GLM-4V 的 image encoder 仅有 1 个输入 tensor,因此只 set 一次;运行方式 ./genie-app -s <Script>(教程 L239)与本用例完全相同。Qwen3-VL 的 encoder 有 3 个输入(pixel_values / position_ids_cos / position_ids_sin),”每图 3 次 set”正是同一写法的自然扩展。
7.3 SDK 源码设计:”凑齐一组输入即编码一张图”
pipeline\ImageEncoder.cpp :
1 | |
IMAGE_POS_COS / IMAGE_POS_SIN是 2.45 专为 Qwen3-VL 新增的官方 IO(include\Genie\GenieNode.hL62-63;ReleaseNotes issue {133935})。- “一组输入 = 一张图,连续凑 k 组 = k 张图”是写进 SDK 代码的设计机制;本用例脚本(6 组 × 3 文件 = 18 次 set)正是该设计的逐字落实。
- SDK 中不存在”多图专用入口”或”批量 set”的备用路径——即换任何写法,多图都只会走这条路径。
7.4 实测反证:分组正确、逐项吻合
- 若 set 方式有误(凑不齐一组 / 分组错乱),encode 根本不会按”每图一次”触发,token 数不可能呈现精确规律;
- 实测 1pics 完全干净(607 = 284 + 3 + 320);2pics = 295 + 3 + 320 + 640 精确到个位——证明每组 3 个文件被正确识别为一张图并独立编码。
7.5 附带说明
SDK 官方文档对多图路径的覆盖非常薄弱(LMM 教程只有 glm-4v 单图示例,无 Qwen3-VL 教程、无多帧示例,教程中的配置路径甚至指向不存在的 configs/lmm/glm-4v/)——这与”多组 set 路径缺测试覆盖、缓冲清理被遗漏”相互印证。
责任定位:缺陷在 SDK 侧(第 6 节两处),用例脚本无需也无法通过改写规避。
8. 疑问:每个用例独立进程单独执行,为什么还会”没有清空”?
答:膨胀发生在”单个用例脚本内部、多次 set image 之间”,与跨进程/跨用例无关。
- 用例之间确实是完全隔离的:
bench_run.py对每个用例单独启动一次genie-app进程、单独执行一个脚本文件;节点对象、m_data、accumulator 全部新建。实测值也证明无跨用例污染(1pics=607 干净、2pics=320+640=960 精确)。 - 膨胀的粒度是脚本内部:一个脚本里有 k 组
node set image(6pics 为 18 条命令)。 node set不是延迟到 execute 才处理,而是逐条立即执行:每条命令一次GenieNode_setData(并产生一条 profile 事件,这就是 18/14 事件计数的来源),每凑齐一张图的 3 个输入就在setImageInputData内立即 encode + append。m_data是同一个 ImageEncoder 节点对象的成员,在这 k 次 encode 之间从未被重置——m_data.clear()在execute()里,而 execute 发生在所有 set 完成之后。- 所以”没有清空”的准确含义是:同一进程内,同一节点的输出缓冲在多次 set 之间被复用而未重置。跨进程状态丢失是 OS 保证的,不需要 SDK 做什么——问题恰恰出在进程内部。
补充佐证:如果按”跨用例残留”假设,2pics 会带上一轮残留产生乱值;实测 960 = 320+640 精确符合”进程内累积”模型,排除跨进程假设。
9. 影响评估
- prefill 真实膨胀:6pics 时有效视觉 token 仅 1920,实际注入 6720(3.5 倍;冗余占比约 2/3)。k 张图膨胀因子 = k(k+1)/2 / k = (k+1)/2,随图数线性增长。
- TTFT 被拖累:prefill 处理 7062 而非 ~2262 token(约 3.1 倍),这是 TTFT 偏大的直接原因之一(8775 6pics TTFT 25.0s,8797 6.1s,均含此膨胀)。
- mrope 位置编码:
setVisionParam的visionPos取自 accumulator 的累积 token 计数(ImageEncoder.cpp L136-137),同样按膨胀后的位置计算。 - 单图不受影响:k=1 时只 encode 一次,无累积(1pics 607 干净可证)。
- 统计口径:SDK 的
num-prompt-tokens诚实反映实际注入的数据量,现有报告”以 SDK 原值为准”的口径无需修改。
10. 修复建议
修复点最小,两处任选其一(均为一行级改动);两处同时应用也无冲突(见 10.3)。
10.1 方案 A — 引擎层
文件:examples/Genie/Genie/src/qualla/engines/qnn-htp/nsp-image-model.cpp
位置:QnnNspImageModel::runInference 结尾(2.45.0 为 L593-596;2.46.0 为 L594-597,内容相同)
改前(现状):
1 | |
改后:
1 | |
对应 unified diff(git apply --recount / patch -p1 均可;不依赖精确行号,按上下文匹配):
1 | |
效果:outputs 每次被本次推理输出完全替换,m_data 恒为单图 320 token;下游 append 与 num-prompt-tokens 同步恢复线性(k×320)。
10.2 方案 B
文件:examples/Genie/Genie/src/pipeline/ImageEncoder.cpp
位置:setImageInputData(2.45.0 为 L103-105;2.46.0 为 L104-106,内容相同)
改前(现状):
1 | |
改后(新增一行):
1 | |
对应 unified diff:
1 | |
效果:encode 前清空 m_data,引擎 insert 从空缓冲开始 → m_data 恒为单图输出;后续 append/计数恢复线性。
10.3 两方案关系与验证
- 任一单独应用即可修复(方案 A 从输出端强制覆盖、方案 B 从输入端提前清空,两条路径独立生效);两处同时应用亦无冲突(清空 + 覆盖叠加后结果不变)。
- 两处改动均不触碰 API 签名与配置文件,单图路径行为等价于现状(k=1 时无累积,本就正确)。
- 构建验证:该源码自带构建系统(
examples/Genie/Genie/CMakeLists.txt的add_library(Genie SHARED ...)即 libGenie),可交叉编译 aarch64-android 版本替换设备上的libGenie.so验证;替换前需确认设备现用 libGenie(8775 为 1.17.0 / 8797 为 1.18.0)与本地源码版本的对应关系。 - 预期收益:6pics 的 num-prompt-tokens 7062 → 约 2262(≈1/3),TTFT 预计同比例下降(prefill 为计算瓶颈,需实测验证)。
- 建议同时向高通反馈该问题(附本文证据链,注明 2.45.0 / 2.46.0 均存在)。
11. SDK 2.46.0 验证:未修复
验证对象:{project_path}\QNN SDK\2.46.0.260424(ReleaseNotes 日期 04/30/2026;QNN API v2.35.0)。
11.1 ReleaseNotes 检索结果
2.46.0 “Bugs → Genie” 段落仅 3 条,均与本问题无关:
| Issue | 内容 |
|---|---|
| {178827} | GeniePipeline_reset 未重置 KV cache |
| {147298} | genie-app async 命令在 loop 语句内不生效 |
| {159119} | 生成 token 数较少时 token 生成速率上报偏高 |
无任何涉及图像编码输出缓冲 / num-prompt-tokens 膨胀的条目。
11.2 源码逐行对比(git diff –no-index,2.45.0 vs 2.46.0)
两个根因文件全量 diff 后,核心缺损代码逐字保留:
| 检查点 | 2.45.0 | 2.46.0 | 状态 |
|---|---|---|---|
outputs.insert(begin) 不清空(含被注释的 resize 行) |
nsp-image-model.cpp L593-596 | L594-597,逐字一致 | 未修复 |
encode 后只清 m_input、不碰 m_data |
pipeline/ImageEncoder.cpp L103-105 | L104-106,逐字一致 | 未修复 |
numElements = m_data.size() 全量 append |
同上 L134 | L135,逐字一致 | 未修复 |
m_data.clear() 仍在 execute() 内 |
同上 L164 | L165,逐字一致 | 未修复 |
2.46 对这条链路的实际改动均为周边重构/新功能,不触碰缓冲清理语义:
qualla::json→nlohmann::json命名空间别名迁移(全文件);- DLC 加载改为 Env/ResourceManager 注入(nsp-image-model.cpp 构造函数与 initializeModel,移除旧的 createDlc/setDlcPath 路径;pipeline ImageEncoder 构造新增 env 参数);
- 输出字节宽度类型
size_t→float(pipeline L116/L131 声明;qualla 层outputTensorQuantParam签名同步改float&,内部由 size_t 转换)——为亚字节量化做准备,numElements计算行未变; ctx_size传参0→-1(prepareAdaptorInputs / nsp_graph.execute / handleAdaptorOutputs);- 新增:输出 tensor 名循环扫描 +
deepstack_visual_embed输出名支持(Qwen3-VL deepstack 特性)、registerModelAdaptor接口。
11.3 结论
2.46.0 未修复该问题。缺陷存在于 2.45.0(首次完整支持 Qwen3-VL 多输入的版本,POS_COS/SIN 为 {133935} 新增)并在 2.46.0 延续。
值得注意的是,2.46 仍在持续增强 Qwen3-VL 相关支持(deepstack 输出),但多图/多帧路径的缓冲清理缺陷尚未被覆盖到。向高通反馈时建议注明”2.45.0 / 2.46.0 双版本源码均存在”。
12. 附录:文件与复现路径
数据文件(本目录)
| 文件 | 内容 |
|---|---|
reports_8775/bench_report.md |
8775 六用例总览 + 尾部”图像 token 口径验证”对账表 |
logs_8775/6pics.profile.json |
8775 原始 profile(L266-294 为 GeniePipeline_execute 事件) |
logs/6pics.profile.json |
8797 原始 profile(同字段,token 数相同、性能不同) |
SDK 源码({project_path}\QNN SDK\2.45.0.260326\examples\Genie\Genie\src)
| 文件 | 关键位置 | 作用 |
|---|---|---|
qualla\engines\qnn-htp\nsp-image-model.cpp |
L536 / L587-596 | 根因:runInference 输出不清空、前置插入 |
pipeline\ImageEncoder.cpp |
L84 / L103-145 / L164 | 放大器:全量 append;迟到的 m_data.clear() |
pipeline\TextGenerator.cpp |
L127-172 | accumulator → embeddingQuery → flush |
pipeline\Pipeline.cpp |
L127-133 | pipelineExecute 遍历节点 |
GeniePipeline.cpp |
L291-322 | GeniePipeline_execute 入口 + profileStat |
GenieNode.cpp |
L199-252 | node set → setData 逐条立即执行(每条一个 profile 事件) |
Dialog.cpp |
L3003-3062 | embeddingQuery → qualla query |
qualla\dialogs\basic.cpp |
L332-407 | _n_prompt += curTokenCount(L374 / L405) |
qualla\dialog.cpp |
L1024-1026 | KPIs 赋值 tps.n_prompt |
Profile.cpp |
L527-532 | profile 事件 num-prompt-tokens |
qualla\encoders\text-encoders\LUT.cpp |
L133 | 对照:文本 encode 为覆盖语义 |
2.46.0 对比源码位于
{project_path}\QNN SDK\2.46.0.260424\examples\Genie\Genie\src,目录结构相同,上表关键行号整体 +1(如 nsp-image-model.cpp L597、ImageEncoder.cpp L165)。
复现路径:任意 k≥2 的多图脚本执行一次 → 查看 <tag>.profile.json 的 GeniePipeline_execute.num-prompt-tokens,对比 k×320 + 文本 即可看到 320×k(k+1)/2 规律。