对应 Maps to:CMU 15-779(Fall 2025,Zhihao Jia),Lecture 4 · Wed 9/10 · GPU Architecture and CUDA Programming (2)。 阅读材料:原课这一讲没有指定必读论文。推荐:M. Harris, Optimizing Parallel Reduction in CUDA(NVIDIA,2007);S. Boehm, How to Optimize a CUDA Matmul Kernel for cuBLAS-like Performance(博客,2022);NVIDIA CUDA C++ Best Practices Guide 的访存优化一章。 依据说明:原课第 3、4 讲共用一份 slides(
03-CUDA-programming.pdf)。本讲对应其后半部分(第 48–69 页):案例 1 矩阵乘法(朴素实现、线程级寄存器分块、线程块级共享内存分块、访存复用分析、合作加载);案例 2 并行归约(树形归约、没有全局同步与内核分解、交错寻址与发散的 warp、步长索引、合并访存、顺序寻址);以及小结(一致的 warp、合并访存、共享内存 bank 冲突、warp 级优化、Tensor Core)。slides 的分块矩阵乘法代码按 (A 以 形状存放)的约定书写,本讲沿用这一约定。全部模拟实验、外推估算和 §6 的转置例子是本讲义自己设计的,不代表课堂上讲了什么。 配套代码 Code:code/lecture04/(4 个脚本;共用第 3 讲的gpusim.py;只需 numpy;每个脚本 ≤ 15 秒) 预计阅读时间:3–4 小时(含运行代码)
0. 本讲导航 · How to read this lecture
0.1 学习目标
学完这一讲,你应该能够:
- 分析朴素矩阵乘法的访存量( 次全局读取),并解释为什么线程编号和数据布局的对应关系决定了访存能否合并。
- 推导线程级寄存器分块(全局访问 )和线程块级共享内存分块(全局访问 、共享内存访问 ),理解”两级分块各管一层存储”;会写合作加载。
- 用三层 roofline(HBM、共享内存、计算)估算分块矩阵乘法的速度,并说出分块参数受哪些资源约束(寄存器、共享内存、占用率)。
- 解释为什么 CUDA 没有块间全局同步,怎样用内核分解实现全局归约。
- 比较并行归约的几个版本:交错寻址(warp 发散)、步长索引(bank 冲突)、顺序寻址(两者都没有)、warp shuffle(减少同步)。
- 区分合并访存(全局内存:一个 warp 的地址要连续)和bank 冲突(共享内存:一个 warp 的地址要分散在不同 bank),会用 padding 消除转置里的 bank 冲突。
0.2 先修与本讲范围
| 默认你已经会 | 本讲只给直觉,后面再展开 |
|---|---|
第 3 讲:线程层次、warp、发散、共享内存、__syncthreads、占用率 | 异步拷贝与软件流水线、warp 特化(第 6 讲) |
| 第 1 讲:roofline 与算术强度 | Triton 怎样自动做这些分块(第 8 讲);自动调优(第 9 讲) |
| 矩阵乘法的定义 | FlashAttention 里的分块与在线 softmax(第 5 讲) |
0.3 本讲地图
§1 为什么要拿矩阵乘法和归约做案例
§2 矩阵乘法(一):朴素实现与"x 当行还是当列" ← 代码 01
§3 矩阵乘法(二):寄存器分块与共享内存分块,复用分析 ← 代码 02(演示 1)
§4 矩阵乘法(三):合作加载;三层 roofline;分块参数的权衡 ← 代码 02(演示 2–3)
§5 并行归约:内核分解、交错寻址、步长索引、顺序寻址、warp shuffle ← 代码 03
§6 合并访存与 bank 冲突:跨步、对齐、矩阵转置 ← 代码 04
§7 回顾:GPU 优化技巧清单
§8 小结 §9 常见误区 §10 练习 §11 延伸阅读 §12 下一讲预告
0.4 一个提醒:本讲的计数是怎么来的
和第 3 讲一样,所有计数都来自 gpusim.py:每个内核在 (矩阵乘法)或 (归约)上真实执行一遍,结果和 NumPy 对照,同时数出每条 warp 级访存指令的 32 字节事务数、共享内存的 wavefront(bank 冲突时一条指令要走几轮)、发散次数等。然后按”读流量 ∝ 、写流量 ∝ “(矩阵乘法)或”∝ “(归约)外推到真实规模,用 H100 的标称值估算时间:HBM 3.35 TB/s;共享内存每个 SM 每周期 1 个 wavefront(128 字节);CUDA core FP32 66.9 TFLOP/s(本讲的内核都不用 Tensor Core)。这些时间是”按瓶颈估算的下界”,不是实测;它们的价值在于比较版本之间的差别和判断瓶颈。
本讲的模拟器新增了向量访存:t.load(A, idx, vec=True) 表示一个线程用一条指令读 个连续的元素(对应 CUDA 的 float4)。真实的高性能内核都这样读写,它减少了指令数。
1. 为什么拿矩阵乘法和归约做案例 · Why these two case studies
slides 选的两个案例代表了深度学习里最重要的两类计算:
- 矩阵乘法:Transformer 里 99% 以上的 FLOPs(第 1 讲 §8:预填充时矩阵乘法占 99.97% 的 FLOPs)。它是计算密集的——但只有在数据被充分复用时才是。怎样组织复用,是本讲的主线。
- 归约(求和、求最大值):softmax、LayerNorm、RMSNorm、注意力里的归一化都离不开它。它是访存密集的——每个数只用一次,优化的目标是”不要比读一遍数据更慢”。
两个案例覆盖了第 3 讲的几乎所有概念:合并访存、共享内存、同步、发散、bank 冲突、占用率。它们也是后面几讲的零件:FlashAttention(第 5 讲)= 分块矩阵乘法 + 在线 softmax(一种归约);Triton(第 8 讲)把这里手写的分块交给编译器。
2. 矩阵乘法(一):朴素实现 · Naive matmul
2.1 slides 的”稻草人”
slides 第 49 页:计算 (),每个线程算 的一个元素,块大小 :
__global__ void mm(float A[N][N], float B[N][N], float C[N][N]) {
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
float result = 0;
for (int k = 0; k < N; ++k) result += A[x][k] * B[k][y];
C[x][y] = result;
}
slides 的分析:每个线程读全局内存 次,共 个线程,总共 次。每做一次乘加,就要从显存读两个数——算术强度只有 ,比 H100 的拐点低三个数量级。
2.2 “x 当行还是当列”:合并访存的第一课(代码 01)
slides 的这段代码有一个容易被忽略、但影响巨大的细节:x 来自 threadIdx.x,而 threadIdx.x 是一个 warp 里变化最快的那一维(第 3 讲 §8)。于是一个 warp 的 32 个线程有 32 个连续的 x、相同的 y:
- 读
A[x][k]:32 个线程读 A 的同一列的 32 个元素,相邻地址隔 个 float——32 个不同的 32 字节段,32 个事务; - 读
B[k][y]:32 个线程读同一个数——广播,1 个事务; - 写
C[x][y]:又是一列,32 个事务。
把行列交换(row 来自 threadIdx.y、col 来自 threadIdx.x):读 A[row][k] 变成广播(1 个事务),读 B[k][col] 是一行里连续的 32 个数(4 个事务),写 C[row][col] 也是连续的(4 个事务)。代码 01 在 上的计数:
| slides 原版(x = 行) | 交换版(x = 列) | |
|---|---|---|
| 全局读取(线程级) | 33,554,432 = | 33,554,432 = |
| 读 A:事务 / warp 指令 | 32 | 1(广播) |
| 读 B:事务 / warp 指令 | 1(广播) | 4 |
| 写 C:事务 / warp 指令 | 32 | 4 |
| 全局事务总数 | 17,367,040 | 2,629,632 |
| 合并效率(不同字节 / 搬运字节) | 12% | 83% |
两个版本访问次数完全相同(都等于 slides 的 ),事务数差了 6.6 倍。外推到 (FP32,H100):slides 原版约 677 ms,交换版约 103 ms——而纯计算只需要 2.05 ms。
规则:让 threadIdx.x(warp 里变化最快的维度)对应内存里连续的维度(行优先存储时就是列下标)。slides 的代码是为了说明访问次数,真正写内核时这一点比什么都重要。
2.3 复用在哪里?
即使合并得最好,朴素版每次乘加也要从显存搬约 2.5 字节(算术强度 0.40)。可是矩阵乘法里有大量复用:A 的每个元素被 个线程(C 的一整行)用到,B 的每个元素也被 个线程用到。如果有一个”理想的缓存”让每个数只从显存读一次,算术强度可以到 。真 GPU 的 L1 / L2 能挡住一部分重复读,但它们很小(H100 的 L2 50 MB,装不下三个 64 MB 的矩阵),而且替换策略不受程序员控制。分块就是把复用显式地组织起来。
3. 矩阵乘法(二):两级分块 · Two-level tiling
3.1 优化 1:线程级寄存器分块
slides 第 50 页:让每个线程计算 的一个 小块。每一步 ,线程从全局内存读 A 的一小段( 个数)和 B 的一小段( 个数)到寄存器,做一个外积,更新 个累加器:
float c[V][V] = {0};
float a[V], b[V];
for (int k = 0; k < N; ++k) {
a[:] = A[k, xbase*V : xbase*V + V]; // 读 V 个数
b[:] = B[k, ybase*V : ybase*V + V]; // 读 V 个数
for (y...) for (x...) c[x][y] += a[x] * b[y]; // V² 次乘加
}
为什么有效? 读进寄存器的每个数被用了 次( 和 个 相乘)。每个线程读 次,线程数变成 ,全局访问总量 。
3.2 优化 2:线程块级共享内存分块
slides 第 51–52 页:再往上一层。让一个线程块计算 的一个 块,块里每个线程仍然算 。沿 维每次推进 :块里的线程合作把 A 和 B 的一个 条带搬进共享内存,同步,然后每个线程从共享内存读数据、在寄存器里做外积:
__shared__ float sA[S][L], sB[S][L];
float c[V][V] = {0};
for (int ko = 0; ko < N; ko += S) {
__syncthreads();
sA[:, :] = A[ko : ko + S, yblock*L : yblock*L + L]; // 合作加载(§4.1)
sB[:, :] = B[ko : ko + S, xblock*L : xblock*L + L];
__syncthreads();
for (int ki = 0; ki < S; ++ki) {
a[:] = sA[ki, threadIdx.y*V : threadIdx.y*V + V];
b[:] = sB[ki, threadIdx.x*V : threadIdx.x*V + V];
c += outer(a, b);
}
}
(slides 的这段代码里第二个 sA 应为 sB,是一个小笔误。)注意 A[ko : ko+S, yblock·L : ...] 的写法:这里的 A 以 的形状存放,计算的是 ——这样 A 和 B 的条带在内存里都是”一行里连续的 个数”,合作加载天然合并。代码 02 沿用了这个约定。
复用分析(slides 第 52 页):
- 全局内存:每个块读 个数(A、B 各 ),块数 ,总量 。每个从显存读进共享内存的数,被块里的线程用了 次。
- 共享内存:每个线程读 个数,线程数 ,总量 。每个从共享内存读进寄存器的数,被用了 次。
3.3 数一数(代码 02 演示 1)
| 版本() | 全局读取 | slides 公式 | 全局事务 | 共享内存请求 | 共享内存 wavefront | __syncthreads |
|---|---|---|---|---|---|---|
| 朴素 | 33,554,432 | 2,621,440 | 0 | 0 | 0 | |
| 寄存器分块 | 8,388,608 | 294,912 | 0 | 0 | 0 | |
| 共享内存分块 | 524,288 | 65,536 | 81,920 | 114,688 | 1,024 |
共享内存分块里,共享内存的读取量是 8,388,608 个元素,正好等于 。实际计数和 slides 的公式完全吻合。
两级分块各管一层存储,这是本讲最重要的直觉:
寄存器 ←──(每个数用 V 次)── 共享内存 ←──(每个数用 L 次)── HBM 显存
寄存器分块管这一层 共享内存分块管这一层
存储层次的每一层都有自己的带宽上限,每一层都需要自己的复用。只做共享内存分块不做寄存器分块,共享内存的带宽会成为瓶颈;只做寄存器分块,HBM 仍然是瓶颈。
4. 矩阵乘法(三):合作加载与参数权衡 · Cooperative fetching & trade-offs
4.1 合作加载
slides 第 53 页:搬一个 的条带进共享内存,要让块里的所有线程一起搬,并且相邻的线程搬相邻的数:
int nthreads = blockDim.y * blockDim.x;
int tid = threadIdx.y * blockDim.x + threadIdx.x;
for (int j = 0; j < L * S / nthreads; ++j) {
int y = (j * nthreads + tid) / L;
int x = (j * nthreads + tid) % L;
s[y, x] = A[k + y, yblock * L + x];
}
线性编号 tid 连续的线程,得到连续的 x(同一行里相邻的列),读的是连续的地址——合并。代码 02 演示 2 把编排反过来(y = lin % S, x = lin / S,相邻线程搬同一列里相邻的行)作对比:
| 编排 | 读 A 的事务 / 指令 | 写共享内存的 wavefront |
|---|---|---|
slides:y = lin / L, x = lin % L | 4 | 40,960 |
反过来:y = lin % S, x = lin / S | 8 | 98,304 |
同样的数据、同样的线程,换一个编排,全局事务翻倍,共享内存的写入也开始冲突。
4.2 三层 roofline:哪组参数最快?(代码 02 演示 3)
把每个配置都在 上模拟,再外推到 (FP32,H100 CUDA core)。每个配置的时间取三者中最大的:计算时间、HBM 时间(事务 × 32 字节 ÷ 3.35 TB/s)、共享内存时间(wavefront ÷ 每 SM 每周期 1 个):
| 版本 | HBM GB | 瓶颈 | 估算 | 寄存器/线程 | 占用率 | |||
|---|---|---|---|---|---|---|---|---|
| 朴素 | 343.7 | 102.6 ms | — | 2.05 ms | HBM | 102.6 ms | 19 | 100% |
| 寄存器分块 | 38.7 | 11.6 ms | — | 2.05 ms | HBM | 11.6 ms | 40 | 75% |
| 寄存器分块 | 19.4 | 5.8 ms | — | 2.05 ms | HBM | 5.8 ms | 96 | 25% |
| 共享内存 | 8.66 | 2.58 ms | 1.80 ms | 2.05 ms | HBM | 2.58 ms | 40 | 75% |
| 共享内存 | 8.66 | 2.58 ms | 0.64 ms | 2.05 ms | HBM | 2.58 ms | 96 | 31% |
| 共享内存 | 4.36 | 1.30 ms | 0.77 ms | 2.05 ms | 计算 | 2.05 ms | 96 | 25% |
| 共享内存 | 2.21 | 0.66 ms | 0.35 ms | 2.05 ms | 计算 | 2.05 ms | 约 304(溢出) | 12% |
(寄存器数按” 个累加器 + 个 a、b + 约 16 个杂项”粗估。)
读这张表的方法:
- 压 HBM, 压共享内存。 从 64 增大到 128,HBM 流量减半; 从 4 增大到 8,共享内存时间从 1.80 降到 0.64 ms。
- 压到计算成为瓶颈就够了。 时 HBM 和共享内存都快于计算,估算 2.05 ms = 纯计算时间。这就是第 1 讲说的”把访存受限的运算推到 roofline 的平顶上”。
- 资源有上限。 要约 304 个寄存器,超过每线程 255 个的硬上限,编译器会把多出来的”溢出”(spill)到显存里,性能断崖式下降——这个简单模型没有体现。块越大、每块要的共享内存越多,能驻留的块越少。
- 最快的配置占用率只有 25%。 roofline 不考虑延迟隐藏(第 3 讲 §7.5)。高性能 GEMM 的做法正是”低占用率 + 每个线程大量独立的乘加”(指令级并行)来藏延迟;第 6 讲的异步拷贝和 warp 特化会把”搬数据”和”算”彻底重叠。
没有一组参数对所有矩阵形状、所有 GPU 都最好:换一块共享内存更大的卡、换一个”瘦”矩阵(第 1 讲 §4.4 的 例子),最优参数都会变。cuBLAS 内部为不同形状准备了大量手调的内核,而第 9 讲的自动调优让机器去搜。
5. 并行归约 · Parallel reduction
5.1 问题与挑战
slides 第 55–57 页:求 。它是归一化、softmax 等算子的常用原语。块内用树形归约:
3 1 7 0 4 1 6 3
4 7 5 9
11 14
25
挑战在于:为了用满 GPU,必须用很多个线程块(一个块只在一个 SM 上运行),每个块归约数组的一段;那么块与块之间的部分和怎样合并?
CUDA 没有全局同步。 回忆第 3 讲的核心假设:线程块可以按任意顺序执行,块之间不能同步。slides 给了两个理由:
- 为处理器数量很多的 GPU 构建全局同步硬件很昂贵;
- 如果块数超过”SM 数 × 每 SM 可驻留的块数”,先运行的块在栅栏处等待,而还没被调度的块永远等不到空出来的 SM——死锁。
解决办法:内核分解。 把计算拆成多次内核启动:第一个内核让每个块产生一个部分和,第二个内核再归约这些部分和……每一层用的是同一份代码。内核启动充当全局同步(第 3 讲 §6:内核边界是隐式的全局栅栏),而启动开销很低(几微秒)。代码 03 演示 2:、每块 256 个线程时,v1–v3 要启动 3 个内核(1024 → 4 → 1 个块),每个块处理 512 个数的 v4 只要 2 个(512 → 1)。所有版本的结果和 float64 参考值相差 量级(float32 累加顺序不同造成的舍入差)。
5.2 版本 1:交错寻址
slides 第 60–62 页:
sdata[tid] = g_idata[i];
__syncthreads();
for (unsigned int s = 1; s < blockDim.x; s *= 2) {
if (tid % (2 * s) == 0) sdata[tid] += sdata[tid + s];
__syncthreads();
}
if (tid == 0) g_odata[blockIdx.x] = sdata[0];
问题:warp 高度发散。 slides 列出了每一轮哪些线程是活跃的:
tid: 0 1 2 3 4 5 6 7 8 9 ...
s=1 T F T F T F T F T F ...
s=2 T F F F T F F F T F ...
s=4 T F F F F F F F T F ...
活跃的线程越来越稀疏,但它们分散在所有 warp 里——每个 warp 都要执行,却只有少数车道在干活。
5.3 版本 2:步长索引,不发散的分支
slides 第 63–64 页:把活跃的线程挤到块的前面:
for (unsigned int s = 1; s < blockDim.x; s *= 2) {
int index = 2 * s * threadIdx.x;
if (index < blockDim.x) sdata[index] += sdata[index + s];
__syncthreads();
}
现在活跃的总是编号最小的那些线程(s=1 时前一半,s=2 时前四分之一……),整个 warp 要么全部活跃、要么全部空闲,直到活跃线程少于 32 个。
新问题:bank 冲突。 线程 tid 访问 sdata[2·s·tid]:同一个 warp 里相邻线程的地址相隔 个字。共享内存有 32 个 bank,每个 bank 4 字节宽,连续的字依次落在 bank 0、1、……、31、0……;相隔 个字的 32 个地址只用到 个 bank( 时 16 个、 时 8 个……),同一个 bank 的不同字要排队—— 路冲突。
5.4 版本 3:顺序寻址
slides 第 68 页:把循环反过来,步长从大到小:
for (unsigned int s = blockDim.x / 2; s > 0; s >>= 1) {
if (threadIdx.x < s) sdata[threadIdx.x] += sdata[threadIdx.x + s];
__syncthreads();
}
活跃的线程仍是编号最小的那些(不发散),而且同一个 warp 访问连续的 32 个字,落在 32 个不同的 bank——没有冲突。slides 把这一页叫做”完全合并的访问”,对共享内存来说更准确的说法是”无 bank 冲突”(合并是全局内存的概念,§6)。
5.5 版本 4:warp 级优化
slides 的小结列出了”warp 级优化”。代码 03 加了一个版本 4,组合两个经典技巧:
- 加载时先两两相加:每个线程从全局内存读两个数、先加起来再存进共享内存。一个块处理 块大小 个数,块数减半,第一轮”一半线程闲着”的浪费也没了。
- 最后一个 warp 用 shuffle:剩下 64 个数时,只让第一个 warp 干活:每个线程先读
sdata[tid] + sdata[tid+32]到寄存器,再用__shfl_down_sync在寄存器之间对折 5 次(16、8、4、2、1)。同一个 warp 的线程在硬件上一起执行,交换寄存器不需要经过共享内存,也不需要__syncthreads()。
5.6 数一数(代码 03 演示 1、3)
只看第一个内核(,1024 个块 × 256 个线程;v4 是 512 个块):
| 版本 | 发射的 warp 指令 | SIMD 效率 | 发散次数 | 共享内存请求 | wavefront | 冲突倍数 | __syncthreads/块 |
|---|---|---|---|---|---|---|---|
| v1 交错寻址 | 284,672 | 43% | 49,152 | 153,600 | 153,600 | 1.00× | 9 |
| v2 步长索引 | 141,312 | 87% | 6,144 | 46,080 | 153,600 | 3.33× | 9 |
| v3 顺序寻址 | 141,312 | 87% | 6,144 | 46,080 | 46,080 | 1.00× | 9 |
| v4 两两加载 + shuffle | 48,128 | 99% | 512 | 14,336 | 14,336 | 1.00× | 3 |
外推到 (约 6700 万个数,268 MB),按”指令发射(每 SM 4 个调度器)、共享内存(每 SM 每周期 1 个 wavefront)、HBM”三者中最慢的估算,再加上内核启动开销:
| 版本 | 启动次数 | 瓶颈 | 估算 | |||
|---|---|---|---|---|---|---|
| v1 | 69.7 µs | 150.4 µs | 80.1 µs | 4 | 共享内存 | 166 µs |
| v2 | 34.6 µs | 150.4 µs | 80.1 µs | 4 | 共享内存 | 166 µs |
| v3 | 34.6 µs | 45.1 µs | 80.1 µs | 4 | HBM | 96 µs |
| v4 | 11.8 µs | 14.0 µs | 80.1 µs | 3 | HBM | 92 µs |
这张表讲了一个完整的故事:
- v1 → v2:消除发散,发射的指令减半(69.7 → 34.6 µs)。但共享内存的总轮数一点没少(v1 是”很多条指令、每条 1 轮”,v2 是”更少的指令、每条 3.33 轮”),瓶颈只是从”发散”换成了”bank 冲突”,估算时间不变。只修一个瓶颈,可能什么也得不到。
- v2 → v3:消除 bank 冲突,共享内存时间降到 45 µs,第一次让 HBM(读一遍数据,80 µs)成为瓶颈。
- v3 → v4:指令和共享内存进一步降低,已经远低于 HBM;剩下的差别来自少一次内核启动。归约每个数只做一次加法,理想情况就是纯访存受限(80 µs)——优化的终点是”和读一遍数据一样快”。
5.7 “为什么需要两个 __syncthreads()?”
slides 第 60 页留了一个问题。代码 03 演示 4 把它们分别删掉:
| 删掉的同步 | 模拟器上的和 | 检测到的跨 warp 竞争 |
|---|---|---|
| v1 循环里的 | 仍然正确 | 7,168 次 |
| v1 加载之后的 | 仍然正确 | 0 次 |
| v3 加载之后的 | 仍然正确 | 131,072 次 |
- 循环里的:保证下一轮读到的是这一轮写完的值。删掉后竞争检测立刻报警。
- 加载之后的:保证所有数都已经搬进共享内存。在 v1 里删掉它,模拟器没有报警——原因是 v1 的第一轮()每个线程只读同一个 warp 里邻居的数,跨 warp 的读取要到 才出现,而那时循环里的
__syncthreads()早已执行过。但这不代表可以删:从 Volta 架构起,同一个 warp 的线程也不再保证同步执行(independent thread scheduling),warp 内通过共享内存交换数据同样需要同步(__syncwarp())——模拟器只检查跨 warp 的竞争,抓不到这一类。同样的删法放在 v3 里(第一轮就读sdata[tid + 128],是别的 warp 写的),竞争 131,072 次。
教训:同一条同步规则是否”恰好”被违反,取决于寻址方式。要按规范写同步,而不是靠测试碰运气。
6. 合并访存与 bank 冲突 · Coalescing & bank conflicts
6.1 合并访存(全局内存)
slides 第 65 页:当一个 warp 的多个线程访问连续的地址时,硬件把它们合并成少数几个事务,带宽利用率最高;地址分散时,同样多的数据需要更多事务。现代 NVIDIA GPU 的全局内存事务粒度是 32 字节(一个 sector)。代码 04 演示 1:一个 warp 的 32 个线程各读一个 float(共 128 字节):
| 访问模式 | 事务 / 指令 | 搬运 / 有用 |
|---|---|---|
连续、对齐(A[tid]) | 4 | 1.0× |
连续、错开 1 个元素(A[tid + 1]) | 5 | 1.2× |
| 跨步 2 | 8 | 2.0× |
| 跨步 4 | 16 | 4.0× |
| 跨步 ≥ 8 | 32 | 8.0× |
跨步 ≥ 8 时,每个线程独占一个 32 字节段,只用到其中 4 字节——这正是 slides 原版矩阵乘法读 A 的情形(§2.2)。对齐也有代价:错开一个元素就多搬 25%(第 3 讲的一维卷积里也出现过)。
6.2 bank 冲突(共享内存)
共享内存被分成 32 个 bank,每个 bank 每个周期提供 4 字节。一个 warp 的一条共享内存指令:
- 32 个线程访问 32 个不同的 bank → 1 轮(wavefront)完成;
- 多个线程访问同一个字 → 广播,不算冲突;
- 多个线程访问同一个 bank 的不同字 → 要排队,排得最长的那个 bank 决定要走几轮。
注意两者的”好模式”恰好不同:全局内存要连续,共享内存要分散在不同的 bank(连续的 32 个字恰好满足两者,所以大多数时候不冲突;问题出在”按列访问”)。
6.3 一个综合例子:矩阵转置(代码 04 演示 2)
转置 out[x][y] = in[y][x] 必然有一边按列访问:
- 朴素版:按行读(4 个事务),按列写(32 个事务)。
- 共享内存分块:每个块先把 的子块按行读进共享内存
tile[32][32],同步,然后按列从共享内存取出(tile[tx][ty])、按行写出去。读写全局内存都只要 4 个事务了——但按列读共享内存时,同一个 warp 的 32 个线程访问的地址相隔 32 个字,全部落在同一个 bank:32 路冲突,每条读指令要走 32 轮。 - padding:把共享内存声明成
tile[32][33],每行多留一个元素。按列读的地址相隔 33 个字,正好错开到 32 个不同的 bank,冲突消失。代价只是 128 字节的共享内存。
| 版本(512 × 512) | 读:事务/指令 | 写:事务/指令 | 按列读共享内存:轮/指令 | 外推到 8192² 的 H100 估算 |
|---|---|---|---|---|
| 朴素 | 4 | 32 | — | 721 µs(HBM) |
分块 tile[32][32] | 4 | 4 | 32 | 265 µs(共享内存) |
分块 + padding tile[32][33] | 4 | 4 | 1 | 160 µs(= 只读写一遍的理想值) |
又是”只修一个瓶颈不够”:分块解决了全局内存的合并问题,却引入了共享内存的冲突,直到 padding 才把两边都理顺。
7. 回顾:GPU 优化技巧清单 · Recap
slides 最后一页列出的 GPU 优化技巧,正好是第 3–4 讲的内容:
| 技巧 | 它对准的瓶颈 | 本课出处 |
|---|---|---|
| 一致的 warp(coherent warps) | 分支发散浪费的车道 | 第 3 讲 §3;本讲 §5.2–5.3 |
| 合并访存(coalesced access) | 全局内存事务里没用上的字节 | 本讲 §2.2、§4.1、§6.1 |
| 共享内存 bank 冲突 | 共享内存指令的多轮执行 | 本讲 §5.3–5.4、§6.3 |
| warp 级优化 | 同步与共享内存的开销 | 本讲 §5.5 |
| Tensor Core | CUDA core 的算力上限 | 第 3 讲 §9 |
再加上本讲的主线——分块复用(每一层存储都需要自己的复用)和占用率 / 延迟隐藏(第 3 讲 §7)——就是手写高性能 CUDA 内核的基本功。下一个问题自然是:这么多细节,能不能交给编译器?第 8–9 讲的 Triton、TVM、Ansor 就是在回答它。而在那之前,第 5 讲先用这些基本功拆解一个真实的、改变了 LLM 训练的内核:FlashAttention。
8. 小结 · Summary
- 朴素矩阵乘法读全局内存 次。线程编号与数据布局的对应决定能否合并:slides 原版(
threadIdx.x当行号)每条读指令 32 个事务,交换后 1–4 个,事务总数差 6.6 倍。 - 寄存器分块把全局访问降到 ,共享内存分块降到 、共享内存访问 ——模拟计数和 slides 的公式完全吻合。两级分块各管一层存储。
- 合作加载要让相邻线程搬相邻的数;编排反过来,事务翻倍。
- 三层 roofline: 压 HBM、 压共享内存,直到计算成为瓶颈( 时 估算 2.05 ms,比朴素快约 50 倍);但受寄存器、共享内存和占用率约束,最优参数随硬件和形状变化。
- 并行归约:没有全局同步 → 内核分解;交错寻址发散(SIMD 效率 43%),步长索引把发散换成了 bank 冲突(3.33 轮/指令、估算时间不变),顺序寻址两者都消除(第一次达到访存受限),warp shuffle 再减少同步和指令。
- 合并访存 vs bank 冲突:前者要连续,后者要分散;转置用分块解决前者、用 padding 解决后者。只修一个瓶颈往往不够。
9. 常见误区 · Common misconceptions
- “访问次数一样,速度就一样。” §2.2:同样 次访问,事务数差 6.6 倍。决定速度的是事务数(字节),不是访问次数。
- “共享内存分块就够了。” 不做寄存器分块,共享内存带宽会成为新的瓶颈(§4.2: 从 4 到 8,共享内存时间降到约三分之一)。
- “分块越大越好。” 寄存器溢出、共享内存不够、占用率太低、尾部效应(第 3 讲 §7.6)都会反过来伤害性能。
- “消除了发散就快了。” §5.6 的 v2:发散没了,bank 冲突来了,估算时间不变。优化要盯着当前的瓶颈。
- “合并访存和无 bank 冲突是一回事。” 一个针对全局内存(要连续),一个针对共享内存(要分散到不同 bank);按列读共享内存的子块正是”合并了却冲突”的典型。
- “同一个 warp 的线程天然同步,warp 内交换数据不用同步。” Volta 以后不再成立;warp 内通过共享内存通信要
__syncwarp(),shuffle 指令要带上参与线程的掩码(__shfl_down_sync(mask, ...))。 - “块之间可以用一个全局变量自旋等待来同步。” 块数超过可驻留的块数时会死锁;需要全局同步就拆成多个内核(或者用协作组 cooperative groups 的网格同步,它要求块数不超过可驻留的上限)。
10. 练习 · Exercises(含参考答案)
练习 1(朴素矩阵乘法的事务):在 slides 原版中,如果块大小改成 ,读 A 和读 B 每条 warp 指令各要几个事务?
答:一个 warp 现在是 16 个
x× 2 个y。读A[x][k]:16 个不同的行,每行一个数,16 个事务(两个y读同样的地址,广播)。读B[k][y]:2 个不同的数,可能落在同一个或相邻的段,1 个事务。仍然不合并,只是比 32 好一些。
练习 2(寄存器分块的复用): 时,每个线程每一步 读多少个数、做多少次乘加?全局访问总量比朴素版少几倍?
答:读 个数,做 次乘加;全局访问 ,少 8 倍。
练习 3(共享内存分块的资源):。每块多少线程?每块要多少共享内存?在 H100 SM 上(228 KB 共享内存、64 个 warp、65536 个寄存器)、每线程 96 个寄存器时能驻留几个块?
答:线程 ;共享内存 KB;寄存器限制 块;共享内存限制 块;warp 限制 块。驻留 2 块,占用率 ,受寄存器限制(和代码
02表中一致)。
练习 4(三层 roofline):用 FP32 CUDA core 算 的方阵乘法,、。按 §4.2 的模型,HBM 流量大约多少 GB?是计算受限还是 HBM 受限?
答:读 字节 GB,写 C 约 0.27 GB; ms; ms。计算受限( 越大,同样的分块下算术强度越高)。
练习 5(归约的 bank 冲突):版本 2 里, 时一个 warp 的 32 个线程访问 sdata[8·tid]。是几路冲突?
答:相邻线程相隔 8 个字,32 个地址只落在 个 bank(0、8、16、24),每个 bank 8 个不同的字:8 路冲突()。不过 时只有
index < blockDim的线程活跃——块大小 256 时活跃的是 tid < 32,正好一个 warp。
练习 6(内核分解):,每块 512 个线程、每个线程先加两个数(每块处理 1024 个数)。要启动几个内核?每个内核各多少块?
答: 块 → 块 → 1 块:3 个内核。
练习 7(padding 为什么是 33):转置时 tile[32][33] 能消除冲突。如果写成 tile[32][34] 呢?tile[32][64] 呢?
答:按列读的地址相隔 个字,落在 bank 。:,32 个不同的 bank,无冲突。:,只用到 16 个偶数 bank,2 路冲突。:全落在 bank 0,32 路冲突。规律: 要和 32 互质。
练习 8(读模拟器代码):在 gpusim.py 里找到计算 bank 冲突的函数,说明它为什么把”同一个 warp 访问同一个字”只算一次。
答:
BlockContext._account_shared先对 (warp, 字地址) 去重(np.unique),这对应硬件的广播:多个线程读同一个字时,bank 只需要提供一次。然后按 (warp, bank) 计数,取每个 warp 里计数最大的 bank 作为这条指令的轮数。
11. 延伸阅读 · Further reading
- M. Harris, Optimizing Parallel Reduction in CUDA(NVIDIA,2007):本讲 §5 的源头,一共 7 个版本,后几个版本讲了循环展开和模板化。
- S. Boehm, How to Optimize a CUDA Matmul Kernel for cuBLAS-like Performance(博客,2022):从朴素版一步步优化到接近 cuBLAS,和本讲 §2–§4 的思路一致,并给出了 A6000 上的实测数字。
- NVIDIA CUTLASS 文档:Efficient GEMM in CUDA:工业级 GEMM 的层次分块(线程块 / warp / 线程 / Tensor Core 指令)。
- V. Volkov, Better Performance at Lower Occupancy(GTC 2010):§4.2 那个”占用率 25% 却最快”的现象。
- NVIDIA, CUDA C++ Best Practices Guide,“Memory Optimizations”一章:合并访存、共享内存 bank、对齐的官方说明。
- 本仓库 CMU11-868 第 4 讲:同样的分块矩阵乘法与转置,侧重 miniTorch 作业的写法。
12. 下一讲预告 · Next lecture
第 5 讲《案例:Transformer、注意力与 FlashAttention》把本讲的两个零件拼起来:注意力 = 两次矩阵乘法 + 一次按行的 softmax(归约)。朴素实现要把 的注意力矩阵写回显存再读出来,是严重的访存受限;FlashAttention 用分块 + 在线 softmax,让整个注意力在片上完成,并在反向传播时用重计算(第 2 讲 §7)代替存储。我们还会看到,推理时的解码阶段为什么需要另一种切分方式(Flash-Decoding)。第 5 讲有两篇必读论文:Attention Is All You Need 和 FlashAttention。