系列:《0.8MB 跑通 Qwen:从零实现 ARM 零依赖纯 C 推理引擎》(30 天 × 90 篇) | 适配模型:Qwen3-VL-8B-Instruct(千问3_VL_8B_Instruct)· Qwen3-VL-2B-Instruct · Qwen3-30B-A3B | 测试设备:RK3588(4×Cortex-A76 + 4×Cortex-A55,aarch64)
系列总纲:《0.8MB 跑通 Qwen》30 天 90 篇 · 总纲(阿里云社区)
上一篇:18-3《MRoPE——3D 位置编码给视觉留的席位》| 下一篇:19-2《vision_tokens 客户端预编码协议》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:推理引擎的 ViT 视觉编码流程:图片像素经预处理、PatchEmbed、24 层自注意力与合并层变成视觉 token;板端 64×64 合成图实测 4 个视觉 token、编码耗时 295.3 ms。
关键词:手搓 Qwen 推理引擎、千问大模型推理、Qwen3-VL、零依赖纯 C、ViT、视觉 token、视觉塔、多模态、RK3588
导语:一张图片要先被视觉塔切成 patch、过 24 层 ViT 自注意力、再做 2×2 空间合并,才能变成喂给千问大模型的视觉 token。本篇在 RK3588 上拆开这条 ViT 编码链路,看 64×64 合成图如何得到 4 个视觉 token,并回答它们为何不是固定数量。
Day 18 收尾讲了 MRoPE:视觉 token 在进入 LLM 前会拿到 (pos_t, pos_h, pos_w) 三套坐标。这篇回答"这些视觉 token 从哪来"的第一半:图片像素 → ViT 编码 → 视觉特征。引擎的视觉塔在 src/model/vllm_vision.c,板端(RK3588 + Qwen3-VL-2B 真权重)实测:64×64 合成图经 ViT 24 层得到 4 个视觉 token,编码耗时 295.3 ms。
1. 知识点:视觉塔把图片"裁"成什么
1.1 多模态 LLM 的图片进法
Qwen3-VL 的视觉塔是一条独立的 ViT(Vision Transformer),与 LLM 分开训练权重、分开量化:
RGB 图片 → resize/归一化(preprocess)
→ PatchEmbed(patch=16,把图切成 16×16 的小块嵌入)
→ 可学习位置嵌入 + 视觉 RoPE
→ ViT 24 层自注意力(DeepStack 在指定层额外抽特征)
→ 2×2 空间合并(spatial_merge)
→ Merger 两层 MLP 投影到 LLM 隐空间
→ 每张图得到 n 个"视觉 token"(LLM 维度)
图片分辨率决定 token 数:一张 64×64 的图 → 64/16 × 64/16 = 4×4 = 16 个 patch → 2×2 合并 → 4 个视觉 token。视觉 token 数随图变(不是固定数量),这正是 MRoPE 网格 (g_h, g_w) 的来源。
1.2 实测的 2B 视觉配置
板端加载日志(不是头文件里的"27 层"宣传注释,是 config.json 驱动、manifest 佐证的真实值):
[ST] Vision: depth=24 hidden=1024 heads=16 ffn=4096 patch=16 temporal=2 merge=2 out=2048
[ST] DeepStack layers: 5 11 17
[ST] MRoPE sections: 24 20 20
Config: dim=2048 layers=28 heads=16 kv_heads=8 hd=128 ffn=6144 vocab=151936
注意:2B 的 ViT 是 24 层(vllm_vision.h 头注释写"27-layer ViT",那是 8B 的口径),视觉隐宽 1024、patch 16、2×2 合并、temporal=2(视频沿时间轴也做 patch)。DeepStack 在层 5/11/17 各抽一组中间特征。生产 model.vqf(2,331,316,232 B,flags=0x63)里能看到对应的 27 个 v_* 张量:4 个 Q8 大矩阵(v_q8_qkv/proj/fc1/fc2,各 24 层)+ patch/pos 嵌入 + 偏置/归一 + merger 6 个 + deepstack 6 个。
2. 对应代码:encode 的八步流水
st_vision_encode_image(vllm_vision.c 1144 行起)按 8 步走完全流程:
/* Step 1: Preprocess (resize + normalize) */ preprocess_image(...) // 1144-1174
/* Step 2: Patch embedding */ patch_embed_image(...) // 1176-1179
/* Step 3: Interpolated position embedding + vision RoPE table */
vision_pos_encode(...) // 1181-1183
/* Step 5: ViT forward with DeepStack */
for (int l = 0; l < cfg->vis_depth; l++) {
// 1194-1214
vit_block_forward(vis, l, n_patches);
if (cfg->vis_ds_idx[d] == l) {
// DeepStack 层 5/11/17
vision_ds_merge(...); // 抽中间特征 → ds_features
}
}
/* Step 6: Spatial merge of final ViT output */ spatial_merge(...) // 1217-1226
/* Step 7: Main merger */ merger_forward(...) // 1228-1236
/* Step 8: Visual token output = main merged tokens only */ // 1238+
配套的权重结构在 include/model/vllm_vision.h:STVisionWeights(patch_embed、pos_embed、attn_qkv/proj、norm1/2、mlpfc1/fc2、merger、ds_)与 STVisionState(运行时缓冲:pixel_values、hidden_states、rot_cos/sin、ds_features)。要盯的转换关系有两条:
- 层数由 config 驱动:
vis_depth/vis_hidden/vis_heads/vis_patch/vis_merge全部来自config.json,代码没有写死 8B 数值——所以同一个引擎能跑 2B(24 层)也能跑 8B(27 层); - Q8_0 权重只读:ViT 的 QKV/O/fc1/fc2 加载后量化为 Q8_0 并释放 F32 副本(
st_vision_weights_free_f32,vllm_vision.h153-159 行注释:RK3588 16GB 板子上这步能省约 1.65GB 内存)——与文本权重同一个"推理只读量化副本"纪律。
3. 改动后果:板端跑一次完整图片 → 编码 → 生成
实测口径:RK3588(Orange Pi 5 Plus)/ aarch64 / Release / 2026-09-07。方法:
--multimodal自测(默认 64×64 合成渐变图),模型目录用 bind-mount 把 2B 挂到自测默认寻找的路径下运行。
[ST-Q8] Estimated memory: 4.5 GB (F32 token + Q8_0 + Q4_0 nibble weights)
Weights loading: 20.5 sec
[TOK] Loaded 151669 tokens from vocab.bin (max_len=256)
Tokenizer: 151669 tokens, vision IDs: start=151652 end=151653 img=151655 vid=151656
--- Image 1/1: synthetic (64x64) ---
[VIS] Image encoded: 64x64 → 16 patches → 4 visual tokens
64x64 -> 4 patches -> 4 visual tokens (grid 2x2)
[VIS] Encode: 295.3 ms
Prompt: 17 tokens (10 text, 6 visual)
Prefill: 513ms (33.16 tok/s)
Decode: 60 tokens in 2974ms (TPOT=50ms, 20.2 tok/s)
Total: TTFT=513ms E2E=3486ms
--- Video Analysis ---
[VIS] Video encoded: 4 frames 64x64 → 32 patches → 8 visual tokens
Video 4 frames 64x64 -> 8 visual tokens
[VIS] Encode: 628.5 ms
三件事值得拆开读:
- token 数随"层"走:单图 64×64 → 16 patch → 合并 → 4 token;4 帧视频因为
temporal=2先沿时间轴两两归并(4 帧 → 2 组),再各自 4×4=16 patch → 2×16=32 patch → 合并 → 2×4 = 8 token。temporal patch 是"视频 token 变少"的关键(帧间信息在 patch 阶段先融合)。 - encode 是板载实数:64×64 编码 295.3 ms、4 帧视频 628.5 ms——这就是"视觉塔在边缘板子上算一次"的成本量级,也是 19-2 里客户端预编码协议存在的理由。
- 诚实边界:该自测的模型目录名与提示词是按 8B 写的,我们用 bind-mount 把 2B 挂进去跑通;2B 的
lm_head是 tied(safetensors 里没有独立lm_head.weight),所以解码文本段不作为质量结论——本文引用的 encode/grid/时序数字来自编码与 prefill 段,均与 serve 部署路径(19-2)交叉验证一致。视觉编码后的真正"看图回答"质量验证,请看 19-2 用model.vqf部署跑的 serve 结果。
4. 学员调试任务
- A 档(板端动手):
- 复刻本文:跑
--multimodal(需要把 2B 或 8B 模型目录放到代码查找的Modl/千问3_VL_8B_Instruct/下,或自己 bind-mount),核对"16 patches → 4 visual tokens / Encode ≈ 295ms / grid 2x2"; - 开
VLLM_MMDBG=1重跑一次,观察[VIS] preprocess/patch_embed/pos_embed/vit_all/merge各步骤的累计耗时——哪一步是热点?(这是给 Day 28 性能篇留的伏笔) - 用
manifest_vqf.py(Day 17)核对model.vqf里 27 个v_*张量的行数与[ST] Vision:一行对得上(qkv/proj/fc1/fc2 都是 24 行)。
- 复刻本文:跑
- B 档(纯读源码):读
vllm_vision.c1144–1240,回答:① 为什么 encode 的输出只算"main merged tokens"而 DeepStack 特征是另存到ds_features(提示:vllm_vision.h124 行注释——它要注入到 LLM 的哪些层,进入 prefill 的哪个环节)?② 若把vis_merge从 2 改成 1,同样 64×64 会产出多少 token?改成 4 呢?③ 若config.json的vis_depth与model.vqf里v_q8_qkv的实际层数不一致,加载或前向会在哪一步先出错(读st_vision_load_weights的分层循环)?
预期输出:一张你自己板子的"图片尺寸 → patch 数 → 合并后 token 数 → encode ms"小表,并能说出 ViT 各步骤里最耗时的一环。
收尾
- 本篇源码点名:vllm_vision.c(encode_image 八步 1144–1240、vit_block_forward/merger)、vllm_vision.h(权重/运行时结构)、vllm_safetensors.c(视觉张量加载与 Q8 量化)
- 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:板载 ViT 要 295ms,那"把编码挪到客户端"会怎样?19-2 拆
vision_tokens客户端预编码协议:图片 base64 上行的板端 ViT 路径、缓存命中路径、以及"客户端自己算好特征直接塞进来"的第三条路。