Article 05 收尾的时候引擎跳得很稳。ORCA 把"进出 batch"的边界问题修了;chunked prefill 给每次 iteration 的开销封了顶,长 prompt 没法一个人霸占整间屋子。每次 iteration 都有上限、每个 request 都大致公平,引擎呼吸均匀。
但那一篇文章末尾留了一根线,§7 第二条:
Decode 卡在 weight 读带宽上;prefill chunk 又是 compute-bound。也许它俩根本就不该共用同一组 GPU。
这一篇就是顺着这根线往下扯。我们用 roofline 的角度量一下这条沟到底有多宽,看着它在 context length 增长时越拉越开,最后落到结构性的修法:让两个阶段彻底不共用一台机器。
起点其实有点尴尬:article 05 那套 piggyback chunked prefill 不是 prefill/decode 不匹配的答案,是个妥协。它把心跳抚平了,但底下的事实是 —— 一块 prefill chunk 和一个 decode token 对 GPU 的要求落在完全不同的 regime。共用同一次 forward,只是逼着双方都退到对自己不合适的那种 regime 里。
1. Roofline,一页讲完
每张 GPU 上的每个 kernel,瓶颈都只在两种物理资源之一:
- 算力(compute) —— tensor core 的峰值 FLOPs/s。
- 内存带宽(memory bandwidth) —— HBM 把 bytes 送到 SM 的速率。
(这里讲的是 GPU 内部的带宽,是 HBM 到 tensor core 之间那条管道。GPU 之间的带宽 —— NVLink、InfiniBand —— 是另一条轴线,等 TP/PP 出场我们再讲。)
权重和数据实际放在哪:一张图
光说"内存带宽"很抽象。现代 GPU 有一套 memory hierarchy —— 几层缓存,越往上越小、越快。Tensor core 只能对 register 里的数据做运算,所以每一个 weight 字节、每一个 KV cache 字节,在真正被算到之前都得先沿着这条 hierarchy 走上来。
数字是 H100 风格的;其他 GPU 绝对值不一样,但形状 —— 顶层和底层在容量和速度上差三到四个数量级 —— 是普遍的。
什么东西放在哪:
- HBM 装常驻的东西:模型权重(Llama-2-7B 是 14 GB)、每个 request 的 KV cache、跨 kernel 留存的 activation。容量大,相对慢。
- L2 cache 是几个 SM 共享的一块小 scratch —— 多个 SM 都在读重叠数据时有用,但只有 ~50 MB,远装不下权重或 KV。
- SRAM(per-SM shared memory) 是 kernel 当下正在动的那一块 weight、Q、K 的暂存区。FlashAttention 那套花招的核心就是把 attention score 矩阵压在 SRAM 里,不让它溢到 HBM。
- Register 是 tensor core 真正读 operand 的地方。每个 SM 几百 KB,访问只要一个 cycle。
所以当你看到"kernel 从 HBM 加载了 14 GB 权重"的时候,路径是 HBM → L2 → SRAM → register → tensor core。一层比一层小、一层比一层快。3.35 TB/s 是这条链子最底的那一档 —— 也是一次 transformer iteration 没法绕过的那个瓶颈,因为权重比 HBM 之上每一层都大。
compute-bound 和 bandwidth-bound 在物理上到底是什么
矩阵乘按 tile 工作:从 HBM 把一块 A、一块 B 装到 SRAM 里,在 register 里相乘(每个元素背后是很多 FLOPs)、累加,下一块。同一块 weight tile 在被换出之前,会被多个输出行反复用到。
- compute-bound 是 tensor core 跑满的状态。当前 tile 消耗得足够快,HBM 把下一块送来都来得及。带宽有富余。每个 weight 字节加载一次,被很多次 FLOPs 复用。
- bandwidth-bound 是 HBM 送不上下一块。tensor core 已经把当前的吃完了,干等着字节到。每个字节复用的 FLOPs 太少,分摊不掉这一次加载的开销。
判断你处在哪种 regime 的那个数,恰好就是 每从 HBM 拉出一个 byte,做了多少 FLOPs —— 这就是 intensity。也是为什么 roofline 这条规则没什么回旋余地:它不是经验观察,是上面这套 hierarchy 的直接推论。
Roofline 这条规则
哪种资源是瓶颈,由一个数决定:arithmetic intensity I,FLOPs 数和从 HBM 加载的 bytes 数的比值:
I = FLOPs done / bytes loaded (单位:FLOPs/byte)
硬件这边对应有一个数,叫 ridge point R:
R = peak FLOPs/s / peak HBM bandwidth (单位:FLOPs/byte)
H100 SXM5:fp16 GEMM 持续算力 ~500 TFLOPs/s,HBM3 带宽 3.35 TB/s → R ≈ 150 FLOPs/byte。
规则:
I > R→ compute-bound。算力是瓶颈;带宽有富余。I < R→ bandwidth-bound。字节是瓶颈;tensor core 在等数据。
就这一条。剩下整篇文章其实就是在反复问两个问题:
- 一次 prefill iteration 和一次 decode iteration 的
I各是多少? - context length 增长时
I怎么变?
2. 纸面估算单次 iteration 的开销
先把符号定下来。全文假设 fp16(每个参数 2 byte、cache 里每个数也 2 byte)。换成更低精度的 dtype,数会跟着变,但故事不变。
| 符号 | 含义 | 单位 |
|---|---|---|
Π | 参数总数 | 无量纲 |
K_tok | 一个 context token 在 KV cache 里的字节数(所有层的 K + V 加起来) | bytes/token |
T | 这次 iteration 里的 token 总数 | tokens |
B | 这次 iteration 里在跑的 request 数 | 无量纲 |
L | 每个 request 的平均 context length | tokens |
C | prefill chunk 大小(每个 chunk 的新 token 数) | tokens |
R | 硬件 ridge point | FLOPs/byte |
(K_tok 是把所有层加起来的总和 —— 一个 context token 在整张网络的 KV cache 里要占多少字节,不是每层。)
先点一下:transformer block 里大部分算力和几乎全部参数都在它的 matmul 层 —— QKV projection、attention 的 output projection、FFN 的 up/down projection。Attention 本身(softmax over scores 那一步)和 pointwise 操作(layernorm、GeLU、residual add)只占总 FLOPs 的一小块(除非上下文极长)。所以下面说"每个 token 多少 FLOPs"或者"weight bytes"的时候,意思都是 matmul —— 那才是开销所在。
对一次跑在 Π 大小模型上的 iteration,有两个物理量要追,两个都关于 Π 线性:
- 从 HBM 拉出来的 weight bytes: 每个参数 fp16 = 2 byte,一次 iteration 读一遍 →
2Πbytes。Llama-2-7B(Π = 7B)就是 14 GB。一次 iteration 付一次,跟塞了多少 token 没关系。 - 每个 token 走完整张网络做的 FLOPs: 一个 token 走过一层 matmul,和那层每个参数都做一次 multiply-accumulate(每个参数 2 FLOPs)。把整张网络的 matmul 加起来,每个 token
2ΠFLOPs —— Llama-2-7B 就是 14 GFLOPs/token。一次 iteration 处理T个 token,做2Π · TFLOPs。token 在 matmul 里互不干扰(只在 attention 里互相看到),所以同一次 iteration 里两个 token 的算力是单个 token 的两倍 —— 但 weight 加载只付一次。
加上 KV cache 读取,把两件事一起写下来:
bytes_loaded = 2Π (weights, 一次 iteration 付一次)
+ K_tok · L · B (KV cache, 每个 request 读自己那 L 行)
FLOPs_done = 2Π · T (T = 这次 iteration 里的 token 数)
塞进 intensity 的定义里:
I = 2Π · T / (2Π + K_tok · L · B)
盯着这条公式看一会儿 —— 这一节余下的内容就是把它读仔细。分母两项、分子一项;按顺序走一遍,整个 prefill/decode 故事就出来了。
第一步:先假装 KV 这一项是零
在 L 极短、或者一段对话刚开始还没什么 context 的时候,分母由 2Π 主导,公式塌成:
I ≈ T
intensity 就是 共享同一次 weight 加载的 token 数。prefill 和 decode 在这里就分了岔:
- Prefill iteration:
T = C = 2048个 token →I ≈ 2000→ 远高于现代 ridge point(~150) → compute-bound。 - Decode iteration:
T = B(同时在 decode 的 request 数,一般几十到一百多)→I ≈ B→ 远低于 ridge → bandwidth-bound。
同一张 GPU、同一个模型、同一个 kernel。唯一区别是这次 iteration 装了多少 token。Prefill 把一次 weight 加载分摊到几千个 token 上;decode 分摊到 B 个上。从第一次 iteration 起,它们就坐在 ridge 的两边 —— 而且差距不小:intensity 上至少差一个数量级。
凭直觉想到的修法是把 decode 的 batch 推得更大 —— 把 B 推到 intensity 越过 ridge 为止。要清掉 R = 150,得 B ≥ 150。下一步说为什么这条路走不通。
第二步:把 KV 那一项打开
context 一长,K_tok · L · B 就开始往分母里加。两项相等的位置(crossover):
L · B = 2Π / K_tok
Llama-2-7B(Π = 7B、K_tok ≈ 512 KB)下,L · B ≈ 27 k。decode batch B = 32 的话,crossover 落在 L ≈ 850 token。
850 这个数,放到今天的标准里小到吓人,值得停一下。生产环境里的 prompt 现在动不动就是几万 token:超长的 system prompt 和工具定义、RAG 灌进来的文档、累积的多轮对话、agentic chain 那种 input/output ratio 经常 100:1 起步的工作流。前沿模型出 200 k – 2 M 的 context window,是因为真实的 workload 真的会塞满。所以"过了 crossover"根本不是 corner case,而是中位 request。
过了 crossover,公式向另一个方向化简:
I ≈ 2Π · T / (K_tok · L · B)
这里的约分关系开始决定命运:
- Decode(
T = B):I ≈ 2Π / (K_tok · L)。B上下消掉 —— *在长 context 下,把 decode 的 batch 推大不再能提升 intensity。*多收的 request 只是按比例多付 KV 读带宽。再加上 KV 内存预算,B还涨不太大就先把卡撑爆。所以"把 batch 推大"这一招在第一步本来就走不通,到了第二步又会再撞一次墙。 - Prefill(
T = C):I ≈ 2Π · C / (K_tok · L · B)。没东西约掉 ——C老老实实留在分子里。Prefill 一直 compute-bound,到夸张的 context 长度都还守得住。
同一条公式,两件事
- Prefill 是 compute-bound、decode 是 bandwidth-bound。 在 context 接近零的时候就成立,完全由"同一次 weight 加载分摊到几个 token"决定。两个阶段从一开始就坐在 ridge 的两边。
- 长 context 把这条沟拉得更宽。 第二项带宽成本(KV 读)从分母里冒出来,过了 crossover 就主导(生产流量基本都过 crossover)。受影响的主要是 decode 这边,prefill 几乎没事。
§3 用 Llama-2-7B 上的具体数字把这两件事坐实。
3. 一个模型、两个阶段、两张表
把公式落到地面上:在一个具体模型 + 一张具体 GPU 上,扫一遍 L。
Llama-2-7B(MHA、32 层、32 head、head_dim 128、fp16)on H100:
- weight bytes
2Π = 14 GB K_tok = 2 (K,V) · 32 层 · 32 head · 128 head_dim · 2 byte ≈ 512 KB/token- ridge
R ≈ 150 FLOPs/byte
Decode at B = 32
L | weight bytes | KV bytes | total | I = 2Π·B / total | regime |
|---|---|---|---|---|---|
| 1 k | 14 GB | 16 GB | 30 GB | ~15 | bandwidth-bound(weights ≈ KV) |
| 4 k | 14 GB | 64 GB | 78 GB | ~5.7 | bandwidth-bound(KV 主导) |
| 16 k | 14 GB | 256 GB | 270 GB | ~1.7 | 严重 bandwidth-bound |
| 64 k | 14 GB | 1.0 TB | 1.0 TB | ~0.4 | cache 在一张 H100 上装不下 |
(分子 2Π · B = 448 GFLOPs —— 钉死的。是分母在炸。)
注意几件事:
- Intensity 跌得很快。 从 L=1k 的 ~15 跌到 L=64k 的 ~0.4 —— 单一个 context 维度上就掉了一个数量级以上。
- 内存预算先于带宽爆。 L=16k、B=32 时单 KV 就 256 GB,远超 H100 的 80 GB。PagedAttention 之所以存在,一部分就是为了管这件事;
B在长 context 下被迫往下压,结果 intensity 又被进一步拖坏。(Llama-2-7B 用的是 MHA;现代 GQA/MLA 把K_tok砍掉 4–8 倍,主要就是为了把这堵墙往后推。) - 主导的字节种类会变。 短 L 时 weight 主导,长 L 时 KV 主导。两边都是 bandwidth-bound,但解法不同 —— batch 推大对 weight 带宽有用;GQA/MLA/FlashDecoding 对 KV 带宽有用。
Prefill at C = 2048
Chunked prefill 拿 C 个新 token 跑,面对一段长度 S 的前缀(所以 T = C 个 token 的算力,读 S 个 token 的 cache KV):
I_prefill = 2Π · C / (2Π + K_tok · S)
分子里挂着 C —— 每个加载的字节都被分摊到几千个 token 的算力上。
前缀 S | weight bytes | KV bytes | total | I | regime |
|---|---|---|---|---|---|
| 4 k | 14 GB | 2 GB | 16 GB | ~1800 | compute-bound(高出 ridge ×12) |
| 64 k | 14 GB | 32 GB | 46 GB | ~620 | compute-bound(×4) |
| 256 k | 14 GB | 128 GB | 142 GB | ~200 | 还是 compute-bound(×1.3) |
| 1 M | 14 GB | 512 GB | 526 GB | ~55 | 终于跌到 ridge 之下 —— 但已经 100 万 token 了 |
Prefill 能一直 compute-bound 撑到极端的 context 长度。哪怕真的跌到 ridge 下面,也远远没到 decode 在常见 context 长度下那种 bandwidth-bound 的程度。
asymmetry 一句话说清楚:
Prefill 把每一字节带宽分摊到
C ≈ 2000个 token 上;decode 是每个 request 一个 token。长 context 把刀子往 decode 这边拧,prefill 几乎没动。
同一个模型,同一张 GPU。两个阶段。两条完全不同的命运曲线。
4. 为什么一台引擎没法把两边都伺候好
把 article 05 那台引擎 —— continuous batching、chunked prefill、piggyback iteration —— 拿过来问一句:你怎么 sizing?
- 按 prefill sizing: 给 GPU 选高 FLOPs 的型号。Decode 跑在一种 ~90% 算力天生用不上的硬件上,因为它是 bandwidth-bound。你为 decode 物理上用不到的 tensor core 付钱。
- 按 decode sizing: 选少一点、按 HBM 带宽和容量来挑的 GPU。Prefill 跑在缺 FLOPs 的机器上,时间被拖长。TTFT 上去。
- 混跑: 每次 iteration 把 prefill chunk 和 decode token 装在一起。TBT 被这次 iteration 里 prefill chunk 抢走的那点算力扣下当人质。Chunked prefill 给这条卡了上限 —— 那就是 article 05 的全部目的 —— 但这个上限不是免费的。共用一台引擎的一次 decode iteration,要为
C行根本对自己没用的 prefill 算力买单。
更深一层:**workload 的瓶颈 profile 是双峰的,引擎是单峰的。**没有一种 sizing、没有一种 parallelism 策略、没有一种 batch policy,能同时把两个阶段都伺候好。两个阶段拉满的是不同的物理资源、追的是不同的 SLO(TTFT vs TBT),一个 scheduler、一个旋钮,没法在两种 regime 下同时满足两个 SLO。
那就别拼了。建两个 pool。
5. 拆开
一个 request 的生命周期中间多了一跳:
- Prefill pool 收下 prompt,对全部
L_p个 token 跑 chunked prefill,产出整个 request 的 KV cache 加上第一个生成 token。 - KV cache 传输 把这
L_p · K_tokbyte 从 prefill GPU 内存搬到 decode GPU 内存。 - Decode pool 接到 KV cache,把 request 塞进自己的 continuous-batching 池子,一直 decode 到 EOS,把 token 流式吐回给用户。
两个 pool、两套调度、两个 SLO 目标。妥协没了。每个 pool 现在可以针对一个单一目标自由地选 parallelism、batch policy、硬件搭配、scheduling 纪律。这份自由就是最大的那块收益 —— 各自具体怎么用它,留给系列后面的文章。
新成本是中间这一跳。我们在 §6 给它定价。
6. 新成本:KV cache 传输
拆开两台引擎之后,每个 request 都要把 KV cache 从一边往另一边搬一次。这是真成本,先估个数。
Llama-2-7B(K_tok ≈ 512 KB)一段 4 k-token 的 prompt:
每个 request 的 KV bytes = L_p · K_tok = 4096 · 512 KB ≈ 2 GB
每个 request 2 GB。每秒几百个 request(不算高的生产负载)的话,两个 pool 之间的总 east-west 流量轻松能到几百 GB/s。中间那条 fabric 得吃得下这个量。
fabric 长什么样、一次传输要多久:
| Fabric | 带宽 | 传 2 GB 要多久 |
|---|---|---|
| NVLink(节点内) | ~900 GB/s | ~2 ms |
| NVLink-network / NVSwitch fabric(集群内) | ~400 GB/s | ~5 ms |
| InfiniBand HDR(跨节点) | ~50 GB/s | ~40 ms |
| PCIe Gen5(host 中转) | ~64 GB/s | ~30 ms |
所以两个 pool 同在一个 NVLink domain 里的话,这一跳几乎免费;隔了 IB 的话,是真的税。40 ms 加在 TTFT 上能感觉到,5 ms 是无所谓。
由此马上冒出几个工程旋钮(每一个都够独立成篇 —— 这里我们只点出来,不解决):
- Layer-streaming overlap. 别等 prefill 全跑完再开始传。每一层的 K、V 是按顺序产出的;后面层还在算的时候,前面层的 KV 就已经能往那边发了。做得好的话,传输几乎完全藏在 prefill 算力背后。
- GPUDirect RDMA. 字节直接在两块 GPU 的 HBM 之间走,不绕 CPU 内存。省掉一次拷贝、一次 context switch。
- 拓扑感知调度. 把同一个 request 的 prefill 和 decode 排到拓扑上靠近的 pool —— 同机架、同 NVLink domain —— 把 fabric 那一档压低。
- 前缀复用. 两个 request 共享一段长前缀的话,只需要算和传 suffix 那段的 KV。生产系统(Mooncake 是个写得比较细的例子)把这件事做成了内存层级的问题:热前缀在 HBM、温前缀在 DRAM、冷前缀在 SSD。
- GQA / MLA 直接砍单价. 把
K_tok砍掉 4–8 倍,传输也跟着砍 4–8 倍。一般不把它叫做拆机优化,但实际上是。
每一条底下都能再开一篇文章。这一节的 takeaway 就是:这次传输是拆机的代价,但是付得起的 —— 有上限、能工程化、相对于 TTFT/TBT 上的收益来说很小。
用户感受:
- TTFT = prefill time + transfer time + 第一次 decode iteration。transfer 是真的在里面,但量级是几 ms 到几十 ms。
- TBT = 纯 decode,不会被 prefill 抢算力。decode pool 的每次 iteration 都只装 decode 工作,所以 TBT 平稳到 decode 硬件能给到的极限。
这桩交易就是想要的那种:TTFT 上一次性吃个小亏,换取整段生成里 TBT 又稳又可预测。用户对 TBT 的感受比对 TTFT 重得多 —— TTFT 是一次顿挫,TBT 是每一次顿挫。
7. 之后还有哪些新问题
Article 05 是给 iteration 封顶。Article 06 是把它拆开。§2 那条公式逼着我们答了"为什么要拆";这一篇大部分篇幅都在做这件事。"怎么让它真跑起来"是另一个问题,大部分实际工程量都落在这一边 —— §6 应该读成一扇门、不是终点站 —— 它只是更大一片工程面里露出水面的那一截。
在那扇门口站一会儿。两块 GPU,可能在不同机架、可能挂在不同的内存层级下,要在能藏在 prefill 延迟里的时间内,把以 GB 计的状态搬过去。这条管道里每一个选择背后都有一片很实在的设计空间:
- 字节走哪条 fabric —— NVLink 还是 NVSwitch 还是 InfiniBand 还是 PCIe —— 单次传输的成本能差出近两个数量级(§6 的表格)。你建出来的集群拓扑长什么样,全看这道选择。
- request 之间 KV cache 住在哪里 —— HBM 还是 DRAM 还是 SSD —— 让拆机引擎变成了一套分层的内存系统。Mooncake 那套前缀池是其中一种;还有别的实现,invalidation 和 locality 行为各不相同。
- 传输怎么和算力 overlap —— layer-by-layer streaming、GPUDirect RDMA、双缓冲队列 —— 这些是让一跳"端到端看不见"还是"在 TTFT 里非常显眼"的分水岭。
- request 怎么在两边 pool 之间路由 —— fabric 局部性感知调度、前缀缓存命中、decode 容量追踪 —— 在 article 05 的所有调度问题之上又叠了一层。
每一条都能独立成篇,系列下一篇要接的就是这根线 —— 跑一套拆机 serving 系统的工程问题。然后 我们才能干净地问下一组问题:拆机让我们终于能干净地问的那些优化问题。每个 pool 现在有了专门化的自由,它想要什么?Pipeline parallelism 给 prefill、Tensor parallelism 给 decode、PagedAttention、GQA/MLA、FlashDecoding、speculative decoding —— 每一项在 pool 拆开之后都有了一个干净的位置,我们再按顺序一个一个走过。
每次都是同一种语法:找出瓶颈、把 workload 切到每块只看见绑住自己那一种瓶颈的程度、按块去优化。拆机是这种切法里最大的一刀。系列接下来要做的,是这一刀切出来之后该做的工程和优化。