0.8MB 跑通 Qwen|第 19-1 篇:ViT 编码——推理引擎里图片是怎么变成视觉 token 的

简介: 本系列《0.8MB跑通Qwen》用纯C手写零依赖推理引擎,实现在RK3588(aarch64)上部署Qwen3-VL多模态模型。真机实测:64×64图经24层ViT编码得4个视觉token,耗时仅295.3ms,支持图文理解与视频分析。(239字)

系列:《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)。要盯的转换关系有两条:

  1. 层数由 config 驱动:vis_depth/vis_hidden/vis_heads/vis_patch/vis_merge 全部来自 config.json,代码没有写死 8B 数值——所以同一个引擎能跑 2B(24 层)也能跑 8B(27 层);
  2. Q8_0 权重只读:ViT 的 QKV/O/fc1/fc2 加载后量化为 Q8_0 并释放 F32 副本(st_vision_weights_free_f32,vllm_vision.h 153-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

三件事值得拆开读:

  1. 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 阶段先融合)。
  2. encode 是板载实数:64×64 编码 295.3 ms、4 帧视频 628.5 ms——这就是"视觉塔在边缘板子上算一次"的成本量级,也是 19-2 里客户端预编码协议存在的理由。
  3. 诚实边界:该自测的模型目录名与提示词是按 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 档(板端动手):
    1. 复刻本文:跑 --multimodal(需要把 2B 或 8B 模型目录放到代码查找的 Modl/千问3_VL_8B_Instruct/ 下,或自己 bind-mount),核对"16 patches → 4 visual tokens / Encode ≈ 295ms / grid 2x2";
    2. 开 VLLM_MMDBG=1 重跑一次,观察 [VIS] preprocess/patch_embed/pos_embed/vit_all/merge 各步骤的累计耗时——哪一步是热点?(这是给 Day 28 性能篇留的伏笔)
    3. 用 manifest_vqf.py(Day 17)核对 model.vqf 里 27 个 v_* 张量的行数与 [ST] Vision: 一行对得上(qkv/proj/fc1/fc2 都是 24 行)。
  • B 档(纯读源码):读 vllm_vision.c 1144–1240,回答:① 为什么 encode 的输出只算"main merged tokens"而 DeepStack 特征是另存到 ds_features(提示:vllm_vision.h 124 行注释——它要注入到 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 路径、缓存命中路径、以及"客户端自己算好特征直接塞进来"的第三条路。
相关文章
|
1天前
|
安全 索引
0.8MB 跑通 Qwen|第 12-2 篇:推理引擎的磁盘 KV 快照格式——0.47 GB 从哪来
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎,实测RK3588上部署Qwen3-VL多模态模型。本文详解0.47GB磁盘KV快照格式:64字节头+token IDs+28层F32原样KV,逐字段对账到字节级,诠释“宁大勿错”的可靠性设计。(239字)
|
23小时前
|
JSON 网络协议 前端开发
0.8MB 跑通 Qwen|第 20-3 篇:SSE 流式——推理引擎响应怎么写一半就发给客户端
本系列《0.8MB跑通Qwen》用纯C手搓零依赖推理引擎,适配Qwen3-VL多尺寸模型,在RK3588(aarch64)实测SSE流式响应:首token延迟130ms,后续约50ms/token,支持标准+自定义事件,真正实现端侧“打字机”体验。(239字)
|
23小时前
|
算法 定位技术 C语言
0.8MB 跑通 Qwen|第 16-3 篇:推理引擎的 tensor 目录与布局重排的落盘顺序
《0.8MB跑通Qwen》系列实录:30天手搓零依赖纯C推理引擎,适配Qwen3-VL多模态模型(2B/8B/30B-A3B),真机跑通RK3588(aarch64)。详解VQFTensor目录结构、64字节对齐落盘、FNV校验及mmap加载机制,含零依赖Python解析器。
|
1天前
|
C++
0.8MB 跑通 Qwen|第 10-3 篇:推理引擎里诚实的坑——哪些场景稀疏无收益,甚至负优化
本篇实测揭示稀疏注意力的真相:短上下文(&lt;1K)开`--sparse-attn`反成负优化!RK3588板端验证,详列8条“禁用清单”,每条附源码行号与实测数据。明确边界:q4 KV、推测解码、L3驱逐等均与稀疏互斥,并坦承`test-sparse`自检崩溃缺陷。(239字)
|
1天前
|
存储 缓存 C++
0.8MB 跑通 Qwen|第 9-1 篇:推理引擎的 KV 也量化——q8 KV 把缓存与 decode 带宽压到一半
本系列手搓零依赖纯C推理引擎,仅0.8MB即可在RK3588上跑通Qwen3-VL多模型。本文详解q8 KV量化:将f16 KV压至INT8,按token每头独立定标,缓存与decode带宽降低约48%,兼顾精度与效率,f32仅作调试探针。
|
23小时前
|
存储 容器
0.8MB 跑通 Qwen|第 17-2 篇:GGUF → VQF——推理引擎的 Q4_0…Q8_K 反量化再量化
本系列《0.8MB跑通Qwen》聚焦ARM零依赖纯C推理引擎,适配Qwen3-VL多版本模型,在RK3588平台实测。本文详解GGUF→VQF的“反量化再量化”路径:将llama.cpp已量化权重(Q4_0/Q8_0等)先还原为F32,再经统一量化与重排固化为VQF格式,确保内核兼容性与可验证性。(239字)
|
1天前
|
缓存 NoSQL 区块链
0.8MB 跑通 Qwen|第 15-3 篇:推理引擎单步走一次生成——token 循环与 KV 追加
本系列《0.8MB跑通Qwen》用纯C手搓零依赖推理引擎,实测RK3588板端运行Qwen3-VL多模型;本文以gdb单步追踪decode循环,直观展示“采样→前向→KV追加→停判”全过程,将大模型逐token生成钉在胶片上。(239字)
|
1天前
|
调度 C++
0.8MB 跑通 Qwen|第 7-2 篇:推理引擎的混合精度路由——flags 决定谁用 Q8、谁用 Q4
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎,适配Qwen3-VL多模态模型,在RK3588上实现实测。核心创新是混合精度路由:prefill阶段用Q8保精度,decode阶段用Q4省带宽,通过flags比特位动态调度,兼顾速度与质量。(239字)
|
1天前
|
开发工具 C++ git
0.8MB 跑通 Qwen|第 5-3 篇:推理引擎的参考实现对拍——怎么读懂 rel err 的数量级
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎开发,实测适配Qwen3-VL多模型,在RK3588上完成Q8/Q4量化与NEON加速。核心创新在于“两把尺子”误差分析法:内核一致性(1e-7)验证手写代码正确性,量化损失(1e-2)界定精度边界,实现可验证、可调试、可落地的轻量级LLM部署。(239字)
|
1天前
|
API 调度 C++
0.8MB 跑通 Qwen|第 14-1 篇:大模型推理的连续批处理——iteration-level 调度思想
本系列《0.8MB跑通Qwen》聚焦ARM端零依赖纯C推理引擎,适配Qwen3-VL多模态模型,在RK3588上实测落地。本文详解iteration-level连续批处理:动态聚合多请求的decode步,单次前向服务多人,兼顾并发与确定性。(239字)

热门文章

最新文章