2.5. 异步执行
2.5.1. 什么是异步并发执行?
CUDA 允许多个任务并发执行,也就是重叠执行,具体包括:
主机上的计算
设备上的计算
从主机到设备的内存传输
从设备到主机的内存传输
给定设备内存内部的内存传输
设备之间的内存传输
这种并发性通过异步接口表达,其中用于分派操作的函数调用或核函数启动会立即返回。异步调用通常在所分派的操作完成之前返回,并且可能在异步操作开始之前就返回。随后,应用程序便可在最初分派的操作执行的同时执行其他任务。当需要最初分派的操作的最终结果时,应用程序必须进行某种形式的同步,以确保该操作已经完成。并发执行模式的一个典型示例是让主机与设备之间的内存传输与计算重叠,从而减少或消除这些传输的开销。
图 20 使用 CUDA 流进行异步并发执行
图内文字中文对照
Stream Execution- 流执行
API calls- API 调用
Memcpy H2D- 主机到设备的内存复制
Kernel- 核函数
Event- 事件
Kernel 2- 核函数 2
Memcpy D2H- 设备到主机的内存复制
CPU Work- CPU 工作
Synchronization point- 同步点
Time- 时间
一般而言,异步接口通常提供三种主要方式来与所分派的操作同步
阻塞方式,应用程序调用一个会阻塞的函数,即一直等待到操作完成
非阻塞方式,也称为轮询方式,应用程序调用一个立即返回并提供操作状态信息的函数
回调方式,在操作完成后执行一个预先注册的函数。
虽然编程接口是异步的,但实际能否并发执行各种操作取决于 CUDA 的版本和所用硬件的计算能力。这些细节将在本指南后面的章节中介绍(请参阅计算能力)。
在同步 CPU 和 GPU中,介绍了 CUDA 运行时函数cudaDeviceSynchronize(),这是一个阻塞调用,会等待此前发出的所有工作完成。之所以需要调用cudaDeviceSynchronize(),是因为核函数启动是异步的,会立即返回。CUDA 为阻塞和非阻塞这两种同步方式都提供了 API,甚至支持使用主机端回调函数。
CUDA 中用于异步执行的核心 API 组件是CUDA 流和CUDA 事件。在本节余下部分中,我们将解释如何使用这些元素来表达 CUDA 中的异步执行。
一个相关主题是CUDA 图,它允许预先定义由异步操作构成的图,然后以极小的开销重复执行该图。我们在第 2.4.9.2 节 通过流捕获入门 CUDA 图中对 CUDA 图进行非常基础的介绍,并在第 4.1 节 CUDA 图中提供更全面的讨论。
2.5.2. CUDA 流
从最基本的层面来看,CUDA 流是一种抽象,允许程序员表达一系列操作。流的工作方式类似于工作队列,程序可以向其中添加操作,例如内存复制或核函数启动,以便按顺序执行。给定流的队首操作执行后会出队,使下一个已排队的操作移至队首,并被考虑执行。流中操作的执行顺序是顺序性的,操作按其进入流中队列的顺序执行。
应用程序可以同时使用多个流。在这种情况下,运行时将根据 GPU 资源的状态,从有待执行工作的流中选择一个任务来执行。可以为流分配优先级,该优先级作为提示提供给运行时以影响调度,但不保证特定的执行顺序。
在流中执行的 API 函数调用和核函数启动相对于主机线程是异步的。应用程序可以通过等待流中不再有任务来与流同步,也可以在设备级别进行同步。
CUDA 有一个默认流,未指定具体流的操作和核函数启动会排入此默认流。未指定流的代码示例隐式地使用此默认流。默认流具有一些特定语义,将在阻塞流、非阻塞流和默认流小节中讨论。
2.5.2.1. 创建和销毁 CUDA 流
可以使用cudaStreamCreate()函数创建 CUDA 流。该函数调用会初始化流句柄,此句柄可在后续函数调用中用于标识该流。
cudaStream_t stream; // Stream handle
cudaStreamCreate(&stream); // Create a new stream
// stream based operations ...
cudaStreamDestroy(stream); // Destroy the stream
如果应用程序调用cudaStreamDestroy()时,设备仍在流stream中执行工作,则该流会在完成流中的所有工作后才被销毁。
2.5.2.2. 在 CUDA 流中启动核函数
用于启动核函数的常规三尖括号语法也可用于在特定流中启动核函数。流作为核函数启动的一个额外参数来指定。在以下示例中,名为kernel的核函数被启动到句柄为stream的流中,该句柄的类型为cudaStream_t,并且假定此流已经事先创建:
kernel<<<grid, block, shared_mem_size, stream>>>(...);
核函数启动是异步的,函数调用会立即返回。假设核函数启动成功,核函数将在流stream中执行,而应用程序可以在核函数执行期间,在 CPU 上或在 GPU 的其他流中执行其他任务。
2.5.2.3. 在 CUDA 流中发起内存传输
要在流中发起内存传输,可以使用函数cudaMemcpyAsync()。此函数与cudaMemcpy()函数类似,但它额外接受一个参数,用于指定内存传输所使用的流。下面代码块中的函数调用在流stream中,将size字节从src指向的主机内存复制到dst指向的设备内存。
// Copy `size` bytes from `src` to `dst` in stream `stream`
cudaMemcpyAsync(dst, src, size, cudaMemcpyHostToDevice, stream);
与其他异步函数调用一样,此函数调用会立即返回,而cudaMemcpy()函数则会阻塞,直到内存传输完成。为了安全地访问传输结果,应用程序必须使用某种形式的同步来确定操作已经完成。
其他 CUDA 内存传输函数,例如cudaMemcpy2D(),也有异步变体。
注意
为了使涉及 CPU 内存的内存复制能够异步进行,主机缓冲区必须采用页锁定内存。如果使用未进行页锁定的主机内存,cudaMemcpyAsync()仍能正确工作,但会退回到同步行为,无法与其他工作重叠。这可能会限制使用异步内存传输所带来的性能收益。建议程序使用cudaMallocHost()来分配用于向 GPU 发送数据或从 GPU 接收数据的缓冲区。
2.5.2.4. 流同步
与流同步的最简单方式是等待流中不再有任务。这可以通过两种方式完成,即使用cudaStreamSynchronize()函数或cudaStreamQuery()函数。
cudaStreamSynchronize()函数会阻塞,直到流中的所有工作完成。
// Wait for the stream to be empty of tasks
cudaStreamSynchronize(stream);
// At this point the stream is done
// and we can access the results of stream operations safely
如果不希望阻塞,而只需要快速检查流是否为空,可以使用cudaStreamQuery()函数。
// Have a peek at the stream
// returns cudaSuccess if the stream is empty
// returns cudaErrorNotReady if the stream is not empty
cudaError_t status = cudaStreamQuery(stream);
switch (status) {
case cudaSuccess:
// The stream is empty
std::cout << "The stream is empty" << std::endl;
break;
case cudaErrorNotReady:
// The stream is not empty
std::cout << "The stream is not empty" << std::endl;
break;
default:
// An error occurred - we should handle this
break;
};
2.5.3. CUDA 事件
CUDA 事件是一种向 CUDA 流中插入标记的机制。它们本质上类似于示踪粒子,可用于跟踪流中任务的进度。设想在一个流中启动两个核函数。如果没有这种跟踪事件,我们只能确定流是否为空。如果第三个操作,无论是在 CPU 上还是在另一个流中,依赖于第一个核函数的输出,那么只有在得知流已为空后,才能安全地开始该操作。流为空时,两个核函数都已完成。
使用 CUDA 事件可以做得更好。通过在第一个核函数之后、第二个核函数之前紧接着将一个事件排入流,我们可以让 CPU 线程或另一个流等待该事件到达流的队首。然后,我们就可以在已知第一个核函数完成、而第二个核函数尚未开始的情况下,安全地开始有依赖关系的操作。以这种方式使用 CUDA 事件可以构建操作和流之间的依赖关系图。这种图的类比直接对应于后面对CUDA 图的讨论。
CUDA 事件还会保存时间信息,可用于对核函数启动和内存传输计时。
2.5.3.1. 创建和销毁 CUDA 事件
可以使用cudaEventCreate()和cudaEventDestroy()函数创建和销毁 CUDA 事件。
cudaEvent_t event;
// Create the event
cudaEventCreate(&event);
// do some work involving the event
// Once the work is done and the event is no longer needed
// we can destroy the event
cudaEventDestroy(event);
应用程序负责在不再需要事件时销毁它们。
2.5.3.2. 向 CUDA 流中插入事件
可以使用cudaEventRecord()函数将 CUDA 事件插入流中。
cudaEvent_t event;
cudaStream_t stream;
// Create the event
cudaEventCreate(&event);
// Insert the event into the stream
cudaEventRecord(event, stream);
2.5.3.3. 对 CUDA 流中的操作计时
CUDA 事件可用于对各种流操作的执行计时,包括核函数。当事件到达流的队首时,它会记录一个时间戳。通过在流中的核函数前后各放置一个事件,可以准确测量核函数执行的持续时间,如下面的代码片段所示:
cudaStream_t stream;
cudaStreamCreate(&stream);
cudaEvent_t start;
cudaEvent_t stop;
// create the events
cudaEventCreate(&start);
cudaEventCreate(&stop);
// record the start event
cudaEventRecord(start, stream);
// launch the kernel
kernel<<<grid, block, 0, stream>>>(...);
// record the stop event
cudaEventRecord(stop, stream);
// wait for the stream to complete
// both events will have been triggered
cudaStreamSynchronize(stream);
// get the timing
float elapsedTime;
cudaEventElapsedTime(&elapsedTime, start, stop);
std::cout << "Kernel execution time: " << elapsedTime << " ms" << std::endl;
// clean up
cudaEventDestroy(start);
cudaEventDestroy(stop);
cudaStreamDestroy(stream);
2.5.3.4. 检查 CUDA 事件的状态
与检查流状态的情况一样,可以通过阻塞或非阻塞方式检查事件的状态。
cudaEventSynchronize()函数会阻塞,直到事件完成。在下面的代码片段中,我们在流中启动一个核函数,接着放入一个事件,然后再启动第二个核函数。我们可以使用cudaEventSynchronize()函数等待第一个核函数之后的事件完成,并且原则上可以立即启动一个依赖于它的任务,时间上可能早于kernel2完成。
cudaEvent_t event;
cudaStream_t stream;
// create the stream
cudaStreamCreate(&stream);
// create the event
cudaEventCreate(&event);
// launch a kernel into the stream
kernel<<<grid, block, 0, stream>>>(...);
// Record the event
cudaEventRecord(event, stream);
// launch a kernel into the stream
kernel2<<<grid, block, 0, stream>>>(...);
// Wait for the event to complete
// Kernel 1 will be guaranteed to have completed
// and we can launch the dependent task.
cudaEventSynchronize(event);
dependentCPUtask();
// Wait for the stream to be empty
// Kernel 2 is guaranteed to have completed
cudaStreamSynchronize(stream);
// destroy the event
cudaEventDestroy(event);
// destroy the stream
cudaStreamDestroy(stream);
可以使用cudaEventQuery()函数以非阻塞方式检查 CUDA 事件是否完成。在下面的示例中,我们在一个流中启动 2 个核函数。第一个核函数kernel1生成一些我们希望复制到主机的数据,但我们也有一些 CPU 端工作要做。在下面的代码中,我们依次将kernel1、一个事件(event)以及kernel2排入流stream1。随后,我们进入 CPU 工作循环,但会偶尔检查一下事件是否完成,这表明kernel1已经完成。如果是,就在流stream2中发起从设备到主机的复制。这种方式允许 CPU 工作与 GPU 核函数执行以及从设备到主机的复制重叠。
cudaEvent_t event;
cudaStream_t stream1;
cudaStream_t stream2;
size_t size = LARGE_NUMBER;
float* d_data;
float* h_data;
// Create some data
cudaMalloc(&d_data, size);
cudaMallocHost(&h_data, size);
// create the streams
cudaStreamCreate(&stream1); // Processing stream
cudaStreamCreate(&stream2); // Copying stream
bool copyStarted = false;
// create the event
cudaEventCreate(&event);
// launch kernel1 into the stream
kernel1<<<grid, block, 0, stream1>>>(d_data, size);
// enqueue an event following kernel1
cudaEventRecord(event, stream1);
// launch kernel2 into the stream
kernel2<<<grid, block, 0, stream1>>>();
// while the kernels are running do some work on the CPU
// but check if kernel1 has completed because then we will start
// a device to host copy in stream2
while ( not allCPUWorkDone() || not copyStarted ) {
doNextChunkOfCPUWork();
// peek to see if kernel 1 has completed
// if so enqueue a non-blocking copy into stream2
if ( not copyStarted ) {
if( cudaEventQuery(event) == cudaSuccess ) {
cudaMemcpyAsync(h_data, d_data, size, cudaMemcpyDeviceToHost, stream2);
copyStarted = true;
}
}
}
// wait for both streams to be done
cudaStreamSynchronize(stream1);
cudaStreamSynchronize(stream2);
// destroy the event
cudaEventDestroy(event);
// destroy the streams and free the data
cudaStreamDestroy(stream1);
cudaStreamDestroy(stream2);
cudaFree(d_data);
free(h_data);
2.5.4. 来自流的回调函数
CUDA 提供了一种从流内在主机上启动函数的机制。目前有两个函数可用于此目的:cudaLaunchHostFunc()和cudaAddCallback()。不过,cudaAddCallback()已计划弃用,因此应用程序应使用cudaLaunchHostFunc()。
使用cudaLaunchHostFunc()
cudaLaunchHostFunc()函数的签名如下:
cudaError_t cudaLaunchHostFunc(cudaStream_t stream, void (*func)(void *), void *data);
其中
stream:要将回调函数启动到其中的流。func:要启动的回调函数。data:指向要传递给回调函数的数据的指针。
主机函数本身是一个简单的 C 函数,其签名为:
void hostFunction(void *data);
其中,data参数指向一个用户定义的数据结构,函数可以解释该数据结构。使用此类回调函数时,需要牢记一些注意事项。特别是,主机函数不得调用任何 CUDA API。
为了与统一内存配合使用,提供了以下执行保证:
在函数执行期间,流被视为空闲。因此,例如,函数始终可以使用关联到该函数所入队的流的内存。
函数开始执行的效果,等同于对紧接在该函数之前记录在同一流中的事件进行同步。因此,它会同步在该函数之前已被“汇合”的流。
向任何流添加设备工作,都不会使该流变为活动状态,直到此前所有主机函数和流回调均已执行完毕。因此,例如,即使已向另一个流添加工作,只要通过事件将该工作安排在函数调用之后执行,函数仍然可以使用全局关联的内存。
除上述情况外,函数的完成不会使流变为活动状态。如果函数之后没有设备工作,则流将保持空闲;在连续的主机函数或流回调之间没有设备工作时,流也会始终保持空闲。因此,例如,可以通过流末尾的主机函数发出信号来完成流同步。
2.5.4.1. 使用cudaStreamAddCallback()
注意
cudaStreamAddCallback()函数已计划弃用并移除,这里讨论它是为了完整性,也因为现有代码中可能仍会出现它。应用程序应使用或改为使用cudaLaunchHostFunc()。
cudaStreamAddCallback()函数的签名如下:
cudaError_t cudaStreamAddCallback(cudaStream_t stream, cudaStreamCallback_t callback, void* userData, unsigned int flags);
其中
stream:要将回调函数启动到其中的流。callback:要启动的回调函数。userData:指向要传递给回调函数的数据的指针。flags:目前,为了将来的兼容性,该参数必须为 0。
callback函数的签名与我们使用cudaLaunchHostFunc()函数时的情况略有不同。在这种情况下,回调函数是一个 C 函数,其签名为:
void callbackFunction(cudaStream_t stream, cudaError_t status, void *userData);
其中,现在传递给函数的是
stream:启动该回调函数的流的句柄。status:触发该回调的流操作的状态。userData:指向已传递给回调函数的数据的指针。
特别是,status参数将包含流的当前错误状态,该状态可能已由先前的操作设置。与cudaLaunchHostFunc()函数的情况类似,在主机函数完成之前,流不会变为活动状态并继续执行任务,而且在回调函数内部不得调用任何 CUDA 函数。
2.5.4.2. 异步错误处理
在 CUDA 流中,错误可能来自流中的任何操作,包括核函数启动和内存传输。在运行时,这些错误可能要等到流被同步时才会传播回用户,例如通过等待事件或调用cudaStreamSynchronize()进行同步。有两种方式可以了解流中可能发生的错误。
使用
cudaGetLastError()函数:此函数返回并清除当前上下文中任何流内遇到的最后一个错误。如果两次调用之间没有发生其他错误,那么立即再次调用 cudaGetLastError() 将返回cudaSuccess。使用
cudaPeekAtLastError()函数:此函数返回当前上下文中的最后一个错误,但不清除它。
这两个函数都将错误作为cudaError_t类型的值返回。可以使用cudaGetErrorName()和cudaGetErrorString()函数生成错误的可打印名称名称。
下面展示了使用这些函数的示例:
// Some work occurs in streams.
cudaStreamSynchronize(stream);
// Look at the last error but do not clear it
cudaError_t err = cudaPeekAtLastError();
if (err != cudaSuccess) {
printf("Error with name: %s\n", cudaGetErrorName(err));
printf("Error description: %s\n", cudaGetErrorString(err));
}
// Look at the last error and clear it
cudaError_t err2 = cudaGetLastError();
if (err2 != cudaSuccess) {
printf("Error with name: %s\n", cudaGetErrorName(err2));
printf("Error description: %s\n", cudaGetErrorString(err2));
}
if (err2 != err) {
printf("As expected, cudaPeekAtLastError() did not clear the error\n");
}
// Check again
cudaError_t err3 = cudaGetLastError();
if (err3 == cudaSuccess) {
printf("As expected, cudaGetLastError() cleared the error\n");
}
提示
当错误在同步时出现,尤其是在包含许多操作的流中出现时,往往很难精确定位错误可能发生在流中的哪个位置。要调试这种情况,一个有用的技巧可能是设置环境变量CUDA_LAUNCH_BLOCKING=1,然后运行应用程序。该环境变量的作用是在每一次核函数启动后进行同步。这有助于追踪是哪个核函数或哪次传输导致了错误。同步的开销可能很高;设置此环境变量后,应用程序的运行速度可能会显著变慢。
2.5.5. CUDA 流顺序
既然已经讨论了流、事件和回调函数的基本机制,接下来就有必要考虑流中异步操作的顺序语义。这些语义旨在让应用程序程序员能够以安全的方式考量流中操作的顺序。在某些特殊情况下,为了优化性能,可以放宽这些语义,例如程序化依赖核函数启动的情况,它通过使用特殊属性和核函数启动机制,允许两个核函数重叠执行;或者使用异步批量内存复制函数对内存传输进行批处理的情况,此时运行时可以并发执行互不重叠的批量复制。
最重要的是,CUDA 流属于所谓的顺序流。这意味着流中操作的执行顺序与这些操作的入队顺序相同。流中的操作不能越过其他操作。内存操作(例如复制)由运行时跟踪,并且总是在下一个操作之前完成,以便依赖这些操作的核函数能够安全地访问正在传输的数据。
2.5.6. 阻塞流、非阻塞流和默认流
CUDA 中有两种类型的流:阻塞流和非阻塞流。这些名称可能略具误导性,因为阻塞和非阻塞语义仅指这些流如何与默认流同步。默认情况下,通过cudaStreamCreate()创建的流是阻塞流。要创建非阻塞流,必须使用带有cudaStreamNonBlocking标志的cudaStreamCreateWithFlags()函数:
cudaStream_t stream;
cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking);
而非阻塞流可以使用cudaStreamDestroy()按通常的方式销毁。
2.5.6.1. 传统默认流
阻塞流与非阻塞流之间的关键区别在于它们如何与默认流同步。CUDA 提供了一个传统默认流(也称为 NULL 流,或流 ID 为 0 的流),当核函数启动或阻塞式cudaMemcpy()调用未指定流时,就会使用该流。这个由所有主机线程共享的默认流是一个阻塞流。当一个操作被启动到此默认流中时,它会与所有其他阻塞流同步;换句话说,它必须等待所有其他阻塞流完成后才能执行。
cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
kernel1<<<grid, block, 0, stream1>>>(...);
kernel2<<<grid, block>>>(...);
kernel3<<<grid, block, 0, stream2>>>(...);
cudaDeviceSynchronize();
默认流的行为意味着,在上面的上述代码片段中,kernel2将等待kernel1完成,而kernel3将等待kernel2完成,即使原则上这三个核函数可以并发执行。通过创建非阻塞流,我们可以避免这种同步行为。在下面的代码片段中,我们创建了两个非阻塞流。默认流将不再与这些流同步,原则上这三个核函数可以并发执行。因此,我们不能假定核函数有任何执行顺序,并且应执行显式同步(例如使用相当强制的cudaDeviceSynchronize()调用),以确保核函数已经完成。
cudaStream_t stream1, stream2;
cudaStreamCreateWithFlags(&stream1, cudaStreamNonBlocking);
cudaStreamCreateWithFlags(&stream2, cudaStreamNonBlocking);
kernel1<<<grid, block, 0, stream1>>>(...);
kernel2<<<grid, block>>>(...);
kernel3<<<grid, block, 0, stream2>>>(...);
cudaDeviceSynchronize();
2.5.6.2. 每线程默认流
从 CUDA-7 开始,CUDA 允许每个主机线程拥有各自独立的默认流,而不是共享的传统默认流。要启用此行为,必须使用nvcc编译器选项--default-stream per-thread,或者定义CUDA_API_PER_THREAD_DEFAULT_STREAM预处理器宏。启用此行为后,每个主机线程都将拥有各自独立的默认流,这些流不会像传统默认流那样与其他流同步。在这种情况下,传统默认流示例现在将表现出与非阻塞流示例相同的同步行为。
2.5.7. 显式同步
有多种方式可以显式地使流彼此同步。
cudaDeviceSynchronize()会等待,直到所有主机线程的所有流中此前的全部命令均已完成。
cudaStreamSynchronize()接受一个流作为参数,并等待给定流中此前的全部命令完成。它可用于将主机与某个特定流同步,同时允许其他流继续在设备上执行。
cudaStreamWaitEvent()接受一个流和一个事件作为参数(有关事件的说明,请参阅CUDA 事件),并使调用cudaStreamWaitEvent()之后添加到给定流中的所有命令延迟执行,直到给定事件完成。
cudaStreamQuery()为应用程序提供了一种获知流中此前的全部命令是否已经完成的方式。
2.5.8. 隐式同步
如果在来自不同流的两个操作之间提交了任何 NULL 流上的 CUDA 操作,那么这两个操作就不能并发运行,除非这些流是非阻塞流(使用cudaStreamNonBlocking标志创建)。
应用程序应遵循以下准则,以增加核函数并发执行的可能性:
所有独立操作都应在具有依赖关系的操作之前发出,
任何形式的同步都应尽可能推迟。
2.5.9. 其他主题与高级主题
2.5.9.1. 流优先级设置
如前所述,开发者可以为 CUDA 流分配优先级。带优先级的流需要使用cudaStreamCreateWithPriority()函数创建。该函数接受两个参数:流句柄和优先级。一般规则是,数值越小,优先级越高。可以使用cudaDeviceGetStreamPriorityRange()函数查询给定设备和上下文的优先级范围。流的默认优先级为 0。
int minPriority, maxPriority;
// Query the priority range for the device
cudaDeviceGetStreamPriorityRange(&minPriority, &maxPriority);
// Create two streams with different priorities
// cudaStreamDefault indicates the stream should be created with default flags
// in other words they will be blocking streams with respect to the legacy default stream
// One could also use the option `cudaStreamNonBlocking` here to create a non-blocking streams
cudaStream_t stream1, stream2;
cudaStreamCreateWithPriority(&stream1, cudaStreamDefault, minPriority); // Lowest priority
cudaStreamCreateWithPriority(&stream2, cudaStreamDefault, maxPriority); // Highest priority
应当注意,流的优先级只是对运行时的一个提示,通常主要适用于核函数启动,对于内存传输则可能不会被遵循。流优先级不会抢占已经在执行的工作,也不保证任何特定的执行顺序。
2.5.9.2. 通过流捕获介绍 CUDA 图
CUDA 流允许程序按顺序指定一系列操作,即核函数或内存复制。通过使用多个流以及通过cudaStreamWaitEvent建立的跨流依赖关系,应用程序可以指定一个完整的操作有向无环图(DAG)。某些应用程序可能有一个操作序列或操作 DAG,需要在执行过程中运行多次。
对于这种情况,CUDA 提供了一项称为 CUDA 图的功能。本节介绍 CUDA 图,以及一种称为流捕获的图创建机制。CUDA 图中对 CUDA 图进行了更详细的讨论。捕获或创建图有助于减少从主机线程反复调用同一串 API 调用的延迟和 CPU 开销。取而代之的是,可以只调用一次用于指定图操作的 API,然后多次执行所生成的图。
CUDA 图按以下方式工作:
由应用程序捕获图。此步骤在首次执行图时进行一次。也可以使用 CUDA 图 API 手动构建图。
对图进行实例化。此步骤在图捕获之后执行一次。此步骤可以设置执行图所需的各种运行时结构,以尽可能加快其组件的启动速度。
在其余步骤中,根据需要多次执行预先实例化的图。由于执行图操作所需的所有运行时结构都已就绪,因此图执行的 CPU 开销得以最小化。
#define N 500000 // tuned such that kernel takes a few microseconds
// A very lightweight kernel
__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];
}
bool graphCreated=false;
cudaGraph_t graph;
cudaGraphExec_t instance;
// The graph will be executed NSTEP times
for(int istep=0; istep<NSTEP; istep++){
if(!graphCreated){
// Capture the graph
cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal);
// Launch NKERNEL kernels
for(int ikrnl=0; ikrnl<NKERNEL; ikrnl++){
shortKernel<<<blocks, threads, 0, stream>>>(out_d, in_d);
}
// End the capture
cudaStreamEndCapture(stream, &graph);
// Instantiate the graph
cudaGraphInstantiate(&instance, graph, NULL, NULL, 0);
graphCreated=true;
}
// Launch the graph
cudaGraphLaunch(instance, stream);
// Synchronize the stream
cudaStreamSynchronize(stream);
}
CUDA 图中提供了更多关于 CUDA 图的详细信息。
2.5.10. 异步执行小结
本节的要点如下:
异步 API 使我们能够表达任务的并发执行,为表达各种操作的重叠提供了途径。实际实现的并发性取决于可用的硬件资源和计算能力。
CUDA 中用于异步执行的关键抽象是流、事件和回调函数。
可以在事件、流和设备级别进行同步
默认流是一个阻塞流,它与所有其他阻塞流同步,但不与非阻塞流同步
通过
--default-stream per-thread编译器选项或 CUDA_API_PER_THREAD_DEFAULT_STREAM 预处理器宏使用每线程默认流,可以避免默认流的行为。可以创建具有不同优先级的流,这些优先级是对运行时的提示,对于内存传输则可能不会被遵循。
CUDA 提供了用于减少核函数启动和内存传输的开销或使这些开销重叠的 API 函数,例如 CUDA 图、批量内存传输和程序化依赖核函数启动。