文章

LLM Serving CUDA Graph 生产实战:用 Shape Buckets、Piecewise Capture 与 Warmup Plan 降低 Kernel Launch 抖动

CUDA Graph 可降低大模型推理的启动开销,但动态批次、形状变化与首次捕获也会制造延迟抖动。本文结合 vLLM 与 TensorRT-LLM,讲清形状分桶、分段捕获、预热策略、显存权衡与生产回退门禁,帮你把 GPU 未满载却延迟下不去的 Host 侧瓶颈压下去。

LLM Serving CUDA Graph 生产实战:用 Shape Buckets、Piecewise Capture 与 Warmup Plan 降低 Kernel Launch 抖动

做 LLM Serving 性能优化时,人们首先会看 GPU 利用率、Attention Kernel、KV Cache 和 Batch Size。但在短 Decode Step、小 Batch 或较小模型上,另一个常见瓶颈是 CPU 到 GPU 的 Kernel Launch 开销

一次自回归 Decode 并不是“调用一次 GPU 就结束”,而是执行大量算子。每个算子都需要 Python/C++ Runtime、CUDA Driver 和 GPU 之间完成参数准备与 Kernel 提交。单个 Kernel 很快时,提交本身就可能成为不可忽略的固定成本。

本文结合 vLLM 与 TensorRT-LLM,讲清形状分桶(Shape Buckets)、分段捕获(Piecewise Capture)、预热策略(Warmup Plan)、显存权衡与生产回退门禁,帮你把“GPU 没满,延迟却下不去”的 Host 侧瓶颈压下去。

核心原理:CUDA Graph 优化的是 Launch Overhead,不是替你减少计算量

1. Graph Replay 省掉了什么

普通执行路径大致是:

CPU prepare op A -> launch kernel A
CPU prepare op B -> launch kernel B
CPU prepare op C -> launch kernel C
...

CUDA Graph 捕获后更接近:

copy/update static inputs

cudaGraphLaunch(graph)

GPU replays A -> B -> C -> ...

因此它最适合 Host-bound 的推理阶段。如果某个阶段本身已经完全 Compute-bound,GPU Kernel 持续时间远大于 Launch Overhead,CUDA Graph 仍可能有收益,但通常不会像短 Kernel、低 Batch Decode 那么明显。

2. 为什么动态 Shape 是核心矛盾

CUDA Graph 依赖稳定的执行图和内存布局。一个针对 Batch=8 捕获的图,不能天然当成 Batch=37 的动态程序使用。

Serving 框架通常采取两种办法:

  1. 捕获多个离散 Shape / Batch Size;
  2. 运行时将实际 Shape Padding 到最近的已捕获 Bucket

这就是 Shape Buckets。假设生产 Decode Batch 的主要分布为 1、2、4、8、12、18、30,可以设计:

运行时 Batch处理方式
12pad / replay 16
18pad / replay 32
40eager fallback / 其他路径

Bucket 太稀,Padding 浪费更多计算;Bucket 太密,则 Capture 数量、静态 Buffer 和显存占用上升。因此 Bucket Grid 本质上是延迟、额外计算、捕获时间与显存之间的成本函数

Full CUDA Graph 与 Piecewise CUDA Graph

Full Capture:收益最高,但约束也最强

如果整段模型 Forward 都满足 CUDA Graph 的要求,可以做 Full CUDA Graph。vLLM 把 Full、Piecewise、Full Decode Only 和 Full + Piecewise 划分为不同运行模式,其中 Full Decode 对纯 Decode 工作负载尤其有意义。

但 Attention 是 LLM 中最难捕获的区域之一,其 Shape、KV 状态、Backend 能力和控制路径都可能影响 Graph Safety。因此“全图一定更快”不是一个可以脱离模型、Attention Backend 和 workload 单独成立的结论。

Piecewise Capture:把难捕获算子留在 Eager 路径

vLLM 与 TensorRT-LLM 都提供了 Piecewise CUDA Graph 思路:

CUDA Graph Segment A

Attention / dynamic op (eager)

CUDA Graph Segment B

Attention / dynamic op (eager)

CUDA Graph Segment C

这样牺牲了一部分完整图收益,但换来更好的动态兼容性。TensorRT-LLM 明确说明,Piecewise CUDA Graph 主要把不适合捕获的区域——尤其是 Attention——保留为 Eager,对其余区域捕获;并按 Token Count 配置 capture_num_tokens,运行时将实际 Token 数 Padding 到下一个已捕获值。

这说明生产调优的真正对象不是一个布尔开关,而是 Graph Partition + Capture Grid

Shape Buckets 应该从生产流量反推,而不是照抄默认值

很多框架有默认 Capture Size,默认值适合“开箱即用”,但不意味着是你的最佳配置。更可靠的方法是先采集 24 小时或 7 天的 workload histogram:

metric: active_decode_sequences
metric: num_scheduled_tokens
metric: prefill_tokens_per_iteration
metric: mixed_batch_tokens

然后计算每个候选 Bucket 的覆盖率。例如:

区间占比
1-4 tokens/sequences31%
5-827%
9-1621%
17-3213%
33-646%
>642%

这类流量应在小 Shape 区间配置更密的 Bucket,而不是平均铺开。

一个实用的 Bucket 选择原则

可以把候选 Bucket 设计为:

1, 2, 4, 8, 12, 16, 24, 32, 48, 64, 96, 128

然后用真实 Trace 比较:

  • Graph Coverage;
  • Padding Ratio;
  • Graph Memory;
  • p95/p99 TTFT;
  • p95/p99 TPOT;
  • 最大并发数。

不要只比较 Tokens/s。vLLM 当前配置逻辑本身也采用离散 Capture Size,运行时针对可覆盖的 Batch 使用相应 CUDA Graph;超过最大 Capture Size 的请求则不会走对应图路径。框架默认 Bucket 可作为 Baseline,但生产配置仍应由真实分布校准。

Warmup Plan:避免把 Compile/Capture 延迟留给第一个真实用户

CUDA Graph 最容易在测试环境被忽略的问题,是 首次请求成本。首次进入某个新 Shape 时,系统可能发生:

  1. Torch/Inductor 编译;
  2. Lazy CUDA 初始化;
  3. Attention Backend 初始化;
  4. Workspace 分配;
  5. CUDA Graph Warmup;
  6. Capture 与 Instantiate;
  7. 静态 Buffer 建立。

PyTorch 官方 CUDA Graph 文档明确建议在 Capture 前先做 Warmup,避免把懒初始化行为捕获进图或导致 Capture 失败。生产部署不应该只做一个“健康检查请求”,而应该有 Shape-aware Warmup Plan

warmup_plan:
  decode_buckets: [1, 2, 4, 8, 16, 32]
  mixed_token_buckets: [64, 128, 256, 512]
  repeat_per_bucket: 3
  block_ready_until_complete: true

Ready 不等于进程启动成功

Kubernetes 或服务注册层建议区分:

Process Started

Model Loaded

Compile Complete

Required Graph Buckets Captured

READY

否则滚动发布时,新实例刚加入负载均衡,就可能把真实用户流量当成 Warmup 请求。

捕获越多,显存并不越省

CUDA Graph 常被描述为“减少 CPU 开销”,但生产配置中必须同时考虑 Graph Memory Footprint。捕获更多 Shape 往往意味着更多 Graph Executable、静态输入输出 Buffer 或相关 Runtime 状态。TensorRT-LLM 也明确指出,更广泛的 Capture Token Count 会增加 GPU Memory,并可能降低可达到的并发度。

因此需要把 CUDA Graph 与 KV Cache 放在同一个显存预算里看:

GPU Memory
├── Model Weights
├── KV Cache
├── Runtime Workspace
├── CUDA Graph / Static Buffers
└── Safety Margin

如果为了把 Graph Coverage 从 97% 提升到 99.8%,却减少了 15% 的可并发序列数,这个优化未必划算。更合理的指标是:在满足 Tail SLO 的前提下,每 GB 显存能承载多少有效并发和多少有效 Tokens/s。

vLLM:先控制 Capture Grid,再评估 Full/Piecewise

下面是一个简化示意,具体字段请以部署版本文档为准:

vllm serve /models/your-model \
  --compilation-config '{"cudagraph_capture_sizes":[1,2,4,8,16,32,64]}'

不要一开始就追求“最大 Capture Grid”。更稳妥的步骤是:

  1. Eager / 默认模式跑 Baseline;
  2. 采集真实 Batch/Token Shape;
  3. 先覆盖 90%~95% 高频 Shape;
  4. 测 Graph Hit 与 Padding;
  5. 观察显存和并发变化;
  6. 再决定是否增加更大的 Capture Size。

vLLM 当前文档还提供 Full、Piecewise、Full Decode Only 和 Full + Piecewise 等 CUDA Graph Mode。不同版本支持能力仍在快速变化,因此 Runtime Version 必须作为 Benchmark Artifact 的一部分固定记录

TensorRT-LLM:Token Count Bucket 比“一个 max_batch_size”更重要

TensorRT-LLM 的 Piecewise CUDA Graph 配置示意:

torch_compile_config:
  capture_num_tokens: [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]
  enable_userbuffers: false
  enable_piecewise_cuda_graph: true

官方文档特别提醒:

  • Piecewise Capture 的 Token Bucket 应根据 硬件、模型与并行策略 调优;
  • Bucket 越多,Padding 越少,但图内存越高;
  • Context Phase 中较大的 Token Count 本身 Host Overhead 占比下降,继续增加 Capture Point 的边际收益可能变小。

因此 Bucket 的合理密度通常是:小 Shape 密、Shape 越大越稀

生产观测:至少增加六类 CUDA Graph 指标

如果只看 TTFT 和 GPU Utilization,很难解释启用 Graph 后为什么偶尔还是抖。建议增加:

  1. Graph Hit Ratiocuda_graph_hit_requests / eligible_requests,最好按 Bucket 分层。
  2. Eager Fallback Ratio:统计因为 Shape 超界、Backend 不兼容或 Graph Invalid 而回退到 Eager 的比例。
  3. Padding Overheadpadding_ratio = padded_tokens / actual_tokens
  4. Capture / Compile Latency:区分 model_load_ms / compile_ms / warmup_ms / capture_ms / ready_ms,对滚动扩容和故障重建尤其重要。
  5. Graph Memory Footprint:记录启用前后的 GPU used memory、KV cache capacity、max concurrent sequences。
  6. Tail Latency:至少比较 TTFT p50/p95/p99、TPOT/ITL p50/p95/p99、E2E p95/p99。

CUDA Graph 的价值通常更容易反映在小 Batch Decode 与 Tail Latency,而不只是平均吞吐。

推荐的上线流程

阶段一:建立 Eager Baseline。 固定 Model Revision、Runtime Version、CUDA/Driver、GPU 型号、Tensor Parallel/Expert Parallel、workload trace,否则不同 Benchmark 之间无法比较。

阶段二:只打开默认 CUDA Graph。 确认框架默认配置的真实收益和显存成本。

阶段三:用生产 Trace 调 Capture Grid。 对每组 Bucket 记录 coverage / padding_ratio / graph_memory / TTFT p99 / TPOT p99 / max_concurrency

阶段四:加入 Warmup Gate。 新 Pod / Worker 必须完成关键 Bucket Capture 后才进入 Ready。

阶段五:Canary。 不要用平均吞吐作为唯一放量门禁,至少设置:

  • TTFT p99 regression <= threshold;
  • TPOT p99 regression <= threshold;
  • OOM = 0;
  • graph fallback ratio <= threshold;
  • max concurrency regression <= threshold。

阶段六:保留 Eager / Previous Runtime 回退。 CUDA Graph、Torch Compile 和 Attention Backend 的兼容矩阵会随版本变化,发布系统必须能回滚到上一个 Runtime Artifact。

适用场景

这套方法尤其适合:

  • Decode 占比较高的在线聊天;
  • 小模型或低 Batch 低延迟服务;
  • GPU 未持续满载、但 CPU Launch 明显占据 Step Gap 的场景;
  • 请求 Shape 有明显稳定分布的线上系统;
  • 已完成 KV Cache、Batching 和 Kernel 基础优化,准备继续压低尾延迟的 Serving 平台。

如果服务主要是超大 Prefill、单次 Kernel 已经长时间占满 GPU,CUDA Graph 可能不是优先级最高的优化项。

常见误区

误区一:Capture Size 越多越好。 更多 Graph 会提高覆盖率,但也会增加捕获成本和显存占用。目标应该是 最小 Graph Set 覆盖大部分生产流量

误区二:Full CUDA Graph 一定优于 Piecewise。 只有在 Attention Backend、动态 Shape 和控制流都满足条件时,Full Capture 才值得使用。生产系统更看重稳定性和兼容性。

误区三:启动后跑一个 Warmup 请求就够了。 如果有多个 Shape Bucket,只 Warmup 一个 Shape 并不能避免其他 Bucket 第一次命中时的 Capture Spike。

误区四:只看 Tokens/s。 如果 Tokens/s 上升 5%,但 TTFT p99、OOM 或最大并发恶化,这未必是生产优化。

误区五:忽略 Runtime 版本。 vLLM、TensorRT-LLM 与 PyTorch 对 CUDA Graph 的实现仍持续演进。Capture Mode、兼容 Attention Backend、默认 Bucket 与内存策略都可能变化,Runtime Version 应与 Model Revision 一样被版本化

上线检查

上线前至少确认:

  • 已有真实 workload shape histogram;
  • Capture Bucket 来自流量分布,而不是直接照抄示例;
  • 已测量 Graph Hit Ratio 与 Eager Fallback;
  • 已测量 Padding Overhead;
  • 已记录 Capture 前后 GPU Memory 与 KV Cache Capacity;
  • Warmup 覆盖所有关键 Bucket;
  • Readiness 在 Warmup/Capture 完成后才放流量;
  • 已比较 TTFT/TPOT/ITL p95 与 p99;
  • 已保留 Eager 或上一 Runtime 版本回退路径;
  • Runtime、Driver、CUDA、模型版本和配置均进入发布 Artifact。

参考资料

  1. vLLM Compilation Configuration
  2. vLLM Compilation API
  3. vLLM Runtime / CUDA Graph Capture Size Configuration
  4. TensorRT-LLM — Torch Compile & Piecewise CUDA Graph
  5. PyTorch — CUDA Semantics / CUDA Graphs
  6. Torch-TensorRT — CUDAGraphs

常见问题

CUDA Graph 为什么对 LLM Decode 阶段通常更有价值?
Decode 常由大量较短的 GPU Kernel 组成,Host Launch Overhead 更容易占据可见比例。CUDA Graph 把多个 Kernel 的提交预先捕获并统一重放,因此更容易降低这部分固定成本,但实际收益仍取决于模型大小、Batch、GPU、Attention Backend 与 Runtime。
CUDA Graph Capture Size 是否越多越好?
不是。更多 Bucket 能降低 Padding 和 Eager Fallback,却会显著增加图对象、静态缓冲区、捕获时间与 GPU 显存,并可能压缩可用于 KV Cache 与并发的内存。生产优化应寻找 Pareto Front,而不是追求 100% Graph Coverage。
Full CUDA Graph 和 Piecewise CUDA Graph 怎么选?
建议先从框架默认模式和 Piecewise 开始。若目标负载主要是 Decode,且 Attention Backend 明确支持 Full Capture,再用同一真实 Trace 对比 Full 与 Piecewise 的尾延迟、显存与并发能力后决定。
生产环境启用 CUDA Graph 后最应该监控什么?
至少同时监控 Graph Hit Ratio、Padding Overhead、Eager Fallback、首次 Capture 延迟、GPU 显存、TTFT、TPOT/ITL 与最大并发数,不能只观察平均吞吐。