Lecture 5: 并行模式三 —— 分块矩阵乘法:共享内存与数据复用 (对应 Lab 3: Tiled Matrix Multiply)

目录 · ← l4 · l6 →

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 FLOP2K FLOP
全局访存总量(元素数)K² 个输出 × 2K = 2K³ 个元素每 block 2·T·K 个元素 × (K/T)² 个 block = 2K³/T 个元素
全局访存总量(字节)8K³ 字节8K³/T 字节
算术强度(AI)2K³ FLOP / 8K³ Byte = 0.25 FLOP/Byte2K³ / (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):

情形行步长 Swarp 内 32 个地址的 bank 分布单个 bank 上的最大不同地址数访存周期数
TILE=16,无 padding16ty=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 个88 周期(8-way)
TILE=16,padding +117ty=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 +218ty=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}11 周期(无冲突)
TILE=32,无 padding32字地址 = 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 个不同地址)3232 周期(32-way)
TILE=32,padding +133bank = (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,仍然两两不同11 周期(无冲突)

逐项展开 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. 与常见考试陷阱):

  1. 块内所有线程都必须到达同一个 __syncthreads() 调用点。 不能只有子集进入(例如把 __syncthreads() 写在 if (Row < Width) 里,边界块中部分线程不进入 → 结果是未定义行为,程序可能挂死或产生错误结果)。Volta 之后硬件引入了 per-thread 的收敛屏障,收敛的(convergent)分支里的屏障在部分新架构上”能用”,但 CUDA 编程指南仍明确规定必须所有线程到达同一个静态调用点,不要依赖未定义行为
  2. 不能放在发散(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 的平衡点差异很大,这决定了”分块够不够”:

GPUFP32 峰值带宽机器平衡点TILE=32(AI=8)能达到的比例结论
RTX 2080 Ti (Turing)13.4 TFLOPS616 GB/s21.8 FLOP/Byte8/21.8 = 37%分块远远不够
A100 (Ampere)19.5 TFLOPS1555 GB/s12.5 FLOP/Byte8/12.5 = 64%分块 + 粗化即可接近峰值
H100 SXM (Hopper)67 TFLOPS3350 GB/s20.0 FLOP/Byte8/20.0 = 40%需要更大 tile / 张量核
RTX 4090 (Ada)82.6 TFLOPS1008 GB/s81.9 FLOP/Byte8/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;
}
  • 【代码做什么?】
    1. 主机端先把两个矩阵填成”0.25 的整数倍”的伪随机数(保证 GPU 与 CPU 的浮点结果可比),在 n=512 上用三重循环 CPU 参考实现验证朴素内核与分块内核,输出最大绝对误差与 PASS/FAIL。
    2. MatrixMulKernelTiled 的网格是 (Width/16, Width/16),块是 (16,16);线程 (tx,ty) 通过 Row = by*16 + tyCol = bx*16 + tx 映射到 P 中的一个元素,Row 同时是它要加载的 M 行、Col 是它要加载的 N 列。
    3. 外层 for (m = 0; m < Width/16; ++m) 共 256 轮(Width=4096 时):每轮先把 M[Row][m*16+tx]N[m*16+ty][Col] 分别写进 subTileMsubTileN(协作加载,块内 256 个线程各搬 2 个元素,正好覆盖两个 16×16 的 tile),然后 __syncthreads(),再在内层 k = 0..15 上做 16 次乘加,最后再 __syncthreads() 才进入下一轮。
    4. 主机端用 cudaEvent 计时(朴素版 5 次、分块版 20 次取平均,另有 3 次预热),打印 GFLOP/s、算术强度、A100 的 Roofline 内存屋顶与瓶颈判定,以及两种实现的全局流量对比。
    5. 最后释放全部 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;
}
  • 【代码做什么?】
    1. 三个内核的差别只有”共享数组的声明方式”与”是否带 padding”:A 用 [32][32](行步长 32),B 用 [32][33](行步长 33),C 用 extern __shared__ 动态申请 8448 字节并手工切成两个 float(*)[33]。三者的计算逻辑、全局访存模式完全相同,因此性能差异只能归因于 bank conflict。
    2. 载入阶段把全局元素写进共享内存的转置位置:线程 (tx,ty) 负责的全局元素 M[Row][m*32+tx] 被写到 subTileM[tx][ty],于是 subTileM[a][b] 保存的就是 M tile 的第 b 行第 a 列。全局读取仍由 tx 沿行方向展开(合并),但共享写入的地址变成 tx*S + ty(warp 内 tx 变化 → 行下标变化)。
    3. 计算阶段访问 subTileM[k][ty](k 固定、warp 内所有线程同地址 → 广播)与 subTileN[tx][k](warp 内 tx 变化 → 行下标变化 → 地址 = tx*S + k)。A 内核在这里就是 32 路冲突,B、C 内核无冲突。
    4. 主机端在 n=512 上验证三个内核,然后在 Width 上各测 20 次取平均,打印耗时、GFLOP/s、相对最优的倍数,并附上 bank 推导与算术强度。
  • 【并行机制与硬件映射解说】
    • 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+txbx*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 资源
  • 【性能优化分析】
    • 算术强度:三个内核的全局流量完全相同(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)32subTileN[tx][k] 32-way 冲突 → 32 周期/次8192 B约 2,500 – 3,50013% – 18%
B 转置载入(静态 padding)33全部 1 周期8448 B约 9,500 – 10,50049% – 54%
C 转置载入(动态 padding)33全部 1 周期8448 B约 9,500 – 10,50049% – 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;
}
  • 【代码做什么?】
    1. 装载阶段: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
    2. 越界处理(讲义第 6 讲的核心):M 的判断是 gRowM < Width && gColM < Width,N 的判断是 gRowN < Width && gColN < Width两者不同,因为 M 的列越界对应 N 的行越界),不满足时写入 0.0f;这样计算阶段完全不需要分支——乘 0 天然让无效项不贡献。
    3. 计算阶段:每个线程持有 4 个寄存器累加器 acc[2][2],对应 P 中 (rowBase+i, colBase+j);每次 k 迭代只从共享内存取 mreg[0..1](两行,广播访问)与 nreg[0..1](两个相邻列,stride-1),然后做 4 次 FMA。
    4. 写回阶段:只有 row < Width && col < Width 的线程写全局内存;块右下角那些”完全在 P 之外”的线程算出的 0 被丢弃(讲义第 6 讲:”Threads outside of P calculate 0, but store nothing”)。
    5. 网格按向上取整计算:numTiles = (Width + TILE_WIDTH - 1)/TILE_WIDTH,与讲义第 6 讲的 (Width - 1)/TILE_WIDTH + 1 等价(对整数倍尺寸正好少算一次除法再加回 1)。程序对 Width=512(整数倍)与 Width=517(非整数倍)都做 CPU 对比验证,并在 Width 上对比未粗化版本与粗化版本的 GFLOP/s。
  • 【并行机制与硬件映射解说】
    • warp 划分与寄存器 tile 的几何形状:block(16,16) = 256 线程 = 8 个 warp;warp 0 = ty∈{0,1} 的两行共 32 个线程。输出映射为”每个线程 2×2”,因此 warp 0 覆盖 P 的 4 行 × 32 列(ty*2ty*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 的写法把发散限制在装载阶段,代价远小于”在计算阶段判断”。
  • 【性能优化分析】
    • 算术强度:全局流量仍是 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 = 819200(全走全局)约 300 – 4001.5% – 2%
分块 TILE=162K/16 = 5122 KB2约 5,500 – 6,30028% – 32%
分块 TILE=322K/32 = 2568 KB2约 9,000 – 10,20046% – 52%
转置载入无 padding TILE=322K/32 = 2568 KB2(含 32-way 冲突)约 2,500 – 3,50013% – 18%
TILE=32 + 2×2 粗化2K/32 = 2568.25 KB1约 12,000 – 14,00062% – 72%
TILE=64 + 4×4 粗化(256 线程)2K/64 = 12833 KB0.5约 14,000 – 16,50072% – 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 缓冲间切换以重叠加载与计算)。

性能优化技巧总结

  1. 先用分块消灭全局内存重复访问:把每个线程独立读全局改成整块协作读、共享复用,全局流量直接降 TILE_WIDTH 倍(2K → 2K/T)。为什么有效:DRAM 带宽有限(A100 1555 GB/s),而共享内存在量级上快 12.5 倍。
  2. 装载按合并访存设计,且保证每个 warp 的一条加载指令覆盖连续 128 字节:让 warp 内变化最快的线程下标对应全局内存中变化最快的维(行主序矩阵的列)。为什么有效:一个 128 字节 transaction 换来 32 个有用的 float,不合并则可能付出 32 倍的 transaction 数。
  3. 每个 tile 元素只从全局内存读一次,装载循环里不要有”每线程读多份重复数据”的写法:用 off = lin + t*256 这种线性切分保证覆盖不重不漏。为什么有效:tile 复用的收益完全建立在”只读一次”的前提上。
  4. 共享内存访问优先安排为”warp 内 stride-1”或”广播”,避免 warp 内变化的下标出现在共享数组的下标上。为什么有效:stride-1 与广播都是 1 个周期;行下标变化可能一次撞出 32 路冲突、32 个周期。
  5. 需要跨列/转置访问时用 padding([TILE][TILE+1],让行步长与 32 互质。为什么有效:stride 33 使 32 个线程的行起始地址落在 32 个不同 bank 上;TILE=16 且 warp 跨两行时应改为 +2(stride 18,奇偶 bank 分离)。
  6. 用线程粗化(2×2、4×4 寄存器 tile)降低共享内存读取与指令开销:1 次共享读/乘加甚至 0.5 次。为什么有效:A100 每周期只有 128 B 共享带宽,而满峰值算力每周期需要几百字节;粗化是唯一能在不增加共享内存的前提下提高”每字节共享流量的 FLOP 数”的手段。
  7. 用线程粗化解耦 tile 尺寸与线程数,从而在 256 线程下使用 64×64 的 tile。为什么有效:AI = TILE/4,只有把 TILE 推过机器平衡点对应的尺寸(A100 需要 T ≥ 50.2),内存屋顶才会高于计算屋顶。
  8. 边界处理用”越界填 0”,不要用”计算时判断”:装载阶段判断一次,计算阶段零分支。为什么有效:乘 0 不影响内积结果,避免了内层循环里的 warp 发散与额外指令;把发散限制在边界块上(占比 O(1/√n))。
  9. 显式管理占用率:用 __launch_bounds__--ptxas-options=-v 观察寄存器数,注意 1024 线程的块每线程只能用 32 个寄存器才能双块驻留。为什么有效:占用率决定了能掩盖多少 DRAM 与共享内存延迟,寄存器溢出或占用率暴跌会把分块挣来的收益吃回去。
  10. __restrict__const 修饰只读指针,帮助编译器做加载合并与指令调度。为什么有效:它消除了别名假设导致的重排障碍。
  11. 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 conflictsubTile[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 之后的硬件上”看起来正确”而在换架构或换块大小时突然失败。