CUDA Micro-benchmark 实战(下):峰值算力、内存带宽与 Hopper 异步流水线
在上一篇文章中,我们探讨了 CUDA 编译工具链、Warp 调度机制以及如何使用 Nsight Compute 进行性能瓶颈分析。
本篇不再罗列孤立的 CUDA 知识点,而是围绕一个问题展开:怎样设计一个可信的 GPU micro-benchmark,并让测量结果能够解释真实硬件行为?
全文依次讨论测量协议、计算吞吐、内存带宽、内存顺序、Inline PTX,最后用 Hopper 的 WGMMA 与 TMA 展示如何把计算和数据搬运组织成异步流水线。
1. 先建立可信的测量协议
Micro-benchmark 的代码通常很短,但短代码不代表结果可靠。GPU Boost、缓存命中、编译器消除、首次启动开销和计时范围都会改变结果。开始测量前,至少要回答下面几个问题。
1.1 测量对象和指标是什么?
同一条指令可以测两个不同指标:
- Latency:一条依赖链从输入到输出需要多少周期。
- Throughput:流水线稳定后,每周期最多完成多少条独立指令。
测 latency 时,要让下一次运算依赖上一次结果;测 throughput 时,则要使用多个互不依赖的 accumulator。把两者混在一起,最常见的结果就是“理论吞吐很高,但 benchmark 只能跑到一半”。
带宽测试也要先规定字节口径。例如 device-to-device copy 同时读取和写入 $N$ 字节,常用的有效带宽定义是:
$$ B_{\text{eff}}=\frac{N_{\text{read}}+N_{\text{write}}}{t} =\frac{2N}{t} $$报告结果时必须说明使用的是单向字节数 $N/t$,还是读写合计 $2N/t$。
1.2 控制实验环境
建议为每次实验记录:
- GPU 型号、驱动、CUDA Toolkit 和编译参数;
- 实际 SM clock、memory clock、功耗上限、温度和 ECC 状态;
- grid/block 配置、寄存器数、shared memory 和 occupancy;
- 输入规模、数据类型、对齐方式和迭代次数。
运行前先 warm-up,避免把 context 初始化、JIT 或冷缓存开销算进结果。正式测量应重复多次,至少报告中位数;如果目标是逼近硬件上限,也可以同时报告最小值,但不能只挑一次最好结果。
对 DRAM 带宽测试,工作集应明显大于 L2 Cache;否则测到的可能是缓存带宽。对 cache benchmark 则相反,应主动扫描工作集大小并观察拐点。
1.3 防止测到“被优化掉的代码”
被测结果必须写回全局内存,且最好在 Host 端验证。否则编译器可能发现结果无人使用,删除整个循环。
此外,要同时检查 PTX 和 SASS:
|
|
源代码里的 fmaf()、向量类型或 inline PTX,并不能保证最终机器码就是预期指令。Micro-benchmark 测的是实际执行的 SASS,不是 C++ 写法。
2. 计算吞吐:如何把 ALU 喂满?
2.1 理论峰值和实测值
某类运算的理论峰值可概括为:
$$ P_{\text{theory}}= N_{\text{unit}}\times \text{ops/cycle/unit}\times f_{\text{clock}} $$$N_{\text{unit}}$、每周期吞吐和可用 pipeline 都依赖具体架构与数据类型。不要只拿营销页的 Boost Clock 代入;benchmark 期间的实际频率才是合理参照。
对于 FMA,a * b + c 按惯例计作 2 FLOPs。若每个线程有 $A$ 个独立 accumulator,循环执行 $I$ 次,共启动 $T$ 个线程,则实测吞吐为:
这里的 $t$ 是只覆盖稳定计算区间的执行时间。
2.2 独立 accumulator 与依赖链
下面是 throughput benchmark 的核心形态:
|
|
四条 FMA 链彼此独立,Warp Scheduler 可以在某条链等待结果时发射另一条链。独立链太少会暴露 RAW latency;太多又会增加寄存器压力、降低 occupancy。正确做法是扫描 accumulator 数量,而不是假设“展开越多越快”。
若要测 latency,则应只保留一条依赖链,并用总周期除以迭代次数。吞吐和延迟必须用不同 kernel。
FP16x2 指令能一次处理两个 half 元素,但“元素吞吐翻倍”不等于 latency 减半;打包、转换和目标架构的执行管线都会影响结果。
3. 内存带宽:指令更少不等于事务更少
3.1 合并、对齐和连续性
Warp 的内存请求会根据访问地址被拆分为一个或多个 memory sectors/transactions,具体粒度和缓存路径具有架构依赖性。因此不要把“连续 128 Bytes”简单等价为“永远只有一个事务”。
三张图对应不同问题:
- 非合并:lane 请求无法落入少量连续 sector;
- 不对齐:访问范围跨越 sector/cache-line 边界;
- 不连续:stride 扩大地址跨度,搬回的数据中只有一部分真正被使用。
判断访问效率时,应结合 Nsight Compute 的 sector 数、请求数和 DRAM bytes,而不是只看 C++ 下标。
3.2 向量化访存的真实作用
使用 int2、int4 或 float4,主要收益是一个线程用更少的 load/store 指令搬运更多字节:
|
|
它可以减少指令发射压力,但不会自动修复不对齐或非合并访问。指针必须满足向量类型对齐,Warp 间访问仍要连续。
图中小工作集阶段曲线接近,是因为固定开销和缓存占主导;进入大工作集后,vector2/vector4 减少了访存指令数量,因此可能获得更高带宽。该结果来自 K20X,不能直接外推到 Hopper;现代架构应重新实测。
一个完整带宽 benchmark 还应做到:
- 数组大于 L2 Cache;
- warm-up 后计时;
- 使用不同输入避免编译器或缓存产生特殊路径;
- 分别报告 scalar/vectorized 的指令数和 DRAM bytes;
- 验证输出内容。
4. Memory Ordering:保证 benchmark 测的是正确程序
并发 benchmark 如果存在数据竞争,跑得再快也没有意义。需要区分四个概念:
- Compiler barrier:限制编译器重排;
- Memory fence:约束内存操作的观察顺序;
- Barrier:让一组线程在执行位置上会合;
- Visibility:其他线程能否通过正确的缓存/原子路径看到数据。
Fence 本身不等于线程同步,也不单独保证可见性。CUDA 官方 partial-sum 示例同时使用可见的结果存储、device-scope fence、原子计数器和线程同步。
|
|
如果没有 fence,其他线程可能先观察到 count 更新,再读取尚未按预期发布的 result。但仅加 fence 也不够:消费者仍需通过正确的原子/同步协议读取。
Release/Acquire 可以更精确地表达发布与消费关系。Scope 则限定哪些线程参与同步,常见范围包括 block、cluster、device 和 system。生产代码应优先考虑 cuda::atomic / cuda::atomic_ref 的 memory order 与 thread scope,而不是只靠传统 intrinsic 猜测语义。
Hopper 的 TMA/WGMMA 还引入了 async proxy。普通线程的 load/store 属于 generic proxy,TMA 和部分异步矩阵操作属于 async proxy;跨 proxy 交接数据时,需要对应的 proxy fence 或 barrier 协议。
5. Inline PTX:控制测量边界,而不是替代验证
5.1 读取计时器
读取 SM 本地 cycle counter:
|
|
注意 inline asm 字符串中的 PTX 特殊寄存器要写成 %%clock。同一 SM 内测短依赖链可以使用 %clock / clock64();跨 SM 比较时间点时,可使用 %globaltimer,但仍要校准读取开销。
对于完整 kernel,CUDA Event 通常更稳妥;线程内计时器适合隔离短指令序列。二者回答的问题不同。
5.2 volatile 与 "memory" clobber
编译器默认认为 asm() 除输出操作数外没有副作用。若指令不能被删除或移动,应使用:
|
|
若 asm 通过指针隐式读写用户内存,或需要阻止编译器在 asm 周围做内存优化,应声明:
|
|
"memory" 是编译器层面的 clobber,不会自动生成 GPU memory fence,也不能替代线程同步。
5.3 操作数约束
Inline PTX 的 = 表示 write-only output,+ 表示 read-write operand:
|
|
如果 PTX 需要读取操作数原值,就必须使用 + 或拆成独立 input/output;这与“阻止物理寄存器被其他指令复用”无关。
= 与读写操作数 +指针还涉及 Generic、Global、Shared 等 state space。内联 PTX 无法自动推断任意指针的目标空间,使用 ld.global、ld.shared 等指令前必须确认地址空间。
6. Hopper 案例一:WGMMA 异步矩阵流水线
传统 warp-level MMA 常先把 fragment 搬入寄存器,再由一个 Warp 协作计算。下面的图片适合作为 fragment layout 背景,但它不是 WGMMA 本身。
Hopper 的 WGMMA 由 4 个连续 Warp,也就是 128 个线程组成一个 Warp Group,共同执行异步矩阵乘加:
$$ D=A\times B+C $$典型控制顺序是:
|
|
wgmma.fence:排序此前对 accumulator registers 的访问与后续 WGMMA;若操作数由 generic proxy 写入 Shared Memory,还要按 PTX 要求使用对应的 async-proxy fence;mma_async:异步发射矩阵乘加;commit_group:把已发射操作提交为一组;wait_group N:等待,直到最多还有 N 组操作 in-flight。
.sync 要求参与 Warp 在该指令处会合;.aligned 要求 Warp Group 的所有线程一致执行同一条 WGMMA。若条件分支让 Warp Group 内线程执行路径不一致,行为未定义。
.sync 与 .aligned 的一致执行要求WGMMA 的 accumulator D 位于寄存器;B 通过 shared-memory descriptor 提供;A 根据指令变体来自寄存器或 shared memory。异步接口的价值在于允许多组 MMA 与下一阶段的数据准备重叠,而不只是“单条 Tensor Core 指令更大”。
测 WGMMA throughput 时应:
- 让矩阵尺寸足以填满多个 Warp Group;
- 使用多 stage buffer,避免只测到 shared-memory 等待;
- 用
commit_group/wait_group控制 in-flight 深度; - 计入真实 MMA FLOPs,并验证结果;
- 同时观察 Tensor Core utilization、stall reason 与 shared-memory throughput。
7. Hopper 案例二:TMA 数据搬运流水线
TMA(Tensor Memory Accelerator)把大块或多维张量在 Global Memory 与 Shared Memory 之间异步搬运。与 Ampere 的 LDGSTS 路径相比,TMA copy 可由一个 elected thread 发起,再由 TMA 单元完成地址生成和传输,让其余线程专注计算。
一个典型 Hopper pipeline 是:
|
|
计算 stage N 时,TMA 可以预取 stage N+1。性能来自 copy/compute overlap,而不是单次 copy latency 必然更低。
7.1 Descriptor 与对齐
多维 TMA 使用 Host 端创建的 tensor map descriptor,描述基地址、维度、stride、tile shape 和布局。Device 端只需给 descriptor 和坐标即可发起传输。
对于 cp.async.bulk 一类强制使用 TMA 的操作,Global/Shared 地址通常要求 16-byte 对齐,传输大小是 16 Bytes 的倍数,mbarrier 地址要求 8-byte 对齐。高级 CUDA API 在不满足条件时可能回退到同步路径;直接 PTX 则可能产生未定义行为,必须查对应指令版本的约束。
7.2 完成机制取决于传输方向
Global → Shared 的 TMA copy 通常由 transaction-aware mbarrier 跟踪:先初始化 barrier 和 expected bytes,消费者等待对应 phase 完成后再读取 Shared Memory。
Shared → Global 的 bulk copy 通常通过 bulk async-group 提交和等待。不能把 mbarrier 和 async-group 当成可随意互换的两种风格;它们对应不同数据方向和完成语义。
TMA micro-benchmark 至少要分别测:
- 单次 copy latency;
- 大工作集 steady-state bandwidth;
- TMA 与 WGMMA 重叠后的端到端 tile throughput;
- 不同 tile shape、stage 数与 cluster multicast 的影响。
只测 copy kernel 无法证明流水线更快;最终应比较“搬运 + 计算”的总时间。
8. 附录:与主线相关但不属于 benchmark 核心的知识
8.1 DMA 与 IOMMU
DMA 允许设备直接访问内存,IOMMU 则负责设备虚拟地址到物理地址的映射与隔离。它们对 GPU Direct、统一虚拟地址和设备间传输很重要,但不直接决定前面单 GPU kernel micro-benchmark 的 ALU/DRAM 峰值。
8.2 CUDA 模板显式实例化
如果模板定义放在 .cu,调用点位于另一个翻译单元,编译器看不到对应模板定义时,需要在 .cu 中显式实例化,并在头文件中声明接口。这是工程组织问题,不是 GPU 性能机制。
.cu 中定义并显式实例化 CUDA 模板
总结
可信的 CUDA micro-benchmark 不是“写一个很小的 kernel 然后计时”,而是一套受控实验:
- 明确测 latency 还是 throughput;
- 控制时钟、缓存、输入规模和 warm-up;
- 防止编译器消除,并检查最终 SASS;
- 验证输出和统计口径;
- 用 profiler 解释测量值为何接近或偏离理论上限。
在 Hopper 上,WGMMA 与 TMA 又把问题推进了一步:性能不再只取决于单条指令吞吐,而取决于能否把 Global Memory、Shared Memory、Tensor Core 和同步机制组织成稳定的多 stage 异步流水线。