系列 《Transformer 与 LLM:结构、实现与算量》 第 13 / 14 篇
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
- Transformer 长什么样——从一句话到下一个 token
- 一个 token 的旅程——训练侧与推理侧
- 手搓 GPT(上)——nanoGPT model.py 逐行解析
- 手搓 GPT(下)——nanoGPT train.py 与训一个会续写的模型
- Transformer 解剖与参数量
- Attention 变体与 KV cache
- 位置编码与长上下文
- MoE 的路由、激活参数量与通信形态
- MTP——改训练目标而不改主干的多 token 预测
- 前向的算量与访存量
- 浮点格式、数值稳定性与混合精度
- 量化、投机解码与 LoRA
- 多模态:vision encoder 的算量与 image token 的 KV 代价
- Transformer 与 LLM:系列总结与通关自测
前十二篇讨论的模型只有一种输入:token id。它查一张 embedding 表得到向量,然后进入 decoder。这个前提决定了前面所有的账——参数量、FLOPs、KV cache——都只与 token 数有关,而 token 数由 tokenizer 决定。
多模态模型打破了这个前提。一张图片不经过 tokenizer,它先经过一个独立的神经网络(vision encoder,通常是 ViT),被切成几百到几千个向量,再由一个连接层(connector)变成 decoder 认得的 token embedding,插进 prompt 的对应位置。于是出现了三笔新账:encoder 自己的算量、connector 决定的 token 数、以及这些 token 进入 decoder 之后与文本 token 完全相同的 prefill FLOPs 和 KV cache。
本篇要回答的核心问题是:
一张 1024×1024 的图片,在 Qwen2-VL 里等于多少个 token?1 它的代价花在 encoder、connector 还是 decoder 的 KV 上?2 为什么”encoder 输出只有 10 MB”而”这张图占的显存有 400 MB”两句话可以同时成立?3
一、总览:三笔账与一个结论
0. 先说清楚”多模态”是怎么接进来的
算账之前,先把机制说明白——否则下面每一笔账都不知道在算谁。一个只会读文字的 LLM 之所以能”看图”,靠的是把图片伪装成一段 token,插进文字 token 中间,decoder 完全不知道它们来自图片:
%% 图:多模态 LLM 怎么把一张图接进来:图片切成 patch → vision encoder(ViT)把每个 patch 变成向量 → connector 压缩并投影成 decoder 的 embedding 维度 → 作为 image token 插进文字 token 序列 → decoder 照常做 attention 和生成
flowchart TB
IMG["一张图 1024 × 1024 像素"] --> P["patchify:切成 14 × 14 的小方块<br/>→ 约 5000 个 patch,每个先拉直成向量"]
P --> ENC["vision encoder(ViT,一个独立的 Transformer)<br/>每个 patch 变成一个 d_enc 维向量,patch 之间做 attention"]
ENC --> CON["connector:2×2 合并 + MLP<br/>÷4 变成约 1300 个向量,并投影到 decoder 的 d_model 维"]
TXT["文字 prompt:请描述这张图"] --> TOK["tokenizer + embedding 表<br/>→ 文字 token 的 embedding"]
CON --> SEQ["拼成一个序列:[文字 token] [约 1300 个 image token] [文字 token]<br/>每个位置都是一个 d_model 维向量,decoder 分不出谁来自图"]
TOK --> SEQ
SEQ --> DEC["decoder:照常 prefill、KV cache、逐 token 生成文字"]
三段各自是什么、为什么需要:
- vision encoder(视觉编码器):文字有 tokenizer 把字切成 token,图片没有——像素太多(100 万个),也没有”词”。ViT 的办法是先把图切成 14×14 像素的小方块(patch,相当于图片的”字”),每个方块拉直成一个向量,再用一个独立的 Transformer 让方块之间互相看(attention),输出每个方块的语义向量。这个 encoder 通常先用图文对比学习(CLIP,L0 第二篇提过)单独训好,所以它输出的向量已经”懂图”。
- connector(连接层):encoder 输出的向量维度(比如 1280)和 decoder 用的维度(4096)不一样,个数也太多(几千个)——所以要一个小网络把相邻几个合并、再投影到 decoder 的维度。它输出的每个向量就叫一个 image token:从 decoder 的角度看,它和查 embedding 表得到的文字 token 向量没有任何区别。
- decoder:就是前面讨论的那个 LLM。image token 插在文字 token 之间,attention 让文字 token 能”看到”图片 token,于是模型可以回答关于图的问题。也正因为 decoder 一视同仁,每个 image token 都要走一遍 prefill、都要占一份 KV cache——这就是本篇要算的账。
另一条路线(第五章):不把图片塞进序列,而是在 decoder 的部分层里加 cross-attention,让文字 token 去”查”图片特征——图片不占序列位置、不占主 KV,代价是多出一组 cross-attention 参数。
VLM 的结构选择(connector 有几种、注入方式怎么选、动态分辨率怎么做)与训练配方,在 L7 多模态系列的第十篇、第六篇展开;本篇只在上面这条链路上算账——因为对 Infra 来说,”图片贵在哪”决定了显存怎么分、批怎么组。
1. 先说答案
一张图进入多模态 LLM,要经过三段,每段一笔账:
| 段 | 做什么 | 账 | 性质 |
|---|---|---|---|
| vision encoder | 把像素切成 patch,过一个 ViT | encoder 参数量 × patch 数 × 2 的 FLOPs;一次性 | compute-bound,与文本无关 |
| connector | 把 encoder 输出变成 decoder 的 embedding | 决定 image token 数;输出 \(n_{img} \times d_{model} \times 2\) 字节 | 便宜,但它的压缩比决定后面两笔账 |
| decoder | image token 与文本 token 一起做 prefill 和 decode | prefill \(2N \cdot n_{img}\) FLOPs;KV cache \(n_{img} \times\) 每 token KV,活到请求结束 | 与文本 token 完全同价 |
结论提前给出:图片贵的不是 encoder,而是它变成的那几百上千个 token 在 decoder 里占的 KV。以 Llama-3-70B 规格的 decoder(每 token KV 320 KiB)为例,1369 个 image token 的 encoder 输出是 21 MiB,它们的 KV 是 418 MiB,是前者的 20 倍;而且 encoder 输出用完即弃,KV 要陪伴整个请求。
2. 本文的路线
沿用系列的方法:写出公式,代入真实模型的 config.json,算出数字,解释数字对系统的意义。四个模型贯穿全篇:
| 模型 | 发布 | encoder | connector | decoder | 代表的路线 |
|---|---|---|---|---|---|
| LLaVA-1.5-7B | 2023-10 | CLIP ViT-L/14,336 px,24 层 d=1024 | 2 层 MLP,不压缩 | Vicuna-7B(MHA) | 固定分辨率、最简单的 decoder-only 注入 |
| Qwen2-VL-7B | 2024-08 | 自训 ViT,32 层 d=1280,原生动态分辨率 | 2×2 merge + MLP,÷4 | Qwen2-7B(GQA 4 KV 头) | 动态分辨率 + M-RoPE |
| InternVL2-8B | 2024-07 | InternViT-300M,448 px,24 层 d=1024 | pixel-shuffle + MLP,÷4 | InternLM2.5-7B | 固定 tile + 动态 tile 数 |
| Llama-3.2-11B-Vision | 2024-09 | ViT-H/14,560 px tile,32 局部 + 8 全局层 | 线性投影 | Llama-3.1-8B + 8 层 cross-attention | cross-attention 注入 |
数字都是理论值,硬件基线仍是 H100 SXM(80 GB,3.35 TB/s,BF16 约 989 TFLOPS)。
3. 本文的章节安排
| 章 | 主题 | 内容 |
|---|---|---|
| 二 | 从像素到 patch | patchify、ViT 的参数量与 FLOPs 公式、四个 encoder 的数字 |
| 三 | connector:谁决定 token 数 | MLP / 2×2 merge 与 pixel-shuffle / resampler 三类,各自的 token 数公式 |
| 四 | image token 在 decoder 里 | prefill FLOPs、KV cache、encoder 输出字节,三者的数量级对比 |
| 五 | 另一条路线:cross-attention | Llama 3.2 Vision 的结构,它的 KV 为什么不随文本增长 |
| 六 | 位置编码:从一维到三维 | M-RoPE 把 head_dim 分给 (t, h, w),ViT 内部的 2D RoPE |
| 七 | 视频与音频 | 帧 × 每帧 token;Whisper encoder 的 30 秒 → 1500 个位置 |
| 八 | 训练侧 | 冻结 encoder 省什么、不省什么;图片解码是 CPU 的活 |
| 九 | 实践 | llm_cost.py 的多模态支持与最终成本表 |
| 十 | 本文小结 | |
| 十一 | 自测 | 5 道题 |
二、从像素到 patch:vision encoder 的账
1. patchify:图片怎么变成”序列”
ViT(Dosovitskiy 等 2020)处理图片的方法与 Transformer 处理文本的方法在形式上完全一样:把输入切成一串向量,然后堆 Transformer 层。区别只在第一步——文本查表,图片切块:
- 把 \(H \times W\) 的图片切成 \(p \times p\) 的小方块(patch),主流取 \(p = 14\);
- 每个 patch 有 \(3 p^2\) 个像素值(三通道),用一个线性层(等价于 stride = p 的卷积)映射到 \(d_{vit}\) 维;
- 加上位置编码,得到 \(n_p = (H/p)(W/p)\) 个 \(d_{vit}\) 维向量,进入 ViT 的 Transformer 层。
代入:336 px 的图,\(336 / 14 = 24\),\(n_p = 576\);448 px,\(32^2 = 1024\);560 px,\(40^2 = 1600\);1024 px(Qwen2-VL 先把边长凑成 28 的倍数——1024 不是 28 的倍数,processor 会 round 到 1036 px),\(74^2 = 5476\)。
一个 patch 就是 ViT 的一个”token”。ViT 内部的 attention 是双向的(不是 causal),每个 patch 看得到全图的所有 patch。
2. ViT 的参数量与 FLOPs
ViT 的一层与第五篇的 decoder 层结构相同:attention 四个矩阵加 FFN 两个矩阵(ViT 用 GELU 的经典 FFN,不是 SwiGLU,所以是两个矩阵;FFN 宽度通常是 \(4 d_{vit}\))。忽略 bias 与 norm:
\[N_{layer} = 4 d_{vit}^2 + 2 \cdot 4 d_{vit}^2 = 12\, d_{vit}^2, \qquad N_{vit} \approx L_{vit} \cdot 12\, d_{vit}^2 + 3p^2 d_{vit}\]FLOPs 的公式与第十篇相同:权重部分每个 patch 每参数 2 FLOPs,attention 部分每层 \(4 n_p^2 d_{vit}\)(\(QK^\top\) 与 \(PV\) 两个 GEMM):
\[\text{FLOPs}_{vit} = 2 N_{vit} \cdot n_p + 4 L_{vit}\, n_p^2\, d_{vit}\]代入四个 encoder:
| encoder | \(L_{vit}\) | \(d_{vit}\) | 参数量 | 输入 | \(n_p\) | 权重 FLOPs | attention FLOPs | 合计 | H100 峰值下界 |
|---|---|---|---|---|---|---|---|---|---|
| CLIP ViT-L/14-336(LLaVA-1.5) | 24 | 1024 | 302 M | 336² | 577(含 CLS) | 0.35 T | 0.03 T | 0.38 T | 0.4 ms |
| InternViT-300M(InternVL2) | 24 | 1024 | 302 M | 448² × 1 tile | 1025 | 0.62 T | 0.10 T | 0.72 T | 0.7 ms |
| Qwen2-VL ViT | 32 | 1280 | 629 M | 1024² | 5476 | 6.89 T | 4.91 T | 11.8 T | 11.9 ms |
| Qwen2.5-VL ViT(28 层 window + 4 层 full) | 32 | 1280 | 629 M | 1024² | 5476 | 6.89 T | 0.66 T | 7.55 T | 7.6 ms |
| Llama-3.2 Vision ViT-H/14 | 32 + 8 | 1280 | 786 M | 560² × 4 tile | 6404 | 10.1 T | 8.4 T | 18.5 T | 18.7 ms |
三个观察:
- encoder 参数量在 0.3–0.8 B,是 decoder 的 4–10%。它的权重字节(BF16 0.6–1.6 GB)在显存账里不是主角。
- attention 的二次项在高分辨率下会追上权重项。576 个 patch 时 attention 只占 8%;5476 个 patch 时占 42%;Llama 3.2 的 4 tile 拼成 6404 个 patch 时占 45%。这就是 Qwen2.5-VL 改用 window attention 的原因:32 层里 28 层只在 8×8 patch 的窗口(112 px)内做 attention,attention FLOPs 从 4.9 T 降到 0.66 T,总量降 36%。
- encoder 是 compute-bound 的。一张图的 5476 个 patch 是一个 5476 行的 GEMM,算术强度远超 H100 的 ridge(约 295,见第十篇);不像 decode 那样受带宽限制。一张 1024² 的图在 Qwen2-VL 上的 12 ms 是算力的时间,用更大的 batch 摊不掉。
第三条对系统的含义:encoder 的时间与文本无关、与 batch 无关、一次性。推理引擎可以把它当作独立于 decoder 的一段计算单独调度、单独预算——这是它在系统里被拆成”encoder 预算”的原因,本篇只给出数字,机制不展开。
3. 分辨率的策略
四个模型对”图片多大”的处理不同,直接决定了 \(n_p\):
| 策略 | 代表 | 做法 | \(n_p\) 的范围 |
|---|---|---|---|
| 固定分辨率 | LLaVA-1.5 | 任何图缩放(或 pad)到 336² | 恒为 576 |
| 固定 tile,动态 tile 数 | InternVL2、Llama 3.2 Vision | 图按长宽比切成 1–12(InternVL)或 1–4(Llama)个 448² / 560² 的 tile,各自过 encoder | 1024 × tile 数 / 1601 × tile 数 |
| 原生动态分辨率 | Qwen2-VL / 2.5-VL | 边长凑到 28 的倍数后整图一次过 encoder,min_pixels / max_pixels 限制范围 |
从 4 个 patch 到默认上限 16384 × 4 个 patch |
固定分辨率简单但浪费:一张 4K 截图缩到 336² 什么字都看不清。tile 方案让 encoder 每次只处理固定形状(对 kernel 和 batch 友好),代价是 tile 之间在 encoder 里通常没有 attention(InternVL 各 tile 独立过 ViT;Llama-3.2 例外,它把 4 个 tile 拼成一个 6404 长的序列,局部层和全局层都在整个序列上做 attention),tile 边界上的物体要靠 decoder 自己拼回去。原生动态分辨率最灵活,但 \(n_p\) 可以相差三个数量级,encoder 的 FLOPs 与后面 decoder 的账都随之剧烈变化——系统必须按图片尺寸而不是”图片张数”来预算。
三、connector:谁决定 image token 数
1. 三类 connector
encoder 输出 \(n_p\) 个 \(d_{vit}\) 维向量,decoder 需要 \(n_{img}\) 个 \(d_{model}\) 维向量。connector 做两件事:换维度、(可能)减数量。按是否减数量分三类:
| 类型 | 代表 | 做法 | \(n_{img}\) | 参数量 |
|---|---|---|---|---|
| MLP projector | LLaVA-1.5 | 每个 patch 向量独立过 2 层 MLP(1024 → 4096 → 4096) | \(= n_p\),576 | \(d_{vit} d + d^2 \approx 21\) M |
| 空间合并 | Qwen2-VL(2×2 merge)、InternVL(pixel-shuffle) | 相邻 2×2 个 patch 的向量拼接成 \(4 d_{vit}\) 维,再过 MLP | \(= n_p / 4\) | Qwen2-VL:5120 → 5120 → 3584,约 45 M |
| 重采样 | Flamingo 的 Perceiver Resampler、BLIP-2 的 Q-Former、MiniCPM-V | 固定数量的可学习 query 对 patch 向量做 cross-attention | 固定 64 / 96 / 256 | 数十到数百 M |
第一类把 token 数的决定权完全交给 encoder;第二类给一个固定的压缩比;第三类把 token 数固定下来,与图片分辨率脱钩。
2. 2×2 merge 的精确布局
第二类是当前的主流,值得把布局画清楚。下图以一个 112×112 像素的区域(8×8 个 patch,正好是 Qwen2.5-VL 的一个 attention window)为例:
三点:
- merge 不是池化,是拼接:4 个 \(d_{vit}\) 维向量拼成一个 \(4 d_{vit}\) 维向量,信息没有丢,只是让 MLP 决定怎么压。MLP 的输入维度因此是 5120 而不是 1280。
- 每个 image token 对应 28×28 像素。Qwen2-VL 的 token 数近似是 \(n_{img} \approx \lceil H / 28 \rceil \cdot \lceil W / 28 \rceil\):336² → 144,1024² → 37 × 37 = 1369,1920 × 1080 → 69 × 39 = 2691。精确值要看 processor 的
smart_resize:它先把边长 round(不是 ceil)到 28 的倍数,再按min_pixels/max_pixels缩放到预算内——1009² 会被 round 成 1008² → 36² = 1296,而 ceil 会给 37² = 1369;模型卡里的max_pixels与本地 processor 的默认值也可能不同。所以预算要以 processor 输出的image_grid_thw为准,不要拿原始像素套公式。 - 展平是行优先的:token \(t_k\) 的二维坐标是 \((k \,/\, W_{tok},\; k \bmod W_{tok})\)。这个坐标在第六章会被 M-RoPE 用到。
InternVL 的 pixel-shuffle 在数学上与 2×2 merge 相同(把 \(2 \times 2 \times d\) 重排成 \(1 \times 1 \times 4d\)),448² 的 1024 个 patch 变成 256 个 token。
3. token 数的一张表
把三种 connector 与三种分辨率策略合起来,”一张图等于多少 token”的答案是:
| 模型 | 一张 336² | 一张 1024² | 上限 |
|---|---|---|---|
| LLaVA-1.5 | 576 | 576(缩放到 336) | 576 |
| InternVL2 | 256(1 tile) | 256 × tile 数,1024² 约 4 tile + 缩略图 = 1280 | 13 × 256 = 3328 |
| Qwen2-VL | 144 | 1369 | 默认 max_pixels 下 16384 |
| Llama-3.2 Vision | 1601(1 tile) | 4 × 1601 = 6404 | 6404 |
同一张 1024² 的图,从 576 到 6404,差 11 倍。这个数决定了下一章的全部内容。
四、image token 在 decoder 里:真正的账
1. 三个字节数
image token 进入 decoder 之后与文本 token 没有任何区别:一样参与 prefill 的 GEMM,一样在每一层留下 K 和 V。第六、十篇的公式直接可用:
\[\text{FLOPs}_{prefill} = 2 N_{dec} \cdot n_{img}, \qquad \text{KV}_{img} = n_{img} \cdot 2 L\, n_{kv}\, d_{head} \cdot 2\ \text{B}, \qquad \text{Enc}_{out} = n_{img} \cdot d_{model} \cdot 2\ \text{B}\]代入两种 decoder 规格(Llama-3-8B:32 层、8 个 KV 头,每 token KV 128 KiB;Llama-3-70B:80 层、8 个 KV 头,320 KiB):
| \(n_{img}\) | 来源 | 8B:prefill FLOPs | 8B:KV | 8B:encoder 输出 | 70B:prefill FLOPs | 70B:KV | 70B:encoder 输出 |
|---|---|---|---|---|---|---|---|
| 576 | LLaVA 336² | 8.6 T | 72 MiB | 4.5 MiB | 80 T | 180 MiB | 9.0 MiB |
| 1369 | Qwen2-VL 1024² | 20.5 T | 171 MiB | 10.7 MiB | 190 T | 428 MiB | 21.4 MiB |
| 3328 | InternVL2 13 tile | 50 T | 416 MiB | 26 MiB | 466 T | 1040 MiB | 52 MiB |
| 6400 | Llama-3.2 4 tile | 96 T | 800 MiB | 50 MiB | 890 T | 2000 MiB | 100 MiB |
(prefill FLOPs 按 \(2 N_{gemm} n_{img}\),\(N_{gemm}\) 是扣掉输入 embedding 的参数量——第十篇的口径;tile 方案的 CLS token 在 encoder 里参与计算、不进 decoder,所以 Llama-3.2 是 \(4 \times 1600 = 6400\) 个 token。)
(Qwen2-VL-7B 自己的 decoder 是 28 层、4 个 KV 头,每 token KV 只有 56 KiB,1369 个 token 的 KV 是 73 MiB;LLaVA-1.5 的 Vicuna-7B 是 MHA,每 token 512 KiB,576 个 token 是 288 MiB——decoder 的 attention 变体对图片的代价影响是 4–9 倍,这正是第六篇 GQA 的价值在多模态上的放大。)
2. 三个数量级的对比
以 70B 规格、一张 1024² 的 Qwen2-VL 风格图片(1369 token)为例,把三段的字节数放在一起:
| 项 | 大小 | 生命周期 |
|---|---|---|
| encoder 权重(BF16) | 1.26 GB | 常驻,所有请求共享 |
| encoder 输出 | 21 MiB | prefill 期间用一次;prefill 完成即可释放 |
| image token 的 KV | 428 MiB | 从 prefill 到请求结束(含全部 decode 步) |
| prefill FLOPs | 190 TFLOP ≈ 192 ms(H100 峰值) | 一次性 |
KV 是 encoder 输出的 20 倍。原因在公式里:encoder 输出每 token 是 \(d_{model} \times 2\) 字节(16 KiB),KV 每 token 是 \(2 L n_{kv} d_{head} \times 2\) 字节(320 KiB),比值是 \(2 L \cdot n_{kv} d_{head} / d_{model} = 2 \times 80 \times 1024 / 8192 = 20\)——K 与 V 两份,乘层数,乘 KV 头总维度与模型维度之比。这个比值对 Llama-3-8B 是 16,对 Qwen2-VL-7B(28 层、4 个 KV 头)是 8。
再加上生命周期的差别:encoder 输出用完即弃,KV 要陪伴请求走完。一个”看一张图、写 300 字说明”的请求,image token 的 KV 会在显存里停留几秒到几十秒,占据的空间等价于 1369 个文本 token。在 KV cache 的账上,一张 1024² 的图就是一段 1369 token 的 system prompt——只是这段”prompt”没法被 tokenizer 压短。
3. prefill 的另一面
image token 让 prefill 变长,而 prefill 是 compute-bound 的(第十篇):1369 个 image token 在 70B 上要 190 TFLOP,与 1369 个文本 token 完全相同;再加上 encoder 自己的 11.8 TFLOP,一张图让这个请求的首 token 延迟多了约 200 ms(H100 峰值下界,实际 1.5–2 倍)。
这里有一个常见的误判:encoder 12 ms、decoder prefill 195 ms,看起来 encoder 不重要。但 encoder 的时间是串行前置的——decoder 的 prefill 必须等 encoder 输出就绪才能开始(image token 的 embedding 来自它)。系统层面能做的是把 encoder 与其他请求的 decoder 计算重叠,而不是缩短它。
4. 几张图与一张图
多图请求(或多轮对话里累积的图)按 token 数线性叠加:Qwen2-VL 里 4 张 1024² 的图是 5476 个 image token,KV 在 70B 规格下 1.7 GiB。这比大多数文本对话的上下文都长。对推理系统而言,多模态请求的 KV 需求方差远大于纯文本请求——纯文本的 prompt 长度分布相对集中,而一个 VLM 服务会同时收到 144 token 的缩略图和 16384 token 的 4K 截图。
五、另一条路线:cross-attention 注入
1. 图片特征不进 decoder 序列
前面四个模型里有三个把 image token 直接塞进 decoder 的输入序列(decoder-only 注入,也叫 early fusion 或 LLaVA 式)。Llama 3.2 Vision 走了 Flamingo(Alayrac 等 2022)的路线:图片特征不进序列,decoder 的某几层增加一个 cross-attention 子层,让文本 token 作为 query 去读图片特征作为 key / value。
Llama-3.2-11B-Vision 的 config.json 给出的结构:
- decoder 共 40 层,其中 8 层是 cross-attention 层(第 3、8、13、…、38 层),32 层是原 Llama-3.1-8B 的自注意力层;
- vision encoder 输出 6404 × 7680 维(把 5 个中间层与最后一层的 1280 维拼接),线性投影到 4096 维;
- cross-attention 的 K、V 由 6404 个图片向量算出,Q 由文本 token 算出;K、V 头数与自注意力相同(8 个 KV 头 × 128)。
2. 两种注入的成本对照
| decoder-only 注入 | cross-attention 注入 | |
|---|---|---|
| 图片进入哪里 | decoder 输入序列,与文本 token 并列 | 8 个 cross-attention 层的 K / V |
| 序列长度 | 文本 + \(n_{img}\) | 只有文本 |
| prefill FLOPs(图片部分) | \(2 N_{gemm} \cdot n_{img}\):6400 token × 7.5B = 96 T | 图片一次性:8 层 K、V 投影各 \(2 d\, d_{kv} n_{img}\) 共 0.9 T,加 7680 → 4096 的视觉投影 0.4 T,约 1.3 T。另有随文本长度走的一项:每个文本 token 在这 8 层里多做 Q、O 投影、对 6400 个图片 K/V 的 attention 和一个 FFN,约 \(2 \times 218\text{M} + 4 \times 6400 \times 4096 \approx 0.54\) GFLOPs/文本 token——文本 2K 时约 1.1 T |
| 图片 KV | 全部 32 层自注意力:6400 × 128 KiB = 800 MiB | 8 层 cross-attention:6400 × 2 × 8 × 128 × 2 B × 8 = 200 MiB(GQA 8 个 KV 头) |
| 每步 decode 多读的字节 | 多读 6400 token 的 KV(800 MiB) | 多读 cross-attention 的 K / V(200 MiB) |
| 图片 KV 随文本增长? | 否,但文本 KV 与图片 KV 在同一份 cache 里 | 否,且与文本 KV 分离,形状固定 |
| 参数量 | 不变 | 每个 cross-attention 层不只 4 个投影:Q、O 各 \(d^2\),K、V 各 \(d\,d_{kv}\)(GQA),再加一个完整的 SwiGLU FFN(\(3 d\, d_{ff}\))与两个 gate,合计约 218M/层,8 层 1.75 B(11B ≈ 8.0B + 0.9B ViT + 1.75B cross 层 + 31M 投影) |
| 结构改动 | decoder 完全不变,任何 LLM 都能接 | decoder 加层,权重要重训 |
cross-attention 用参数换序列长度:多了 1.75 B 参数(这 8 层对每个文本 token 也要算,所以文本侧每 token 多约 20% 的算量),换来 decoder 序列不被图片撑长、图片 KV 减到四分之一。哪边总成本低取决于文本有多长、图片有几张、生成多少 token——不是”cross 一定省”。代价是 decoder 不再是”标准的 Llama”——推理引擎要为它单独实现 cross-attention 的 KV 管理(图片 KV 的形状与文本 KV 不同,不能放进同一套分页),训练框架也要处理两种 attention 的并行切分。这是它在开源社区里不如 decoder-only 注入流行的工程原因;Llama 4 已经改回 early fusion。
六、位置编码:从一维到三维
1. 问题
RoPE(第七篇)给每个 token 一个一维位置 \(m\),把 \(d_{head}\) 维的 q、k 分成 \(d_{head}/2\) 对,每对以不同频率旋转 \(m \theta_i\)。image token 排成一行之后当然可以沿用一维位置:LLaVA 就是这么做的,576 个 token 从左到右编号。但这丢掉了二维信息——第 24 个 token 与第 25 个 token 在图上是”这一行的末尾”与”下一行的开头”,一维位置只差 1;而第 1 个与第 25 个在图上是上下相邻,一维位置差 24。
2. M-RoPE:把 head_dim 分给三个轴
Qwen2-VL 的 M-RoPE(Multimodal RoPE)把 \(d_{head} = 128\) 的 64 对旋转维度分成三段(mrope_section: [16, 24, 24]):16 对用时间位置 \(t\) 旋转,24 对用高度位置 \(h\),24 对用宽度位置 \(w\)。每个 token 的位置不再是一个数,而是三元组 \((t, h, w)\):
| token 类型 | \((t, h, w)\) |
|---|---|
| 文本 token(第 \(m\) 个) | \((m, m, m)\)——三个分量相同,退化为普通一维 RoPE |
| 图片第 \((i, j)\) 个 token(图片起始位置 \(s\)) | \((s, s + i, s + j)\) |
| 视频第 \(f\) 帧第 \((i, j)\) 个 token | \((s + f, s + i, s + j)\) |
| 图片之后的文本 | 从 \(s + \max(H_{tok}, W_{tok})\) 继续,而不是 \(s + n_{img}\) |
最后一行是 M-RoPE 对长上下文的一个副作用:一张 37 × 37 = 1369 个 token 的图,只让位置编号前进 37 而不是 1369。图片在位置空间里占的”长度”是它的边长,不是它的面积。这对第七篇讨论的 RoPE 外推范围是个好消息,但不改变 KV cache 的账——KV 仍然是 1369 份。位置编码决定 attention 怎么”看”,不决定要”存”多少。
对 kernel 的含义:M-RoPE 的实现是三组不同的 \(\cos / \sin\) 表按维度段拼接后做一次普通 RoPE 旋转,计算量与一维 RoPE 相同;但 position id 从一个 [seq] 向量变成 [3, seq],推理引擎的 position 管理要随之改动。
3. ViT 内部的 2D RoPE
encoder 自己也要位置编码。CLIP ViT 用的是可学习的绝对位置 embedding,固定 577 个位置,这也是它只能处理固定分辨率的原因之一(换分辨率要插值位置表)。Qwen2-VL 的 ViT 改用 2D RoPE:\(d_{head}\) 的一半用 patch 的行号旋转、一半用列号旋转,位置与分辨率无关,这是原生动态分辨率的前提。
七、视频与音频
1. 视频:帧数乘上每帧 token
视频是一串图片。Qwen2-VL 把相邻两帧在时间维上合并(temporal_patch_size: 2),所以 token 数是”每两帧的图片 token 数 × 帧对数”:
一段 60 秒、1 fps 采样、720p(1280 × 720 → 46 × 26 = 1196 token / 帧对)的视频:30 个帧对 × 1196 = 35880 个 token。在 70B 规格下 KV 是 11 GiB,prefill 5 PFLOP(约 5 秒)。一分钟视频比 128K 上下文的四分之一还长。
所以视频模型的全部工程都围绕”减 token”:降采样帧率(Qwen2-VL 默认 2 fps 但有 max_pixels 限制单帧)、把每帧压到很低的分辨率、在时间维做更激进的合并。它们都是在改公式里的 \(F\) 和 \(H \cdot W / 28^2\),账的形式不变。
2. 音频:Whisper encoder 的 1500 个位置
音频 LLM(Qwen2-Audio 等)用 Whisper 的 encoder 做前端。它的输入是 30 秒音频的 log-mel 谱:16 kHz 采样、10 ms 一帧、3000 帧;两层卷积把时间轴减半到 1500 个位置,然后过 32 层 \(d = 1280\) 的 Transformer(large 版,encoder 约 0.63 B 参数)。
\[n_{audio} = \frac{T_{sec}}{30} \times 1500 = 50\ \text{token / 秒}\]一段 30 秒音频经 encoder 是 2.3 TFLOP、输出 1500 × 1280 × 2 B = 3.7 MiB;如果 connector 不压缩,就是 1500 个 token 进 decoder。实际模型通常再做 2–4 倍的时间维合并(Qwen2-Audio 池化到 25 token / 秒)。音频每秒 12–50 token,介于文本(每秒说话约 3–4 token)与视频(每秒数百到上千)之间。
3. 三种模态的一张表
| 模态 | 单位 | encoder | 每单位 token 数 | 主要调节手段 |
|---|---|---|---|---|
| 文本 | 1000 汉字 | 无(查表) | 约 600–1000 | tokenizer 词表 |
| 图片 | 1024² | ViT,7–12 TFLOP | 576(固定)/ 1369(动态 ÷4)/ 6404(4 tile) | 分辨率、merge 比、tile 数 |
| 视频 | 1 分钟 720p | ViT × 帧对数 | 35880(1 fps) | 帧率、单帧分辨率、时间合并 |
| 音频 | 1 分钟 | Whisper encoder,4.5 TFLOP | 3000(不压缩)/ 1500(Qwen2-Audio) | 时间维池化 |
所有模态最终都归结为同一个数——进入 decoder 的 token 数。decoder 不知道也不关心 token 从哪里来,它的 prefill FLOPs 和 KV 只看这个数。encoder 的差异只影响前置的一次性计算。
八、训练侧的账
1. 冻结 encoder 省的主要是激活,状态只是小头
多模态模型的训练通常分阶段:先冻结 encoder 与 LLM、只训 connector(对齐),再解冻 LLM(指令微调),encoder 是否解冻各家不同(LLaVA-1.5 冻结,Qwen2-VL 在前两个阶段训练 ViT、第三阶段冻结)。用第十一篇的训练状态公式看冻结省了什么:
| 组件 | 参数 | 冻结时的状态 | 解冻时的状态(16 B/参数) |
|---|---|---|---|
| ViT(0.63 B) | 1.26 GB BF16 | 1.26 GB(只有权重) | 10 GB |
| connector(45 M) | 90 MB | — | 0.7 GB |
| LLM(7.6 B) | 15.2 GB | 15.2 GB | 122 GB |
冻结 ViT 省 9 GB 状态,相对 LLM 的 122 GB 只有 7%——状态不是冻结的主要收益。真正省的是激活值:encoder 位于计算图的最前端,它的参数不需要梯度、它前面也没有需要梯度的层,所以反向传播到 connector 就停了,ViT 前向的中间张量一个都不必保存(可以在 torch.no_grad() 下跑)。一张 1024² 图片的 5476 个 patch × 32 层,每层十几个 seq × d 的张量,激活值在 10 GB 量级,比它的训练状态还大;解冻 ViT 意味着这些全部要留到反向。
2. 序列长度的方差
纯文本预训练可以把样本打包成定长序列(第十二篇的 cu_seqlens),每个 micro-batch 的 token 数恒定。多模态样本的 token 数由图片分辨率决定,同一 batch 里 144 与 5476 并存;打包算法要按 token 数而非样本数装箱,否则 GPU 在小图样本上空转。这是 Qwen2-VL 一类原生动态分辨率模型在训练效率上付出的代价,也是 tile 方案(每个 tile 的 token 数固定)在训练时的优势。
3. 图片解码是 CPU 的活
一张 1024² 的 JPEG 解码加 resize 加归一化,在一个 CPU 核上是毫秒级;encoder 在 H100 上处理它也是十毫秒级。文本预训练里数据加载几乎不占 CPU,多模态训练里每张卡每秒要喂几十到几百张图,8 卡机器的 CPU 很容易先于 GPU 饱和。数据管线的形态从”读 token id”变成”解码图片”,这是训练基础设施在多模态上遇到的第一个实际瓶颈,解法(预处理离线化、GPU 解码 nvJPEG、DALI)都是在把这一步搬离 CPU。
九、实践:llm_cost.py 的多模态支持
1. 新增的函数
延续全系列的 llm_cost.py,本篇新增 vision encoder 的参数与 FLOPs、image token 数、image token 在 decoder 中的三个字节数。ModelConfig、param_count、kv_bytes_per_token 沿用第十二篇的定义。
from dataclasses import dataclass
from math import ceil
@dataclass
class VisionConfig:
name: str
layers: int
hidden: int
patch: int = 14
mlp_ratio: int = 4
merge: int = 1 # 空间合并的边长:Qwen2-VL / InternVL 为 2,LLaVA 为 1
tile: int = 0 # 固定 tile 边长(px);0 表示原生动态分辨率
max_tiles: int = 1
cls_token: int = 0
tile_attn: bool = False # True: 各 tile 独立过 encoder(InternVL);False: 多 tile 拼成一个序列做 attention(Llama-3.2)
CLIP_L_336 = VisionConfig("CLIP ViT-L/14-336", 24, 1024, tile=336, cls_token=1)
INTERN_VIT_300M = VisionConfig("InternViT-300M", 24, 1024, merge=2, tile=448, max_tiles=13, tile_attn=True)
QWEN2_VL_VIT = VisionConfig("Qwen2-VL ViT", 32, 1280, merge=2)
LLAMA32_VIT = VisionConfig("Llama-3.2 ViT-H/14", 40, 1280, tile=560, max_tiles=4, cls_token=1)
def vit_params(v):
return v.layers * 12 * v.hidden ** 2 + 3 * v.patch ** 2 * v.hidden
def patches_per_tile(v):
return (v.tile // v.patch) ** 2 + v.cls_token
def image_patches(v, h, w, tiles=1):
"""一张 h×w 图片进入 encoder 的 patch 数。"""
if v.tile:
return tiles * patches_per_tile(v)
f = v.patch * v.merge
return ceil(h / f) * ceil(w / f) * v.merge ** 2
def image_tokens(v, h, w, tiles=1):
"""connector 之后进入 decoder 的 token 数(tile 方案里 CLS 不进 decoder)。"""
if v.tile:
return tiles * ((v.tile // v.patch) ** 2 // v.merge ** 2)
return image_patches(v, h, w) // v.merge ** 2
def vit_flops(v, n_patches, window=0, full_layers=0, tiles=1):
"""一张图的 encoder FLOPs;window>0 时按窗口 attention 计,full_layers 层做全图 attention。
tiles>1 且 v.tile_attn 时各 tile 独立做 attention(二次项按每 tile 求和,而不是全部 patch 平方)。"""
weight = 2 * vit_params(v) * n_patches
if window:
attn = 4 * n_patches * window * v.hidden * (v.layers - full_layers) \
+ 4 * n_patches ** 2 * v.hidden * full_layers
elif tiles > 1 and v.tile_attn:
per_tile = n_patches // tiles
attn = tiles * 4 * per_tile ** 2 * v.hidden * v.layers
else:
attn = 4 * n_patches ** 2 * v.hidden * v.layers
return weight, attn
def image_cost_in_decoder(cfg, n_img, dtype_bytes=2):
"""image token 在 decoder 里的三个数:prefill FLOPs、KV 字节、encoder 输出字节。"""
# 输入 embedding 是查表不算 GEMM;tied 模型那张表兼作 lm_head,仍要算
gemm_params = param_count(cfg)["total"] - (0 if cfg.tie_embeddings else cfg.vocab * cfg.hidden)
return {
"prefill_flops": 2 * gemm_params * n_img,
"kv_bytes": n_img * kv_bytes_per_token(cfg, dtype_bytes),
"encoder_out_bytes": n_img * cfg.hidden * dtype_bytes,
}
if __name__ == "__main__":
MiB = 2 ** 20
for v, (h, w, tiles) in [(CLIP_L_336, (336, 336, 1)), (QWEN2_VL_VIT, (1024, 1024, 1)),
(INTERN_VIT_300M, (1024, 1024, 5)), (LLAMA32_VIT, (1024, 1024, 4))]:
n_p, n_t = image_patches(v, h, w, tiles), image_tokens(v, h, w, tiles)
wf, af = vit_flops(v, n_p, tiles=tiles)
print(f"{v.name:22s} patches {n_p:5d} tokens {n_t:5d} "
f"encoder {(wf + af) / 1e12:5.2f} TFLOP (attn {af / (wf + af):.0%})")
for cfg in (LLAMA3_8B, LLAMA3_70B):
c = image_cost_in_decoder(cfg, n_t)
print(f" {cfg.name:12s} prefill {c['prefill_flops'] / 1e12:6.1f} TFLOP "
f"KV {c['kv_bytes'] / MiB:7.1f} MiB enc-out {c['encoder_out_bytes'] / MiB:5.1f} MiB")
运行输出(节选):
CLIP ViT-L/14-336 patches 577 tokens 576 encoder 0.38 TFLOP (attn 8%)
Llama-3-8B prefill 8.6 TFLOP KV 72.0 MiB enc-out 4.5 MiB
Llama-3-70B prefill 80.1 TFLOP KV 180.0 MiB enc-out 9.0 MiB
Qwen2-VL ViT patches 5476 tokens 1369 encoder 11.81 TFLOP (attn 42%)
Llama-3-8B prefill 20.5 TFLOP KV 171.1 MiB enc-out 10.7 MiB
Llama-3-70B prefill 190.3 TFLOP KV 427.8 MiB enc-out 21.4 MiB
InternViT-300M patches 5120 tokens 1280 encoder 3.61 TFLOP (attn 14%)
Llama-3-8B prefill 19.2 TFLOP KV 160.0 MiB enc-out 10.0 MiB
Llama-3-70B prefill 177.9 TFLOP KV 400.0 MiB enc-out 20.0 MiB
Llama-3.2 ViT-H/14 patches 6404 tokens 6400 encoder 18.48 TFLOP (attn 45%)
Llama-3-8B prefill 96.1 TFLOP KV 800.0 MiB enc-out 50.0 MiB
Llama-3-70B prefill 889.6 TFLOP KV 2000.0 MiB enc-out 100.0 MiB
2. 成本表新增的一列
全系列的成本表在第十二篇完成了三个文本模型的对照。本篇给它加上”一张 1024² 图片”这一行,按三种注入方式放到 Llama-3-8B 规格的 decoder 上:
| 一张 1024² 图片 | LLaVA 式(576) | Qwen2-VL 式(1369) | Llama-3.2 式(6404,cross-attn) |
|---|---|---|---|
| encoder FLOPs | 0.38 T | 11.8 T | 18.5 T |
| encoder 时间下界(H100) | 0.4 ms | 12 ms | 19 ms |
| decoder prefill FLOPs | 8.6 T | 20.5 T | 1.3 T(图片投影)+ 文本每 token 多 0.54 G |
| prefill 时间下界 | 9 ms | 22 ms | 1.3 ms |
| image KV(8B 规格) | 72 MiB | 171 MiB | 200 MiB(8 层 cross-attn,与 decoder 层数无关) |
| encoder 输出 | 4.5 MiB | 10.7 MiB | 50 MiB(7680 维拼接前) |
| 等价于多长的文本 prompt | 576 token | 1369 token | KV 上约 1600 token;序列长度上 0 |
3. 实验设计
有 GPU 时可以验证两件事:
- encoder 时间与 batch 无关:用
transformers加载 Qwen2-VL-7B,只跑visual子模块,输入 1 张与 8 张 1024² 图片,测时间。预期接近线性(compute-bound),与 decode 那种”8 个请求几乎不比 1 个慢”形成对照。 - image token 就是 token:用同一模型对比”1369 个文本 token 的 prompt”与”一张 1024² 图片 + 几个字”的首 token 延迟与
torch.cuda.max_memory_allocated()的增量。预期后者比前者多出的只有 encoder 的 12 ms 与 encoder 输出的 10 MiB;KV 增量相同。
十、本文小结
三笔账与它们的量级(Qwen2-VL 风格、1024² 图片、70B 规格 decoder):
| 项 | 公式 | 数字 | 性质 |
|---|---|---|---|
| encoder FLOPs | \(2 N_{vit} n_p + 4 L_{vit} n_p^2 d_{vit}\) | 11.8 TFLOP | 一次性、compute-bound、与 batch 无关 |
| image token 数 | \((H/28)(W/28)\) | 1369 | 由分辨率与 connector 压缩比决定 |
| encoder 输出 | \(n_{img} \cdot d_{model} \cdot 2\) B | 21 MiB | prefill 后即可释放 |
| prefill FLOPs | \(2 N_{gemm} \cdot n_{img}\) | 190 TFLOP | 与同样长度的文本相同 |
| image KV | \(n_{img} \cdot 2 L n_{kv} d_{head} \cdot 2\) B | 428 MiB | 活到请求结束;是 encoder 输出的 \(2 L n_{kv} d_{head} / d_{model} = 20\) 倍 |
十一、自测
-
Qwen2-VL 处理一张 \(1344 \times 896\) 的文档图与一张 \(224 \times 224\) 的缩略图,各多少 token?
答案
\((1344/28) \times (896/28) = 48 \times 32 = 1536\);\((224/28)^2 = 64\)。相差 24 倍——同一个请求类型,KV 需求差 24 倍。
-
LLaVA-1.5 用 CLIP-L/14-336 加 MLP connector(不压缩),一张图多少 token?换成 2×2 merge 呢?
答案
\((336/14)^2 = 576\);merge 后 144。
-
encoder 输出 1369 个 token、\(d_{model} = 3584\)、BF16:多大?进入一个 \(L = 28\)、\(n_{kv} = 4\)、\(d_{head} = 128\) 的 decoder 后 KV 多大?比值多少?
答案
输出 \(1369 \times 3584 \times 2 = 9.4\) MiB;KV 每 token \(2 \times 28 \times 4 \times 128 \times 2 = 56\) KiB,1369 个 75 MiB;比值 \(2 L n_{kv} d_{head} / d_{model} = 8\)。
-
为什么说 encoder 的算量是”一次性、compute-bound、与 batch 无关”,而 image token 的 KV 是”活到请求结束”?
答案
encoder 对一张图做一次前向就完成,1369 个 patch 一起过 ViT 是大 GEMM、算力饱和,之后输出可以丢;KV 则是 decoder 每生成一个 token 都要读一遍,直到请求结束才释放——占的是并发容量。
-
cross-attention 注入(Llama 3.2 Vision)与序列注入(Qwen2-VL)在 KV 上的差别是什么?
答案
序列注入的 image token 与文本同价,占序列长度与每层 KV;cross-attention 注入把图像特征放在旁路,只在插入的 cross-attn 层读它,不占主序列长度与自注意力的 KV,代价是那些层多一份 K、V 投影和参数。
下一篇
本系列到此为止。紧接着的系列《预训练:从 tokenizer 到训练配方》用同样的方法算这张成本表的训练侧,第五篇是分词与词表:BPE、词表大小与 token 效率。
-
约 1369 个:Qwen2-VL 每 \(14 \times 14\) 像素一个 patch、2×2 merge 后每 \(28 \times 28\) 像素一个 token,\((1024/28)^2 \approx 1369\)。token 数由 connector 的合并比例与图片分辨率决定,与文本长度无关。详见第二章、第三章。 ↩
-
encoder 一次性 11.8 TFLOP、compute-bound、与 batch 无关;connector 几乎不花;decoder 的 prefill 190 TFLOP 与同样长度的文本一样;真正长期占用的是 image token 在 decoder 里的 KV——1369 个 token × 每 token 320 KiB(7B 规格)≈ 428 MiB,活到请求结束。详见第二章、第四章。 ↩
-
两个数在流水线的不同位置。encoder / connector 的输出是 \(n_{img} \times d_{model} \times 2\) 字节 ≈ 21 MiB,prefill 之后就能释放;这些 token 进入 decoder 后每层每个 KV 头都要存一份 K、V,放大 \(2 L n_{kv} d_{head} / d_{model} \approx 20\) 倍。对推理系统的含义:图片请求的 KV 需求由分辨率决定,方差远大于文本。详见第四章。 ↩
系列 《Transformer 与 LLM:结构、实现与算量》 第 13 / 14 篇
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
- Transformer 长什么样——从一句话到下一个 token
- 一个 token 的旅程——训练侧与推理侧
- 手搓 GPT(上)——nanoGPT model.py 逐行解析
- 手搓 GPT(下)——nanoGPT train.py 与训一个会续写的模型
- Transformer 解剖与参数量
- Attention 变体与 KV cache
- 位置编码与长上下文
- MoE 的路由、激活参数量与通信形态
- MTP——改训练目标而不改主干的多 token 预测
- 前向的算量与访存量
- 浮点格式、数值稳定性与混合精度
- 量化、投机解码与 LoRA
- 多模态:vision encoder 的算量与 image token 的 KV 代价
- Transformer 与 LLM:系列总结与通关自测
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/multimodal-vision-encoder-cost-and-image-token-kv.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。