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

  1. ✅ 确认所有 kernel 在 stream-ordered 模式下运行
  2. ✅ 确保捕获前预分配所有内存
  3. ✅ 使用 cudaStreamCaptureModeGlobal 避免跨 stream 同步问题
  4. ✅ 监控 graph instantiation 时间(不应超过 50ms)
  5. ✅ 对每个 bucket shape 验证数值正确性(capture 的 padding 不影响结果)
  6. ✅ 使用 cudaGraphInstantiateFlagAutoFreeOnLaunch 实现一次性 graph 释放

  7. 九、前沿进展: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 推理领域已从"高端优化"变为"标配方案"。其核心价值在于:

    1. 消除 CPU-GPU 交互瓶颈:将数十次 launch 合并为一次提交
    2. 降低 kernel-to-kernel gap:GPU 端连续接收 work,几乎无气泡
    3. 可预测的执行时间:预录制的 graph 执行时间稳定,有利于 SLO 保障
    4. 工程落地的关键是 shape bucketing + 预分配内存 + warm-up cache 三件组合拳。在 LLaMA-7B 级别的模型上,CUDA Graph 可以轻松带来 2x 以上的首 token 延迟改善,这使它在 2024-2026 年 AI 推理基础设施中持续保持核心地位。

      对于尚未启用 CUDA Graph 的推理服务团队,我的建议是:从 batch size=1 的 decode 阶段开始接入,这是最易实现、收益最大的切入点。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部