Lecture 4: 并行模式二 —— 基础矩阵乘法及其性能瓶颈 (对应 Lab 2: Simple Matrix Multiply)

目录 · ← l3 · l5 →

Lecture 4: 并行模式二 —— 基础矩阵乘法及其性能瓶颈 (对应 Lab 2: Simple Matrix Multiply)

概述

本讲把”并行模式一”(向量加法那样的 element-wise 模式)推广到第一个真正有计算量的应用:矩阵乘法。Lab 2 的 Simple Matrix Multiply 不允许使用共享内存,只允许”每线程一个输出元素(thread-per-output)”的映射,因此它是理解 GPU 访存代价最好的反面教材。本讲先用 M×K 乘 K×N 的数学定义与 FLOP 计数说明矩阵乘法是”O(N³) 计算、O(N²) 数据”的高算术强度问题,再用行主序(row-major)线性化 M[row*Width + col] 把二维矩阵落到一维显存上,然后推出朴素实现需要 2·M·N·K 次全局内存访问这一”访存爆炸”结论。核心分析工具是 warp 级访存合并(memory coalescing)与 Roofline 模型:朴素版算术强度只有 0.25 FLOP/Byte,在 A100 上的理论上限约 389 GFLOP/s,仅为其 19.5 TFLOPS 峰值的 2%。最后用”数据复用次数”的计算引出下一讲的分块(tiling)优化。

核心概念与 GPU 架构图解

概念 1:矩阵乘法的数学定义与计算量(Matrix Multiplication: Definition and FLOP Count)

  • 定义与目的:给定矩阵 A(M 行 K 列)与矩阵 B(K 行 N 列),乘积 P = A × B 是一个 M 行 N 列的矩阵,其第 i 行第 j 列的元素为 P[i][j] = Σ(k=0..K-1) A[i][k] * B[k][j]。这个定义把”内积(inner product / dot product)”作为基本操作:A 的第 i 行与 B 的第 j 列做一次长度为 K 的内积,就得到 P 的一个元素。Lab 2 取方阵特例 M = N = K = Width,三个矩阵都按行主序存放在一维显存中。定义它的目的有两个:一是给出可并行化的粒度(M×N 个独立内积,彼此没有依赖,天然适合 GPU 的数万线程);二是给出可精确计算的”工作量”与”数据量”,从而可以定量判定程序是计算受限(compute-bound)还是带宽受限(memory-bandwidth-bound)。

  • 直观解释(”它是什么?”):把 A 的每一行看成一张配方(recipe),把 B 的每一列看成一张订单(order)。配方 i 的第 k 项是”第 k 种原料的用量 A[i][k]”,订单 j 的第 k 项是”第 k 种原料的需求倍数 B[k][j]”。那么 P[i][j] 就是把配方 i 按订单 j 的倍数配一遍所得到的总量。这个类比马上揭示了一件极其重要的事:同一张配方 i 会被所有 N 张订单使用,同一张订单 j 会被所有 M 张配方使用。也就是说,矩阵乘法里每一个输入数据都天然含有大量可复用的价值(A 的每个元素被用 N 次,B 的每个元素被用 M 次),而”能不能把这份复用价值变成性能”正是本讲与下一讲的全部张力所在。

  • 架构/机制图解:下面用 4×4 的具体例子把定义写全(真实实验里 Width 取 1024、2048 或更大,规则完全相同)。

        B  (K=4 行, N=4 列)                  P = A x B   (M=4 行, N=4 列)
          c0   c1   c2   c3
       +----+----+----+----+
   r0  |  1 |  2 |  3 |  4 |
       +----+----+----+----+
   r1  |  5 |  6 |  7 |  8 |
       +----+----+----+----+
   r2  |  9 | 10 | 11 | 12 |
       +----+----+----+----+
   r3  | 13 | 14 | 15 | 16 |
       +----+----+----+----+

   P[i][j] = A[i][0]*B[0][j] + A[i][1]*B[1][j] + A[i][2]*B[2][j] + A[i][3]*B[3][j]

   举例(K = 4,所以每个输出元素要做 4 次乘加):
   P[2][1] = A[2][0]*B[0][1] + A[2][1]*B[1][1] + A[2][2]*B[2][1] + A[2][3]*B[3][1]
           = A[2][0]*2       + A[2][1]*6       + A[2][2]*10      + A[2][3]*14

   并行粒度:M x N = 16 个输出元素,两两之间没有任何数据依赖
              -> 可以交给 16 个线程同时算,甚至交给出 1 个线程算(只是慢)

计算量的推导(这是全讲所有数字的起点):

一次 "乘加"(multiply-add,也称 FMA,fused multiply-add)
     P += A * B      =>     1 次乘法 + 1 次加法 = 2 FLOP(floating-point operation)

输出元素个数          = M * N
每个输出元素的乘加次数 = K            (内积长度)
总的乘加次数          = M * N * K
总的 FLOP 数          = 2 * M * N * K          <-- 记住这个公式
方阵特例 (M=N=K=N)    = 2 * N^3                <-- 计算量按 O(N^3) 增长

数据量:
输入元素个数 = M*K + K*N = 2*N^2 (方阵)
输出元素个数 = M*N       =   N^2
若每个元素 4 Byte(float):
输入字节数 = 2 * N^2 * 4 = 8*N^2  Byte
输出字节数 =     N^2 * 4 = 4*N^2  Byte
总数据量   = 12*N^2 Byte                        <-- 数据量按 O(N^2) 增长

理想算术强度(算术强度 arithmetic intensity = FLOP / Byte,假设完美复用不重复读):
    AI_ideal = 2*N^3 / (12*N^2) = N/6  FLOP/Byte

具体数字(这一步的数字会在整讲反复用到):

Width = NFLOPs = 2·N³输入数据 8·N²输出数据 4·N²理想算术强度 N/6
25633.6 MFLOP0.5 MB0.25 MB42.7 FLOP/Byte
10242.147 GFLOP8 MB4 MB170.7 FLOP/Byte
204817.18 GFLOP32 MB16 MB341.3 FLOP/Byte
4096137.4 GFLOP128 MB64 MB682.7 FLOP/Byte

关键对照:A100 的机器平衡点(machine balance,即峰值算力除以峰值带宽)为

Machine Balance (A100) = 19.5 TFLOPS / 1555 GB/s
                       = 19.5e12 FLOP/s / 1555e9 Byte/s
                       = 12.5 FLOP/Byte

只要一个程序的算术强度大于 12.5 FLOP/Byte,它就在”计算受限”一侧,理论上可以跑到接近 19.5 TFLOPS。矩阵乘法在 N=1024 时的理想强度是 170.7 FLOP/Byte,是机器平衡点的 13.6 倍——所以从算法角度看这是”再好不过”的问题。坏消息全部藏在”理想”两个字里:理想强度假设每个输入元素只从 DRAM 搬一次,而朴素 kernel 做不到这一点,见概念 4、5、6。

概念 2:行主序线性化寻址(Row-Major Linearization and Address Arithmetic)

  • 定义与目的:C 语言里的二维数组 float M[M][K] 在内存中并不是真正的二维结构,它是一个连续的一维字节数组,编译器自动把两个下标换算成一个线性偏移(offset)。CUDA 中最常用的动态分配接口 cudaMalloc 只接受一维指针,cudaMallocPitch 也只是把一维指针加上”行距”信息,因此在 GPU 上写矩阵代码必须自己手写线性化公式。行主序(row-major,也叫 C 风格布局)规定:同一行的元素在内存里连续排列,第 0 行放完放第 1 行,依此类推。于是 M[row][col] 的线性偏移是 row * Width + col,其中 Width 是矩阵一行的元素个数(这个量在数值线性代数里叫 leading dimension,在图像/多媒体代码里常叫 pitch 或 stride)。

  • 直观解释(”它是什么?”):想象一个剧院,座位按”排”编号:第 0 排有 1024 个座位,然后是第 1 排的 1024 个座位,等等。工作人员不记”第 3 排第 5 座”这样的二元坐标,只记一个连续编号:第 3 排第 5 座 = 前面 3 整排(3×1024 个座位)+ 本排第 5 个 = 编号 3077row * Width + col 就是这个”前面有几整排”的算术。它带来一个极其重要的几何直觉:同一排里相邻座位的物理距离是 1(4 字节),而同一列里上下相邻座位之间的距离是整整一排(Width × 4 字节)。GPU 的访存合并机制只对”相邻座位”友好,对”隔一整排”的访问极其昂贵——这就是本讲所有性能悲剧的几何根源。

  • 架构/机制图解:下图的矩阵是 4 行 6 列(Width = 6),二维逻辑视图与一维物理布局的对应关系必须能一眼画出来。

逻辑二维视图(Width = 6,共 4 行)
          col=0   col=1   col=2   col=3   col=4   col=5
  row=0  [ M00 ] [ M01 ] [ M02 ] [ M03 ] [ M04 ] [ M05 ]
  row=1  [ M10 ] [ M11 ] [ M12 ] [ M13 ] [ M14 ] [ M15 ]
  row=2  [ M20 ] [ M21 ] [ M22 ] [ M23 ] [ M24 ] [ M25 ]
  row=3  [ M30 ] [ M31 ] [ M32 ] [ M33 ] [ M34 ] [ M35 ]

物理一维内存(行主序:一行接一行,行内连续)
  线性偏移:   0     1     2     3     4     5     6     7     8     9    10    11
  内容:     M00   M01   M02   M03   M04   M05   M10   M11   M12   M13   M14   M15
            |<-------- 第 0 行(6 个元素,24 Byte)-------->|<---- 第 1 行(6 个元素)--->

  地址公式:
      M[row][col]  的线性偏移 = row * Width + col
      M[row][col]  的字节地址 = base + 4 * (row * Width + col)      (float 占 4 Byte)

  两个距离(务必背下来):
      同一行内相邻列(col -> col+1):偏移 +1,字节地址 +4   Byte   <-- 连续,可合并
      同一列内相邻行(row -> row+1):偏移 +Width,字节地址 +Width*4 Byte <-- 跨步 stride

代入具体数字来感受”跨步”有多夸张(Width = 1024):

  M[3][5]   -> 偏移 = 3*1024 + 5 = 3077          -> 字节地址 = base + 12308
  M[3][6]   -> 偏移 = 3078                       -> 字节地址 = base + 12312   (相差 4 B)
  M[4][5]   -> 偏移 = 4*1024 + 5 = 4101          -> 字节地址 = base + 16404   (相差 4096 B)

  4096 Byte / 128 Byte(一条 cache line 的粒度) = 32 条 cache line
  -> 同列上下相邻的两个元素,落在相隔 32 条 cache line 的两块不同内存里
  -> 一个 warp 里 32 个线程若沿着"列"走,硬件就必须发出 32 次互不相干的取数请求

在硬件层面,GPU 的全局内存系统以 32 Byte 的 sector 为最小的取数粒度、以 128 Byte 的 cache line(= 4 个 sector)为标签(tag)管理粒度,一条 warp 指令的理想情况是 32 个线程访问 32 个连续 float(32×4 = 128 Byte = 4 个 sector = 1 条 cache line),一次全部取回、全部有用。Lecture 7 讲 DRAM 时给出了更底层的理由:DRAM 允许一次行激活(row activation)后把整行数据”猝发”(burst)送出,现代 DRAM 系统总是工作在 burst 模式,不是顺序访问时被取回的 burst 字节会被直接丢弃。讲义里的练习题把代价算得很直白:burst size = 512 Byte、峰值带宽 240 GB/s 时,访问 A[4*i]A[4*i+1] 只用到 burst 中每一对 8 Byte 里的一半,于是可用带宽降到 120 GB/s;在早期没有 cache 的 GPU 上两次 load 甚至会各自砍掉一半,落到 60 GB/s。这解释了为什么”跨步访存”不是”多花一点点时间”,而是成倍地浪费带宽。

概念 3:每线程一个输出元素(Thread-per-Output Mapping)与二维 block/grid

  • 定义与目的:最简单的并行分解方式:P 有 M×N 个元素,就启动 M×N 个线程,每个线程独立算完一个输出元素(一整条长度为 K 的内积)并写回。为了让”线程坐标”与”矩阵坐标”自然对应,CUDA 允许 block 和 grid 都是二维的(dim3),于是行号由 y 方向决定、列号由 x 方向决定,索引计算只写两行代码。这个映射的价值在于:零通信、零同步、无数据依赖,非常适合 GPU;它的代价则完全落在内存系统上(见概念 4)。Lab 2 要写的正是这个版本。

  • 直观解释(”它是什么?”):把 P 想象成一张巨大的报表,M×N 个格子。我们雇 M×N 个抄写员,每人负责一个格子,每人手里有两份资料:A 的一整行和 B 的一整列。抄写员互相不说话(没有同步),各算各的。问题是他们共享同一间资料室:打印 1024×1024 的报表需要 100 多万个抄写员,而每个人都要跑去资料室取 2048 份资料(A 行 1024 份 + B 列 1024 份),资料室的门(内存带宽)会被挤爆。更糟的是,如果资料室按”整排书架”取书,而某类抄写员需要的那列资料散落在 1024 个不同书架上,那么每取一份资料都要跑一趟——这就是”不合并的列访问”。

  • 架构/机制图解:先看映射关系(玩具尺寸:Width = 4,block = 2×2,于是 grid = 2×2)。

Grid(dim3 dimGrid(2,2,1))          每个 block 负责 P 的一块 2x2 子矩阵
   blockIdx = (0,0)   blockIdx = (1,0)
  +-----------------+-----------------+
  |  P[0][0] P[0][1]|  P[0][2] P[0][3]|
  |  P[1][0] P[1][1]|  P[1][2] P[1][3]|
  +-----------------+-----------------+
  |  P[2][0] P[2][1]|  P[2][2] P[2][3]|
  |  P[3][0] P[3][1]|  P[3][2] P[3][3]|
  +-----------------+-----------------+
   blockIdx = (0,1)   blockIdx = (1,1)

Block(0,0) 内部(dim3 dimBlock(2,2,1),4 个线程)
     threadIdx=(0,0) -> P[0][0]      threadIdx=(1,0) -> P[0][1]
     threadIdx=(0,1) -> P[1][0]      threadIdx=(1,1) -> P[1][1]

索引公式(讲义原文,逐字对应):
     int Row = blockIdx.y * blockDim.y + threadIdx.y;   // 输出行 = 该 block 的行起点 + 线程行偏移
     int Col = blockIdx.x * blockDim.x + threadIdx.x;   // 输出列 = 该 block 的列起点 + 线程列偏移
     P[Row * Width + Col] = Pvalue;

再看”一个线程”在 k 循环里到底访问了哪些内存:

线程 (Row, Col) 的内积循环 for (k = 0; k < Width; ++k)
   k=0:       读 A[Row][0]       与 B[0][Col]        -> 相乘累加
   k=1:       读 A[Row][1]       与 B[1][Col]        -> 相乘累加
   k=2:       读 A[Row][2]       与 B[2][Col]        -> 相乘累加
   (k = 3 一直到 k = Width-2 依此类推,访问规律完全相同)
   k=Width-1: 读 A[Row][Width-1] 与 B[Width-1][Col]  -> 相乘累加

   A 侧:地址 = Row*Width + k,k 递增 1 -> 沿 A 的一行"连续"前进(每次 +4 Byte)
   B 侧:地址 = k*Width + Col,k 递增 1 -> 沿 B 的一列"跨步"前进(每次 +Width*4 Byte)

   本线程的访存足迹:
       读 A:Width 次(连续,硬件预取/cache 友好)
       读 B:Width 次(每次跨 Width*4 Byte,cache 完全帮不上忙)
       写 P:1 次

最后看 256 线程的 block 在硬件上如何被切成 warp——这是后面所有 transaction 计算的依据:

 blockDim = (16, 16)  =>  256 threads  =>  8 个 warp
 线性线程号 tid = ty * blockDim.x + tx = ty * 16 + tx   (x 方向变化最快)
 硬件划分:warp w = tid / 32,所以一个 warp = 相邻的两个 ty 行

        tx:    0    1    2    3    4    5    6    7    8    9   10   11   12   13   14   15
  ty= 0  |<----------------------  Warp 0  (tid   0 ..  31)  ---------------------->|
  ty= 1  |<----------------------  Warp 0  (tid   0 ..  31)  ---------------------->|
  ty= 2  |<----------------------  Warp 1  (tid  32 ..  63)  ---------------------->|
  ty= 3  |<----------------------  Warp 1  (tid  32 ..  63)  ---------------------->|
  ty= 4  |<----------------------  Warp 2  (tid  64 ..  95)  ---------------------->|
  ty= 5  |<----------------------  Warp 2  (tid  64 ..  95)  ---------------------->|
  ty= 6  |<----------------------  Warp 3  (tid  96 .. 127)  ---------------------->|
  ty= 7  |<----------------------  Warp 3  (tid  96 .. 127)  ---------------------->|
  ty= 8  |<----------------------  Warp 4  (tid 128 .. 159)  ---------------------->|
  ty= 9  |<----------------------  Warp 4  (tid 128 .. 159)  ---------------------->|
  ty=10  |<----------------------  Warp 5  (tid 160 .. 191)  ---------------------->|
  ty=11  |<----------------------  Warp 5  (tid 160 .. 191)  ---------------------->|
  ty=12  |<----------------------  Warp 6  (tid 192 .. 223)  ---------------------->|
  ty=13  |<----------------------  Warp 6  (tid 192 .. 223)  ---------------------->|
  ty=14  |<----------------------  Warp 7  (tid 224 .. 255)  ---------------------->|
  ty=15  |<----------------------  Warp 7  (tid 224 .. 255)  ---------------------->|

 (每一行的 16 个格子依次是 tx = 0..15;Warp 0 覆盖 ty=0 与 ty=1 两行,
   所以 32 个线程里 threadIdx.y 只有 2 个取值,而 threadIdx.x 有 16 个取值。)

硬件映射与性能特征(以 A100 / sm_80 为基准):

  • warp 与调度:一个 block 的 256 个线程被切成 8 个 warp,每个 warp 交给 SM 里的 4 个 warp scheduler 之一去发射指令;A100 每个 SM 最多同时驻留 64 个 warp(2048 线程),因此 256 线程的 block 最多驻留 8 个(受线程数限制),占用率(occupancy)= 2048/2048 = 100%。这一点非常重要:朴素矩阵乘法的问题是内存,不是占用率。 很多同学第一反应是”增大 block 或减少寄存器来提升占用率”,但占用率已经满了。
  • 延迟与并行度:全局内存访问延迟约 400–800 个时钟周期。要以 1555 GB/s 的速率喂饱 108 个 SM,每个 SM 需要维持大约 1555e9 / 108 / 1.41e9 ≈ 10 Byte/cycle/SM 的取数速率,这必须靠”大量在飞(in-flight)的访存请求”来实现:Little's Law:需要的在飞请求数 = 延迟 × 速率 = 约 600 cycle × 10 Byte/cycle ÷ 128 Byte/请求 ≈ 47 条未完成的 128 Byte 请求。8 个 block × 256 线程 × 2 次 load = 4096 个在飞 load 是够的——前提是它们都能被合并成完整的 cache line。
  • warp 发散(divergence)if (Row < Width && Col < Width) 这个边界保护只在 Width 不是 blockDim 整数倍时对边界 block 产生发散;Width = 1024、block = 16×16 时完全不发散(1024/16 = 64 整除)。
  • 规模数字:Width = 1024、block = 16×16 时,dimGrid = (ceil(1024/16), ceil(1024/16)) = (64, 64),共 4096 个 block、1,048,576 个线程、每线程 2×1024 = 2048 FLOP;108 个 SM 各驻留 8 个 block = 864 个 block 并发,所以整个 grid 要跑 4096 / 864 ≈ 4.74 个”波次”(wave)。

概念 4:朴素实现的访存爆炸与跨步访存(Memory Access Explosion and Strided Access)

  • 定义与目的:把概念 3 的映射写成代码,内积循环里只有两条 load:d_M[Row*Width+k]d_N[k*Width+Col]每做一次乘加(2 FLOP),就要从内存取两个 float(8 Byte),因此每 1 FLOP 需要 4 Byte——这就是讲义中反复出现的”4B of Data per FLOP“。也就是说,GPU 每获得 1 个浮点运算能力,就必须同时供给它 4 Byte 的内存带宽。这个比例是判断”这台机器能不能跑这个算法”的第一把尺子,也是 Lab 2 性能上不去的根本原因。本概念要把它拆到 warp 与 transaction 的粒度,弄清楚哪一半流量是”必要的”,哪一半是”被访存模式浪费掉的”。

  • 直观解释(”它是什么?”):把 GPU 的内存系统想象成一家只批发整箱货物的仓库:最小出库单位是 32 Byte 的一个 sector(一”箱”),而线程提出的 load 请求是”我要这箱里的某一个 4 Byte”。如果 32 个线程要的东西恰好落在同一箱(以及相邻的几箱)里,仓库出一次货就满足了所有人——这是合并访问(coalesced access)。如果 32 个线程要的东西散落在 32 个不同的箱子里,仓库就必须出 32 次货、搬 32 箱回来,而每箱里只有 4 Byte 被用掉、其余 28 Byte 都是垃圾——这是跨步访问(strided access),也是”4 B/FLOP”变成”实际带宽被吃掉 8 倍”的原因。更糟的是重复取货:1024×1024 的矩阵乘法里,A 的每一个元素被 1024 个不同线程用到,如果每个线程都自己去仓库拿一次,那就是 1024 倍的冗余请求。合并访问解决的是”取一趟多拿点”,数据复用(概念 7)解决的是”别拿那么多趟”——本讲的 Lab 2 两个问题都没解决。

  • 架构/机制图解:先算清请求的总量,再算清每一条请求在硬件上的代价。

【第一层:总请求量("访存爆炸")】
  每个输出元素:K 次读 A 的一行 + K 次读 B 的一列 = 2K 次 load
  输出元素总数:M * N
  总 load 次数:2 * M * N * K                   (方阵:2*N^3)
  总请求字节数:2 * M * N * K * 4 Byte = 8*M*N*K Byte
  总 FLOP 数 :2 * M * N * K
  => 每 FLOP 需要的请求字节数 = 8*M*N*K / (2*M*N*K) = 4 Byte/FLOP

  代入 N = 1024:
      总 load 次数 = 2 * 1024^3      = 2.147e9 次 load
      请求字节数   = 8 * 1024^3 Byte = 8.59 GB
      若这些请求全部打到 DRAM 且完美利用带宽:
          时间下界 = 8.59 GB / 1555 GB/s = 5.52 ms
      若纯按峰值算力执行(19.5 TFLOPS):
          时间下界 = 2.147 GFLOP / 19.5 TFLOPS = 0.110 ms
      => 访存时间下界是计算时间下界的 50 倍! 内存完全统治了这个 kernel。

【第二层:数据复用次数(为什么请求量这么大)】
  A 有 M*K 个元素,每个元素被多少个线程读?  A[i][k] 被 P 第 i 行的所有 N 个输出用到 -> N 次
  B 有 K*N 个元素,每个元素被多少个线程读?  B[k][j] 被 P 第 j 列的所有 M 个输出用到 -> M 次
  验证:A 侧总 load = M*K*N = MNK,B 侧总 load = K*N*M = MNK,合计 2MNK   (对上了)
  => 请求量是"必要数据量"的多少倍?
     MNK 次 A 的 load  vs  只需要 M*K 个元素  ->  N 倍冗余
     MNK 次 B 的 load  vs  只需要 K*N 个元素  ->  M 倍冗余
  这就是"数据复用(data reuse)没有被利用"的定量表述。

第二层分析已经说明:一半以上的请求是冗余的。但即使请求都是”必要”的,还有一层更狠的浪费——warp 内的地址分布。下面逐 warp 剖析 16×16 block、Width = 1024 时,warp 0 在固定 k 的一次迭代里两条 load 的地址分布(warp 0 覆盖 ty = t0 与 ty = t0+1 两行共 32 个线程)。

【对 A 的访问 d_M[Row*Width + k]】地址只依赖 ty,不依赖 tx
     ty = t0   的 16 个线程:地址 = base + (t0   * 1024 + k) * 4
     ty = t0+1 的 16 个线程:地址 = base + ((t0+1)* 1024 + k) * 4
     -> warp 内只有 2 个不同的地址,每个地址被 16 个线程重复请求(硬件做广播 broadcast)
     -> 两个地址相距 1024 * 4 = 4096 Byte = 32 条 128 B cache line
     -> 落在两个不同的 32 B sector 上,必须发两次取数请求

     [ sector #1: 32 Byte ]                 [ sector #2: 32 Byte ]
      +-----------+--------------------+     +-----------+--------------------+
      | USED 4B   |   wasted 28 Byte   |     | USED 4B   |   wasted 28 Byte   |
      +-----------+--------------------+     +-----------+--------------------+
            ^                                      ^
         地址 A                               地址 A + 4096 B
      |<---------- 4096 Byte = 32 条 128 B cache line ---------->|

     请求数(transaction)= 2
     取回字节 = 2 * 32 B   = 64 B
     有用字节 = 2 * 4 B    =  8 B
     效率     = 8 / 64     = 12.5%          <-- 87.5% 的带宽被浪费

【对 B 的访问 d_N[k*Width + Col]】地址只依赖 tx,不依赖 ty
     ty = t0   的 16 个线程:地址 = base + (k*1024 + bx*16 + tx) * 4,tx = 0..15
     ty = t0+1 的 16 个线程:地址表达式完全相同(式子里没有 ty)
     -> warp 内一共 16 个互不相同的地址,而且它们在物理上"连续"排布
     -> 16 个连续 float = 64 Byte,通常落在同一条 128 B cache line 内

     +---------------------------------------------------------------+
     | 128 Byte cache line(4 个 32 B sector)                        |
     | [ sector 0 ][ sector 1 ][ sector 2 ][ sector 3 ]              |
     |   ^^^^^^^^^^  ^^^^^^^^^^                                      |
     |   16 个 float 全部被用到(64 Byte,2 个 sector)               |
     +---------------------------------------------------------------+

     请求数(transaction)= 1 条 warp 请求(覆盖 2 个 sector,合并在一条 cache line 内)
     取回字节 = 64 B
     有用字节 = 16 * 4 B = 64 B
     效率     = 100%                        <-- 这一半是"好"的

【warp 0 在固定 k 上两条 load 的合计】
     取回字节 = 64 B (A) + 64 B (B) = 128 B
     有用字节 =  8 B (A) + 64 B (B) =  72 B
     整体效率 = 72 / 128 = 56%

这就解释了为什么”每 1 FLOP 需要 4 Byte”在真实硬件上还要再打折扣:请求字节数已经等于 4 B/FLOP,而取回的字节里只有一部分有用,实际搬运量更大。Lab 2 的实测带宽利用率之所以远低于峰值,原因就在这里。

关于”哪一个操作数是跨步的”:必须把命名讲清楚。 本课程的约定是 threadIdx.x 对应(Col)、threadIdx.y 对应(Row),在这种约定下,A(讲义中的 d_M)的访问是跨步的、B(讲义中的 d_N)的访问是合并的——Lecture 7 的原文就是两页对照:”Accesses to N are Coalesced” 与 “Accesses to M are NOT Coalesced!”。原因很朴素:一个地址里出现 threadIdx.y 的线性系数是 Width(跨步),出现 threadIdx.x 的系数是 1(连续)。一旦交换 x/y 的语义,受害者就换成另一个操作数,跨步量依旧是 Width * 4 Byte、transaction 数依旧由”warp 内有多少个不同的行索引”决定。下面这张表把四种常见情形一次算完(16×16 block,Width = 1024,均为”每 warp 每 k”的统计):

情形A 侧地址分布A 侧取回/有用B 侧地址分布B 侧取回/有用合计效率
标准映射:x→Col, y→Row(本讲与 Lab 2 的写法2 个地址,相距 Width*4 = 4096 B64 B / 8 B = 12.5%16 个连续地址 = 64 B64 B / 64 B = 100%128 B / 72 B = 56%
A 以转置形式给出(M_T[k*Width + Row]2 个相邻地址,相距 4 B32 B / 8 B = 25%同左(不变)64 B / 64 B = 100%96 B / 72 B = 75%
B 以转置形式给出(N_T[Col*Width + k],即按列主序读 B)同第一行(不变)64 B / 8 B = 12.5%16 个地址,每个相距 4096 B → 16 个独立 sector512 B / 64 B = 12.5%576 B / 72 B = 12.5%
交换语义:x→Row, y→Col16 个地址,每个相距 4096 B → 16 个独立 sector512 B / 64 B = 12.5%2 个相邻地址,相距 4 B32 B / 8 B = 25%544 B / 72 B = 13.2%

把第三行单独展开,因为它就是”按列读 B”最直观的受害者形态(也是很多同学把 B 按列主序存好后踩的坑):

【B 按列主序(转置)存储时 warp 0 对 B 的访问 N_T[Col*Width + k]】
     warp 内 16 个线程(tx = 0..15,ty 只有 2 个取值但地址里没有 ty)
     地址 = base + ((bx*16 + tx) * 1024 + k) * 4
     tx 每 +1,地址 +4096 Byte

     sector 编号:  #1      #2      #3      #4      #5      #6      #7      #8
     地址(Byte):   0      4096    8192   12288   16384   20480   24576   28672
     有用:        4B      4B      4B      4B      4B      4B      4B      4B

     sector 编号:  #9     #10     #11     #12     #13     #14     #15     #16
     地址(Byte): 32768   36864   40960   45056   49152   53248   57344   61440
     有用:        4B      4B      4B      4B      4B      4B      4B      4B
     -> 16 个互不相邻的 32 B sector,硬件必须发 16 次独立取数请求
     取回字节 = 16 * 32 B = 512 B
     有用字节 = 16 * 4 B  =  64 B
     效率     = 12.5%

  与"B 行主序"的 64 B 相比,同样是 16 个有用的 float,
  却搬回了 512 B —— 8 倍的无效搬运量。

最后必须补一个诚实的工程细节:L1/L2 cache 会把模型”变好看”,但不会让问题消失。原因有两层:(1) 跨步访问取回的 32 B sector 里,除了被用到的 4 B,其余 28 B 其实是同一行里 k+1 到 k+7 的邻居元素,而内积循环接下来正好会用到它们(k 每 +1,地址 +4 B),所以只要 cache 装得下,一次 sector 取回会被 8 次迭代摊薄;(2) A100 的 L2 有 40 MB,N = 1024 时三个矩阵只占 8 + 4 = 12 MB,整个工作集都装得进 L2,此时大部分请求被 L2 吸收(L2 命中延迟约 200 cycle,带宽远高于 DRAM),实测性能会明显优于”纯 DRAM 模型”的预测。因此做诊断实验时必须把矩阵开大(N ≥ 4096 时单个矩阵 64 MB,远超 L2)才能看到真正的 DRAM 行为;而这一点也提示了下讲分块优化的真正收益来源——分块减少的是”请求数量”,它对 cache 是否命中同样有效

概念 5:算术强度与机器平衡点(Arithmetic Intensity and Machine Balance)

  • 定义与目的:算术强度(arithmetic intensity,AI)定义为程序完成的总浮点运算数除以它从内存搬进/搬出的总字节数,单位 FLOP/Byte。它把”程序”和”机器”分开描述:程序提供 AI,机器提供”每 Byte 能配多少 FLOP”的能力上限(机器平衡点 machine balance = 峰值算力 ÷ 峰值带宽)。两者一比,立刻知道瓶颈在哪一侧,以及理论上还能提速多少倍。它是 Roofline 模型(概念 6)的横轴,也是判断”该优化计算还是优化访存”的唯一依据。

  • 直观解释(”它是什么?”):厨房里做菜。算术强度就是”每跑一趟冰箱(内存)能做出多少道菜(FLOP)”。如果你的厨房只有一块很小的案板,每次只能拿一根葱回来切一刀,那你的出菜速度完全取决于你跑冰箱的速度(带宽受限);如果案板够大,一趟搬回一整筐食材、切出一百盘菜,那限制你的就是你的刀工(计算受限)。机器平衡点就是这家厨房的”案板与跑腿速度之比”:A100 的比例是 12.5 FLOP/Byte,意思是每搬 1 Byte 至少要能换来 12.5 次浮点运算,跑腿的才不会是瓶颈。朴素矩阵乘法每 Bytes 只换来 0.25 次运算,相当于每搬 1 筐菜只切一刀——跑腿的完全累死,刀工师傅闲得发慌。

  • 架构/机制图解

            计算侧                                 访存侧
   +------------------------+            +------------------------+
   |  108 个 SM             |            |  DRAM (HBM2, 1555 GB/s)|
   |  每 SM 64 个 FP32 单元 |            |  全局内存 40 GB        |
   |  1.41 GHz              |            +-----------+------------+
   |  => 19.5 TFLOP/s       |                        |
   +-----------+------------+                        |
               |                                     |
               |        +-----------------+          |
               +------->|  GPU 内核流水线  |<---------+
                        +-----------------+
                         每周期能"吃掉"多少数据?
                         需要 19.5e12 / 1555e9 = 12.5 FLOP/Byte 才平衡

   程序侧(朴素矩阵乘法):
       AI = FLOP / Byte = 2*M*N*K / (8*M*N*K) = 0.25 FLOP/Byte
       AI(0.25) << Balance(12.5)  =>  相差 50 倍  =>  严重带宽受限

AI 的推导要能默写(左式是 FLOP,右式是 Byte):

  AI_naive = (2 * M * N * K) / (2 * M * N * K * 4 Byte)
           = 1 / 4  (Byte/FLOP 的倒数)
           = 0.25 FLOP/Byte            <-- 与本讲的 4 B/FLOP 是同一件事的两种说法

  换个算法看 AI(同样的数字,不同的解读):
       每 1 次乘加(2 FLOP)读 2 个 float(8 Byte)  ->  2/8 = 0.25 FLOP/Byte
       等价说法:"每 FLOP 需要 4 Byte",即 4 B/FLOP

四台参考 GPU 的机器平衡点(用规范表格中的统一硬件档案):

GPU峰值带宽FP32 峰值机器平衡点 = 算力/带宽朴素版能否达到平衡(需要 12.5)
RTX 2080 Ti (sm_75)616 GB/s13.4 TFLOPS21.8 FLOP/Byte否,差 87 倍
A100 (sm_80)1555 GB/s19.5 TFLOPS12.5 FLOP/Byte否,差 50 倍
H100 SXM (sm_90)3350 GB/s67 TFLOPS20.0 FLOP/Byte否,差 80 倍
RTX 4090 (sm_89)1008 GB/s82.6 TFLOPS81.9 FLOP/Byte否,差 328 倍

注意最后一行:消费级显卡的算术强度鸿沟更可怕。RTX 4090 的 FP32 算力是 A100 的 4.2 倍,带宽却只有 A100 的 65%,机器平衡点高达 81.9 FLOP/Byte。这意味着同一份朴素 kernel 在 4090 上、以峰值百分比衡量会更难看——把”我认为这个 GPU 更快”当成”我的 kernel 会更快”是初学者最常见的误判。

概念 6:Roofline 模型与朴素版的性能天花板(Roofline Model and the 389 GFLOP/s Ceiling)

  • 定义与目的:Roofline 模型把上面两件事画成一张图:横轴是算术强度 AI(对数刻度),纵轴是可达性能(GFLOP/s,对数刻度)。图上有两条”屋顶”:一条水平的算力屋顶(峰值算力),一条斜率为带宽的带宽屋顶性能 = 带宽 × AI)。程序的实际性能上限就是两条屋顶的较小值。它的用途是在做优化之前先算出”最好能到多少”:如果天花板本身只有峰值的 2%,那么无论怎么调 block 大小、怎么调线程数都没用,必须换算法(这就是下一讲分块的全部动机)。

  • 直观解释(”它是什么?”):Roofline 就像给一条公路算出”每小时最多能过多少车”:公路的限速(算力)是一条水平线,收费站的处理速度(带宽)在车流量小时根本不影响通行,但当每辆车都要在收费站停很久(AI 很低)时,通行量就完全由收费站决定。此时的通行量 = 收费站速度 × 每辆车能带多少”有效载荷”。对朴素矩阵乘法来说,收费站(内存)就是唯一的瓶颈,而每 1 Byte 只换来 0.25 FLOP 的”有效载荷”,于是通行量被钉死在 1555 × 0.25

  • 架构/机制图解

   性能 (GFLOP/s, 对数刻度)
     ^
19.5T|============================================================  算力屋顶 (19.5 TFLOPS)
     |                                        *
     |                                       *
     |                                      *
     |                                     *    <-- 屋顶交点 = 机器平衡点
     |                                    *          AI = 12.5 FLOP/Byte
     |                                   *           性能 = 19.5 TFLOPS
     |                                  *
     |                                 *
     |                                *
     |                               *
     |                              *
     |                             *
     |                            *
 389G|---------------------------X------------------------------------
     |                          ^
     |                          |
     |                    朴素矩阵乘法
     |                    AI = 0.25 FLOP/Byte
     |                    (只有峰值的 2%)
     +----------------------------------------------------------> AI (FLOP/Byte, 对数)
        0.25        1        4        12.5      50     170.7
        |<--- 带宽屋顶: 性能 = 1555 GB/s * AI --->|<- 算力屋顶 ->|

天花板的计算(必须写出代入的数字):

  Roofline 上限 = 峰值带宽 * 算术强度
                = 1555 GB/s * 0.25 FLOP/Byte
                = 388.75 GFLOP/s
                ~= 389 GFLOP/s

  占峰值算力的比例 = 389 GFLOP/s / 19500 GFLOP/s
                   = 1.99%  ~= 2%
                  (也就是 98% 的算力因为"喂不饱"而闲置)

  另一种等价算法(更直观):
      需要的带宽 = 峰值算力 / 算术强度 = 19.5e12 / 0.25 = 78,000 GB/s = 78 TB/s
      实际带宽 = 1555 GB/s
      => 只满足了 1555/78000 = 2.0%

  用时间比较(N = 1024):
      访存时间下界 = 8.59 GB / 1555 GB/s       = 5.52 ms
      计算时间下界 = 2.147 GFLOP / 19.5 TFLOPS = 0.110 ms
      比值 = 50.2  -> 算力必须闲置 98% 才等得起内存

把同样的算式套到其他三台机器上,可以看到 Roofline 上限的绝对值虽然不同,但”占峰值的百分比都是个位数”这一结论完全一致:

GPU带宽 × 0.25Roofline 上限占 FP32 峰值注释
RTX 2080 Ti616 × 0.25154 GFLOP/s1.15%2018 年的实验机
A1001555 × 0.25389 GFLOP/s2.0%本课程基准机(NCSA Delta)
H100 SXM3350 × 0.25838 GFLOP/s1.25%算力涨得比带宽快
RTX 40901008 × 0.25252 GFLOP/s0.31%失衡最严重

讲义用更早一代 GPU 给出过同一个结论:1000 GFLOP/s 算力配 150 GB/s 带宽时,(150 / 4) = 37.5 GFLOP/s,而实际运行只有约 25 GFLOP/s(因为”内存并非时刻繁忙”),于是”我们必须大幅削减对全局内存的访问“——这就是从 Lab 2 走向 Lab 3(分块)的原动力。注意讲义那个式子里 150/4 的含义正是本讲的 4 B/FLOP:带宽除以”每 FLOP 需要的字节数”,得到的就是带宽能”养得起”的算力

概念 7:数据复用与分块优化的动机(Data Reuse and the Motivation for Tiling)

  • 定义与目的:概念 4 已经算出 A 的每个元素被重复读 N 次、B 的每个元素被重复读 M 次,这些都是冗余请求。分块(tiling,也叫 blocking)的思路是:把矩阵切成若干小方块(tile),让一个 block 的线程协作地把一小块 A 和一小块 B 从全局内存一次性搬进片上存储,之后所有线程都从片上存储里反复取用,从而把”每个元素被读 N 次”降为”每个元素被读 N/TILE_WIDTH 次”。本概念只做动机与定量收益的推导,具体实现(共享内存数组、协作加载、__syncthreads() 屏障、边界处理)全部留到下一讲。

  • 直观解释(”它是什么?”):这就是”案板“的比喻:现在每个抄写员(线程)都自己跑资料室(全局内存)拿资料,同一份资料被 1024 个人各拿一次。改造方案是给每个小组发一块案板(共享内存,shared memory,延迟只有约 20–30 cycle,而全局内存是 400–800 cycle),派一个人跑一趟把整筐资料搬到案板上,全组人围着案板干活。搬运次数从”人数 × 资料数”降到”资料数 / 每组人数”。分块的效果可以用一个数字概括:TILE_WIDTH = 16 时,全局内存访问量减少 16 倍;32 时减少 32 倍。

  • 架构/机制图解

【不分块:每个线程各自去全局内存取】
   线程(0,0) --\        +----------------------------+
   线程(0,1) ---\       |  全局内存 (DRAM, 400-800   |   A 的每个元素被读 N 次
   线程(0,2) ----\      |  cycle 延迟, 1555 GB/s)    |   B 的每个元素被读 M 次
   线程(0,3) -----\     |                            |   总请求 = 2*M*N*K 次
   (其余线程同理) >---->|                            |
   全部 M*N 个线程 ----/ +----------------------------+

【分块:一个 block 协作搬运,然后反复使用片上数据】
   +---------------------------+        +---------------------------+
   | Block(bx,by) 的 256 个线程 |        | 全局内存                  |
   |  第 m 轮:协作加载          |        |  A 的 TILE x TILE 小块    |
   |    - 每个线程加载 1 个 A    |=======>|  B 的 TILE x TILE 小块    |
   |    - 每个线程加载 1 个 B    |        +---------------------------+
   |  然后 __syncthreads() 等齐  |
   |  再在共享内存上做 TILE 次  |        +---------------------------+
   |  乘加,累加进寄存器 Pvalue  |        | 共享内存 (on-chip, 20-30  |
   |  再 __syncthreads() 防覆盖  |        | cycle 延迟, 每 SM 164 KB) |
   |  进入第 m+1 轮              |        |  subTileM[16][16]  1 KB   |
   +---------------------------+        |  subTileN[16][16]  1 KB   |
                                        +---------------------------+
   全局内存访问量下降 TILE_WIDTH 倍,片上访问量上升 TILE_WIDTH 倍

定量收益(沿用讲义 (带宽/4) × 复用倍数 的算法,并换成 A100 的数字):

  未分块: 每次全局访问只支撑 1 次乘加(2 FLOP / 8 Byte = 0.25 FLOP/Byte)
           A100 上限 = 1555 * 0.25 =  389 GFLOP/s    (峰值的 2.0%)

  TILE_WIDTH = 16:
           每次访存取回的元素被复用 16 次
           AI = 16 * 0.25 = 4 FLOP/Byte
           上限 = 1555 * 4 = 6220 GFLOP/s = 6.22 TFLOP/s   (峰值的 31.9%)
           讲义同款算式(150 GB/s 的老 GPU):(150/4)*16 = 600 GFLOP/s

  TILE_WIDTH = 32:
           AI = 32 * 0.25 = 8 FLOP/Byte
           上限 = 1555 * 8 = 12440 GFLOP/s = 12.44 TFLOP/s  (峰值的 63.8%)
           讲义同款算式(150 GB/s 的老 GPU):(150/4)*32 = 1200 GFLOP/s

  通用公式:AI_tiled = TILE_WIDTH / 4  FLOP/Byte
            Roofline 上限 = 带宽 * TILE_WIDTH / 4

  重要结论:当 TILE_WIDTH = 32 时上限才 12.44 TFLOP/s,仍**低于** A100 的机器平衡点
            12.5 FLOP/Byte 所要求的算力(19.5 TFLOPS),说明仅靠 32x32 分块仍然
            是带宽受限;要到 TILE_WIDTH ≈ 50 以上(或叠加寄存器级复用/线程粗化,
            见代码示例三的【性能优化分析】)才能真正转入计算受限区间。

分块还顺带解决了概念 4 里那个最刺眼的访存模式问题:从全局内存搬运 tile 时,subTileM[ty][tx] = M[Row*Width + m*TILE_WIDTH + tx]tx 变化对应连续地址(合并访问),而”跨步”的那一维被彻底移到了共享内存内部——共享内存的根本机制是 32 个 bank(每个 bank 宽 4 Byte)的并行访问,完全没有”cache line / sector”的概念,”列访问”在共享内存里根本不是问题。讲义把这一步称为 corner turning(转角):把原本在 DRAM 上无法合并的访问模式,在”全局 → 共享”的搬运过程中重新组织成合并的访问模式。这条思路的代价是引入两个新问题:块内同步(谁保证数据已经搬完了?——__syncthreads())与边界处理(Width 不是 TILE_WIDTH 整数倍怎么办),它们分别是下一讲与再下一讲的主题。

代码示例与性能分析

示例 1:朴素矩阵乘法(matmul_naive.cu)

这是 Lab 2 的标准答案骨架:kernel 与讲义 Lecture 5 / Lecture 7 中 “A Simple Matrix Multiplication Kernel” 逐字一致,主机端补齐设备查询、cudaMalloc / cudaMemcpycudaEvent 计时、CPU 参考实现对比、算术强度与 Roofline 上限计算。

// 文件: matmul_naive.cu
// 主题: ECE408 Lecture 4 / Lab 2 -- 朴素矩阵乘法(每线程一个输出元素,不使用共享内存)
// 编译: nvcc -O3 -arch=sm_80 matmul_naive.cu -o matmul_naive
// 运行: ./matmul_naive 1024        (参数为矩阵边长 Width,建议 512 / 1024 / 2048)

#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <ctime>
#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 BLOCK_WIDTH   16    // 16 x 16 = 256 threads = 8 warps per block
#define WARMUP_ITERS   5
#define TIMED_ITERS   20

// ============================== device kernel ==============================
// 与讲义中的 Simple Matrix Multiplication Kernel 完全一致
__global__ void matmulNaiveKernel(const float* __restrict__ d_M,
                                  const float* __restrict__ d_N,
                                  float* __restrict__ d_P,
                                  int Width)
{
    // Calculate the row index of the d_P element and d_M
    int Row = blockIdx.y * blockDim.y + threadIdx.y;
    // Calculate the column index of d_P and d_N
    int Col = blockIdx.x * blockDim.x + threadIdx.x;

    if (Row < Width && Col < Width) {
        float Pvalue = 0.0f;
        // each thread computes one element of the block sub-matrix
        for (int k = 0; k < Width; ++k) {
            Pvalue += d_M[Row * Width + k] * d_N[k * Width + Col];
        }
        d_P[Row * Width + Col] = Pvalue;
    }
}

// ============================== host utilities ==============================
static void initMatrix(float* a, int n, unsigned int seed)
{
    srand(seed);
    for (int i = 0; i < n * n; ++i) {
        a[i] = (float)(rand() % 7) * 0.25f;   // 取值 0.00 ~ 1.50,便于结果校验
    }
}

// CPU 参考实现:i-k-j 顺序,最内层沿行连续访问,比 i-j-k 快得多
static void cpuMatmul(const float* A, const float* B, float* C, int n)
{
    for (int i = 0; i < n * n; ++i) C[i] = 0.0f;
    for (int i = 0; i < n; ++i) {
        for (int k = 0; k < n; ++k) {
            const float  a    = A[i * n + k];
            const float* Brow = B + k * n;
            float*       Crow = C + i * n;
            for (int j = 0; j < n; ++j) {
                Crow[j] += a * Brow[j];
            }
        }
    }
}

// 每个 SM 的 FP32 单元数(估算理论峰值算力用)
static int fp32CoresPerSM(int major, int minor)
{
    if (major == 7) return 64;                        // Volta (sm_70) / Turing (sm_75)
    if (major == 8) return (minor == 0) ? 64 : 128;   // A100 (sm_80)=64, GA10x (sm_86/89)=128
    if (major >= 9) return 128;                       // Hopper (sm_90) 及以后
    return 128;
}

// ================================== main ===================================
int main(int argc, char** argv)
{
    const int    N     = (argc > 1) ? atoi(argv[1]) : 1024;
    const size_t bytes = (size_t)N * (size_t)N * sizeof(float);

    // ---------- 1. 设备属性:理论峰值带宽与峰值算力 ----------
    CUDA_CHECK(cudaSetDevice(0));
    cudaDeviceProp prop;
    CUDA_CHECK(cudaGetDeviceProperties(&prop, 0));

    const double peakBW   = 2.0 * (double)prop.memoryClockRate * 1.0e3
                          * ((double)prop.memoryBusWidth / 8.0) / 1.0e9;   // GB/s
    const double peakFP32 = (double)prop.multiProcessorCount
                          * (double)fp32CoresPerSM(prop.major, prop.minor)
                          * 2.0 * (double)prop.clockRate * 1.0e3 / 1.0e12; // TFLOPS

    printf("== Device ==\n");
    printf("  name                 : %s\n", prop.name);
    printf("  compute capability   : sm_%d%d\n", prop.major, prop.minor);
    printf("  SMs                  : %d\n", prop.multiProcessorCount);
    printf("  core clock           : %.3f GHz\n", prop.clockRate * 1e-6);
    printf("  memory clock         : %.3f GHz, bus %d bit\n",
           prop.memoryClockRate * 1e-6, prop.memoryBusWidth);
    printf("  peak bandwidth       : %.1f GB/s\n", peakBW);
    printf("  peak FP32            : %.2f TFLOPS\n", peakFP32);
    printf("  machine balance      : %.2f FLOP/Byte\n",
           peakFP32 * 1e12 / (peakBW * 1e9));
    printf("  L2 cache             : %.1f MB\n", prop.l2CacheSize / 1048576.0);
    printf("  max threads per SM   : %d\n", prop.maxThreadsPerMultiProcessor);

    // ---------- 2. 主机端数据与 CPU 参考结果 ----------
    float* h_M   = (float*)malloc(bytes);
    float* h_N   = (float*)malloc(bytes);
    float* h_P   = (float*)malloc(bytes);
    float* h_Ref = (float*)malloc(bytes);
    if (!h_M || !h_N || !h_P || !h_Ref) {
        fprintf(stderr, "host malloc failed\n");
        exit(EXIT_FAILURE);
    }
    initMatrix(h_M, N, 1u);
    initMatrix(h_N, N, 2u);

    const clock_t cpuT0 = clock();
    cpuMatmul(h_M, h_N, h_Ref, N);
    const double cpuSec = (double)(clock() - cpuT0) / CLOCKS_PER_SEC;

    // ---------- 3. 设备端分配与拷贝 ----------
    float *d_M = NULL, *d_N = NULL, *d_P = NULL;
    CUDA_CHECK(cudaMalloc((void**)&d_M, bytes));
    CUDA_CHECK(cudaMalloc((void**)&d_N, bytes));
    CUDA_CHECK(cudaMalloc((void**)&d_P, bytes));
    CUDA_CHECK(cudaMemcpy(d_M, h_M, bytes, cudaMemcpyHostToDevice));
    CUDA_CHECK(cudaMemcpy(d_N, h_N, bytes, cudaMemcpyHostToDevice));

    // ---------- 4. 执行配置(讲义原文的写法:向上取整) ----------
    dim3 dimBlock(BLOCK_WIDTH, BLOCK_WIDTH, 1);
    dim3 dimGrid((N + BLOCK_WIDTH - 1) / BLOCK_WIDTH,
                 (N + BLOCK_WIDTH - 1) / BLOCK_WIDTH, 1);
    printf("\n== Launch configuration ==\n");
    printf("  dimBlock = (%u, %u, 1) = %u threads = %u warps\n",
           dimBlock.x, dimBlock.y, dimBlock.x * dimBlock.y,
           dimBlock.x * dimBlock.y / 32);
    printf("  dimGrid  = (%u, %u, 1) = %u blocks\n",
           dimGrid.x, dimGrid.y, dimGrid.x * dimGrid.y);
    printf("  total threads        : %llu\n",
           (unsigned long long)dimGrid.x * dimGrid.y * dimBlock.x * dimBlock.y);

    // ---------- 5. 资源占用(寄存器 / 占用率) ----------
    cudaFuncAttributes attr;
    CUDA_CHECK(cudaFuncGetAttributes(&attr, matmulNaiveKernel));
    int blocksPerSM = 0;
    CUDA_CHECK(cudaOccupancyMaxActiveBlocksPerMultiprocessor(
        &blocksPerSM, matmulNaiveKernel, BLOCK_WIDTH * BLOCK_WIDTH, 0));
    const int threadsPerSM = blocksPerSM * BLOCK_WIDTH * BLOCK_WIDTH;
    printf("\n== Occupancy ==\n");
    printf("  registers per thread : %d\n", attr.numRegs);
    printf("  static shared memory : %zu Byte\n", attr.sharedSizeBytes);
    printf("  blocks per SM        : %d\n", blocksPerSM);
    printf("  threads per SM       : %d  -> occupancy %.1f%%\n",
           threadsPerSM,
           100.0 * threadsPerSM / (double)prop.maxThreadsPerMultiProcessor);

    // ---------- 6. 计时:先热身,再取多次平均 ----------
    for (int i = 0; i < WARMUP_ITERS; ++i) {
        matmulNaiveKernel<<<dimGrid, dimBlock>>>(d_M, d_N, d_P, N);
    }
    CUDA_CHECK(cudaGetLastError());
    CUDA_CHECK(cudaDeviceSynchronize());

    cudaEvent_t evStart, evStop;
    CUDA_CHECK(cudaEventCreate(&evStart));
    CUDA_CHECK(cudaEventCreate(&evStop));
    CUDA_CHECK(cudaEventRecord(evStart));
    for (int i = 0; i < TIMED_ITERS; ++i) {
        matmulNaiveKernel<<<dimGrid, dimBlock>>>(d_M, d_N, d_P, N);
    }
    CUDA_CHECK(cudaEventRecord(evStop));
    CUDA_CHECK(cudaEventSynchronize(evStop));
    float totalMs = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&totalMs, evStart, evStop));
    const double ms = (double)totalMs / TIMED_ITERS;

    // ---------- 7. 结果校验 ----------
    CUDA_CHECK(cudaMemcpy(h_P, d_P, bytes, cudaMemcpyDeviceToHost));
    double maxAbsErr = 0.0, maxRef = 0.0;
    for (int i = 0; i < N * N; ++i) {
        const double e = fabs((double)h_P[i] - (double)h_Ref[i]);
        if (e > maxAbsErr) maxAbsErr = e;
        if (fabs((double)h_Ref[i]) > maxRef) maxRef = fabs((double)h_Ref[i]);
    }
    const double relErr = (maxRef > 0.0) ? (maxAbsErr / maxRef) : maxAbsErr;

    // ---------- 8. 性能、算术强度与 Roofline 上限 ----------
    const double flops       = 2.0 * (double)N * N * N;
    const double bytesNeeded = 4.0 * flops;                     // 4 Byte per FLOP
    const double gflops      = flops / (ms * 1.0e-3) / 1.0e9;
    const double reqBW       = bytesNeeded / (ms * 1.0e-3) / 1.0e9;
    const double ai          = flops / bytesNeeded;             // = 0.25 FLOP/Byte
    const double roofCap     = peakBW * ai;
    const double peakGF      = peakFP32 * 1.0e3;

    printf("\n== Correctness ==\n");
    printf("  max abs error        : %.6e  (relative %.3e)\n", maxAbsErr, relErr);
    printf("  CPU reference time   : %.3f s\n", cpuSec);
    printf("  verification         : %s\n", (relErr < 1e-4) ? "PASS" : "FAIL");

    printf("\n== Performance (average of %d runs) ==\n", TIMED_ITERS);
    printf("  kernel time          : %.4f ms\n", ms);
    printf("  GFLOP/s              : %.2f\n", gflops);
    printf("  arithmetic intensity : %.3f FLOP/Byte  (4 Byte per FLOP)\n", ai);
    printf("  requested bytes      : %.3f GB  (2*N^3 loads x 4 Byte)\n",
           bytesNeeded / 1.0e9);
    printf("  requested bandwidth  : %.1f GB/s  (%.1f%% of peak)\n",
           reqBW, 100.0 * reqBW / peakBW);

    printf("\n== Roofline ==\n");
    printf("  machine balance      : %.1f FLOP/Byte vs naive AI %.2f -> memory bound\n",
           peakFP32 * 1e12 / (peakBW * 1e9), ai);
    printf("  roofline ceiling     : %.1f GFLOP/s  (%.1f GB/s x %.2f FLOP/Byte)\n",
           roofCap, peakBW, ai);
    printf("  ceiling as %% of peak: %.2f%%\n", 100.0 * roofCap / peakGF);
    printf("  measured / ceiling   : %.2f%%   <-- 实测性能占 Roofline 上限的比例\n",
           100.0 * gflops / roofCap);
    printf("  measured / peak      : %.2f%%\n", 100.0 * gflops / peakGF);

    // ---------- 9. 释放资源 ----------
    CUDA_CHECK(cudaEventDestroy(evStart));
    CUDA_CHECK(cudaEventDestroy(evStop));
    CUDA_CHECK(cudaFree(d_M));
    CUDA_CHECK(cudaFree(d_N));
    CUDA_CHECK(cudaFree(d_P));
    free(h_M); free(h_N); free(h_P); free(h_Ref);
    CUDA_CHECK(cudaDeviceReset());
    return 0;
}

【代码做什么?】

  1. 设备查询(步骤 1)cudaGetDeviceProperties 取回 SM 数、时钟、显存位宽与等效显存时钟,据此算出理论峰值带宽 2 × memoryClockRate × (busWidth/8)(乘 2 是因为 DDR 接口每个时钟沿传两次数据)与理论峰值 FP32 算力 SM 数 × 每 SM FP32 单元数 × 2 × 时钟。这两个数字是后面所有百分比的分母,不查设备就只能靠猜。
  2. 主机端准备(步骤 2):用 initMatrix 生成两份随机小数值矩阵(元素是 0.25 的整数倍,避免浮点误差掩盖逻辑错误),再用 cpuMatmul 算出参考结果 h_Ref。CPU 版用 i-k-j 循环顺序——内层沿 B 的行连续访问,比教科书的 i-j-k 快 5–10 倍,否则光等参考结果就要几分钟。
  3. 内存分配与拷贝(步骤 3)cudaMalloc 在显存里开三块 N*N*4 字节的缓冲区(M、N、P),cudaMemcpy(d_M, h_M, bytes, cudaMemcpyHostToDevice) 把两个输入搬进去。注意 P(输出)不需要初始值,也不需要拷进去。
  4. 执行配置(步骤 4)dimBlock = (16,16,1)(256 线程 / block),dimGrid = (ceil(N/16), ceil(N/16), 1)。设 16 的原因是:一个 block 有 256 线程,恰好 8 个完整 warp;同时 16×16 的 tile 会让 warp 内部出现”两行 16 列”的结构,正好暴露跨步访存(见下一小节)。向上取整保证 grid 覆盖整个矩阵,多出来的线程由 kernel 里的 if (Row < Width && Col < Width) 挡掉。
  5. kernel 执行流程:每个线程先用两条乘法加法算出自己负责的 (Row, Col);然后在 k 从 0 到 Width−1 的循环里,每次从全局内存取 d_M[Row*Width + k]d_N[k*Width + Col] 各一个 float,做一次乘加累加进寄存器 Pvalue;循环结束后把结果写回 d_P[Row*Width + Col]。寄存器压力极小(Pvaluek、两个基址),这也是它占用率能做到 100% 的原因。
  6. 计时(步骤 6):先热身 5 次(触发 CUDA 上下文与页表的一次性开销),再用 cudaEventRecord / cudaEventElapsedTime 计时 20 次取平均。cudaEvent 记录的是设备侧时间戳,比用 clock()gettimeofday() 包住 kernel 启动准确得多(后者会把主机端 API 开销和异步执行的误差算进去)。
  7. 校验(步骤 7):把 d_P 拷回主机,与 h_Ref 逐元素比较,输出最大绝对误差、相对误差与 PASS/FAIL。
  8. 性能与 Roofline(步骤 8):由 FLOPs = 2N³Bytes = 4 × FLOPs 反推 GFLOP/s、请求带宽、算术强度,再算出 Roofline 上限 peakBW × AI 以及”实测占上限的百分比”——这是本讲最重要的一个输出。

【并行机制与硬件映射解说】

  • warp 划分dimBlock = (16,16) → 256 线程 → 8 个 warp,划分规则是 tid = ty * 16 + txwarp = tid / 32,所以 warp 0 = ty∈{0,1} 的两行共 32 个线程,warp 1 = ty∈{2,3},依此类推(参见概念 3 的图)。这个”一个 warp 横跨两行 ty”的结构是下面所有 transaction 计算的起点。
  • warp 如何被调度:这 8 个 warp 被分配到 SM 的 4 个 warp scheduler 上(每个 scheduler 管一批 warp,轮流发射指令)。A100 每个 SM 最多驻留 64 个 warp / 2048 个线程,256 线程的 block 因此在线程数上限下最多驻留 8 个 block;寄存器上限方面,若编译器分配 24 个寄存器/线程,65536 / (24 × 256) = 10.6 → 也不构成限制,两者取小仍为 8 个 block。
  • 占用率(occupancy)定量8 blocks × 256 threads = 2048 threads = 64 warps = 100%(A100 满占用率)。但要注意一个临界点:如果寄存器用量超过 32 个/线程,占用率就会掉。例如 40 寄存器 → 65536 / (40 × 256) = 6.4 → 6 个 block → 1536 线程 → 75%;48 寄存器 → 5 个 block → 62.5%。示例 1 打印的 registers per threadblocks per SM 就是为了让学生亲眼看到这条链。
  • 逐 warp 分析对 B(d_N)的访问模式:在固定 k 上,warp 0 的 32 个线程访问 d_N[k*Width + Col],其中 Col = bx*16 + tx地址表达式里没有 ty,所以 ty=0 组的 16 个线程与 ty=1 组的 16 个线程访问的地址集合完全相同:一共 16 个互不相同的地址,且相邻(tx 每 +1,地址 +4 Byte)。16 个连续 float = 64 Byte,落在同一(或相邻的)32 B sector 上,硬件把它们合并成 1–2 条取数请求,取回 64 Byte 全部有用——B 的访问是合并的(coalesced),效率 100%。这也解释了为什么 threadIdx.x 必须映射到”内存中连续的那一维”:Lecture 7 的思考题正是问 B[ty*Width+tx]B[tx*Width+ty] 哪个快,答案是前者,因为”相邻的 x 通常意味着同一个 warp”。
  • 逐 warp 分析对 A(d_M)的访问模式d_M[Row*Width + k] 的地址只依赖 ty。warp 0 里 ty 只有两个取值(t0 与 t0+1),于是 32 个线程只产生 2 个不同的地址,每个地址被 16 个线程重复请求(硬件做广播,这不会增加流量)。但这两个地址相差 Width*4 = 4096 Byte = 32 条 128 B cache line,落在两个不同的 sector 上,硬件必须发 2 次取数请求,取回 2×32 = 64 Byte,而其中只有 2×4 = 8 Byte 有用——A 的访问是不合并的,效率 12.5%
  • 一个 warp 在固定 k 上的总账:取回 64(A)+ 64(B)= 128 Byte,有用 8 + 64 = 72 Byte,整体效率 56%;平均到每条 load 指令上,A 那条 load 要占 2 个 L1 wavefront(两个不同 cache line),B 那条占 1 个。按 N = 1024 估算:warp 级 load 指令数 2N³/32 = 67.1M,其中 A 的一半各耗 2 个 wavefront → 总 wavefront ≈ 67.1M + 33.6M = 100.7M;A100 的 L1 吞吐约为 108 SM × 1 wavefront/cycle × 1.41 GHz = 152 G wavefront/s,折合 0.66 ms——远小于按 DRAM 带宽算出的 5.52 ms 下界。结论很清楚:L1 不是瓶颈,L2/DRAM 才是
  • 寄存器与 warp 发散:kernel 只用一个累加寄存器 Pvalue 加少量索引寄存器,实测通常 20–30 个,不会 spill 到 local memory。发散方面,Width 为 16 的整数倍时 if 判断对所有线程一致(全部进入),零发散;只有 Width 不是 16 的整数倍时,最右边/最下边的 block 才会出现部分线程被屏蔽的情况,代价被摊薄到整个 grid 上,通常可以忽略。
  • A100 上的驻留规模:8 个 block/SM × 108 SM = 864 个 block 并发,而 grid 共有 64 × 64 = 4096 个 block,因此要跑 4096/864 ≈ 4.74 个波次(wave)。每个波次结束时会有”尾巴效应”(tail effect),大约浪费半个波次的时间,这也是 N 很小时实测性能偏差大的原因之一。

【性能优化分析】

先看定量结论(下面括号里是 A100 上 Width = 1024 的典型实测值区间,不同编译器版本会有差异):

  • 算术强度AI = FLOP / Byte = 2MNK / (2MNK × 4) = 0.25 FLOP/Byte。代入 M = N = K = 1024:FLOPs = 2.147e9请求字节 = 8.59 GB2.147e9 / 8.59e9 = 0.25
  • 机器平衡点19.5 TFLOPS / 1555 GB/s = 12.5 FLOP/Byte0.25 << 12.5,相差 50 倍
  • Roofline 上限1555 GB/s × 0.25 FLOP/Byte = 388.75 ≈ 389 GFLOP/s,只占峰值算力 19.5 TFLOPS2.0%。也就是说,这台 GPU 有 98% 的算力注定闲置
  • 实测定位:A100 上朴素版通常落在 50–150 GFLOP/s,即 Roofline 上限的 13%–39%、峰值的 0.3%–0.8%。达不到 389 GFLOP/s 的原因有二:(1) 上面算出的 56% sector 效率意味着实际搬运量大于请求量;(2) N = 1024 时三个矩阵共 12 MB 能装进 40 MB 的 L2,L2 命中会”帮倒忙式”地让 DRAM 显得没那么紧张,但也让访存延迟与 L1/L2 吞吐成为第二重限制。把 N 提高到 4096 再测,会更接近纯 DRAM 模型的预测。
  • 瓶颈判定内存带宽受限(memory-bandwidth-bound),而且是最坏的那种——既受 DRAM 带宽限制,又因为跨步访问浪费 sector,还因为重复请求放大了总量。占用率 100% 说明延迟不是问题占用率不是问题,任何”调 block 大小 / 加 __launch_bounds__ / 提高占用率”的手段都不会有实质收益(占用率优化的适用场景是”延迟受限”的 kernel)。
  • Little’s Law 复核:要在 1555 GB/s 下工作、面对约 400–800 cycle(1.41 GHz 下约 284–567 ns)的全局内存延迟,GPU 需要同时在飞(in-flight)1555e9 × 300e-9 ≈ 466 KB 的数据,平均到 108 个 SM 约 4.3 KB ≈ 34 条 cache line。8 个 block × 256 线程 × 2 条在飞 load ≈ 4096 个请求,足以满足——所以”加更多线程”救不了它,问题在请求本身的”质量”与”数量”

三种改进方向(本讲给出方向,实现分别落在后续实验)

  1. 把跨步的列访问改成合并访问。手段有两种:(a) 改变数据布局——例如把 A 以转置形式(M_T)提供,让 A 的地址变成 M_T[k*Width + Row],warp 内两个地址从”相距 4096 B”变成”相距 4 B”,transaction 从 2 次降到 1 次、取回字节从 64 B 降到 32 B(效率 12.5% → 25%);(b) 让每个线程负责输出的一整行/一整块,把”按行复用 A、按列复用 B”的冲突用寄存器摊平。但要清醒地认识到:在 thread-per-output 模式下,无论怎么重排映射,都只能把跨步访问从一个操作数搬到另一个操作数(概念 4 的表格里四种情形全部逃不掉),而且它只把每 warp 每 k 的搬运量从 128 B 降到 96 B(1.33 倍),改不动”请求总量 = 2MNK”这个根本问题。示例 3 会用实测数据证明这一点。
  2. 引入共享内存做分块(tiling)以实现数据复用,这是下一讲的主题。它把全局内存请求量降低 TILE_WIDTH 倍(16×16 分块 → 降低 16 倍,算术强度从 0.25 提升到 4 FLOP/Byte,Roofline 上限从 389 GFLOP/s 提升到 6.22 TFLOP/s),并且顺手把跨步访问”关进”共享内存——共享内存没有 sector 概念,只有 32 个 bank 的并行访问,跨步在片上不再是灾难。这是唯一同时解决”流量总量”和”访问模式”两个问题的路线。
  3. 线程粗化(thread coarsening)让每个线程算多个输出元素,把 A 的行元素与 B 的列元素在寄存器里复用。设每个线程算 r 行 × c 列共 r*c 个输出,则每个 k 需要 r + c 次 load、做 2*r*c 次 FLOP,于是
   粗化后的算术强度 AI(r, c) = 2*r*c FLOP / (4*(r + c) Byte)
                             = r*c / (2*(r + c))  FLOP/Byte

   例:r=1, c=4 (线程沿一行算 4 个输出)
       AI = 4 / (2*5) = 0.4 FLOP/Byte       (比 0.25 好 1.6 倍,仍远远不够)
   例:r=4, c=4 (4x4 寄存器块,每 k 只读 8 个 float、做 32 次 FLOP)
       AI = 16 / (2*8) = 1.0 FLOP/Byte      (比 0.25 好 4 倍)
   例:r=8, c=8
       AI = 64 / (2*16) = 2.0 FLOP/Byte     (仍只有机器平衡点的 1/6)

   与共享内存分块叠加时(block 用 T x T 线程,每线程算 r x c 个输出):
       AI(T, r, c) = 2*T^3*r*c FLOP / (4*T^2*(r+c) Byte) = T*r*c / (2*(r+c)) FLOP/Byte
       例:T=16, r=4, c=4  ->  AI = 16*16 / (2*8) = 16.0 FLOP/Byte > 12.5
       => 这时才真正跨过 A100 的机器平衡点,进入计算受限区间

也就是说:寄存器粗化自己只能把强度抬高到 1–2 FLOP/Byte,必须与共享内存分块叠加(16×16 分块 + 4×4 寄存器块 → 16 FLOP/Byte)才能真正摆脱带宽的统治。这是本课程后半段所有优化技巧的主线。

示例 2:跨步访存诊断实验(matmul_diag.cu)

这个程序不做矩阵乘法,它把三个”纯访存”的探针放在一起跑,用实测带宽证明:”地址连续”与”地址跨步”在 GPU 上是两种完全不同的物理事件。三个探针读完全相同的数据量,唯一的区别是 warp 内 32 个线程的地址分布。

// 文件: matmul_diag.cu
// 主题: ECE408 Lecture 4 -- 诊断实验:合并访问 vs 跨步访问(stride = Width)的带宽代价
// 编译: nvcc -O3 -arch=sm_80 matmul_diag.cu -o matmul_diag
// 运行: ./matmul_diag 4096
//       参数为探测矩阵边长 W,默认 4096(单矩阵 64 MB,远超 A100 的 40 MB L2,
//       只有把工作集赶出 cache 才能观察到真正的 DRAM 行为)

#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 PROBE_BLOCK   256
#define TILE_W         16
#define STREAM_ITERS   10

// ---------------- 探针 1:完全合并的顺序读(每 warp 128 Byte 连续) ----------------
__global__ void streamReadKernel(const float* __restrict__ in,
                                 float* __restrict__ out, int n)
{
    int g = blockIdx.x * blockDim.x + threadIdx.x;
    if (g < n) out[g] = in[g] * 2.0f;
}

// ---------------- 探针 2:跨步读(相邻线程地址相距 Width*4 Byte) ----------------
__global__ void stridedReadKernel(const float* __restrict__ in,
                                  float* __restrict__ out, int Width)
{
    const int g = blockIdx.x * blockDim.x + threadIdx.x;
    const int n = Width * Width;
    if (g < n) {
        const int Row = g / Width;    // 与 thread-per-output 中的输出行号同构
        const int Col = g % Width;    // 与 thread-per-output 中的输出列号同构
        out[g] = in[Col * Width + Row] * 2.0f;   // 相邻线程(Col+1)地址 + Width*4 Byte
    }
}

// ---------------- 探针 3:朴素矩阵乘法的访存流量(保留访问模式,去掉 FLOP) ----------------
__global__ void naiveLoadsOnlyKernel(const float* __restrict__ d_M,
                                     const float* __restrict__ d_N,
                                     float* __restrict__ out, int Width)
{
    const int Row = blockIdx.y * blockDim.y + threadIdx.y;
    const int Col = blockIdx.x * blockDim.x + threadIdx.x;
    if (Row < Width && Col < Width) {
        float acc = 0.0f;
        for (int k = 0; k < Width; ++k) {
            acc += d_M[Row * Width + k] + d_N[k * Width + Col];
        }
        out[Row * Width + Col] = acc;
    }
}

// ============================== 校验(抽样) ==============================
static void verifyStream(const float* h_out, const float* h_in, int n)
{
    int bad = 0;
    for (int g = 0; g < n; g += 9973) {
        if (h_out[g] != h_in[g] * 2.0f) ++bad;
    }
    printf("  verification         : %s (sampled)\n", (bad == 0) ? "PASS" : "FAIL");
}

static void verifyStrided(const float* h_out, const float* h_in, int W)
{
    const int n = W * W;
    int bad = 0;
    for (int g = 0; g < n; g += 9973) {
        const int Row = g / W;
        const int Col = g % W;
        if (h_out[g] != h_in[Col * W + Row] * 2.0f) ++bad;
    }
    printf("  verification         : %s (sampled)\n", (bad == 0) ? "PASS" : "FAIL");
}

static void verifyLoadsOnly(const float* h_out, const float* h_in, int W)
{
    const int n = W * W;
    int bad = 0;
    for (int g = 0; g < n; g += 262147) {
        const int Row = g / W;
        const int Col = g % W;
        double expect = 0.0;
        for (int k = 0; k < W; ++k) {
            expect += (double)h_in[Row * W + k] + (double)h_in[k * W + Col];
        }
        const double diff = (double)h_out[g] - expect;
        if (fabs(diff) > 1e-3 * fabs(expect) + 1.0) ++bad;
    }
    printf("  verification         : %s (sampled)\n", (bad == 0) ? "PASS" : "FAIL");
}

// ================================== main ===================================
int main(int argc, char** argv)
{
    const int    W     = (argc > 1) ? atoi(argv[1]) : 4096;
    const int    n     = W * W;
    const size_t bytes = (size_t)W * (size_t)W * sizeof(float);

    CUDA_CHECK(cudaSetDevice(0));
    cudaDeviceProp prop;
    CUDA_CHECK(cudaGetDeviceProperties(&prop, 0));
    const double peakBW = 2.0 * (double)prop.memoryClockRate * 1.0e3
                        * ((double)prop.memoryBusWidth / 8.0) / 1.0e9;

    printf("== Device ==\n");
    printf("  %s\n", prop.name);
    printf("  peak bandwidth       : %.1f GB/s\n", peakBW);
    printf("  L2 cache             : %.1f MB\n", prop.l2CacheSize / 1048576.0);
    printf("  probe width W        : %d   (one matrix = %.1f MB)\n",
           W, bytes / 1048576.0);
    printf("  stride between adjacent threads in probe 2: W*4 = %d Byte\n", W * 4);

    // ---------- 主机数据 ----------
    float* h_in  = (float*)malloc(bytes);
    float* h_out = (float*)malloc(bytes);
    if (!h_in || !h_out) { fprintf(stderr, "host malloc failed\n"); exit(EXIT_FAILURE); }
    for (int i = 0; i < n; ++i) h_in[i] = (float)(i % 17) * 0.125f;

    float *d_in = NULL, *d_out = NULL;
    CUDA_CHECK(cudaMalloc((void**)&d_in, bytes));
    CUDA_CHECK(cudaMalloc((void**)&d_out, bytes));
    CUDA_CHECK(cudaMemcpy(d_in, h_in, bytes, cudaMemcpyHostToDevice));

    cudaEvent_t evA, evB;
    CUDA_CHECK(cudaEventCreate(&evA));
    CUDA_CHECK(cudaEventCreate(&evB));

    const int gridStream = (n + PROBE_BLOCK - 1) / PROBE_BLOCK;
    const double usefulBytes = (double)bytes;      // 每个探针"有用"的读字节数

    // ================= 探针 1:顺序(合并)读 =================
    for (int i = 0; i < 2; ++i) {
        streamReadKernel<<<gridStream, PROBE_BLOCK>>>(d_in, d_out, n);
    }
    CUDA_CHECK(cudaDeviceSynchronize());
    CUDA_CHECK(cudaEventRecord(evA));
    for (int i = 0; i < STREAM_ITERS; ++i) {
        streamReadKernel<<<gridStream, PROBE_BLOCK>>>(d_in, d_out, n);
    }
    CUDA_CHECK(cudaEventRecord(evB));
    CUDA_CHECK(cudaEventSynchronize(evB));
    float ms1 = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&ms1, evA, evB));
    ms1 /= STREAM_ITERS;
    CUDA_CHECK(cudaMemcpy(h_out, d_out, bytes, cudaMemcpyDeviceToHost));
    const double bw1 = usefulBytes / (ms1 * 1.0e-3) / 1.0e9;

    printf("\n== Probe 1: coalesced sequential read ==\n");
    printf("  read bytes per iter  : %.1f MB\n", usefulBytes / 1048576.0);
    printf("  time                 : %.4f ms\n", ms1);
    printf("  achieved bandwidth   : %.1f GB/s  (%.1f%% of peak)\n",
           bw1, 100.0 * bw1 / peakBW);
    printf("  model                : 32 threads x 4 B = 128 B = 4 sectors, 100%% useful\n");
    verifyStream(h_out, h_in, n);

    // ================= 探针 2:跨步读 =================
    for (int i = 0; i < 2; ++i) {
        stridedReadKernel<<<gridStream, PROBE_BLOCK>>>(d_in, d_out, W);
    }
    CUDA_CHECK(cudaDeviceSynchronize());
    CUDA_CHECK(cudaEventRecord(evA));
    for (int i = 0; i < STREAM_ITERS; ++i) {
        stridedReadKernel<<<gridStream, PROBE_BLOCK>>>(d_in, d_out, W);
    }
    CUDA_CHECK(cudaEventRecord(evB));
    CUDA_CHECK(cudaEventSynchronize(evB));
    float ms2 = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&ms2, evA, evB));
    ms2 /= STREAM_ITERS;
    CUDA_CHECK(cudaMemcpy(h_out, d_out, bytes, cudaMemcpyDeviceToHost));
    const double bw2 = usefulBytes / (ms2 * 1.0e-3) / 1.0e9;

    printf("\n== Probe 2: strided read (stride = W*4 Byte) ==\n");
    printf("  read bytes per iter  : %.1f MB\n", usefulBytes / 1048576.0);
    printf("  time                 : %.4f ms\n", ms2);
    printf("  achieved bandwidth   : %.1f GB/s  (%.1f%% of peak)\n",
           bw2, 100.0 * bw2 / peakBW);
    printf("  model                : 32 addresses spaced %d B apart\n", W * 4);
    printf("                         -> 32 sectors x 32 B = 1024 B fetched, 128 B useful\n");
    printf("                         -> sector efficiency 12.5%%, i.e. 8x waste\n");
    printf("  slowdown vs probe 1  : %.2fx\n", ms2 / ms1);
    verifyStrided(h_out, h_in, W);

    // ================= 探针 3:朴素矩阵乘法的访存流量 =================
    dim3 dimBlock(TILE_W, TILE_W, 1);
    dim3 dimGrid((W + TILE_W - 1) / TILE_W, (W + TILE_W - 1) / TILE_W, 1);
    CUDA_CHECK(cudaEventRecord(evA));
    naiveLoadsOnlyKernel<<<dimGrid, dimBlock>>>(d_in, d_in, d_out, W);
    CUDA_CHECK(cudaEventRecord(evB));
    CUDA_CHECK(cudaEventSynchronize(evB));
    float ms3 = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&ms3, evA, evB));
    CUDA_CHECK(cudaMemcpy(h_out, d_out, bytes, cudaMemcpyDeviceToHost));

    const double requested3  = 2.0 * (double)W * W * W * 4.0;      // 2*W^3 次 load
    const double warps3      = (double)(dimGrid.x * dimGrid.y) * 8.0;
    const double modelFetch3 = warps3 * (double)W * 128.0;         // 每 warp 每 k 128 B
    const double bw3         = requested3 / (ms3 * 1.0e-3) / 1.0e9;

    printf("\n== Probe 3: naive matmul access pattern without FMA ==\n");
    printf("  grid                 : (%u, %u, 1), block (%u, %u, 1)\n",
           dimGrid.x, dimGrid.y, dimBlock.x, dimBlock.y);
    printf("  requested bytes      : %.2f GB  (2*W^3 loads x 4 Byte)\n",
           requested3 / 1.0e9);
    printf("  modeled fetched bytes: %.2f GB  (warps x W x 128 B per warp per k)\n",
           modelFetch3 / 1.0e9);
    printf("  time                 : %.4f ms\n", ms3);
    printf("  requested bandwidth  : %.1f GB/s  (%.1f%% of peak)\n",
           bw3, 100.0 * bw3 / peakBW);
    printf("  DRAM-bound lower bnd : %.4f ms  (modeled fetch / peak BW)\n",
           modelFetch3 / (peakBW * 1e9) * 1.0e3);
    verifyLoadsOnly(h_out, h_in, W);

    // ---------- 释放 ----------
    CUDA_CHECK(cudaEventDestroy(evA));
    CUDA_CHECK(cudaEventDestroy(evB));
    CUDA_CHECK(cudaFree(d_in));
    CUDA_CHECK(cudaFree(d_out));
    free(h_in);
    free(h_out);
    CUDA_CHECK(cudaDeviceReset());
    return 0;
}

【代码做什么?】

  1. 探针 1(streamReadKernel:建立”这台 GPU 在最优访存模式下能跑多快”的标尺。线程 g 读写 in[g] / out[g],warp 内 32 个线程访问 32 个连续 float = 128 Byte 连续地址,这是教科书式的完美合并访问;把它测出来的带宽当作 100% 基准。
  2. 探针 2(stridedReadKernel:故意制造跨步。线程编号 g 被解释成 (Row, Col) = (g/W, g%W),但真正访问的是 in[Col*W + Row]——也就是把矩阵当作”列优先”来读。相邻线程(Col 相差 1)的地址相差 W*4 = 16384 Byte。数据总量、指令条数、线程数都与探针 1 完全相同,唯一的差别是地址分布,因此两者的时间差就是”不合并”的纯粹代价。
  3. 探针 3(naiveLoadsOnlyKernel:把示例 1 的 kernel 原封不动搬过来,只把 Pvalue += d_M[Row*Width + k] * d_N[k*Width + Col] 换成 acc += d_M[Row*Width + k] + d_N[k*Width + Col](保留全部 load,去掉乘法)。这样测出的就是”朴素矩阵乘法的访存流量本身要花多少时间“。它可以和矩阵乘法的实测时间对比:如果二者接近,就证明矩阵乘法几乎完全在等内存。
  4. 计时与数据量统计:三个探针都用 cudaEvent 计时(探针 1、2 各热身 2 次、取 10 次平均;探针 3 只跑一次,因为它本身就要几百毫秒)。探针 3 另外打印两个关键的建模数字:请求字节数 2W³×4(软件发出的 load 量)与建模取回字节数 warps × W × 128 B(硬件按 sector 实际搬运的量)。
  5. 主机端抽样校验:三个探针都按固定步长抽样若干元素,用 CPU 重算同样的表达式逐位比较,输出 PASS/FAIL——确认”我们测的确实是我们要测的那个访问模式”。
  6. 资源释放cudaEventDestroy / cudaFree / cudaDeviceReset

【并行机制与硬件映射解说】

  • 探针 1 的 warp 访问分布:线程 g = 0..31 组成一个 warp,访问地址 base + g*4,跨度 128 Byte,恰好覆盖 4 个 32 B sector、通常落在 1 条 128 B cache line 内。硬件合并成最少量的请求,取回 128 Byte 全部有用。这类访问能把 A100 推到峰值带宽的 85%–95%(约 1300–1480 GB/s),差的那部分来自 DRAM 刷新、行切换与 ECC 开销——任何程序都不可能超过探针 1 的实测值,这是它作为基准的意义。
  • 探针 2 的 warp 访问分布(这是本实验的核心):
     线程 g 与其访问的地址(W = 4096,同一 warp 内 Row 不变、Col 递增)
       lane  0 -> 地址 = (Col+0)*4096 + Row   -> 偏移 0        Byte
       lane  1 -> 地址 = (Col+1)*4096 + Row   -> 偏移 16384    Byte
       lane  2 -> 地址 = (Col+2)*4096 + Row   -> 偏移 32768    Byte
       lane  3 -> 地址 = (Col+3)*4096 + Row   -> 偏移 49152    Byte
       (lane 4 到 lane 30 完全同理,每增一个 lane 偏移增加 16384 Byte)
       lane 31 -> 地址 = (Col+31)*4096 + Row  -> 偏移 507904   Byte
    
     +----+     +----+     +----+                        +----+
     \| 4B \|     \| 4B \|     \| 4B \|                        \| 4B \|     每个方块是一个 32 B sector
     +----+     +----+     +----+                        +----+
     0 B       16384 B    32768 B                    507904 B
     \|<-------------------- 一次 warp 请求覆盖 512 KB 的地址范围 ------------------->\|
    
     请求数(transaction)= 32 个互不相邻的 sector
     取回字节 = 32 x 32 B = 1024 B
     有用字节 = 32 x 4 B  =  128 B
     效率     = 12.5%      -> 理论减速 8 倍
    

    注意一个容易被忽略的细节:探针 2 的out[g])是完全合并的,只有跨步;因此它的实测带宽大约落在探针 1 的 1/4 到 1/8 之间——因为每 128 Byte 有用数据里还夹着 128 Byte 的有用写入,而写是高效的。把读写分开看,读侧的效率就是 12.5%。

  • 探针 3 的 warp 访问分布(与示例 1 的逐 warp 分析一致,此处按”整块 block”再算一次总账):16×16 block 共 8 个 warp,每个 warp 在每次 k 上对 A 产生 2 个相距 4096 Byte 的地址(2 个 sector)、对 B 产生 16 个连续地址(2 个 sector)。于是
   每 warp 每 k 取回   = 64 B (A) + 64 B (B) = 128 B
   每 warp 每 k 有用   =  8 B (A) + 64 B (B) =  72 B
   整个 grid 的 warp 数 = (W/16)^2 blocks * 8 warps = 65536 * 8 = 524288 (W = 4096)
   建模取回总量        = 524288 * 4096 * 128 B = 274.9 GB
   按峰值带宽的访存下界 = 274.9 GB / 1555 GB/s = 176.8 ms
   请求总量(软件视角) = 2 * 4096^3 * 4 B = 549.8 GB

这两个数字的对比很有教育意义:软件请求了 549.8 GB,硬件最少要搬 274.9 GB(因为 L1/L2 会把重复请求与相邻请求合并掉一些),而真正跑矩阵乘法时还要再加上 FMA 的时间。这正是”访存爆炸”的量化形态。

  • 占用率与延迟:三个探针都是纯访存 kernel,寄存器用量个位数、占用率接近 100%,因此延迟被大量并行请求掩盖得很好——这一点很重要,它说明”性能差”不是没藏住延迟,而是带宽真的被浪费掉了。用 Little’s Law 估算:要让 1555 GB/s 的带宽跑满、面对 400–800 cycle(284–567 ns)的延迟,GPU 需要约 1555e9 × 300e-9 ≈ 466 KB 的数据同时在飞,即约 3640 条 128 Byte 请求在途;探针 1 有 16,777,216 个线程、每个线程各发出 1 条 load(其中驻留在 SM 上的那部分同时在飞),请求数远超所需,因此延迟被充分掩盖。
  • cache 的影响必须诚实说明:W = 4096 时单矩阵 64 MB,两个矩阵 128 MB,远超 40 MB 的 L2,所以探针 1、2 测的是真 DRAM;如果误用 W = 1024(单矩阵 4 MB),探针 2 的”跨步惩罚”会被 L2 命中大幅削弱(跨步访问取回的 32 B sector 里剩下的 28 B 后续会被用到),测出来的减速可能只有 1.5–3 倍而不是 8 倍。做访存实验时,工作集大小与 L2 容量的关系是必须先想清楚的实验参数。

【性能优化分析】

  • 占用率:三个探针都 > 90%,示例 1 的矩阵乘法也是 100%。占用率不是瓶颈,这不是一个”延迟受限(latency-bound)”的 kernel。
  • 算术强度:探针 1、2 的强度是 1 FLOP / 8 Byte = 0.125 FLOP/Byte(乘 2 是 1 次 FLOP);示例 1 的矩阵乘法是 0.25 FLOP/Byte;A100 的机器平衡点是 12.5 FLOP/Byte——三者都远在带宽受限区(探针 3 干脆是 0 FLOP/Byte 的纯流量实验)。
  • Roofline 交叉验证:探针 1 实测约 85%–95% 峰值带宽,说明”屋顶”是真实的;由它推出的矩阵乘法上限 1555 × 0.25 = 389 GFLOP/s 因此不是纸上数字。反过来,用探针 2 测到的有效带宽(例如 1555 × 0.125 ≈ 194 GB/s)去乘算术强度,就得到”如果整个 kernel 都按跨步模式访问“时的上限只有 194 × 0.25 ≈ 49 GFLOP/s——这是朴素实现的悲观下界,实际值介于 49 与 389 GFLOP/s 之间,取决于跨步那一半访问占多大比重(本例中 A 侧跨步占请求量的一半)。
  • 本实验的结论与三种改进方向:① 把跨步访问改成合并访问(改布局 / 转置 / 调整线程映射):由探针 1 与探针 2 的 8 倍差距可以看出这条路的天花板很高,但在 thread-per-output 下最多只能把每 warp 每 k 的搬运量从 128 B 降到 96 B(1.33 倍),收益有限——因为请求总量没变。② 引入共享内存分块:把请求总量降低 TILE_WIDTH 倍,是唯一能同时解决”总量”与”模式”的路线(下一讲)。③ 线程粗化:让每线程算多个输出,用寄存器复用把强度从 0.25 抬到 1–2 FLOP/Byte,属于”顺手能拿的便宜”,但要跨过 12.5 必须与 ② 联用。

示例 3:存储布局实验 —— A 转置(恢复合并)与 B 转置(制造跨步)(matmul_transposed.cu)

本示例回答一个具体问题:“把某个操作数转置后再传给 kernel”到底能不能救朴素矩阵乘法? 程序同时跑三个代数上完全等价、只有访存布局不同的 kernel:matmulNaive(A、B 都按行主序)、matmulAMT(A 以转置形式 M_T 给出)、matmulBT(B 以转置形式 N_T 给出),并用一个完整的 transposeKernel 在 GPU 上生成两份转置矩阵。

// 文件: matmul_transposed.cu
// 主题: ECE408 Lecture 4 -- 存储布局对合并访存的影响(A 转置 vs B 转置)
// 编译: nvcc -O3 -arch=sm_80 matmul_transposed.cu -o matmul_transposed
// 运行: ./matmul_transposed 2048
//       参数为矩阵边长 Width(默认 2048,单矩阵 16 MB,三个矩阵共 48 MB > A100 L2 40 MB)

#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <ctime>
#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 BLOCK_WIDTH  16
#define WARMUP_ITERS  3
#define TIMED_ITERS  10

// ============================== device kernels =============================
// (a) 转置 kernel:out[c*Width + r] = in[r*Width + c]
//     读:相邻 tx -> 相邻地址(合并);写:相邻 tx 的地址相差 Width*4 Byte(跨步)
__global__ void transposeKernel(const float* __restrict__ in,
                                float* __restrict__ out, int Width)
{
    const int Row = blockIdx.y * blockDim.y + threadIdx.y;
    const int Col = blockIdx.x * blockDim.x + threadIdx.x;
    if (Row < Width && Col < Width) {
        out[Col * Width + Row] = in[Row * Width + Col];
    }
}

// (b) 朴素版:A、B 都是行主序。A 侧跨步(2 个 sector),B 侧合并(2 个 sector)
__global__ void matmulNaiveKernel(const float* __restrict__ d_M,
                                  const float* __restrict__ d_N,
                                  float* __restrict__ d_P, int Width)
{
    const int Row = blockIdx.y * blockDim.y + threadIdx.y;
    const 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 += d_M[Row * Width + k] * d_N[k * Width + Col];
        }
        d_P[Row * Width + Col] = Pvalue;
    }
}

// (c) A 转置版:M_T[k][Row] = M[Row][k]。A 侧变成"2 个相邻地址"(1 个 sector)
__global__ void matmulAMTKernel(const float* __restrict__ d_MT,
                                const float* __restrict__ d_N,
                                float* __restrict__ d_P, int Width)
{
    const int Row = blockIdx.y * blockDim.y + threadIdx.y;
    const 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 += d_MT[k * Width + Row] * d_N[k * Width + Col];
        }
        d_P[Row * Width + Col] = Pvalue;
    }
}

// (d) B 转置版:N_T[Col][k] = N[k][Col],即把 B 按列主序存储后按行读取
//     B 侧变成"16 个相距 Width*4 Byte 的地址"(16 个独立 sector)
__global__ void matmulBTKernel(const float* __restrict__ d_M,
                               const float* __restrict__ d_NT,
                               float* __restrict__ d_P, int Width)
{
    const int Row = blockIdx.y * blockDim.y + threadIdx.y;
    const 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 += d_M[Row * Width + k] * d_NT[Col * Width + k];
        }
        d_P[Row * Width + Col] = Pvalue;
    }
}

// ============================== host utilities =============================
static void initMatrix(float* a, int n, unsigned int seed)
{
    srand(seed);
    for (int i = 0; i < n * n; ++i) a[i] = (float)(rand() % 7) * 0.25f;
}

static void cpuMatmul(const float* A, const float* B, float* C, int n)
{
    for (int i = 0; i < n * n; ++i) C[i] = 0.0f;
    for (int i = 0; i < n; ++i) {
        for (int k = 0; k < n; ++k) {
            const float  a    = A[i * n + k];
            const float* Brow = B + k * n;
            float*       Crow = C + i * n;
            for (int j = 0; j < n; ++j) Crow[j] += a * Brow[j];
        }
    }
}

static void cpuTranspose(const float* in, float* out, int n)
{
    for (int r = 0; r < n; ++r) {
        for (int c = 0; c < n; ++c) {
            out[c * n + r] = in[r * n + c];
        }
    }
}

static int fp32CoresPerSM(int major, int minor)
{
    if (major == 7) return 64;
    if (major == 8) return (minor == 0) ? 64 : 128;
    if (major >= 9) return 128;
    return 128;
}

// 计时辅助:把一次 kernel 启动包成可调用对象,返回平均毫秒数
template <typename LaunchFn>
static double timeKernel(LaunchFn launch, int warmup, int iters)
{
    for (int i = 0; i < warmup; ++i) launch();
    CUDA_CHECK(cudaGetLastError());
    CUDA_CHECK(cudaDeviceSynchronize());
    cudaEvent_t a, b;
    CUDA_CHECK(cudaEventCreate(&a));
    CUDA_CHECK(cudaEventCreate(&b));
    CUDA_CHECK(cudaEventRecord(a));
    for (int i = 0; i < iters; ++i) launch();
    CUDA_CHECK(cudaEventRecord(b));
    CUDA_CHECK(cudaEventSynchronize(b));
    float ms = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&ms, a, b));
    CUDA_CHECK(cudaEventDestroy(a));
    CUDA_CHECK(cudaEventDestroy(b));
    return (double)ms / iters;
}

static void verifyMatrix(const float* got, const float* want, int n, const char* tag)
{
    double maxAbs = 0.0, maxRef = 0.0;
    for (int i = 0; i < n * n; ++i) {
        const double e = fabs((double)got[i] - (double)want[i]);
        if (e > maxAbs) maxAbs = e;
        const double r = fabs((double)want[i]);
        if (r > maxRef) maxRef = r;
    }
    const double rel = (maxRef > 0.0) ? maxAbs / maxRef : maxAbs;
    printf("  %-26s: max abs err %.3e, rel %.3e -> %s\n",
           tag, maxAbs, rel, (rel < 1e-4) ? "PASS" : "FAIL");
}

// ================================== main ===================================
int main(int argc, char** argv)
{
    const int    N     = (argc > 1) ? atoi(argv[1]) : 2048;
    const size_t bytes = (size_t)N * (size_t)N * sizeof(float);

    CUDA_CHECK(cudaSetDevice(0));
    cudaDeviceProp prop;
    CUDA_CHECK(cudaGetDeviceProperties(&prop, 0));
    const double peakBW   = 2.0 * (double)prop.memoryClockRate * 1.0e3
                          * ((double)prop.memoryBusWidth / 8.0) / 1.0e9;
    const double peakFP32 = (double)prop.multiProcessorCount
                          * (double)fp32CoresPerSM(prop.major, prop.minor)
                          * 2.0 * (double)prop.clockRate * 1.0e3 / 1.0e12;
    const double flops    = 2.0 * (double)N * (double)N * (double)N;

    printf("== Device ==\n");
    printf("  %s, peak BW %.1f GB/s, peak FP32 %.2f TFLOPS, L2 %.1f MB\n",
           prop.name, peakBW, peakFP32, prop.l2CacheSize / 1048576.0);
    printf("  N = %d, three matrices = %.1f MB\n", N, 3.0 * bytes / 1048576.0);

    // ---------- 主机端数据与参考结果 ----------
    float* h_M   = (float*)malloc(bytes);
    float* h_N   = (float*)malloc(bytes);
    float* h_MT  = (float*)malloc(bytes);
    float* h_NT  = (float*)malloc(bytes);
    float* h_P   = (float*)malloc(bytes);
    float* h_Ref = (float*)malloc(bytes);
    if (!h_M || !h_N || !h_MT || !h_NT || !h_P || !h_Ref) {
        fprintf(stderr, "host malloc failed\n");
        exit(EXIT_FAILURE);
    }
    initMatrix(h_M, N, 3u);
    initMatrix(h_N, N, 4u);
    cpuTranspose(h_M, h_MT, N);
    cpuTranspose(h_N, h_NT, N);

    const clock_t t0 = clock();
    cpuMatmul(h_M, h_N, h_Ref, N);
    const double cpuSec = (double)(clock() - t0) / CLOCKS_PER_SEC;

    // ---------- 设备端内存 ----------
    float *d_M = NULL, *d_N = NULL, *d_MT = NULL, *d_NT = NULL, *d_P = NULL;
    CUDA_CHECK(cudaMalloc((void**)&d_M,  bytes));
    CUDA_CHECK(cudaMalloc((void**)&d_N,  bytes));
    CUDA_CHECK(cudaMalloc((void**)&d_MT, bytes));
    CUDA_CHECK(cudaMalloc((void**)&d_NT, bytes));
    CUDA_CHECK(cudaMalloc((void**)&d_P,  bytes));
    CUDA_CHECK(cudaMemcpy(d_M, h_M, bytes, cudaMemcpyHostToDevice));
    CUDA_CHECK(cudaMemcpy(d_N, h_N, bytes, cudaMemcpyHostToDevice));

    dim3 dimBlock(BLOCK_WIDTH, BLOCK_WIDTH, 1);
    dim3 dimGrid((N + BLOCK_WIDTH - 1) / BLOCK_WIDTH,
                 (N + BLOCK_WIDTH - 1) / BLOCK_WIDTH, 1);

    // ---------- 转置(GPU) ----------
    const double msT_M = timeKernel([&] {
        transposeKernel<<<dimGrid, dimBlock>>>(d_M, d_MT, N);
    }, WARMUP_ITERS, TIMED_ITERS);
    const double msT_N = timeKernel([&] {
        transposeKernel<<<dimGrid, dimBlock>>>(d_N, d_NT, N);
    }, WARMUP_ITERS, TIMED_ITERS);
    const double transposeGBs = 2.0 * (double)bytes / (msT_M * 1.0e-3) / 1.0e9;

    CUDA_CHECK(cudaMemcpy(h_P, d_MT, bytes, cudaMemcpyDeviceToHost));
    printf("\n== Transpose kernel ==\n");
    printf("  time per transpose   : %.4f ms (M_T), %.4f ms (N_T)\n", msT_M, msT_N);
    printf("  effective bandwidth  : %.1f GB/s (read+write counted)\n", transposeGBs);
    printf("  model                : read coalesced (2 sectors), write strided (16 sectors)\n");
    verifyMatrix(h_P, h_MT, N, "M_T = transpose(M)");
    CUDA_CHECK(cudaMemcpy(h_P, d_NT, bytes, cudaMemcpyDeviceToHost));
    verifyMatrix(h_P, h_NT, N, "N_T = transpose(N)");

    // ---------- 三个矩阵乘法 kernel ----------
    const double msNaive = timeKernel([&] {
        matmulNaiveKernel<<<dimGrid, dimBlock>>>(d_M, d_N, d_P, N);
    }, WARMUP_ITERS, TIMED_ITERS);
    CUDA_CHECK(cudaMemcpy(h_P, d_P, bytes, cudaMemcpyDeviceToHost));
    printf("\n== matmulNaiveKernel (A row-major, B row-major) ==\n");
    printf("  time                 : %.4f ms   ->  %.2f GFLOP/s\n",
           msNaive, flops / (msNaive * 1.0e-3) / 1.0e9);
    printf("  non-coalesced side   : A  (2 sectors = 64 B fetched for 8 B useful)\n");
    verifyMatrix(h_P, h_Ref, N, "P = M*N (naive)");

    const double msAMT = timeKernel([&] {
        matmulAMTKernel<<<dimGrid, dimBlock>>>(d_MT, d_N, d_P, N);
    }, WARMUP_ITERS, TIMED_ITERS);
    CUDA_CHECK(cudaMemcpy(h_P, d_P, bytes, cudaMemcpyDeviceToHost));
    printf("\n== matmulAMTKernel (A transposed) ==\n");
    printf("  time                 : %.4f ms   ->  %.2f GFLOP/s\n",
           msAMT, flops / (msAMT * 1.0e-3) / 1.0e9);
    printf("  A side               : 2 adjacent addresses -> 1 sector = 32 B (was 64 B)\n");
    verifyMatrix(h_P, h_Ref, N, "P = M*N (A transposed)");

    const double msBT = timeKernel([&] {
        matmulBTKernel<<<dimGrid, dimBlock>>>(d_M, d_NT, d_P, N);
    }, WARMUP_ITERS, TIMED_ITERS);
    CUDA_CHECK(cudaMemcpy(h_P, d_P, bytes, cudaMemcpyDeviceToHost));
    printf("\n== matmulBTKernel (B stored column-major) ==\n");
    printf("  time                 : %.4f ms   ->  %.2f GFLOP/s\n",
           msBT, flops / (msBT * 1.0e-3) / 1.0e9);
    printf("  B side               : 16 addresses spaced Width*4 B -> 16 sectors = 512 B\n");
    verifyMatrix(h_P, h_Ref, N, "P = M*N (B column-major)");

    // ---------- 汇总 ----------
    printf("\n== Summary (N = %d) ==\n", N);
    printf("  kernel                        ms      GFLOP/s   warp-bytes per k   sector eff\n");
    printf("  naive   (A row, B row)     %7.4f   %8.2f        128 B               56%%\n",
           msNaive, flops / (msNaive * 1.0e-3) / 1.0e9);
    printf("  A-trans (A^T, B row)       %7.4f   %8.2f         96 B               75%%\n",
           msAMT, flops / (msAMT * 1.0e-3) / 1.0e9);
    printf("  B-trans (A row, B col)     %7.4f   %8.2f        576 B               12.5%%\n",
           msBT, flops / (msBT * 1.0e-3) / 1.0e9);
    printf("  ratio B-trans / A-trans    %.2fx\n", msBT / msAMT);
    printf("  ratio B-trans / naive      %.2fx\n", msBT / msNaive);
    printf("  ratio naive   / A-trans    %.2fx\n", msNaive / msAMT);

    printf("\n== Roofline view (naive) ==\n");
    printf("  arithmetic intensity : 0.25 FLOP/Byte (4 Byte per FLOP)\n");
    printf("  roofline ceiling     : %.1f GFLOP/s (%.1f GB/s x 0.25)\n",
           peakBW * 0.25, peakBW);
    printf("  ceiling / peak FP32  : %.2f%%\n", 100.0 * peakBW * 0.25 / (peakFP32 * 1.0e3));
    printf("  CPU reference time   : %.3f s\n", cpuSec);

    // ---------- 释放 ----------
    CUDA_CHECK(cudaFree(d_M));
    CUDA_CHECK(cudaFree(d_N));
    CUDA_CHECK(cudaFree(d_MT));
    CUDA_CHECK(cudaFree(d_NT));
    CUDA_CHECK(cudaFree(d_P));
    free(h_M); free(h_N); free(h_MT); free(h_NT); free(h_P); free(h_Ref);
    CUDA_CHECK(cudaDeviceReset());
    return 0;
}

【代码做什么?】

  1. 准备六份主机矩阵h_Mh_N(两个输入)、h_MTh_NT(CPU 版转置,用作校验基准)、h_P(接收 GPU 结果)、h_Ref(CPU 参考结果)。
  2. transposeKernel(GPU 转置)out[Col*Width + Row] = in[Row*Width + Col]读是合并的(相邻 tx → 相邻地址),写是跨步的tx 每 +1,写地址 +Width*4 Byte),所以这个 kernel 本身就是”一半好一半坏”的典型;实测带宽通常只有峰值的一半左右。行业中真正的做法是下一讲的共享内存分块转置(先把 tile 合并读进来,再交换行列后合并写出),这里刻意用最朴素的版本以保持”本讲不使用共享内存”的边界。
  3. matmulNaiveKernel:基准版,与示例 1 完全一致。
  4. matmulAMTKernel:把 A 换成它的转置 M_TM_T[k][Row] = M[Row][k]),内积写成 d_MT[k*Width + Row] * d_N[k*Width + Col]。代数上完全等价(乘法的两个因子顺序不变,只是取数方式变了),但 A 侧的地址从”相距 Width*4 Byte”变成”相距 4 Byte”
  5. matmulBTKernel:把 B 换成它的转置 N_TN_T[Col][k] = N[k][Col]),内积写成 d_M[Row*Width + k] * d_NT[Col*Width + k]。这模拟了”B 按列主序存储(或按列读取)”的情形,B 侧的 16 个线程会访问 16 个相距 Width*4 Byte 的地址——就是概念 4 表格第三行那个 512 Byte / 64 Byte 的最坏情形。
  6. 三次计时与三次校验:每个 kernel 都用 timeKernel 热身 3 次、计时 10 次取平均,然后各自拷回结果与 h_Ref 比较。三个 kernel 的输出必须逐元素一致——这正是本实验的价值:三个程序的数学结果完全相同,性能差别 100% 来自访存布局。
  7. 汇总与 Roofline:打印三个 kernel 的毫秒数、GFLOP/s、每个 warp 每 k 的建模搬运字节、sector 效率,以及两两时间比。

【并行机制与硬件映射解说】

  • warp 划分与三个 kernel 的差异:三个 kernel 的 dimBlockdimGrid、线程到输出的映射完全一样(都是 16×16、Row ← tyCol ← tx),因此 warp 的组成、占用率、发散情况也都一样。唯一的差别是每条 load 的地址表达式——这就是控制变量法的正确用法。
  • 逐 warp 的取数分布(每 warp 每 k):三个 kernel 的线程映射相同,所以差别只体现在两条 load 的地址表达式上:
kernelA 侧 warp 地址分布A 取回B 侧 warp 地址分布B 取回合计取回有用sector 效率
matmulNaive(A 行主序, B 行主序)2 个地址,相距 Width*4 Byte64 B16 个连续地址(64 Byte)64 B128 B72 B56%
matmulAMT(A 转置, B 行主序)2 个地址,相距 4 Byte(同一 sector)32 B16 个连续地址(64 Byte)64 B96 B72 B75%
matmulBT(A 行主序, B 列主序)2 个地址,相距 Width*4 Byte64 B16 个地址,各相距 Width*4 Byte512 B576 B72 B12.5%

把第三行的 B 侧单独画出来(N = 2048,Width*4 = 8192 Byte):

  lane  0 .. 15 访问 d_NT[Col*Width + k],Col 每 +1 地址 +8192 Byte

  +----+        +----+        +----+        +----+              +----+
  | 4B |        | 4B |        | 4B |        | 4B |              | 4B |
  +----+        +----+        +----+        +----+              +----+
  0 B         8192 B       16384 B      24576 B            122880 B
  |<---- 16 个互不相邻的 32 B sector,需要 16 次独立取数请求 ---->|
  取回 16 * 32 B = 512 B      有用 16 * 4 B = 64 B      效率 12.5%
  • 对 B 的访问为什么在 matmulBTKernel 里变成 16 次独立 transactiond_NT[Col*Width + k] 的地址里,Col = bx*16 + tx 只随 tx 变化,而 tx 在 warp 内取 16 个值,每个值使地址增加 Width*4 = 8192 Byte(N = 2048)。这 16 个地址彼此相距 8192 Byte,必然落在 16 个不同的 32 B sector、16 条不同的 cache line 上,硬件必须发出 16 次互不相干的取数请求,每次只带回 4 Byte 有用的数据。这 16 次请求的代价就是 512 Byte 换 64 Byte——8 倍浪费。相比之下 matmulNaiveKernel 的 B 访问是 16 个连续 float(64 Byte),落在一条 cache line 内的 2 个 sector 上,1 次请求解决。
  • 对 A 的访问为什么在 matmulAMTKernel 里变好(但只好了 2 倍)d_MT[k*Width + Row] 的地址随 Row = by*16 + ty 变化,warp 内 ty 只有 2 个取值(相差 1),所以两个地址是相邻的 4 Byte,落在同一个 32 B sector 里。transaction 从 2 次降到 1 次、取回从 64 B 降到 32 B——改善,但有限:(1)每 warp 每 k 的搬运量只从 128 B 降到 96 B(1.33 倍);(2)A 侧的请求总量一次都没减少,仍然是每线程 K 次 load。这也是为什么 A 转置带来的实测加速通常只有百分之几到 30%,而 B 转置带来的减速却是成倍的。
  • 占用率与寄存器:三个 kernel 的寄存器用量都在 20–30 之间(编译器可能因为地址计算方式不同而略有差异,示例 1 的 cudaFuncGetAttributes 可以打印出来核对),16×16 block 在 A100 上都是 8 blocks/SM = 2048 threads = 100% 占用率(若寄存器超过 32 个就降到 6–7 个 block)。占用率完全相同而性能差数倍,这正是”占用率不是万能指标”的最好例证。
  • 共享内存与 bank conflict 在本讲的适用性:本讲(以及 Lab 2)完全不用共享内存,所以”bank conflict”这个问题不存在——它属于下一讲:一旦把 tile 装进 __shared__ float subTileN[16][16]subTileN[k][tx] 的 bank 号就是 (k*16 + tx) % 32tx = 0..15 对应 16 个不同的 bank,warp 内两个 ty 组访问的是同一批地址(被硬件广播),因此 16×16 的 tile 在 [k][tx] 访问模式下没有 bank conflict;但若 tile 宽度写成 32 并且按列访问,就会出现 2-way / 32-way 冲突,那时才需要 padding 或重排下标。
  • 关于 DRAM 的层次transposeKernel 的”读合并 + 写跨步”结构正好对应 Lecture 7 讲的 DRAM 行为——写跨步时,每次 32 B 的写只填 4 Byte,剩下的字节被浪费;而且写通常比读更难掩盖(没有”等着用”的指令可以继续执行)。这也解释了为什么实测的转置带宽通常明显低于探针 1 的纯读带宽。

【性能优化分析】

  • 算术强度:三个 kernel 的强度都是 2N³ FLOP / (8N³ Byte) = 0.25 FLOP/Byte(请求口径)——完全相同。这提醒我们:算术强度只看”请求量与计算量的比”,它对”请求内部的地址分布好不好”是完全无感的,那需要 sector 效率(本讲第二个指标)来补充。两个指标必须一起看。
  • Roofline:上限都是 1555 × 0.25 = 389 GFLOP/s(峰值的 2.0%),三个 kernel 的差别不是”能不能突破屋顶”,而是”离屋顶有多远”。调用 matmulBTKernel 的有效搬运量是 naive 的 4.5 倍(576 B vs 128 B),因此它离屋顶更远。
  • 占用率:三者都是 100%,再次确认瓶颈在内存系统而不在调度
  • 预期结果与解读matmulAMT 通常比 matmulNaive5%–30%(取决于 N 是否超出 L2,以及编译器是否把 A 的两次 load 优化成一次);matmulBTKernel 通常比 matmulNaive 慢 1.5–4 倍,在 N 较大(工作集超出 L2)时更接近模型预测的 4.5 倍。如果 matmulAMT 的加速远小于预期,不要怀疑实验做错了——这正是本讲最重要的工程结论:转置/改布局只能修正”访问模式”,修正不了”请求总量”
  • 三种改进方向(本讲的总结性结论)
  方向 (1) 把跨步的列访问改成合并访问:
           手段:改变存储布局(转置某一个操作数)、调整线程映射让"变化最快的
                 线程索引"落在"内存中连续的那一维"、必要时把转置的成本
                 (一次 O(N^2) 的拷贝)算进总时间。
           上限:把每 warp 每 k 的搬运量从 128 B 降到 96 B(1.33x),
                 或在纯跨步(探针 2)情形下最高 8x。
           局限:只能搬走"跨步",搬不走"2MNK 次请求";而且 thread-per-output
                 下 A 复用与 B 复用的需求互相冲突,必然有一个操作数受害。

  方向 (2) 引入共享内存做分块(tiling):<-- 真正解决问题的是这一条
           把全局请求量降低 TILE_WIDTH 倍 -> AI 从 0.25 提升到 TILE_WIDTH/4
           TILE=16 -> 6.22 TFLOP/s 上限;TILE=32 -> 12.44 TFLOP/s 上限
           并顺带把跨步访问关进片上(共享内存只有 32 个 bank 的并行访问,
           没有 sector 概念,"按列访问"不再是灾难)——即讲义的 corner turning。

  方向 (3) 线程粗化(thread coarsening):每线程算 r x c 个输出
           AI(r,c) = r*c / (2*(r+c)) FLOP/Byte
           r=c=1 -> 0.25 ; r=1,c=4 -> 0.4 ; r=c=4 -> 1.0 ; r=c=8 -> 2.0
           收益来自寄存器级复用,是"顺手能拿的便宜",但单独使用跨不过 12.5;
           与 TILE=16 分块叠加后才能到 TILE*r*c/(2*(r+c)) = 16 FLOP/Byte,
           从而真正进入计算受限区间(这是后续实验的性能目标)。

性能优化技巧总结

  1. 让”变化最快的线程索引”落在”内存中连续的那一维”threadIdx.x 必须对应行主序矩阵的列,否则一个 warp 的地址会散落在 32 个 sector 上,带宽直接损失 8 倍。
  2. 用 sector 效率而不是”是否连续”来判断访存好坏:连续是感性描述,(有用字节 / 取回字节) 是硬指标;16×16 block、Width=1024 的 naive kernel 两个操作数分别是 12.5% 与 100%,整体 56%。
  3. 不要为占用率而优化占用率:先算算术强度与 Roofline 上限;本讲的 kernel 占用率 100% 却只有峰值的百分之几,说明它是带宽受限,此时提高占用率毫无收益。
  4. 分清”请求量”与”访问模式”两个独立问题:转置/改布局只能改善访问模式(1.33 倍级别),降低请求量必须靠数据复用(分块,TILE_WIDTH 倍)——两者的数量级完全不同。
  5. 把矩阵开大到超过 L2 再做访存实验:A100 的 L2 有 40 MB,N=1024 时整个工作集都在 cache 里,会把”跨步惩罚”掩盖掉一大半,得到的结论不可靠。
  6. cudaEvent 计时并做热身:kernel 启动是异步的,用主机端时钟会把 API 开销算进去;第一次运行还包含上下文初始化与页表建立的开销。
  7. 边界保护用”向上取整 + if”而不是”假设整除”dimGrid = ceil(W/BLOCK) 配合 if (Row < Width && Col < Width) 是通用写法,代价只是边界 block 的少量发散。
  8. CPU 参考实现要用缓存友好的循环顺序:i-k-j 顺序比 i-j-k 快 5–10 倍,能把等待参考结果的时间从几分钟降到几秒。
  9. 任何”优化”都要回到三个指标上定量说明:时间/带宽(实测)、算术强度(请求口径)、sector 效率(硬件口径);只报”变快了”而不给出这三个数字的优化说明是不可信的。

关键要点

  • 矩阵乘法的计算量是 2·M·N·K FLOP、数据量是 O(N²)理想算术强度 = N/6 FLOP/Byte(N=1024 时 170.7),远高于 A100 的机器平衡点 12.5——问题不在于算法,而在于实现没有利用上这份复用。
  • 行主序线性化 M[row*Width + col] 决定了两个距离:同行相邻列相差 4 Byte(可合并),同列相邻行相差 Width*4 Byte(跨步)。所有访存悲剧都来自第二个距离。
  • “每线程一个输出元素”的朴素实现对每个输出做 2K 次全局内存访问,总计 2·M·N·K 次 load,即 4 Byte/FLOP;A 的每个元素被读 N 次、B 的每个元素被读 M 次,全部是冗余请求。
  • warp 级的代价:16×16 block 时每个 warp 每 k 对 A 产生 2 个相距 4096 Byte 的 sector(效率 12.5%)、对 B 产生 64 Byte 连续访问(效率 100%),整体效率 56%;若按列读 B(d_NT[Col*Width+k]),B 侧会变成 16 次独立 transaction、512 Byte 换 64 Byte
  • Roofline 上限 = 1555 GB/s × 0.25 FLOP/Byte ≈ 389 GFLOP/s,仅为 19.5 TFLOPS 峰值的 2%;A100 要达到峰值需要 19.5e12/0.25 = 78 TB/s 的带宽,是实际带宽的 50 倍。
  • 唯一的出路是数据复用:分块把全局请求量降低 TILE_WIDTH 倍,AI = TILE_WIDTH/4;16×16 分块给出 6.22 TFLOP/s 上限(31.9%),32×32 给出 12.44 TFLOP/s,要越过机器平衡点还需要 16×16 分块叠加 4×4 寄存器粗化(AI = 16 FLOP/Byte)

常见陷阱与注意事项

  • threadIdx.x 映射到错误的维度:现象是性能只有预期的几分之一,代码却完全正确 → 记住”x 是变化最快的线程索引,通常应映射到内存中连续的那一维(行主序的列)”,写代码前先把 Row/Col 两行索引与内存布局画在一起核对。
  • 用主机端时钟给 kernel 计时:现象是测出的时间随迭代次数剧烈波动,或把 cudaMalloc/cudaMemcpy 的开销算进了 kernel → 用 cudaEventRecord + cudaEventSynchronize,并在正式计时前热身若干次;反复出现的”忘记 cudaDeviceSynchronize()“是同一类错误的变体:不调用它,cudaMemcpy 之后的性能打印读到的是没有意义的中间量cudaMemcpy 本身是同步的,但它掩盖不了自己之前没有同步的计时)。
  • cudaMalloc / cudaMemcpy / kernel 启动不检查返回值:现象是 kernel 因越界或共享内存超限启动失败,程序却”正常”输出一堆零或垃圾数据 → 统一使用 CUDA_CHECK 宏,并在 kernel 启动后调用 cudaGetLastError()cudaDeviceSynchronize() 让错误在发生处暴露。
  • cudaMemcpy 方向写反:现象是结果全为 0 或直接报 invalid argument(host 指针给了 cudaMemcpyDeviceToHost)→ 记住第三个参数必须是 cudaMemcpyHostToDevice(主机→设备)或 cudaMemcpyDeviceToHost(设备→主机),第四次参数是字节数而不是元素个数。
  • 整数除法取整导致网格覆盖不足:现象是结果矩阵右下角一片未初始化(对 N/BLOCK 直接整除的写法),取整丢掉了不足一个 block 的尾巴 → 网格维度必须写成 (N + BLOCK - 1) / BLOCK,并在 kernel 内用 if (Row < Width && Col < Width) 挡住多余线程;越界写显存可能不立即报错,但会静默破坏其他缓冲区。
  • host/device 指针混用:现象是段错误(把 d_ 指针交给 CPU 循环)或 invalid argument(把 h_ 指针传给 kernel)→ 用命名规范(h_/d_ 前缀)区分,并记住 kernel 只能接收设备指针、cpuMatmul 只能接收主机指针。
  • 在不用共享内存的版本里”顺手”加上 __syncthreads():现象是编译通过但语义错误或性能无变化 → 本讲与 Lab 2 的 kernel 没有跨线程通信,__syncthreads() 不需要;它属于分块版本,而且绝不能放在条件分支内部(”所有线程必须进入同一个静态调用点”,否则行为未定义或直接死锁)。
  • 以为”把某个矩阵转置一下”就能解决性能问题:现象是花时间写好转置 kernel 后性能只提升了十几个百分点 → 转置只修正访问模式(本讲实测上限约 1.33 倍),请求总量不变;真正的量级改善必须来自共享内存分块与寄存器复用。
  • 共享内存容量超限(下一讲的预警):现象是 kernel 启动失败或占用率骤降 → 32×32 tile 需要 2 × 32 × 32 × 4 = 8 KB 静态共享内存,64×64 需要 32 KB(超过很多 GPU 每 block 的默认上限 48 KB 之外还要走 cudaFuncSetAttribute),写分块代码时必须先算 2 × TILE_WIDTH² × 4 Byte 与设备每 block 上限。
  • 只在小矩阵上测性能就下结论:现象是”我的优化没有效果”或”跨步一点都不慢” → 小矩阵的工作集全在 L2/L1 里,掩盖了 DRAM 行为;评估访存优化时矩阵至少要大到超过 L2 容量(A100 为 40 MB,即 N ≥ 4096 的 float 方阵)。

思考题(带答案)

Q1. 朴素矩阵乘法(A、B 均行主序)在 A100 上执行,blockDim 取哪一组时”每 warp 每 k 的搬运用”更划算:(a) 16×16,还是 (b) 32×32?请分别算出 A、B 两侧的 sector 数、取回字节与有用字节(Width 足够大,忽略 cache)。

(a) 16×16 = 256 线程,一个 warp 覆盖 ty ∈ {t, t+1} 两行、tx = 0..15:A 侧地址只依赖 ty,产生 2 个相距 Width*4 Byte 的地址 → 2 个 sector、取回 64 B、有用 8 B;B 侧地址只依赖 tx,产生 16 个连续地址(64 Byte)→ 2 个 sector、取回 64 B、有用 64 B。合计 128 B 取回 / 72 B 有用 / 效率 56%。(b) 32×32 = 1024 线程,一个 warp 恰好覆盖一整行 ty = ttx = 0..31:A 侧 32 个线程访问同一个地址(广播)→ 1 个 sector、取回 32 B、有用 4 B;B 侧 32 个连续地址 = 128 Byte → 4 个 sector、取回 128 B、有用 128 B。合计 160 B 取回 / 132 B 有用 / 效率 82.5%注意结论的反直觉之处:单看”效率”,32×32 更漂亮(82.5% > 56%),但按每个 warp 每 k 的取回字节算,16×16 反而更省(128 B vs 160 B),因为 16×16 的 warp 用”两行 ty 复用同一批 B 数据”换来了 A 的广播摊薄。这也是为什么真正的答案要靠分块(下一讲)——无论 16 还是 32,请求总量都没有改变。

Q2. 用 Roofline 模型估算:在 A100 上把朴素版改成 16×16 分块后,理论上限是多少 GFLOP/s?如果目标是把上限推到机器平衡点以上(即变成计算受限),TILE_WIDTH 至少要多大?

分块后全局内存访问量降低 TILE_WIDTH 倍,因此算术强度 AI = TILE_WIDTH/4 FLOP/Byte。TILE_WIDTH = 16 → AI = 4,上限 = 1555 GB/s × 4 = 6220 GFLOP/s ≈ 6.22 TFLOP/s,占峰值 19.5 TFLOPS 的 31.9%(比朴素版的 2% 提升约 16 倍)。要达到机器平衡点 12.5 FLOP/Byte,需要 TILE_WIDTH/4 ≥ 12.5,即 TILE_WIDTH ≥ 50;而 32×32 分块的 AI = 8 只给到 12.44 TFLOP/s(63.8%),仍是带宽受限。所以现实的方案是”16×16 分块 + 4×4 寄存器粗化”(AI = T × r × c /(2(r+c)) = 16 × 16/16 = 16 FLOP/Byte > 12.5),既绕开了超大 tile 对共享内存容量的要求(16×16 只需 2 KB),又跨过了平衡点。

Q3. 有同学为了”让 x 方向访问连续”,把索引写成 Row = blockIdx.x*blockDim.x + threadIdx.x; Col = blockIdx.y*blockDim.y + threadIdx.y;(即 x→行、y→列),其余代码不变。以 16×16 block、行主序 A、B 为例,说明 A、B 两侧的 warp 访问分布发生了什么变化,性能会变好还是变坏,为什么?

交换语义后,warp 内(ty ∈ {t, t+1}tx = 0..15)A 的地址 d_M[Row*Width + k] 开始随 tx 变化:得到 16 个互不相同的地址,每个相距 Width*4 Byte → 16 个独立 sector、取回 16 × 32 = 512 B,而有用只有 64 B(两个 ty 组地址相同,被广播);B 的地址 d_N[k*Width + Col] 开始随 ty 变化:只有 2 个相邻地址(相距 4 B) → 1 个 sector、取回 32 B、有用 8 B。合计 544 B 取回 / 72 B 有用,效率 13.2%,比标准映射的 128 B 取回差 4.25 倍,性能显著变坏。原因是:跨步访问的受害者从一个操作数转移到了另一个操作数,而且从一个”2 次 transaction”的温和形态恶化成”16 次 transaction”的极端形态——把变化最快的线程索引放在内存中跨步的那一维,等于让整个 warp 的地址散开。这条不等式(x→连续维)是 CUDA 访存优化的第一原则。