CUDA 会将下列一些操作当作一些独立的任务,并可以进行并发的执行:
并发主机端的执行通过异步库函数来实现的,它会在设备端任务执行完毕前将控制权交还给主机端的线程。使用异步调用,许多设备端操作可以排队让CUDA驱动在资源允许的情况下进行执行。这种方式大大减少了主机端线程对设备的管理责任,并且可以同时执行其他任务。下列设备端操作相对于主机端来说是异步的:
程序员可以在在同一系统上全局地关闭所有CUDA应用的核函数的异步启动,只需要将环境变量CUDA_LAUNCH_BLOCKING设置为1即可。这一个特性只是为了调试使用,在发布应用的时候不应该使用。
一些计算力大于2的设备可以同时执行多个核函数。应用程序可以通过查询设备属性concurrentKernels来确定是否支持该特性, 如果值为1表示支持,否则不支持。
不同计算力的设备可以同时执行核函数的个数是不相同的, 具体如下表所示:
但是,来自不同CUDA 上下文的核函数不能并发执行。如果一个核函数使用了大量的本地内存或者很多纹理, 一般也不会同其他很函数并发执行。
一些设备可以在执行核函数的时候,异步地进行数据的拷贝。应用程序可以通过查询设备属性asyncEngineCount, 如果该值大于0, 则表示支持这一特性。 但是此处涉及的主机端内存必须是锁页内存。设备内部的数据拷贝也是可以同时进行的, 此时只需要使用标准的内存拷贝函数即可。
计算力高于2的设备,数据拷贝的过程是可以重叠的。应用程序可以通过查询设备属性asyncEngineCount, 如果该值等于2, 则表示支持这一特性。但是此处涉及的主机端内存必须是锁页内存。
应用程序通过流(stream)来管理并发操作。一个流是一个等待执行的命令队列。也就是说, 不同流中的命令执行可以是并发的, 不存在执行顺序。
流的定义是通过创建一个流对象,然后可以将其作为参数传递核函数和主机与设备之间的拷贝操作。下列代码创建了两个流,并分配了锁页内存。
cudaStream_t stream[2];
for (int i = 0; i < 2; ++i)
cudaStreamCreate(&stream[i]);
float* hostPtr;
cudaMallocHost(&hostPtr, 2 * size);
for (int i = 0; i < 2; ++i) {
cudaMemcpyAsync(inputDevPtr + i * size, hostPtr + i * size, size, cudaMemcpyHostToDevice, stream[i]);
MyKernel <<<100, 512, 0, stream[i]>>>(outputDevPtr + i * size, inputDevPtr + i * size, size);
cudaMemcpyAsync(hostPtr + i * size, outputDevPtr + i * size,size, cudaMemcpyDeviceToHost, stream[i]);
}
for (int i = 0; i < 2; ++i)
cudaStreamDestroy(stream[i]);
每个流拷贝各自那部分的数据和进行处理。每个流执行的过程中存在重叠overlap。当函数cudaStreamDestroy()调用时,设备上的流仍旧在执行操作,此时该函数会直接返回。但是该流所占用的资源你会在设备执行完该流的所有任务时进行释放。
核函数启动与主机设备之间的内存拷贝在不指定流的时候,通过将流参数设置为0,即设置为默认流。因此他们是顺序执行的。
对于在编译时设定了编译选项–default-stream per-thread的代码,默认流是一个常规流,且每个主机线程都有自己的默认流。对于在编译时设定了编译选项–default-stream legacy的代码,默认流是一个称为NULL的特殊流,且每个设备都有一个各自的NULL流。NULL流之所以特殊,是因为他会引起隐式同步。注意,编译选项–default-stream是将leagcy作为默认参数的。
显式同步
有很多种方法来进行流的显式同步:
为了避免不必要的速度下降,所有这些同步函数仅用来测试事件或者隔离一个失败的核函数启动或者内存拷贝。
隐式同步
如果主机线程在两个不同流的两个命令之间发出了一些操作之一,则这两个命令不能并发进行:
对于计算力小于3.0且支持核函数并发执行的设备,每个操作都需要做依赖性检测以查看流上的核是否启动完毕:
重叠行为(overlapping)
两个流之间执行过程重叠的量取决于命令的执行顺序以及设备是否支持数据传输和核函数执行过程的重叠、即核函数的并发执行与数据并发传输。
例如, 当设备不支持数据并发传输时, 示例代码中的两个流的创建与销毁并不会重叠, 因为流1的内存拷贝发生在流0的内存拷贝之后, 只有在流0完成数据拷贝之后才会开始流1的数据拷贝。当设备支持数据传输重叠和核函数执行重叠时, 下示代码中流1的主机至设备的内存拷贝会与流0的核函数执行重合。
for (int i = 0; i < 2; ++i)
cudaMemcpyAsync(inputDevPtr + i * size, hostPtr + i * size, size, cudaMemcpyHostToDevice, stream[i]);
for (int i = 0; i < 2; ++i)
MyKernel<<<100, 512, 0, stream[i]>>> (outputDevPtr + i * size, inputDevPtr + i * size, size);
for (int i = 0; i < 2; ++i)
cudaMemcpyAsync(hostPtr + i * size, outputDevPtr + i * size, size, cudaMemcpyDeviceToHost, stream[i]);
回调
运行时提供给了一种方法:在流中的任意点通过cudaStreamAddCallback插入回调。当在回调函数执行完毕之前流内的命令都发出时,回调函数在主机端被执行。 流0 中的回调函数会等待所有流内的任务和命令都发出时执行。
void CUDART_CB MyCallback(cudaStream_t stream, cudaError_t status, void *data){
printf("Inside callback %d\n", (size_t)data);
}
...
for (size_t i = 0; i < 2; ++i) {
cudaMemcpyAsync(devPtrIn[i], hostPtr[i], size, cudaMemcpyHostToDevice, stream[i]);
MyKernel<<<100, 512, 0, stream[i]>>>(devPtrOut[i], devPtrIn[i], size);
cudaMemcpyAsync(hostPtr[i], devPtrOut[i], size, cudaMemcpyDeviceToHost,stream[i]);
cudaStreamAddCallback(stream[i], MyCallback, (void*)i, 0);
}
上述示例代码中,两个流中均在主机至设备内存拷贝、核函数启动、设备至主机内存拷贝之后添加了回调函数。回调函数在主机端执行,当各自的设备至主机内存拷贝完成之后。在回调函数之后的命令在该函数执行完毕之前是不会开始执行的。cudaStreamAddCallback()参数列表中的最后一项是保留给以后使用的。
回调函数不能执行 CUDA API 调用,无论是直接的还是间接的,因为这可能会导致它一直等待自己执行完毕从而陷入死锁。
优先级
创建流的时候, 可以通过接口cudaStreamCreateWithPriority来设置优先级。优先级范围可以通过cudaDeviceGetStreamPriorityRange()得到一个类似于[highest, lowest]的范围。在运行的时候,当低优先级的流中的块完成时,等待状态的高优先级流的块会被调度至当前块的位置进行执行。
下列代码示例了流优先级的设置:
// get the range of stream priorities for this device
int priority_high, priority_low;
cudaDeviceGetStreamPriorityRange(&priority_low, &priority_high);
// create streams with highest and lowest available priorities
cudaStream_t st_high, st_low;
cudaStreamCreateWithPriority(&st_high, cudaStreamNonBlocking, priority_high);
CUDA中工作任务的提交可以通过一种新的模型来表示----图。一个图是有一系列操作,如核函数启动, 及其依赖项构成。这图的定义时和执行无关的,因此这就可以使得这样的图可以定义一次并重复执行多次。将图的定义和执行分离可以得到一些优化:首先,相比于流来说CPU启动开销可以被削减,因为很多前置操作已经提前完成了;其次,将整个工作流提供给CUDA, 可以让其对图进行优化。
在流中,通过图优化的过程大致发生了: 当将一个核函数放入流中,主机端驱动执行一系列操作来准备核函数在GPU上的运行。这些操作主要是用来建立和启动核函数,是一个核函数运行的必要前置开销, 不可避免。对于一个执行时间很短的核函数来说,这些前置开销相对于整体的端对端执行时间来说,有时候会占据很大的比重。
通过图来提交工作任务,被分为三个可分离的阶段: 定义、实例化与执行。
图结构
在图中,一个操作是一个节点, 操作之间的依赖关系是边。这些依赖关系限制了操作执行的顺序。一个操作的调度在它的依赖项完成之后的任意时刻都可能发生。具体什么时候调度是由CUDA系统决定的。
可以作为节点的操作包括: 核函数、CPU函数调用、内存拷贝、内存设置、空节点、子图。
图的创建可以通过两种机制来实现:
// Create the graph - it starts out empty
cudaGraphCreate(&graph, 0);
// For the purpose of this example, we'll create
// the nodes separately from the dependencies to
// demonstrate that it can be done in two stages.
// Note that dependencies can also be specified
// at node creation.
cudaGraphAddKernelNode(&a, graph, NULL, 0, &nodeParams);
cudaGraphAddKernelNode(&b, graph, NULL, 0, &nodeParams);
cudaGraphAddKernelNode(&c, graph, NULL, 0, &nodeParams);
cudaGraphAddKernelNode(&d, graph, NULL, 0, &nodeParams);
// Now set up dependencies on each node
cudaGraphAddDependencies(graph, &a, &b, 1); // A->B
cudaGraphAddDependencies(graph, &a, &c, 1); // A->C
cudaGraphAddDependencies(graph, &b, &d, 1); // B->D
cudaGraphAddDependencies(graph, &c, &d, 1); // C->D
cudaGraph_t graph;
cudaStreamBeginCapture(stream);
kernel_A<<< ..., stream >>>(...);
kernel_B<<< ..., stream >>>(...);
libraryCall(stream);
kernel_C<<< ..., stream >>>(...);
cudaStreamEndCapture(stream, &graph);
一个代码块中的工作任务需要启动至流中,可以将其使用API cudaStreamBeginCapture()和cudaStreamEndCapture()括起来。如上述代码所示。cudaStreamBeginCapture()可以将一个流置于捕获模式。当一个流被置于捕获时, 启动进入流内的工作并不会进行排队执行, 而是被加入一个正在逐步建立的内部图。 当调用cudaStreamEndCapture()时, 最终构建的图会被返回,同时结束流的捕获模式。通过流捕获模式构建的图称之为 捕获图capture graph。
流的捕获机制可以用于除cudaStreamLegacy(NULL stream)之外的任意流。 也就是说这种机制也可以用于cudaStreamPerThread。 当一个流处于捕获模式时,可以使用cudaStreamIsCapturing()来进行查询。
跨流的图
流与流之间可能存在依赖性。当使用cudaEventRecord() 和 cudaStreamWaitEvent()实现的依赖, 在流的捕获时能自动将这种记录加入图中。当一个事件记录进入一个捕获模式的流时,这会导致一个捕获事件captured event。一个捕获事件在图中表示一组节点。当流在等待捕获事件时, 若果这个事件没有准备好则会将这个流置为捕获模式,然后流中的下一项则会被添加一个关于捕获事件的依赖。然后这两个流都会被捕获,同时存在一个图内。
当在执行流的捕获时,需要确保cudaStreamBeginCapture()和cudaStreamEndCapture()需要写在同一流中, 因为这是原始流。
// stream1 is the origin stream
cudaStreamBeginCapture(stream1);
kernel_A<<< ..., stream1 >>>(...);
// Fork into stream2
cudaEventRecord(event1, stream1);
cudaStreamWaitEvent(stream2, event1);
kernel_B<<< ..., stream1 >>>(...);
kernel_C<<< ..., stream2 >>>(...);
// Join stream2 back to origin stream (stream1)
cudaEventRecord(event2, stream2);
cudaStreamWaitEvent(stream1, event2);
kernel_D<<< ..., stream1 >>>(...);
// End capture in the origin stream
cudaStreamEndCapture(stream1, &graph);
// stream1 and stream2 no longer in capture mode
当流处于捕获中或者捕获事件中, 则不能对其进行同步或者执行状态查询, 因为这并不代表流中的元素在被调度执行。
cudaGraph_t对象并不是线程安全的。因此需要用户自己去确保多线程并不会同时访存同一个cudaGraph_t。
cudaGraph_Exec_t不能与自己并发执行。一个发出的cudaGraph_Exec_t对象会排在之前发出的相同的可执行图之后执行。图的执行是在流中完成的,以便与其他异步工作一起排序。然而, 流仅仅是为了排序,并不会显示内部的图并行或者并不会影响图节点的执行。
运行时同时也提供了一种方法来检测设备的进程以及进行精确的计时, 那就是通过让应用程序可以在程序中的任意点异步地记录事件并查询事件的完成状态。当事件中的所有任务以及可能的流中的所有命令都完成时则表示这个事件完成了。流0中的事件完成表示所有流中的任务和命令都完成了。
流的创建与销毁如下所示:
cudaEvent_t start, stop;
// creation
cudaEventCreate(&start);
cudaEventCreate(&stop);
// destroy
cudaEventDestroy(start);
cudaEventDestroy(stop);
事件的创建与销毁可以用来对示例代码的运行进行计时。
cudaEventRecord(start, 0);
for (int i = 0; i < 2; ++i) {
cudaMemcpyAsync(inputDev + i * size, inputHost + i * size,
size, cudaMemcpyHostToDevice, stream[i]);
MyKernel<<<100, 512, 0, stream[i]>>>
(outputDev + i * size, inputDev + i * size, size);
cudaMemcpyAsync(outputHost + i * size, outputDev + i * size,
size, cudaMemcpyDeviceToHost, stream[i]);
}
cudaEventRecord(stop, 0);
cudaEventSynchronize(stop);
float elapsedTime;
cudaEventElapsedTime(&elapsedTime, start, stop);
当一个同步函数被调用时, 控制权在设备完成所需的任务之前并不会返回给主机端线程。在主机线程执行其他CUDA调用之前可以通过调用cudaSetDeviceFlags()来指定一些标志从而使得主机线程进入不同的状态(yield, block, spin)。