一、子图
子图,child graph。子图其实就是图,这样说可能有点意外。这么说吧,父亲是人,儿子同样也是人。这么理解就明白了。之所以叫子图,是因为把这个图嵌入到了另外一个图中,所以为了区别二者间的关系,就称之为子图。
计算机世界和现实世界没什么区别,当解决一个问题时发现这个问题比较复杂,就为将其分解为多个小的问题。这些小问题,就可以称之为大问题的子问题。顺而推理,子图就是嵌套在图中的图。
简单的理解就是子图和父图只是一种逻辑层次关系而不是一种互相独立的功能关系。
二、子图创建的方式
在前面的"图的构建"中已经分析了图的创建的两种方法。既然说子图就是图,那么在CUDA中创建子图的方法与其相同,有两种形式,一种是直接使用创建图的接口创建一个图,然后嵌入到另外一个图中;另外一个就是使用显式的子图接口创建。下面将分别进行说明:
- 创建图然后嵌入
这种方法一般为分以下几步:
1)创建CUDA流
2)通过cudaStreamBeginCapture捕获流
3)在流中启动子图中的任务操作
4)调用cudaStreamEndCapture结束捕获
5)创建主图(也可以在其它地方创建)并调用cudaGraphAddChildGraphNod添子图添加到父图中去 - 使用显式的创建接口
这种方法可以通过手动的方法更精细的操作子图的创建,其步骤一般是:
1)使用cudaGraphCreate创建一个空子图
2)利用cudaGraphAddMemcpyNode等接口向子图中添加节点并指定其依赖关系
3)创建主图,使用cudaGraphAddChildGraphNode将其嵌入到父图中
三、应用场景
子图的使用其实从定义就可以看出来,它主要是用来解决复杂的问题。简单问题还分出子图来干啥?所以子图的主要目的就是通过分治法实现图的分层、分模块,实现动态的工作流控制。其常用的工作场景包括:
- 工作任务的模块化分解
这个非常好理解,一些可以固定划分的任务流,为了可以反复重用或其它图中调用,可以独立封装一个子图 - 复杂图的分解
如果一个图的流程比较复杂,可以把其划分成多个功能子图,这样更容易进行开发、调试和维护 - 条件节点
这个没有办法,要求必须应用子图 - 图的更新
这也是分治法的优势,如果一个大图更新一定不如只更新一个子图简单安全 - 动态构建图
这个就更容易理解了,在动态创建图的过程中,同样只增加子节点的方式来进行图的创建会更方便和安全 - 其它
这里就包括CUDA中其它和子图的相关的应用,如并行操作、内存的复用等等
四、限制条件
虽然说子图和父图一应的操作方法没有什么区别,但是在细节还是有些不同。不然为啥一个是儿子一个是父亲呢?一般来说,子图的限制条件如下:
- 节点类型受限
子图中的节点只允许内核节点、空节点内存设备、拷贝节点和条件节点以及子图节点 - 上下文统一
即子图中所有的内核操作必须属于同一个CUDA上下文 - 静态图要求
即图的定义必须在构建后固定不支持动态修改。 - 禁止同步
在流捕获期间,禁止CPU和GPU间的同步操作(有可能导致捕获失败)。
五、例程
子图其实是一个很常见的应用,在前面的条件节点中已经有相关的例程,下面再看一下创建两种方法动态创建:
c
#include "device_launch_parameters.h"
#include <cuda_runtime.h>
#include <iostream>
// 内核A:设置为1.0
__global__ void kernelA(float* data) {
int idx = threadIdx.x + blockIdx.x * blockDim.x;
if (idx < 1024) {
data[idx] = 1.0f;
}
}
// 内核B:加1.0
__global__ void kernelB(float* data) {
int idx = threadIdx.x + blockIdx.x * blockDim.x;
if (idx < 1024) {
data[idx] += 1.0f;
}
}
int main() {
float* dData = nullptr;
cudaMalloc(&dData, 1024 * sizeof(float));
// 创建子图(两个节点)
cudaGraph_t childGraph;
cudaGraphCreate(&childGraph, 0);
cudaGraphNode_t nodeA, nodeB;
cudaKernelNodeParams paramsA = { 0 }, paramsB = { 0 };
// 配置内核A参数
void* argsA[] = { &dData };
paramsA.func = (void*)kernelA;
paramsA.gridDim = dim3(4, 1, 1); // 4个块,每个块256线程 1024个元素
paramsA.blockDim = dim3(256, 1, 1);
paramsA.sharedMemBytes = 0;
paramsA.kernelParams = argsA;
cudaGraphAddKernelNode(&nodeA, childGraph, nullptr, 0, ¶msA);
// 配置内核B参数,并依赖于内核A
void* argsB[] = { &dData };
paramsB.func = (void*)kernelB;
paramsB.gridDim = dim3(4, 1, 1);
paramsB.blockDim = dim3(256, 1, 1);
paramsB.sharedMemBytes = 0;
paramsB.kernelParams = argsB;
cudaGraphAddKernelNode(&nodeB, childGraph, &nodeA, 1, ¶msB);
std::cout << "子图通过显式API创建成功!" << std::endl;
// 创建主图并将子图作为节点添加
cudaGraph_t mainGraph;
cudaGraphCreate(&mainGraph, 0);
cudaGraphNode_t childGraphNode;
// 将子图节点添加到主图,无依赖
cudaGraphAddChildGraphNode(&childGraphNode, mainGraph, nullptr, 0, childGraph);
// 内存拷贝节点,将结果数据从设备拷贝回主机
float hData[1024] = { 0 };
cudaGraphNode_t memcpyNode;
cudaMemcpy3DParms memcpyParams = { 0 };
memcpyParams.srcPtr = make_cudaPitchedPtr(dData, 1024 * sizeof(float), 1024, 1);
memcpyParams.dstPtr = make_cudaPitchedPtr(hData, 1024 * sizeof(float), 1024, 1);
memcpyParams.extent = make_cudaExtent(1024 * sizeof(float), 1, 1);
memcpyParams.kind = cudaMemcpyDeviceToHost;
cudaGraphAddMemcpyNode(&memcpyNode, mainGraph, &childGraphNode, 1, &memcpyParams);
//实例化并执行主图
cudaGraphExec_t graphExec;
cudaGraphInstantiate(&graphExec, mainGraph, NULL, NULL, 0);
cudaGraphLaunch(graphExec, 0);
cudaDeviceSynchronize();
// 验证结果:所有元素应为2.0
bool ok = true;
for (int i = 0; i < 1024; ++i) {
if (hData[i] != 2.0f) {
ok = false;
break;
}
}
std::cout << "结果验证: " << (ok ? "通过" : "失败") << std::endl;
cudaGraphExecDestroy(graphExec);
cudaGraphDestroy(mainGraph);
cudaGraphDestroy(childGraph);
cudaFree(dData);
return 0;
}
注释已经将相关的应用说明的很清楚,不再赘述。使用的流的方式创建子图请参看前面的条件节点中的方法。
六、总结
分治法是计算机中解决问题的不二法门,几乎所有的应用中都或多或少的可以看到它的影子。掌握好分治法对软件开发者来说是全场景覆盖的,不管是算法、前端、架构还后端等等。