文章

LLM CUDA Graph 生产实战:用 Shape Bucket、分段捕获与 Eager 回退降低解码启动开销

大模型解码阶段 CPU Kernel Launch 开销常被忽视。本文深入讲解 CUDA Graph 捕获与重放机制,结合 Shape Bucket 设计、分段捕获、预热状态机和 Eager 回退策略,给出可落地的生产方案,帮助团队降低推理尾延迟与 P99 抖动。

背景:GPU 很忙,CPU 也可能拖慢解码

大模型推理优化经常把注意力集中在 GPU 利用率、批处理大小和 KV Cache 上,但在低批量、短序列或延迟敏感场景中,另一个瓶颈会逐渐暴露:CPU 需要反复准备并提交大量 GPU Kernel

自回归解码每生成一个 Token,都要执行一轮模型前向。单轮计算由许多 Kernel、内存操作和依赖关系组成。如果每一步都从 Python、C++ 运行时一路下发到 CUDA Driver,即使每个 Kernel 本身很快,反复提交产生的启动成本仍会累积,并放大 P95、P99 延迟抖动。

CUDA Graph 的思路不是让矩阵乘法本身更快,而是把一段稳定的 GPU 工作预先捕获成图,后续以一次 Graph Launch 重放,减少逐个 Kernel 的参数准备和调度开销。

这项优化看似直接,真正上线却有三个难点:

  1. 大模型请求的 Batch Size、序列长度和执行路径并不完全固定;
  2. Graph 重放要求内存地址和多数执行结构保持稳定;
  3. Attention Backend、MoE、采样逻辑和自定义算子未必都支持完整捕获。

因此,生产方案不能只有”开启 CUDA Graph”一个开关,而应由 Shape Bucket、分段捕获、预热和 Eager 回退 共同组成。

核心原理:捕获一次,稳定重放

CUDA Graph 记录了什么

PyTorch 官方文档将 CUDA Graph 描述为一段 CUDA Stream 工作的记录,其中包含 Kernel、参数和依赖关系。捕获阶段并不立即执行这些工作,而是把它们写入图;捕获完成后,同一张图可以被多次重放。

Graph Replay 的主要收益是跳过 Python、C++ 和 CUDA Driver 层面的重复调度,让一整段 GPU 工作通过较少的主机调用提交。它牺牲了一部分动态灵活性,换取更低的 CPU Overhead。

这里有两个必须理解的约束:

  • 每次重放通常执行相同的 Kernel 拓扑和参数结构;
  • 输入输出 Tensor 需要复用捕获时的虚拟内存地址,业务数据应复制进固定缓冲区,而不是每次创建新的地址。

这也是 CUDA Graph 与普通”代码编译”不同的地方。编译解决算子生成和优化问题,Graph Replay 解决的是一段已确定 GPU 工作的重复提交问题,两者可以协同,但不是同一件事。

为什么解码阶段更容易受益

大模型推理通常分为 Prefill 和 Decode:

阶段特点CPU 启动开销占比
Prefill一次处理较长 Prompt,矩阵计算量大
Decode每步只处理少量新 Token,单步 Kernel 较碎

因此,低 Batch、交互式请求和 Decode-heavy 工作负载通常更值得优先验证 CUDA Graph。反过来,如果服务已经被大批量 GEMM、跨卡通信或排队时间主导,Graph Replay 可能只能带来有限变化。

静态 Shape 与动态流量的冲突

PyTorch 文档明确指出,常规 CUDA Graph 捕获要求 Shape 和布局在重放时保持一致。LLM Serving 却天然具有动态性:同一时刻活跃序列数不同,Prefill 与 Decode 会混合,部分请求还包含 LoRA、MoE、视觉编码器或定制 Logits Processor。

生产系统通常不追求一张图覆盖所有请求,而是建立一组有限的 Shape Bucket

  • Batch Size 1、2、4、8、16;
  • 纯 Decode 与 Prefill/Mixed 分开;
  • 必要时再按 Query Length、Speculative Token 数或模型分支拆分。

运行时先把请求映射到最近的可用 Bucket,再决定重放已有 Graph,还是进入 Eager 路径。

工程落地:从流量画像到安全回退

第一步:先证明 Kernel Launch 是瓶颈

不要先开功能,再寻找收益。应先通过 Profiler 建立基线:

  • GPU Kernel 之间是否存在明显的 CPU 提交空隙;
  • 单步 Decode 中 CPU 时间占比;
  • Batch Size 1、2、4、8 下的 TPOT 和 P99;
  • GPU 是否已经被计算、显存带宽或 NCCL 通信占满;
  • 不同 Prompt 长度、输出长度的流量占比。

如果绝大多数请求是高 Batch 或长 Prefill,CUDA Graph 未必是第一优先级。若小批量 Decode 占比高,且时间线上存在频繁的 Kernel Launch Gap,才适合进入下一步。

第二步:用真实分布设计 Shape Bucket

Bucket 不是越多越好。每增加一个 Bucket,通常都意味着额外的捕获时间、固定输入输出缓冲区和 Graph 缓存管理成本。

可先从少量高覆盖桶开始:

cuda_graph_policy:
  decode_batch_buckets: [1, 2, 4, 8, 16]
  capture_prefill: false
  max_cached_graphs: 5
  fallback_mode: eager
  capture_on_startup: true

这段配置是架构示例,不对应特定框架的完整参数。实际桶位应来自线上 Batch 分布,而不是机械使用二次幂。

Bucket 选择器需要明确处理三类情况:

def choose_execution_mode(batch_size: int, graph_cache: dict):
    bucket = smallest_bucket_not_less_than(batch_size)
    if bucket is None:
        return "eager", None
    if bucket in graph_cache:
        return "cuda_graph", bucket
    return "eager", None

如果采用向上取整 Bucket,需要通过 Padding 或有效 Token Mask 保证结果正确,同时衡量多算部分是否抵消 Graph 收益。

第三步:优先分段捕获,而不是强求全图

完整捕获要求整条执行路径都兼容 CUDA Graph。生产模型通常会包含 Attention、Collective、动态分支和自定义算子,一处不兼容就可能导致捕获失败。

更稳妥的策略是 Piecewise Capture

  • 可捕获的稳定算子进入 Graph;
  • Attention 或不兼容部分保留 Eager;
  • 运行时根据 Batch 类型和 Backend 能力选择 Full、Piecewise 或 None。

vLLM 当前文档提供了 NONEPIECEWISEFULLFULL_DECODE_ONLYFULL_AND_PIECEWISE 等模式,并通过 Dispatcher 根据 Batch 组成选择运行路径。其默认推荐体现了一个重要原则:性能优化必须与 Backend 兼容性和显存成本一起评估

示例命令如下:

vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --compilation-config '{"cudagraph_mode":"FULL_AND_PIECEWISE"}'

上线时不要照搬模式名称,应先确认当前 vLLM 版本、Attention Backend 和模型类型的支持矩阵。

第四步:把预热和捕获纳入启动状态机

Graph Capture 不是普通健康检查。它需要模型、CUDA Context、内存池、Attention Kernel 和通信路径进入稳定状态。

建议将实例启动拆成四个状态:

状态含义
MODEL_LOADED权重已加载
EAGER_WARMED典型 Shape 已完成若干次 Eager 预热
GRAPH_CAPTURED目标 Bucket 捕获完成并通过校验
READY可以接收正式流量

PyTorch 文档要求捕获前执行 Eager Warmup,并强调捕获后的输入输出 Tensor 要保持长期引用。vLLM 的实现也会区分 Warmup 和实际 Capture,以确保 Attention Backend 走到正确的 Kernel 路径。

如果实例在 Graph 尚未准备好时就对外 Ready,第一批用户请求可能承担捕获抖动,甚至触发超时。

第五步:始终保留 Eager 回退

Graph 路径不应成为单点故障。以下情况应直接回退:

  • Batch 或 Query Shape 未命中 Bucket;
  • Backend 声明不支持当前 Graph 模式;
  • 捕获、实例化或重放失败;
  • 动态功能改变了执行拓扑;
  • 新模型版本尚未完成 Graph 兼容验证;
  • 调试期间需要获取更完整的错误栈。

回退不是失败,而是兼容性策略。关键是必须记录原因,否则系统表面正常,实际长期运行在 Eager 模式,团队却误以为 CUDA Graph 已生效。

建议输出以下标签:

execution_mode=cuda_graph|piecewise|eager
bucket_id=decode_bs_4
fallback_reason=shape_miss|backend_unsupported|capture_error|disabled
model_revision=...
attention_backend=...

第六步:处理 Graph 缓存和显存预算

每张 Graph 不只是一个轻量句柄。固定 Tensor、私有内存池、工作区和捕获状态都可能增加常驻显存。

应为 Graph 建立独立预算:

  • 最大 Graph 数量;
  • 每个 Bucket 的显存增量;
  • 捕获耗时;
  • 空闲 Graph 的淘汰规则;
  • 模型切换、LoRA 切换后的失效条件;
  • Graph 重建期间的流量处理方式。

vLLM 文档指出,FULL_AND_PIECEWISE 往往性能更好,但也需要更多内存并具有更长捕获时间。生产选择应以 P99 收益与显存成本的比值为准,而不是默认开启最激进模式。

适用场景

适合需谨慎
在线聊天、代码补全等低 Batch 交互式 Decode请求 Shape 极度离散
单模型、固定精度、执行路径较稳定大量动态控制流和不兼容自定义算子
CPU 调度开销在单步 Decode 中占比明显主要瓶颈是大矩阵计算、NCCL 或显存带宽
Shape 分布集中,少量 Bucket 覆盖多数请求实例频繁缩容重启,捕获成本无法摊薄
对 TPOT 和 P99 抖动比极限吞吐更敏感显存接近上限,无空间维护多套 Graph

常见误区

误区一:CUDA Graph 等于模型编译

torch.compile、Kernel Fusion 和 CUDA Graph 解决的是不同层次的问题。编译可以优化计算图和生成 Kernel,CUDA Graph 主要减少稳定 GPU 工作序列的重复提交开销。它们可以组合,也可以独立使用。

误区二:Graph 数量越多,覆盖率越高就越好

更多 Bucket 确实可能降低 Shape Miss,但会增加启动时间、显存占用和版本管理复杂度。生产系统更应关注”少量 Bucket 覆盖多少核心流量”。

误区三:只看平均延迟

CUDA Graph 的价值经常体现在 TPOT、P95 和 P99 的稳定性。只比较平均吞吐,可能看不到 CPU 调度抖动被削弱后的收益。

误区四:捕获成功就代表结果正确

Graph Replay 复用固定地址和执行结构。缓冲区更新、Mask、随机数状态、采样参数和跨流依赖如果处理错误,可能产生静默错误。必须对 Eager 与 Graph 输出做一致性回归。

误区五:发生 Shape Miss 时现场捕获

在线请求触发同步捕获会制造不可预测的延迟尖峰。更稳妥的做法是启动期捕获白名单 Bucket,未命中请求走 Eager,并通过离线数据决定下一版本是否新增 Bucket。

上线检查清单

  • 已通过 Profiler 确认 CPU Kernel Launch 是可见瓶颈;
  • Bucket 来自真实流量分布,并记录覆盖率;
  • Eager、Piecewise、Full 三条路径均可独立运行;
  • 捕获发生在实例 Ready 之前;
  • Graph 与 Eager 的输出一致性测试已覆盖采样、Mask 和边界 Shape;
  • Attention Backend 和模型版本纳入兼容矩阵;
  • Shape Miss、Backend 不兼容和 Capture Error 有明确回退;
  • 已监控 Graph 命中率、Fallback Rate、捕获耗时和显存增量;
  • 发布支持按实例、模型或租户灰度关闭 CUDA Graph;
  • 模型升级后会重新执行捕获和兼容性门禁。

FAQ

CUDA Graph 是否一定能降低 TTFT?

不一定。TTFT 通常包含排队、Prefill、网络和调度等多段时间。CUDA Graph 更直接影响重复 GPU 工作的提交开销,在 Decode 和小计算粒度场景中更明显。是否改善 TTFT 要看 Prefill 是否也被捕获,以及 TTFT 的真实瓶颈位置。

Shape Bucket 应按 Batch Size 还是序列长度划分?

先看执行路径对哪些维度敏感。纯 Decode 常以 Batch Size 和 Query Length 为主;Prefill/Mixed 可能还需要考虑 Token 数和 Attention Metadata。不要一次建立多维笛卡尔积,应从最影响捕获兼容性的维度开始。

CUDA Graph 可以更新参数而不重新捕获吗?

CUDA 提供 Graph Update 和单节点参数更新能力,但更新有拓扑、上下文和内存类型等限制。LLM 框架通常会在自身 Dispatcher 和 Graph Cache 层封装这些细节,业务层不应假设所有动态变化都能原地更新。

参考资料

  1. PyTorch CUDA Semantics — CUDA Graphs
  2. NVIDIA CUDA Programming Guide — CUDA Graphs
  3. vLLM Design Documents — CUDA Graphs
  4. PyGraph: Robust Compiler Support for CUDA Graphs in PyTorch
  5. Hybrid JIT-CUDA Graph Optimization for Low-Latency Large Language Model Inference

常见问题

CUDA Graph 是否适合所有大模型推理请求?
不适合。它更适合执行路径稳定、Shape 可分桶、CPU 启动开销明显的解码任务;高度动态或包含不兼容算子的请求应保留 Eager 回退。
Shape Bucket 越多,命中率是否一定越高?
命中率通常会上升,但捕获时间、显存占用和图缓存管理成本也会增加,应根据真实流量分布选择少量高覆盖桶。
启用 CUDA Graph 后为什么延迟仍可能没有改善?
当请求主要受矩阵计算、显存带宽、通信或排队时间限制时,Kernel Launch 并非主瓶颈,Graph 重放的收益会被其他耗时淹没。
CUDA Graph 是否一定能降低 TTFT?
不一定。TTFT 通常包含排队、Prefill、网络和调度等多段时间。CUDA Graph 更直接影响重复 GPU 工作的提交开销,在 Decode 和小计算粒度场景中更明显。