CUDA 流介绍
Categories: Hpc
CUDA Stream 原理
CUDA 程序的并行,不只发生在 kernel 内部。一方面,kernel 内部会通过 thread、warp、block 在 GPU 上并行执行;另一方面,kernel 之外的 CUDA 操作,例如内存拷贝、kernel 启动、内存清零,也可以通过 CUDA Stream 进行组织,并在硬件允许、依赖关系满足时实现重叠执行。
初学者可能会把 stream 简单理解成“让 kernel 外部并行起来的工具”,这个说法不算错,但不够准确。更准确的说法是:CUDA Stream 是一条操作队列。主机线程把 CUDA 操作按顺序提交到某个 stream 中;同一条 stream 内保持顺序,不同 stream 之间没有默认顺序约束,因此有机会并发执行。
这里的重点不是“天然并行”,而是异步提交 + 有条件重叠。
可以重叠什么
从程序行为上看,stream 常见的作用主要有几类:
- kernel 计算与数据传输重叠
- 主机计算与设备端工作重叠
- 不同 stream 中的 kernel 交错或并发执行
- 不同 stream 中的数据传输与计算交错执行
但要注意,是否真的并发,不只取决于你用了几个 stream,还取决于:
- GPU 是否支持相应并发能力
- 操作之间是否存在依赖
- 是否使用了异步 API
- 主机内存是否为 pinned memory
- kernel 是否已经吃满了 GPU 资源
所以,stream 提供的是调度上的独立性,不等于一定带来并行收益。
为什么很多时候 stream 看起来“不重要”
如果程序设计得足够“GPU 化”,通常会尽量做到两点:
- 尽量减少主机和设备之间的数据传输
- 尽量把计算留在设备端完成,避免主机频繁介入
在这种情况下:
- 主机计算与设备计算的重叠,价值会下降
- 主机与设备频繁 copy 的优化空间,也会变小
另外,如果单个 kernel 已经足够大,能够很好地占满 SM、寄存器、shared memory 和带宽,那么同时再跑多个 kernel,收益也未必明显,甚至可能相互争抢资源。
所以在很多高吞吐场景里,优化重点往往不是“开更多 stream”,而是:
- 提高单个 kernel 的效率
- 减少不必要的数据搬运
- 提升访存质量
- 做 kernel fusion
- 减少同步点
不过,在下面这些场景中,stream 依然非常重要:
- 推理服务中的请求级并发
- pipeline 式的数据预取与计算重叠
- 多 batch 分块执行(Deepseekv3 的推理优化方案,实现计算和通信的重叠)
- H2D / D2H copy 与 kernel overlap
- 多阶段任务链路的异步调度
CUDA Stream 的基本语义
一个 CUDA Stream,可以理解为设备上的一条命令队列。主机线程把 CUDA 操作提交到某个 stream 后:
- 同一 stream 内的操作按提交顺序执行
- 不同 stream 之间默认没有先后依赖。因此,不同 stream 中的操作可能:并发执行、交错执行、也可能仍然串行,取决于硬件资源和运行时条件
Stream 的一些关键理解:
- 同一 stream 内是有序的:比如先发起 memcpy,再启动 kernel,那么这个 kernel 会等前面的 memcpy 在同一 stream 中完成后再执行。
- 不同 stream 之间默认无序:如果两个操作在两个不同 stream 中,CUDA 运行时不会自动为它们建立顺序关系。它们可能重叠,也可能因为资源不足而排队。
- stream 解决的是“调度关系”,不是“自动提速”:stream 本身不创造算力。它只是给运行时一个机会,让原本被顺序提交的操作,变成可以异步推进、可能重叠的工作流。
CUDA Stream 实践
任何 CUDA 操作都属于某个 stream:
- 要么属于默认流(default stream)
- 要么属于显式创建的非默认流
如果不手动指定 stream,大多数 CUDA Runtime API 会把操作提交到默认流。
早期资料里经常把默认流叫做 null stream 或 空流。这个名字历史上常见,但在写文章时,直接称为“默认流”更清晰。
创建 CUDA Stream
CUDA Runtime API 中,stream 由 cudaStream_t 表示,可以这样创建:
__host__ cudaError_t cudaStreamCreate(cudaStream_t* pStream);
更常见的写法是:
cudaStream_t stream;
cudaStreamCreate(&stream);
使用完成后销毁:
cudaStreamDestroy(stream);
一个最小例子
下面的代码创建了一条非默认流,并把 kernel 提交到这条 stream 中:
cudaStream_t stream;
cudaStreamCreate(&stream);
kernel<<<grid, block, 0, stream>>>(...);
cudaStreamDestroy(stream);
这里第四个执行配置参数就是 stream。
如果不写这个参数,kernel 默认提交到默认流。
如果基于 stream 做 CUDA 性能优化,关于 stream,优先关注这几件事:
- 先确认是否真的存在可重叠的工作
- 优先减少不必要的数据传输
- 需要重叠 copy 与 compute 时,使用
cudaMemcpyAsync+ pinned memory - 显式使用非默认 stream,避免默认流语义干扰
- 用 profiler 看时间线,而不是凭感觉判断是否并发
一个简单判断标准是:
如果时间线中 copy、kernel、主机侧准备工作可以形成流水线,stream 往往值得做;
如果 kernel 本身已经很重,先优化 kernel,通常比增加 stream 更有效。
小结:CUDA Stream 本质上不是“并行开关”,而是组织异步操作的机制。
- 同一 stream 内,操作按顺序执行
- 不同 stream 之间,没有默认顺序约束
- 是否真正重叠,要看 API、依赖关系、内存类型和硬件资源
CUDA Stream 与 Event
如果说 stream 用来组织工作,那么 event 更像是用来描述“工作做到哪里了”。在 CUDA 中,event 常用于两件事:
- 做同步
- 做计时
单靠 stream,只能表达“同一条队列里的先后顺序”。如果要在不同 stream 之间建立依赖关系,通常就要配合 event。
一个典型用法是:
- 在 stream A 中记录一个 event
- 让 stream B 等待这个 event
- 这样就建立了 “A 的某一步完成之后,B 再继续” 的关系
这比直接调用全局同步更细,也更适合做流水线。
例如:
cudaStream_t streamA, streamB;
cudaEvent_t event;
cudaStreamCreate(&streamA);
cudaStreamCreate(&streamB);
cudaEventCreate(&event);
kernelA<<<gridA, blockA, 0, streamA>>>(...);
cudaEventRecord(event, streamA);
cudaStreamWaitEvent(streamB, event, 0);
kernelB<<<gridB, blockB, 0, streamB>>>(...);
上述代码表达的是:kernelA 在 streamA 中运行,当 kernelA 前面的工作执行到 event 时,记录这个事件,streamB 等待该事件完成后,再启动 kernelB。这样做的好处是,依赖关系只约束必要的部分,不会把整个设备都停下来。
Event 和同步的关系:
cudaDeviceSynchronize():等待整个设备上的工作完成cudaStreamSynchronize(stream):等待某个 stream 完成cudaEventSynchronize(event):等待某个 event 完成cudaStreamWaitEvent(stream, event):让一个 stream 等另一个 stream 的事件