06. GPU 与 Roofline:Decode 为什么打不满算力
第 2 篇留下了一个反直觉的结论:Decode 每一步只处理一个 token,计算量很小,却是更慢、更浪费 GPU 的阶段。这一篇从 GPU 的结构讲起,引入算术强度和 Roofline 模型两个工具,把这件事算清楚。这是整个推理优化最重要的一条原理:读完以后,你会发现批处理、量化、投机解码、FlashAttention、P/D 分离这些看似不相干的技术,都在回应同一个问题。
GPU 里有两种资源
把 GPU 想成一个厨房。厨房里有很多厨师(计算单元),每秒能切很多菜;食材放在隔壁的大仓库(显存),由一条传送带运过来。厨房的产出受两个因素限制:厨师切得多快,以及传送带送得多快。
对应到 H100 SXM:
- 计算单元:132 个流式多处理器(SM),每个里面有专门做矩阵乘法的 Tensor Core。BF16 稠密矩阵运算的峰值约 989 万亿次浮点运算每秒(989 TFLOPS)。
- 显存(HBM):80 GB 的高带宽内存,模型权重和 KV Cache 都放在这里。它和计算单元之间的带宽约 3.35 TB/s。
- 片上存储:每个 SM 内部有寄存器和共享内存,全芯片还有约 50 MB 的 L2 缓存。容量很小,但速度比 HBM 快一个数量级以上。
厂商资料里还常见一个约两倍的数字(BF16 约 1979 TFLOPS),那是按“结构化稀疏”计算的理论值,普通推理用不到。估算时要用稠密算力。
一次计算花的时间,至少是下面两者中较大的那个:
text
计算时间 ≥ 浮点运算次数 ÷ 峰值算力
搬运时间 ≥ 需要读写的字节数 ÷ 显存带宽
实际时间 ≥ max(计算时间, 搬运时间)哪一项更大,哪一项就是瓶颈。
算术强度:每搬一个字节,做多少次运算
判断一个任务偏计算还是偏搬运,用算术强度(Arithmetic Intensity):
text
算术强度 = 浮点运算次数 ÷ 访存字节数 (单位:FLOP/Byte)直白地说:每从显存搬来 1 个字节,能顺带做多少次运算。比值高,说明数据搬来后被充分利用,厨师忙得过来;比值低,说明厨师大部分时间在等传送带。
GPU 自己也有一个对应的比值:峰值算力除以带宽。H100 上:
text
989 TFLOPS ÷ 3.35 TB/s ≈ 295 FLOP/Byte这个数叫平衡点。一个任务的算术强度高于 295,算力先被用满,叫计算受限(Compute Bound);低于 295,带宽先被用满,算力闲着,叫访存受限(Memory Bound)。请记住 295 这个数字,下面拿它当标尺。
Roofline 模型
把上面的关系画成图,就是 Roofline 模型。横轴是算术强度,纵轴是能达到的实际算力。
图上的“屋顶”由两段组成。左边是一段斜坡:算术强度越低,能用上的算力越少,被带宽压着。右边是一条水平线:算术强度超过平衡点后,算力封顶。两段的交点就是平衡点。
一个任务落在斜坡上,买再贵的 GPU、算力再高也用不上;只有落在水平线上,才是真正把 GPU 用满了。
把 Prefill 和 Decode 放上去
模型推理的主要工作是矩阵乘法:每一层都要拿输入向量去乘权重矩阵。一个形状为 输入维度 × 输出维度 的 BF16 权重矩阵,读一遍要搬 2 × 输入维度 × 输出维度 字节;用它处理一个 token,每个权重做一次乘加,也就是 2 次浮点运算。下面把输入维度记作 m、输出维度记作 n:
text
处理 1 个 token:运算 2·m·n 次,搬运 2·m·n 字节,算术强度 ≈ 1
处理 B 个 token:运算 2·B·m·n 次,权重仍只搬一次,算术强度 ≈ B同一份权重搬来以后,能服务多少个 token,算术强度就大约是多少。 这一条就能解释两个阶段的差别。
Prefill:一次处理整段输入,比如 2000 个 token,算术强度约 2000,远高于 295,落在水平线上,计算受限。算一下时间下界:8B 模型每个 token 约需 2 × 80 亿 = 160 亿次运算,2000 个 token 共约 32 万亿次,除以 989 TFLOPS,约 32 毫秒。
Decode:每步只处理 1 个 token,却要把全部 16 GB 权重从显存搬一遍。算术强度约 1,比平衡点低了两个多数量级,死死落在斜坡底部。时间下界:
text
搬运:16 GB ÷ 3.35 TB/s ≈ 4.8 毫秒
计算:160 亿次 ÷ 989 TFLOPS ≈ 0.016 毫秒搬运时间是计算时间的约 300 倍。这一步里,GPU 的计算单元有 99% 以上的时间在等数据。单个用户的出字速度上限约为 1 ÷ 4.8 毫秒 ≈ 每秒 208 个 token,无论算力多高都突破不了,除非减少要搬的字节数,或者让一次搬运产出多个 token(第 15 篇的投机解码)。
Decode 慢,不是因为算不过来,而是因为搬不过来。
批处理:一次搬运,服务多人
既然瓶颈在搬权重,自然的办法是让一次搬运服务更多 token。把 64 个用户的 Decode 放在同一步里算:权重仍然只搬一次,却同时为 64 个 token 做计算,算术强度从 1 变成约 64。
| 一步 Decode 的批大小 | 搬权重 | 计算 | 这一步的下界 | 系统吞吐上限 |
|---|---|---|---|---|
| 1 | 4.8 ms | 0.016 ms | 4.8 ms | 约 208 token/s |
| 64 | 4.8 ms | 1.0 ms | 4.8 ms | 约 13,300 token/s |
步长几乎不变,吞吐却提高了 64 倍。这就是批处理在推理里如此重要的原因,也是第 9 篇连续批处理的理论基础。按这个算法,批大小要到 300 左右,Decode 才会接近平衡点。
批处理省不掉的那部分:KV
上面的表忽略了一件事:Decode 每一步还要读 KV Cache。权重是大家共用的,搬一次可以服务所有人;KV Cache 是每人一份,批里有 64 个人,就要读 64 份。
假设这 64 个用户每人的上下文都是 4096 个 token。每人的 KV 是 4096 × 128 KiB = 512 MiB,64 人合计约 34 GB:
text
搬权重 16 GB + 搬 KV 34 GB = 50 GB
50 GB ÷ 3.35 TB/s ≈ 15 毫秒 / 步步长从 4.8 毫秒涨到了 15 毫秒,每个用户的 TPOT 变成原来的 3 倍,系统吞吐约 64 ÷ 15 毫秒 ≈ 每秒 4,270 个 token。批越大、上下文越长,KV 的读取就越占主导。
注意力这部分的算术强度很低,而且不随批大小改善:每个 KV 数值只被它所在组的几个 Query 头使用,强度只和 GQA 的分组大小有关,量级在个位数。所以长上下文的 Decode 即使批很大,仍然是访存受限。这就是为什么 KV 的压缩(GQA、MLA、KV 量化)对长上下文服务特别重要。
动手:改变计算量与搬运量
下面可以调整运算量、搬运字节数、算力和带宽,看看下界由哪一项决定:
计算与搬运的理想下界简化单步稠密模型估算
读取权重 7.00 ms
执行计算 0.14 ms
本轮耗时至少 7.00 ms · 当前偏权重带宽约束
假设整批共享一次权重读取,运算量随序列数线性增加,两项可理想重叠。未计 KV、注意力、通信、启动和调度;有效带宽与算力由你假设。默认 14 GB / 2 TB/s = 7 ms 仅为权重搬运下界,不是实测 token 速度。
上面所有数字都是理论下界。实际系统里,显存带宽通常只能用到标称值的八九成,矩阵运算也达不到峰值;每一步还要启动几百个 GPU 内核,CPU 调度也要时间。真实的 Decode 步长会比下界长,但瓶颈的性质不会变。
这条原理解释了什么
“Decode 卡在搬数据”这一句话,可以推出后面很多章节的出发点:
| 优化 | 它怎样回应这个瓶颈 | 章节 |
|---|---|---|
| 连续批处理 | 让一次权重搬运服务尽可能多的请求 | 第 9 篇 |
| FlashAttention | 减少注意力计算中对显存的反复读写 | 第 12 篇 |
| CUDA Graph | 减少每一步的 CPU 调度开销,让 GPU 不必空等 | 第 12 篇 |
| 权重量化 | 权重从 2 字节压到半字节,搬运量直接减到 1/4 | 第 14 篇 |
| KV 量化、GQA、MLA | 减少每一步要读的 KV 字节数 | 第 4、14 篇 |
| 投机解码 | 利用闲着的算力,让一次搬运验证多个 token | 第 15 篇 |
| P/D 分离 | 计算受限和访存受限的两个阶段分开部署,各自调优 | 第 17 篇 |
以后遇到一项新的推理优化,可以先问:它在攻 TTFT 还是 TPOT?在省显存还是提吞吐?在斜坡上动刀,还是在水平线上动刀?
GPU 的产出同时受算力和显存带宽限制,算术强度决定一个任务受哪一个限制。H100 的平衡点约 295 FLOP/Byte;Prefill 一次处理大量 token,落在计算受限一侧;Decode 每步只处理一个 token 却要搬全部权重,算术强度约 1,严重访存受限。批处理可以摊薄权重的搬运,却摊不薄每人一份的 KV。
下一篇把镜头拉远,沿着一个请求的完整链路走一遍:从 HTTP 请求到达,到分词、调度、采样、流式返回,看看 GPU 之外还有哪些环节会成为瓶颈。
