☰
CUDA Stream深入解析:多流并发让GPU性能翻倍
2026/10/3 17:23:30 网站建设 项目流程

1. 为什么需要Stream:GPU执行模型下的性能黑洞

1.1 等等,GPU不是已经很快了吗?

很多刚接触CUDA的开发者都有过这样的体验:花了一周时间把算法改成kernel,跑起来一看,确实比CPU快了不少,但仔细一测,GPU利用率只有三成,显存带宽没吃满,SM有一大半在闲着。这时候你通常会怀疑kernel写得不够好,于是开始调block数量、调共享内存、调访存模式,折腾一圈下来提升有限。其实问题可能根本不在kernel内部,而在kernel之间——你压根没有让GPU真正“忙起来”。

CUDA Stream(流)就是解决这个问题的核心机制。它不是一个新算法,也不是某种黑魔法,而是一条“任务流水线”的概念:GPU上的所有操作(kernel计算、显存拷贝、事件记录)都会被放入某个流中排队执行。如果你只会用默认流,那所有操作都挤在同一条队伍里,一个跑完才能轮到下一个。而如果你擅长用多个流,就能让互不依赖的计算和拷贝同时进行,把GPU那张“多核大网”真正铺开。

我见过太多人跳过Stream直接学什么tensor core、warp shuffle,结果连最基本的并行度都没榨干净。这一篇我先把Stream的底层逻辑和基础API讲透,下一篇再聊事件同步和多流协作的高级玩法。

1.2 默认流的“串行陷阱”:一次真实的性能浪费

先看一个非常典型的例子。假设你在做视频帧处理:每一帧都要先从CPU拷贝到GPU(H2D),在GPU上做一次滤波kernel,再把结果拷回CPU(D2H)。初学者往往会这样写:

for (int i = 0; i < frameCount; i++) { cudaMemcpy(d_input + i * frameSize, h_input + i * frameSize, frameBytes, cudaMemcpyHostToDevice); myFilterKernel<<<blocks, threads>>>(d_input + i * frameSize, d_output + i * frameSize, frameSize); cudaMemcpy(h_output + i * frameSize, d_output + i * frameSize, frameBytes, cudaMemcpyDeviceToHost); }

这段代码在默认流里执行时,完整的时间线是:拷贝、计算、拷贝、计算……所有操作严格串行。但是仔细想想,GPU引擎其实分两大类:拷贝引擎(Copy Engine)负责DMA传输,计算引擎(SM)负责跑kernel。我只需要让第i帧的kernel在执行时,第i+1帧的H2D拷贝同时进行,两个引擎就能并行工作。仅仅这一步,帧处理吞吐量就能明显提升。

实测过一个1920×1080灰度图的滤波流程,单流版本的H2D拷贝约0.6ms,kernel约1.2ms,D2H约0.6ms,单帧总耗时接近2.4ms;用双流把拷贝和计算重叠后,吞吐能提升30%~40%。这还只是最简单的场景,如果kernel足够长,重叠收益会更明显。所以我说,理解Stream的第一个价值,就是让你意识到“GPU不是只能做一件事”。

2. Stream基础API:创建、使用、销毁的一整套规矩

2.1 cudaStreamCreate与cudaStreamDestroy的细节

Stream的API极其简洁,创建和销毁就是两个函数:

cudaStream_t stream; cudaError_t err = cudaStreamCreate(&stream); // 使用 cudaStreamDestroy(stream);

但细节藏在“使用”里。创建流之后,所有能接受流参数的CUDA操作都可以指定到这个流上。最常见的三类是:

  • kernel启动:myKernel<<<grid, block, sharedMemSize, stream>>>(args...)
  • 异步拷贝:cudaMemcpyAsync(dst, src, size, kind, stream)
  • 事件操作:cudaEventRecord(event, stream)

这里有个很多人忽略的关键点:cudaMemcpyAsync才是异步版本,普通的cudaMemcpy即使传了流参数也依然是同步行为(实际上cudaMemcpy不接受流参数,编译器会报错)。你要做流水线重叠,必须用Async版本。

另外一个常用的创建方式是带标志位:

cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking);

默认创建的流跟默认流(即stream 0)之间存在隐式同步关系,而cudaStreamNonBlocking可以让这个新流不被默认流“管住”。具体的同步语义我建议你先记在脑子里:默认流是一条“老大流”,普通的命名流在默认流面前是要主动让路的。这个规则的细节,我在第4节会详细讲。

2.2 三种必须掌握的流相关操作

除了创建销毁,日常用得最多的还有三个操作:

cudaStreamSynchronize(stream); // 阻塞CPU,直到该流所有操作完成 cudaStreamQuery(stream); // 非阻塞查询,返回cudaSuccess表示完成 cudaStreamWaitEvent(stream, event); // 让流等待某个事件

cudaStreamSynchronize在多流场景下是调试利器——你可以在程序关键节点确认某个流真的算完了。但别在性能敏感的代码里到处撒同步点,否则前面做的并行重叠就全白费了。

cudaStreamQuery适合做“轮询”场景。比如你在等一个流算完,但又不想彻底阻塞主线程,可以每隔一小段时间查询一次状态。

cudaStreamWaitEvent则是跨流依赖的核心:如果流B的kernel需要用到流A的结果,就让B等待A上的某个事件。这比直接cudaDeviceSynchronize精度高得多,不会把无关的流也一起锁住,是精细调度必不可少的工具。

2.3 流的生命周期与资源管理

流不是用完就自动销毁的。它在创建时会占用GPU上下文资源,包括内部的命令缓冲区、事件对象等。如果一个程序反复创建和销毁大量流,你会发现CUDA context的内存占用持续波动,甚至触发驱动层的资源回收开销。

我自己的习惯是:流的数量按需创建,最常用的是2~4个,超过16个流的场景很少;长期运行的服务型程序应该在初始化阶段就建好流,运行期复用,而不是每帧都新建。

提示:调用cudaStreamDestroy时,如果流内还有未完成的操作,CUDA会自动阻塞直到操作完成再销毁。所以没必要在销毁前手动cudaStreamSynchronize,但如果你希望“销毁但不等待”,那需要用cudaStreamDestroy以外的机制来管理,这个比较复杂,一般场景用不到。

3. 多Stream并发的硬件真相:调度器、SM占用与背压

3.1 GPU内部到底是怎么调度多个流的

很多人以为“多流并发”就是GPU把多个kernel同时塞进所有SM里公平地运行,这个理解不完全对。GPU的硬件调度器(Work Distributor)负责把线程块(thread block)分发到各个SM上。当一个流中的kernel启动时,它的一堆线程块会进入调度队列;如果此时还有其他流的kernel也在排队,调度器会尽可能把不同流的线程块混合分发到不同SM上。

问题来了:一个SM能同时运行多少个线程块,取决于每个线程块的资源占用(寄存器、共享内存)以及SM本身的硬件限制。比如一个SM最多能驻留2048个线程,如果你的kernel每个block用了512个线程,那最多驻留4个block;如果每个block又用了大量共享内存,可能连2个block都放不下。SM资源被第一个kernel占满后,第二个kernel的线程块只能排队等待。

所以多流并发能不能生效,本质上是看“资源还有没有空位”。在实际项目中,我曾用Nsight Compute看过一个占用率76%的kernel:它把SM的线程槽位占了大半,结果我开了4个流,并发效率依然很差——因为线程块根本插不进去。反而把kernel的block数调小(比如从256线程改成128线程),让多个kernel的block能混布在同一批SM上,并发度才真正提上来。

3.2 “背压”现象:为什么流开多了反而更慢

Stream的并发不是无限制的。当大量流同时启动kernel时,GPU的工作队列会堆积,调度器要在多个流之间切换、分配资源,这本身有开销。表现就是:流从4个增加到16个,吞吐先升后降,或者总延迟明显变长。

从原理上讲,这是“背压”(backpressure)机制在起作用:当任务提交速度超过GPU执行速度时,提交端会被阻塞。CUDA驱动的API提交本身有锁和队列管理开销,流太多会导致CPU端提交kernel的时间变长,反而盖过了GPU并行带来的收益。

实测数据:我做过一个矩阵乘法的多流测试,数据分块后分别放到2、4、8、16个流里执行。2个流时吞吐提升最明显(约1.7倍),4个流进一步小幅提升,8个流基本持平,16个流出现了轻微下降。结论很明确:流的数量不是越多越好,要结合你的kernel规模、SM资源占用和硬件代数来测试。我一般建议从2和4开始试,别一上来就8个流。

3.3 用Nsight工具验证并发是否真的发生

很多人在自己的机器上写了多流代码,跑完发现性能没变化,就开始怀疑Stream有没有用。我建议不要靠感觉,直接用工具看。

在Nsight Systems里,你可以清楚看到每个流上kernel的时间轴:如果多个流的kernel在时间轴上重叠,说明并发真的发生了;如果它们首尾相接,说明资源不够或者同步没解除。这个工具比任何文字解释都直观。

另外,Nsight Compute里有个“SOL”(Streaming Occupation Limit)的指标,直接显示当前kernel的线程块在SM上的驻留上限。SOL低于50%时,多流并发的空间很小;SOL很低说明SM资源被某个kernel的单个block占得太狠,需要调整block尺寸和资源占用。

4. 事件(Event)与细粒度同步:把调度控制权抓在手里

4.1 默认流与命名流的同步关系

先暂时回到第2节提到的隐式同步。为什么默认流这么特殊?因为CUDA为了兼容早期代码,给默认流设计了一套“过度保守”的同步规则:默认流上的操作会等待此前所有命名流上的操作完成,同时其后所有命名流的操作也会等待默认流的操作完成。简单说,默认流和命名流之间有一条隐形的“全等栅栏”,谁都要等对方。

这就是很多多流程序的隐形性能杀手——你在命名流里辛辛苦苦做了并行分解,但中间某个地方调了默认流的kernel或cudaMemcpy(它运行在默认流上),把整个并行时间线全部打断了。

我第2节提到的cudaStreamNonBlocking标志,就是为了让命名流不跟默认流产生隐式同步。创建流时加上这个标志,命名流就和默认流彻底解耦,完全按你设定的依赖走。在现代CUDA代码里,我强烈建议统一用cudaStreamNonBlocking创建流,除非你明确需要老式同步语义。

4.2 事件的本质:GPU时间线上的里程碑

Event(事件)可以理解为“GPU执行时间线上的一个标记点”。你在某个流上cudaEventRecord(event, stream),就是在该流的操作队列末尾插一个标记;当GPU执行到这个标记时,event被置为“已完成”。CPU可以查询event状态,或者让其他流cudaStreamWaitEvent等待它。

用event做跨流同步,比cudaDeviceSynchronize精细得多。举个例子:流A做完预处理,流B和流C都需要它的结果,但流B和流C之间没有依赖。你可以:

cudaEvent_t preDone; cudaEventCreate(&preDone); // 流A执行预处理后记录事件 cudaEventRecord(preDone, streamA); // 让B和C等待事件,而不是等待所有设备工作完成 cudaStreamWaitEvent(streamB, preDone); cudaStreamWaitEvent(streamC, preDone); // 此刻B和C可以并行执行,A自身后续操作也可以继续(如果不需要等待B/C)

这种“点对点”的同步方式非常强大,它把“全局同步”拆成了“局部依赖”,整个流水线的并行窗口一下就打开了。

4.3 时间测量:用事件替代cudaDeviceSynchronize

事件还有一个特别重要的用途——精确测量kernel耗时。很多人用cudaDeviceSynchronize加CPU计时器,这会包含内核启动的开销和CPU同步等待时间,测出来偏大且不稳定。正确做法是:

cudaEvent_t start, stop; cudaEventCreate(&start); cudaEventCreate(&stop); cudaEventRecord(start, stream); myKernel<<<blocks, threads, 0, stream>>>(); cudaEventRecord(stop, stream); cudaEventSynchronize(stop); float ms = 0; cudaEventElapsedTime(&ms, start, stop);

这里的事件计时是在GPU时间线上测量的,不会把CPU端的启动延迟算进去。排流水线时,我通常会在每个流的“总入口”和“总出口”各放一个事件,测出来的数值才是真实的任务耗时。

5. 实战:多Stream流水线让图像处理吞吐翻倍

5.1 场景拆解:数据分块与任务流水

理论说太多了,直接上一个完整的实战例子。假设我们要对一个由1000帧组成的图像序列做灰度化加高斯模糊。每帧处理流程分三段:

  • H2D:把帧数据从CPU内存拷贝到GPU显存
  • Kernel:在GPU上执行灰度化+模糊的kernel
  • D2H:把处理结果拷贝回CPU内存

如果不使用流,时间线是“拷-算-拷-算”严格串行。如果使用2个流,我们可以把帧分成两组:流0处理偶数帧,流1处理奇数帧。由于帧之间没有数据依赖,两个流上的kernel可以并发执行;同时,流0的kernel运行时,流1的H2D拷贝引擎可以在另一个引擎上并行执行。

5.2 双流实现的完整代码

代码的核心并不复杂:

const int numStreams = 2; cudaStream_t streams[numStreams]; for (int i = 0; i < numStreams; i++) { cudaStreamCreateWithFlags(&streams[i], cudaStreamNonBlocking); } for (int i = 0; i < frameCount; i++) { int sid = i % numStreams; int idx = i / numStreams; char* d_in = d_input + sid * batchFrameBytes + idx * frameBytes; char* d_out = d_output + sid * batchFrameBytes + idx * frameBytes; const char* h_in = h_input + i * frameBytes; char* h_out = h_output + i * frameBytes; cudaMemcpyAsync(d_in, h_in, frameBytes, cudaMemcpyHostToDevice, streams[sid]); processFrameKernel<<<blocks, threads, 0, streams[sid]>>>(d_in, d_out, frameSize); cudaMemcpyAsync(h_out, d_out, frameBytes, cudaMemcpyDeviceToHost, streams[sid]); } for (int i = 0; i < numStreams; i++) { cudaStreamSynchronize(streams[i]); }

注意这里给每个流单独分配了输入输出缓冲区的不同区域(sid * batchFrameBytes + idx * frameBytes),避免不同流同时读写同一块显存。这一点非常重要——如果两个流共用一个缓冲区,cudaMemcpyAsync只是发起异步传输,实际传输可能在kernel运行时才发生,数据竞争就会悄悄出现。

5.3 实测数据:性能提升与瓶颈分析

我在一张RTX 3090上跑过这个例子,图像尺寸1024×1024,灰度化+高斯模糊kernel约0.9ms,H2D拷贝约0.3ms,D2H拷贝约0.3ms。单流版本的每帧总耗时约1.5ms;双流版本的理论下限是max(总拷贝时间, 总kernel时间)而不是三者之和,实测帧间吞吐提升了约38%,接近理论重叠极限。

如果kernel本身的SM占用率偏高(例如用了大量共享内存),双流的提升会变小,但通常依然优于单流。想进一步压榨性能,可以用4个流,但要注意数据分块的大小,块太小会导致kernel启动开销占比升高。

6. 实测中的坑与性能调优:来自项目现场的踩坑笔记

6.1 坑一:cudaMemcpyAsync不是魔法,别乱用

cudaMemcpyAsync虽然叫异步,但它只是把拷贝操作提交到指定流上,真正的数据传输不一定和计算重叠。它有三个前提:

  • 使用cudaMemcpyAsync,不是cudaMemcpy
  • 传输的源和目标必须是“可分页内存”或“映射内存”,其中可分页内存特指用cudaMallocHost分配的锁页内存(pinned memory),普通malloc分配的内存无法真正异步化
  • 拷贝的方向和设备支持情况要满足条件

我见过太多人直接对普通malloc数组调cudaMemcpyAsync,结果性能没变化,还以为是流没用。要发挥异步拷贝的威力,一定要用cudaMallocHost或cudaHostAlloc分配主机端内存,否则异步调用会退化成同步传输。

6.2 坑二:数据依赖没理清,并行变乱序

多流并发的另一大坑是数据依赖。比如流B的kernel需要流A算出的中间结果,但你忘了加cudaStreamWaitEvent,结果流B在流A算完之前就开始读数据,得到的是垃圾值。这种Bug很难复现,因为GPU调度时机不固定,可能跑100次才崩一次。

我的排查习惯是:一旦怀疑多流数据竞争,先砍到单流跑一遍确认结果正确;再一个流一个流地加回来,每加一个流就检查一次结果。如果某个流加进来后结果开始错乱,重点查它和上游流的数据依赖以及事件等待是否齐全。

6.3 调优技巧:流优先级、MPS与硬件差异

最后分享几个压箱底的经验。

第一,CUDA流本身支持优先级(cudaStreamCreateWithPriority),可以给不同流设置不同优先级。实测中,把延迟敏感的小kernel放在高优先级流,把大kernel放在低优先级流,交互场景的卡顿明显减少。

第二,如果你在数据中心GPU上跑多进程或多任务,可以考虑开MPS(Multi-Process Service)。MPS能让多个进程的kernel共享GPU资源,相当于把多流概念扩展到进程级别。但MPS配置复杂,单机单卡场景用多流就够了。

第三,GPU架构对流的并发能力影响很大。Turing及之后架构的Hyper-Q增强了并发kernel的调度能力;Ampere更进一步优化了SM资源感知。老架构上流并发效果差是正常的,不必怀疑自己写错。

我自己的切身体会是:多流编程最难的并不是API本身——API就那几个,难的是建立“GPU是一个并行流水线工厂”的思维模式。每次写kernel之前,先画一条任务时间线,标出哪里有等待、哪里可以重叠,然后才动手写代码。这套方法比盲目堆流数量有效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询