4. 流与并发执行:CUDA流(Stream)概念、默认流与非默认流、流同步机制、多流并发执行模式

好,咱们今天聊一个实战中特别关键的话题——CUDA流。说白了,流就是GPU上的一串操作队列。你往队列里丢任务,GPU就按顺序执行。但如果你只开一个队列,那GPU的很多计算单元就闲着,效率上不去。

我个人习惯把流想象成「流水线」。一条流水线干活,另一条也能同时干活。这就是并发的核心。

4.1 什么是CUDA流?

CUDA流,本质上是一个异步操作的序列。同一个流里的操作,严格按照顺序执行。不同流里的操作,则可以乱序、甚至并发执行。

举个例子:

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

// 在stream1中启动kernel
kernel1<<<grid, block, 0, stream1>>>(d_data1);
// 在stream2中启动kernel
kernel2<<<grid, block, 0, stream2>>>(d_data2);

这里kernel1和kernel2如果硬件资源够,就会同时跑。我在项目中遇到过,有些新手把所有操作都丢到默认流里,结果GPU利用率只有30%。一拆成多流,直接飙到80%以上。

4.2 默认流 vs 非默认流

每个CUDA程序启动时,都会自动创建一个默认流。你没显式指定流的时候,所有操作都默认进这个流。

这里有个坑——默认流在某些设备上是阻塞的。什么意思?就是默认流里的操作,会阻塞其他流里的操作。嗯,这其实是个历史遗留问题。

特性 默认流(NULL流) 非默认流
创建方式 自动存在 cudaStreamCreate()
阻塞行为 可能阻塞其他流 不阻塞其他流
并发能力
使用场景 简单测试 生产环境
注意: 在计算能力3.5及以上的设备上,默认流的行为可以通过编译选项控制。但我建议你,但凡写正经代码,就别用默认流。自己创建流,心里踏实。

4.3 流同步机制

多流并发虽好,但你不能让它们乱跑。比如stream1算完了结果,stream2要用这个结果。这时候就需要同步。

常用的同步方式有几种:

  • cudaDeviceSynchronize():阻塞CPU,直到所有流都完成。简单粗暴,但效率低。
  • cudaStreamSynchronize(stream):只等某一个流完成。
  • cudaEventSynchronize(event):用事件做更细粒度的同步。

我给你们看个实际例子:

cudaEvent_t event;
cudaEventCreate(&event);

// stream1做完后,记录事件
kernel1<<<grid, block, 0, stream1>>>(d_data);
cudaEventRecord(event, stream1);

// stream2等event完成后再执行
cudaStreamWaitEvent(stream2, event, 0);
kernel2<<<grid, block, 0, stream2>>>(d_data);

我曾经在一个视频处理项目里,用事件同步把流水线延迟从5ms降到了1.2ms。你想想看,这差距有多大。

4.4 多流并发执行模式

多流并发,不是简单地把任务扔到不同流里就完事了。你得考虑硬件资源。

GPU内部有多个引擎:

  • 计算引擎(SM)
  • 拷贝引擎(DMA)
  • 显存控制器

这些引擎是可以并行工作的。所以多流并发的最佳实践是:

  1. 计算与拷贝重叠:一个流在做数据传输,另一个流在做计算。
  2. 多计算流并行:多个小kernel同时跑,填满SM。
  3. 流水线模式:把一个大任务拆成多个阶段,每个阶段一个流。
核心原则: 多流并发的本质,是让GPU的不同硬件单元同时工作,而不是让同一个单元干更多活。

我给你们画个典型的多流并发模式:

// 流0:负责数据传输
cudaMemcpyAsync(d_in1, h_in1, size, cudaMemcpyHostToDevice, stream0);

// 流1:负责计算
kernel1<<<grid, block, 0, stream1>>>(d_in1, d_out1);

// 流2:负责结果回传
cudaMemcpyAsync(h_out1, d_out1, size, cudaMemcpyDeviceToHost, stream2);

这里有个细节——同一个流里的操作是串行的。所以你要把不依赖的操作放到不同流里。依赖的操作,用事件同步。

小技巧: 调试多流并发时,可以用Nsight Systems看时间轴。哪个流在干活、哪个流在空等,一目了然。我每次调优都离不开这个工具。

4.5 避坑指南

讲几个我踩过的坑:

  • 流数量不是越多越好。GPU的硬件队列有限,一般4-16个流就够了。我曾经试过开32个流,结果性能反而下降了。
  • 小心隐式同步。比如cudaMalloc、cudaMemcpy(不带Async的版本)都会隐式同步所有流。我建议统一用cudaMemcpyAsync。
  • 事件要重用。别每次同步都创建新事件,用cudaEventRecord反复记录就行。

嗯,说到隐式同步,我记得有一次排查性能问题,找了半天发现是cudaMalloc在作怪。每次分配显存都让所有流停下来等。后来改成预分配,性能直接翻倍。

4.6 总结

流与并发执行,说白了就是让GPU忙起来。别让它闲着。默认流是新手村,多流才是高手区。

我建议你从两个流开始练手:一个做数据传输,一个做计算。等跑通了,再慢慢加流。记住,并发不是目的,提高吞吐才是

下一章咱们聊内存优化,那又是另一个大坑。到时候我给你们讲讲怎么用统一内存和零拷贝,让数据传输不再是瓶颈。