4、CUDA流与并发执行:Stream的概念、默认流与非默认流、Stream同步、Stream优先级、使用Stream实现Kernel执行与数据传输的重叠

各位同学,咱们今天来聊聊CUDA编程里一个特别重要的概念——Stream(流)。

说实话,我刚开始接触CUDA的时候,觉得这玩意儿不就是把数据扔给GPU算嘛,有啥复杂的?后来在实际项目中,发现GPU利用率老是上不去,显存带宽也跑不满,才意识到自己根本没用好Stream。说白了,Stream就是让你手里的GPU真正忙起来的关键。

4.1 什么是CUDA Stream?

Stream,你可以把它理解成GPU上的一条任务流水线。所有提交到同一个Stream里的操作(比如Kernel执行、数据拷贝),都会按照你提交的顺序,一个接一个地执行。

但不同Stream里的操作呢?它们可以乱序执行,甚至可以同时执行——这就是并发。

我打个比方:你开了一家奶茶店,只有一个店员(GPU)。Stream就是不同的订单队列。如果你把所有订单都塞进同一个队列,那店员只能一杯一杯地做。但如果你把订单分到两个队列,一个做奶茶,一个做果茶,那店员就可以穿插着做,效率自然就上来了。

核心要点:Stream是CUDA实现并发执行的基本单位。每个Stream维护一个独立的操作序列,不同Stream之间的操作可以并发执行。

4.2 默认流 vs 非默认流

CUDA里有两种流:默认流(也叫NULL流)和非默认流(你显式创建的流)。

默认流是每个CUDA上下文自带的,你如果不显式创建Stream,所有操作都会跑到默认流里。嗯,这里要注意:默认流的行为在CUDA 6.0前后有过一次大改。

  • CUDA 6.0之前:默认流是阻塞流。意思是,如果你在默认流里提交了一个操作,它会阻塞其他所有流里的操作,直到它执行完。
  • CUDA 6.0之后:默认流变成了非阻塞流。它不再阻塞其他流了,但为了兼容旧代码,你可以通过编译选项切换回旧行为。

我个人习惯是:永远不要依赖默认流来做并发。显式创建非默认流,代码更清晰,行为也更可控。

创建非默认流的API很简单:

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

// 使用流
kernel1<<<grid, block, 0, stream1>>>(...);
kernel2<<<grid, block, 0, stream2>>>(...);

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

你看,创建、使用、销毁,三步走。Kernel启动时,第四个参数就是Stream句柄。不传的话,默认就是NULL流。

4.3 Stream同步

并发虽好,但同步不能少。你想想看,如果Stream A在写数据,Stream B在读同一块数据,那不乱套了?

CUDA提供了几种同步方式:

同步方式 API 说明
设备同步 cudaDeviceSynchronize() 阻塞CPU,直到设备上所有Stream的所有操作完成
流同步 cudaStreamSynchronize(stream) 阻塞CPU,直到指定Stream的所有操作完成
事件同步 cudaEventSynchronize(event) 阻塞CPU,直到指定事件被记录
流等待事件 cudaStreamWaitEvent(stream, event) 让一个Stream等待另一个Stream的事件

我在项目中遇到过一个问题:两个Stream之间需要协调,但我不想让CPU等着。这时候cudaStreamWaitEvent就派上用场了。它让Stream之间可以互相等待,而CPU该干嘛干嘛。

小技巧:能用cudaStreamSynchronize就别用cudaDeviceSynchronize。后者太粗暴了,会把所有Stream都停下来,影响并发效率。

4.4 Stream优先级

CUDA支持给Stream设置优先级。高优先级的Stream里的操作,会优先被调度执行。

这个功能在CUDA 4.0之后就有了。用法如下:

int priority_high, priority_low;
cudaDeviceGetStreamPriorityRange(&priority_low, &priority_high);

cudaStream_t high_pri_stream, low_pri_stream;
cudaStreamCreateWithPriority(&high_pri_stream, cudaStreamNonBlocking, priority_high);
cudaStreamCreateWithPriority(&low_pri_stream, cudaStreamNonBlocking, priority_low);

priority_high是最高优先级(数值最小),priority_low是最低优先级(数值最大)。

我曾经在一个实时渲染项目里用过这个特性。渲染管线里的Kernel必须尽快执行,不然画面会卡顿。而一些后处理Kernel可以慢一点。把渲染Kernel放到高优先级Stream里,后处理放到低优先级Stream里,效果立竿见影。

注意:不是所有GPU都支持Stream优先级。你可以用cudaDeviceGetStreamPriorityRange查询一下。如果返回的priority_low == priority_high,说明不支持。

4.5 使用Stream实现Kernel执行与数据传输的重叠

好了,前面铺垫了这么多,终于到实战环节了。Stream最经典的应用场景,就是让Kernel计算和数据传输重叠起来。

你想想看,传统的做法是:

  1. 把数据从CPU拷贝到GPU(cudaMemcpy)
  2. 在GPU上执行Kernel
  3. 把结果从GPU拷贝回CPU

这三步是串行的。GPU在拷贝数据的时候,计算单元闲着;GPU在计算的时候,拷贝引擎闲着。浪费啊!

用Stream可以这样优化:

// 假设有4个数据块
const int N = 4;
float *d_data[N], *h_data[N];
cudaStream_t streams[N];

// 创建流和分配内存
for (int i = 0; i < N; i++) {
    cudaStreamCreate(&streams[i]);
    cudaMalloc(&d_data[i], size);
    // h_data[i] 假设已经准备好
}

// 流水线执行
for (int i = 0; i < N; i++) {
    // 异步拷贝到GPU
    cudaMemcpyAsync(d_data[i], h_data[i], size, 
                    cudaMemcpyHostToDevice, streams[i]);
    
    // 启动Kernel
    kernel<<<grid, block, 0, streams[i]>>>(d_data[i]);
    
    // 异步拷贝回CPU
    cudaMemcpyAsync(h_data[i], d_data[i], size,
                    cudaMemcpyDeviceToHost, streams[i]);
}

// 等待所有完成
cudaDeviceSynchronize();

// 清理
for (int i = 0; i < N; i++) {
    cudaStreamDestroy(streams[i]);
}

这段代码的精髓在于:每个Stream内部的操作是串行的,但不同Stream之间是并发的。当Stream 0在计算时,Stream 1可能正在拷贝数据。这样一来,计算和传输就重叠起来了。

我曾经在一个视频处理项目里用这个技巧,把整体吞吐量提升了将近2倍。当然,前提是你的GPU有足够多的拷贝引擎和计算单元。你可以用cudaDeviceGetAttribute查询一下设备属性。

关键点:要实现重叠,必须使用cudaMemcpyAsync(异步拷贝),不能使用cudaMemcpy(同步拷贝)。同步拷贝会阻塞当前Stream,破坏并发性。

另外,别忘了使用固定内存(pinned memory)。异步拷贝需要主机内存是页锁定的,否则会退化成同步拷贝。用cudaMallocHost分配就行。

嗯,说到这里,我想起一个坑。有一次我写代码,发现重叠效果不明显,查了半天才发现是主机内存没固定。换成cudaMallocHost之后,性能立马就上来了。所以,这个细节一定要注意。

4.6 本章小结

Stream是CUDA并发编程的基石。用好Stream,你的GPU才能真正发挥出它的潜力。

  • 默认流方便但不灵活,建议显式创建非默认流
  • 同步方式要选对,别动不动就cudaDeviceSynchronize
  • Stream优先级在实时场景下很有用
  • 计算与传输重叠是Stream最经典的应用,记得用固定内存和异步拷贝

下一章,我们会深入讨论事件(Event)和更高级的同步技巧。到时候,你会发现Stream和Event配合起来,能玩出更多花样。

可可精品资料库