背景:GPU 很忙,CPU 也可能拖慢解码
大模型推理优化经常把注意力集中在 GPU 利用率、批处理大小和 KV Cache 上,但在低批量、短序列或延迟敏感场景中,另一个瓶颈会逐渐暴露:CPU 需要反复准备并提交大量 GPU Kernel。
自回归解码每生成一个 Token,都要执行一轮模型前向。单轮计算由许多 Kernel、内存操作和依赖关系组成。如果每一步都从 Python、C++ 运行时一路下发到 CUDA Driver,即使每个 Kernel 本身很快,反复提交产生的启动成本仍会累积,并放大 P95、P99 延迟抖动。
CUDA Graph 的思路不是让矩阵乘法本身更快,而是把一段稳定的 GPU 工作预先捕获成图,后续以一次 Graph Launch 重放,减少逐个 Kernel 的参数准备和调度开销。
这项优化看似直接,真正上线却有三个难点:
- 大模型请求的 Batch Size、序列长度和执行路径并不完全固定;
- Graph 重放要求内存地址和多数执行结构保持稳定;
- 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 当前文档提供了 NONE、PIECEWISE、FULL、FULL_DECODE_ONLY 和 FULL_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 层封装这些细节,业务层不应假设所有动态变化都能原地更新。