
把计算放进多条 CUDA Stream 之后,时间线仍像串行一样,是 GPU 程序中很常见的误判。Stream 不是额外的计算单元,而是一组按序提交的工作队列;它允许彼此没有依赖的任务被调度器重叠,但不会自动拆除数据依赖,也不会凭空增加流处理器、寄存器、共享内存或复制引擎。判断是否“没有提速”,第一步不是继续增加 Stream 数量,而是把预期重叠的两段工作明确写出来:是主机到设备传输与内核执行重叠,两个内核同时执行,还是设备到主机传输覆盖下一批计算。三种目标需要的条件不同,测量方式也不同。
先把时间线画对
主机 API 返回很快并不代表设备工作已经完成。若用主机时钟包住异步调用,却在循环尾部执行全设备同步,结果只能说明整个批次耗时,无法证明哪两段发生重叠。更可靠的办法是在相同 Stream 内记录 CUDA Event,分别测量每个阶段,同时用 Nsight Systems 观察设备时间线。Event 也有边界:跨 Stream 比较时必须明确先后依赖,不能把两个未建立因果关系的时间戳当成统一墙钟。测试前还要预热上下文、固定输入规模并排除首次内存分配与即时编译,否则冷启动开销会盖住真正的并发效果。
异步拷贝为何仍然串行
传输与计算重叠通常要求页锁定主机内存、异步拷贝 API、非默认 Stream 以及设备具备相应复制能力。普通可分页内存可能触发运行时内部暂存,使调用行为和预期不同。即便具备复制引擎,同一方向的大块传输、PCIe 链路带宽、NUMA 位置和上游内存带宽也会形成共享瓶颈。工程上应分别记录 H2D、D2H 和内核持续时间,再观察总时长是否接近较长阶段,而不是简单相加。若数据很小,提交、事件和队列管理本身可能比可隐藏的传输时间更大,多流反而增加抖动。
内核并发受到哪些资源限制
两个内核没有数据依赖,也未必能驻留在同一组 SM 上。单个内核若已占满线程块槽位、寄存器、共享内存或其他执行资源,调度器就没有空间容纳第二个内核。占用率高也不能直接等同于性能好,它只是驻留能力的一个维度。应结合编译器资源报告、实际网格规模和每个线程块的资源需求判断。短小内核还可能在可视化时间线上看似相邻而非重叠,因为前一个任务在第二个任务完成调度前已经结束。这时合并内核、批处理或 CUDA Graph 可能比增加 Stream 更符合问题形态,但是否采用必须由实测决定。
同步点经常藏在无关代码里
显式的设备同步容易发现,隐式同步更难。默认流的语义、同步内存分配与释放、某些主机访问、库函数使用的内部 Stream,以及错误检查方式,都可能在时间线上建立全局屏障。调用 cuBLAS、cuDNN 或其他库时,需要确认句柄绑定的 Stream,并核对工作区和输入输出的生命周期。为了避免“为了并发而并发”,可把一次迭代缩减为最小复现:两份独立输入、两个明确的内核、两条非默认 Stream,不插入全局同步;确认基础重叠后,再逐项加回真实应用组件。这样能把硬件限制、运行时语义和业务依赖分开。
一套可复现的排查顺序
- 定义希望重叠的准确阶段,并记录串行基线、数据规模和批量大小。
- 用设备事件和系统级时间线同时测量,排除冷启动、内存分配和主机阻塞。
- 核对页锁定内存、异步 API、Stream 归属、设备复制能力与 NUMA 路径。
- 检查内核寄存器、共享内存、线程块和网格规模,判断是否存在并发驻留空间。
- 搜索显式与隐式同步,把第三方库、错误检查和资源释放逐项加回最小复现。
如果单流已经饱和关键资源,多流没有提速可能正是合理结果;如果目标是降低请求尾延迟,也不能只看总体吞吐。研发记录应保留 GPU 型号、驱动、CUDA 运行时、编译参数、输入形状和时间线文件,避免把一次偶然的重叠截图当成长期结论。CUDA Stream 的执行与同步语义可继续对照 NVIDIA CUDA C++ Programming Guide,具体行为仍应以当前软件版本和目标平台实测为准。
WeChat
Profile