Skip to content

Piecewise CUDA Graph ​

Piecewise CUDA Graph(PCG)将模型计算拆成多个区域,分别捕获稳定部分,并在图段之间执行动态操作。SGLang 的 TC Piecewise 路径先通过 torch.compile 跟踪模型,再按注册的算子边界切分 FX 图,使 Prefill 中的投影、Norm 和 MLP 能够复用 CUDA Graph。

本文将 PCG 的分段执行原理与 SGLang 的编译器实现分开说明,源码固定到提交 0b3bb0cbe3。底层捕获与重放约束见 CUDA Graph。

1. 部分捕获需求 ​

Prefill 可以将多个请求的新增 token 拼为 [T, D] 的隐藏状态,其中 T 为新增 token 总数,D 为隐藏维度。固定 T 后,普通线性投影与 MLP 的计算形状容易固定;Attention 还需要真实的请求边界、前缀长度和 KV Cache 索引。

部分后端需要根据这些运行时信息规划 Attention。如果整个前向都要求可捕获,一处动态操作就可能使周围的大量稳定计算失去图重放机会。PCG 将该操作单独留在图外:

text
Graph A:Norm → QKV 投影 → RoPE
    ↓
Eager:Attention 后端调用
    ↓
Graph B:O 投影 → 残差/Norm → MLP → 下一层 Norm/QKV/RoPE
    ↓
Eager:下一层 Attention

图段可以跨层,实际边界取决于注册的操作与模型执行路径。Attention 留在图外也不意味着使用 CPU 计算;其 Python 包装函数仍可提交 GPU Kernel。

2. 编译器切图原理 ​

2.1 FX 图与 CUDA Graph ​

两种图处于不同层次:

图表达对象在 TC Piecewise 中的作用
FX Graph张量操作及其调用关系支持识别边界、切分与编译
CUDA GraphGPU 工作与设备依赖支持低开销重复提交

SGLang 先让 torch.compile 得到 FX 图,再由自定义 Backend 切分。fullgraph=True 要求目标函数形成完整的编译器图,随后仍可由 Backend 将它拆成多张 CUDA Graph。它与“整个模型只捕获一张 CUDA Graph”是不同含义。编译入口

2.2 显式切分算子 ​

SGLang 用 register_split_op() 收集切分点。普通 Attention 同时注册成自定义算子,使编译器能够保留调用边界,并利用 Fake 实现传播张量信息;切图 Backend 再识别这个节点。

text
完整 FX 图
  投影/Norm → Attention 节点 → 投影/MLP
      ↓ 按注册节点切分
  子图 0        子图 1          子图 2
  可捕获        保持图外        可捕获

split_graph() 遇到注册节点时,为它分配独立子图,并从下一个节点开始使用新的子图编号。调用 split_module(..., keep_original_order=True) 保持原始操作顺序,尤其避免带原地写入的操作被错误重排。之后只有非切分点子图会交给分段捕获 Backend。切图实现

2.3 编译与捕获 ​

SGLang 的 tc_compiler 支持 eager 和 inductor。选择 eager 仍然经过 torch.compile 跟踪和 FX 切分,只是不使用 Inductor 对片段进行代码生成优化;CUDA Graph 捕获仍然存在。

每个可捕获片段由 CUDAPiecewiseBackend 管理,并按具体尺寸维护 ConcreteSizeEntry。其中保存可调用计算、预热状态、CUDA Graph 与输出。运行流程可归纳为:

text
跟踪与切分 → 准备片段计算 → 按尺寸预热 → 捕获片段
                                             ↓
在线执行:调用切分后的模型 → 片段选中对应尺寸 → replay

动态形状 FX 图与固定尺寸 CUDA Graph 可以共存:前者表达多个尺寸的计算规则,后者为选定尺寸保存具体的设备执行计划。编译缓存与图捕获缓存因此不能混为一谈。分段 Backend

3. 独立分段示例 ​

3.1 计算与边界 ​

下面的教学示例手工指定两段图,展示 PCG 最终的执行结构;它不调用 SGLang,也不演示自动 FX 切图。

输入形状固定为 [8, 32],最多容纳 8 个 token,每个 token 有 32 个特征。中间使用简化的单头因果自注意力:令 Q/K/V 都等于前一段的输出,按传入的请求长度分别计算;两个请求之间不共享 Attention。

text
Graph A:h = tanh(0.5 × x)
    ↓
Eager:按请求长度计算 Attention,写入固定 bridge
    ↓
Graph B:y = bridge + sigmoid(bridge)

请求长度可在每次重放时变化,但桥接缓冲区始终为 [8, 32]。无效 token 行清零后,后续逐行计算仍会处理 padding,消费输出时只取真实 token 行。

3.2 完整代码 ​

示例需要 NVIDIA GPU 与支持 CUDA 的 PyTorch,无需模型权重。每次改变请求长度后都与完全 Eager 的参考结果比较。

python
import math
import torch


CAPACITY, DIM = 8, 32


def before_attention(x):
    return torch.tanh(0.5 * x)


def after_attention(x):
    return x + torch.sigmoid(x)


def eager_attention(h, lengths, out):
    # lengths 是 CPU 上的请求长度列表,动态控制流保留在图外。
    if not lengths or any(n <= 0 for n in lengths):
        raise ValueError("Request lengths must be positive.")
    if sum(lengths) > CAPACITY:
        raise ValueError("Batch exceeds the captured token capacity.")

    out.zero_()
    start = 0
    for length in lengths:
        qkv = h[start:start + length]
        scores = (qkv @ qkv.T) / math.sqrt(DIM)
        future = torch.ones(
            length, length, device=h.device, dtype=torch.bool
        ).triu(diagonal=1)
        scores.masked_fill_(future, float("-inf"))
        out[start:start + length].copy_(scores.softmax(dim=-1) @ qkv)
        start += length


@torch.no_grad()
def main():
    if not torch.cuda.is_available():
        raise RuntimeError("This example requires CUDA-enabled PyTorch and a GPU.")

    static_x = torch.zeros(CAPACITY, DIM, device="cuda")
    bridge = torch.zeros_like(static_x)
    stream = torch.cuda.Stream()
    stream.wait_stream(torch.cuda.current_stream())

    with torch.cuda.stream(stream):
        for _ in range(3):
            warm_h = before_attention(static_x)
            eager_attention(warm_h, [4, 4], bridge)
            warm_y = after_attention(bridge)
    stream.synchronize()
    del warm_h, warm_y

    graph_a = torch.cuda.CUDAGraph()
    graph_b = torch.cuda.CUDAGraph()
    with torch.cuda.graph(graph_a, stream=stream):
        static_h = before_attention(static_x)
    with torch.cuda.graph(graph_b, stream=stream):
        static_y = after_attention(bridge)

    for lengths in ([3, 2], [4, 4], [1, 5]):
        tokens = sum(lengths)
        x = torch.randn_like(static_x)

        reference_bridge = torch.zeros_like(bridge)
        eager_attention(before_attention(x), lengths, reference_bridge)
        expected = after_attention(reference_bridge)

        # 图与中间操作按当前 Stream 的顺序执行。
        static_x.copy_(x)
        graph_a.replay()
        eager_attention(static_h, lengths, bridge)
        graph_b.replay()

        actual = static_y[:tokens].clone()
        torch.testing.assert_close(actual, expected[:tokens])
        print(f"Passed: request lengths = {lengths}")

    torch.cuda.synchronize()


if __name__ == "__main__":
    main()

graph_b 始终读取 bridge 的原地址,因此图外函数必须原地写入。把 bridge 重新赋值为另一个张量,无法改变已捕获图的输入地址。static_h 同理:它是前一图段反复写入的输出,也是 Eager Attention 每轮读取的输入。

示例保留了独立缓冲区和图各自的内存池,便于说明生命周期。生产实现还需要共享内存池、减少复制、管理 KV Cache 和多设备通信;这些优化不能破坏跨段的数据依赖。

4. SGLang 源码示例 ​

4.1 Attention 注册 ​

普通 Attention 使用以下两个装饰器声明边界。output 是预分配的输出张量,通过原地写入交给下一图段。

📄 radix_attention.py:切分算子
python
@register_custom_op(mutates_args=["output"])
@register_split_op()
def unified_attention_with_output(
    query: torch.Tensor,
    key: Optional[torch.Tensor],
    value: Optional[torch.Tensor],
    output: torch.Tensor,
    save_kv_cache: bool,
    layer_id: int,
    ...
) -> None:
    context = get_tc_piecewise_forward_context()
    forward_batch = context.forward_batch
    ...

源码摘录中的省略号表示省略参数与实现,不是独立运行脚本。完整函数从当前 context 获取请求信息,将 Q/K/V 截到真实 token 数,再调用 Attention 后端并写入 output。

4.2 FX 切图 ​

下面保留了为注册节点分配独立子图的核心代码:

📄 backend.py:split_graph()
python
subgraph_id = 0
node_to_subgraph_id = {}
split_op_graphs = []
for node in graph.graph.nodes:
    if node.op in ("output", "placeholder"):
        continue
    if node.op == "call_function" and str(node.target) in ops:
        subgraph_id += 1
        node_to_subgraph_id[node] = subgraph_id
        split_op_graphs.append(subgraph_id)
        subgraph_id += 1
    else:
        node_to_subgraph_id[node] = subgraph_id

split_gm = torch.fx.passes.split_module.split_module(
    graph, None, lambda node: node_to_subgraph_id[node], keep_original_order=True
)

ops 来自注册表和配置追加的切分点。本文版本还会在使用 DeepEP 或 Mooncake MoE All-to-All 后端时加入相应 MoE 操作,因此切分边界并不限于 Attention。TC Piecewise 配置

4.3 形状选择与重放 ​

Prefill Runner 将输入补齐到捕获 token 桶,并准备当前 Attention 元数据。切分后的模型进入某个片段时,该片段根据运行尺寸选择 ConcreteSizeEntry;已捕获的分支调用 entry.cudagraph.replay() 并返回保存的输出。

编译器的动态形状支持不会让一张 CUDA Graph 自由改变尺寸。实际仍需捕获多个桶,或对未覆盖的情况采用其他执行路径。调试模式还会比较捕获与重放时的输入地址,检查静态地址约束是否被破坏。具体尺寸状态与重放

5. 性能与边界 ​

方案构建方式主要收益主要代价
Full Graph整体捕获选定区域图启动次数少捕获兼容性要求高
TC Piecewise编译器跟踪、切图、分段捕获动态操作周围的计算可重放,可选编译优化跟踪与编译准备成本、边界注册和多段启动
BCG捕获过程中遇标记直接分段无需编译器完成整段跟踪仍需维护断点、地址与图外执行

PCG 对启动开销明显的计算区域更有效。若多数时间消耗在图外 Attention、通信或大型 GEMM 上,收益会受限。较多图段和 token 桶还会增加建图时间、缓存状态及显存管理成本。

torch.compile 的普通 Graph Break 也不等于本文的切分点:TC Piecewise 先要求可跟踪的完整图,再由自定义 Backend 切分;无法跟踪的 Python 行为可能在切图之前就导致失败。BCG 避开了这项整体跟踪要求,但图内区域仍需兼容 CUDA 捕获。

本文独立示例包含结果断言,用于检查分段前后计算是否一致;当前环境未执行 CUDA 测试,不将示例视为性能基准。

6. 参考 ​

  1. SGLang:TC Piecewise Runner Backend
  2. SGLang:FX 切图与编译 Backend
  3. SGLang:分段 CUDA Graph 捕获
  4. SGLang:切分点注册与配置
  5. SGLang:Attention 实现
  6. PyTorch:CUDA Graph 与部分网络捕获