4、流捕获入门:cudaStreamBeginCapture、cudaStreamEndCapture、捕获模式详解

好,咱们今天聊聊流捕获(Stream Capture)。

这东西说白了,就是CUDA提供的一个“录屏”功能。你想想看,平时我们写CUDA代码,都是手动把一个个kernel launch、cudaMemcpy调用排好队,扔进流里。但有时候,我们希望把一段已有的、非CUDA Graph的代码,自动“翻译”成一个CUDA Graph。流捕获就是干这个的。

我个人习惯,在需要做动态图优化或者反复执行相同计算模式时,一定会用流捕获。它比手写Graph API要省心得多,尤其是当你的计算流程比较复杂的时候。

4.1 核心API:开始与结束

流捕获的入口和出口,就是两个函数:cudaStreamBeginCapturecudaStreamEndCapture

cudaStreamBeginCapture

这个函数告诉CUDA驱动:“嘿,从现在开始,往这个流里丢的所有操作,你都给我记下来,别真执行。” 它会返回一个 cudaError_t,一般我们检查一下就行。

cudaStream_t stream;
cudaStreamCreate(&stream);
cudaError_t err = cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal);
// 检查err...

cudaStreamEndCapture

这个函数告诉CUDA:“好了,录完了,把刚才录下来的东西打包成一个Graph给我。” 它会返回一个 cudaGraph_t 对象。

cudaGraph_t graph;
err = cudaStreamEndCapture(stream, &graph);
// 检查err...
// 现在graph里就包含了从Begin到End之间所有的操作

嗯,这里要注意:Begin和End必须在同一个流上调用。而且,在Begin之后、End之前,你不能对这个流做任何“非捕获”的操作,比如 cudaStreamSynchronize 或者 cudaStreamQuery。我曾经犯过这个错,在捕获中间加了个同步,结果CUDA直接报错,说流状态不对。

4.2 捕获模式详解

你可能会问:“录屏的时候,能不能录到其他流里的操作?” 这就是捕获模式要解决的问题了。

cudaStreamBeginCapture 的第二个参数,就是捕获模式。目前有三种:

模式 枚举值 行为描述
全局模式 cudaStreamCaptureModeGlobal 捕获所有流上的操作。只要有一个流在捕获,其他流上的操作也会被捕获进去。
线程局部模式 cudaStreamCaptureModeThreadLocal 只捕获当前线程发起的、且属于被捕获流的操作。其他线程的操作不影响。
中继模式 cudaStreamCaptureModeRelaxed 最宽松的模式。允许在捕获期间,其他流上有未被捕获的操作。但要注意,这可能导致Graph依赖关系不完整。

全局模式:我最常用。它最严格,也最安全。你想想看,如果你的计算图里涉及多个流(比如一个流做计算,一个流做数据传输),用全局模式能保证所有依赖关系都被正确捕获。我在项目中遇到过,用线程局部模式时,因为另一个线程偷偷往别的流里丢了个操作,结果Graph执行时数据还没到位,直接崩了。后来换成全局模式,问题就解决了。

线程局部模式:适合单线程、单流的简单场景。如果你确定你的计算只在一个线程里、一个流上,用这个模式可以减少一些不必要的检查开销。

中继模式:这个模式我建议新手慎用。它允许你在捕获期间,其他流上可以自由操作。但代价是,这些自由操作不会被记录到Graph里。如果你后续的Graph执行依赖了这些操作的结果,那就出大问题了。说白了,这是给高级用户准备的,他们能精确控制哪些操作进图,哪些不进。

4.3 一个完整的捕获示例

光说不练假把式。咱们看个完整的例子,把向量加法用流捕获做成Graph。

#include <cuda_runtime.h>
#include <stdio.h>

__global__ void vecAdd(float *a, float *b, float *c, int n) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    if (idx < n) {
        c[idx] = a[idx] + b[idx];
    }
}

int main() {
    int n = 1024;
    size_t size = n * sizeof(float);
    float *d_a, *d_b, *d_c;
    cudaMalloc(&d_a, size);
    cudaMalloc(&d_b, size);
    cudaMalloc(&d_c, size);

    // 创建流
    cudaStream_t stream;
    cudaStreamCreate(&stream);

    // 开始捕获
    cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal);

    // 这些操作会被捕获
    vecAdd<<<1, 256, 0, stream>>>(d_a, d_b, d_c, n);
    // 可以加更多kernel或者cudaMemcpyAsync

    // 结束捕获,得到Graph
    cudaGraph_t graph;
    cudaStreamEndCapture(stream, &graph);

    // 实例化Graph(编译成可执行形式)
    cudaGraphExec_t graphExec;
    cudaGraphInstantiate(&graphExec, graph, NULL, NULL, 0);

    // 执行Graph
    cudaGraphLaunch(graphExec, stream);
    cudaStreamSynchronize(stream);

    // 清理
    cudaGraphExecDestroy(graphExec);
    cudaGraphDestroy(graph);
    cudaStreamDestroy(stream);
    cudaFree(d_a);
    cudaFree(d_b);
    cudaFree(d_c);

    return 0;
}

你看,代码结构很清晰。Begin、丢操作、End、Instantiate、Launch。就这么几步。

小技巧: 我个人习惯在Begin之前,先把流同步一下(cudaStreamSynchronize),确保流里没有残留的操作。这样可以避免捕获到一些意料之外的依赖。

4.4 避坑指南

注意: 流捕获不是万能的。它不能捕获所有CUDA操作。比如:
  • cudaMalloccudaFree 不能被捕获。你必须在捕获开始前分配好所有内存。
  • cudaMemcpy(同步版本)不能被捕获,必须用 cudaMemcpyAsync
  • 任何会导致隐式同步的操作(比如 cudaDeviceSynchronize)都会导致捕获失败。

我曾经在一个项目里,为了图方便,在捕获中间调用了 cudaMalloc 来分配临时缓冲区。结果CUDA直接返回 cudaErrorStreamCaptureImplicit 错误。后来我改成在捕获前一次性分配好所有内存,问题就解决了。

另外,捕获期间不要切换CUDA上下文。比如不要调用 cudaSetDevice。这会导致捕获状态混乱。

4.5 什么时候用流捕获?

我个人觉得,流捕获最适合以下场景:

  • 你的计算流程是固定的,但每次执行时参数(比如数据指针)会变。
  • 你有一个复杂的、多kernel的计算链,手动构建Graph太麻烦。
  • 你想把现有的、基于流的代码快速迁移到Graph上,获得性能提升。

说白了,流捕获就是CUDA给咱们提供的一条“捷径”。它让你不用手写Graph API那些繁琐的节点和边,而是通过“录制”的方式自动生成。对于大多数场景,这已经足够了。

好,关于流捕获的入门,咱们就聊到这里。下一节,我会讲讲如何对捕获到的Graph进行修改和优化,让它更贴合你的实际需求。