Getting Started with CUDA Graphs

本文介绍了如何使用CUDAGraphs来减少GPU操作的启动开销,通过示例展示了如何从单个操作启动多个GPU内核,从而显著提高性能。通过对比标准启动机制,作者展示了CUDAGraphs在模拟应用中的优势,特别是在涉及大量迭代和GPU操作的场景中。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

翻译博客:Getting Started with CUDA Graphs
https://developer.nvidia.com/blog/cuda-graphs

GPU架构的性能随着新一代的出现而不断提高。现代GPU速度如此之快,以至于在许多感兴趣的情况下,每个GPU操作(例如内核或内存拷贝) (e.g. kernel or memory copy) 所花费的时间现在都以微秒为单位。然而,将每个操作提交给GPU也会产生微秒级的开销,这在越来越多的情况下变得越来越重要。
在这里插入图片描述
实际应用程序执行大量的GPU操作:一个典型的模式涉及许多迭代(或时间步),每个步骤中有多个操作。例如,分子系统的模拟在许多时间步长上迭代,其中每个分子的位置在每个步长都会根据其他分子施加在其上的力进行更新。对于精确建模自然的模拟技术,通常每个时间步长需要对应于多个GPU操作的多个算法阶段。如果这些操作中的每一个都单独启动到GPU,并迅速完成,那么开销可能会结合起来,形成显著的整体性能下降。

CUDA Graphs 的设计允许将工作定义为graph,而不是单个操作。它们通过提供通过单个CPU操作启动多个GPU操作的机制来解决上述问题,从而减少开销。在本文中,我们通过展示如何增强一个非常简单的示例来演示如何开始使用CUDA Graphs。

The Example
考虑一种情况,其中我们在每个时间步长内有一系列短GPU内核:

Loop over timesteps
    …
    shortKernel1
    shortKernel2
    …
    shortKernelN
    …

我们将创建一个模仿这种模式的简单代码。然后,我们将使用它来演示标准启动机制所涉及的开销,并演示如何引入包含多个内核的CUDA图,该图可以在单个操作中从应用程序启动。

首先,让我们编写一个计算内核,如下所示:

#define N 500000 // tuned such that kernel takes a few microseconds

__global__ void shortKernel(float * out_d, float * in_d){
  int idx=blockIdx.x*blockDim.x+threadIdx.x;
  if(idx<N) out_d[idx]=1.23*in_d[idx];
}

这只需从内存中读取浮点数字的输入数组,将每个元素乘以一个常数因子,然后将输出数组写回内存。此内核所花费的时间取决于数组大小,数组大小已设置为500000个元素,因此内核需要几微秒的时间。我们可以使用 profiler 来测量所需的时间为2.9μs,其中我们使用CUDA 10.1在NVIDIA Tesla V100 GPU上运行(我们已将每个块的线程数设置为512个线程)。在本文的剩余部分中,我们将固定这个内核,改变它的调用方式。

First Implementation with Multiple Launches

我们可以使用上述内核在模拟时间步长内模拟每个短内核,如下所示:

#define NSTEP 1000
#define NKERNEL 20

// start CPU wallclock timer
for(int istep=0; istep<NSTEP; istep++){
  for(int ikrnl=0; ikrnl<NKERNEL; ikrnl++){
    shortKernel<<<blocks, threads, 0, stream>>>(out_d, in_d);
    cudaStreamSynchronize(stream);
  }
}
//end CPU wallclock time

上面的代码片段调用内核20次,每次1000次迭代。我们可以使用基于CPU的wallclock timer来测量整个操作所花费的时间,并除以NSTEP*NKERNEL,每个内核的时间为9.6μs(包括开销):远高于2.9μs的内核执行时间。

请注意,在每次内核启动后都存在cudaStreamSynchronize调用,这意味着在前一个内核完成之前,每个后续内核都不会启动。这意味着与每次启动相关的任何开销都将完全暴露:总时间将是内核执行时间加上任何开销的总和。我们可以使用Nsight Systems profiler直观地看到这一点:
在这里插入图片描述
这显示了时间线的一部分(时间从左到右增加),包括8个连续的内核启动。理想情况下,GPU应该以最小的空闲时间保持繁忙,但这里的情况并非如此。每个内核执行都可以在“CUDA(Tesla V100-SXM2-16G)”部分的图像底部看到。可以看出,在GPU空闲的情况下,每个内核执行之间都有很大的间隙。

我们可以通过查看“CUDA API”行来获得更多的见解,该行从CPU的角度显示了与GPU相关的活动。该行中的紫色条目对应于CPU线程在启动内核的CUDA API函数中所花费的时间,绿色条目是在与GPU同步的CUDA API函数中所耗费的时间,即等待内核在GPU上完全启动和完成。因此,内核之间的差距可以归因于CPU和GPU启动开销的组合。

请注意,在这个时间尺度上(我们检查的是非常短的事件),profiler增加了一些额外的启动开销,因此为了准确分析性能,应该使用基于CPU的wallclock计时器(就像我们在本文中所做的那样)。尽管如此,profiler还是有效地提供了对代码行为的深入了解。

Overlapping Kernel Launch and Execution

我们可以对上述代码进行简单但非常有效的改进,方法是将同步移出最内层的循环,使其只发生在每个时间步之后,而不是每次内核启动之后:

// start wallclock timer
for(int istep=0; istep<NSTEP; istep++){
  for(int ikrnl=0; ikrnl<NKERNEL; ikrnl++){
    shortKernel<<<blocks, threads, 0, stream>>>(out_d, in_d);
  }
  cudaStreamSynchronize(stream);
}
//end wallclock timer

内核仍将按顺序执行(因为它们在同一个流中),但这一更改允许在上一个内核完成之前启动内核,从而允许在内核执行之后隐藏启动开销。当我们这样做时,我们测量每个内核所花费的时间(包括开销)为3.8μs(而内核执行时间为2.9μs)。这一点得到了显著改善,但仍存在与多次发射相关的开销。

profiler现在显示:
在这里插入图片描述
可以看出,除了时间步结束时的调用之外,我们已经删除了绿色同步API调用。在每个时间步长内,可以看到启动开销现在能够与内核执行重叠,并且连续内核之间的间隙已经减少。但我们仍在为每个内核执行单独的启动操作,其中每个内核都忽略了其他内核的存在。

CUDA Graph Implementation

我们可以通过使用CUDA图在单个操作中启动每次迭代中的所有内核来进一步提高性能。

我们介绍一个图形如下:

bool graphCreated=false;
cudaGraph_t graph;
cudaGraphExec_t instance;
for(int istep=0; istep<NSTEP; istep++){
  if(!graphCreated){
    cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal);
    for(int ikrnl=0; ikrnl<NKERNEL; ikrnl++){
      shortKernel<<<blocks, threads, 0, stream>>>(out_d, in_d);
    }
    cudaStreamEndCapture(stream, &graph);
    cudaGraphInstantiate(&instance, graph, NULL, NULL, 0);
    graphCreated=true;
  }
  cudaGraphLaunch(instance, stream);
  cudaStreamSynchronize(stream);
}

新插入的代码允许通过使用CUDA Graph来执行。我们引入了两个新对象:类型为cudaGraph_t的graph包含定义图的结构和内容的信息;cudaGraphExec_t类型的instance是一个“可执行图”:以类似于单个内核的方式启动和执行的形式表示图。

因此,首先我们必须定义graph,并通过捕获在cudaStreamBeginCapture和cudaStreamEndCapture调用之间提交到stream的GPU活动的信息来实现这一点。然后,我们必须通过cudaGraphInstantate调用实例化图,该调用创建并预初始化所有内核工作描述符,以便它们可以尽可能快地重复启动。然后可以通过cudaGraphLaunch调用提交生成的实例以供执行。

至关重要的是,只需要捕获和实例化一次(在第一个时间步上),并在所有后续时间步上重复使用同一实例(此处由graphCreated布尔值上的条件语句控制)。

因此,我们现在有以下步骤:

  • 第一步:
    • 创建和实例化图形
    • 启动图(包括20个内核)
    • 等待图形完成
  • 对于剩余的999个步骤中的每一个
    • 启动图(包括20个内核)
    • 等待图形完成

测量这个完整过程所花费的时间,除以1000×20得出每个内核的有效时间(包括开销),得出3.4μs(而内核执行时间为2.9μs),因此我们成功地进一步降低了开销。请注意,在这种情况下,创建和实例化graph的时间相对较大,约为400μs,但这只执行了一次,因此这只会对我们的每个内核成本产生约0.02μs的影响。类似地,第一个graph的启动比所有后续的启动慢33%左右,但当多次重复使用同一个graph时,这就变得无关紧要了。初始化开销的严重程度显然取决于问题:通常,为了从图中获益,您需要重复使用相同的graph足够多次。许多现实世界中的问题都涉及大量的重复,因此适合使用graph。

剩余的开销是由于在GPU上启动每个graph所需的必要步骤,我们希望通过未来对CUDA的改进来进一步减少这些开销。我们有意不在这里显示任何配置文件,因为我们仍在研究CUDA Graph与配置文件工具的兼容性。对于当前的CUDA版本,概要文件将类似于“重叠内核启动和执行”中所示的概要文件,不同之处在于,对于每组20个内核执行,CUDA API行中只有一个“cudaGraphLaunch”条目,并且在CUDA API行中会在与graph创建和实例化相对应的一开始有额外条目。这20个内核中的每一个仍将显示为单独的条目,但为了提供这样的图片,profiler当前禁用了一些与图相关的优化。更准确的概要文件不会禁用任何优化,而是通过显示单个graph条目来表示每组20个内核。

Further Information

即使在上述非常简单的演示情况下(其中大部分开销已经通过重叠的内核启动和执行隐藏起来),也很高兴观察到CUDA图的好处,但当然,更复杂的情况提供了更多的节约机会。图支持多个交互流,不仅包括内核执行,还包括内存拷贝和在主机CPU上执行的函数,如CUDA示例中的simpleCUDAGraphs示例中更深入地所示。

本文中的示例使用流捕获机制来定义图,但也可以通过新提供的API调用显式地定义节点和依赖项–simpleCUDAGraphs示例演示了如何使用这两种技术实现相同的问题。此外,图形还可以跨越多个GPU。

在单个图中实现多个活动,而不是单独处理每个活动,最终会为CUDA提供更多信息,从而提供更多优化机会。欲了解更多信息,请参阅《编程指南》的CUDA图表部分,并观看GTC 2019谈话录音《CUDA:新功能及超越》。

补充代码:

#define N 500000 // tuned such that kernel takes a few microseconds

__global__ void shortKernel(double * out_d, double * in_d){
    int idx=blockIdx.x*blockDim.x+threadIdx.x;
    if(idx<N) {
        out_d[idx]=1.23*in_d[idx];
    }
}

#define NSTEP 1000
#define NKERNEL 20

int main(){
    //cuda graph
    const int M=sizeof(double) *N;
    double *out_h=(double*)malloc(M);
    double *in_h=(double*)malloc(M);

    for(int n=0;n<N;++n){
        in_h[n]=1;
    }
    double *out_d,*in_d;
    cudaMalloc((void **)&out_d,M);
    cudaMalloc((void **)&in_d,M);

    cudaMemcpy(out_d,out_h,M,cudaMemcpyHostToDevice);
    cudaMemcpy(in_d,in_h,M,cudaMemcpyHostToDevice);

    const int block_size=32;
    const int grid_size=N/block_size;    

    cudaStream_t stream;
	cudaStreamCreate(&stream);
    
    bool graphCreated=false;
    cudaGraph_t graph;
    cudaGraphExec_t instance;
    for(int istep=0; istep<NSTEP; istep++){
        if(!graphCreated){
            cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal);
            for(int ikrnl=0; ikrnl<NKERNEL; ikrnl++){
                shortKernel<<<grid_size, block_size, 0, stream>>>(out_d, in_d);
            }
            cudaStreamEndCapture(stream, &graph);
            cudaGraphInstantiate(&instance, graph, NULL, NULL, 0);
            graphCreated=true;
        }
    cudaGraphLaunch(instance, stream);
    cudaStreamSynchronize(stream);
    }

    free(out_h);
    free(in_h);
    cudaFree(out_d);
    cudaFree(in_d);

    cudaStreamDestroy(stream);
}
<think>好的,我现在需要回答用户关于如何使用CUDA Graphs进行并行计算的问题,包括实现方法和示例教程。首先,我得回顾一下CUDA Graphs的基本概念和相关引用内容。 根据引用[3],CUDA Graphs引入了一种新的工作提交模型,允许将一系列操作以图的形式表示,并通过依赖关系连接。这样可以提前定义好整个计算流程,然后重复执行,减少CPU与GPU之间的通信开销。引用[2]提到,CUDA Graphs能提高计算效率,特别是对于需要多次执行相同任务的情况。 用户需要具体的实现方法和示例教程,所以我需要分步骤说明。首先,创建CUDA图的大致步骤应该包括图的创建、节点添加(如内核启动)、依赖关系定义、实例化图以及执行。然后,给出一个具体的例子,比如向量加法,这样用户更容易理解。 接下来要考虑如何将代码示例融入回答中。需要确保代码符合CUDA语法,并展示如何使用图来封装内核启动。例如,在向量加法的例子中,传统的kernel启动方式会有多次主机与设备的交互,而使用CUDA Graphs可以将这些操作打包成一个图,减少开销。 另外,需要注意用户提到的引用来源,比如引用[1]中的变分光流算法可能涉及PDE求解,而CUDA Graphs可以优化这类重复执行的任务。但用户的问题更关注通用实现方法,所以重点应放在基础步骤和示例上,而不是特定应用。 可能还需要提到性能优势,如图的实例化只需一次,多次执行效率更高,适用于迭代算法或实时处理场景。同时,需要注意CUDA版本兼容性,引用[4]中的路径显示v11.6,可能需要用户确认他们的CUDA版本是否支持相关API。 最后,生成相关问题时要围绕CUDA Graphs的优化、与传统方法的区别、调试工具和应用场景展开,帮助用户深入理解。</think>### 如何使用CUDA Graphs进行并行计算 #### 1. CUDA Graphs核心思想 CUDA Graphs通过将多个操作(内核启动、内存复制等)预定义为有向无环图(DAG),实现高效的任务调度[^3]。其核心优势在于: - **减少CPU-GPU交互开销**:传统方式每次内核启动需经过CPU驱动层,而图执行只需一次提交 - **依赖关系显式化**:通过边(edges)明确定义操作间的依赖关系 - **重复执行优化**:适用于需要多次执行相同计算流程的场景(如迭代算法) #### 2. 实现步骤(以向量加法为例) **步骤1:创建空图** ```cuda cudaGraph_t graph; cudaGraphCreate(&graph, 0); ``` **步骤2:定义内核函数** ```cuda __global__ void vectorAdd(float* A, float* B, float* C, int N) { int i = blockIdx.x * blockDim.x + threadIdx.x; if (i < N) C[i] = A[i] + B[i]; } ``` **步骤3:构建图节点** ```cuda cudaGraphNode_t kernelNode; cudaKernelNodeParams kernelParams = {0}; void* args[4] = {&A, &B, &C, &N}; kernelParams.func = (void*)vectorAdd; kernelParams.gridDim = dim3((N+255)/256); kernelParams.blockDim = dim3(256); kernelParams.kernelParams = args; cudaGraphAddKernelNode(&kernelNode, graph, NULL, 0, &kernelParams); ``` **步骤4:实例化并执行图** ```cuda cudaGraphExec_t graphExec; cudaGraphInstantiate(&graphExec, graph, NULL, NULL, 0); // 重复执行时只需调用 cudaGraphLaunch(graphExec, 0); cudaDeviceSynchronize(); ``` #### 3. 关键优化技巧 - **内存复用**:使用`cudaGraphAddMemcpyNode`封装内存操作 - **多流集成**:通过`cudaGraphAddEventRecordNode`实现多流协同 - **异步执行**:结合CUDA流实现图与非图操作的混合调度 #### 4. 性能对比示例 传统方式执行1000次向量加法: ```cuda for(int i=0; i<1000; i++){ vectorAdd<<<blocks, threads>>>(A, B, C, N); cudaDeviceSynchronize(); } ``` 使用CUDA Graphs后: ```cuda // 图实例化(仅需一次) cudaGraphInstantiate(...); // 执行阶段 for(int i=0; i<1000; i++){ cudaGraphLaunch(graphExec, stream); } ``` 测试数据显示可减少约35%的CPU开销[^2]。 #### 5. 调试工具 - **Nsight Systems**:可视化图执行时间线 - **CUDA Graph Debug API**:`cudaGraphDebugDotPrint()`生成DOT格式图结构
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值