BCG:为什么 prefill 不该整图静态化?
BCG(Breakable CUDA Graph),一种在捕获期显式打断的 CUDA Graph 捕获方式,用于加速 LLM 推理的 prefill 阶段,也是 SGLang 在 CUDA 平台上 prefill 的默认图捕获策略PR-29458。它把「哪些算子必须留在图外」的决定权从编译器手里拿回引擎手里:只让少数不兼容算子退回 eager,就保住大部分启动开销收益。这篇专题沿着一条论证链走到底,先立发射瓶颈,再拆整图捕获的代价,吃透 BCG 的机制与实测,最后用 Amdahl 定律收成一个判断框架。核心结论一句话:prefill 的静态化边界,应该由系统主动选择,而不是把动态性赶尽杀绝。
1. prefill 计算量这么大,为什么延迟曲线却是平的?
直觉上,prefill 的计算量应该随输入长度线性上涨:prompt 越长,要做的矩阵乘越多。可 SGLang 团队在 2026-08-17 的官方博客里贴出的实测曲线,恰恰违背了这个直觉。而这条曲线的形状本身就是答案:输入长度翻了 32 倍,延迟却纹丝不动,说明 prefill 慢的根源不在 GPU 算得慢,而在 CPU 把 kernel 逐个「发射」到 GPU 的启动开销。这是发射瓶颈的签名,不是计算瓶颈的签名。先看数据。
下面的数值出自上述博客在同一硬件上的隔离 prefill 测试(gpt-oss-120b、TP4、4×GB300、一次一个请求、固定输入长度加单输出 token、每个方案都禁用 decode 图):
| 捕获策略 | 输入长度 64 → 2048 tokens(32 倍跨度)内的延迟 |
|---|---|
| eager 基线(no graph) | 近乎平坦,整段落在 0–160 ms 区间 |
| torch.compile piecewise | 近乎平坦 |
| BCG | 近乎平坦 |
| full capture | 近乎平坦 |
横轴把输入长度从 64 扫到 2048 个 token,跨度 32 倍;纵轴是 prefill 延迟,约 0 到 160 ms。四条曲线全部近乎水平。博客原话说得很直接:32 倍长度范围内每条曲线都是平的,这是发射开销的签名,而不是计算量的签名。输入长了 32 倍,延迟纹丝不动,说明 GPU 大部分时间在等 CPU 把 kernel 一个一个「发射」过去(launch,指 CPU 逐 kernel 递交执行的开销),而不是在等 GPU 算完。
这份「平」给出的答案就是发射瓶颈,而且它不是 SGLang 一家的问题。arXiv 2603.22774(IISWC 2026)在 vLLM 上测量多 GPU serving 时发现:CPU 核供给不足会引发延迟发射、集合通信停顿和 tokenize 争抢,GPU 大面积空转,而且这些瓶颈在开启 CUDA Graphs 的优化栈里依然存在CPU-Launch。
既然瓶颈在发射,把发射次数变少就有效,这正是 CUDA Graph 的用武之地:把一串 kernel launch 定义成一张图,定义与执行分离,发射准备在实例化时一次性完成,之后可以极低开销反复启动(capture 就是记录一次,replay 就是反复启动,图内地址固定)CUDA-Graphs。那这条思路在 prefill 上为什么难走?回答这个问题只需要两点背景:引擎是谁,以及 prefill 的形状。
BCG 所在的引擎是 SGLang,一个把「前端语言 + 后端运行时」分开的 serving 系统:用户用前端语言写推理程序,解释器逐条执行这些原语,把真正的计算交给优化运行时。图捕获这类性能工程量,发生在运行时这一侧,而不是语言层。

图注:SGLang 的上层结构。左侧是语言程序与推理原语,中间的"解释器"(interpreter)逐条执行它们,再交给右侧的优化运行时(optimized runtime)。本篇要讲的 prefill 图捕获策略发生在运行时这一层。SGLang
难点在 prefill 的形状。DistServe(OSDI 2024)很早就把两阶段的性质分开:当用户 prompt 不短时,prefill 趋于计算密集,decode 则被 GPU 显存带宽约束,两者各有独立的延迟指标(TTFT 与 TPOT)DistServe。但计算密集不等于图捕获容易:prefill 的麻烦不在算得慢,而在形状一直在变。prefill 的 batch 在两个维度上同时变化:token 总数,以及这些 token 所属的请求数;注意力后端还依赖运行时才知道的 metadata。而 CUDA Graph 要求捕获那一刻的形状与内存地址在生命周期内固定。相比之下 decode 里每个请求只贡献一个 token,主形状变量只有一个 batch size,早就能被一组捕获好的 batch 桶覆盖。所以 decode 阶段的图捕获经验,不能直接平移到 prefill:prefill 的曲线再平,也平不出 decode 那样的固定形状。
到这里,第一块多米诺骨牌立住了:prefill 的瓶颈大头在 CPU 把 kernel 逐个递给 GPU 的发射开销,不在 GPU 算得快慢。所以把发射次数变少的图捕获值得做,而后面所有方案的成败,都系于一个问题上:有多少算子能进图。
2. 整图捕获的静态性,是用什么换来的?
既然发射是瓶颈,「把整个 prefill 都抓进图里」看起来是最彻底的解法,这就是 full capture(整图捕获)。但它面对的约束很硬:图内存地址在生命周期内固定,形状拓扑一变就得重新实例化。要让两维同时变化的 prefill 满足这份静态,唯一的办法是把动态性「钉死」。
token 这一维用桶来钉。live batch 被 padding 到最近的已捕获 token 数,思路和 decode 的 batch 桶一样。请求这一维用固定数量的请求槽来钉:live 请求占前几个槽,没被用到的槽改写成零长度 sentinel(zero-length sentinel,即序列长度为零的占位请求);请求数超过槽数就整批回退到 eager。
这两维的代价严重不对称,这是理解 full capture 贵在哪的关键:
| 维度 | 固定方式 | 代价 |
|---|---|---|
| token 维 | 桶 padding | 贵:padded token 是真实计算行,穿过稠密投影的 GEMM 照常付费。MoE routing、attention 等内核可以跳过大部分 padding 区,但稠密计算逃不掉这些行 |
| 请求维 | 固定请求槽 + 零长度 sentinel | 便宜:FlashAttention 按各序列实际长度调度工作,零长度请求几乎没有注意力工作,只加 metadata 和少量调度开销 |
除此之外,attention 的 metadata 每次回放前都要在图外重建,目前主要工作都挂在 FlashAttention(fa4)和 FlashInfer 两个注意力后端上,full prefill capture 至今仍是实验特性,需要显式开启,引擎会警告并建议生产环境走 breakable 或 tc_piecewiseSGLang-BCG。「默认值」本身也是一份兼容性承诺,需要持续维护。BCG 转默认的 PR(#29458,2026-07-02 合入)就是现成的例子:CI 上 H100/H200 出现 CUDA OOM(一次试图分配 8.01 GiB),B200/H200 的 GSM8K 得分跌破阈值,PR 顺势新增了回退参数(如 --cuda-graph-backend-prefill=tc_piecewise)和一批禁用规则。要注意这是转默认过程的记录,不是 BCG 的既定缺陷,但它说明换一个默认后端,要同时动 runner、失败提示、服务器参数与量化测试,默认值本身就是需要维护的兼容性承诺PR-29458。
所以整图捕获不是做不到,而是把动态性硬「钉死」:靠真计算的 padding 行、固定请求槽和特定注意力后端的元数据重建。这是一条处处要还账的路。有没有办法不付这份代价,也能把发射次数降下来?这就是 BCG 要回答的问题。
3. 只让少数算子退回 eager,凭什么保住大部分收益?
BCG 的答案不是消灭动态性,而是改变捕获边界本身。
捕获进行中,一旦遇到被 @eager_on_graph 装饰器标记的不兼容函数,当前捕获段就结束:函数以 eager 方式正常执行,之后在函数返回处开一个新段继续捕获。产出的是「由 eager 区域分隔的 CUDA Graph 段序列」。断点由人在捕获期显式放置,而不是编译器先把整个 forward trace 完再分割。eager 在这里的意思就是逐算子即时执行、不进图,我们把它想成图段之间的一小块「自由区」即可。
跨断点的数据流靠 persistent boundary buffer(固定地址边界缓冲区)接续:捕获期标记函数在段间运行一次,返回的 tensor 被保留为固定设备地址的持久缓冲;后一段图段针对这个地址捕获。每次回放时 eager 函数正常运行并返回新 tensor,BCG 把新输出拷入保留缓冲,下一段图段从最初捕获的固定地址读到更新后的值。BCG 从不检查也不 trace eager 区域内部,它们只需正确执行SGLang-BCG。整条数据流是这样接起来的:
图注:跨断点的数据流。捕获期:图段 1 在显式断点处结束,eager 函数在段间运行一次,输出被保留为固定设备地址的边界缓冲区,图段 2 针对该地址捕获。回放期:每次 eager 函数正常运行,新输出拷入保留缓冲,图段 2 从最初捕获的固定地址读到更新后的值,数据流不断开。无断点时整块退化为一个标准 CUDA Graph 段。
落到 API 层,SGLang 官方文档给出了捕获与回放两段流程。捕获:结束当前段 → eager 执行标记函数 → 记录函数供回放 → 开新段。回放:对每个段 i,先启动图段 i,再 eager 运行对应的非图函数,最后启动末段;非图函数以捕获期同一批张量引用重跑,这些引用指向图的静态输入输出缓冲,所以每次回放读到的是更新后的值。每个断点的精确代价是:一次 cudaGraphLaunch、一次 eager Python 调用、捕获期一对 Begin/EndCapture;断点少时相对被省下的发射开销可以忽略BCG-Docs。实现上,BCG 捕获的是 bs=1 的 transformer 主干,尾部需要活的多请求信息的逻辑(如 logits 处理)留在图外 eager 执行,用 eager 小岛换掉了 full capture 那套请求槽 paddingPR-29458。
和 torch.compile 的 piecewise 相比,两者的区别不在「分段」这个结果,而在断点由谁决定、回放是否还背着编译器。piecewise 是编译器先理解整个 forward,再切成多段;BCG 是捕获发生时引擎直接放置断点。这个区别的代价数字,也就是 Dynamo guard 税,下一章给出,这里先记住结论:BCG 把「哪些算子必须留在图外」的决定权,从编译器手里拿回了引擎手里。
这个机制也不专属 SGLang。Meta 的独立库 meta-pytorch/breakable-cuda-graphs 提供了同构的 breakable_graph 与 @no_graph API,DESIGN.md 明言灵感来自 SGLang PR #19102,说明它已经是可以脱离引擎复用的通用技巧breakable-cuda-graphs。
所以变化的不是动态性的有无,而是捕获边界本身。那么紧接着的问题就是:这样分段出来的东西,和 torch.compile piecewise 在运行时有实质差别吗?
4. 同样的硬件,BCG 为什么比 piecewise 快 17%?
数字来自 SGLang 官方博客(2026-08-17)在同一套硬件上的隔离 prefill 测试:gpt-oss-120b、TP4、4×GB300,固定输入长度加单输出 token、一次一个请求、全部禁用 decode 图。以 eager 为基线,回放加速比如下:
| 方案 | 相对 eager 的回放加速 |
|---|---|
| full capture | 1.93× |
| BCG | 1.70× |
| torch.compile piecewise | 1.45× |
1.70 ÷ 1.45 ≈ 1.17,所以 BCG 回放比 piecewise 快约 17%SGLang-BCG。两者都分段,差距不在分段本身,而在每次回放是否还背着一个编译器的运行时检查。
piecewise 每次回放都要回调进已编译的 callable,先付 Torch Dynamo 的 guard 检查再分发。PyTorch 官方文档说得直白:guard 在每次调用时都会运行,带来每次调用的开销;guard 一旦失败还要把原代码重新 trace 一遍PyTorch-Docs。BCG 直接重放记录好的图段,没有这层税。
构图侧的差距更大。torch.compile 占掉了 prefill 图准备时间的 78% 到 86%,且随模型复杂度增长:一个 235B 规模的 MoE 模型要 90 秒,GLM-5.2 要 158 秒。BCG 去掉编译这一阶段,单次捕获即可到达分段执行,构图快 3.8 到 5.2 倍;代码规模只有 piecewise 的四分之一左右,521 行对 1,771 行SGLang-BCG。
四个数字放在一起,启动速度的差异就有了机制解释:如果你每次换模型、换配置都要先等编译,那就先输在起跑线上。
于是问题来了:既然 full 的加速比最高,为什么不用 full?它的 1.93× 是从哪儿借来的?这是下一章的事。
5. full capture 的峰值,是从哪儿借来的?
full 的 1.93× 是账面上的最高值,但它的原料我们在第二章已经拆过:token padding 的真实计算行、固定请求槽、特定注意力后端的元数据重建。换句话说,峰值是用兼容性与浪费借来的,而这两种东西都有还账的时候。
兼容性的代价在 GLM-5.2 上看得最清楚。SGLang 博客报告,这款模型上只有 BCG 能捕获:piecewise 无法 trace 它的 forward,full capture 没有对应的后端路径,BCG 在它上面达到 eager 的 1.60×SGLang-BCG。模型侧的解释来自 Z.ai 自己的发布博客:GLM-5.2(753B MoE,MIT 协议,1M 上下文)在 DSA 稀疏注意力之上用了 IndexShare,每 4 层共享一个轻量索引器,索引器做的是逐 query、数据依赖的 topk 选择。数据依赖的动态选择没有静态路径,这正是它进不了静态图、只能在断点处 eager 运行的模型侧原因。顺带注意命名:发布博客叫 IndexShare,对应论文叫 IndexCache,是同一机制的两个叫法GLM-5.2。再说清一个事实归属:「索引器在断点处 eager」是 SGLang 博客的断言,Z.ai 只提供了模型机制,两者互相印证但不混为一谈。
| 捕获方案 | GLM-5.2 上能否捕获 |
|---|---|
| piecewise | ✗ 无法 trace 数据依赖的稀疏索引 |
| full capture | ✗ 无对应后端路径 |
| BCG | ✓ 1.60× |
BCG 拿来交换的,是一块可预测的显存账单。它靠三个机制控制图内存:段间共享同一个 CUDA Graph 内存池;断点处用弱引用(借自 vLLM #9724,vLLM 用它共享捕获图的输出缓冲);跨捕获尺寸共用单一最大输出缓冲、按形状切片。边界缓冲本身是唯一例外,它必须存活并在每次回放时就地更新,因为它被后一段图段钉住了地址。结果是 42 个捕获形状在 78 层 MoE 上只增加 2.4 GB 图内存(GLM-5.2)SGLang-BCG。
图内存是常驻的,eager 激活是瞬时的,两者的账要分开算。捕获桶上限没盖到 chunked_prefill_size(引擎按每批 prefill token 共享预算把长 prompt 切块处理的参数)之前,你既加了常驻图、激活峰值又原地不动,总内存反而略高于无图基线;一旦捕获桶覆盖到 chunked size,最大 prefill 回放图的峰值就塌下来SGLang-BCG:
| 模型 | 峰值(桶未覆盖 chunked size) | 峰值(桶已覆盖) | 相对无图基线总量 |
|---|---|---|---|
| gpt-oss-120b | 0.56 GB | 0.001 GB | -0.51 GB |
| GLM-5.2 | 1.55 GB | 0.35 GB | -1.10 GB |
GLM-5.2 那 0.35 GB 是它稀疏注意力索引器仍在断点处 eager 运行的结果。工作负载相关的瞬态尖峰,变成了捕获时确立的固定分配,这种「可预测」本身就是生产收益:排显存时不用再为最坏情况的瞬时峰值留余量。
也要说一句平衡话:BCG 自己转默认时同样付过维护税(第二章的 OOM 与阈值波动),兼容性摩擦没有谁能豁免PR-29458。但机制本身可以迁移,独立库就是例证breakable-cuda-graphs。
所以 BCG 的真实回报不是峰值性能,而是少拿一部分峰值,换兼容性、启动速度与可维护性,外加一块可预测的显存账单。反过来说,eager 小岛是不是可以无限加下去?有没有一个理论说清楚收益的天花板?这正是最后一章的问题。
6. BCG 的收益上限,到底由什么决定?
1967 年,Gene Amdahl 在 AFIPS 春季联合计算机会议上提出了一条至今没被推翻的判断:如果顺序处理部分的速率没有量级相当的提升,把精力花在实现高并行处理率上是浪费的。原文是自然语言论证,没有公式;今天教材里常见的 S=1/((1-p)+p/N) 是后世的形式化转写,不能当作论文原文。他还给出过经验数字:生产运行里数据管理开销约占执行指令的 40%,仅这一项就把吞吐率上限定在顺序处理率的 5 到 7 倍Amdahl-1967。
把它映射到 BCG 场景,注意这里要做一次约定:习惯公式里的 p 指可并行占比,而本篇反过来,把必须退回 eager 的动态/发射占比记作 p,则同一公式写作 S=1/(p+(1-p)/N),上限 S_max=1/p。eager 小岛占得越多,总收益的天花板越低:
| eager 小岛占比 p | 收益上限 S_max = 1/p |
|---|---|
| 0.1 | 10× |
| 0.2 | 5× |
| 0.35 | ≈ 2.9× |
| 0.5 | 2× |
Amdahl 当年估算的「最可能区域」顺序占比约 35%,对应理论上限约 2.9 倍,那是 1967 年对当时生产负载的经验统计,不能当作现代推理的实测参数。但方向与结构是普适的:你每多留一个算子在图外,就是在用整个方案的上限付费。用同样的视角看,SGLang 团队自己也说 BCG 移除的是发射开销,不减少 FLOPs、不会让计算密集的内核变便宜,它在暴露的 launch gap 占执行时间可观比例时收益最大。把 Amdahl 用到 prefill 推理是这篇专题的推演应用,不是 1967 年论文的论述,但骨架是同一副SGLang-BCG。
那这笔账在实际引擎里怎么算?四个可测参数共同决定你是否该用 BCG、断点放哪、放多少:
- launch gap 占比。CPU 发射与协调开销在总延迟里占多少,是真实可测的系统属性。arXiv 2603.22774 用核数扫描和宿主侧延迟分解把它量化过:从最少核(GPUs+1)到 CPU 充裕配置,长序列 TTFT 改善 1.47 到 7.11 倍,全部不增加 GPUCPU-Launch。
- 动态区大小。数据依赖的算子占前向的比例,决定了 p 的下界。GLM-5.2 的稀疏索引器就是活例子。
- 捕获桶覆盖上限。桶没盖到 chunked_prefill_size 之前,常驻图内存是纯支出,上一章的峰值坍塌论证就是这个参数的函数。
- 常驻图显存。图内存是常驻占用、eager 内存是瞬时的,两者此消彼长,超过 GPU 显存预算就不可行。
这四件事因模型、因负载、因显存预算而异,所以单一加速比(1.70× 也好,1.93× 也好)不可照搬。正确的动作是回到自己的机器上,把 p 量出来,再决定边界画在哪里。
把整条链收回来:BCG 不是「一种更快的 CUDA Graph」,而是一套允许你在 launch gap、动态区与显存之间主动划界的机制。静态化的边界由系统主动选择,这正是它和「把动态性赶尽杀绝」的整图捕获之间的根本分歧。用 Amdahl 的 p 去问自己的引擎:那块必须退回 eager 的部分到底占多大?答案决定一切。
参考文献
本专题引用的核心资料如下,建议深入学习时阅读原文:
- Advanced CUDA Graph Techniques in SGLang(LMSYS/SGLang 官方博客,2026-08-17)— https://www.lmsys.org/blog/2026-08-17-advanced-cuda-graph/
- Breakable CUDA Graph — SGLang Documentation — https://docs.sglang.io/advanced_features/breakable_cuda_graph.html
- CUDA C++ Programming Guide §4.2 CUDA Graphs(NVIDIA 官方文档)— https://docs.nvidia.com/cuda/cuda-programming-guide/04-special-topics/cuda-graphs.html
- PyTorch 官方文档:torch.cuda.CUDAGraph / torch.compiler(Dynamo 与 graph break) — https://pytorch.org/docs/stable/cuda.html
- SGLang: Efficient Execution of Structured Language Model Programs — arXiv:2312.07104
- Validity of the Single Processor Approach to Achieving Large Scale Computing Capabilities(Amdahl, AFIPS 1967)— https://safari.ethz.ch/digitaltechnik/spring2021/lib/exe/fetch.php?media=amdahl1967.pdf
- GLM-5.2: Built for Long-Horizon Tasks(Z.ai 官方博客)— https://z.ai/blog/glm-5.2
- meta-pytorch/breakable-cuda-graphs(BCG 独立库官方仓库)— https://github.com/meta-pytorch/breakable-cuda-graphs
- Characterizing CPU-Induced Slowdowns in Multi-GPU LLM Inference — arXiv:2603.22774
- DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving — arXiv:2401.09670
- Enable Breakable Cuda Graph as Default(sgl-project/sglang PR #29458)— https://github.com/sgl-project/sglang/pull/29458