Lecture 5: 并行模式三 —— 分块矩阵乘法:共享内存与数据复用 (对应 Lab 3: Tiled Matrix Multiply)
Lecture 5: 并行模式三 —— 分块矩阵乘法:共享内存与数据复用 (对应 Lab 3: Tiled Matrix Multiply)
概述
本讲要解决整个课程最核心的性能问题:矩阵乘法朴素内核每做 2 次浮点运算就要访问 8 字节全局内存(4 Byte/FLOP),而 GPU 的全局内存带宽远跟不上算力,导致内核被死死卡在内存带宽上(讲义中 1,000 GFLOP/s 算力配 150 GB/s 带宽的一代 GPU 只剩下 37.5 GFLOP/s 的实际上限)。 解法是”分块(tiling)”:把 M、N 切成 TILE_WIDTH × TILE_WIDTH 的小块,由同一个线程块(block)协作搬进共享内存(shared memory)这个片上的”暂存区”,再让块内所有线程反复使用,从而把每个输出元素的全局访存次数从 2·Width 次降到 2·Width/TILE_WIDTH 次。 为此本讲引入三个必须精通的 CUDA 机制:__shared__ 声明的共享内存、保证块内协作正确性的屏障 __syncthreads()、以及决定共享内存实际带宽的 bank 与 bank conflict(存储体冲突);最后用算术强度与 Roofline 模型定量说明”分块把工作点沿着屋顶线向右推”,以及为什么更大的 tile 与线程粗化(thread coarsening)才能让内核真正变成计算受限。
核心概念与 GPU 架构图解
概念 1:分块 / 分片(Tiling, Blocking)
- 定义与目的:分块是把大矩阵在逻辑上切成尺寸为
TILE_WIDTH × TILE_WIDTH(记作 T×T)的小方块,让一个线程块只负责计算 P 中对应的一个 T×T 输出块;该块在计算过程中只需反复读取 M 的一行 tile 与 N 的一列 tile,而不是完整的一行一列。目的是用片上存储换取全局内存访问量的成倍下降,把一个带宽受限的内核变成有机会达到计算峰值的核。 - 直观解释(”它是什么?”):想象厨房里做一道需要反复取用两种食材的菜。冰箱(全局内存)很大但离灶台很远,走一趟要 400 到 800 个时钟周期;灶台边的案板(共享内存)很小,但伸手就到(20 到 30 个周期)。朴素做法是每加一次料就跑一趟冰箱:做 4096 次乘加就要跑 8192 趟。分块做法是:先把这一小块所需的两种食材(各 T×T 份)一次性搬到案板上,然后 T×T 个厨师(线程)围着同一块案板,每人从案板上取 T 份 M 食材、T 份 N 食材,做完 T 份成品;案板上的食材用完(这一轮 m 结束)再集体去冰箱搬下一批。食材从冰箱只搬了一次,却被用了 T 次。
- 架构/机制图解:
全局内存(Global Memory, DRAM) 片上共享内存(Shared Memory, 每个 block 一份)
+--------------------------------+ +-----------------------------+
| M (Width x Width, 行主序) | | subTileM[T][T] |
| +------+------+------+------+ | 一次加载 | +------+ |
| | M0,0 | M0,1 | M0,2 | M0,3 | | =========> | | M0,0 | <- block(0,0) |
| +------+------+------+------+ | 搬 T*T 个 | +------+ 第 m=0 轮 |
| | M1,0 | M1,1 | M1,2 | M1,3 | | | | M1,0 | 所需的 |
| +------+------+------+------+ | | +------+ M 行 tile |
| | M2,0 | M2,1 | M2,2 | M2,3 | | +-----------------------------+
| +------+------+------+------+ | | subTileN[T][T] |
| | M3,0 | M3,1 | M3,2 | M3,3 | | | +------+------+ |
| +------+------+------+------+ | | | N0,0 | N0,1 | |
+--------------------------------+ | +------+------+ |
| | N1,0 | N1,1 | |
每个线程块只读取自己需要的那一"行 tile"和 | +------+------+ |
那一"列 tile",块内 T*T 个线程共享这两块。 +-----------------------------+
|
block 内 T*T 个线程反复读取(T 次复用)
v
+-----------------------------+
| 寄存器 Pvalue -> P[Row][Col]|
+-----------------------------+
数据流总结(一个 block 的一轮 m):
全局内存 --(协作加载 2*T*T 个元素, 合并访存)--> 共享内存 --(T 次读取/元素)--> 寄存器 --(2*T^3 FLOP)--> 全局内存 P
关键性能特征:一轮 m 加载 2·T² 个元素(8·T² 字节),却支撑了 T³ 次乘加(2·T³ FLOP),因此每字节全局流量支撑的浮点运算量从朴素版的 0.25 FLOP/Byte 提升到 T/4 FLOP/Byte(T=16 时 4,T=32 时 8)。全局内存延迟没有变(仍是 400 到 800 个周期),但需要发起的全局访存次数少了 T 倍;同时块内 T² 个线程的加载请求并行发出,内存级并行度(memory-level parallelism, MLP)很高。
- 为什么分块能减少全局内存访问:定量推导(这是 Lab 3 报告与考试必考的部分)。设矩阵边长为 K(讲义中写作 Width,注意不要与矩阵 N 混淆),朴素内核中每个输出元素 P[Row][Col] 需要做 K 次乘加,第 k 次乘加要读 M[Row][k] 与 N[k][Col] 各 1 个元素(各 4 字节):
| 项目 | 朴素内核 | 分块内核(tile 边长 T = TILE_WIDTH) |
|---|---|---|
| 每个输出元素的全局访存次数 | K 次乘加 × 2 个元素 = 2K 个元素(8K 字节) | 2·T·K / T² = 2K/T 个元素(8K/T 字节) |
| 每个输出元素的浮点运算 | 2K FLOP | 2K FLOP |
| 全局访存总量(元素数) | K² 个输出 × 2K = 2K³ 个元素 | 每 block 2·T·K 个元素 × (K/T)² 个 block = 2K³/T 个元素 |
| 全局访存总量(字节) | 8K³ 字节 | 8K³/T 字节 |
| 算术强度(AI) | 2K³ FLOP / 8K³ Byte = 0.25 FLOP/Byte | 2K³ / (8K³/T) = T/4 FLOP/Byte |
| 降低的倍数 | 1×(基准) | T 倍 |
推导中的关键一步是算”每个 block 需要多少全局访存”:一个 block 要算完自己的 T×T 个输出,必须遍历 M 的第 by 个行条带(共 K/T 个 tile)与 N 的第 bx 个列条带(共 K/T 个 tile),每一轮 m 各读一个 T×T 的 tile,因此每个 block 的全局加载量 = 2 × (K/T) × T² = 2·T·K 个元素;除以它负责的 T² 个输出元素,就得到 2K/T 个元素/输出元素,与要求一致。 再换个角度看复用的来源:一个 T×T 的 M tile 从全局内存只加载 1 次(T² 个元素),但在内层循环里被 block 内 T² 个线程各读 T 次(每个线程读自己那一行 tile 的 T 个元素),于是这块 tile 总共被读取 T² × T = T³ 次,平均每个加载进来的元素被复用了 T 次;N tile 同理。M、N 两个 tile 合起来:加载 2T² 个元素,产生 2T³ 次共享内存读取,正好对应 T³ 次乘加(2T³ FLOP)。这就是”用 T 倍复用换 T 倍带宽节省”的全部数学。
以 K = 4096、A100(1555 GB/s、19.5 TFLOPS FP32)为例:
- 朴素:全局流量 8K³ = 5.50 × 10¹¹ 字节 = 550 GB → 550 GB / 1555 GB/s = 354 ms;而计算只需 2K³ = 1.37 × 10¹¹ FLOP / 19.5 TFLOPS = 7.0 ms。内存比计算慢 50 倍,实测只能跑到约 350 GFLOP/s(约峰值的 1.8%),与讲义”150 GB/s 只能支撑 37.5 GFLOP/s、实测约 25 GFLOP/s”是同一个现象。
- 分块 T=16:流量 34.4 GB → 22.1 ms,仍然内存受限,上界 6.2 TFLOPS(约峰值 32%)。
- 分块 T=32:流量 17.2 GB → 11.0 ms,仍略慢于 7.0 ms 的计算时间,上界 12.4 TFLOPS(约峰值 64%)。
- 分块 T=64(需要线程粗化才能用 256 个线程覆盖 64×64 的输出块):流量 8.6 GB → 5.5 ms < 7.0 ms,终于变成计算受限。
概念 2:共享内存(Shared Memory)
- 定义与目的:共享内存是位于每个 SM 片上的、由
__shared__关键字声明的可读写暂存区,作用域是一个线程块,生命周期与线程块相同(块启动时分配、块结束时回收,不跨块保留)。它存在的目的就是给分块算法提供”每字节成本远低于全局内存”的可复用存储:全局内存延迟 400 到 800 个周期,而共享内存命中只需约 20 到 30 个周期(Ampere 讲义给出的理想数字是约 5 个周期,模型化时通常用 20 到 30 个周期以包含流水线与冲突开销),且不消耗 DRAM 带宽。 - 直观解释(”它是什么?”):共享内存就是厨房里的案板:它比冰箱(全局内存)小得多,但伸手可得。案板是同一个厨房(线程块)里所有厨师共用的——隔壁厨房(另一个 block)的厨师看不见也够不着你家的案板;而且案板在换菜(block 结束)时就清空了,不能留给下一道菜。寄存器则是”厨师自己手里的碗”:最快(约 1 个周期)但完全私有,别人不能看;全局内存是冰箱:容量巨大(几十 GB)但每次取用都要走很远。
- 架构/机制图解(A100 / Ampere SM 的内存层次):
+============================ 一个 SM(A100 共 108 个 SM)============================+
| |
| +---------------+ +---------------+ +---------------+ +---------------+ |
| | Warp Scheduler| | Warp Scheduler| | Warp Scheduler| | Warp Scheduler| |
| | 0 | | 1 | | 2 | | 3 | |
| +-------+-------+ +-------+-------+ +-------+-------+ +-------+-------+ |
| | | | | |
| v v v v |
| +----------------------------------------------------------------------------+ |
| | 寄存器堆 Register File 256 KB 65536 x 32 bit (~1 cycle) | |
| | 每个线程最多 255 个寄存器;SM 上所有线程共享这 65536 个寄存器 | |
| +----------------------------------------------------------------------------+ |
| +----------------------------------------------------------------------------+ |
| | L1 Cache / Shared Memory 合计 192 KB 片上存储(在二者之间划分) | |
| | +--------------------------------------------------------------------+ | |
| | | Shared Memory 划分: 最多 164 KB 给共享内存(须显式 opt-in) | | |
| | | 32 个 bank x 4 Byte = 每周期最多 128 Byte (一个 warp 一条指令) | | |
| | | 无冲突访存 ~20-30 cycles;命中 L1 ~30 cycles | | |
| | +--------------------------------------------------------------------+ | |
| | | L1 Cache 划分: 剩余部分(默认 128 KB 共享 / 64 KB L1 之类可调) | | |
| | +--------------------------------------------------------------------+ | |
| +---------------------------------+------------------------------------------+ |
+-------------------------------------|----------------------------------------------+
v
+--------------------------------+
| L2 Cache (A100 约 40 MB) | ~200 cycles
+---------------+----------------+
v
+--------------------------------+
| DRAM HBM2 ~1555 GB/s | 400-800 cycles
+--------------------------------+
容量速查(每个 SM 可用的共享内存上限):
RTX 2080 Ti (sm_75) : 64 KB A100 (sm_80) : 164 KB
RTX 4090 (sm_89) : 100 KB H100 SXM (sm_90): 228 KB
注意:单个线程块在没有 opt-in 的情况下只能静态申请 48 KB;更多必须用动态共享内存
+ cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, bytes)。
声明方式与静态/动态两种形态(这一段代码是后续三个示例的基础):
// (a) 静态共享内存:编译期确定大小,写在内核里,随内核一起编译进函数属性
__global__ void StaticKernel(const float* M, int Width)
{
__shared__ float subTileM[32][32]; // 32*32*4 = 4 KB,每个 block 一份
__shared__ float subTileN[32][32];
subTileM[threadIdx.y][threadIdx.x] = M[0];
__syncthreads();
}
// (b) 动态共享内存:大小在启动时以第三个 <<<>>> 参数给出,多个数组手工切分同一块
__global__ void DynamicKernel(const float* M, int Width)
{
extern __shared__ float dynShared[]; // 只声明,不指定大小
float (*subTileM)[33] = reinterpret_cast<float (*)[33]>(dynShared);
float (*subTileN)[33] = reinterpret_cast<float (*)[33]>(dynShared + 32 * 33);
subTileM[threadIdx.y][threadIdx.x] = M[0];
__syncthreads();
}
// 启动动态版本:第三个 <<<>>> 参数是"每个 block 的动态共享内存字节数"
void launchDynamicVersion(dim3 grid, dim3 block, const float* dM, float* dP, int Width)
{
int shmemBytes = 2 * 32 * 33 * (int)sizeof(float); // 2 个 32x33 的 float 数组 = 8448 B
// 若超过 48 KB(单个 block 静态共享内存上限),必须先 opt-in:
// 例如 TILE=96 时 2*96*97*4 = 74496 B > 48 KB,就必须调用下面这一行。
cudaFuncSetAttribute(DynamicKernel, cudaFuncAttributeMaxDynamicSharedMemorySize, shmemBytes);
DynamicKernel<<<grid, block, shmemBytes>>>(dM, Width);
(void)dP;
}
性能特征小结:共享内存带宽是”每 SM 每周期 32 个 bank × 4 字节 = 128 字节”,对 A100 全芯片即 108 SM × 128 B × 1.41 GHz ≈ 19.5 TB/s,是 DRAM 带宽(1.555 TB/s)的 12.5 倍,这正是分块能奏效的硬件基础。但它不是免费的无限带宽:见下一个概念,bank conflict 会把这条 128 B/cycle 的通道按冲突路数成倍地拖慢。
概念 3:共享内存的 bank 与 bank conflict(含 padding 消除法)
- 定义与目的:共享内存被硬件切成 32 个 bank,每个 bank 宽 4 字节,可在一个周期内各自独立地服务一个 4 字节访问。warp 中 32 个线程的地址按照
bank = (字节地址 / 4) % 32映射到 bank。若同一条访存指令中,同一个 bank 被多个线程访问了不同的地址,硬件只能串行地分多次完成(k 路冲突 = k 个周期);若多个线程访问的是同一个地址,则硬件做广播(broadcast),一次取回、发给所有请求者,不算冲突。目的是让程序员知道”什么时候共享内存会变慢”,并用 padding(在行尾补几个字节,改变行步长)等技巧恢复满带宽。 - 直观解释(”它是什么?”):把共享内存想成超市的 32 条收银台。一条指令就是一批 32 位顾客同时结账:理想情况每人排到不同的收银台(无冲突),一秒钟处理完;如果 3 个人挤在同一条队伍,就要排队 3 轮(3-way conflict,3 个周期);如果有人只是问”同一个人同一个问题”(同一地址),那就相当于广播通知,所有人一次都听到了,不额外排队。关键是”同一条指令”:两条不同源码行里的访问不属于同一条指令,不会互相冲突;同一个线程前后两次访问也不冲突。
- 架构/机制图解:
共享内存地址 -> bank 映射(32 banks x 4 Bytes,bank = (wordAddr) % 32)
wordAddr 0 到 15 : 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
bank : 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
wordAddr 16 到 31 : 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
bank : 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
wordAddr 32 到 47 : 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
bank : 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
结论:bank 编号每 32 个 4 字节字循环一次,第 1 行(字地址 32 到 63)与第 0 行
(字地址 0 到 31)落在完全相同的 bank 上,这正是冲突的根源。
三种典型情形(warp = 32 线程,一条访存指令):
(a) 无冲突 (conflict-free) -> 1 个周期
线程 t 访问 wordAddr = t : bank 依次为 0,1,2,3,4,5,6,7,8,9,10,11,12,13,
14,15,16,17,18,19,20,21,22,23,24,25,26,27,
28,29,30,31,32 个 bank 两两不同
(b) 广播 (broadcast, 不算冲突) -> 1 个周期
线程 t 访问 wordAddr = 5 : 32 个线程同一个地址 -> 一次取回广播
(c) k 路冲突 (k-way conflict) -> k 个周期
线程 t 访问 wordAddr = 32*t : 全部落在 bank 0 -> 32-way, 32 个周期
线程 t 访问 wordAddr = 16*t : 落在 bank {0,16} -> 8-way, 8 个周期
周期数速查:无冲突 = 1 周期;2-way = 2 周期;32-way = 32 周期(128 B/cycle 的通道被打成 4 B/cycle)
(1)讲义原版分块内核内层循环的 bank 访问模式(必须能逐 warp 推导出来):内核是 Pvalue += subTileM[ty][k] * subTileN[k][tx];,配 block(32,32)、TILE_WIDTH=32,因此一个 warp = ty 固定、tx = 0..31 的一整行 32 个线程(blockDim.x = 32 正好等于 warp 大小):
subTileM[ty][k]:k 对所有线程相同、ty 相同、tx 完全不出现 → 32 个线程访问同一个地址 → 广播,1 个周期,不冲突。subTileN[k][tx]:字地址 = k×32 + tx,bank = (k×32 + tx) % 32 = tx % 32,即 tx=0..31 恰好取遍 bank 0..31 → 无冲突,1 个周期。 结论:讲义第 34 页这个内核的内层循环本来就是无 bank conflict 的(写回 tile 的那条赋值语句subTileM[ty][tx] = M[Row*Width + m*TILE_WIDTH+tx];同样是 tx 连续、落在 bank 0 到 bank 31 各一次)。这是一条容易被误解的结论:bank conflict 与”是否使用共享内存”无关,只与”同一 warp 同一条指令内地址的 bank 分布”有关。TILE_WIDTH=16 时结论也一样:warp 跨两行(ty=0 的 16 个线程 + ty=1 的 16 个线程),subTileN[k][tx]的 32 个线程只访问 16 个不同地址(两半 warp 地址相同 → 广播),落在 bank 0 到 bank 15,仍然 1 个周期。
(2)什么时候真的会冲突:跨列 / 转置访问。真正危险的是”warp 内变化的下标出现在共享数组的行下标上“,即访问一个 T×T tile 的一列:字地址 = i×S + j(S = 行步长 = TILE_WIDTH),bank = (S×i + j) % 32。课程 2012 年模拟考题(Practice Exam 2 第 3 题)给出的正是这个形态的内核:
#define BLOCK_SIZE 16 // 课程考题中 BLOCK_SIZE 是编译期常量,可取值 1 到 20
__global__ void BlockTranspose(float* A_elements, int A_width, int A_height)
{
__shared__ float blockA[BLOCK_SIZE][BLOCK_SIZE];
int baseIdx = blockIdx.x * BLOCK_SIZE + threadIdx.x;
baseIdx += (blockIdx.y * BLOCK_SIZE + threadIdx.y) * A_width;
blockA[threadIdx.y][threadIdx.x] = A_elements[baseIdx];
A_elements[baseIdx] = blockA[threadIdx.x][threadIdx.y]; // 跨列读取 -> bank conflict
}
手工推演(tile 边长 T、行步长 S = T、线程 (tx,ty) 访问 tile[tx][ty],字地址 = tx·S + ty):
| 情形 | 行步长 S | warp 内 32 个地址的 bank 分布 | 单个 bank 上的最大不同地址数 | 访存周期数 |
|---|---|---|---|---|
| TILE=16,无 padding | 16 | ty=0 的 16 个线程:bank 依次为 0,16,0,16,0,16,0,16,0,16,0,16,0,16,0,16(8 个落在 bank 0、8 个落在 bank 16);ty=1 的 16 个线程:同理 bank 1 与 bank 17 各 8 个 | 8 | 8 周期(8-way) |
| TILE=16,padding +1 | 17 | ty=0:bank {0,17,2,19,4,21,6,23,8,25,10,27,12,29,14,31};ty=1:bank {1,18,3,20,5,22,7,24,9,26,11,28,13,30,15,0} | 2(只有 bank 0 被两组各命中一次) | 2 周期(2-way) |
| TILE=16,padding +2 | 18 | ty=0 的 16 个线程取遍全部偶数 bank {0,2,4,6,8,10,12,14,16,18,20,22,24,26,28,30};ty=1 取遍全部奇数 bank {1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31} | 1 | 1 周期(无冲突) |
| TILE=32,无 padding | 32 | 字地址 = 32·tx + ty,bank = (32·tx + ty) % 32 = ty(常数):32 个线程全部落在同一个 bank(例如 ty=0 时 bank 始终为 0,地址为 0,32,64,96,128,160,192,224,256,288,320,352,384,416,448,480,512,544,576,608,640,672,704,736,768,800,832,864,896,928,960,992 共 32 个不同地址) | 32 | 32 周期(32-way) |
| TILE=32,padding +1 | 33 | bank = (33·tx + ty) % 32 = (tx + ty) % 32,tx=0..31 → 取遍全部 32 个 bank;ty=3 时 bank 依次为 3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,0,1,2,仍然两两不同 | 1 | 1 周期(无冲突) |
逐项展开 TILE=32 的 bank 编号推导表(ty = 0,padding 前 vs 后):
无 padding,S=32,ty=0(warp 内 32 个线程,tx = 0..31):
tx : 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
字地址 : 0 32 64 96 128 160 192 224 256 288 320 352 384 416 448 480
bank : 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
tx : 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
字地址 : 512 544 576 608 640 672 704 736 768 800 832 864 896 928 960 992
bank : 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
=> 32 个不同地址全部落在 bank 0:一次 32 路串行 = 32 个周期
有 padding,S=33,ty=0:
tx : 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
字地址 : 0 33 66 99 132 165 198 231 264 297 330 363 396 429 462 495
bank : 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
tx : 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
字地址 : 528 561 594 627 660 693 726 759 792 825 858 891 924 957 990 1023
bank : 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
=> 32 个不同地址落在 32 个不同 bank:1 个周期,无冲突
有 padding,S=33,ty=5(整张 bank 表整体右移 5 格,仍然是 32 个不同 bank):
tx : 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
字地址 : 5 38 71 104 137 170 203 236 269 302 335 368 401 434 467 500
bank : 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
tx : 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
字地址 : 533 566 599 632 665 698 731 764 797 830 863 896 929 962 995 1028
bank : 21 22 23 24 25 26 27 28 29 30 31 0 1 2 3 4
(3)padding 消除冲突的原理:把行步长从 S 改成 S+1,等价于把”跨列访问的地址序列”从等差数列 {S·i} 变成 {(S+1)·i}。当 S+1 与 32 互质时,i = 0..31 产生的 (S+1)·i mod 32 恰好取遍 0..31(因为 (S+1) 是模 32 的乘法生成元),于是 32 个线程访问 32 个不同的 bank,冲突彻底消失。TILE=32 时 S+1 = 33,gcd(33,32)=1,一次 +1 就完美解决;TILE=16 时 warp 横跨两行、每行只有 16 个线程只覆盖 16 个 bank,+1(S=17)仍会让两半 warp 在一个 bank 上撞车(2-way),要 +2(S=18,取遍全部偶数 bank / 奇数 bank)才完全无冲突。padding 的代价只是每行多 4 到 8 字节(TILE=32 时每块共享内存从 8 KB 变成 8448 B,多出 448 B),可以忽略。
概念 4:__syncthreads() 屏障与批量同步执行(Barrier / Bulk Synchronous Execution)
- 定义与目的:
void __syncthreads(void);是块级屏障:块内所有线程都必须到达该调用点,且在最后一条线程到达之前,先到的线程一直阻塞(睡眠);只有当所有线程都到达后,大家才一起继续。它解决的问题是:分块的”协作加载 → 共同计算 → 换下一块”这种流水必须让所有线程步调一致,否则线程 A 可能在读到线程 B 还没写进去的 tile 元素(RAW 竞争),或者把线程 C 还在读的 tile 元素覆盖掉(WAR 竞争)。 - 直观解释(”它是什么?”):像接力赛的交接区或集体合影:拍合影时摄影师(屏障)要求所有人都站好(都到达
__syncthreads())才按快门,谁没到大家就都等着;快门之后所有人同时继续。它也是”批量同步(bulk synchronous)”编程模型的实现:一群线程先各自干活,再一起等齐(屏障),再一起进入下一段。这种模式把程序切成若干个”只在段内并行”的小步骤,让调试变成调试许多个小程序,而不是一个巨大的并行体——这正是高性能计算领域的主流写法。 - 架构/机制图解:
时间轴(一个 block 内 T*T 个线程、TILE_WIDTH 轮 m 循环)
thread 0 : [ 加载 tile ]####[ 计算 T 步 ]####[ 加载 tile ]####[ 计算 T 步 ]####[ 写 P ]
thread 1 : [ 加载 tile ]####[ 计算 T 步 ]####[ 加载 tile ]####[ 计算 T 步 ]####[ 写 P ]
thread 2 : [ 加载 tile ]####[ 计算 T 步 ]####[ 加载 tile ]####[ 计算 T 步 ]####[ 写 P ]
thread 3 : [ 加载 tile ]####[ 计算 T 步 ]####[ 加载 tile ]####[ 计算 T 步 ]####[ 写 P ]
(thread 4 到 thread N-1 的行为与上面完全相同;慢的线程会让快的线程在屏障处等待)
thread N : [ 加载 tile ]####[ 计算 T 步 ]####[ 加载 tile ]####[ 计算 T 步 ]####[ 写 P ]
^ ^ ^
| | |
屏障 1(本轮加载 -> 本轮计算) 屏障 2(本轮计算 -> 下一轮加载)
屏障 1 防止 RAW(read-after-write,真依赖):
线程 A 读 subTileM[k][ty] 之前,线程 B 必须已经完成 subTileM[*][*] 的写入。
屏障 2 防止 WAR(write-after-read,反依赖):
线程 A 覆盖 subTileM[*][*] 之前,线程 B 必须已经读完上一轮的旧值。
两次同步缺一不可;没有屏障 1 -> 读到旧值/未初始化值;没有屏障 2 -> 读到"半新半旧"的混合 tile。
一个 block 的循环体(m = 0, 1, 2, 直到 Width/TILE_WIDTH - 1):
+---------------+ +---------------------+ +----------------------+
| 协作加载 M/N | --> | __syncthreads() #1 | --> | 内层 k 循环 T 次乘加 | --+
| tile 到共享内存| | (等待全部写完) | | 读共享内存 -> 累加器 |
+---------------+ +---------------------+ +----------------------+
|
+----------------------+-----------v-----+
| __syncthreads() #2 (等待全部读完) |
+------------------+----------------------+
|
+---------------------------------+
| 回到循环开头(下一轮 m);最后一轮之后写 P[Row*Width+Col]
+--------------------------------------------------------
两条铁律(讲义的 N.B. 与常见考试陷阱):
- 块内所有线程都必须到达同一个
__syncthreads()调用点。 不能只有子集进入(例如把__syncthreads()写在if (Row < Width)里,边界块中部分线程不进入 → 结果是未定义行为,程序可能挂死或产生错误结果)。Volta 之后硬件引入了 per-thread 的收敛屏障,收敛的(convergent)分支里的屏障在部分新架构上”能用”,但 CUDA 编程指南仍明确规定必须所有线程到达同一个静态调用点,不要依赖未定义行为。 - 不能放在发散(divergent)的分支里:线程必须在同一条静态调用上等待(”同一个静态调用”不等于”调用同一个函数”)。 屏障的可见性保证:CUDA 手册规定屏障之前的所有全局内存与共享内存访问(包括原子操作变体)都已完成(对块内可见);但不要对 I/O 做假设(例如
printf的输出顺序不受屏障约束)。 讲义第 41 到 44 页的四个”Problem Solving”判定,可以整理成一张判定表,它是 Lab 3 与考试中最常考的模式:
代码 需要 __syncthreads 吗? 理由
----------------------------------------------------------------------------------------------
tile[ty][tx] = myNumber; otherNumber = tile[ty][tx]; 不需要 每个线程只读自己写的位置
tile[ty][tx] = myNumber; otherNumber = tile[tx][ty]; 需要 读的是别人写的元素(RAW)
tile[ty][tx] = myNumber; __syncthreads();
otherNumber = tile[tx][ty];
thirdNumber = tile[TW-tx-1][ty]; 不需要 屏障后没有新写入,顺序已被保证
tile[ty][tx] = myNumber; __syncthreads();
otherNumber = tile[tx][ty];
thirdNumber = tile[TW-tx-1][ty];
tile[ty][tx] = myNumber * 2.0f; 需要 覆盖写前必须等别人读完(WAR)
----------------------------------------------------------------------------------------------
概念 5:算术强度与 Roofline 移动(Arithmetic Intensity & Roofline Model)
- 定义与目的:算术强度 AI = 浮点运算次数 / 访问的字节数(FLOP/Byte),它刻画一个内核”每搬运 1 字节数据能算多少活”。Roofline 模型把硬件分成两条上界:内存带宽上界(斜率 = 带宽)与计算峰值上界(水平线),二者的交点就是机器平衡点(machine balance)= 峰值算力 / 带宽。目的:一眼判断内核是”内存带宽受限”还是”计算受限”,并算出需要多少数据复用才能达到峰值。
- 直观解释(”它是什么?”):像评价一辆车的油耗:每升油能跑多少公里就是这辆车的”算术强度”。发动机马力(算力)再大,如果油箱补给(带宽)跟不上,也只能慢慢开。Roofline 图就是这个”耗油率-速度”曲线:低速时受限于供油(斜坡段,内存受限),高速时受限于发动机(平顶段,计算受限),拐点就是这台 GPU 的”经济时速”。
- 架构/机制图解(以 A100:19.5 TFLOPS FP32、1555 GB/s、机器平衡点 19.5×10¹²/1.555×10¹² = 12.54 FLOP/Byte 为基准):
性能 (GFLOP/s, 对数坐标)
20T | +-------------------------------+ <-- 计算屋顶
| ..--' | 19,500 GFLOP/s
15T | ..--'' |
| ..--'' <-- 内存屋顶:斜率 = 1555 GB/s
10T | ..--''
| ..--'' * TILE=32 工作点 (8 FLOP/B) -> 12,440 GFLOP/s (64%)
5T | ..--'' * TILE=16 工作点 (4 FLOP/B) -> 6,220 GFLOP/s (32%)
| ..-'' *
0.4T |* 朴素工作点 (0.25 FLOP/B) -> 389 GFLOP/s (2.0%)
+----+---------+---------+---------+---------+---------+------> 算术强度 FLOP/Byte
0.25 4 8 12.54 16 32
^
A100 机器平衡点 = 19.5 TFLOPS / 1555 GB/s
分块只有把工作点推到 12.54 的右侧,才能真正 "计算受限"
分块后算术强度的推导:
一轮 tile(边长 T): 内存流量 = 2*T*T 个元素 = 8*T^2 Byte
浮点运算 = T^3 次乘加 = 2*T^3 FLOP
AI = 2*T^3 / (8*T^2) = T/4 FLOP/Byte
T=16 -> 4 FLOP/Byte T=32 -> 8 FLOP/Byte T=64 -> 16 FLOP/Byte
与讲义”Use of Large Tiles Shifts Bottleneck”(第 35 页)的经典数字完全一致:一代 GPU 算力 1,000 GFLOP/s、带宽 150 GB/s(平衡点 6.67 FLOP/Byte)。朴素版每 FLOP 要 4 字节 → 150/4 = 37.5 GFLOP/s(实测约 25 GFLOP/s);TILE=16 使每个操作数被复用 16 次 → (150/4)×16 = 600 GFLOP/s;TILE=32 → (150/4)×32 = 1,200 GFLOP/s > 1,000 GFLOP/s,内存带宽不再是瓶颈,瓶颈转移到计算。 不同 GPU 的平衡点差异很大,这决定了”分块够不够”:
| GPU | FP32 峰值 | 带宽 | 机器平衡点 | TILE=32(AI=8)能达到的比例 | 结论 |
|---|---|---|---|---|---|
| RTX 2080 Ti (Turing) | 13.4 TFLOPS | 616 GB/s | 21.8 FLOP/Byte | 8/21.8 = 37% | 分块远远不够 |
| A100 (Ampere) | 19.5 TFLOPS | 1555 GB/s | 12.5 FLOP/Byte | 8/12.5 = 64% | 分块 + 粗化即可接近峰值 |
| H100 SXM (Hopper) | 67 TFLOPS | 3350 GB/s | 20.0 FLOP/Byte | 8/20.0 = 40% | 需要更大 tile / 张量核 |
| RTX 4090 (Ada) | 82.6 TFLOPS | 1008 GB/s | 81.9 FLOP/Byte | 8/81.9 = 9.8% | 必须靠寄存器分块 + 张量核 |
要真正越过平衡点,需要 AI ≥ 12.54,即 T ≥ 4 × 12.54 = 50.2;而”一个线程算一个输出元素”的写法要求 block 有 T² 个线程,T=32 时已是 1024 个线程(一个 block 的线程上限),T 无法再大。这正是下一个概念要解决的问题。
概念 6:线程粗化与寄存器分块(Thread Coarsening & Register Tiling)
- 定义与目的:线程粗化指让每个线程计算多个输出元素(例如 2×2 = 4 个),把这些输出元素的累加器放进寄存器形成”寄存器 tile”。它解决的问题有三层:(1) 把 tile 的几何尺寸与块的线程数解耦,于是可以用 256 个线程覆盖 64×64 的输出块,从而使用更大的 tile、获得更高的全局复用(AI 从 T/4 继续上升);(2) 减少共享内存访问次数(每做一次乘加所需的共享内存读取从 2 次降到 1 次甚至 0.5 次);(3) 摊薄地址计算、循环控制等指令开销,让 FMA 指令的密度更高。
- 直观解释(”它是什么?”):像一位厨师不再”炒一个菜就跑一趟案板”,而是同时照看 4 口锅:他从案板上取一次 M 食材(一行),可以配 4 种不同的 N 食材做出 4 份成品;每份食材的取用次数翻倍,跑案板的次数相对减半。案板还是那块案板,但”每次伸手”都更值钱。寄存器就是厨师手里的 4 个碗(累加器),别人看不到也不需要看到。
- 架构/机制图解:
线程粗化前(1 输出/线程) 线程粗化后(2x2 = 4 输出/线程)
block(16,16) 覆盖 16x16 输出 block(16,16) 覆盖 32x32 输出
+-------------------+ +-------------------------------+
| 线程(tx,ty) 算 1 个 | | 线程(tx,ty) 算 2x2 个寄存器 tile|
| P[ty][tx] | | P[2ty ][2tx ] P[2ty ][2tx+1]|
+-------------------+ | P[2ty+1][2tx ] P[2ty+1][2tx+1]|
+-------------------------------+
每个 k 步:2 次共享内存读 + 1 次 FMA 每个 k 步:4 次共享内存读 + 4 次 FMA
-> 2 次共享读 / 乘加 -> 1 次共享读 / 乘加
-> 4 Byte/FLOP(含广播后约 2 Byte/FLOP) -> 2 Byte/FLOP(含广播后约 0.6 Byte/FLOP)
共享内存带宽瓶颈核算(A100 每 SM:128 FLOP/cycle,共享内存 128 Byte/cycle)
未粗化:4 B/FLOP 需要 512 B/cycle,硬件只有 128 B/cycle -> 理论上只能达到峰值的 1/4
2x2 :2 B/FLOP 需要 256 B/cycle -> 仍超配 2 倍
4x4 :1 B/FLOP 需要 128 B/cycle -> 与共享内存带宽恰好平衡
结论:共享内存带宽(而不是 DRAM 带宽)才是"已经做完分块"之后的下一个瓶颈,
这正是真实 SGEMM 内核普遍采用 4x4 或 8x8 寄存器 tile 的原因。
课程 SGEMM 对比表(ECE409 联合讲座,2020 年 4 月 16 日):
+---------+---------+---------+---------------------+---------------------+-----------+
| 方案 | A 复用 | B 复用 | 每 block 产物数据量 | 共享内存/block | 寄存器/block|
+---------+---------+---------+---------------------+---------------------+-----------+
| Basic | 1 | 1 | 1024 | 0 | 4 KB |
| Tiled | 32 | 32 | 1024 (TILE_SIZE^2) | 32*32*2*4B = 8 KB | 4 KB |
| Joint | 16 | 64 | 1024 | 64*4B = 256 B | 5 KB |
+---------+---------+---------+---------------------+---------------------+-----------+
(Joint = 共享内存 + 寄存器联合分块:共享内存只放 B 的一个小 tile,A 的值从全局/寄存器直接广播,
用 64 个 A 行 × 16 个 B 值 在寄存器里做 64*16 次乘加,因此共享内存占用骤降到 256 B,
但每线程需要 64*16 个累加器以上的寄存器压力——课程注明 ECE408 不要求复现。)
粗化对全局内存流量本身不改变:全局流量依旧是 8K³/T;它改变的是共享内存流量、指令数与可达的占用率,并通过”允许更大 T 而线程数不变”间接提高全局复用。用一句准确的话总结:分块降低全局访存,粗化降低共享访存并解锁更大的分块。
概念 7:占用率与共享内存的权衡(Occupancy vs Shared Memory)
- 定义与目的:占用率 = SM 上实际驻留的 warp(或线程)数 / SM 支持的最大数。它决定了 SM 能同时掩盖多少延迟。共享内存是按 block 分配的资源,因此 tile 越大,每个 block 占用的共享内存越多,能同时驻留的 block 就越少;而线程数上限、寄存器上限同样在竞争。目的:在”更大的 tile 带来更高复用”与”更小的 tile 带来更高占用率”之间找平衡点,并知道究竟是哪一项资源在真正限制占用率。
- 直观解释(”它是什么?”):像一家餐厅同时能接待多少桌客人。桌子(block)占地越大(共享内存多),餐厅能摆下的桌子就越少;但每桌能坐的人(线程)也有上限,员工(寄存器)也是有限的。翻台率(占用率)高,才能在前一桌客人还在等菜(内存延迟)时让后一桌先点菜——这就是”用并行度掩盖延迟”。
- 架构/机制图解(以 A100:每 SM 2048 线程 / 64 warp / 65536 寄存器 / 164 KB 共享内存 为基准):
一个 SM 上的资源分配(每个 block 依次占用一份,直到某项资源耗尽)
+----------------------------------------------------------------------------------+
| SM (A100) |
| 线程槽位 : 2048 threads [ block 0: 1024 ][ block 1: 1024 ](已满,不能再放第 3 块) |
| 共享内存 : 164 KB [ block 0: 8 KB ][ block 1: 8 KB ](还剩 148 KB 可用) |
| 寄存器 : 65536 regs [ block 0:32768 ][ block 1:32768 ](已满,每线程 32 个寄存器)|
+----------------------------------------------------------------------------------+
^ 本例(TILE=32, block=1024 线程, 每线程 32 个寄存器)中真正卡住的是
"线程槽位" 与 "寄存器" 两项;共享内存只用了 16 KB / 164 KB,远不是限制。
占用率计算(A100,每种配置的驻留 block 数取其四项限制的最小值):
+----------------------+---------+-----------+-----------+------------+-----------+-----------+
| 配置 | 共享内存 | 共享内存 | 线程槽位 | 寄存器限制 | 驻留 | 占用率 |
| | /block | 限制块数 | 限制块数 | (R=32/40) | block 数 | (warp) |
+----------------------+---------+-----------+-----------+------------+-----------+-----------+
| T=16, 256 线程 | 2 KB | 164/2 = 82| 2048/256=8| 8 / 6 | 8 | 100% |
| T=32, 1024 线程 | 8 KB | 164/8 = 20| 2048/1024 | 2 / 1 | 2 | 100% |
| | | | = 2 | | | |
| T=32 粗化 2x2, | 8.25 KB | 164/8.25 | 2048/256=8| 8 / 6 | 6..8 | 75%-100% |
| 256 线程(P=32) | | = 19 | | | | |
| T=64 粗化 4x4, | 33 KB | 164/33 = 4| 2048/256=8| 8 / 4 | 4 | 50% |
| 256 线程(P=40) | | | | | | |
+----------------------+---------+-----------+-----------+------------+-----------+-----------+
寄存器限制的算法:65536 / (256 线程 × R 寄存器) ;R=32 -> 8 块,R=40 -> 6 块,R=64 -> 4 块。
注意 1024 线程的 block 想要 2 块驻留,每线程只能用 65536/2048 = 32 个寄存器——这是
讲义 TILE=32 方案最容易被忽视的硬约束(编译时可用 __launch_bounds__(1024, 2) 强制)。
讲义第 36-37 页的老 GPU 数字(Maxwell 时代,2048 线程/SM,64 KB 共享内存):
TILE=16(256 线程):2*256*4B = 2 KB/block -> 共享内存允许 32 个 block;
2048 线程上限 -> 8 个 block -> 8*512 = 4,096 个挂起加载
TILE=32(1024 线程):2*1024*4B = 8 KB/block -> 共享内存允许 8 个 block;
2048 线程上限 -> 2 个 block -> 2*2,048 = 4,096 个挂起加载
结论:两种 tile 尺寸暴露的内存级并行度相同(都是每线程 2 个挂起加载),
因此都可以用来掩盖全局内存延迟;tile 的选择由复用率与占用率的平衡决定。
在 RTX 4090(1536 线程/SM、48 warp、100 KB 共享内存)上:
TILE=16(256 线程):min(100/2 = 50, 1536/256 = 6) = 6 块 -> 100% 占用率
TILE=32(1024 线程):min(100/8 = 12, 1536/1024 = 1) = 1 块 -> 1024/1536 = 66.7% 占用率
=> 在 Ada 上,TILE=32 因为"线程槽位"限制拿不到 100% 占用率,粗化(256 线程 + 2x2 寄存器 tile)
反而是恢复占用率的关键手段。
代码示例与性能分析
代码示例 1:讲义原版分块矩阵乘法(tiled_matmul_basic.cu)
完整内核来自讲义第 34 页,并补全主机端、计时、CPU 参考实现、GFLOP/s 与算术强度打印、Roofline 判定:
// 文件: tiled_matmul_basic.cu
// 编译: nvcc -O3 -arch=sm_80 tiled_matmul_basic.cu -o tiled_matmul_basic
// 运行: ./tiled_matmul_basic 2048 (矩阵边长,必须是 TILE_WIDTH 的整数倍)
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <cuda_runtime.h>
#define CUDA_CHECK(call) \
do { \
cudaError_t err__ = (call); \
if (err__ != cudaSuccess) { \
fprintf(stderr, "CUDA error %s:%d: %s\n", __FILE__, __LINE__, \
cudaGetErrorString(err__)); \
exit(EXIT_FAILURE); \
} \
} while (0)
#define TILE_WIDTH 16
// A100 硬件档案(用于 Roofline 分析)
#define A100_PEAK_TFLOPS 19.5
#define A100_BW_GBPS 1555.0
// ---------- 朴素版本(讲义第 4-5 页):每个输出元素 2K 次全局访存 ----------
__global__ void MatrixMulKernelNaive(const float* __restrict__ M,
const float* __restrict__ N,
float* __restrict__ P,
int Width)
{
int Row = blockIdx.y * blockDim.y + threadIdx.y;
int Col = blockIdx.x * blockDim.x + threadIdx.x;
if ((Row < Width) && (Col < Width)) {
float Pvalue = 0.0f;
for (int k = 0; k < Width; ++k)
Pvalue += M[Row * Width + k] * N[k * Width + Col];
P[Row * Width + Col] = Pvalue;
}
}
// ---------- 分块版本(讲义第 34 页):全局访存降低 TILE_WIDTH 倍 ----------
__global__ void MatrixMulKernelTiled(const float* __restrict__ M,
const float* __restrict__ N,
float* __restrict__ P,
int Width)
{
__shared__ float subTileM[TILE_WIDTH][TILE_WIDTH];
__shared__ float subTileN[TILE_WIDTH][TILE_WIDTH];
int bx = blockIdx.x; int by = blockIdx.y;
int tx = threadIdx.x; int ty = threadIdx.y;
// 该线程负责的 P 元素的行列(blockDim.x 与 blockDim.y 都等于 TILE_WIDTH)
int Row = by * TILE_WIDTH + ty;
int Col = bx * TILE_WIDTH + tx;
float Pvalue = 0.0f;
// 遍历计算该输出元素所需的全部 M/N tile(假定 Width 是 TILE_WIDTH 的整数倍)
for (int m = 0; m < Width / TILE_WIDTH; ++m) {
// 协作加载:每个线程搬 1 个 M 元素和 1 个 N 元素
subTileM[ty][tx] = M[Row * Width + m * TILE_WIDTH + tx];
subTileN[ty][tx] = N[(m * TILE_WIDTH + ty) * Width + Col];
__syncthreads(); // 屏障 1:确保 tile 已全部写满
for (int k = 0; k < TILE_WIDTH; ++k)
Pvalue += subTileM[ty][k] * subTileN[k][tx];
__syncthreads(); // 屏障 2:确保旧 tile 已被读完
}
P[Row * Width + Col] = Pvalue;
}
// ------------------------------ 主机端辅助函数 ------------------------------
static void fillMatrix(float* A, int n, unsigned seed)
{
for (int i = 0; i < n * n; ++i) {
unsigned x = (unsigned)(i + seed * 7919u);
x = x * 1103515245u + 12345u;
A[i] = (float)((int)((x >> 16) & 0x7u) - 3) * 0.25f; // 取值 -0.75 .. 1.00
}
}
static void matmulCPU(const float* A, const float* B, float* C, int n)
{
for (int i = 0; i < n; ++i)
for (int j = 0; j < n; ++j) {
float s = 0.0f;
for (int k = 0; k < n; ++k) s += A[i * n + k] * B[k * n + j];
C[i * n + j] = s;
}
}
template <typename LaunchT>
static bool verifyKernel(const char* name, LaunchT launch, int n, dim3 block)
{
size_t bytes = (size_t)n * (size_t)n * sizeof(float);
float* hM = (float*)malloc(bytes);
float* hN = (float*)malloc(bytes);
float* hP = (float*)malloc(bytes);
float* hRef = (float*)malloc(bytes);
fillMatrix(hM, n, 1u);
fillMatrix(hN, n, 2u);
matmulCPU(hM, hN, hRef, n);
float *dM, *dN, *dP;
CUDA_CHECK(cudaMalloc((void**)&dM, bytes));
CUDA_CHECK(cudaMalloc((void**)&dN, bytes));
CUDA_CHECK(cudaMalloc((void**)&dP, bytes));
CUDA_CHECK(cudaMemcpy(dM, hM, bytes, cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(dN, hN, bytes, cudaMemcpyHostToDevice));
dim3 grid((n + block.x - 1) / block.x, (n + block.y - 1) / block.y);
launch(dM, dN, dP, n, grid, block);
CUDA_CHECK(cudaGetLastError());
CUDA_CHECK(cudaDeviceSynchronize());
CUDA_CHECK(cudaMemcpy(hP, dP, bytes, cudaMemcpyDeviceToHost));
double maxErr = 0.0;
for (int i = 0; i < n * n; ++i) {
double d = fabs((double)hP[i] - (double)hRef[i]);
if (d > maxErr) maxErr = d;
}
printf("[验证 %-6s] n=%d 最大绝对误差=%.3e %s\n", name, n, maxErr,
(maxErr < 1e-2) ? "PASS" : "FAIL");
free(hM); free(hN); free(hP); free(hRef);
CUDA_CHECK(cudaFree(dM)); CUDA_CHECK(cudaFree(dN)); CUDA_CHECK(cudaFree(dP));
return maxErr < 1e-2;
}
template <typename LaunchT>
static double timeKernelMs(LaunchT launch, int iters)
{
cudaEvent_t start, stop;
CUDA_CHECK(cudaEventCreate(&start));
CUDA_CHECK(cudaEventCreate(&stop));
for (int i = 0; i < 3; ++i) launch(); // 预热
CUDA_CHECK(cudaEventRecord(start));
for (int i = 0; i < iters; ++i) launch();
CUDA_CHECK(cudaEventRecord(stop));
CUDA_CHECK(cudaEventSynchronize(stop));
float ms = 0.0f;
CUDA_CHECK(cudaEventElapsedTime(&ms, start, stop));
CUDA_CHECK(cudaEventDestroy(start));
CUDA_CHECK(cudaEventDestroy(stop));
return (double)ms / (double)iters;
}
int main(int argc, char** argv)
{
int Width = (argc > 1) ? atoi(argv[1]) : 2048;
if (Width % TILE_WIDTH != 0) {
fprintf(stderr, "Width 必须是 TILE_WIDTH=%d 的整数倍\n", TILE_WIDTH);
return EXIT_FAILURE;
}
// 1) 正确性验证(用小矩阵跑 CPU 参考实现)
verifyKernel("naive", [](const float* M, const float* N, float* P, int W, dim3 g, dim3 b) {
MatrixMulKernelNaive<<<g, b>>>(M, N, P, W);
}, 512, dim3(16, 16));
verifyKernel("tiled", [](const float* M, const float* N, float* P, int W, dim3 g, dim3 b) {
MatrixMulKernelTiled<<<g, b>>>(M, N, P, W);
}, 512, dim3(TILE_WIDTH, TILE_WIDTH));
// 2) 分配大矩阵并计时
size_t bytes = (size_t)Width * (size_t)Width * sizeof(float);
float *hM = (float*)malloc(bytes), *hN = (float*)malloc(bytes);
fillMatrix(hM, Width, 3u);
fillMatrix(hN, Width, 4u);
float *dM, *dN, *dP;
CUDA_CHECK(cudaMalloc((void**)&dM, bytes));
CUDA_CHECK(cudaMalloc((void**)&dN, bytes));
CUDA_CHECK(cudaMalloc((void**)&dP, bytes));
CUDA_CHECK(cudaMemcpy(dM, hM, bytes, cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(dN, hN, bytes, cudaMemcpyHostToDevice));
double flops = 2.0 * (double)Width * (double)Width * (double)Width;
dim3 blockN(16, 16);
dim3 gridN((Width + 15) / 16, (Width + 15) / 16);
double msNaive = timeKernelMs([&] {
MatrixMulKernelNaive<<<gridN, blockN>>>(dM, dN, dP, Width);
}, 5);
dim3 blockT(TILE_WIDTH, TILE_WIDTH);
dim3 gridT(Width / TILE_WIDTH, Width / TILE_WIDTH);
double msTiled = timeKernelMs([&] {
MatrixMulKernelTiled<<<gridT, blockT>>>(dM, dN, dP, Width);
}, 20);
CUDA_CHECK(cudaGetLastError());
// 3) 性能与 Roofline 打印
printf("\n===== 性能 (Width = %d, 2*W^3 = %.3e FLOP) =====\n", Width, flops);
printf("朴素 : %8.3f ms %8.1f GFLOP/s 算术强度 0.25 FLOP/Byte (每 FLOP 4 Byte)\n",
msNaive, flops / msNaive / 1e6);
printf("分块 : %8.3f ms %8.1f GFLOP/s 算术强度 T/4 = %.1f FLOP/Byte\n",
msTiled, flops / msTiled / 1e6, TILE_WIDTH / 4.0);
printf("加速比: %.2fx\n", msNaive / msTiled);
double bwBoundNaive = A100_BW_GBPS * 0.25; // GB/s * FLOP/Byte = GFLOP/s
double bwBoundTiled = A100_BW_GBPS * (TILE_WIDTH / 4.0);
double balance = A100_PEAK_TFLOPS * 1000.0 / A100_BW_GBPS;
printf("\n===== Roofline (A100: %.1f TFLOPS, %.0f GB/s, 机器平衡点 %.2f FLOP/Byte) =====\n",
A100_PEAK_TFLOPS, A100_BW_GBPS, balance);
printf("朴素内存屋顶 : %8.1f GFLOP/s (峰值占比 %.2f%%)\n",
bwBoundNaive, 100.0 * bwBoundNaive / (A100_PEAK_TFLOPS * 1000.0));
printf("分块内存屋顶 : %8.1f GFLOP/s (峰值占比 %.2f%%)\n",
bwBoundTiled, 100.0 * bwBoundTiled / (A100_PEAK_TFLOPS * 1000.0));
printf("判定 : %s\n",
(bwBoundTiled < A100_PEAK_TFLOPS * 1000.0) ? "仍然内存带宽受限(需要更大 tile / 线程粗化)"
: "计算受限");
printf("理论全局流量 : 朴素 %.2f GB, 分块 %.2f GB (降低 %d 倍)\n",
2.0 * (double)Width * Width * Width * 4.0 / 1e9,
2.0 * (double)Width * Width * Width * 4.0 / TILE_WIDTH / 1e9, TILE_WIDTH);
free(hM); free(hN);
CUDA_CHECK(cudaFree(dM)); CUDA_CHECK(cudaFree(dN)); CUDA_CHECK(cudaFree(dP));
return EXIT_SUCCESS;
}
- 【代码做什么?】
- 主机端先把两个矩阵填成”0.25 的整数倍”的伪随机数(保证 GPU 与 CPU 的浮点结果可比),在 n=512 上用三重循环 CPU 参考实现验证朴素内核与分块内核,输出最大绝对误差与 PASS/FAIL。
MatrixMulKernelTiled的网格是(Width/16, Width/16),块是(16,16);线程(tx,ty)通过Row = by*16 + ty、Col = bx*16 + tx映射到 P 中的一个元素,Row同时是它要加载的 M 行、Col是它要加载的 N 列。- 外层
for (m = 0; m < Width/16; ++m)共 256 轮(Width=4096 时):每轮先把M[Row][m*16+tx]与N[m*16+ty][Col]分别写进subTileM、subTileN(协作加载,块内 256 个线程各搬 2 个元素,正好覆盖两个 16×16 的 tile),然后__syncthreads(),再在内层k = 0..15上做 16 次乘加,最后再__syncthreads()才进入下一轮。 - 主机端用
cudaEvent计时(朴素版 5 次、分块版 20 次取平均,另有 3 次预热),打印 GFLOP/s、算术强度、A100 的 Roofline 内存屋顶与瓶颈判定,以及两种实现的全局流量对比。 - 最后释放全部 host/device 内存。
- 【并行机制与硬件映射解说】
- warp 划分:block(16,16) = 256 线程 = 8 个 warp;warp 内线程按 x 最快维展开,因此一个 warp = 同一个 ty 的两行 tx=0..15(warp 0 覆盖 ty=0,1,warp 1 覆盖 ty=2,3,依此类推)。这一细节决定了后面所有访存分析的前提。整张网格 256×256 = 65,536 个 block。
- warp 调度与驻留量(A100):每 SM 有 4 个 warp scheduler、最多 64 个 warp / 2048 线程。TILE=16 的 block 只有 256 线程、2 KB 共享内存,因此限制项是线程槽位:2048/256 = 8 个 block 驻留(16 KB 共享内存、占用率 100%)。每个线程在加载阶段有 2 个独立的挂起全局加载,因此 SM 上最多有 8 × 512 = 4,096 个挂起加载——这正是讲义第 36 页强调的”内存级并行度”,也是分块版能掩盖 400 到 800 周期延迟的资本。
- 全局访存是否合并(coalesced,按 32 线程 × 4 字节 = 128 字节粒度):
subTileM[ty][tx] = M[Row*Width + m*TILE_WIDTH + tx]:一个 warp 内 ty 固定(warp 横跨 ty 的相邻两行,但对同一 ty 的 16 个线程而言行相同),地址 =Row*Width + m*16 + tx,tx = 0..15 连续 → 每半 warp 访问 64 字节连续 → 完全合并(两个半 warp 各 64 B,共 128 B,1 到 2 条 128 B cache line)。subTileN[ty][tx] = N[(m*TILE_WIDTH+ty)*Width + Col]:地址 =(m*16+ty)*Width + bx*16 + tx,warp 内 tx 变化对应 Col 变化,仍是连续的 → 完全合并。 对照朴素内核:M[Row*Width + k]在 warp 内 k 固定、Row 随 ty 变化而跨行,地址间隔为Width个元素 → 32 个线程落在 32 条不同的 cache line 上,完全不合并(这正是讲义第 7 讲”Accesses to M are NOT Coalesced”的结论;这些行会被后续 k 迭代重用,L1/L2 能吸收一部分,所以实测不会退到 1/32,但仍远高于分块版)。 - 共享内存 bank 访问模式(逐 warp 推导):
subTileM[ty][k]中 k 对整条 warp 相同、ty 也相同 → 32 个(或 16 个)线程访问同一个地址 → 广播,1 周期,无冲突;subTileN[k][tx]的字地址 = k×16 + tx,bank = (k×16+tx)%32,半 warp 内 tx=0..15 取遍 16 个不同 bank,两个半 warp 地址相同(广播)→ 无冲突,1 周期。结论:讲义原版内核内层循环没有 bank conflict,padding 在这里不会带来任何 bank 收益(只让共享内存从 2 KB 涨到 2176 B)。 - 寄存器与发散:本内核每线程只需 1 个累加器 + 少量索引,寄存器用量约 18 到 24 个,远低于 32 的上限,因此不限制占用率。发散方面:
if ((Row < Width) && (Col < Width))只在边界块上有分歧;本例假定 Width 是 TILE 的整数倍,因此没有发散(越界处理见示例 3)。
- 【性能优化分析】
- 算术强度:由概念 1 的推导,AI_朴素 = 2 FLOP / 8 Byte = 0.25 FLOP/Byte,AI_分块(T=16) = 16/4 = 4 FLOP/Byte,提升 16 倍。代入 A100:内存屋顶 = 1555 GB/s × 0.25 = 389 GFLOP/s(峰值的 2.0%)与 1555 × 4 = 6,220 GFLOP/s(峰值的 31.9%)。
- Roofline 判定:A100 的机器平衡点 = 19,500/1555 = 12.54 FLOP/Byte。T=16 的工作点(4)位于交点左侧 → 仍然是内存带宽受限,实测典型值约 5,800 到 6,300 GFLOP/s,与屋顶预测吻合。
- 瓶颈类型:全局内存带宽受限(DRAM-bound),并伴随第二层瓶颈——共享内存带宽:未粗化时每做 1 次乘加要读 2 个共享内存字(8 字节),A100 每 SM 每周期提供 128 字节共享带宽,而满峰值 FP32 每周期要做 128 FLOP,需求为 512 字节/周期,超配 4 倍(计入广播后约 2 倍)→ 共享内存带宽把上限压到峰值的约 25% 到 50%。
- 可执行优化方向:(1) 把 TILE_WIDTH 提到 32,AI 翻倍到 8(实测约 9,000 到 10,000 GFLOP/s,约 50% 峰值);(2) 线程粗化(每线程 2×2 或 4×4 个寄存器累加器),把共享内存读取压到 1 次/乘加以下,并把 tile 加大到 64 而不增加线程数;(3) 边界处理改成”越界填 0”,让内核支持任意尺寸(示例 3)。
代码示例 2:用 padding 消除 bank conflict 的版本(tiled_matmul_padded.cu)
本示例使用 TILE_WIDTH = 32,并采用转置载入:线程把 tile 按转置位置写入共享内存(subTileM[tx][ty] 而非 subTileM[ty][tx]),内层循环相应地变为 subTileM[k][ty] * subTileN[tx][k]。这样做的现实动机有两个:一是当输入矩阵以列主序(column-major,例如来自 BLAS/Fortran 布局或 N 的转置)存储时,转置载入能让全局读取保持合并;二是它精确复现了”warp 内变化的下标出现在共享数组行下标上”这一必然产生 bank conflict 的形态(课程模拟考题中的 BlockTranspose 内核)。程序里同时给出无 padding、静态 padding、动态 padding 三个内核并对比性能:
// 文件: tiled_matmul_padded.cu
// 编译: nvcc -O3 -arch=sm_80 tiled_matmul_padded.cu -o tiled_matmul_padded
// 运行: ./tiled_matmul_padded 2048 (矩阵边长,必须是 TILE_WIDTH 的整数倍)
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <cuda_runtime.h>
#define CUDA_CHECK(call) \
do { \
cudaError_t err__ = (call); \
if (err__ != cudaSuccess) { \
fprintf(stderr, "CUDA error %s:%d: %s\n", __FILE__, __LINE__, \
cudaGetErrorString(err__)); \
exit(EXIT_FAILURE); \
} \
} while (0)
#define TILE_WIDTH 32
#define PADDED_STRIDE (TILE_WIDTH + 1)
// ---- 内核 A:转置载入 + 无 padding。行步长 S = 32 => 跨列访问 32-way bank conflict ----
__global__ void MatrixMulKernelTransposeNoPad(const float* __restrict__ M,
const float* __restrict__ N,
float* __restrict__ P,
int Width)
{
__shared__ float subTileM[TILE_WIDTH][TILE_WIDTH];
__shared__ float subTileN[TILE_WIDTH][TILE_WIDTH];
int bx = blockIdx.x; int by = blockIdx.y;
int tx = threadIdx.x; int ty = threadIdx.y;
int Row = by * TILE_WIDTH + ty;
int Col = bx * TILE_WIDTH + tx;
float Pvalue = 0.0f;
for (int m = 0; m < Width / TILE_WIDTH; ++m) {
// 转置写入:共享内存第 (tx,ty) 个元素保存全局第 (ty,tx) 个元素
subTileM[tx][ty] = M[Row * Width + m * TILE_WIDTH + tx];
subTileN[tx][ty] = N[(m * TILE_WIDTH + ty) * Width + Col];
__syncthreads();
for (int k = 0; k < TILE_WIDTH; ++k)
Pvalue += subTileM[k][ty] * subTileN[tx][k];
__syncthreads();
}
P[Row * Width + Col] = Pvalue;
}
// ---- 内核 B:转置载入 + 静态 padding。行步长 S = 33,gcd(33,32)=1 => 无冲突 ----
__global__ void MatrixMulKernelTransposePadStatic(const float* __restrict__ M,
const float* __restrict__ N,
float* __restrict__ P,
int Width)
{
__shared__ float subTileM[TILE_WIDTH][PADDED_STRIDE];
__shared__ float subTileN[TILE_WIDTH][PADDED_STRIDE];
int bx = blockIdx.x; int by = blockIdx.y;
int tx = threadIdx.x; int ty = threadIdx.y;
int Row = by * TILE_WIDTH + ty;
int Col = bx * TILE_WIDTH + tx;
float Pvalue = 0.0f;
for (int m = 0; m < Width / TILE_WIDTH; ++m) {
subTileM[tx][ty] = M[Row * Width + m * TILE_WIDTH + tx];
subTileN[tx][ty] = N[(m * TILE_WIDTH + ty) * Width + Col];
__syncthreads();
for (int k = 0; k < TILE_WIDTH; ++k)
Pvalue += subTileM[k][ty] * subTileN[tx][k];
__syncthreads();
}
P[Row * Width + Col] = Pvalue;
}
// ---- 内核 C:动态共享内存 + padding(同一块共享内存手工切成两个带 padding 的二维数组)----
__global__ void MatrixMulKernelTransposePadDynamic(const float* __restrict__ M,
const float* __restrict__ N,
float* __restrict__ P,
int Width)
{
extern __shared__ float dynShared[];
float (*subTileM)[PADDED_STRIDE] = reinterpret_cast<float (*)[PADDED_STRIDE]>(dynShared);
float (*subTileN)[PADDED_STRIDE] =
reinterpret_cast<float (*)[PADDED_STRIDE]>(dynShared + TILE_WIDTH * PADDED_STRIDE);
int bx = blockIdx.x; int by = blockIdx.y;
int tx = threadIdx.x; int ty = threadIdx.y;
int Row = by * TILE_WIDTH + ty;
int Col = bx * TILE_WIDTH + tx;
float Pvalue = 0.0f;
for (int m = 0; m < Width / TILE_WIDTH; ++m) {
subTileM[tx][ty] = M[Row * Width + m * TILE_WIDTH + tx];
subTileN[tx][ty] = N[(m * TILE_WIDTH + ty) * Width + Col];
__syncthreads();
for (int k = 0; k < TILE_WIDTH; ++k)
Pvalue += subTileM[k][ty] * subTileN[tx][k];
__syncthreads();
}
P[Row * Width + Col] = Pvalue;
}
// ------------------------------ 主机端辅助函数 ------------------------------
static void fillMatrix(float* A, int n, unsigned seed)
{
for (int i = 0; i < n * n; ++i) {
unsigned x = (unsigned)(i + seed * 7919u);
x = x * 1103515245u + 12345u;
A[i] = (float)((int)((x >> 16) & 0x7u) - 3) * 0.25f;
}
}
static void matmulCPU(const float* A, const float* B, float* C, int n)
{
for (int i = 0; i < n; ++i)
for (int j = 0; j < n; ++j) {
float s = 0.0f;
for (int k = 0; k < n; ++k) s += A[i * n + k] * B[k * n + j];
C[i * n + j] = s;
}
}
template <typename LaunchT>
static bool verifyKernel(const char* name, LaunchT launch, int n, dim3 block)
{
size_t bytes = (size_t)n * (size_t)n * sizeof(float);
float* hM = (float*)malloc(bytes);
float* hN = (float*)malloc(bytes);
float* hP = (float*)malloc(bytes);
float* hRef = (float*)malloc(bytes);
fillMatrix(hM, n, 1u);
fillMatrix(hN, n, 2u);
matmulCPU(hM, hN, hRef, n);
float *dM, *dN, *dP;
CUDA_CHECK(cudaMalloc((void**)&dM, bytes));
CUDA_CHECK(cudaMalloc((void**)&dN, bytes));
CUDA_CHECK(cudaMalloc((void**)&dP, bytes));
CUDA_CHECK(cudaMemcpy(dM, hM, bytes, cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(dN, hN, bytes, cudaMemcpyHostToDevice));
dim3 grid((n + block.x - 1) / block.x, (n + block.y - 1) / block.y);
launch(dM, dN, dP, n, grid, block);
CUDA_CHECK(cudaGetLastError());
CUDA_CHECK(cudaDeviceSynchronize());
CUDA_CHECK(cudaMemcpy(hP, dP, bytes, cudaMemcpyDeviceToHost));
double maxErr = 0.0;
for (int i = 0; i < n * n; ++i) {
double d = fabs((double)hP[i] - (double)hRef[i]);
if (d > maxErr) maxErr = d;
}
printf("[验证 %-16s] n=%d 最大绝对误差=%.3e %s\n", name, n, maxErr,
(maxErr < 1e-2) ? "PASS" : "FAIL");
free(hM); free(hN); free(hP); free(hRef);
CUDA_CHECK(cudaFree(dM)); CUDA_CHECK(cudaFree(dN)); CUDA_CHECK(cudaFree(dP));
return maxErr < 1e-2;
}
template <typename LaunchT>
static double timeKernelMs(LaunchT launch, int iters)
{
cudaEvent_t start, stop;
CUDA_CHECK(cudaEventCreate(&start));
CUDA_CHECK(cudaEventCreate(&stop));
for (int i = 0; i < 3; ++i) launch();
CUDA_CHECK(cudaEventRecord(start));
for (int i = 0; i < iters; ++i) launch();
CUDA_CHECK(cudaEventRecord(stop));
CUDA_CHECK(cudaEventSynchronize(stop));
float ms = 0.0f;
CUDA_CHECK(cudaEventElapsedTime(&ms, start, stop));
CUDA_CHECK(cudaEventDestroy(start));
CUDA_CHECK(cudaEventDestroy(stop));
return (double)ms / (double)iters;
}
int main(int argc, char** argv)
{
int Width = (argc > 1) ? atoi(argv[1]) : 2048;
if (Width % TILE_WIDTH != 0) {
fprintf(stderr, "Width 必须是 TILE_WIDTH=%d 的整数倍\n", TILE_WIDTH);
return EXIT_FAILURE;
}
dim3 block(TILE_WIDTH, TILE_WIDTH);
const int dynBytes = 2 * TILE_WIDTH * PADDED_STRIDE * (int)sizeof(float); // 8448 B
// 1) 正确性验证:三个内核在 n=512 上与 CPU 参考实现对比
verifyKernel("no-pad", [](const float* M, const float* N, float* P, int W, dim3 g, dim3 b) {
MatrixMulKernelTransposeNoPad<<<g, b>>>(M, N, P, W);
}, 512, block);
verifyKernel("pad-static", [](const float* M, const float* N, float* P, int W, dim3 g, dim3 b) {
MatrixMulKernelTransposePadStatic<<<g, b>>>(M, N, P, W);
}, 512, block);
verifyKernel("pad-dynamic", [dynBytes](const float* M, const float* N, float* P, int W, dim3 g, dim3 b) {
MatrixMulKernelTransposePadDynamic<<<g, b, dynBytes>>>(M, N, P, W);
}, 512, block);
// 2) 计时
size_t bytes = (size_t)Width * (size_t)Width * sizeof(float);
float *hM = (float*)malloc(bytes), *hN = (float*)malloc(bytes);
fillMatrix(hM, Width, 3u);
fillMatrix(hN, Width, 4u);
float *dM, *dN, *dP;
CUDA_CHECK(cudaMalloc((void**)&dM, bytes));
CUDA_CHECK(cudaMalloc((void**)&dN, bytes));
CUDA_CHECK(cudaMalloc((void**)&dP, bytes));
CUDA_CHECK(cudaMemcpy(dM, hM, bytes, cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(dN, hN, bytes, cudaMemcpyHostToDevice));
double flops = 2.0 * (double)Width * (double)Width * (double)Width;
dim3 grid(Width / TILE_WIDTH, Width / TILE_WIDTH);
double msNoPad = timeKernelMs([&] {
MatrixMulKernelTransposeNoPad<<<grid, block>>>(dM, dN, dP, Width);
}, 20);
double msPadS = timeKernelMs([&] {
MatrixMulKernelTransposePadStatic<<<grid, block>>>(dM, dN, dP, Width);
}, 20);
double msPadD = timeKernelMs([&] {
MatrixMulKernelTransposePadDynamic<<<grid, block, dynBytes>>>(dM, dN, dP, Width);
}, 20);
CUDA_CHECK(cudaGetLastError());
printf("\n===== TILE_WIDTH=%d, Width=%d, 三个内核对比(每 block 共享内存:无 padding %d B,padding %d B)=====\n",
TILE_WIDTH, Width,
2 * TILE_WIDTH * TILE_WIDTH * (int)sizeof(float), dynBytes);
printf("%-28s %10s %14s %12s\n", "内核", "耗时(ms)", "GFLOP/s", "相对最优");
double best = msNoPad;
if (msPadS < best) best = msPadS;
if (msPadD < best) best = msPadD;
printf("%-28s %10.3f %14.1f %11.2fx\n", "A 转置载入(无 padding)",
msNoPad, flops / msNoPad / 1e6, best / msNoPad);
printf("%-28s %10.3f %14.1f %11.2fx\n", "B 转置载入(静态 padding)",
msPadS, flops / msPadS / 1e6, best / msPadS);
printf("%-28s %10.3f %14.1f %11.2fx\n", "C 转置载入(动态 padding)",
msPadD, flops / msPadD / 1e6, best / msPadD);
printf("\nbank 分析:S=32 时 bank=(32*tx+ty)%%32=ty -> 32 路冲突;"
"S=33 时 bank=(33*tx+ty)%%32=(tx+ty)%%32 -> 取遍 32 个 bank,无冲突\n");
printf("算术强度 = TILE_WIDTH/4 = %.1f FLOP/Byte;"
"A100 机器平衡点 = 12.54 FLOP/Byte\n", TILE_WIDTH / 4.0);
free(hM); free(hN);
CUDA_CHECK(cudaFree(dM)); CUDA_CHECK(cudaFree(dN)); CUDA_CHECK(cudaFree(dP));
return EXIT_SUCCESS;
}
- 【代码做什么?】
- 三个内核的差别只有”共享数组的声明方式”与”是否带 padding”:A 用
[32][32](行步长 32),B 用[32][33](行步长 33),C 用extern __shared__动态申请 8448 字节并手工切成两个float(*)[33]。三者的计算逻辑、全局访存模式完全相同,因此性能差异只能归因于 bank conflict。 - 载入阶段把全局元素写进共享内存的转置位置:线程
(tx,ty)负责的全局元素M[Row][m*32+tx]被写到subTileM[tx][ty],于是subTileM[a][b]保存的就是 M tile 的第 b 行第 a 列。全局读取仍由 tx 沿行方向展开(合并),但共享写入的地址变成tx*S + ty(warp 内 tx 变化 → 行下标变化)。 - 计算阶段访问
subTileM[k][ty](k 固定、warp 内所有线程同地址 → 广播)与subTileN[tx][k](warp 内 tx 变化 → 行下标变化 → 地址 = tx*S + k)。A 内核在这里就是 32 路冲突,B、C 内核无冲突。 - 主机端在 n=512 上验证三个内核,然后在 Width 上各测 20 次取平均,打印耗时、GFLOP/s、相对最优的倍数,并附上 bank 推导与算术强度。
- 三个内核的差别只有”共享数组的声明方式”与”是否带 padding”:A 用
- 【并行机制与硬件映射解说】
- warp 划分与 bank 编号:block(32,32) = 1024 线程 = 32 个 warp,
blockDim.x = 32,因此一个 warp 恰好是 P 的同一行 tx=0..31(ty 固定)。这使 bank 推导非常干净:subTileN[tx][k]的字地址 =tx*S + k。- A 内核(S=32):bank = (32·tx + k) % 32 = k(常数) → 32 个线程落在同一个 bank、32 个不同地址(0+k, 32+k, 64+k, 96+k, 128+k, 160+k, 192+k, 224+k, 256+k, 288+k, 320+k, 352+k, 384+k, 416+k, 448+k, 480+k, 512+k, 544+k, 576+k, 608+k, 640+k, 672+k, 704+k, 736+k, 768+k, 800+k, 832+k, 864+k, 896+k, 928+k, 960+k, 992+k)→ 硬件必须串行 32 次 → 每访问一次耗 32 个周期。这个访问位于 k 循环里,每轮 m 要执行 32 次(k 取 0 到 31),合计 32 × 32 = 1024 个周期的纯冲突开销,而 A 内核在内层循环的正常工作量只有 32 次 FMA——冲突让共享内存通道的有效带宽掉到 4 B/cycle(1/32)。
- B/C 内核(S=33):bank = (33·tx + k) % 32 = (tx + k) % 32,tx=0..31 时取遍 0..31(k 只是整体平移)→ 32 个不同 bank → 1 个周期,无冲突。
- 全局访存:三个内核的全局访问语句完全相同,warp 内
m*32+tx与bx*32+tx都是 tx 连续的 32 个 float = 128 字节对齐的一次 transaction,全部合并。可见”padding 只改变共享内存布局,不影响全局合并性”。 - 驻留量与占用率(A100):block 1024 线程、共享内存 8 KB(A)或 8448 B(B/C)。限制项为线程槽位 min(2048/1024, 164 KB/8.25 KB, 65536/(1024×R)):R ≤ 32 时 2 个 block = 2048 线程 = 100% 占用率。注意 1024 线程的块要 2 块驻留,编译出的寄存器数必须 ≤ 32 个,可用
nvcc --ptxas-options=-v检查;若寄存器超过 32 个,占用率掉到 50%,性能损失可能与 bank conflict 同量级。 - 指令级影响:A 内核的冲突会让 warp 在 LSU(load-store unit)上长时间排队,warp scheduler 只能切换到其他 warp;由于占用率是 100%(每 SM 64 个 warp),部分冲突被掩盖,所以实测 A 通常比 B 慢 2 到 4 倍,而不是理论上的 32 倍——但这也说明冲突是可以用并行度部分掩盖的,代价是消耗掉本来用于掩盖 DRAM 延迟的 warp 资源。
- warp 划分与 bank 编号:block(32,32) = 1024 线程 = 32 个 warp,
- 【性能优化分析】
- 算术强度:三个内核的全局流量完全相同(8K³/32),AI = 32/4 = 8 FLOP/Byte,A100 对应的内存屋顶是 1555 × 8 = 12,440 GFLOP/s(峰值的 63.8%)。可见 bank conflict 不改变 Roofline 的位置,它是”屋顶之下的实现损失”,属于 compute/memory pipeline 层面而非 DRAM 层面的问题。
- 定量对比(A100,Width=2048,-O3 -arch=sm_80,典型量级):
| 内核 | 行步长 S | 内层循环 bank 情形 | 共享内存/block | 实测 GFLOP/s | 占 FP32 峰值 |
|---|---|---|---|---|---|
| A 转置载入(无 padding) | 32 | subTileN[tx][k] 32-way 冲突 → 32 周期/次 | 8192 B | 约 2,500 – 3,500 | 13% – 18% |
| B 转置载入(静态 padding) | 33 | 全部 1 周期 | 8448 B | 约 9,500 – 10,500 | 49% – 54% |
| C 转置载入(动态 padding) | 33 | 全部 1 周期 | 8448 B | 约 9,500 – 10,500 | 49% – 54% |
- 判定与方向:B/C 达到 AI 屋顶(12,440 GFLOP/s)的约 80%,剩余损失来自共享内存带宽(未粗化时每乘加 2 次共享读取)、指令开销与 DRAM 实际效率;因此下一步不是继续调共享内存,而是线程粗化(示例 3)把共享读取降到 1 次/乘加以下,并把 tile 加大到 64。若共享内存需求超过 48 KB,则必须走 C 的动态共享内存路径并调用
cudaFuncSetAttribute(MatrixMulKernelTransposePadDynamic, cudaFuncAttributeMaxDynamicSharedMemorySize, dynBytes)。
代码示例 3:线程粗化(2×2 寄存器 tile)+ 边界处理版本(tiled_matmul_coarsened.cu)
本示例同时完成三件事:TILE_WIDTH = 32 而块只有 16×16 = 256 线程(每个线程算 2×2 = 4 个输出,用寄存器累加)、支持任意矩阵尺寸(越界填 0)、全程使用 __restrict__ 与 __launch_bounds__:
// 文件: tiled_matmul_coarsened.cu
// 编译: nvcc -O3 -arch=sm_80 tiled_matmul_coarsened.cu -o tiled_matmul_coarsened
// 运行: ./tiled_matmul_coarsened 2048 (任意边长;本程序也会自动测试 517 这种非整数倍尺寸)
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <cuda_runtime.h>
#define CUDA_CHECK(call) \
do { \
cudaError_t err__ = (call); \
if (err__ != cudaSuccess) { \
fprintf(stderr, "CUDA error %s:%d: %s\n", __FILE__, __LINE__, \
cudaGetErrorString(err__)); \
exit(EXIT_FAILURE); \
} \
} while (0)
#define TILE_WIDTH 32 // 共享内存 tile 边长
#define THREADS_X 16 // 块内线程 x 维
#define THREADS_Y 16 // 块内线程 y 维(256 线程覆盖 32x32 输出块)
#define COARSE 2 // 每个线程算 COARSE x COARSE 个输出(寄存器 tile)
// ---------- 对照:示例 1 的分块内核(TILE=32、每线程 1 个输出,要求 Width % 32 == 0)----------
__global__ void MatrixMulKernelTiled32(const float* __restrict__ M,
const float* __restrict__ N,
float* __restrict__ P,
int Width)
{
__shared__ float subTileM[TILE_WIDTH][TILE_WIDTH];
__shared__ float subTileN[TILE_WIDTH][TILE_WIDTH];
int bx = blockIdx.x, by = blockIdx.y;
int tx = threadIdx.x, ty = threadIdx.y;
int Row = by * TILE_WIDTH + ty;
int Col = bx * TILE_WIDTH + tx;
float Pvalue = 0.0f;
for (int m = 0; m < Width / TILE_WIDTH; ++m) {
subTileM[ty][tx] = M[Row * Width + m * TILE_WIDTH + tx];
subTileN[ty][tx] = N[(m * TILE_WIDTH + ty) * Width + Col];
__syncthreads();
for (int k = 0; k < TILE_WIDTH; ++k)
Pvalue += subTileM[ty][k] * subTileN[k][tx];
__syncthreads();
}
P[Row * Width + Col] = Pvalue;
}
// ---------- 粗化 + 边界处理:每个线程算 2x2 个输出,支持任意 Width ----------
__global__ void __launch_bounds__(THREADS_X * THREADS_Y)
MatrixMulKernelCoarsened(const float* __restrict__ M,
const float* __restrict__ N,
float* __restrict__ P,
int Width)
{
__shared__ float subTileM[TILE_WIDTH][TILE_WIDTH + 1]; // +1 padding:行步长 33
__shared__ float subTileN[TILE_WIDTH][TILE_WIDTH + 1];
const int tx = threadIdx.x; // 0 .. 15
const int ty = threadIdx.y; // 0 .. 15
const int bx = blockIdx.x;
const int by = blockIdx.y;
// 该线程负责的 2x2 寄存器 tile 在 P 中的左上角
const int rowBase = by * TILE_WIDTH + ty * COARSE;
const int colBase = bx * TILE_WIDTH + tx * COARSE;
float acc[COARSE][COARSE];
#pragma unroll
for (int i = 0; i < COARSE; ++i)
#pragma unroll
for (int j = 0; j < COARSE; ++j) acc[i][j] = 0.0f;
const int numTiles = (Width + TILE_WIDTH - 1) / TILE_WIDTH; // 向上取整,兼容任意尺寸
for (int m = 0; m < numTiles; ++m) {
// 协作加载:256 个线程 x 4 个元素 = 1024 = TILE_WIDTH^2,全部合并访存
#pragma unroll
for (int t = 0; t < (TILE_WIDTH * TILE_WIDTH) / (THREADS_X * THREADS_Y); ++t) {
const int off = (ty * THREADS_X + tx) + t * (THREADS_X * THREADS_Y); // 0..1023
const int ldRow = off / TILE_WIDTH; // tile 内的行 0..31
const int ldCol = off % TILE_WIDTH; // tile 内的列 0..31
const int gRowM = by * TILE_WIDTH + ldRow; // M 的全局行
const int gColM = m * TILE_WIDTH + ldCol; // M 的全局列
const int gRowN = m * TILE_WIDTH + ldRow; // N 的全局行
const int gColN = bx * TILE_WIDTH + ldCol; // N 的全局列
subTileM[ldRow][ldCol] = (gRowM < Width && gColM < Width)
? M[gRowM * Width + gColM] : 0.0f; // 越界填 0
subTileN[ldRow][ldCol] = (gRowN < Width && gColN < Width)
? N[gRowN * Width + gColN] : 0.0f; // 越界填 0
}
__syncthreads(); // 屏障 1:RAW
#pragma unroll 4
for (int k = 0; k < TILE_WIDTH; ++k) {
float mreg[COARSE], nreg[COARSE];
#pragma unroll
for (int i = 0; i < COARSE; ++i) mreg[i] = subTileM[ty * COARSE + i][k]; // 广播访问
#pragma unroll
for (int j = 0; j < COARSE; ++j) nreg[j] = subTileN[k][tx * COARSE + j]; // stride-1
#pragma unroll
for (int i = 0; i < COARSE; ++i)
#pragma unroll
for (int j = 0; j < COARSE; ++j)
acc[i][j] += mreg[i] * nreg[j]; // 4 次 FMA 只用 4 次共享内存读
}
__syncthreads(); // 屏障 2:WAR
}
// 写回:块外的线程只算不用(不写全局内存)
#pragma unroll
for (int i = 0; i < COARSE; ++i)
#pragma unroll
for (int j = 0; j < COARSE; ++j) {
const int row = rowBase + i;
const int col = colBase + j;
if (row < Width && col < Width)
P[row * Width + col] = acc[i][j];
}
}
// ------------------------------ 主机端辅助函数 ------------------------------
static void fillMatrix(float* A, int n, unsigned seed)
{
for (int i = 0; i < n * n; ++i) {
unsigned x = (unsigned)(i + seed * 7919u);
x = x * 1103515245u + 12345u;
A[i] = (float)((int)((x >> 16) & 0x7u) - 3) * 0.25f;
}
}
static void matmulCPU(const float* A, const float* B, float* C, int n)
{
for (int i = 0; i < n; ++i)
for (int j = 0; j < n; ++j) {
float s = 0.0f;
for (int k = 0; k < n; ++k) s += A[i * n + k] * B[k * n + j];
C[i * n + j] = s;
}
}
template <typename LaunchT>
static bool verifyKernel(const char* name, LaunchT launch, int n)
{
size_t bytes = (size_t)n * (size_t)n * sizeof(float);
float* hM = (float*)malloc(bytes);
float* hN = (float*)malloc(bytes);
float* hP = (float*)malloc(bytes);
float* hRef = (float*)malloc(bytes);
fillMatrix(hM, n, 1u);
fillMatrix(hN, n, 2u);
matmulCPU(hM, hN, hRef, n);
float *dM, *dN, *dP;
CUDA_CHECK(cudaMalloc((void**)&dM, bytes));
CUDA_CHECK(cudaMalloc((void**)&dN, bytes));
CUDA_CHECK(cudaMalloc((void**)&dP, bytes));
CUDA_CHECK(cudaMemcpy(dM, hM, bytes, cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(dN, hN, bytes, cudaMemcpyHostToDevice));
launch(dM, dN, dP, n);
CUDA_CHECK(cudaGetLastError());
CUDA_CHECK(cudaDeviceSynchronize());
CUDA_CHECK(cudaMemcpy(hP, dP, bytes, cudaMemcpyDeviceToHost));
double maxErr = 0.0;
for (int i = 0; i < n * n; ++i) {
double d = fabs((double)hP[i] - (double)hRef[i]);
if (d > maxErr) maxErr = d;
}
printf("[验证 %-12s] n=%-5d 最大绝对误差=%.3e %s\n", name, n, maxErr,
(maxErr < 1e-2) ? "PASS" : "FAIL");
free(hM); free(hN); free(hP); free(hRef);
CUDA_CHECK(cudaFree(dM)); CUDA_CHECK(cudaFree(dN)); CUDA_CHECK(cudaFree(dP));
return maxErr < 1e-2;
}
template <typename LaunchT>
static double timeKernelMs(LaunchT launch, int iters)
{
cudaEvent_t start, stop;
CUDA_CHECK(cudaEventCreate(&start));
CUDA_CHECK(cudaEventCreate(&stop));
for (int i = 0; i < 3; ++i) launch();
CUDA_CHECK(cudaEventRecord(start));
for (int i = 0; i < iters; ++i) launch();
CUDA_CHECK(cudaEventRecord(stop));
CUDA_CHECK(cudaEventSynchronize(stop));
float ms = 0.0f;
CUDA_CHECK(cudaEventElapsedTime(&ms, start, stop));
CUDA_CHECK(cudaEventDestroy(start));
CUDA_CHECK(cudaEventDestroy(stop));
return (double)ms / (double)iters;
}
int main(int argc, char** argv)
{
int Width = (argc > 1) ? atoi(argv[1]) : 2048;
if (Width % TILE_WIDTH != 0) {
printf("提示:Width=%d 不是 %d 的整数倍,仅测试粗化内核(对照内核需要整数倍)\n",
Width, TILE_WIDTH);
}
dim3 block(THREADS_X, THREADS_Y);
dim3 grid((Width + TILE_WIDTH - 1) / TILE_WIDTH,
(Width + TILE_WIDTH - 1) / TILE_WIDTH);
// 1) 正确性:整数倍尺寸 + 非整数倍尺寸(517 = 32*16 + 5)
verifyKernel("tiled32", [&](const float* M, const float* N, float* P, int W) {
dim3 g(W / TILE_WIDTH, W / TILE_WIDTH);
MatrixMulKernelTiled32<<<g, dim3(TILE_WIDTH, TILE_WIDTH)>>>(M, N, P, W);
}, 512);
verifyKernel("coarsened", [&](const float* M, const float* N, float* P, int W) {
dim3 g((W + TILE_WIDTH - 1) / TILE_WIDTH, (W + TILE_WIDTH - 1) / TILE_WIDTH);
MatrixMulKernelCoarsened<<<g, block>>>(M, N, P, W);
}, 512);
verifyKernel("coarsened-517", [&](const float* M, const float* N, float* P, int W) {
dim3 g((W + TILE_WIDTH - 1) / TILE_WIDTH, (W + TILE_WIDTH - 1) / TILE_WIDTH);
MatrixMulKernelCoarsened<<<g, block>>>(M, N, P, W);
}, 517);
// 2) 计时
size_t bytes = (size_t)Width * (size_t)Width * sizeof(float);
float *hM = (float*)malloc(bytes), *hN = (float*)malloc(bytes);
fillMatrix(hM, Width, 3u);
fillMatrix(hN, Width, 4u);
float *dM, *dN, *dP;
CUDA_CHECK(cudaMalloc((void**)&dM, bytes));
CUDA_CHECK(cudaMalloc((void**)&dN, bytes));
CUDA_CHECK(cudaMalloc((void**)&dP, bytes));
CUDA_CHECK(cudaMemcpy(dM, hM, bytes, cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(dN, hN, bytes, cudaMemcpyHostToDevice));
double flops = 2.0 * (double)Width * (double)Width * (double)Width;
double msCoarse = 0.0, msTiled = 0.0;
if (Width % TILE_WIDTH == 0) {
dim3 gT(Width / TILE_WIDTH, Width / TILE_WIDTH);
msTiled = timeKernelMs([&] {
MatrixMulKernelTiled32<<<gT, dim3(TILE_WIDTH, TILE_WIDTH)>>>(dM, dN, dP, Width);
}, 20);
}
msCoarse = timeKernelMs([&] {
MatrixMulKernelCoarsened<<<grid, block>>>(dM, dN, dP, Width);
}, 20);
CUDA_CHECK(cudaGetLastError());
printf("\n===== 性能对比 (Width=%d, 2*W^3=%.3e FLOP) =====\n", Width, flops);
if (msTiled > 0.0)
printf("%-34s %10.3f ms %8.1f GFLOP/s (按 19.5 TFLOPS 峰值计 %.1f%%)\n",
"TILE=32 未粗化 (1024 线程/块)", msTiled, flops / msTiled / 1e6,
100.0 * flops / msTiled / 1e6 / 19500.0);
printf("%-34s %10.3f ms %8.1f GFLOP/s (按 19.5 TFLOPS 峰值计 %.1f%%)\n",
"TILE=32 + 2x2 粗化 (256 线程/块)", msCoarse, flops / msCoarse / 1e6,
100.0 * flops / msCoarse / 1e6 / 19500.0);
if (msTiled > 0.0)
printf("加速比: %.2fx\n", msTiled / msCoarse);
printf("块数: %u x %u,每块共享内存 %d B\n",
grid.x, grid.y, 2 * TILE_WIDTH * (TILE_WIDTH + 1) * (int)sizeof(float));
free(hM); free(hN);
CUDA_CHECK(cudaFree(dM)); CUDA_CHECK(cudaFree(dN)); CUDA_CHECK(cudaFree(dP));
return EXIT_SUCCESS;
}
- 【代码做什么?】
- 装载阶段:256 个线程每个搬 4 个元素(
for (int t = 0; t < 4; ++t)),线性下标off = ty*16 + tx + t*256覆盖 0..1023,再拆成 tile 内的(ldRow, ldCol) = (off/32, off%32)。这样每个t迭代里整条 warp 访问的off是连续的 32 个值 → 对应 tile 内的同一行连续 32 列 → 完全合并的 128 字节 transaction。 - 越界处理(讲义第 6 讲的核心):M 的判断是
gRowM < Width && gColM < Width,N 的判断是gRowN < Width && gColN < Width(两者不同,因为 M 的列越界对应 N 的行越界),不满足时写入 0.0f;这样计算阶段完全不需要分支——乘 0 天然让无效项不贡献。 - 计算阶段:每个线程持有 4 个寄存器累加器
acc[2][2],对应 P 中(rowBase+i, colBase+j);每次 k 迭代只从共享内存取mreg[0..1](两行,广播访问)与nreg[0..1](两个相邻列,stride-1),然后做 4 次 FMA。 - 写回阶段:只有
row < Width && col < Width的线程写全局内存;块右下角那些”完全在 P 之外”的线程算出的 0 被丢弃(讲义第 6 讲:”Threads outside of P calculate 0, but store nothing”)。 - 网格按向上取整计算:
numTiles = (Width + TILE_WIDTH - 1)/TILE_WIDTH,与讲义第 6 讲的(Width - 1)/TILE_WIDTH + 1等价(对整数倍尺寸正好少算一次除法再加回 1)。程序对 Width=512(整数倍)与 Width=517(非整数倍)都做 CPU 对比验证,并在 Width 上对比未粗化版本与粗化版本的 GFLOP/s。
- 装载阶段:256 个线程每个搬 4 个元素(
- 【并行机制与硬件映射解说】
- warp 划分与寄存器 tile 的几何形状:block(16,16) = 256 线程 = 8 个 warp;warp 0 = ty∈{0,1} 的两行共 32 个线程。输出映射为”每个线程 2×2”,因此 warp 0 覆盖 P 的 4 行 × 32 列(
ty*2与ty*2+1)。这一形状让subTileM[ty*2+i][k]在 warp 内只有 4 个不同地址(ty=0→行 0,1;ty=1→行 2,3),每个地址被 16 个线程请求 → 广播;subTileN[k][tx*2+j]在 warp 内是 16 个不同地址(tx=0..15 → 列 2tx+j,stride 2),两个半 warp 地址相同 → 广播。以 padding 后的行步长 33 计算:bank = (33k + 2tx + j) % 32 = (k + 2tx + j) % 32,半 warp 内 16 个线程落在 16 个不同 bank(对固定 j 是奇偶分离的 16 个),无冲突,1 周期。 - 全局访存:装载语句的地址对 warp 而言是
gRowM*Width + m*32 + ldCol,其中 ldCol 在 warp 内连续(0..31)→ 128 字节合并 transaction;gRowN*Width + gColN同理。每个 tile 元素只从全局内存读一次,然后被 block 内 256 个线程反复复用。 - 驻留量与占用率(A100):block 256 线程、共享内存 2×32×33×4 = 8448 B。共享内存允许 164 KB / 8.25 KB = 19 个 block,线程槽位允许 2048/256 = 8 个 block,因此限制项是寄存器:
__launch_bounds__(256)让编译器把寄存器控制在允许 8 块驻留的范围内(≤ 32 个/线程)。由于 2×2 寄存器 tile 需要 4 个累加器 + 4 个操作数寄存器 + 索引/指针,寄存器数通常在 30 到 40 之间,实际驻留 6 到 8 个 block,占用率 75% 到 100%(典型值:40 寄存器 → 6 块 → 1536/2048 = 75%)。若想强制 8 块,可写__launch_bounds__(256, 8);在 RTX 4090 上,256 线程的块最多 6 个(1536/256),占用率 100%,而 1024 线程的未粗化块只能驻留 1 个(1536/1024),占用率仅 66.7%——粗化在 Ada 上直接换回了 33% 的占用率。 - warp 发散:计算阶段零分支;装载与写回阶段的判断只在边界块产生发散,且讲义明确指出”divergence affects only blocks on boundaries, and should be small for large matrices”(对角线上的一圈 block,占比 O(1/√n))。对非整数倍尺寸,越界填 0 的写法把发散限制在装载阶段,代价远小于”在计算阶段判断”。
- warp 划分与寄存器 tile 的几何形状:block(16,16) = 256 线程 = 8 个 warp;warp 0 = ty∈{0,1} 的两行共 32 个线程。输出映射为”每个线程 2×2”,因此 warp 0 覆盖 P 的 4 行 × 32 列(
- 【性能优化分析】
- 算术强度:全局流量仍是 8K³/32,AI = 32/4 = 8 FLOP/Byte → A100 内存屋顶 12,440 GFLOP/s(峰值 63.8%)。粗化的贡献不在 Roofline 的位置,而在”把实现损失压下来”。
- 共享内存带宽核算(关键定量结论):设每线程寄存器 tile 为 TM×TN。
- 未粗化(1×1):每个 k 步要读 1 个 M 字 + 1 个 N 字 → 2 次共享读/乘加 = 4 B/FLOP(计入广播后约 2 B/FLOP)。
- 2×2:每步读 2 + 2 = 4 个字,做 4 次乘加 → 1 次共享读/乘加 = 2 B/FLOP(计入广播后约 0.56 B/FLOP)。
- 4×4:每步 4 + 4 = 8 个字,做 16 次乘加 → 0.5 次共享读/乘加 = 1 B/FLOP。 A100 每 SM 每周期可执行 128 FLOP,却只有 128 B/cycle 的共享内存带宽,即每 FLOP 只配得起 1 字节共享带宽:未粗化需要 4 B/FLOP(超配 4 倍,理论上限压到峰值 25%),2×2 需要 2 B/FLOP(超配 2 倍),4×4 恰好 1 B/FLOP(平衡)。这就是真实 SGEMM 普遍采用 4×4 到 8×8 寄存器 tile 的定量依据。
- 指令开销:未粗化时每次乘加要配 2 条共享加载指令 + 地址计算 + 循环控制(约 5 到 6 条指令/乘加);2×2 粗化后 4 次乘加共用 4 条加载与一份地址计算(约 2 条指令/乘加),指令发射压力下降约 2.5 倍。
- 实测对比(A100,Width=4096,-O3 -arch=sm_80,典型量级;随频率与编译器版本波动):
| 版本 | 每输出元素全局访存 | 共享内存/块 | 共享读/乘加 | 实测 GFLOP/s | 占 19.5 TFLOPS |
|---|---|---|---|---|---|
| 朴素(无分块) | 2K = 8192 | 0 | 0(全走全局) | 约 300 – 400 | 1.5% – 2% |
| 分块 TILE=16 | 2K/16 = 512 | 2 KB | 2 | 约 5,500 – 6,300 | 28% – 32% |
| 分块 TILE=32 | 2K/32 = 256 | 8 KB | 2 | 约 9,000 – 10,200 | 46% – 52% |
| 转置载入无 padding TILE=32 | 2K/32 = 256 | 8 KB | 2(含 32-way 冲突) | 约 2,500 – 3,500 | 13% – 18% |
| TILE=32 + 2×2 粗化 | 2K/32 = 256 | 8.25 KB | 1 | 约 12,000 – 14,000 | 62% – 72% |
| TILE=64 + 4×4 粗化(256 线程) | 2K/64 = 128 | 33 KB | 0.5 | 约 14,000 – 16,500 | 72% – 85% |
- 判定:粗化后的工作点(AI=8)仍略低于 A100 平衡点(12.54),因此最优点仍是”分块 + 粗化 + 更大 tile”的组合:把 TILE 提到 64(配 4×4 寄存器 tile 与 256 线程)可把 AI 提到 16,越过平衡点变成计算受限;代价是共享内存 33 KB/块(仍可用动态共享内存,无需 opt-in 到 48 KB 以上)与寄存器压力(4×4 需要 16 个累加器,寄存器数容易超过 40,占用率降到 50%)。此外还可考虑
float4向量化共享访问(把subTileN[k][tx*2..tx*2+3]用一次 128-bit 访问完成)以及双缓冲(double buffering,用__syncthreads()在两个 tile 缓冲间切换以重叠加载与计算)。
性能优化技巧总结
- 先用分块消灭全局内存重复访问:把每个线程独立读全局改成整块协作读、共享复用,全局流量直接降 TILE_WIDTH 倍(2K → 2K/T)。为什么有效:DRAM 带宽有限(A100 1555 GB/s),而共享内存在量级上快 12.5 倍。
- 装载按合并访存设计,且保证每个 warp 的一条加载指令覆盖连续 128 字节:让 warp 内变化最快的线程下标对应全局内存中变化最快的维(行主序矩阵的列)。为什么有效:一个 128 字节 transaction 换来 32 个有用的 float,不合并则可能付出 32 倍的 transaction 数。
- 每个 tile 元素只从全局内存读一次,装载循环里不要有”每线程读多份重复数据”的写法:用
off = lin + t*256这种线性切分保证覆盖不重不漏。为什么有效:tile 复用的收益完全建立在”只读一次”的前提上。 - 共享内存访问优先安排为”warp 内 stride-1”或”广播”,避免 warp 内变化的下标出现在共享数组的行下标上。为什么有效:stride-1 与广播都是 1 个周期;行下标变化可能一次撞出 32 路冲突、32 个周期。
- 需要跨列/转置访问时用 padding(
[TILE][TILE+1]),让行步长与 32 互质。为什么有效:stride 33 使 32 个线程的行起始地址落在 32 个不同 bank 上;TILE=16 且 warp 跨两行时应改为+2(stride 18,奇偶 bank 分离)。 - 用线程粗化(2×2、4×4 寄存器 tile)降低共享内存读取与指令开销:1 次共享读/乘加甚至 0.5 次。为什么有效:A100 每周期只有 128 B 共享带宽,而满峰值算力每周期需要几百字节;粗化是唯一能在不增加共享内存的前提下提高”每字节共享流量的 FLOP 数”的手段。
- 用线程粗化解耦 tile 尺寸与线程数,从而在 256 线程下使用 64×64 的 tile。为什么有效:AI = TILE/4,只有把 TILE 推过机器平衡点对应的尺寸(A100 需要 T ≥ 50.2),内存屋顶才会高于计算屋顶。
- 边界处理用”越界填 0”,不要用”计算时判断”:装载阶段判断一次,计算阶段零分支。为什么有效:乘 0 不影响内积结果,避免了内层循环里的 warp 发散与额外指令;把发散限制在边界块上(占比 O(1/√n))。
- 显式管理占用率:用
__launch_bounds__、--ptxas-options=-v观察寄存器数,注意 1024 线程的块每线程只能用 32 个寄存器才能双块驻留。为什么有效:占用率决定了能掩盖多少 DRAM 与共享内存延迟,寄存器溢出或占用率暴跌会把分块挣来的收益吃回去。 - 用
__restrict__与const修饰只读指针,帮助编译器做加载合并与指令调度。为什么有效:它消除了别名假设导致的重排障碍。 - 用
cudaEvent而非 CPU 计时,并且测多次取平均、保留预热。为什么有效:内核启动是异步的,CPU 端clock()测到的是启动开销而不是执行时间。
关键要点
- 分块的本质是”用片上复用换全局带宽”:一个 T×T 的 tile 从全局内存只加载 1 次,却被块内 T² 个线程各读 T 次(平均每元素复用 T 次),因此每个输出元素的全局访存从 2K 次降到 2K/T 次,全局流量从 8K³ 字节降到 8K³/T 字节,算术强度从 0.25 FLOP/Byte 提升到 TILE_WIDTH/4 FLOP/Byte。
- 共享内存是 block 级、SM 片上的资源:
__shared__静态声明或extern __shared__动态申请,作用域与生命周期都限于一个 block;容量 A100 164 KB/SM、H100 228 KB/SM、RTX 4090 100 KB/SM(单块静态上限 48 KB,更多需 opt-in);延迟约 20 到 30 个周期,带宽 32 bank × 4 B = 128 B/cycle/SM。 - bank conflict 的判定只看”同一条访存指令内、同一 warp 内、多个线程是否访问同一 bank 的不同地址”:无冲突 1 周期、2-way 2 周期、32-way 32 周期;同一地址是广播,不算冲突。讲义原版内核(
subTileM[ty][k]广播 +subTileN[k][tx]stride-1)本来无冲突;跨列/转置访问(stride = 32)才需要 padding,[TILE][TILE+1]使步长与 32 互质,一步解决。 __syncthreads()的正确性是分块内核的第一道门槛:两次同步分别防 RAW 与 WAR;块内所有线程必须到达同一条静态调用,绝不能放在发散分支里;屏障保证之前的所有全局/共享内存访问已完成,但不保证 I/O 顺序。- Roofline 与机器平衡点决定优化方向:A100 平衡点 12.54 FLOP/Byte,TILE=16(AI=4)与 TILE=32(AI=8)都还在斜坡上(内存受限);要把工作点推过平衡点,必须靠线程粗化把 TILE 提到 64 以上,或改用张量核(RTX 4090 的平衡点高达 81.9,分块 + 粗化也只是起步)。
- tile 尺寸的代价是占用率:TILE=32 时每块 8 KB 共享内存,A100 上共享内存允许 20 个 block,但 1024 线程的块受线程槽位限制只能驻留 2 个——真正的限制通常不是共享内存容量,而是线程数上限与寄存器上限(双块驻留要求每线程 ≤ 32 个寄存器)。
常见陷阱与注意事项
- 忘记
__syncthreads()(或只写一次):现象是结果在小矩阵上偶尔正确、在大矩阵上随机错误 → 分块内核必须写两次同步,一次在协作加载之后(防 RAW),一次在计算之后(防 WAR)。 - 把
__syncthreads()放进分支里:if (Row < Width) { P[Row*Width+Col] = Pvalue; __syncthreads(); }会让边界块的部分线程不进入屏障,行为未定义(可能挂死)→ 把屏障移到条件之外,只让数据访问带条件。 - 共享内存 bank conflict:
subTile[tx][ty]这类跨列/转置访问在 TILE=32 时是 32-way、TILE=16 时是 8-way → 改成[TILE][TILE+1](TILE=16 时用+2),或改用 stride-1 的访问顺序。注意”同一地址的广播不算冲突”,不要误判。 - 忘记
cudaDeviceSynchronize()/ 用 CPU 计时:现象是测出的耗时只有几十微秒(其实是内核启动开销)→ 用cudaEventRecord包住内核并用cudaEventSynchronize等待,验证阶段也要同步后再cudaMemcpy回主机。 - 越界访问(非整数倍尺寸):现象是结果在边界处错误或程序崩溃(
an illegal memory access was encountered)→ 把 m 循环上界改成(Width - 1)/TILE_WIDTH + 1、装载时越界写 0、写回 P 时判断Row < Width && Col < Width;注意 M 与 N 的边界条件不同(M 判行与列、N 判行与列,但越界的方向相反)。 - 整数除法导致网格覆盖不足:
dim3 grid(Width/TILE_WIDTH, Width/TILE_WIDTH)在 Width 不是整数倍时少算一圈 block,右下角结果全错 → 用(Width + TILE_WIDTH - 1)/TILE_WIDTH向上取整(或ceil((double)Width/TILE_WIDTH))。 - 共享内存容量超限:现象是
too much shared data编译错误或invalid argument启动失败(静态声明超过 48 KB)→ 缩减 tile、改用动态共享内存并调用cudaFuncSetAttribute(MatrixMulKernelTransposePadDynamic, cudaFuncAttributeMaxDynamicSharedMemorySize, dynBytes),或减少每块共享内存用量(例如只把 N 的一个小 tile 放进共享内存)。 - 未检查 CUDA API 返回值:
cudaMalloc失败、内核启动参数非法(如 block 超过 1024 线程)都会静默地让后续代码拿到垃圾数据 → 统一用CUDA_CHECK宏,并在内核启动后加cudaGetLastError()。 cudaMemcpy方向写错:cudaMemcpyHostToDevice/DeviceToHost写反会导致主机缓冲区里的垃圾被当成结果(验证必然 FAIL)或设备数据被主机数据覆盖 → 输入方向 HostToDevice、输出方向 DeviceToHost,检查方向参数。- host/device 指针混用:把
hM传给内核或在主机上解引用dM→ 现象是段错误或an illegal memory access was encountered,且报错位置常常离真正的错误很远;命名上用h/d前缀区分。 - 寄存器溢出与占用率暴跌:现象是加了粗化反而变慢 → 用
--ptxas-options=-v看寄存器数与 spill 情况,必要时用__launch_bounds__(threads, minBlocksPerMultiprocessor)限制。 - 以为 padding 一定有用:在讲义原版按行访问的内核里,
subTileM[ty][k]是广播、subTileN[k][tx]是 stride-1,加 padding 不会带来 bank 收益(只多占共享内存)→ 先用分析或 profiler 确认冲突存在,再决定是否 padding。
思考题(带答案)
Q1. 设矩阵边长为 K = 4096,TILE_WIDTH = 32,请定量比较朴素内核与分块内核的全局内存流量与算术强度,并判断在 A100(19.5 TFLOPS FP32、1555 GB/s)上是内存受限还是计算受限。 朴素内核每个输出元素需要 2K = 8192 次全局访问(8K = 32,768 字节),全局流量 = 2K³ 个元素 × 4 B = 8K³ = 5.50 × 10¹¹ 字节 = 550 GB,算术强度 = 2 FLOP/8 Byte = 0.25 FLOP/Byte,内存屋顶 = 1555 × 0.25 = 389 GFLOP/s(峰值的 2%)。分块内核每个输出元素的全局访问降到 2K/32 = 256 次,全局流量 = 8K³/32 = 17.2 GB,算术强度 = TILE/4 = 8 FLOP/Byte,内存屋顶 = 1555 × 8 = 12,440 GFLOP/s(峰值的 64%)。由于 A100 的机器平衡点是 19.5 TFLOPS/1555 GB/s = 12.54 FLOP/Byte > 8,分块 TILE=32 仍然是内存带宽受限:理论上界 11.0 ms(内存)> 7.0 ms(计算),必须靠线程粗化把有效 tile 提到 64(AI = 16 > 12.54)才能变成计算受限。
Q2. __shared__ float subTileM[32][32] 与 __shared__ float subTileM[32][33],在 subTileM[tx][ty](warp 内 tx = 0..31、ty 固定)这种访存下各自的 bank 分布与访存周期数是多少?为什么 +1 能解决问题?TILE_WIDTH = 16 时还可以只 +1 吗? 无 padding 时字地址 = 32·tx + ty,bank = (32·tx + ty) % 32 = ty,32 个线程全部落在同一个 bank、却有 32 个不同地址 → 32-way conflict = 32 个周期。padding 后字地址 = 33·tx + ty,bank = (33·tx + ty) % 32 = (tx + ty) % 32,tx = 0..31 时取遍全部 32 个 bank → 1 个周期,无冲突。原因是 33 与 32 互质,(33·tx) mod 32 遍历所有余数;一般地,行步长 S 只要满足 gcd(S,32)=1(或至少让 warp 内地址的 bank 覆盖尽量分散)即可。TILE_WIDTH = 16 时一个 warp 由 ty = 0 和 ty = 1 两个半 warp 组成,每行只有 16 个线程:stride 17 时两组 bank 集合会重叠 1 个 bank(2-way,2 个周期),要 +2(stride 18) 才能让 ty=0 取遍全部偶数 bank、ty=1 取遍全部奇数 bank,达到无冲突。
Q3. 为什么分块矩阵乘法的内层循环需要两次 __syncthreads()?如果只保留第一次会发生什么?如果在某个边界块里部分线程因为 if 判断而不进入第二次同步,会有什么后果? 第一次同步放在协作加载之后、计算之前,防止 RAW(read-after-write) 竞争:线程 A 要在 subTileM[k][ty] 上读取的值可能是线程 B 负责写入的,若 B 还没写完,A 会读到旧值或未初始化值,结果不确定。第二次同步放在计算之后、下一轮加载之前,防止 WAR(write-after-read) 竞争:下一轮 m 会用新 tile 覆盖共享内存,而较慢的线程可能还在读上一轮的旧 tile,覆盖会让它读到”半新半旧”的混合数据。只保留第一次同步时,第 m = 0 轮通常正确(初始时共享内存未使用),但 m ≥ 1 轮开始出现随机错误,且错误依赖于 warp 调度,极难复现。若把第二次同步放进 if 分支,让部分线程不参与屏障,则违反了”块内所有线程必须到达同一条静态 __syncthreads() 调用”的规则,属于未定义行为:可能挂死,也可能在 Volta 之后的硬件上”看起来正确”而在换架构或换块大小时突然失败。
