前十二篇讨论的模型只有一种输入: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 生成文字"]

三段各自是什么、为什么需要:

  1. vision encoder(视觉编码器):文字有 tokenizer 把字切成 token,图片没有——像素太多(100 万个),也没有”词”。ViT 的办法是先把图切成 14×14 像素的小方块(patch,相当于图片的”字”),每个方块拉直成一个向量,再用一个独立的 Transformer 让方块之间互相看(attention),输出每个方块的语义向量。这个 encoder 通常先用图文对比学习(CLIP,L0 第二篇提过)单独训好,所以它输出的向量已经”懂图”。
  2. connector(连接层):encoder 输出的向量维度(比如 1280)和 decoder 用的维度(4096)不一样,个数也太多(几千个)——所以要一个小网络把相邻几个合并、再投影到 decoder 的维度。它输出的每个向量就叫一个 image token:从 decoder 的角度看,它和查 embedding 表得到的文字 token 向量没有任何区别。
  3. 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,要经过三段,每段一笔账:

一张图进入多模态 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 层。
\[n_p = \frac{H}{p} \cdot \frac{W}{p}\]

代入: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:

四个 vision encoder 的参数量与 FLOPs
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

三个观察:

  1. encoder 参数量在 0.3–0.8 B,是 decoder 的 4–10%。它的权重字节(BF16 0.6–1.6 GB)在显存账里不是主角。
  2. 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%。
  3. encoder 是 compute-bound 的。一张图的 5476 个 patch 是一个 5476 行的 GEMM,算术强度远超 H100 的 ridge(约 295,见第十篇);不像 decode 那样受带宽限制。一张 1024² 的图在 Qwen2-VL 上的 12 ms 是算力的时间,用更大的 batch 摊不掉。

第三条对系统的含义:encoder 的时间与文本无关、与 batch 无关、一次性。推理引擎可以把它当作独立于 decoder 的一段计算单独调度、单独预算——这是它在系统里被拆成”encoder 预算”的原因,本篇只给出数字,机制不展开。

3. 分辨率的策略

四个模型对”图片多大”的处理不同,直接决定了 \(n_p\):

四个模型的分辨率策略与 patch 数范围
策略 代表 做法 \(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 做两件事:换维度、(可能)减数量。按是否减数量分三类:

三类 connector 的做法、token 数与参数量
类型 代表 做法 \(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)为例:

patchify → 2×2 merge → decoder 序列

三点:

  • 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”的答案是:

一张图等于多少 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):

不同 image token 数下的 prefill FLOPs、KV 与 encoder 输出
\(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-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)\):

M-RoPE 下各类 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 数 × 帧对数”:

\[n_{video} = \frac{F}{2} \cdot \frac{H}{28} \cdot \frac{W}{28}\]

一段 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. 三种模态的一张表

三种模态的每单位 token 数与调节手段
模态 单位 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、第三阶段冻结)。用第十一篇的训练状态公式看冻结省了什么:

冻结与解冻 encoder 时各组件的训练状态
组件 参数 冻结时的状态 解冻时的状态(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² 图片在三种注入方式下的成本
一张 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 时可以验证两件事:

  1. encoder 时间与 batch 无关:用 transformers 加载 Qwen2-VL-7B,只跑 visual 子模块,输入 1 张与 8 张 1024² 图片,测时间。预期接近线性(compute-bound),与 decode 那种”8 个请求几乎不比 1 个慢”形成对照。
  2. 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\) 倍

十一、自测

  1. 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 倍。

  2. LLaVA-1.5 用 CLIP-L/14-336 加 MLP connector(不压缩),一张图多少 token?换成 2×2 merge 呢?

    答案

    \((336/14)^2 = 576\);merge 后 144。

  3. 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\)。

  4. 为什么说 encoder 的算量是”一次性、compute-bound、与 batch 无关”,而 image token 的 KV 是”活到请求结束”?

    答案

    encoder 对一张图做一次前向就完成,1369 个 patch 一起过 ViT 是大 GEMM、算力饱和,之后输出可以丢;KV 则是 decoder 每生成一个 token 都要读一遍,直到请求结束才释放——占的是并发容量。

  5. cross-attention 注入(Llama 3.2 Vision)与序列注入(Qwen2-VL)在 KV 上的差别是什么?

    答案

    序列注入的 image token 与文本同价,占序列长度与每层 KV;cross-attention 注入把图像特征放在旁路,只在插入的 cross-attn 层读它,不占主序列长度与自注意力的 KV,代价是那些层多一份 K、V 投影和参数。

下一篇

本系列到此为止。紧接着的系列《预训练:从 tokenizer 到训练配方》用同样的方法算这张成本表的训练侧,第五篇是分词与词表:BPE、词表大小与 token 效率。

  1. 约 1369 个:Qwen2-VL 每 \(14 \times 14\) 像素一个 patch、2×2 merge 后每 \(28 \times 28\) 像素一个 token,\((1024/28)^2 \approx 1369\)。token 数由 connector 的合并比例与图片分辨率决定,与文本长度无关。详见第二章、第三章。 ↩

  2. encoder 一次性 11.8 TFLOP、compute-bound、与 batch 无关;connector 几乎不花;decoder 的 prefill 190 TFLOP 与同样长度的文本一样;真正长期占用的是 image token 在 decoder 里的 KV——1369 个 token × 每 token 320 KiB(7B 规格)≈ 428 MiB,活到请求结束。详见第二章、第四章。 ↩

  3. 两个数在流水线的不同位置。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 需求由分辨率决定,方差远大于文本。详见第四章。 ↩

这篇对你有用?

本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/multimodal-vision-encoder-cost-and-image-token-kv.html)的前提下,欢迎各种形式的转载、翻译或商业引用。


COMMENTS

评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。

×