Lesson 03 · CUDA 性能调优 · 访存瓶颈的头号武器
你已经能判断一个 kernel 是访存瓶颈了。现在的问题是:同样搬一样多的数据,为什么有的 kernel 带宽利用 90%,有的只有 15%? 最常见的答案是访存合并(coalescing)。这一节,你将第一次真正动手把一个 kernel 改快。
GPU 不是一个线程一个线程地读显存。它以 warp(32 个线程) 为单位执行, 而显存访问以 固定大小的事务(transaction) 为单位——通常是 128 字节对齐的一段。[1]
当一个 warp 的 32 个线程同时读内存时,硬件会看它们的地址:
设每个线程读一个 4 字节 float,一个 warp 32 线程,一次能用一个 128B 事务全部覆盖(32×4=128)。
同样是读 32 个数,合并访问 1 个事务搞定,非合并要 32 个——带宽有效利用率可能差 10 倍以上。 这就是为什么访存瓶颈的 kernel,第一件事就是检查合并。[1]
合并的一切都绕着"事务"这个词。说透它:GPU 读显存不是一个字节一个字节读,而是一整块一整块读。那一块就是一次事务。
打个比方:去仓库取货,规定每次必须开一整个货箱,哪怕你只要箱里一颗螺丝。 货箱 = 事务。A100 上货箱固定 32 字节(一个 sector),硬件常把 4 个凑成 128 字节一起处理。
为什么这样设计?显存(HBM)的物理特性:按大块读极快,按零散小块读极慢——和机械硬盘顺序读快、随机读慢同理。 所以硬件干脆规定:不管你要几个字节,都按固定大小的块搬。
关键就在一句:一次事务搬回 128 字节,但其中有多少是你真正要用的?
| 情况 | 开几个货箱 | 有效利用 |
|---|---|---|
| 合并:32 线程的数据挤在一个 128B 块里 | 1 次事务 | 128/128 = 100% ✅ |
| 非合并:32 线程数据分散(stride) | 最多 32 次事务 | 每箱只用 4B,4/128 ≈ 3% ❌ |
同样取 128 字节有用数据,合并开 1 个箱,非合并开 32 个箱。带宽是固定的"开箱速度", 非合并白白浪费 31/32 的箱子——这就是"差 10 倍以上"的来源。
request(请求)= 一个 warp 的一条访存指令(它要的那 128B);
sector = 32B 的货箱。最常见的非合并来自按行处理矩阵。假设一个 M×N 矩阵按行主序存放,让每个线程处理一整列求和:
// thread t 处理第 t 列;一个 warp 的 32 个线程
// 同一时刻访问 data[0*N+t], data[0*N+t+1]... 看似连续?
// 但下一步它们一起跳到 row=1: 地址各 +N → 跨步!
int col = blockIdx.x * blockDim.x + threadIdx.x;
float sum = 0;
for (int row = 0; row < N; row++)
sum += data[row * N + col]; // 每轮 warp 内连续 ✓,但…见下
其实这一版反而是合并的!同一轮里,warp 的线程 t 读 row*N + t,
地址连续。真正的坑是反过来写:
int row = blockIdx.x * blockDim.x + threadIdx.x;
float sum = 0;
for (int c = 0; c < N; c++)
sum += data[row * N + c]; // warp 内线程地址相隔 N → 非合并!
这里同一轮里线程 t 读 t*N + c,相邻线程地址差 N(一整行),完全分散。
修复方法是让相邻线程访问相邻地址:换数据布局(转置)、或换线程到数据的映射方式。
threadIdx.x 应当乘在最低维(变化最快的维)上。
非合并访问在 Nsight Compute 里有明确信号。除了 SOL 显示访存瓶颈外, Memory Workload Analysis 段会出现:
[Warning] Uncoalesced global accesses. The memory access
pattern has low efficiency. X sectors per request 。
看 sectors per request(上一节已解释:理想≈4,越大越不合并)。 如果是 16、32,就说明严重不合并——每个请求拖回一堆没用的 sector。[1]
"合并的 warp 开 1 个事务"有个隐藏前提:起始地址要对齐到事务边界。 128B 事务只能从 0、128、256… 这些 128 的倍数开始——把显存想成划好格子的货架, 事务只能整格取,不能跨格错位取。
于是即使数据完全连续、索引完美,只要起点偏了,事务数也会翻倍:
| warp 要的 128B 区间 | 跨几格 | 事务数 |
|---|---|---|
[0, 128) 对齐 | 1 格 | 1 ✅ |
[32, 160) 偏了 32B | 横跨 2 格 | 2 ❌(各有浪费) |
kernel(data + 1, ...),或结构体字段没对齐。cudaMalloc 返回的指针保证至少 256B 对齐,从数组开头规矩访问通常没事。坑出在手动加偏移、自管内存时。float4(16B)天然带对齐;必要时 __align__(128)。[1]前面把模型简化成"warp 直接读显存"。真相是中间隔着两层片上缓存:
warp 访存请求
↓
L1(每 SM 一个,A100 上与 shared memory 共用 SRAM)
↓ miss
L2(全 GPU 共享,A100 有 40MB,很大)
↓ miss
HBM 显存(真正的"远路")
关键:事务发生在缓存这一层,不一定直达 HBM。两个后果:
非合并、地址分散,但数据若之前被读过、还在 L2 里,这次就不必走 HBM。
这解释了一个困惑:同样非合并的代码,为何有时慢得要死、有时还能忍? 取决于数据是否恰好被缓存住。A100 的 40MB L2 常能兜住一部分。
回想 第 2 节那两行,现在能读出差异含义:
| Memory % | DRAM % | 含义 |
|---|---|---|
| 高 | 高 | 真在猛读显存,缓存没兜住 → 典型访存瓶颈 |
| 高 | 低 | 流量多被 L2 接住 → 瓶颈在缓存/数据复用,不在 HBM |
这是判断"该优化访存模式,还是该优化数据复用(下一课 shared memory)"的关键线索。
别依赖缓存救场。 数据在不在缓存里是不可控的运气,取决于运行时历史。 正确做法仍是从一开始就写合并访问——缓存是安全网,不是设计依据。 数据集变大、缓存装不下时,寄希望于"L2 会兜住"一定会崩。
每题盯住 warp 内相邻线程(threadIdx.x 差 1)的地址差。点选项看反馈。
📘 CUDA C++ Best Practices Guide — Coalesced Access to Global Memory ——这一小节用图解讲透了合并规则,是本节最高信任的来源,务必读一遍。