高通平台 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
2
3
4
5
6
log create dbglog verbose log.txt
profile create benchprofile
pipeline config create pipelineConfig
pipeline config bind profile pipelineConfig benchprofile
pipeline create GeniePipeline pipelineConfig
pipeline config bind log pipelineConfig dbglog

理论上来说,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 源码中两处行为叠加:

  1. 图像编码引擎(QnnNspImageModel::runInference)的输出缓冲从未清空,每次 encode 以”前置插入”方式追加,导致第 i 张图 encode 后缓冲里装着前 i 张图的全部数据;
  2. 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
2
3
4
5
6
7
8
9
10
{
"type": "GeniePipeline_execute",
"duration": 27120515,
"num-prompt-tokens": { "value": 7062 },
"prompt-processing-rate": { "value": 282.49, "unit": "toks/sec" },
"time-to-first-token": { "value": 25001581, "unit": "us" },
"num-generated-tokens": { "value": 11 },
"token-generation-rate": { "value": 5.26, "unit": "toks/sec" },
"token-generation-time": { "value": 2089916, "unit": "us" }
}
  • 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. 排查过程

  1. 从总览表发现 Input ≠ Image + Prompt,确认三列口径不同(理论 / 离线分词 / SDK 实测),但差距规律未解。
  2. 尝试线性解释(k×320 + 文本)失败:2pics 实测 960 ≠ 640;观测到边际增量按 320 等差递增。
  3. 拟合出三角数公式:图像侧 = 320×k(k+1)/2。
  4. 用 profile 事件明细排除脚本侧重复(见 3.2)——输入侧干净,问题在 SDK 内部。
  5. 获取 SDK 源码(QAIRT 2.45.0 examples/Genie/Genie/src,即 libGenie 源码),沿数据流逐层追查。
  6. 定位到图像编码引擎 runInference 的 outputs.insert(...)(不清空)与 pipeline 层 setImageInputData 的全量 append 两处叠加,数字与实测完全吻合。
  7. 复核”脚本写法”假设:对照官方命令语法(一次一个文件)、官方 LMM 示例(glm-4v)与 SDK 源码设计(凑齐一组输入即 encode),确认多次 set 是唯一正确姿势(第 7 节)。

5. 数据流全链路

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
脚本 node set image ×3(一张图的 3 个输入文件)
→ genie-app CLI → GenieNode_setData GenieNode.cpp L199
→ pipeline::ImageEncoder::setImageInputData pipeline/ImageEncoder.cpp L84
(输入凑齐 3 个 → 立即 encode,不等 execute)
→ genie::Embedding::encode → qualla::ImageEncoder::process
→ NspEngine::process → QnnNspImageModel::runInference
→ outputs.insert(outputs.begin(), ...) ← 不清空,缓冲累积 ★根因
→ m_accumulator->append(整个 m_data) ← 全量注入 ★放大器
...
pipeline execute(脚本末尾,全部 set 完成后)
→ GeniePipeline_execute → Pipeline::pipelineExecute GeniePipeline.cpp L291 / Pipeline.cpp L127
→ TextGenerator::execute
accData = m_accumulator->getData() pipeline/TextGenerator.cpp L135
→ Dialog::embeddingQuery(accData, accSize) Dialog.cpp L3003
→ qualla Dialog::query(embeddingVector)
→ BasicDialog::process(embedding_vectors)
curTokenCount = embedding_vectors.size() / embedBufSize basic.cpp L374
_n_prompt += curTokenCount basic.cpp L405
→ KPIs: _kpis.tps.n_prompt = _n_prompt qualla/dialog.cpp L1026
→ ProfileStat::translateDialogQueryKPIsToEvents
→ profile 事件 "num-prompt-tokens" = 7062 Profile.cpp L527-532
→ m_accumulator->flush() TextGenerator.cpp L172

6. 根本原因:两处 SDK 行为叠加

6.1 位置①(根因):图像编码引擎输出缓冲不清空

{project_path}\QNN SDK\2.45.0.260326\examples\Genie\Genie\src\qualla\engines\qnn-htp\nsp-image-model.cpp
QnnNspImageModel::runInference(L536)结尾,L587-596:

1
2
3
4
5
6
7
8
9
10
uint32_t rank      = t_output_tensor->tensor->v1.rank;
size_t numElements = 1;
for (size_t i = 0; i < rank; i++) {
numElements *= t_output_tensor->tensor->v1.dimensions[i];
}

// outputs.resize(numElements * t_image_features->dtype.bw()); ← 原设计:覆盖语义(被注释)
uint32_t bw = t_output_tensor->dtype.bw();
uint8_t* output_buffer = reinterpret_cast<uint8_t*>(getBuffer(t_output_tensor));
outputs.insert(outputs.begin(), output_buffer, output_buffer + numElements * bw); ← 实际:前置插入且不清空
  • 被注释的旧代码 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
2
3
4
5
6
7
8
9
10
11
12
if (m_input.size() == m_inputIOMap.size()) {          // 本张图的 3 个输入凑齐
status = m_encoder->encode(m_input, m_data, nullptr);
m_input.clear(); // 只清输入,m_data 保留
...
} else if (isConnected()) {
...
size_t numElements = m_data.size() / outputByteWidth; ← 用的是整个 m_data(累积后大小)
...
m_pipeline->m_accumulator->append(
m_data.data(), outputDataType, outputScale, outputOffset, numElements, embeddingTokenNum);
}
}

每次追加的不是”本张图的 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
output.resize(p_vec.size() * embeddingSize);   // L133:每次重置为本段文本的大小

所以 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
node set image  NODE_NAME NODE_IO IMAGE_PATH     ← 一次只接受一个文件

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
node set image imageEncoder GENIE_NODE_IMAGE_ENCODER_IMAGE_INPUT preprocessed_image_dog.raw

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
2
3
4
5
6
7
8
9
10
11
12
13
// L50-58:官方输入映射表(节选,含 Qwen3-VL 的 3 个输入)
{"pixel_values", GENIE_NODE_IMAGE_ENCODER_IMAGE_INPUT},
{"position_ids_sin", GENIE_NODE_IMAGE_ENCODER_IMAGE_POS_SIN},
{"position_ids_cos", GENIE_NODE_IMAGE_ENCODER_IMAGE_POS_COS},

// L61-70:按模型实际声明的输入构建映射(Qwen3-VL → 3 个)
m_encoder->getInputNames(inputNames);
for (auto& name : inputNames) m_inputIOMap[inputNameToIOName.at(name)] = name;

// L103-105:每凑齐一组输入就立即 encode 一次,然后清空输入槽位、准备接收下一张图
if (m_input.size() == m_inputIOMap.size()) {
status = m_encoder->encode(m_input, m_data, nullptr);
m_input.clear(); // clear input buffer after encode —— 清空输入、迎接下一组
  • IMAGE_POS_COS / IMAGE_POS_SIN 是 2.45 专为 Qwen3-VL 新增的官方 IO(include\Genie\GenieNode.h L62-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 之间”,与跨进程/跨用例无关。

  1. 用例之间确实是完全隔离的:bench_run.py 对每个用例单独启动一次 genie-app 进程、单独执行一个脚本文件;节点对象、m_data、accumulator 全部新建。实测值也证明无跨用例污染(1pics=607 干净、2pics=320+640=960 精确)。
  2. 膨胀的粒度是脚本内部:一个脚本里有 k 组 node set image(6pics 为 18 条命令)。
  3. node set 不是延迟到 execute 才处理,而是逐条立即执行:每条命令一次 GenieNode_setData(并产生一条 profile 事件,这就是 18/14 事件计数的来源),每凑齐一张图的 3 个输入就在 setImageInputData 内立即 encode + append。
  4. m_data 是同一个 ImageEncoder 节点对象的成员,在这 k 次 encode 之间从未被重置——m_data.clear() 在 execute() 里,而 execute 发生在所有 set 完成之后。
  5. 所以”没有清空”的准确含义是:同一进程内,同一节点的输出缓冲在多次 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
2
3
4
// outputs.resize(numElements * t_image_features->dtype.bw());   // 原设计:覆盖语义(被注释)
uint32_t bw = t_output_tensor->dtype.bw();
uint8_t* output_buffer = reinterpret_cast<uint8_t*>(getBuffer(t_output_tensor));
outputs.insert(outputs.begin(), output_buffer, output_buffer + numElements * bw); // 前置插入且不清空

改后:

1
2
3
4
// outputs.resize(numElements * t_image_features->dtype.bw());   // 原设计:覆盖语义(注释保留)
uint32_t bw = t_output_tensor->dtype.bw();
uint8_t* output_buffer = reinterpret_cast<uint8_t*>(getBuffer(t_output_tensor));
outputs.assign(output_buffer, output_buffer + numElements * bw); // 覆盖语义:先清空再写入本次推理输出

对应 unified diff(git apply --recount / patch -p1 均可;不依赖精确行号,按上下文匹配):

1
2
3
4
5
6
7
8
--- a/examples/Genie/Genie/src/qualla/engines/qnn-htp/nsp-image-model.cpp
+++ b/examples/Genie/Genie/src/qualla/engines/qnn-htp/nsp-image-model.cpp
@@ -593,4 +593,4 @@ size_t QnnNspImageModel::runInference(
// outputs.resize(numElements * t_image_features->dtype.bw());
uint32_t bw = t_output_tensor->dtype.bw();
uint8_t* output_buffer = reinterpret_cast<uint8_t*>(getBuffer(t_output_tensor));
- outputs.insert(outputs.begin(), output_buffer, output_buffer + numElements * bw);
+ outputs.assign(output_buffer, output_buffer + numElements * bw);

效果: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
2
3
if (m_input.size() == m_inputIOMap.size()) {
status = m_encoder->encode(m_input, m_data, nullptr);
m_input.clear(); // clear input buffer after encode

改后(新增一行):

1
2
3
4
if (m_input.size() == m_inputIOMap.size()) {
m_data.clear(); // 新增:保证每次 encode 的输出从空缓冲开始(多图/多帧场景)
status = m_encoder->encode(m_input, m_data, nullptr);
m_input.clear(); // clear input buffer after encode

对应 unified diff:

1
2
3
4
5
6
7
8
--- a/examples/Genie/Genie/src/pipeline/ImageEncoder.cpp
+++ b/examples/Genie/Genie/src/pipeline/ImageEncoder.cpp
@@ -102,4 +102,5 @@ int32_t pipeline::ImageEncoder::setImageInputData(...)
int32_t status = GENIE_STATUS_SUCCESS;
if (m_input.size() == m_inputIOMap.size()) {
+ m_data.clear();
status = m_encoder->encode(m_input, m_data, nullptr);
m_input.clear(); // clear input buffer after encode

效果: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 规律。


高通平台 Qwen3-VL 多图推理时图像 tokens 膨胀问题
http://www.horus-space.cloud/posts/34efdacc.html
作者
Horus
发布于
2026年9月15日
许可协议