第四节:异步操作与事件——CUDA异步API、事件机制与同步计时
各位同学,今天我们来聊聊CUDA编程里一个特别实用的技巧——计算与通信的重叠。说白了,就是让GPU一边算数据,一边搬数据,别闲着。
我刚开始做GPU加速的时候,总觉得把数据拷过去、算完、再拷回来就完事了。后来发现,GPU干活的时候,CPU和PCIe总线经常在摸鱼。这浪费太大了。于是我开始研究CUDA的异步操作和事件机制。
4.1 CUDA异步API:让GPU自己管自己
CUDA的异步API,核心思想就是「发完指令就走,不等结果」。你调用一个核函数或者一个内存拷贝函数,它立刻返回,不阻塞CPU。GPU在后端慢慢执行。
举个例子,我们常用的cudaMemcpy是同步的,CPU会等到数据拷贝完才继续。但cudaMemcpyAsync就不一样,它把任务丢给GPU的某个流(Stream),然后CPU马上回来干别的事。
cudaMemcpyAsync(dst, src, size, cudaMemcpyHostToDevice, stream);
嗯,这里要注意:异步拷贝必须用固定内存(Pinned Memory)。我早期踩过这个坑,用普通malloc分配的内存,一调用异步拷贝就报错。后来才明白,固定内存的物理地址是锁定的,DMA控制器才能直接访问。
核心要点:
- 异步API不阻塞CPU,但GPU内部是串行执行的(同一个流内)
- 不同流之间可以并行执行
- 固定内存是异步拷贝的前提条件
4.2 CUDA事件(Event)机制:给GPU装个秒表
CUDA事件,你可以把它想象成一个「时间戳记录器」。它在GPU上记录某个操作完成的时间点。我个人习惯用事件来做两件事:一是计时,二是同步。
先看计时。以前我测GPU核函数时间,用clock()或者gettimeofday(),结果发现时间不准。为什么?因为这些是CPU时间,而核函数是异步的,CPU早就跑远了。正确的做法是用CUDA事件。
cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);
cudaEventRecord(start, 0); // 记录开始时间
myKernel<<<grid, block>>>(...);
cudaEventRecord(stop, 0); // 记录结束时间
cudaEventSynchronize(stop); // 等待GPU完成
float ms = 0;
cudaEventElapsedTime(&ms, start, stop);
printf("核函数耗时: %f ms\n", ms);
你看,这里cudaEventRecord把事件插入到流中,GPU执行到事件时才会记录时间。最后用cudaEventSynchronize等GPU干完活,再算时间差。这样测出来的时间才是真实的GPU执行时间。
小技巧: 我建议每次测时间都跑3-5次取平均值,排除第一次的「热身」开销。GPU第一次启动核函数时,会有驱动加载和上下文初始化的延迟。
4.3 事件同步:让不同流之间「打招呼」
事件另一个重要用途是同步。不同流之间默认是互相独立的,但有时候我们需要让流A等流B干完某件事再继续。这时候事件就派上用场了。
举个例子,流1在计算,流2在拷贝数据。我想让流2等流1算完再拷贝。可以这样写:
cudaEvent_t event;
cudaEventCreate(&event);
// 流1:执行计算
kernel1<<<grid, block, 0, stream1>>>(...);
cudaEventRecord(event, stream1); // 流1完成时记录事件
// 流2:等待事件,然后拷贝
cudaStreamWaitEvent(stream2, event, 0);
cudaMemcpyAsync(dst, src, size, cudaMemcpyDeviceToHost, stream2);
这里cudaStreamWaitEvent让流2停下来,直到事件被记录(即流1的核函数完成)。注意,这个等待是GPU层面的,CPU不会被阻塞。
我曾经踩过的坑: 事件记录和等待必须在同一个CUDA上下文中。如果你在多GPU环境下,跨设备的事件同步是不支持的。另外,事件本身有创建和销毁的开销,别在循环里频繁创建销毁,最好复用。
4.4 实战:计算与通信重叠
好了,我们把异步API和事件结合起来,做一个经典的计算-通信重叠模式。假设我们要处理一批数据,分成N块,每块先拷到GPU,然后计算,再拷回CPU。
朴素的做法是串行:拷一块、算一块、拷回一块。但我们可以用两个流来流水线:
cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
for (int i = 0; i < N; i += 2) {
// 流1:处理第i块
cudaMemcpyAsync(d1, h1 + i * size, size, cudaMemcpyHostToDevice, stream1);
kernel<<<grid, block, 0, stream1>>>(d1);
cudaMemcpyAsync(h1 + i * size, d1, size, cudaMemcpyDeviceToHost, stream1);
// 流2:处理第i+1块
if (i + 1 < N) {
cudaMemcpyAsync(d2, h2 + (i+1) * size, size, cudaMemcpyHostToDevice, stream2);
kernel<<<grid, block, 0, stream2>>>(d2);
cudaMemcpyAsync(h2 + (i+1) * size, d2, size, cudaMemcpyDeviceToHost, stream2);
}
}
cudaStreamSynchronize(stream1);
cudaStreamSynchronize(stream2);
这样,当流1在计算时,流2可以同时拷贝下一块数据。PCIe总线和计算单元同时工作,整体吞吐量能提升不少。
我实际测过,在NVIDIA V100上,这种流水线方式比串行快了将近40%。当然,具体收益取决于计算量和数据量的比例。
4.5 知识体系图
下面这张图总结了本章的核心逻辑,我画成了SVG,方便你理解各个概念之间的关系。