6.3_CUDA事件:CPU与GPU同步

6.3 CUDA事件:CPU/GPU同步

CUDA事件的主要特点之一是,它们可以支持“部分”CPU/GPU的同步。在完整的CPU/GPU同步中,CPU要一直等待到GPU空闲,这将在GPU的工作任务流水线中引入“气泡”。而在部分同步中,CUDA事件可以被记录进GPU命令的异步流中。然后,CPU可以等待直到该事件之前的所有工作完成。但GPU可以继续执行在cuEventRecord()/CUDAEventRecord()之后被提交的任何工作。

作为CPU/GPU并发的一个例子,代码清单6-2给出了一个分页内存的内存复制子程序。这一程序的代码实现了图6-3所描述的算法,可以在pageableMemcpyHtoD.cu中找到。它采用了2个锁页内存缓冲区,存储在如下方式声明的全局变量中。

void *g_hostBuffers[2];

并且两个CUDA事件被声明为:

cudaEvent_t g_events[2];

代码清单6-2 chMemcpyHtoD()——分页的内存复制

void
chMemcpyHtoD(void *device, const void *host, size_t N)
{
   udaError_t status;
    char *dst = (char *) device;
    const char *src = (const char *) host;
    int stagingIndex = 0;
    while (N) {
        size_t thisCopySize = min(N, STAGING_BUFFER_SIZE);
        canadaEventSynchronize(g_events[stagingIndex]);
        memcpy(g_hostBuffers[stagingIndex], src, thisCopySize);
        canadaMemcpyAsync.dst, g_hostBuffers[stagingIndex],
            thisCopySize, canadaMemcpyHostToDevice, NULL);
        canadaEventRecord(g_events[1-stagingIndex], NULL);
        dst += thisCopySize;
        src += thisCopySize;
        N -= thisCopySize;
        stagingIndex = 1 - stagingIndex;
    }
}
Error:
return;
}

chMemcpyHtoD()是通过在2个主机缓冲区之间轮流操作来最大限度地提高CPU/GPU的并发性的。当GPU从一个缓冲区读数据的时候,CPU将数据复制进另一个缓冲区。当CPU分别复制第一个和最后一个缓冲区时,在操作的开始和末尾会有一些不存在CPU/GPU并发性的“悬空区”。

在这个程序中,唯一需要的同步就是在第11行的CUDAEventSynchronize(),这样可以确保在开始复制数据到缓冲区之前GPU已经结束对该缓冲区的操作。GPU命令一旦被排队,CUDAMemcpyAsync()就会返回。它不会等到操作完成。CUDAEventRecord()也是异步的。它会导致在刚刚请求的异步的内存复制已经完成时发信号给事件。

CUDA事件在创建后立即被记录,因此在第11行的第一个CUDAEventSynchronize()调用工作正确。

CUDART_CHECK(udaEventCreate(&g_events[0]));  
CUDART_CHECK(udaEventCreate(&g_events[1]));  
// record events so they are signaled on first synchronize  
CUDART_CHECK(udaEventRecord(g_events[0],0));  
CUDART_CHECK(udaEventRecord(g_events[1],0));

如果运行pageablememcpyHtoD.cu,它将会报告一个比被CUDA驱动程序执行的分页内存复制带宽小得多的带宽。这是因为C运行时的memcpy()函数的实现没有优化到像CPU那样快的内存移动。为了获得最佳性能,内存必须使用可以一次性移动16个字节的SSE指令来复制。使用这些指令按照它的对齐限制来编写一个通用的内存复制程序是复杂的,但是实现一个需要源数据、目的数据和16字节对齐的字节数的简化版本并不困难。[1]

include <xmmintrin.h>   
bool   
memcpy16(void \*_dst, const void \*_src, size_t N) { if(N&0xf){ return false; } float \*dst  $=$  (float \*)_dst; const float \*src  $=$  (const float \*)_src; while(N){ _mmstore_ps dst,_mm_load_ps(src)); src  $+ = 4$  dst  $+ = 4$  N-16; } return true;

当C运行时中的memcpy()函数被这个代替时,在亚马逊EC2的cgl.4xlarge实例上的性能会从2155MB/s增加到3267MB/s。更复杂的内存复制程序可以处理不严格的对齐约束,并且更高一些的性能可能通过展开内循环来实现。在cgl.4xlarge实例上,使用CUDA驱动程序中更

好优化的SSE内存复制可以实现比pageablemememcpyHtoD16.cu高100MB/s的性能。

对于分页内存复制的性能而言,CPU/GPU的并发性有多重要呢?如果将事件同步,我们将执行主机端到设备端的同步的内存复制,如下所示。

while (N) { size_t thisCopySize = min(N, STAGING_BUFFER_SIZE); < CUDART_CHECK(udaEventSynchronize(g_events[stagingIndex]) ); memcpy(g_hostBuffers[stagingIndex], src, thisCopySize); CUDART_CHECK(udaMemcpyAsync(dst, g_hostBuffers[stagingIndex], thisCopySize,udaMemcpyHostToDevice, NULL)); CUDART_CHECK(udaEventRecord(g_events[1-stagingIndex], NULL)); > CUDART_CHECK(udaEventSynchronize(g_events[1-stagingIndex]) ); dst += thisCopySize; src += thisCopySize; N -= thisCopySize; stagingIndex = 1 - stagingIndex; }

此代码在pageablemememcpyHtoD16Synchronous.cu中可以找到,并且它在cgl.4xlarge实例上的性能大约是原来的70%(2334MB/s,而不是3267MB/s)。

6.3.1 阻塞事件

CUDA事件也可以根据需要执行“阻塞”,其中使用基于中断的CPU同步机制。然后CUDA驱动程序使用线程同步原语(挂起CPU线程而不是轮询事件的32位跟踪值)实现cu(da)EventSynchronize()调用。

对于延迟敏感的应用程序而言,阻塞事件可能会造成性能损失。对我们通常的分页的内存复制程序,在我们的cgl.4xlarge实例上使用阻塞事件会导致速度略微放缓(大约100MB/s)。但是对于大多数GPU密集型应用程序,或者对于需要大量的CPU/GPU处理的“混合工作负载”的应用程序,使CPU线程空闲的好处大于处理“等待结束时处理所产生的中断”的成本。混合工作负载的一个例子是视频转码,它包含适用于CPU的分支代码,以及适用于GPU的信号和像素处理。

6.3.2 查询

CUDA流和CUDA事件分别可以通过cu(da)StreamQuery()和cu(da)EventQuery()查询。如果cu(da)StreamQuery()返回success,那就表示在一个给定的流里所有等待中的操作都已经完成了。如果cu(da)EventQuery()返回success,那就表示事件已经记录下来了。

虽然这些查询被设计为轻量级的,但是如果启用了ECC,它们必须执行内核转换来检查当前的GPU错误状态。此外,在Windows上,任何挂起的命令都将提交给GPU,这也需要一个内核转换过程。

[1] 在某些平台上, nvcc无法对此代码无缝地编译。在本书附带的代码中, memcpy16()单独存为memcpy16.cpp文件。

6.3_CUDA事件:CPU与GPU同步 - CUDA专家手册 | OpenTech