云计算百科
云计算领域专业知识百科平台

Day 19·1 ViT视觉编码:图片如何变成视觉token

> 上一篇: 18·3 《MRoPE——3D 位置编码给视觉留的席位》| 下一篇:  19·1 《ViT视觉编码:图片如何变成视觉token》

一句话导读:ViT 视觉编码流程:图片像素经预处理、PatchEmbed、24 层自注意力与合并层变成视觉 token;板端 64×64 合成图实测 4 个视觉 token、编码耗时 295.3 ms。

真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)

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、mlp_fc1/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.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 (64×64) —
    [VIS] Image encoded: 64×64 → 16 patches → 4 visual tokens
    64×64 -> 4 patches -> 4 visual tokens (grid 2×2)
    [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 64×64 → 32 patches → 8 visual tokens
    Video 4 frames 64×64 -> 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 2×2”;
    • 开 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.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)(源码可得双许可:学习 / 学术研究免费)
    • 下篇预告:板载 ViT 要 295ms,那“把编码挪到客户端”会怎样?19-2 拆 vision_tokens 客户端预编码协议:图片 base64 上行的板端 ViT 路径、缓存命中路径、以及“客户端自己算好特征直接塞进来”的第三条路。
    • 关键词:ViT、视觉 token、视觉塔、多模态、PatchEmbed、DeepStack、MRoPE、RK3588
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Day 19·1 ViT视觉编码:图片如何变成视觉token
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!