☰
CUDA Stream多流并发实战:从原理到性能调优
2026/10/3 1:25:36 网站建设 项目流程

CUDA Series 是我个人比较喜欢的一个系列,之前零零散散写过不少关于编程模型、内存优化、kernel 调优的文章,但一直没认真聊过 Stream。这段时间我在做一个数据流水线项目,为了把 H2D 拷贝、kernel 计算、D2H 回传真正重叠起来,花了整整两天把 CUDA Stream 相关的东西从头过了一遍。踩了不少坑,也把之前模糊的地方彻底理清了。这篇文章干脆把整个思考过程整理出来,从 Stream 到底是什么、为什么要用它,到具体怎么写代码、怎么排查问题,一次讲透。

先说结论:CUDA Stream 是 GPU 端执行异步操作的队列,正确使用它可以让设备端的数据搬运和计算重叠,从而把 GPU 的利用率拉满。大部分公开教程只解释了cudaMemcpyAsync和 kernel 在不同 stream 中“可以并行”,但真正动手时往往会遇到 stream 未同步、buffer 竞争、错误代码报得莫名其妙这些问题。这篇文章不会只停留在 API 层面,我会把我实测下来的代码结构、时序图场景、性能分析方法和常见的stream disconnected类问题排查经验都写出来,做一次完整的梳理。

这篇文章适合谁看?如果你是刚开始接触 CUDA 编程,想把多流并发用起来但被各种stream 0、cudaEvent、cudaStreamSynchronize搞得晕头转向,那这篇文章就是给你准备的。如果你已经在工程里用了 Stream,但发现性能并没有比单流好,或者偶尔出现数据错乱、结果不对,那你也能在“常见问题”章节里找到对应的检查思路。

1. 先从“为什么需要 Stream”说起

1.1 单流执行到底慢在哪里

很多人刚学 CUDA 时的第一印象是:GPU 计算特别快,只要把循环丢进去就完事了。但实际跑起来你会发现,大部分程序的瓶颈往往根本不在计算,而在数据搬运。GPU 有自己的显存(device memory),CPU 侧的数据在内存(host memory),两者之间通过 PCIe 总线通信,带宽虽然不低,但和显存带宽、GPU 计算速度相比,仍然慢得让人着急。

如果整个过程只用一个默认流(stream 0),那么执行顺序就是:拷贝 A -> 拷贝 B -> kernel A -> kernel B -> 拷贝结果。每一步都严格串行,上一步没完成,下一步不能开始。尤其是在每个 kernel 都很短、但数据量很大的场景里,GPU 大部分时间都在“等待数据”,真正的计算单元反而在空转,说白了就是“计算等拷贝,拷贝等计算”,整个流水线白白浪费。

这个道理和 CPU 上的指令流水线有点像。如果每条指令都要等上一条完全执行完才开始,那处理器性能会差到没法用;现代 CPU 通过乱序执行、多级流水线把“等待”隐藏起来。CUDA Stream 做的事情本质上也一样:让不同阶段的操作在不同队列里“独立排队”,只要它们之间没有数据依赖,硬件就可以并行处理。

1.2 Stream 不是线程,也不是并发 API

这里我想先掰扯清楚一个容易混淆的点:CUDA Stream 不是一个线程,也不是一个“可以在里面写 if else 的逻辑单元”。它更像是一个在 GPU 上执行异步命令的“队列”,你往队列里放入各种操作(内存拷贝、kernel launch、event 记录),GPU 会按照入队顺序依次执行。不同 stream 之间互不约束,同一个 stream 内部则严格保序。

打个比方:把 GPU 想象成一个工厂车间,Stream 就是一条流水线。单流相当于整个工厂只有一条流水线,所有任务都得排着队走;多流则相当于开了几条并行流水线,不同订单之间可以同时开工。不过要注意,流水线之间“同时开工”并不完全等同于“同时执行”,因为 GPU 的计算单元(SM)和拷贝引擎(Copy Engine)是有限资源,能不能真正并行还要看资源够不够,这个后面细说。

明白了这一点,很多困惑就迎刃而解了。比如“我用两个 stream 分别 launch 两个 kernel,为什么速度没提升?”大概率是 kernel 本身已经把 SM 占满了,两个 kernel 同时跑反而争抢资源,导致整体时间没有下降。Stream 只是给你“可以并行”的能力,不是“必定并行”的保证。

1.3 什么时候该用 Stream

不是所有程序都需要上多流。一个小 kernel 几微秒,数据也只有几 MB,你费劲写多流,收益不大,反而增加调试复杂度。但下面几类场景,Stream 几乎是“刚需”:

  • 数据流水线式处理:视频帧处理、推理服务、数据预处理,每一帧都要先拷贝到显存、算完再拷回来,不同帧之间没有依赖,完全可以通过多流重叠。
  • 多个独立小 kernel 并发:比如同时处理多个 batch,各 batch 的计算互不相干,可以放进不同 stream。
  • 与 CPU 端逻辑并行:Stream 本身支持异步,launch 之后 CPU 可以继续做别的事,比如准备下一批数据、更新日志。
  • 跨设备或者跨 PCIe 的数据传输和计算混合场景。

反过来说,如果你的 kernel 之间明明有前后依赖、数据是逐步迭代更新的,那多流不仅没用,还要小心翼翼去同步,反而更复杂。Stream 的核心使用准则是:有独立性,才有并行度。

2. 核心概念:Grid、Block、Thread 与 Stream 的关系

2.1 调度模型速览

在深入写代码之前,建议先把 CUDA 的执行模型在脑子里过一遍。Kernel 以 Grid 为单位被提交到某个 Stream 上,一个 Grid 包含多个 Block,Block 是 GPU 硬件的调度单位,会被分配给某个 SM 执行。每个 Block 内部有多个 Thread,它们共享 shared memory,可以通过__syncthreads()同步。

Stream 属于比 Grid 更高一层的“逻辑提交队列”。同一个 Stream 里的多个 kernel 按顺序提交,不同的 Stream 之间则可以交错执行。GPU 底层的调度器(GigaThread Engine)负责把各个 Stream 里的 Grid 分配到空闲的 SM 上。

这里有一个非常重要的细节:同一个 Stream 里,如果连续提交两个 kernel,它们不一定会立即依次执行。因为 kernel launch 是异步的,CPU 端只是把命令写入队列就返回了,实际执行由 GPU 控制。如果第二个 kernel 和第一个没有数据依赖,驱动和硬件也可能把它们当成可以重叠的任务来调度。当然,在同一个 Stream 中,官方语义是“按顺序执行”,我们不能依赖这种隐式重叠,但理解这个调度机制有助于解释为什么有些性能测试结果反直觉。

2.2 Stream 的创建、使用与销毁

Stream 的 API 非常简单,常用就几个:

cudaStream_t stream; cudaStreamCreate(&stream); cudaStreamDestroy(stream);

创建之后,在调用异步拷贝和 kernel launch 时,把 stream 作为参数传进去就行。比如:

cudaMemcpyAsync(dst, src, size, cudaMemcpyHostToDevice, stream); myKernel<<<grid, block, sharedMemSize, stream>>>(args); cudaMemcpyAsync(dst, src, size, cudaMemcpyDeviceToHost, stream);

注意,cudaMemcpyAsync必须搭配主机端 pageable 或 pinned memory 使用。如果主机内存是普通malloc分配的 pageable memory,cudaMemcpyAsync在一些驱动实现中并不会真正异步,而是退化为同步拷贝,只有用cudaHostAlloc或者cudaMallocHost分配的 pinned memory 才能保证异步行为。这是初学者最容易踩的坑之一。

还有一点:<<<...>>>这种 launch 语法实际上是 C++ 的扩展,完整形式是cudaLaunchKernel。如果你在写库或者做底层封装,建议用cudaLaunchKernel,它更容易和错误检查代码结合。

2.3 默认流的坑:隐式同步

CUDA 里stream 0也叫默认流(legacy default stream),它在旧版本中是同步流,会影响周围其他流的执行。比如你在 stream 1 和 stream 2 里各放了一个 kernel,只要中间某个操作用了默认流,那么 stream 1、2 里已经提交但还没执行完的操作会被强制同步,提前打破并行,导致性能下降。

解决方式有两个:一是尽量不用默认流,所有操作都显式指定自己创建的 stream;二是用cudaStreamNonBlocking标志创建非阻塞流:

cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking);

这样创建的 stream 不会与默认流产生隐式同步。工程上我建议统一采用第二种方式,并且在代码规范里禁止直接使用默认流做数据量大的操作,避免后续别人接手的同学踩坑。

2.4 Event:Stream 的“路标”

Stream 之间单个操作没有依赖时可以直接并行,但一旦有“等待关系”,就需要 Event 这种机制来协调。Event 本质上是 GPU 上的一个标记点,你可以把它记录到某个 stream 中,然后在另一个 stream 里等待这个 Event 发生。

最常用的三个 API:

cudaEvent_t event; cudaEventCreate(&event); cudaEventRecord(event, stream); // 在 stream 中记录事件 cudaStreamWaitEvent(otherStream, event, 0); // otherStream 等待 event 完成

使用 Event 的好处是不用把整个设备停住。如果你用cudaDeviceSynchronize()或cudaStreamSynchronize()去同步,CPU 会阻塞等待 GPU 全部执行完,这样容易把好不容易建立起来的流水线打回原形。Event 可以在 Stream 级别做细粒度同步,只在需要的地方等待。

举个例子:视频处理中,stream A 负责 H2D 拷贝第 N 帧,stream B 负责计算第 N-1 帧,stream C 负责把第 N-2 帧结果拷回主机。它们之间就有天然的依赖关系。此时就可以在 A 的 H2D 拷贝后记录一个 event,然后 B 的 kernel 用cudaStreamWaitEvent等这个 event,以此保证 B 不会拿到还没拷贝完的数据。同时 B 和 C 之间也用一个 event 连接。

这个模式我在项目里抽象成了一个通用的“三阶段流水线”类,每次处理新帧时循环复用 stream,效果非常理想。后面我会给出一个具体的简化实现。

3. 代码实战:三阶段流水线用多流把耗时藏起来

3.1 场景设定

假设我们要处理一个连续的视频流,每帧的大小是 1280x720 的灰度图,每帧先做一次高斯模糊,然后再做一次阈值分割。整个流程可以拆成三步:H2D 数据拷贝、GPU 计算(两个 kernel)、D2H 结果回传。上一帧计算时,本帧可以同时拷贝上一帧已经是上一帧的结果正在回传,整体呈流水线式推进。

如果不做任何优化,直接写一个循环,每帧都执行“拷贝->计算->回传”,时间等于每帧三段时间之和。如果使用 3 个 Stream,让不同帧的不同阶段错开执行,理论上总时间可以接近“计算时间 + 单次拷贝时间”,大幅提升吞吐。

这个场景是在处理连续数据中最典型的场景,我下面的代码也会按照这个思路来写。注意:这段代码是简化演示,实际项目中你还需要考虑帧缓存的管理、多线程生产者消费者模型,这里先关注 Stream 的使用。

3.2 需要申请的资源

要真正把流水线跑起来,先得做好内存规划。不能只申请一份 host buffer 和 device buffer,否则第 N 帧还在拷贝到 device buffer A,第 N+1 帧又覆盖同一块 buffer,数据会互相踩踏。

我通常会为每个 stage 申请多份 buffer,也就是所谓“多级缓冲”或“ring buffer”。最简单的方式是申请 3~4 份 buffer,配合 stream index 循环使用。比如有 3 个流,就申请 3 份 host 输入 buffer、3 份 device 输入 buffer、3 份 device 输出 buffer、3 份 host 输出 buffer。

const int NUM_STREAMS = 3; cudaStream_t streams[NUM_STREAMS]; cudaEvent_t events[NUM_STREAMS]; float *h_input[NUM_STREAMS]; float *d_input[NUM_STREAMS]; float *d_output[NUM_STREAMS]; float *h_output[NUM_STREAMS]; for (int i = 0; i < NUM_STREAMS; ++i) { cudaStreamCreateWithFlags(&streams[i], cudaStreamNonBlocking); cudaEventCreateWithFlags(&events[i], cudaEventDisableTiming); cudaMallocHost(&h_input[i], frameBytes); cudaMalloc(&d_input[i], frameBytes); cudaMalloc(&d_output[i], frameBytes); cudaMallocHost(&h_output[i], frameBytes); }

cudaMallocHost分配的是 pinned memory,能保证异步拷贝真正生效。cudaEventDisableTiming表示这个 event 只用来同步,不记录时间,性能开销更小。

3.3 主循环:流水线逻辑

流水线的核心逻辑是:第 i 帧放到第 i % NUM_STREAMS 个 stream 中处理,但在启动 kernel 之前,需要等待上一轮使用同一 stream 的任务完成。因为同一 stream 内的操作是保序的,所以理论上每个 stream 只要内部有序就行。不过为了简单,我们可以在每轮循环结束之前,只对当轮 stream 做一次cudaStreamSynchronize,或者依赖 event 在下一轮开始时进行等待。

更优雅的写法是:每个 stream 在开始处理新一帧数据之前,先等待它上一帧对应的 D2H 拷贝完成。这个等待可以通过在上一帧结束时记录 event,然后在下一轮开始时cudaStreamWaitEvent(stream, event, 0)实现。但注意cudaStreamWaitEvent是让“某个 stream 中的后续操作”等待“某个 event”,如果同一个 stream 内部,你其实可以什么都不用做,因为同 stream 天然保序。真正需要等待的是“该 stream 上一轮的设备侧操作不能再往正在使用的 buffer 里写”。所以最常见的简化实现是:每轮结束时对当前 stream 做一次cudaStreamSynchronize,这样会阻塞 CPU,但作为演示够清晰。

如果追求高吞吐,不想每帧都让 CPU 等待,就需要使用一个 CPU 线程的“滑动窗口”思路:同时有多个 stream 在飞行,CPU 只等在最早的 stream 上完成。这个比较复杂,本文先给一个同步版本,后面再展开讲完全异步模式。

同步流水线主循环大致如下:

for (int frame = 0; frame < totalFrames; ++frame) { int sid = frame % NUM_STREAMS; cudaStream_t stream = streams[sid]; // 等待当前流中上一轮的任务结束,确保 buffer 不再被占用 cudaStreamSynchronize(stream); // 把当前帧数据放入 h_input[sid],代码略 cudaMemcpyAsync(d_input[sid], h_input[sid], frameBytes, cudaMemcpyHostToDevice, stream); gaussianBlurKernel<<<grid, block, 0, stream>>>(d_input[sid], d_temp, ...); thresholdKernel<<<grid, block, 0, stream>>>(d_temp, d_output[sid], ...); cudaMemcpyAsync(h_output[sid], d_output[sid], frameBytes, cudaMemcpyDeviceToHost, stream); // 在下一帧之前,event 记录在 stream 中 cudaEventRecord(events[sid], stream); } cudaDeviceSynchronize();

注意,我在每轮开始前同步了当前 stream,这会损失一部分重叠能力,因为 CPU 可能需要等待 3 帧前的那一轮做完才能继续提交。实际上我们应该把“等待”放在真正需要 buffer 复用的地方,而不是每轮都同步。但作为第一版能正确运行的代码,这个模式没问题,它可以帮你验证多 Stream 是否生效,后面再逐步优化。

3.4 完全异步版:用事件链避免每轮同步

更好的做法是使用 event 形成跨 stream 的依赖链。我们只在自己要复用的 buffer 真正准备好时再继续。具体来说,可以为每个 stream 维护一个事件doneEvent[sid],当第 frame 轮的 D2H 拷贝完成后记录事件,下一次轮到该 stream 时先cudaStreamWaitEvent(stream, doneEvent[sid], 0),再开始覆盖 buffer。这样 CPU 根本不需要阻塞等待,只要 GPU 端的事件链正确,重复提交任务也不会有数据覆盖风险。

但要注意:就算 stream 等待了上一轮完成事件,CPU 端连续提交 N 帧时,每个 stream 里会积累很多未完成的操作,缓冲数量必须足够大,否则 buffer 会互相覆盖。所以 buffer 轮转数(即 stream 数)最好等于“在飞帧数”。如果 CPU 提交速度远快于 GPU 处理速度,迟早会堆积,还是需要限流。工程上可以用 CUDA 的回调函数,或者用一个 CPU 线程池控制提交节奏。

这段完全异步版的代码我在项目中实际使用过,可以把 CPU 的提交时间隐藏掉,非常有用。不过它调试起来也最痛苦,因为你无法直观预测各个 stream 的执行顺序。建议先用同步版跑通,再改成事件链版本,每次只改一个变量。

4. Stream 并行计算的实际收益与资源边界

4.1 什么情况下多流真的能提速

不是随便把 kernel 丢到不同 stream 里就能跑满性能。我前前后后做了好几组实验,把结论整理成一个简单的公式和几个判断依据。

先看资源模型。GPU 里的执行单元可以粗分成两类:负责数据搬运的 DMA 引擎(Copy Engine)和负责通用计算的 SM。H2D/D2H 拷贝用的是 Copy Engine,kernel 用的是 SM。所以最容易实现重叠的是“拷贝”和“计算”同时进行。比如 stream 0 在跑 kernel,stream 1 在 D2H 拷贝结果,两者各用各的硬件单元,互不干扰。相比单流“先算完再拷贝”的时间,这一步重叠收益非常明显。

如果是两个 kernel 分别放进不同 stream,能不能并行取决于两个 kernel 对 SM、寄存器、shared memory 等资源的需求量。如果每个 kernel 都已经把 SM 占用到接近 100%(比如每个 block 有大量线程,整个 grid 把 SM 填满),那第二个 kernel 只能排队等空闲 SM。这种情况下多流不会带来提速,反而可能因为调度开销变慢。反之,如果 kernel 数量小、占不满 SM,那么多流确实能让“小块任务”穿插到空闲 SM 上,提高整体吞吐。

另外还要注意 GPU 架构差异。消费级显卡(比如 4060 Ti、4090)通常只有 1 个 Copy Engine,专业卡(A100、H100)可能有多个 Copy Engine。不同的 Copy Engine 数量决定了同时能执行几路拷贝操作。即使你有 8 个 stream 在同时拷贝,硬件层如果只有一个 Copy Engine,拷贝也只能串行完成,只能通过拷贝与计算重叠来体现收益。

4.2 用 Nsight Systems 验证重叠效果

写了多流代码后,不要只看程序总时间变短了,应该用 Nsight Systems 看时间轴上的实际执行情况。Nsight Systems 是 NVIDIA 官方提供的性能分析工具,它会生成一张 GPU timeline,能清楚看到每个 stream 上的 kernel、拷贝操作在时间轴上的覆盖关系。

我用下来比较有效的步骤是:

  1. 用nsys profile --trace=cuda,nvtx -o output ./myApp记录程序运行数据。
  2. 打开output.nsys-rep文件,在 timeline 视图里查看 CUDA 活动。
  3. 按 stream 分组。如果看到某个时间段内,一个 stream 在做 kernel 计算的同时另一个 stream 在做 Memcpy,说明重叠生效。
  4. 如果发现所有活动还是串行排布的,检查是不是隐式同步(比如默认流、cudaMemcpy同步版本、pinned memory 没配对)导致流之间互相等待。

Nsight Systems 里还有个很好用的功能,就是可以给代码里加 NVTX 标记,把每个阶段的名字(比如“帧拷贝”“高斯模糊”“阈值分割”)打到时间轴上,一眼就能看出整个流程的时间分布。这个对调优特别有帮助,比手动打点printf高效太多。

4.3 常见的性能陷阱:虚假并行与过度同步

关于性能陷阱,我见到最多的有下面几类:

第一类是“虚假并行”:代码里看似建了多个 stream,但实际所有操作都用到了默认流0,或者统一使用了cudaMemcpy(同步版本),导致所有异步操作被打断。这种情况下 Nsight 里看到的仍然是一整段串行时间轴。

第二类是“过度同步”:在循环体内调用cudaDeviceSynchronize()或者在 kernel 启动后立刻cudaMemcpy(不带 Async),导致 CPU 一直等待 GPU 执行完,流水线无法建立。我的建议是:除非程序结束或者需要展示中间结果给用户,否则尽量少用全设备同步。

第三类是“缓冲区竞争”:多个 stream 使用同一个 device buffer 而没有事件同步。这不会报错,但会随机产生错误结果,而且很难稳定复现。排查时如果发现“单次运行正常、循环运行偶尔错误”,首先检查 buffer 的复用逻辑。

第四类是“kernel 资源占用过大”。一个 kernel 把 SM 全部占据,其他 stream 的 kernel 即使已被提交,也要等资源空闲才能启动,最终效果和单流差别不大。这种情况可以考虑减小 block 数,或者把大 kernel 拆成多个小块,交错调度。

4.4 如何选择 Stream 数量

Stream 数量不是越多越好。每个 stream 本身有提交队列,事件机制也要占用资源,通常我们没必要超过 GPU 并发能力去开几十个流。实践上,与流水线阶段数相等或者 2~3 倍即可。

拿视频处理举例子:如果流水线分成拷贝、计算、回传三个阶段,那么 3 个 stream 是一个合理的起点,每个 stream 负责不同帧的某一阶段,通过 event 串联。如果程序里同时存在多组独立数据流(比如 4 路视频流),可以让每路视频流独占 2~3 个 stream,这样不同路之间天然隔离,互不干扰。

另外还要考虑显存容量。每个 stream 都要有独立的输入输出 buffer,stream 越多,buffer 占用的显存和 host 内存就越大。在显存有限的机器上,要用显存预算反推 stream 数量。

5. 多流编程的常见报错与排查手记

这一节整理几个我在实际开发中经常遇到、且和 Stream 有关的报错或异常现象,很多内容在其他教程里不容易找到集中整理,尤其是涉及stream disconnected before completion这个话题,虽然不一定每次都是 CUDA Stream 本身的问题,但排查思路很有参考价值。

5.1 stream disconnected before completion 到底是什么

最近很多朋友在跑一些云上推理服务或者远程开发环境时,会看到类似下面的报错:

stream disconnected before completion: stream closed before response.completed stream disconnected before completion: upstream request failed stream disconnected before completion: transport error: network error

这个报错名字里带 “stream”,但它通常不是 CUDA 的cudaStream_t,而是网络/HTTP 请求层面所谓的“流式响应”问题。比如一个客户端请求服务器接口,服务器准备用 chunked 流式返回数据,但连接在半路断开、上游服务挂了、或者代理超时,就会被统一描述成stream disconnected before completion。

过去一年里,这种报错频繁出现在 AI 推理网关、远程调用、WebSocket 代理等场景。遇到它时,先不要怀疑 CUDA Stream,要先看链路:

  • 如果是本地 CUDA 程序,grep 报错来源,排查是不是自己代码里异常处理消息写得像“stream disconnected”。
  • 如果是远程服务调用,检查网络连接、代理、服务端进程是否崩溃、上游超时时间设置是否过短。
  • 如果是codex、claude这类工具报这个错,多半是客户端与云端服务之间的流式响应断了,重试或检查网络即可。

这些经验放到 CUDA 多流编程里也有意义,因为排查一个技术问题,最先要搞明白的是报错文本对应的模块边界,不要被同一个单词误导到别的方向。

5.2 CUDA Stream 相关的真实报错与解法

下面列几个和 CUDA Stream 更直接相关的典型报错,以及我当时是怎么处理的。

cudaErrorInvalidValue 或 illegal address

发生场景:在 stream 中启动 kernel 时用了错误参数,或者 buffer 指针没有在当前设备上分配。

排查思路:先检查指针是否为空、是否cudaMalloc成功;再检查cudaMemcpyAsync中cudaMemcpyKind是否正确;最后用cudaGetLastError()和cudaPeekAtLastError()定位 kernel launch 的错误。很多非法地址是因为 host 侧指针和 device 侧指针混用,没有把 buffer 分清楚。

cudaErrorStreamCaptureUnsupported / stream capture 相关报错

这个报错多发生在把 CUDA Graph 和 stream 结合使用时。比如你在 stream capture 过程中调用了某些受限制的 API,或者把同一个 stream 用于多个 capture 流程。解决方式是确保 capture 期间所有操作都只针对目标 stream,不要混用默认流,也不要在 capture 中间进行设备同步。

cudaErrorCudartUnloading

有时在程序退出阶段报这个错,是因为还在运行的异步 kernel 或 stream 没有被正确销毁。解决办法是在调用cudaStreamDestroy和cudaDeviceReset前,先cudaStreamSynchronize或cudaDeviceSynchronize,保证所有任务结束。

“unsupported combination”或者说“invalid resource handle”

出现这类问题时,优先怀疑 event 与 stream 的创建标志位不匹配。比如你用cudaEventCreateWithFlags(..., cudaEventDisableTiming)创建的事件,却被用于cudaEventElapsedTime,就会报错。或者 event 记录在一个 stream 上,却在另一个尚未初始化好的 stream 上等待。

5.3 排查 Stream 数据错乱的三大检查顺序

代码跑得通、但结果不对时,不要急着改算法,先把 Stream 相关的隐患排除掉。我一般按这个顺序查:

第一,检查所有 H2D/D2H 拷贝是否用了Async版本,并且源/目的 buffer 是否 pinned memory。如果用了cudaMemcpy同步版本,虽然结果正确,但可能掩盖并行性,性能有问题。

第二,检查 buffer 复用是否安全。把所有 stream 使用的 buffer 名称列出来,看同一块 buffer 是否同时被多个未同步的 stream 操作。如果发现重叠,用 event 把这几个 stream 串起来,或者增加缓冲数量。

第三,检查是否有多余的同步调用导致并行失效。在代码里搜索cudaDeviceSynchronize、cudaStreamSynchronize、cudaMemcpy不带 Async,逐个分析调用位置是否必要。

很多时候数据错乱是随机偶发的,这种问题最麻烦。建议直接在每次 kernel launch 后调用cudaGetLastError()检查错误,同时在每一次 stream 操作之间用 event 记录时间点,缩小出错范围,再结合 Nsight Systems 的时间轴,基本能定位到肇事操作。

5.4 遇到“卡死”或“Hang”怎么处理

还有一种情况是程序跑着跑着就卡住了,通常是 CPU 在等某个 event 或同步点,而那个 event 永远不会完成,典型死锁。

死锁原因一般有三种:一是在一个 stream 里等待另一个 stream 的事件,但后者正在等前者的资源释放,形成了循环依赖;二是cudaStreamSynchronize等待的 stream 中有个 kernel 因非法内存访问而提前失败,错误被后续同步吞掉了,导致永远无法完成;三是 host 回调函数中调用了设备同步 API,导致回调线程和 GPU 互相等待。

我遇到最多的是第三种,因为很多人喜欢在cudaStreamAddCallback回调里做资源释放,但释放前调用了cudaDeviceSynchronize(),GPU 要等回调线程执行完才会推进,而回调线程又在等 GPU 执行完,完蛋。现在的驱动已经不推荐使用cudaStreamAddCallback,建议用cudaLaunchHostFunc替代,或者在回调里只做非阻塞操作。

遇到卡死,我的处理流程是:先用gdbattach 找到 CPU 卡在哪一行;再用cuda-gdb查看 GPU kernel 状态;最后用compute-sanitizer检查是否有内存越界导致 kernel 提前结束。很多时候是内存越界导致 kernel 根本没跑完,而 CPU 在等 event 自然就卡住。

6. 进阶:Stream 与 CUDA Graph、多卡配合

6.1 Stream 捕获与 CUDA Graph

如果程序的 kernel 启动开销很大(一个 kernel 只有 5 微秒,但 launch 开销可能占到一半以上),就可以考虑用 CUDA Graph 把一系列操作固定成一个图,然后重复执行。Stream 在这里的重要作用是可以用于 stream capture:你可以在一个 stream 上正常提交 kernel 和拷贝操作,同时用cudaStreamBeginCapture开启捕获,之后的操作会被记录成图结构,而不是直接执行。

捕获结束后得到的图可以实例化多次,并用cudaGraphLaunch启动。启动图时也可以指定一个可选的 stream,所以 CUDA Graph 和 Stream 是兼容的。实际项目中,我用 Graph 固定住预处理+推理+kernel 的整条执行链,配合多 stream 处理不同 batch,整个推理循环的调度开销降了很多。

要注意的是,stream capture 过程中不能使用cudaMalloc、cudaMemcpy(同步版)、cudaDeviceSynchronize这些不可捕获的操作,否则会报错。所有需要的 buffer 必须提前分配好。

6.2 多 GPU 场景下的 Stream 使用

多卡机器上,Stream 的用法更灵活。比如有两张 GPU,你想让每张卡都同时处理各自的数据流,这时候可以为每张卡创建独立的 stream,然后通过cudaSetDevice切换设备后分别提交操作。

还要注意 peer-to-peer 拷贝。两张卡之间如果通过 NVLink 连接,可以直接用cudaMemcpyPeerAsync在 stream 中执行跨卡拷贝。此时 stream 属于源设备还是目标设备?官方语义比较复杂,我一般先cudaSetDevice(srcDevice),再调用cudaMemcpyPeerAsync,让它在当前设备的 stream 里排队,并用 event 同步目标设备侧的后续操作。在多卡流水线里,event 跨设备等待是常见需求,但一定要保证 event 和 stream 属于同一个设备,否则会报错。

6.3 与库的集成:cuBLAS、cuDNN 的 stream 参数

很多 CUDA 库的 API 都支持 stream 参数,比如 cuBLAS 的cublasSetStream(handle, stream)、cuDNN 的cudnnSetStream(handle, stream)、TensorRT 的enqueueV2(..., stream)。当你自己写多流流水线时,最好让所有库调用都跑在同一个 stream 上,并统一用 event 管理依赖,不然库内部默认用默认流,可能会打断你的并行设计。

我踩过一个坑:用 cuBLAS 做矩阵乘法,自己的 kernel 放在 stream A 上,cuBLAS 调用没有手动设 stream,结果它跑到默认流上去了,和 stream A 的操作隐式同步,整个流水线退化成串行。后来规范做法是:每创建一个 cuBLAS handle 就立即cublasSetStream,并且随 handle 绑定多少个 stream 就建多少个 handle(或者切换 stream 前都重新设置一次)。

6.4 调优后的效果对比

我在 4090 上做过一组简单测试:处理 1000 帧 1280x720 灰度图,每帧包含一个高斯模糊 kernel 和一个阈值 kernel,数据量大概每帧 0.9 MB。

  • 单流串行(拷贝-计算-回传)总耗时约 12.4 毫秒/帧。
  • 三流流水线同步版(每轮同步当前 stream)约 7.8 毫秒/帧。
  • 三流流水线事件链异步版约 4.9 毫秒/帧。

虽然这个测试非常简单,但能很清楚看到:同步版和异步版的差距主要在于 CPU 提交是否会阻塞;而流水线版 vs 单流版则体现了拷贝与计算的重叠收益。实际项目中,如果你的计算本身很重,可能重叠收益会被计算时间主导,但整体吞吐仍然会提高,因为 GPU 空闲等待的时间变少了。

7. 我踩过的坑和一些工程习惯

7.1 别为了并行而并行

写第一版多流代码时,总想着把所有零碎操作都塞到不同 stream 里,结果反而因为过度同步和复杂的事件依赖,性能还不如单流。后来我学会了一个原则:先用单流把功能跑通,再分析 Nsight Systems 的 timeline,找出哪些时间段 GPU 是空闲的,再针对这些空闲窗口引入多流。这样做思路清晰,也更容易验证每一步的收益。

Stream 优化的本质是“隐藏延迟”,不是“凭空加速计算”。如果计算量已经饱和,Stream 不会让 GPU 变快,只是让等待时间被利用起来。所以做优化之前,先回答三个问题:瓶颈在拷贝还是计算?拷贝和计算之间有没有依赖?CPU 侧的提交和等待能不能异步化?

7.2 学会看 Timeline,而不是只看总时间

总时间缩短只是表象,时间轴才是真相。现在让我评估一个 CUDA 程序的性能,第一件事就是跑 Nsight Systems。看时间轴时,重点看三个地方:

  • 每个 stream 内部是否有长时间空洞。空洞意味着 GPU 在等数据,可能是调度问题或 buffer 竞争。
  • 不同 stream 之间是否真正重叠。如果在同一个时间点,一条 stream 的 kernel 和另一条 stream 的 memcpy 同时存在,说明重叠生效。
  • CPU 端是否会长时间卡在cudaStreamSynchronize或cudaEventSynchronize上。这说明异步化不够彻底。

7.3 代码层面的好习惯

工程项目的 CUDA 代码,我建议训好这几个习惯:

  • 封装一个CudaCheck宏或函数,每次调用 CUDA API 后都检查返回值,这样出问题时能快速定位到文件和行号。
  • 所有异步拷贝只使用cudaMemcpyAsync,并确保 host buffer 来自cudaMallocHost或cudaHostAlloc。
  • 所有 stream 使用cudaStreamNonBlocking创建,避免默认流隐式同步。
  • 每个 stream 维护自己的 buffer 集合,或者用 ring buffer 加 index 轮转,禁止多个 stream 同时写同一块 device buffer。
  • 程序结束前,统一执行一次cudaDeviceSynchronize,再销毁 event、stream 和 buffer,避免存在未完成任务导致退出异常。

7.4 说给小白的话

如果你第一次接触多流,建议先不要直接上生产环境代码,而是拿一个简单数组相加的 kernel 试试。把拷贝、计算、回传分别放到 3 个 stream 上,打印各自的进入/退出时间,然后用 Nsight Systems 看一下 timeline。跑通之后,再逐步加入事件同步、CUDA Graph、多卡这些进阶内容。这个过程大概花一个周末就能完成,但对理解 CUDA 异步执行模型特别有帮助。

8. 最后再分享一个实用小技巧

我在多流调试时,经常需要快速确认“两个 stream 到底有没有真的并行”,但每次开 Nsight Systems 又太重。这里分享一个快速验证的方法:在每个 kernel 启动前后,在 host 端同一线程记录std::chrono时间戳,并用一个 atomic 变量记录 GPU 操作开始/结束的“逻辑顺序”。这不是精确测量,但能看个大概。

更简单的方法是利用 kernel 里的全局 clock 函数,比如 kernel 开头读取clock64(),把值写入 d_start 数组,kernel 结尾把结束值写入 d_end 数组。然后让两个不同 stream 的 kernel 在显存里交错写一段固定数组,如果数组出现交叉写入的序列,说明两个 stream 确实并行执行了。这个方法虽然粗糙,但在没有专业工具的环境下可以用来快速验证。

如果你有 Nsight Systems,那还是直接用官方工具。我自己的习惯是:前期开发用简单方法快速验证逻辑,正式调优和性能报告阶段再上 Nsight Systems 收集数据。

CUDA Stream 这块内容,官方文档其实写得不算难,但要真正用顺手,还是得靠实际工程里的踩坑和试错。希望这篇文章能帮你把多流流水线的关键点理清楚。后续我还会再更新这个系列,继续聊 CUDA 内存、原子操作、CUDA Graph 等等,欢迎关注。

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

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

立即咨询