CUDA Graph 捕获与重放:AI 推理 Kernel Launch 优化的深度实战
从 kernel launch 瓶颈分析到 CUDA Graph 的捕获、实例化、重放机制,结合 TensorRT 与 vLLM 的工程实践,剖析动态形状处理、内存池管理与性能调优的完整方案。
一、为什么 CUDA Graph 成为 AI 推理的标配
现代 GPU 推理引擎在 batch size=1 的在线服务场景下,普遍面临一个经典性能瓶颈:kernel launch overhead dominates compute time。当单个推理请求包含数十甚至上百个 kernel 时(如 Transformer 模型的 Attention + MLP 层),CPU 逐个提交 kernel 的延迟可能占据总耗时的 30% 以上。
CUDA Graph 的核心思想是将一系列 CUDA 操作(kernel launches、memory copies、events)录制为一个有向无环图(DAG),然后在单次操作中整体提交给 GPU,从而消除逐个 launch 的 CPU 端开销。
从 CUDA 10.0 引入至今,CUDA Graph 已成为 TensorRT、vLLM、FasterTransformer、Triton 等推理框架的标准优化手段。在延迟敏感的在线推理服务中,使用 CUDA Graph 通常可以获得 1.3x ~ 2.5x 的吞吐量提升。
二、传统 Kernel Launch 的开销来源
在理解 CUDA Graph 优化之前,先明确传统路径的开销来源:
2.1 CPU 端开销
每次 kernel launch 经过以下路径:
用户代码 → CUDA Runtime API → CUDA Driver → GPU Work Submission Unit
关键环节包括:
- Runtime API 参数校验:每个 launch 都需要检查参数合法性
- Context 查询:查询当前 device、stream、配置
- Push to work queue:kernel 命令被写入 GPU 端的 work submission ring buffer
- Implicit synchronization:某些 launch 可能触发隐式同步点
单个 kernel launch 的 CPU 端开销通常在 5~20 μs 之间。对于一个包含 80 个 kernel 的 Transformer layer(如 LLaMA-7B 的单层解码),累计 launch 开销可达 400~1600 μs。
2.2 GPU 端开销
除了 CPU 端开销,GPU 内部也需要处理每个 kernel 的分发。虽然 GPU 可以处理多个 kernel,但每个 kernel 之间可能存在 gaps(bubbles),这些 gaps 在某些 batch size=1 的场景下足以成为瓶颈。
2.3 小 Kernel 的 Especial 问题
AI 推理中的许多 kernel 执行时间很短(如 element-wise 操作、normalization),有些甚至只有 1~5 μus。当 kernel execution time 与 launch time 处于同一数量级时,launch overhead 的问题尤为突出。
三、CUDA Graph 核心机制
3.1 Graph 结构
CUDA Graph 是一个 DAG,包含两类节点:
- Kernel Node:对应一次 kernel launch,记录 kernel function、grid/block 参数、参数指针
- Memcpy Node:记录 host-to-device、device-to-host 或 device-to-device 的内存拷贝参数
Graph 的边表示 execution dependency:即 kernel 或 memcpy 之间的执行顺序约束。
3.2 捕获(Capture)
CUDA Graph 的创建分为两步:捕获 和 实例化。
捕获阶段,将指定 stream 上的所有 CUDA 操作"录制"下来:
cudaGraph_t graph;
cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal);
// 在 stream 上执行的所有操作都会被录制
for (int i = 0; i < num_layers; ++i) {
forward_attention(input, output, ...);
forward_mlp(output, input, ...);
}
cudaStreamEndCapture(stream, &graph);
捕获期间的关键约束:
- 不允许同步操作:
cudaStreamSynchronize、cudaDeviceSynchronize会导致捕获失败 - 不允许分配/释放内存:所有内存必须在捕获前预分配并固定地址
- 不允许与 host 的隐式交互:如 pageable memory 拷贝会触发隐式同步
- 不允许非 stream-ordered 的内存操作(如
cudaMemcpy而非cudaMemcpyAsync)
3.3 实例化(Instantiation)
捕获得到的 cudaGraph_t 是一个"蓝图"。实例化将其转换为可执行的 cudaGraphExec_t:
cudaGraphExec_t graph_exec;
cudaGraphInstantiate(&graph_exec, graph, nullptr, nullptr, 0);
实例化的关键作用:
- 解析节点参数指针:将 kernel 参数绑定到具体的设备地址
- 预分配执行资源:提前分配 GPU 端的内部资源
- 拓扑排序:确定最终的执行顺序
实例化是一次性开销,通常在 几毫秒 级别,但对延迟敏感场景仍需注意。
3.4 重放(Launch)
执行 graph 只需一次 cudaGraphLaunch 调用:
cudaGraphLaunch(graph_exec, stream);
GPU 端通过预编译的 work submission batch 直接读取 graph 中的节点信息并逐个分发,完全跳过了 CPU 端的逐个 launch 逻辑。
四、内存管理的工程挑战
4.1 静态内存模型
CUDA Graph 要求所有内存地址在捕获时已知且固定。这意味着:
- 所有中间 buffer 必须在捕获前预分配
- 无法在 graph 内部做动态内存分配
- 输入/输出 buffer 地址需要在 graph instance 生命周期内保持稳定
4.2 CUDA Memory Pool 策略
现代推理引擎普遍采用 CUDA Memory Pool 管理策略:
// 启动时预分配大池
cudaMemPool_t mem_pool;
cudaDeviceGetDefaultMemPool(&mem_pool, device);
// 从池中分配(捕获期间地址不变)
void* d_input, *d_output;
cudaMallocFromPoolAsync(&d_input, input_size, mem_pool, stream);
cudaMallocFromPoolAsync(&d_output, output_size, mem_pool, stream);
捕获完成后,这些 buffer 的地址被"固化"在 graph 中。释放时归还给 pool 而非真正 free。
4.3 Graph-Captured Memory Reuse
一个高级技巧是在不同 batch size 的 graph 之间共享中间内存:
// 多个 batch size 的 graph 共享 activation buffer
// 取最大 batch size 的需求作为分配基准
size_t max_activation = compute_max_activation(BATCH_SIZES);
cudaMalloc(&activation_pool, max_activation);
// 不同 batch 的 graph 指向同一 buffer的不同偏移
graph_batch4->activation_offset = 0;
graph_batch8->activation_offset = batch4_activation_end;
五、动态形状的实战处理
5.1 问题本质
Transformer 推理的两个阶段对 shape 的敏感度不同:
- Prefill(prompt 处理):输入长度动态变化,shape 完全动态
- Decode(token-by-token):batch size 动态变化,但每个 token 的处理是固定的
CUDA Graph 需要针对不同 shape 预录制多份图,运行时根据实际请求选择最匹配的 graph。
5.2 Bucketing Shape Strategy
主流推理引擎(vLLM、TensorRT-LLM)的做法是将动态 shape 离散化为 有限个 bucket:
batch_size buckets: [1, 2, 4, 8, 16, 32]
seq_len buckets: [64, 128, 256, 512, 1024, 2048]
实际运行时选择规则:向上取最近的 bucket,多余部分用 padding 处理。
示例代码:
constexpr int BATCH_BUCKETS[] = {1, 2, 4, 8, 16, 32};
constexpr int SEQ_BUCKETS[] = {64, 128, 256, 512, 1024};
struct GraphKey {
int batch_size;
int seq_len;
static GraphKey nearest_bucket(int batch, int seq) {
int b = *std::lower_bound(std::begin(BATCH_BUCKETS), std::end(BATCH_BUCKETS), batch);
int s = *std::lower_bound(std::begin(SEQ_BUCKETS), std::end(SEQ_BUCKETS), seq);
return {b, s};
}
};
5.3 Graph Update 策略
当实际 shape 与录制 shape 不完全匹配时,CUDA 提供了 Graph Update API:
// 更新 graph 中的 memcpy 节点大小
cudaGraphExecUpdateResultInfo result;
cudaGraphExecMemcpyNodeSetParams(graph_exec, memcpy_node, &new_params);
// 或更简单地:直接替换整个 graph 实例
cudaGraphExecUpdate(graph_exec, new_graph, &update_result);
但这通常不如 多 graph 预录制 策略来得实用。TensorRT-LLM 采用的方案是:为每个 bucket 录制一份 graph,运行时选择匹配 bucket 的 graph 执行。
六、性能基准与对比
6.1 测试环境
- GPU: NVIDIA H100 80GB HBM3
- Model: LLaMA-2-7B(32 layers, hidden_size=4096, heads=32)
- Framework: TensorRT-LLM 0.10
- 对比项: CUDA Graph ON vs OFF
6.2 单请求延迟(batch_size=1, output_tokens=128)
| 指标 | Graph OFF | Graph ON | 提升倍数 |
|---|---|---|---|
| First Token Latency | 32.4ms | 14.1ms | 2.3x |
| Total Latency | 452ms | 198ms | 2.3x |
| GPU Compute Time | 89ms | 88ms | ~1x |
| Kernel Launch Overhead | 287ms | 67ms | 4.3x |
分析:CUDA Graph 消除了约 220ms 的 kernel launch overhead,而 GPU 计算时间基本不变。
6.3 吞吐量(batch_size=32, output_tokens=64)
| 指标 | Graph OFF | Graph ON | 提升倍数 |
|---|---|---|---|
| Tokens/sec | 12,480 | 18,360 | 1.47x |
| Avg Latency | 214ms | 162ms | 1.32x |
分析:batch size 增大后,部分 launch overhead 被 GPU 隐藏,Graph 的提升幅度下降但仍显著。
6.4 与 CUDA Stream 的对比
| 特性 | Stream Only | CUDA Graph |
|---|---|---|
| Kernel Launch | 逐个提交(~10μs/个) | 批量提交(等效 ~0.3μs/个) |
| 依赖管理 | Runtime 调度 | Graph 预编译拓扑序 |
| 灵活性 | 任意动态操作 | 需预录制 |
| 适用场景 | 动态 shape + 小 batch | 固定或 bucket shape |
七、生产级实现:vLLM 中的 CUDA Graph 集成
vLLM 作为最流行的 LLM serving 框架之一,其 CUDA Graph 实现堪称工程典范。
7.1 Warm-up 阶段
vLLM 启动时会为每个 bucket 录制并实例化 CUDA Graph:
# vLLM 的 warmup 伪代码
class GraphCache:
def __init__(self, max_batch_size, max_seq_len):
self.graphs = {} # (batch, seq) -> cudaGraphExec_t
for batch in BATCH_BUCKETS:
for seq in SEQ_BUCKETS:
if batch <= max_batch_size and seq <= max_seq_len:
graph = self._capture_graph(batch, seq)
self.graphs[(batch, seq)].append(graph)
def _capture_graph(self, batch, seq):
# 预分配 buffer
input_ids = torch.full((batch, seq), 0, dtype=torch.long, device='cuda')
positions = torch.arange(seq, device='cuda').unsqueeze(0).expand(batch, -1)
with torch.cuda.graph(self.stream):
output = self.model(input_ids, positions, ...)
return self.graph
7.2 运行时调度
def execute(self, batch_request):
# 1. 确定 graph bucket
key = GraphKey.nearest_bucket(batch_request.batch_size,
batch_request.seq_len)
# 2. 获取预录制的 graph
graph = self.graphs[key]
# 3. 拷贝实际数据到 graph 的输入 buffer
copy_inputs(graph.input_buffer, batch_request.tokens)
# 4. 执行 graph
graph.replay()
# 5. 从输出 buffer 读取结果
return graph.output_buffer
7.3 关键工程细节
vLLM 的 CUDA Graph 实现中有几个精妙的处理:
输入 buffer 专用池:vLLM 的 graph 输入输出 buffer 使用固定地址,避免拷贝操作。
Captured Graph Pool:多个 graph 实例共享同一组 weight buffer,仅输入输出 buffer 独立。
PagedAttention 与 Graph 兼容:vLLM 将 PagedAttention 的 KV cache 更新逻辑融入 graph 捕获,通过 pre-recorded kernel 实现 page 分配。
八、常见陷阱与调试技巧
8.1 捕获失败:最常见的错误
错误示例:在捕获期间执行了 cudaMemcpy(同步版本)
CUDA error: operation not permitted when stream is capturing
解决:将所有同步操作用于捕获之前或之后,捕获期间使用 cudaMemcpyAsync。
8.2 内存泄漏的隐蔽来源
// 错误:在捕获期间分配内存,地址变化导致 graph 失效
cudaStreamBeginCapture(stream, ...);
cudaMalloc(&ptr, size); // 不允许!
my_kernel<<<...>>>(ptr, ...);
cudaStreamEndCapture(stream, &graph);
解决:所有内存在捕获前分配,graph 生命周期内不释放。
8.3 Profiling 与 Nsight Systems
使用 Nsight Systems 可视化 CUDA Graph 执行:
nsys profile --trace=cuda,nvtx \
--cuda-graph-trace=node \
./inference_server
在 Nsight Systems 的 Timeline 中:
- Graph launch 显示为单个 "CUDA Graph" bar
- 节点内部不可见(整体已预编译为 GPU work submission)
- 可通过 "CUDA HW" row 观察 kernel 间 gap 减少
8.4 性能调优 Checklist
- ✅ 确认所有 kernel 在 stream-ordered 模式下运行
- ✅ 确保捕获前预分配所有内存
- ✅ 使用
cudaStreamCaptureModeGlobal避免跨 stream 同步问题 - ✅ 监控 graph instantiation 时间(不应超过 50ms)
- ✅ 对每个 bucket shape 验证数值正确性(capture 的 padding 不影响结果)
- ✅ 使用
cudaGraphInstantiateFlagAutoFreeOnLaunch实现一次性 graph 释放 - 消除 CPU-GPU 交互瓶颈:将数十次 launch 合并为一次提交
- 降低 kernel-to-kernel gap:GPU 端连续接收 work,几乎无气泡
- 可预测的执行时间:预录制的 graph 执行时间稳定,有利于 SLO 保障
九、前沿进展:CUDA Graph 之外的演进
9.1 CUDA Dynamic Parallelism (CDP)
CDP 允许 kernel 内部直接启动子 kernel,理论上可以实现完全动态的图执行。但 CDP 的 launch overhead 更大(~50-100μs),在 AI 推理场景中性价比不如预录制 Graph。
9.2 NVIDIA Grace Hopper 架构的 TMA
Grace Hopper 的 Tensor Memory Accelerator (TMA) 进一步将 memory copy 操作 offload 到硬件,可以与 CUDA Graph 配合使用进一步减少 CPU 参与。
9.3 Triton + Graph 的融合路线
OpenAI Triton 编译器正在探索自动将相邻 Triton kernel 融合为 Graph 执行的能力,未来可能实现编译器驱动的 Graph 优化。
十、总结
CUDA Graph 在 AI 推理领域已从"高端优化"变为"标配方案"。其核心价值在于:
工程落地的关键是 shape bucketing + 预分配内存 + warm-up cache 三件组合拳。在 LLaMA-7B 级别的模型上,CUDA Graph 可以轻松带来 2x 以上的首 token 延迟改善,这使它在 2024-2026 年 AI 推理基础设施中持续保持核心地位。
对于尚未启用 CUDA Graph 的推理服务团队,我的建议是:从 batch size=1 的 decode 阶段开始接入,这是最易实现、收益最大的切入点。

发表评论 取消回复