CUDA系统拆解-09-Streams、异步拷贝与Overlap:如何把拷贝和计算叠起来

本文是「CUDA系统拆解」系列第 09 篇。
系列导读:CUDA系统拆解-00-导读:从编程模型到 AI 推理系统的学习路线
上一篇:CUDA系统拆解-08-Occupancy、寄存器压力与Launch参数:调优不是把占用率拉满
下一篇:CUDA系统拆解-10-Profiling、调试与瓶颈定位:先找到根因再谈优化

1. 这篇解决什么问题

这一篇要讲清 5 件事:

  • 为什么 CUDA 程序不能只盯着“一个 kernel 算得快不快”,还要关心整条执行流水。
  • stream 到底是什么,它和“主机端异步返回”是什么关系。
  • 为什么“主机端看起来异步”不等于“设备端真的并发执行”。
  • pinned memory 为什么会影响异步拷贝和计算重叠。
  • overlap 到底在什么条件下才成立,以及它为什么在小 batch 和 decode 场景更重要。

如果这篇只记住一句话,那就是:

异步执行的目标不是把 API 写成异步,而是把拷贝、launch 和计算组织成真正能重叠的设备端流水。

2. 先记住的核心结论

  • stream 可以理解成设备端的一条有序执行队列;同一个 stream 内通常按提交顺序执行,不同 stream 才有机会并发。
  • 主机端函数“提前返回”只说明 CPU 没被阻塞,不说明 GPU 上的拷贝和 kernel 一定并发。
  • 默认 stream 往往带来更强的顺序语义,如果所有操作都丢进默认 stream,就很难形成真正的 overlap。
  • pinned memory 是页锁定内存。它更适合做高效 DMA 拷贝,也是很多异步 H2D / D2H 和重叠执行成立的前提。
  • overlap 通常需要同时满足:不同 stream、异步 API、合适的硬件能力、合适的内存类型,以及没有额外同步把流水打断。
  • 在大算子很重的场景里,launch 和流水组织问题可能被算力掩盖;在小 batch、短序列、decode 这类场景里,它们会更容易暴露出来。

3. 正文讲解

3.1 为什么需要异步执行

最朴素的 CUDA 执行方式是:

  1. 把数据从主机拷到设备
  2. launch 一个 kernel
  3. 等它跑完
  4. 再把结果拷回主机

这种方式逻辑简单,但资源利用率通常不高。原因是:

  • CPU 在很多时刻只是等待
  • PCIe / NVLink 拷贝和 GPU 计算没有尽量重叠
  • 多个小任务之间容易出现空洞

所以异步执行的目标不是“代码看起来高级一点”,而是尽量把:

  • 主机准备下一批工作
  • H2D / D2H 拷贝
  • kernel 执行

组织成一条连续流水,减少设备空转和主机等待。

3.2 stream 到底是什么

理解 stream 时,最稳的口径是:

stream 是设备端的一条有序工作队列。

你往一个 stream 里提交的操作,例如:

  • H2D 拷贝
  • kernel launch
  • D2H 拷贝

通常会按顺序排队。于是:

  • 同一个 stream 更强调顺序性
  • 不同 stream 才有机会被设备调度成并发

这里要特别区分两件事:

  • 主机端 API 是否阻塞
  • 设备端任务是否并发

例如,一个异步 API 可能很快就返回给 CPU,但 GPU 端仍然只是把任务排进某条 stream,最后是否并发,还要看设备端队列关系和资源条件。

3.3 默认 stream 和普通 stream 的区别

很多初学者以为“用了异步 API 就天然并发”,问题常常出在默认 stream 上。

默认 stream 的直觉可以理解成:它常常带着更强的顺序语义。
如果所有拷贝和 kernel 都放在同一条默认 stream 里,那么即使主机端调用是异步的,设备端通常也还是顺序推进。

所以判断有没有 overlap,不能只看代码里有没有:

  • cudaMemcpyAsync
  • 异步 launch

还要看:

  • 是否真的分到了不同 stream
  • 中间是否插入了同步
  • 默认 stream 语义是否把事情重新串行化了

3.4 为什么主机异步不等于设备端并发

这是这一篇最容易踩坑的地方。

“主机异步”只说明:

  • CPU 提交任务后不用原地等
  • CPU 可以继续做别的事

但设备端是否并发,还要继续问:

  • 这些任务是否在不同 stream
  • GPU 是否有 copy engine 和计算资源可同时工作
  • 数据拷贝是否真的是异步可执行的
  • 是否有 cudaDeviceSynchronize、事件等待、默认 stream 依赖把队列卡住

所以更准确的说法是:

异步是主机视角的接口行为,并发是设备视角的执行结果。两者相关,但不等价。

3.5 pinned memory 为什么重要

pinned memory 指的是页锁定内存,也叫 page-locked memory。它和普通 pageable host memory 的关键区别在于:

  • 普通主机内存可能被操作系统换页
  • pinned memory 不会被随意换出
  • 设备做 DMA 传输时更容易直接、高效地访问它

这对 CUDA 有两个直接意义:

  1. 主机到设备、设备到主机的拷贝效率通常更好
  2. 很多真正想做异步拷贝和 overlap 的场景,会更依赖 pinned memory

可以把它理解成:
如果 host memory 本身不适合直接做高效异步搬运,那么所谓“异步拷贝”在设备端很可能达不到你预期的重叠效果。

所以 pinned memory 的意义不只是“快一点”,而是它经常是异步数据通路能否真正成立的一块地基。

3.6 overlap 在什么条件下才成立

想让拷贝和计算重叠,通常要同时满足几类条件:

  • 使用异步 API,而不是阻塞式拷贝
  • 工作被放到合适的多个 stream,而不是全都塞进同一条队列
  • host memory 类型合适,常见就是使用 pinned memory
  • GPU 具有相应的并发执行能力,例如 copy engine 与计算单元能协同
  • 没有额外同步把流水切断

也就是说,真正的 overlap 是一条链路条件同时满足后的结果。
只满足其中一个条件,通常不够。

这也是为什么很多人代码里已经写了 cudaMemcpyAsync,实际 profile 一看仍然几乎全串行。问题不是“异步 API 没用”,而是整条流水没有真的被搭起来。

3.7 overlap 的工程意义

overlap 的价值,本质上是在隐藏不能消掉的时间:

  • H2D / D2H 拷贝时间
  • kernel launch 间隙
  • 多阶段流水之间的等待

如果一段计算本身非常重,例如一个大 GEMM 跑很久,那么这些开销有时会被淹没。
但如果任务变碎,问题就会变明显:

  • batch 很小
  • 请求很多但每个都短
  • decode 每步计算量小
  • 一轮里要频繁 launch 多个小 kernel

这时瓶颈可能不再只是某个 kernel 的 FLOPs,而是:

  • launch overhead
  • stream 组织
  • 拷贝和计算有没有重叠
  • CPU 和 GPU 是否形成稳定流水

所以在 AI 推理里,异步执行和 overlap 往往不是“高级优化项”,而是系统吞吐和尾延迟能不能做好的基础条件。

4. 和 AI 推理的关系

这一篇和推理系统的关系非常直接。

很多在线推理场景并不是“大 batch 大矩阵一路轰过去”,而是:

  • 动态请求持续到达
  • batch 经常变化
  • 小 kernel 很多
  • decode 单步计算偏小
  • 主机端调度、数据准备和设备端执行要持续配合

所以推理系统里常见的问题,最后都能翻译回这一篇:

  • 为什么小 batch 吞吐上不去
  • 为什么 GPU 利用率不稳定
  • 为什么 decode 阶段 launch 开销特别显眼
  • 为什么有些系统要非常重视 stream、事件、异步 H2D、pinned buffer 和流水组织

换句话说,大模型推理不只是“kernel 快”,还要“整机流水顺”。

5. 常见误区

  • 用了异步 API,不代表 GPU 端就一定并发。
  • stream 不是 CPU 线程,也不是“随便分个标签”;它的本质是设备端队列关系。
  • 默认 stream 往往会带来更强顺序语义,容易把你以为的并发重新串起来。
  • pinned memory 不只是更快的 host memory,它经常决定异步拷贝和 overlap 能不能真正成立。
  • overlap 不是单点优化,而是拷贝、队列、硬件能力和同步策略共同作用的结果。

6. 复习自测

  • 为什么说 stream 更适合理解成设备端的有序执行队列?
  • 为什么“主机端异步返回”不等于“设备端并发执行”?
  • 默认 stream 为什么容易让程序重新串行化?
  • pinned memory 对异步拷贝和 overlap 的价值到底是什么?
  • overlap 通常需要满足哪些条件?
  • 为什么小 batch 和 decode 场景更容易暴露 launch overhead 和流水组织问题?

系列导航