☰ GPU 编程基础

2. 内存层级:合并访问、shared memory 与 bank

这是 GPU 与 CPU 差别最大的一章。CPU 上“写个循环,剩下的交给 cache”,在 GPU 上通常只能拿到峰值带宽的 10%–20%。原因有两个:访问模式要按 warp 对齐,以及shared memory 需要你自己搬。

2.1 五层存储

层级典型容量延迟(约)带宽 / 作用域谁管理
寄存器每线程最多 255 个 32-bit1 cycle极高编译器
shared memory每 SM 48–227 KB20–30 cycles每 SM 数 TB/s程序员显式
L1 / 纹理缓存与 shared 共用物理存储30 cycles每 SM硬件
L240–72 MB约 200 cycles全卡共享硬件
显存(HBM/GDDR)16–80 GB400–800 cycles(约 500 ns)1–3.35 TB/scudaMalloc
主机内存系统内存数 µs(PCIe)32–64 GB/s操作系统

关键区别不在“快慢”,而在谁负责搬运。CPU 的 cache 对程序透明:你访问一次,硬件自动把整条 cache line 拉进来,下次命中就快。GPU 的 shared memory 是每个 block 独占的 scratchpad:容量固定、生命周期等于 block、必须用 __syncthreads() 协调读写。硬件不会自动把你要用的数据放进去,你要自己写加载代码。

L1 和 shared 在物理上是同一块存储,按 kernel 的需求划分(carveout)。所以“shared 用得越多,L1 越小”,两者不能同时吃满。

2.2 在途字节:为什么 100% 占用率也可能跑不满带宽

回到 0.3 的 Little 定律。要跑满带宽,任意时刻都要有

\[ N_{\text{inflight}} = BW \times t_{\text{lat}} \]

字节的请求在路上。H100 上约 2 MB;除以 132 个 SM,每个 SM 要维持约 15 KB 的在途数据。

这就是“占用率明明 100%,带宽只有 40%”的最常见原因:每条访存指令携带的字节数太少,或者编译器把独立请求串行化了。先看字节/指令,再看占用率。

2.3 合并访问:一个 warp 的 32 个地址怎么变成事务

显存的读写以 32 B 的 sector 为最小单位,L2 line 是 128 B。一个 warp 的 32 个 lane 同时发出地址,硬件把它们按 sector 归并:

coalesce.cu

// 好:一个 warp 覆盖 128 B 连续地址
out[i] = in[i];

// 坏:一个 warp 跨 4 KiB
out[i] = in[i * 32];

// 好:每个 lane 一次搬 16 B(要求 16 B 对齐,且 n 是 4 的倍数)
float4 v = *reinterpret_cast<const float4*>(in + 4 * i);
float4 r;
r.x = v.x * 2.f; r.y = v.y * 2.f; r.z = v.z * 2.f; r.w = v.w * 2.f;
*reinterpret_cast<float4*>(out + 4 * i) = r;

如果数据本来就是“每个元素多个字段”,优先改成结构体数组(SoA):

// AoS:读 x 时每个 lane 拉 16 B,只用 4 B
struct Particle { float x, y, z, w; };
Particle* p;

// SoA:三个独立数组,读 x 完全合并
float *px, *py, *pz, *pw;
判断方法

问自己:同一个 warp 的 32 个 lane,在一次访存里覆盖多少字节?理想值是 128 B(float)或 512 B(float4)。如果每个 lane 各走各的 128 B,有效带宽就掉到 1/8。

2.4 间接寻址:当地址是查表来的

2.3 的三种形态有一个共同前提:地址由下标算出来,i 定了,地址就定了。分页 KV 打破了这个前提 —— 逻辑上的第 3 页在物理上可能是第 42 页,地址要先读一张表。

逻辑页 0  →  物理页 17   ┐
逻辑页 1  →  物理页 3    │  连续的逻辑页
逻辑页 2  →  物理页 42   │  物理上不连续
逻辑页 3  →  物理页 8    ┘
           每页 128 token:页内仍然连续

图里最重要的一点是:页内仍然是连续的。所以 2.3 的账没有被推翻,只是换了一个变量。

账没有变。最小单位还是 32 B sector,浪费仍然是「每 lane 有用字节 ÷ 32 B」。页表改变的不是公式,而是「32 个 lane 的地址落在几个地方」:

看最后一条:每个 lane 一次要的字节够大时,sector 取整的损失只剩 3%。「分页 KV 很浪费带宽」这句话在字节层面基本不成立 —— 真正的成本在下面三条。

1. 依赖加载。block_table[i] 要先读一次,数据地址依赖它的结果,编译器不能提前发这条请求。2.2 的在途字节靠的正是「独立请求能排队」,依赖链把这个 ILP 砍掉,表现就是「占用率够、带宽上不去」。

2. 页边界。一个 warp 读 512 B,起始位置在页内随机,跨页概率约 512 ÷ 页字节。以 KV 为例(每 (层 · token) 528 B):16 token 一页(8448 B)→ 6.1%;128 token 一页(67584 B)→ 0.76%。差 8 倍,但一次跨页只多一组事务 —— 这是三条里最小的一项,别把它当主因。

3. 并行度。32 个 lane 各落在不同页时,硬件没法把它们合并成一条 128 B line:每个 lane 一个 sector,同时在飞的请求数涨上去,而能用来藏延迟的 warp 只有 64 个(1.5)。页表本身不贵,贵的是它把「一次请求」变成「32 次」。

三个缓解。

极端形态:稀疏索引。decode 时常常不是读整个窗口,而是按 top-k 索引读 KV(某生产框架的 decode 直接拿 indices_in_kvcache 做注意力)。这时 32 个 lane 的 32 个 token 可能来自 32 个不同页,而每个 lane 只取其中 4 B 的一个分量 —— 回到 8 倍。同一个公式,粒度不同,答案就不同:先看每 lane 要多少字节,再看地址怎么来。

判断方法

拿到一个 kernel,按顺序问三句:① 地址能直接算吗?能 → 用 2.3 的账;不能 → 先看索引表多大、在不在 L2。② 一个 warp 的 32 个地址落在几个页里?大于 1 就有合并损失,但一次跨页只值一组事务。③ 每 lane 一次要多少字节?小于 16 B 基本没救;到几百 B 的量级,字节就不是问题,成本回到延迟和并行度。

练习。取 block_size 为 16 和 128 两种页,各画出一个 warp 读 512 B 的三种落点(页内、刚好跨页、跨两页),算出事务数与有效字节比;再回答:为什么 128 页更划算,但生产里不会无限调大?

数字出处。528 B/(层 · token) 的 SWA KV、128 token 的落盘与复用粒度、per-16 的 FP4 主 KV、indices_in_kvcache 的稀疏 decode,均取自 DeepSeek 开源的 DeepInfer(main@774ce24,Apache-2.0)。

doc/inference_deployment_qa.md · attention_backend.py(decode_mla_mqa_sparse) · csrc/engine/scheduler.hpp:116(max_num_batched_tokens ≥ block_size)

2.5 shared memory 与 bank conflict

shared memory 被划分成 32 个 bank,每个 bank 宽 4 B。一次访问中,如果 warp 的 32 个地址落在 32 个不同 bank 上,就能一次完成;如果多个 lane 落在同一个 bank 的不同地址,就串行化(n-way conflict);如果落在同一地址,则是广播,不冲突。

bank 编号就是字地址对 32 取模:

\[ \text{bank}(i) = i \bmod 32 . \]

于是 float tile[32][32] 有一个经典陷阱:按列访问时,元素 tile[0][c]、tile[1][c]…的地址相差 32 个字,全部落在同一个 bank → 32 路冲突,慢 32 倍。把第二维加 1:

__shared__ float tile[32][33];   // 每行多一个填充字
// bank(tile[r][c]) = (33r + c) mod 32 = (r + c) mod 32
// 连续变化的 r 或 c 都会落到不同的 bank

填充一个 float 只多花 128 B,却能消掉整个冲突。这是 GPU 上最划算的优化之一。

2.6 实战:矩阵转置

转置是“读合并、写就必然不合并”的典型。朴素写法让一个方向完美、另一个方向完全散开:

transpose.cu

constexpr int T = 32;

// 朴素:读合并(x 连续),写跨 rows 个元素 → 不合并
__global__ void transpose_naive(const float* in, float* out, int rows, int cols) {
    int x = blockIdx.x * T + threadIdx.x;   // 输入列
    int y = blockIdx.y * T + threadIdx.y;   // 输入行
    if (x < cols && y < rows)
        out[x * rows + y] = in[y * cols + x];
}

// 分块:全局读写都合并,转置在 shared 里完成
__global__ void transpose_tiled(const float* in, float* out, int rows, int cols) {
    __shared__ float tile[T][T + 1];        // +1 消除 bank conflict

    int x = blockIdx.x * T + threadIdx.x;
    int y = blockIdx.y * T + threadIdx.y;
    if (x < cols && y < rows)
        tile[threadIdx.y][threadIdx.x] = in[y * cols + x];
    __syncthreads();

    int row = blockIdx.x * T + threadIdx.y; // 输出行 = 输入列
    int col = blockIdx.y * T + threadIdx.x; // 输出列 = 输入行
    if (row < cols && col < rows)
        out[row * rows + col] = tile[threadIdx.x][threadIdx.y];
}

账很容易算:转置的流量是读 4 B + 写 4 B = 8 B/元素。1 GB 数据就是 8 GB 流量,在 3.35 TB/s 下约 2.4 ms。朴素版的写端每次浪费 8 倍 sector,读端正常,所以总时间大约是理想值的 4–5 倍;分块版把浪费消掉,代价只是 4 KB 的 shared 和一次 barrier。

顺便注意:transpose_tiled 的读端仍然是一个 warp 读连续 32 个 float(因为 threadIdx.x 最快变化),写端也是。转置的“不连续”被关在了 shared memory 里,而 shared 的随机访问代价只有 bank conflict,没有 sector 浪费。

2.7 只读数据与向量化

只读的输入加上 const __restrict__,编译器才会放心走只读缓存(LDG),不会因为“可能和输出别名”而插入额外同步:

__global__ void axpy(float* __restrict__ y,
                     const float* __restrict__ x, float a, int n) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) y[i] = a * x[i] + y[i];
}

L2 是全卡共享的,跨 block 的复用发生在 L2。矩阵乘里 A、B 被不同 block 反复读,靠的就是 L2 命中;再往上才是 shared 的 block 内复用(第 5 节)。

2.8 和 CPU 的对照

2.9 容易写错的地方

常见错误
  • 写了 shared 却忘了 __syncthreads()。读到的可能是别的 lane 还没写入的旧值,而且不一定每次都错。
  • 以为 shared 会跨 block 保留。它的生命周期等于 block;下一个 block 拿到的可能是任意残留数据。
  • 越界写不会报错。GPU 上没有数组边界检查,可能静默改坏别的数据,也可能让整个 kernel 崩掉;用 compute-sanitizer 查。
  • float4 没对齐。要求地址是 16 B 的倍数,否则行为未定义或性能骤降。
  • 把「分页」当成字节浪费。页内仍然连续,sector 账不变;分页真正贵的是依赖加载和并行度,调页大小只在跨页概率与尾页浪费之间取舍。
  • 只优化读,不看写。转置、直方图、稀疏 scatter 的瓶颈常常在写端。
  • shared 开太大。每 block 超过 48 KB 需要 cudaFuncSetAttribute,否则 launch 直接失败;同时会把占用率压下去。