4、CUDA流与异步操作:让GPU真正忙起来

说实话,很多刚接触GPU编程的朋友,最容易犯的一个错误就是——把GPU当CPU用。什么意思呢?就是一条指令执行完,再执行下一条。GPU的并行能力,其实被浪费了大半。

今天我们要聊的CUDA流,就是解决这个问题的核心工具。我个人习惯把CUDA流比作「GPU上的传送带」。你可以同时开多条传送带,每条传送带独立干活,互不干扰。

4.1 CUDA流的概念

CUDA流,说白了就是一个操作队列。你把kernel启动、内存拷贝这些操作丢进队列里,GPU按顺序执行。但不同流之间的操作,是可以并发执行的。

默认情况下,所有操作都在默认流(NULL流)里。这就像只有一条传送带,所有任务排着队来。你想想看,GPU那么多计算单元,就干等着?太浪费了。

核心要点:CUDA流是实现GPU并发执行的关键机制。每个流内部是串行的,但不同流之间可以并行。

4.2 创建与销毁流

创建流很简单,API就两个:cudaStreamCreatecudaStreamDestroy。我在项目中遇到过一种情况:有人创建了几百个流,结果性能反而下降了。为什么?因为流太多,调度开销反而成了瓶颈。

cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);

// 在流中启动kernel
myKernel<<<grid, block, 0, stream1>>>(d_data);
myKernel<<<grid, block, 0, stream2>>>(d_data);

// 销毁流
cudaStreamDestroy(stream1);
cudaStreamDestroy(stream2);

我的建议:流的数量一般不超过GPU的硬件队列数。对于现代GPU,4-8个流通常就够用了。别贪多。

4.3 异步内存拷贝

这是很多人的知识盲区。常规的 cudaMemcpy 是同步的,它会阻塞CPU。而 cudaMemcpyAsync 是异步的,它把拷贝操作丢进流里,CPU立刻返回继续干活。

// 同步拷贝 - CPU会等
cudaMemcpy(d_data, h_data, size, cudaMemcpyHostToDevice);

// 异步拷贝 - CPU不等待
cudaMemcpyAsync(d_data, h_data, size, cudaMemcpyHostToDevice, stream1);

嗯,这里要注意:异步拷贝要求主机内存必须是页锁定内存(pinned memory)。否则,拷贝会退化成同步操作。我曾经因为这个坑,排查了整整一个下午。

避坑指南:我曾经在项目里用 cudaMemcpyAsync 但没分配pinned memory,结果性能一点没提升。后来才发现,CUDA在背后默默做了同步。记住:异步拷贝必须搭配 cudaHostAlloccudaMallocHost 分配的主机内存。

4.4 多流并发执行

多流并发,才是CUDA流的精髓。你可以让一个流做计算,另一个流做数据拷贝,两者同时进行。这叫做「计算-拷贝重叠」。

// 流1:做计算
kernelA<<<grid, block, 0, stream1>>>(d_data1);

// 流2:同时拷贝数据
cudaMemcpyAsync(d_data2, h_data2, size, cudaMemcpyHostToDevice, stream2);

// 流3:再做另一个计算
kernelB<<<grid, block, 0, stream3>>>(d_data3);

你想想看,如果这三个操作都在默认流里,那就得排队:先拷贝,再算A,再算B。现在呢?拷贝和计算同时进行,时间直接砍半。

场景 默认流(串行) 多流(并发)
数据拷贝 + 计算 拷贝 → 计算 → 总时间 = T_copy + T_compute 拷贝 || 计算 → 总时间 ≈ max(T_copy, T_compute)
多个小kernel 依次执行 可同时执行(取决于硬件资源)

4.5 事件(Event)与同步

多流并发是好,但问题来了:怎么保证执行顺序?比如,流2需要等流1算完才能开始。这时候就需要事件(Event)了。

事件就像赛道上的「计时点」和「信号灯」。你可以记录某个流执行到哪一步了,也可以让另一个流停下来等。

cudaEvent_t event;
cudaEventCreate(&event);

// 流1执行完kernel后,记录事件
kernelA<<<grid, block, 0, stream1>>>(d_data);
cudaEventRecord(event, stream1);

// 流2等待事件完成后再执行
cudaStreamWaitEvent(stream2, event, 0);
kernelB<<<grid, block, 0, stream2>>>(d_data);

// 测量时间
cudaEventSynchronize(event);
float elapsed;
cudaEventElapsedTime(&elapsed, startEvent, event);

关键理解:cudaEventRecord 只是「记录」事件,不阻塞任何流。cudaStreamWaitEvent 才是让流等待的指令。这两个配合使用,就能实现流之间的精确同步。

我记得有一次做视频处理管线,需要解码、预处理、推理、后处理四个阶段。每个阶段一个流,用事件串联。结果呢?整个管线的吞吐量提升了3倍。说白了,就是让GPU的每个部分都别闲着。

4.6 实战建议

最后,我总结几条实战经验:

  • 先分析瓶颈:你的程序是计算密集还是IO密集?如果是计算密集,多流帮助不大;如果是IO密集,多流是神器。
  • 别滥用事件:事件同步是有开销的。我曾经见过有人每个kernel之间都插事件,结果同步开销比计算还大。
  • 用工具验证:NVIDIA Nsight Systems可以直观看到各个流的时间线。我每次优化完都会跑一遍,看看流之间有没有真正的重叠。
  • 注意默认流阻塞:默认流有个「暗坑」——它会阻塞其他流。所以,一旦用了多流,就尽量别碰默认流。

小技巧:如果你不确定自己的多流优化是否有效,可以先用 cudaStreamSynchronize(stream) 改成同步版本,对比一下性能。如果异步版本更快,说明优化有效;如果差不多甚至更慢,那就要检查是不是有隐式的同步点了。

好了,CUDA流的内容就这些。说白了,它就是让GPU从「单线程」变成「多线程」的关键。下一章我们会聊更高级的CUDA图(Graph),那又是另一个维度的优化了。