☰ GPU 编程基础

7. 性能测量、数值与常见陷阱

GPU 上的性能直觉比 CPU 更容易错:同一段代码,改一个 block 大小可能差 3 倍,换个访问顺序可能差 8 倍。这一节先把“怎么测”定下来,再给三个可以手算的极限,最后是移植检查清单。

7.1 怎么测才算数

三步,缺一不可:warmup、多次采样、每次之间加同步。

bench.cu

#include <algorithm>
#include <vector>

// 1) warmup:让时钟爬升、cache 预热、驱动完成首次加载
for (int i = 0; i < 10; ++i) kernel<<<blocks, 256>>>(d, n);
CK(cudaDeviceSynchronize());

// 2) 逐次计时,拿分布而不是一个数
cudaEvent_t a, b;
CK(cudaEventCreate(&a)); CK(cudaEventCreate(&b));
std::vector<float> ms(iters);

for (int i = 0; i < iters; ++i) {
    CK(cudaEventRecord(a));
    kernel<<<blocks, 256>>>(d, n);
    CK(cudaEventRecord(b));
    CK(cudaEventSynchronize(b));          // 只等这一次
    CK(cudaEventElapsedTime(&ms[i], a, b));
}

std::sort(ms.begin(), ms.end());
printf("median %.4f ms, p90 %.4f ms\n", ms[iters / 2], ms[iters * 9 / 10]);

为什么要这样:

还要留意降频:nvidia-smi -q -d CLOCK 可以看到当前频率是否掉到 base clock。功耗墙和温度墙在长时间跑大 kernel 时很常见。

7.2 三个极限:先算下界,再看差距

任何 kernel 的时间都不可能低于这三者中的最大项:

  1. 带宽下界:必须搬运的字节数除以 \(BW\)。
  2. 算力下界:必须做的浮点运算数除以 \(P\)。
  3. 延迟下界:启动开销、同步点、依赖链长度之和。

先手算前两个,取较大者,再和实测比。如果实测是下界的 1.1–1.5 倍,已经不错;如果是 5 倍,先查访问模式和占用率。下面这个例子把三个都算一遍:

例子带宽下界算力下界实际瓶颈
16M float 向量加(\(N=2^{24}\))192 MiB / 3.35 TB/s ≈ 60 µs33M FLOP / 67 TFLOPS ≈ 0.5 µs带宽
1024³ GEMM12 MiB / 3.35 TB/s ≈ 3.6 µs2.1 GFLOP / 67 TFLOPS ≈ 32 µs算力(未算复用前)
100 个 2 µs 的小 kernel——launch:100 × 5 µs = 0.5 ms

最后一行解释了为什么“把 100 次 launch 合成 1 次、在 kernel 里循环”经常能立刻提速几倍。启动开销是固定成本,和 kernel 大小无关。

有效带宽的定义是

\[ BW_{\text{eff}}=\frac{\text{实际搬运的字节}}{\text{时间}} . \]

例如 1 GiB 的拷贝(读 + 写共 2 GiB)耗时 1.2 ms,则 \(BW_{\text{eff}}\approx1.7\) TB/s,约为 H100 峰值的 50%。低于 50% 时,先看合并访问(2.3)和每指令字节数(2.2)。

7.3 profiler 看哪几个数

不要靠猜。Nsight Compute(ncu)先看“Speed of Light”页的两个百分比:

Nsight Systems(nsys)看时间线:传输和计算有没有重叠、流之间有没有空隙、有没有意料之外的同步。它回答“整体时间花在哪”,Compute 回答“这个 kernel 内部为什么慢”。

正确性用 compute-sanitizer:--tool memcheck 查越界,--tool racecheck 查 shared 竞争,--tool initcheck 查未初始化读。它的开销很大,只在调试时开。

7.4 数值:精度、FTZ 与确定性

格式符号/指数/尾数有效位数相对精度(约)
FP321 / 8 / 23246e-8
TF321 / 8 / 10115e-4
FP161 / 5 / 10115e-4
BF161 / 8 / 784e-3

和 CPU 相同的是:都遵循 IEEE 754 的舍入规则,误差用 ULP 或相对误差衡量,Kahan 补偿求和同样有效。不同的是:

7.5 移植检查清单

  1. 数据量够大吗?低于几十万元素、或只跑一次,传输和启动就吃掉收益(0.5)。
  2. 数据能常驻显存吗?能,才谈得上加速;每次请求都重传,先算 PCIe 的账。
  3. 分支整齐吗?同一 warp 内路径是否一致;能不能改写成无分支(查表、掩码、排序)。
  4. 访问合并吗?看每个 warp 覆盖的字节数、Sectors/Request、AoS 能否改 SoA。
  5. 复用进 shared 了吗?tile 是否够大(≥ 64×64),有没有 bank conflict。
  6. 占用率与 ILP 够吗?寄存器有没有爆、每线程是否只发一个 4 B 请求(0.3)。
  7. 启动与传输占比多少?小 kernel 合并,流式传输用 pinned + 多流(6.2、6.3)。
  8. 精度在容差内吗?换了 Tensor Core 或 fast math 之后重新验收。
  9. 需要复现吗?去掉原子汇总,固定归约顺序。
  10. 用 profiler 验证过瓶颈吗?没有数据支撑的优化都是猜。

7.6 容易写错的地方

常见错误
  • 循环里带同步计时。测到的是“launch + 同步”的和,不是 kernel 本身。
  • 只跑一次就下结论。时钟、cache、调度都会让第一次偏慢。
  • 拿 debug 版 GPU 代码对比 -O3 的 CPU 代码。这不是对比,是自欺。
  • CPU 只跑单线程。要公平就都用满,或者明确说明只比单线程延迟。
  • 忽略降频。笔记本和被动散热的卡跑久了大面积掉频,数字不可比。
  • “能跑通”当成“正确”。越界写经常不崩,只是悄悄改坏别的数据;用 compute-sanitizer。
  • 在 GPU 上照搬 CPU 的优化顺序。CPU 上先减少指令数,GPU 上通常先减少字节数、先修访问模式。