CUDA编程实战06:矩阵乘法优化——从朴素 GEMM 到共享内存分块

CUDA编程实战06:矩阵乘法优化------从朴素 GEMM 到共享内存分块

系列定位:从可以亲手运行的 CUDA 程序出发,逐步建立正确性、性能分析与工程优化能力。

本篇适合:已经理解 Grid、Block、Warp、合并访问、Shared Memory 与 __syncthreads(),想把这些知识组合成第一个计算密集型算子的读者。

本篇成果:3 个可直接构建的 CUDA 程序、6 张配图、朴素 GEMM、16×16 与 32×32 Shared Tiled GEMM、2×1 寄存器分块、任意 M/N/K 边界、FP64 CPU 参考、CUDA Event 基准、有效 TFLOPS、核函数资源报告和 cuBLAS SGEMM 对照。

代码目录:blogs/code/06


开篇:你真正需要的,不是再背一个矩阵乘模板

第五篇中,我们用 Shared Memory 解决了矩阵转置的矛盾:输入想沿行连续读取,输出也想沿行连续写入,但直接交换坐标只能顾住一端。于是线程块先把一个 Tile 搬到片上,再换一种顺序取走。

矩阵乘法会把这套能力推进一步。

转置的核心是"重新排列"。矩阵乘的核心则是"重复使用"。一个输入元素通常不会只服务一个输出,它会参与一整行或一整列结果的计算。如果每个线程都从 Global Memory 独立取回自己所需的全部数据,程序当然能算对,但大量本来可以共享的数据没有在 Block 内复用。

这正是许多初学者遇到的实际困境:

text 复制代码
我已经把一个 C 元素交给一个线程;
我已经使用二维 Block;
B 的读取看起来还是连续的;
结果也与 CPU 相同;
为什么性能仍然离 cuBLAS 很远?

因为"线程很多"只回答了并行度问题,没有回答数据通路问题。高性能 GEMM 还需要回答:

  • A 的一个值从显存搬进来后,能被多少个输出复用?
  • B 的一个值能同时服务多少个累加器?
  • 当前 K 方向分块何时可以被下一块覆盖?
  • 一个线程只算一个输出,还是一次维护多个寄存器结果?
  • Tile 变大后,线程数、寄存器和 Shared Memory 会怎样限制并发?
  • 非整数倍尺寸怎样补零,而不让部分线程绕过屏障?
  • 自写 Kernel 与 cuBLAS 应怎样做同口径比较?
  • 为什么大矩阵更快的方案,在 17×19×13 上反而可能更慢?

本篇不会只给出一个"看起来很高级"的 Kernel。我们会从一个可以打印的 3×4 乘 4×5 示例开始,逐步得到朴素版、Shared Tiled 版、寄存器分块版,再把同一批 FP32 数据交给 cuBLAS。每个结果都与 FP64 累加的 CPU 参考逐元素比较,每个性能数字都来自 CUDA Event 的实际测量。

主题图只负责建立直觉:A 与 B 的有限 K 分块被搬入片上缓冲区,一个二维线程块在片上重复使用它们,并把部分和留在寄存器,最终生成 C 的一个有限输出 Tile。精确尺寸、线程坐标和同步时序以下面的 SVG 与代码为准。

读完本篇,你应该能够独立完成这些事情:

  1. 从 M、N、K 推导 A、B、C 的形状;
  2. C[row,col] 映射给一个二维 CUDA 线程;
  3. 写出支持任意 M、N、K 的朴素行主序 GEMM;
  4. 解释朴素实现中的 Global Memory 重复需求;
  5. 使用算术强度理解"搬一次、算多次";
  6. 让一个 Block 负责 C 的一个 Tile;
  7. 协作装载 A Tile 与 B Tile;
  8. 正确放置每轮 K Tile 的两道 __syncthreads()
  9. 对不完整 Tile 补零并安全写回;
  10. 区分寄存器累加、Shared 复用与 Global 访问;
  11. 解释为什么简单 GEMM 不必机械地给 Shared Tile 加一列;
  12. 让一个线程同时计算两个输出;
  13. 读取编译后寄存器数和静态 Shared Memory 字节数;
  14. 使用 FP64 CPU 参考与组合误差阈值;
  15. 用 CUDA Event 得到 Kernel 平均时间;
  16. 正确计算有效 TFLOPS;
  17. 把行主序输入交给列主序接口的 cublasSgemm
  18. 公平比较自写 Kernel 与 cuBLAS;
  19. 解释大矩阵与小矩阵表现不同的原因;
  20. 判断什么时候应该继续自写,什么时候应该直接使用成熟库。

一、本篇要解决的真实问题

矩阵乘法不是为了练习下标而存在。它是深度学习全连接层、注意力中的投影、图像变换、数值模拟、最小二乘、图算法和大量科学计算的基础构件。即使上层框架把它包装成一个函数,底层仍要解决数据搬运、分块、累加与调度问题。

对学习者来说,本篇解决三个层次的诉求。

第一层是"我想把矩阵乘跑起来"。因此我们提供一个默认 3×4 与 4×5 的完整程序,输入和结果都能直接打印。它不是只支持正方形,也不假设维度是 16 的倍数。

第二层是"我想知道 Shared Memory 为什么有效"。因此不会从优化代码倒推,而是先数朴素版本的逻辑读取,再推导一个 Tile 的加载量与计算量。你会看到 Shared Memory 的价值不是"比显存快"这句口号,而是让同一份输入服务多个线程。

第三层是"我想写出接近工程的基准"。因此性能程序包含预热、CUDA Event、CPU 参考、组合容差、非整块尺寸、资源查询、cuBLAS 和小尺寸反例。你可以直接替换 GPU、Toolkit 与矩阵尺寸,得到属于自己的结果。

本篇也会主动打破一个不现实的期待:

写出 Shared Tiled GEMM,不等于写出了能够战胜 cuBLAS 的 GEMM。

Shared Tiled 是高性能 GEMM 的第一层骨架,却远不是终点。成熟库还会根据架构和尺寸选择更复杂的线程块形状、每线程输出块、向量化访问、流水线、双缓冲、指令级并行、Tensor Core、布局与 Epilogue。学习自写 Kernel 的价值,是理解这些优化为什么存在,并在标准库覆盖不了的融合算子或特殊布局中做正确决策。


二、先把矩阵形状说清楚

设 A 有 M 行 K 列,B 有 K 行 N 列,那么 C 等于 A 乘 B,并拥有 M 行 N 列。

A ∈ R M × K A\in\mathbb{R}^{M\times K} A∈RM×K

B ∈ R K × N B\in\mathbb{R}^{K\times N} B∈RK×N

C ∈ R M × N C\in\mathbb{R}^{M\times N} C∈RM×N

中间维度 K 必须一致。C 的第 i 行、第 j 列元素,来自 A 的第 i 行与 B 的第 j 列做点积:

C i , j = ∑ k = 0 K − 1 A i , k B k , j C_{i,j}=\sum_{k=0}^{K-1}A_{i,k}B_{k,j} Ci,j=k=0∑K−1Ai,kBk,j

请先忽略 CUDA,只拿一个具体元素练习。若 M 为 3、N 为 5、K 为 4,那么:

text 复制代码
A: 3 行 4 列
B: 4 行 5 列
C: 3 行 5 列

C[1,3] 的计算过程是:

text 复制代码
C[1,3]
= A[1,0] * B[0,3]
+ A[1,1] * B[1,3]
+ A[1,2] * B[2,3]
+ A[1,3] * B[3,3]

这里最容易犯的错误,是把 N 与 K 混在一起。可以给三个字母一个固定含义:

text 复制代码
M:输出有多少行
N:输出有多少列
K:每个输出点积有多长

以后看到卷积展开、批量 GEMM 或注意力矩阵,先把问题还原为这三个方向。


三、本文统一使用行主序

CUDA C++ 的一维数组并不自带二维布局。本文约定 A、B、C 都以行主序保存,即同一行中相邻列在内存中连续。

cpp 复制代码
A[row * K + k]
B[k * N + col]
C[row * N + col]

因此三个行跨度分别是:

text 复制代码
A 的行跨度:K
B 的行跨度:N
C 的行跨度:N

不要因为 B 的形状是 K×N,就把 B 的索引写成 B[k * K + col]。行主序的一行包含多少列,行跨度就是多少;B 每行有 N 列,所以乘 N。

以 A 为例,二维坐标到线性下标的映射是:

p A = r o w × K + k p_A=row\times K+k pA=row×K+k

B 和 C 分别是:

p B = k × N + c o l p_B=k\times N+col pB=k×N+col

p C = r o w × N + c o l p_C=row\times N+col pC=row×N+col

这三条关系是后续所有 Kernel 的地基。若结果错误,先手算一个具体元素和三个线性下标,不要立刻怀疑浮点误差或 CUDA 调度。


四、第一个线程映射:一个线程负责 C 的一个元素

最自然的并行分解是:

text 复制代码
线程的 y 坐标决定 C 的 row;
线程的 x 坐标决定 C 的 col;
线程内部串行遍历 K。

对应代码:

cpp 复制代码
const int col =
    blockIdx.x * blockDim.x + threadIdx.x;
const int row =
    blockIdx.y * blockDim.y + threadIdx.y;

网格尺寸由输出 C 决定,而不是由 A 或 B 单独决定:

cpp 复制代码
const dim3 block(16, 16);
const dim3 grid(
    (N + block.x - 1) / block.x,
    (M + block.y - 1) / block.y);

x 方向覆盖 N 列,y 方向覆盖 M 行。边界条件也是:

cpp 复制代码
if (row < M && col < N) {
    // 计算 C[row, col]
}

K 不决定 Grid 的尺寸,因为 K 是每个线程内部的归约方向。以后学习并行归约或 Split-K 时,K 也可能被多个线程共同处理;但在本篇的基础方案中,每个线程独立完成一个完整点积。


五、示例一:朴素 GEMM Kernel

第一个 Kernel 只有一个目标:正确表达数学定义。

cpp 复制代码
__global__ void matmul_naive(
    const float* A,
    const float* B,
    float* C,
    int M,
    int N,
    int K) {

    const int col =
        blockIdx.x * blockDim.x + threadIdx.x;
    const int row =
        blockIdx.y * blockDim.y + threadIdx.y;

    if (row < M && col < N) {
        float sum = 0.0f;

        for (int k = 0; k < K; ++k) {
            sum +=
                A[row * K + k] *
                B[k * N + col];
        }

        C[row * N + col] = sum;
    }
}

这个版本具备几个非常重要的优点:

  • 数学语义直接;
  • 易于逐元素验证;
  • 支持矩形矩阵;
  • 支持任意正整数 M、N、K;
  • sum 留在寄存器;
  • 只在最后写一次 C;
  • 适合作为后续优化的 GPU 基线。

它也有明显局限:线程之间没有显式共享 A、B 数据。每个线程从自己的视角完成整条点积,不知道邻居正在请求大量相同输入。

完整可运行文件:

text 复制代码
blogs/code/06/matrix_multiply_basic.cu

它还包含:

  • CUDA 错误检查;
  • 参数解析;
  • Host 与 Device 内存;
  • FP64 CPU 参考;
  • 组合误差阈值;
  • 小矩阵打印;
  • 非整块边界验证;
  • 资源释放;
  • 失败时返回非零状态。

六、为什么累加器应该留在寄存器

下面两种写法在数学上都能表达累加,但性能含义不同。

不推荐在循环中反复读写 Global Memory:

cpp 复制代码
for (int k = 0; k < K; ++k) {
    C[row * N + col] +=
        A[row * K + k] *
        B[k * N + col];
}

推荐先在局部标量中累加:

cpp 复制代码
float sum = 0.0f;

for (int k = 0; k < K; ++k) {
    sum +=
        A[row * K + k] *
        B[k * N + col];
}

C[row * N + col] = sum;

局部标量 sum 通常会被编译器放在寄存器中。这样 K 次乘加都在片上进行,C 只写一次。即使我们尚未使用 Shared Memory,这已经是最基本的数据驻留思想:

text 复制代码
频繁更新的短生命周期标量 → 尽量留在寄存器;
线程块共同重复使用的输入块 → 放在 Shared Memory;
完整矩阵与跨 Block 数据 → 放在 Global Memory。

"哪一层数据应该停在哪里"比"用了哪种 CUDA 语法"更接近优化本质。


七、先运行最小示例,建立可观察的正确性

进入代码目录:

powershell 复制代码
cd blogs\code\06

Windows 使用 CMake 与 Visual Studio 2022:

powershell 复制代码
cmake -S . -B build `
  -G "Visual Studio 17 2022" `
  -A x64 `
  -DCMAKE_CUDA_ARCHITECTURES=75

cmake --build build --config Release --parallel

75 对应本文实测 RTX 2060 的 Compute Capability 7.5。请根据自己的 GPU 修改。若不清楚,可以先运行 deviceQuery、查询 cudaDeviceProp,或查阅 NVIDIA 官方 GPU 能力表。

运行:

powershell 复制代码
.\build\Release\matrix_multiply_basic.exe

本文机器上的核心输出:

text 复制代码
C = A x B
Shape: A(3 x 4), B(4 x 5), C(3 x 5)
Block: (16, 16), Grid: (1, 1)

A (3 x 4):
     1.10      1.20      1.30      1.40
     2.10      2.20      2.30      2.40
     3.10      3.20      3.30      3.40

B (4 x 5):
     0.50      0.40      0.30      0.20      0.10
     1.00      0.90      0.80      0.70      0.60
     1.50      1.40      1.30      1.20      1.10
     2.00      1.90      1.80      1.70      1.60

C (GPU) (3 x 5):
     6.50      6.00      5.50      5.00      4.50
    11.50     10.60      9.70      8.80      7.90
    16.50     15.20     13.90     12.60     11.30

Mismatches: 0
Verification: PASSED

不要跳过这个小例子。大矩阵的一百万个输出即使全部正确,也不如一个能手算的元素有教学价值。你可以手工验证:

C 0 , 0 = 1.1 × 0.5 + 1.2 × 1.0 + 1.3 × 1.5 + 1.4 × 2.0 = 6.5 C_{0,0}=1.1\times0.5+1.2\times1.0+1.3\times1.5+1.4\times2.0=6.5 C0,0=1.1×0.5+1.2×1.0+1.3×1.5+1.4×2.0=6.5

当打印值与手算一致、全量验证也通过时,我们才拥有可信的优化起点。


八、非整块边界不是附加功能

默认 Block 是 16×16,但 M、N、K 不必是 16 的倍数。运行:

powershell 复制代码
.\build\Release\matrix_multiply_basic.exe 17 19 13

输出:

text 复制代码
Shape: A(17 x 13), B(13 x 19), C(17 x 19)
Block: (16, 16), Grid: (2, 2)
Mismatches: 0
Verification: PASSED

Grid 多启动一些边界线程,再由 row < M && col < N 拦住越界输出。K 的循环恰好执行 13 次,不需要补齐。

这种"先把任意尺寸做对"的习惯非常重要。若一开始只测试 1024×1024,很多错误会被整数倍尺寸掩盖:

  • Grid 向上取整写错;
  • C 的行跨度错用 K;
  • 最后一行或最后一列越界;
  • Tiled 版本读取未初始化 Shared 元素;
  • 部分线程因为越界提前返回,导致其他线程卡在屏障;
  • cuBLAS leading dimension 只在矩形尺寸上暴露错误。

性能优化的测试矩阵至少应包含:

text 复制代码
小型可打印:3 × 5 × 4
刚过边界:17 × 19 × 13
高度不整:31 × 7 × 23
大型非整:1003 × 997 × 509
大型整块:1024 × 1024 × 1024

九、朴素 GEMM 到底重复了什么

观察同一输出行中的相邻线程:

text 复制代码
thread 0 计算 C[row, 0]
thread 1 计算 C[row, 1]
thread 2 计算 C[row, 2]
...

在固定 k 时,它们访问:

cpp 复制代码
A[row * K + k]

这个 A 地址相同。硬件可以对 Warp 中相同地址做广播,缓存也可能保留数据,所以不能把源码中的每次表达式读取都直接等同于一次 DRAM 事务;但从算法的数据需求看,这些线程确实重复需要同一个 A 值。

它们同时访问:

cpp 复制代码
B[k * N + col]

随着 col 连续变化,B 地址连续,通常有利于合并访问。不过不同输出行、不同 Warp、不同 Block 仍会反复需要同一片 B 数据。缓存可能缓解,却不保证按照我们理想的生命周期保留。

官方 CUDA C++ Programming Guide 对朴素矩阵乘的描述也是:每个线程读取 A 的一行和 B 的一列,计算 C 的一个元素;分块实现让一个 Block 负责 C 的一个子矩阵,协作把 A、B 子块搬到 Shared Memory,再进行复用。官方 Best Practices Guide 进一步强调 Shared Memory 可用于消除矩阵乘中的冗余 Global Memory 传输。

这里应形成一个精确结论:

朴素 GEMM 不是每一次硬件读取都必然穿过显存,而是没有用程序可控的 Block 级 Tile 明确保证复用。Shared Tiling 把"希望缓存命中"改成"由线程协作装载一次并在片上使用多次"。


十、用算术强度看问题

算术强度描述每搬运一个字节,可以完成多少次浮点运算。它不是唯一性能指标,但很适合解释 GEMM 为什么需要数据复用。

一个 C 元素进行 K 次乘法和近似 K 次加法,通常按约 2K 次浮点运算计算:

F o n e ≈ 2 K F_{one}\approx2K Fone≈2K

若从源码层面粗略假设每次乘加都读取一个 A 和一个 B,并在最后写一个 C,那么字节需求约为:

B o n e ≈ 4 ( 2 K + 1 ) B_{one}\approx4(2K+1) Bone≈4(2K+1)

对应粗略算术强度:

I n a i v e ≈ 2 K 4 ( 2 K + 1 ) I_{naive}\approx\frac{2K}{4(2K+1)} Inaive≈4(2K+1)2K

K 很大时,它接近每字节 0.25 次浮点运算。这只是没有考虑缓存复用的源代码级上界分析,不能当成实际 DRAM 计数。

现在看一个边长为 T 的输出 Tile。在一个 K Tile 阶段:

  • 装载 T×T 个 A 元素;
  • 装载 T×T 个 B 元素;
  • 计算 T×T 个输出局部和;
  • 每个局部和做 T 次乘加。

输入字节数:

B t i l e = 2 T 2 × 4 B_{tile}=2T^2\times4 Btile=2T2×4

浮点运算数:

F t i l e = 2 T 3 F_{tile}=2T^3 Ftile=2T3

忽略最终 C 写回后,本轮片上分块的算术强度约为:

I t i l e ≈ T 4 I_{tile}\approx\frac{T}{4} Itile≈4T

T 等于 16 时约为每字节 4 次浮点运算;T 等于 32 时约为每字节 8 次。这个推导表达的不是"实际性能一定提高 16 或 32 倍",而是同一批 A、B 输入在 Block 内被多个输出重复使用,单位输入搬运支撑了更多计算。

图中位置是概念示意,不是 RTX 2060 的实测 Roofline。真实位置需要设备峰值、可持续显存带宽和 Profiler 数据。正确用法是:把它当作思考方向------若算子受内存带宽限制,提升复用可以向右移动;到达计算上限后,继续减少字节未必带来同比收益。


十一、从输出 Tile 反推输入 Tile

设一个 Block 负责 C 中 T×T 的区域。这个输出区域包含:

text 复制代码
T 个输出行;
T 个输出列;
每个输出都要沿 K 做完整点积。

将 K 分成若干长度为 T 的阶段。在某一阶段,Block 只需要:

text 复制代码
A:这 T 个输出行 × 当前 T 个 K 位置
B:当前 T 个 K 位置 × 这 T 个输出列

两者都是 T×T。它们相乘,贡献给当前 C Tile 的一部分;遍历所有 K Tile 后,局部和才完整。

若 K 为 40、T 为 16,则阶段起点是:

text 复制代码
tile_k = 0
tile_k = 16
tile_k = 32

最后一阶段只有 8 个有效 K 位置,其余位置补零。补零不会改变结果,却允许内部循环始终执行固定 16 次,也让所有线程以相同控制流穿过屏障。

K 方向 Tile 数:

Q K = ⌈ K T ⌉ Q_K=\left\lceil\frac{K}{T}\right\rceil QK=⌈TK⌉

在 C 的每个输出 Tile 上,这些 K Tile 的乘积贡献会依次累加:

C t i l e = ∑ q = 0 Q K − 1 A t i l e ( q ) B t i l e ( q ) C_{tile}=\sum_{q=0}^{Q_K-1}A_{tile}^{(q)}B_{tile}^{(q)} Ctile=q=0∑QK−1Atile(q)Btile(q)


十二、Shared Tiled Kernel 的完整核心

下面是本文第二个程序中的核心 Kernel:

cpp 复制代码
template <int Tile>
__global__ void matmul_tiled(
    const float* A,
    const float* B,
    float* C,
    int M,
    int N,
    int K) {

    __shared__ float tile_A[Tile][Tile];
    __shared__ float tile_B[Tile][Tile];

    const int tx = threadIdx.x;
    const int ty = threadIdx.y;
    const int col = blockIdx.x * Tile + tx;
    const int row = blockIdx.y * Tile + ty;

    float sum = 0.0f;

    for (int tile_k = 0;
         tile_k < K;
         tile_k += Tile) {

        const int a_col = tile_k + tx;
        const int b_row = tile_k + ty;

        tile_A[ty][tx] =
            (row < M && a_col < K)
                ? A[row * K + a_col]
                : 0.0f;

        tile_B[ty][tx] =
            (b_row < K && col < N)
                ? B[b_row * N + col]
                : 0.0f;

        __syncthreads();

        #pragma unroll
        for (int k = 0; k < Tile; ++k) {
            sum +=
                tile_A[ty][k] *
                tile_B[k][tx];
        }

        __syncthreads();
    }

    if (row < M && col < N) {
        C[row * N + col] = sum;
    }
}

它只有三个阶段:

text 复制代码
协作装载当前 A/B Tile
    ↓
全 Block 同步
    ↓
从 Shared Memory 读取并累加
    ↓
全 Block 同步
    ↓
进入下一个 K Tile

代码不长,但每一个位置都承担明确的正确性责任。后面逐行拆解。


十三、协作装载为什么是合并的

对 A:

cpp 复制代码
tile_A[ty][tx] =
    A[row * K + tile_k + tx];

同一行中 tx 连续变化,对应 A 的连续列,Global Load 连续。

对 B:

cpp 复制代码
tile_B[ty][tx] =
    B[(tile_k + ty) * N + col];

固定 ty 时,tx 使 col 连续变化,对应 B 同一行的连续列,Global Load 也连续。

因此每个线程通常装载 A 的一个元素与 B 的一个元素。256 个线程组成 16×16 Block,恰好装满两个 16×16 Tile。

与矩阵转置不同,基础 GEMM 不需要在装载时把 Shared Tile 旋转。A、B 都可按行连续读入。真正的复用发生在内部循环:

cpp 复制代码
tile_A[ty][k]
tile_B[k][tx]

同一输出行中的线程共享 A 的行数据,同一输出列方向的多个线程共享 B 的列数据。Shared Memory 让这些跨线程需求落在可编程的片上存储中。


十四、第一道屏障保护什么

装载完成后:

cpp 复制代码
__syncthreads();

它保证同一个 Block 的所有线程都完成了本轮 Shared 写入,并且这些写入对 Block 内线程可见,然后任何线程才开始计算。

为什么不能删除?因为线程会读取别的线程写入的元素。例如当前线程只写:

cpp 复制代码
tile_A[ty][tx]

但内部循环会读取:

cpp 复制代码
tile_A[ty][0]
tile_A[ty][1]
...
tile_A[ty][Tile - 1]

其中大多数由同一行的其他线程写入。Warp 和 Warp 之间的相对进度没有保证;没有屏障时,快 Warp 可能读到尚未被慢 Warp 更新的 Shared 位置。

屏障不是"让 GPU 变慢的多余保险"。它建立了这段算法必需的生产者---消费者顺序。


十五、第二道屏障保护什么

内部乘加结束后还有一道:

cpp 复制代码
__syncthreads();

它保证所有线程都已经用完当前 Tile,才能让任何线程在下一轮覆盖 tile_Atile_B

删除第二道屏障会产生另一种竞态:

text 复制代码
快线程完成当前 Tile;
快线程进入下一轮并写入新的 Shared 数据;
慢线程仍在读取旧 Tile;
慢线程读到一部分旧值与一部分新值。

这类错误可能只在某些尺寸、某些优化级别、某些 GPU 或运行负载下出现,因此尤其危险。程序偶尔通过不能证明没有数据竞争。

记忆方式不是"矩阵乘固定写两个同步",而是两个数据依赖:

text 复制代码
写新 Tile → 读新 Tile:需要装载后屏障;
读旧 Tile → 覆盖旧 Tile:需要覆盖前屏障。

若以后使用异步复制、流水线或双缓冲,同步机制可能改变,但这两条依赖不会消失。


十六、为什么边界判断不能包住屏障

错误写法:

cpp 复制代码
if (row < M && col < N) {
    tile_A[ty][tx] = ...;
    tile_B[ty][tx] = ...;
    __syncthreads();
    // ...
}

右侧和下侧边界 Block 中,一部分线程满足条件,另一部分不满足。前者进入 __syncthreads(),后者绕过。Block 内线程没有一致地到达同一个屏障,行为未定义,可能挂起或产生错误。

正确做法是让所有线程参与控制流,但对越界装载写零:

cpp 复制代码
tile_A[ty][tx] =
    (row < M && a_col < K)
        ? A[row * K + a_col]
        : 0.0f;

tile_B[ty][tx] =
    (b_row < K && col < N)
        ? B[b_row * N + col]
        : 0.0f;

__syncthreads();

最后只保护 Global Store:

cpp 复制代码
if (row < M && col < N) {
    C[row * N + col] = sum;
}

所有线程执行相同轮数的 K Tile 循环和相同次数的屏障。越界线程虽然不输出,也承担补零和同步职责。


十七、补零为什么保持数学正确

假设 K 为 13、Tile 为 16。最后一个 Tile 中:

text 复制代码
k = 0 到 12:读取真实 A、B
k = 13 到 15:至少一个操作数写成 0

额外项对结果的贡献是:

a × 0 = 0 a\times0=0 a×0=0

或者:

0 × b = 0 0\times b=0 0×b=0

所以固定 16 次内部循环仍等价于真实长度 13 的点积。补零换来三个工程收益:

  • 内部循环长度固定,便于展开;
  • 所有线程控制流一致;
  • 边界与整数倍尺寸共享同一 Kernel。

代价是最后一个 Tile 可能做一些无效乘加。大矩阵中边界比例通常很小;很小或极瘦矩阵中,浪费比例可能显著,这也是固定 Tile 不适合所有形状的原因之一。


十八、不要在屏障前提前 return

另一个常见错误:

cpp 复制代码
if (row >= M || col >= N) {
    return;
}

for (...) {
    // ...
    __syncthreads();
}

边界线程提前退出,剩余线程继续等待整个 Block 到达屏障,逻辑同样不成立。

凡是 Kernel 含 Block 级屏障,都要问:

text 复制代码
是否所有尚未退出的线程会以相同顺序到达?
是否分支条件对整个 Block 一致?
是否循环次数对整个 Block 一致?

对本文 Kernel,tile_k < K 对整个 Block 相同,循环次数一致;边界只影响写入值和最终 Store,不影响屏障路径。


十九、Shared Memory 用量怎样计算

Tiled Kernel 有两个 T×T 的 float 数组:

S t i l e d = 2 T 2 × 4 S_{tiled}=2T^2\times4 Stiled=2T2×4

T 为 16:

S 16 = 2 × 16 2 × 4 = 2048 S_{16}=2\times16^2\times4=2048 S16=2×162×4=2048

即 2 KiB。

T 为 32:

S 32 = 2 × 32 2 × 4 = 8192 S_{32}=2\times32^2\times4=8192 S32=2×322×4=8192

即 8 KiB。

本文程序使用 cudaFuncGetAttributes 查询编译后的静态 Shared Memory,实际输出正是 2048 与 8192 字节。

但资源判断不能只看 Shared Memory。32×32 Block 有 1024 个线程,已经触及许多设备的单 Block 最大线程数。每线程寄存器数乘以线程数也会形成很大的 Block 级寄存器需求。Tile 更大意味着:

  • 片上复用潜力提高;
  • Shared Memory 增加;
  • 每 Block 线程增加;
  • 寄存器总量增加;
  • 可驻留 Block 数可能下降;
  • 边界浪费可能加重;
  • 调度形状可能不适合目标架构。

所以 32 不会因为大于 16 就在所有 GPU 上更快。


二十、Shared Memory Bank:这里为什么不能照抄 32×33

第五篇矩阵转置中,tile[32][32] 的列方向访问会让一个 Warp 的不同地址映射到同一个 Bank,因此加一列 Padding 可以改变模 32 步长。

基础 GEMM 的访问模式不同。

内部循环读取 A:

cpp 复制代码
tile_A[ty][k]

对同一输出行的一组线程,tyk 相同,多个线程读取同一地址。这属于广播需求,不是不同地址碰撞到同一 Bank。

读取 B:

cpp 复制代码
tile_B[k][tx]

tx 连续变化,对应 Shared 一行中的连续元素,通常映射到连续 Bank。

因此本文基础 Tiled Kernel 直接使用:

cpp 复制代码
__shared__ float tile_A[Tile][Tile];
__shared__ float tile_B[Tile][Tile];

不机械地改成 Tile + 1。Padding 是为具体地址模式服务的,不是 Shared Memory 的固定仪式。

后面的 2×1 寄存器分块示例使用了 17 列,主要是为进一步改变装载或读取形状保留更安全的布局;在当前访问模式中,性能收益的主要来源是一个 B 值同时服务两个累加器,而不是那一列 Padding。判断 Bank 问题仍应以实际 Warp 地址与 Profiler 计数为准。


二十一、循环展开做了什么

模板参数 Tile 在编译期已知:

cpp 复制代码
#pragma unroll
for (int k = 0; k < Tile; ++k) {
    sum += tile_A[ty][k] * tile_B[k][tx];
}

编译器可以展开固定次数循环,减少分支与计数更新,并暴露更多指令级优化机会。

但展开不是免费午餐。它可能:

  • 增大指令体积;
  • 增加寄存器生命周期;
  • 提高寄存器占用;
  • 造成指令缓存压力;
  • 在复杂循环中不一定有收益。

本文用 cudaFuncGetAttributes 报告寄存器数,而不是假设 #pragma unroll 必然更快。若你修改 Tile、每线程输出数或数据类型,应重新编译并观察资源与时间。


二十二、示例二:Naive 与 16/32 Tile 基准

完整文件:

text 复制代码
blogs/code/06/matrix_multiply_tiled_benchmark.cu

运行默认 1024×1024×1024:

powershell 复制代码
.\build\Release\matrix_multiply_tiled_benchmark.exe

本文环境:

text 复制代码
GPU: NVIDIA GeForce RTX 2060
Compute Capability: 7.5
CUDA Toolkit: 11.6
编译目标: sm_75
配置: Release
矩阵: M=N=K=1024
计时: 每个 Kernel 20 次预热,20 次正式迭代

一次实际输出:

text 复制代码
Kernel resources (registers/thread, static shared bytes):
  naive:       49, 0
  tiled_16x16: 39, 2048
  tiled_32x32: 42, 8192

kernel,average_ms,effective_TFLOPS,mismatches
naive,6.394486,0.3358,0
tiled_16x16,3.493154,0.6148,0
tiled_32x32,3.230461,0.6648,0

Tiled 16x16 speedup over naive: 1.83x
Tiled 32x32 speedup over naive: 1.98x
Verification: PASSED

这组结果说明在本机、本次运行和这个尺寸上:

  • 两个 Tiled 版本都快于 Naive;
  • 32×32 略快于 16×16;
  • 32×32 使用四倍静态 Shared Memory;
  • 三个版本逐元素验证全部通过;
  • 加速远小于算术强度推导的复用倍数。

最后一点非常重要。算法层面的冗余减少不会一比一变成时间减少,因为实际性能还受到缓存、指令、Shared Load、同步、寄存器、占用率、时钟和调度影响。


二十三、怎样计算有效 TFLOPS

M×N 个输出,每个输出做 K 次乘加。通常把一次乘法与一次加法计为两次浮点运算:

F = 2 M N K F=2MNK F=2MNK

若 CUDA Event 测得平均时间为 t 毫秒,则:

P T F L O P S = 2 M N K t × 10 9 P_{TFLOPS}=\frac{2MNK}{t\times10^9} PTFLOPS=t×1092MNK

以 1024³ 与 3.230461 毫秒为例:

P T F L O P S ≈ 0.6648 P_{TFLOPS}\approx0.6648 PTFLOPS≈0.6648

这里称"有效 TFLOPS",原因是我们根据问题规模计算逻辑运算量,再除以测量时间。它不是硬件指令计数,也不意味着每个逻辑操作恰好对应一条独立指令。

不要把 H2D、D2H、内存分配与 CPU 参考时间混入 Kernel TFLOPS。若你的业务关心端到端延迟,应另外报告:

text 复制代码
Kernel-only latency
H2D + Kernel + D2H latency
完整业务流程 latency

这三者回答不同问题。


二十四、CUDA Event 的计时边界

本文基准流程:

cpp 复制代码
for (int warmup = 0; warmup < 20; ++warmup) {
    launch();
}

CUDA_CHECK(cudaGetLastError());
CUDA_CHECK(cudaDeviceSynchronize());

cudaEventRecord(start);

for (int iteration = 0;
     iteration < iterations;
     ++iteration) {
    launch();
}

cudaEventRecord(stop);
cudaEventSynchronize(stop);
cudaEventElapsedTime(&total_ms, start, stop);

预热用于减少首次上下文、缓存与时钟状态带来的干扰。Event 记录在默认流中,Stop 同步后得到 GPU 时间。内存分配、Host 初始化、CPU 参考和数据复制都位于计时区间外。

正式报告还应:

  • 重复多轮;
  • 记录中位数与分位数;
  • 关闭不必要的桌面负载;
  • 保持功耗与散热状态一致;
  • 记录驱动、Toolkit 和编译参数;
  • 分别测量代表性尺寸;
  • 避免只挑最好的一次。

本文表格是可复现实验记录,不是对所有 RTX 2060 的固定性能承诺。


二十五、非整块大型矩阵的实测

运行:

powershell 复制代码
.\build\Release\matrix_multiply_tiled_benchmark.exe `
  1003 997 509 5

参数顺序:

text 复制代码
M N K iterations

本文测试结果:

text 复制代码
GEMM: A(1003 x 509) * B(509 x 997) = C(1003 x 997)

kernel,average_ms,effective_TFLOPS,mismatches
naive,6.955859,0.1464,0
tiled_16x16,3.333152,0.3054,0
tiled_32x32,3.323648,0.3063,0

Verification: PASSED

这一组尺寸同时测试:

  • M 不是 16 或 32 的倍数;
  • N 不是 16 或 32 的倍数;
  • K 不是 16 或 32 的倍数;
  • 最右侧输出 Tile 不完整;
  • 最下侧输出 Tile 不完整;
  • 最后一个 K Tile 不完整;
  • 补零、屏障和写回边界同时工作。

全量 mismatches=0 比性能数字更重要。若只测试整数倍尺寸,不能证明边界逻辑正确。


二十六、为什么 CPU 参考使用 FP64 累加

GPU Kernel 的输入、输出与累加器都是 FP32。CPU 参考把每个 FP32 输入转换为 double 后累加:

cpp 复制代码
void cpu_matmul(
    const std::vector<float>& A,
    const std::vector<float>& B,
    std::vector<double>& C,
    int M,
    int N,
    int K) {

    std::fill(C.begin(), C.end(), 0.0);

    for (int row = 0; row < M; ++row) {
        for (int k = 0; k < K; ++k) {
            const double a =
                static_cast<double>(A[row * K + k]);

            for (int col = 0; col < N; ++col) {
                C[row * N + col] +=
                    a * static_cast<double>(
                        B[k * N + col]);
            }
        }
    }
}

使用 FP64 参考不是说 GPU 必须与它逐位一致,而是得到一个数值误差更小的对照。FP32 Kernel 的乘加顺序、编译器生成的 FMA、cuBLAS 算法和 CPU 循环都可能不同。

浮点加法不满足严格结合律:

( a + b ) + c ≠ a + ( b + c ) (a+b)+c\ne a+(b+c) (a+b)+c=a+(b+c)

当 K 增加时,不同累加顺序的舍入误差会逐步积累。因此不能用:

cpp 复制代码
gpu_value == cpu_value

作为一般 GEMM 的正确性判断。

CPU 参考使用 i-k-j 循环,而不是最直观的 i-j-k。两者数学语义相同,前者在 CPU 上连续遍历 B 与 C 的行,通常有更好的局部性。参考程序本身也需要足够快,否则每次实验都在等待 CPU,学习体验会很差。


二十七、组合误差阈值为什么比单一阈值稳健

本文每个结果使用:

cpp 复制代码
const double tolerance =
    1.0e-3 +
    5.0e-4 * std::fabs(expected);

判断:

cpp 复制代码
if (abs_error > tolerance) {
    ++mismatches;
}

绝对误差部分保护接近零的参考值。若只使用相对误差,参考值非常小时,分母会让一个很小的绝对差异看起来巨大。

相对项则允许较大数值拥有与量级成比例的误差范围。若只使用固定绝对阈值,结果幅度增大后可能过于苛刻。

绝对误差:

E a b s = ∣ x g p u − x r e f ∣ E_{abs}=|x_{gpu}-x_{ref}| Eabs=∣xgpu−xref∣

相对误差:

E r e l = ∣ x g p u − x r e f ∣ max ⁡ ( ∣ x r e f ∣ , ϵ ) E_{rel}=\frac{|x_{gpu}-x_{ref}|}{\max(|x_{ref}|,\epsilon)} Erel=max(∣xref∣,ϵ)∣xgpu−xref∣

程序同时报告最大绝对误差与最大相对误差,但通过条件使用组合阈值。本文 1024³ 实测中,自写 Kernel 最大绝对误差约为 9.414e-06,所有元素通过;最大相对误差约为 2.815e-03,它往往出现在参考值接近零的位置,所以不能脱离绝对误差单独解读。

阈值不是通用常数。它应考虑:

  • 输入分布;
  • K 的长度;
  • 数据类型;
  • 是否使用 FMA;
  • 允许的业务误差;
  • 是否存在病态矩阵;
  • 使用的 cuBLAS 数值模式;
  • 是否采用 Tensor Core 或混合精度。

生产系统最好用代表性真实数据建立误差基线,而不是永久复制教程常数。


二十八、为什么验证必须覆盖全部输出

只打印前十个元素或随机抽查几个位置,适合快速观察,却不足以证明边界和索引正确。一个错误 Kernel 可能只破坏最后一列、最后一个 Tile 或某个特殊 Block。

本文逐元素比较整个 C:

cpp 复制代码
for (size_t i = 0; i < output.size(); ++i) {
    const double actual =
        static_cast<double>(output[i]);
    const double expected = reference[i];
    const double abs_error =
        std::fabs(actual - expected);
    // ...
}

首次发现不匹配时还应报告:

text 复制代码
线性下标
二维 row、col
GPU 值
CPU 值
绝对误差
允许阈值

当前示例为了保持代码紧凑,只打印前五个错误的线性下标。你可以进一步转换:

cpp 复制代码
const int row = static_cast<int>(i / N);
const int col = static_cast<int>(i % N);

若错误集中在 col >= floor(N/Tile)*Tile,优先检查右边界;若集中在 K 最后一段,优先检查补零;若每隔固定行出现,优先检查 leading dimension 或行跨度。


二十九、Shared Tiled 之后,为什么还要寄存器分块

基础 Tiled Kernel 中,一个线程只维护一个输出:

cpp 复制代码
float sum = 0.0f;

每次内部循环读取一个 A Shared 值和一个 B Shared 值,完成一次乘加。

但 B 的同一个值不仅可以服务当前输出行,也能服务同一列中的另一个输出行。若一个线程同时计算两个 C 元素,就可以:

text 复制代码
读取一次 B Shared 值;
读取两次不同 A Shared 值;
更新 sum0;
更新 sum1。

这就是最小的寄存器分块。它让一个线程拥有一个 2×1 的输出微块,而不是 1×1。

更一般地,每线程可以维护 R×C 个输出:

F t h r e a d = R × C F_{thread}=R\times C Fthread=R×C

每个累加器占用寄存器,输入片段也可能进入寄存器。R 与 C 增大能提升复用和指令级并行,却会增加寄存器压力、代码体积与边界浪费。高性能 GEMM 会精细平衡线程块 Tile、Warp Tile 和 Thread Tile,本篇只实现容易理解且能直接验证的 2×1。


三十、2×1 寄存器分块的线程块形状

本文使用 16×16 个线程,即 256 个线程,但一个 Block 计算 C 的 32×16 输出 Tile:

text 复制代码
Block 线程:16 × 16
输出 Tile:32 行 × 16 列
每线程输出:2 行 × 1 列
K Tile:16

对线程 (tx,ty)

cpp 复制代码
const int col =
    blockIdx.x * 16 + tx;

const int row0 =
    blockIdx.y * 32 + ty;

const int row1 =
    row0 + 16;

线程保存:

cpp 复制代码
float sum0 = 0.0f;
float sum1 = 0.0f;

最终写回:

cpp 复制代码
if (row0 < M && col < N) {
    C[row0 * N + col] = sum0;
}

if (row1 < M && col < N) {
    C[row1 * N + col] = sum1;
}

Grid 的 y 方向覆盖单位从 16 行变成 32 行:

cpp 复制代码
const dim3 grid_register(
    (N + 15) / 16,
    (M + 31) / 32);

这类变化不能只改 Kernel 而忘记 Host 启动参数。若 Grid 仍按 16 行划分,会重复或错位计算输出区域。


三十一、2×1 版本如何装载 A 与 B

A Shared Tile 需要 32×16,因为输出包含 32 行;B Shared Tile 仍是 16×16,因为输出只有 16 列,K 阶段长度也是 16。

每个线程装载两个 A 元素:

cpp 复制代码
tile_A[ty][tx] =
    (row0 < M && a_col < K)
        ? A[row0 * K + a_col]
        : 0.0f;

tile_A[ty + 16][tx] =
    (row1 < M && a_col < K)
        ? A[row1 * K + a_col]
        : 0.0f;

每个线程装载一个 B 元素:

cpp 复制代码
tile_B[ty][tx] =
    (b_row < K && col < N)
        ? B[b_row * N + col]
        : 0.0f;

256 个线程总共装载:

text 复制代码
A:512 个 float
B:256 个 float

刚好填满 32×16 与 16×16。对每个 K Tile,Global Memory 的装载职责仍均匀分配给线程。


三十二、2×1 版本的核心复用

乘加循环:

cpp 复制代码
#pragma unroll
for (int k = 0; k < 16; ++k) {
    const float b = tile_B[k][tx];

    sum0 += tile_A[ty][k] * b;
    sum1 += tile_A[ty + 16][k] * b;
}

b 被读入局部标量后,立即服务两个 FMA。对比基础版,单个线程每个 k 只更新一次 sum;现在更新两个累加器。

片上数据路径可以概括为:

text 复制代码
Global A 的两行片段 → Shared A
Global B 的一行片段 → Shared B
Shared B 的一个值 → 线程局部 b
线程局部 b + 两个 A 值 → sum0、sum1
两个寄存器部分和 → Global C 的两个位置

这一步把优化从"Block 内线程共享"推进到"单线程内部多个输出共享"。


三十三、2×1 完整 Kernel

完整核心如下:

cpp 复制代码
__global__ void matmul_register_2x1(
    const float* A,
    const float* B,
    float* C,
    int M,
    int N,
    int K) {

    constexpr int TileK = 16;
    constexpr int BlockRows = 32;
    constexpr int BlockCols = 16;

    __shared__ float tile_A[BlockRows][TileK + 1];
    __shared__ float tile_B[TileK][BlockCols + 1];

    const int tx = threadIdx.x;
    const int ty = threadIdx.y;

    const int col =
        blockIdx.x * BlockCols + tx;
    const int row0 =
        blockIdx.y * BlockRows + ty;
    const int row1 = row0 + 16;

    float sum0 = 0.0f;
    float sum1 = 0.0f;

    for (int tile_k = 0;
         tile_k < K;
         tile_k += TileK) {

        const int a_col = tile_k + tx;
        const int b_row = tile_k + ty;

        tile_A[ty][tx] =
            (row0 < M && a_col < K)
                ? A[row0 * K + a_col]
                : 0.0f;

        tile_A[ty + 16][tx] =
            (row1 < M && a_col < K)
                ? A[row1 * K + a_col]
                : 0.0f;

        tile_B[ty][tx] =
            (b_row < K && col < N)
                ? B[b_row * N + col]
                : 0.0f;

        __syncthreads();

        #pragma unroll
        for (int k = 0; k < TileK; ++k) {
            const float b = tile_B[k][tx];
            sum0 += tile_A[ty][k] * b;
            sum1 += tile_A[ty + 16][k] * b;
        }

        __syncthreads();
    }

    if (row0 < M && col < N) {
        C[row0 * N + col] = sum0;
    }

    if (row1 < M && col < N) {
        C[row1 * N + col] = sum1;
    }
}

完整程序:

text 复制代码
blogs/code/06/matrix_multiply_register_cublas.cu

它还包含基础 16×16 Tiled Kernel 与 cuBLAS,对三者使用同一输入、同一 CPU 参考和同一计时函数。


三十四、寄存器分块的资源代价

静态 Shared Memory:

S A = 32 × 17 × 4 = 2176 S_A=32\times17\times4=2176 SA=32×17×4=2176

S B = 16 × 17 × 4 = 1088 S_B=16\times17\times4=1088 SB=16×17×4=1088

总计:

S 2 × 1 = 2176 + 1088 = 3264 S_{2\times1}=2176+1088=3264 S2×1=2176+1088=3264

本文实际查询:

text 复制代码
Kernel resources (registers/thread, static shared bytes):
  tiled_16x16: 39, 2048
  register_2x1: 54, 3264

2×1 版本每线程寄存器从 39 增加到 54,静态 Shared 从 2048 增加到 3264 字节。它不是"免费加速"。

若一个 SM 的寄存器总量固定,每线程寄存器变多可能减少同时驻留的 Warp 或 Block。低占用率不一定慢,高占用率也不一定快;关键是是否有足够活跃 Warp 隐藏延迟,同时保留足够寄存器支持有效计算。

优化时应同时观察:

text 复制代码
每线程寄存器
每 Block Shared Memory
每 Block 线程数
理论与实际 Occupancy
是否发生 Local Memory Spill
指令吞吐
内存吞吐
Kernel 时间

只看到"一个线程算两个结果"就宣布更优,会遗漏资源侧代价。


三十五、寄存器分块的实测结果

运行:

powershell 复制代码
.\build\Release\matrix_multiply_register_cublas.exe

本文 1024³ 的一次实测:

text 复制代码
operation,average_ms,effective_TFLOPS,mismatches
tiled_16x16,5.074534,0.4232,0
register_2x1,3.627320,0.5920,0
cuBLAS_SGEMM,0.399755,5.3720,0

Register 2x1 speedup over tiled 16x16: 1.40x
cuBLAS speedup over tiled 16x16: 12.69x
Verification: PASSED

同一个基础 Tiled Kernel 在不同可执行文件、不同运行时刻的绝对时间会波动,因此不要把本表的 5.074534 与上一程序中的 3.493154 直接当成 Kernel 源码变化。二者编译目标相同,但运行顺序、桌面负载、GPU 时钟状态与可执行文件环境可能改变测量。

程序内部的同轮比较更有参考价值:在这次运行中,2×1 比同一可执行文件里的 16×16 Tiled 快 1.40 倍,同时使用更多寄存器和 Shared Memory。它验证了"增加线程内复用可能有效",却没有证明 2×1 是最佳形状。

应继续尝试:

text 复制代码
1×2
2×2
4×1
4×2
不同 Block Tile
不同 K Tile

每个版本都必须重新检查合并访问、Bank、寄存器、边界与时间。


三十六、小矩阵会推翻"大 Tile 必然更快"

运行:

powershell 复制代码
.\build\Release\matrix_multiply_tiled_benchmark.exe `
  17 19 13 3

实测:

text 复制代码
naive:       0.005099 ms
tiled_16x16: 0.004352 ms
tiled_32x32: 0.007147 ms

32×32 反而慢于 Naive。原因很直观:

  • 只有 17×19 个有效输出;
  • 32×32 Block 启动 1024 个线程;
  • 大量线程只负责补零和同步;
  • K 只有 13,却执行固定 32 次内部循环;
  • Kernel 启动与屏障固定开销占比高;
  • 算法没有足够工作量摊薄 Tile 成本。

同样尺寸的第二个程序实测:

text 复制代码
tiled_16x16: 0.004235 ms
register_2x1: 0.004747 ms
cuBLAS_SGEMM: 0.007733 ms

cuBLAS 在这次极小矩阵测试中也不是最快。成熟库需要调度与选择算法,单次小工作量无法充分利用 GPU。实际工程常通过批处理、Strided Batched GEMM、算子融合或直接留在 CPU 来降低小矩阵固定开销。

结论不是"cuBLAS 对小矩阵不好",而是:

性能方案必须匹配问题规模。高吞吐大矩阵与低延迟小矩阵是两种不同目标,不能用一张 1024³ 跑分替代所有场景。


三十七、为什么要加入 cuBLAS 对照

只比较 Naive 与 Tiled,容易产生一种错觉:快两倍就已经很好。加入 cuBLAS 后,我们得到更有价值的参照。

cuBLAS 是 NVIDIA 提供的 GPU BLAS 库。cublasSgemm 计算 FP32 通用矩阵乘:

C = α A B + β C C=\alpha AB+\beta C C=αAB+βC

它不仅是"别人写的一个 Kernel"。库会根据架构、尺寸、转置标志、数学模式和版本使用优化实现。对标准 GEMM,成熟库通常比教程 Kernel 快得多。

比较 cuBLAS 的意义有三点:

  1. 检查自己的 TFLOPS 是否离成熟实现还有数量级差距;
  2. 学习布局、leading dimension 与库接口;
  3. 建立工程判断:标准算子优先使用库,特殊融合才考虑自写。

官方 cuBLAS 文档明确说明 cublasSgemm 的 A、B、C 默认采用列主序,并定义了 m、n、k、lda、ldb、ldc 的约束。本文的自写 Kernel 使用行主序,因此必须做正确转换。


三十八、行主序数据怎样交给列主序 cuBLAS

假设内存中保存行主序:

text 复制代码
A_row:M × K
B_row:K × N
C_row:M × N

同一段一维内存若被列主序接口解释,会分别看成:

text 复制代码
A_col_view:K × M,也就是 A 的转置
B_col_view:N × K,也就是 B 的转置
C_col_view:N × M,也就是 C 的转置

我们希望:

C = A B C=AB C=AB

两边转置:

C T = B T A T C^T=B^TA^T CT=BTAT

因此在 cuBLAS 的列主序视角下,把 d_B 作为第一个矩阵,把 d_A 作为第二个矩阵,计算 N×M 输出:

cpp 复制代码
cublasSgemm(
    handle,
    CUBLAS_OP_N,
    CUBLAS_OP_N,
    N,
    M,
    K,
    &alpha,
    d_B,
    N,
    d_A,
    K,
    &beta,
    d_C,
    N);

参数解释:

text 复制代码
cuBLAS m = N
cuBLAS n = M
cuBLAS k = K
第一个矩阵 = d_B,列主序视角 N×K,lda=N
第二个矩阵 = d_A,列主序视角 K×M,ldb=K
输出 = d_C,列主序视角 N×M,ldc=N

内存中的输出正好仍是行主序 M×N 的 C,不需要额外转置。

这段"交换 A/B 与交换 M/N"的技巧常用于紧凑行主序矩阵。若存在子矩阵、Padding、复杂 leading dimension、批量布局或转置标志,应重新严格推导,不能机械复制。


三十九、cuBLAS 错误检查不可省略

CUDA Runtime 与 cuBLAS 使用不同状态类型。本文分别定义:

cpp 复制代码
#define CUDA_CHECK(call) \
    cuda_check_impl(
        (call), #call, __FILE__, __LINE__)

#define CUBLAS_CHECK(call) \
    cublas_check_impl(
        (call), #call, __FILE__, __LINE__)

创建与销毁 Handle:

cpp 复制代码
cublasHandle_t handle{};
CUBLAS_CHECK(cublasCreate(&handle));

// 使用 handle

CUBLAS_CHECK(cublasDestroy(handle));

不要只检查 cudaGetLastError() 来代替 cuBLAS 返回值。库调用可能返回参数、架构、分配或执行错误。

若使用自定义 Stream,还要通过 cublasSetStream 绑定,并把 CUDA Event 记录在同一 Stream 中。本文使用默认 Stream,因此三个操作共享一致的简单计时语义。


四十、公平比较 cuBLAS 需要统一哪些口径

本文比较满足:

  • A、B、C 都是 FP32;
  • 输入数据相同;
  • 输出尺寸相同;
  • 逻辑运算量都按 2MNK;
  • H2D 与 D2H 都在计时外;
  • cublasCreate 在计时外;
  • CPU 参考在计时外;
  • 每个操作独立预热 20 次;
  • 正式迭代次数相同;
  • Event 位于同一默认流;
  • beta=0,每轮直接覆盖 C;
  • 输出都与同一个 FP64 CPU 参考比较。

cuBLAS 调用:

cpp 复制代码
const float alpha = 1.0f;
const float beta = 0.0f;

所以语义是:

C = 1 × A B + 0 × C C=1\times AB+0\times C C=1×AB+0×C

若把自写 Kernel 只测计算,而 cuBLAS 时间包含内存复制和 Handle 创建,比较不公平;反过来也一样。端到端比较可以做,但要让所有方案包含相同阶段。

还要记录库版本和数学模式。不同 CUDA 版本、GPU 架构与默认策略可能选择不同实现,数值误差和性能也会变化。


四十一、为什么 cuBLAS 快得多

本文大矩阵实测中,cuBLAS SGEMM 达到约 5.3720 有效 TFLOPS,而简单 2×1 Kernel 约为 0.5920,相差约九倍;相对基础 Tiled 的本轮时间加速约 12.69 倍。

可能的差距来源包括:

  • 更合理的 Thread Block Tile;
  • Warp 级输出 Tile;
  • 每线程维护更多累加器;
  • 更高指令级并行;
  • 向量化 Global Load/Store;
  • 更少地址计算;
  • Shared Memory 双缓冲;
  • 计算与数据搬运流水化;
  • 架构相关指令调度;
  • 更精细的寄存器分配;
  • 多种尺寸专用 Kernel;
  • 更合适的边界处理;
  • Tensor Core 或其他硬件路径;
  • 更成熟的自动选择策略;
  • 融合的缩放与写回路径。

教程 Kernel 的作用不是用几十行代码击败多年优化的库,而是让你能解释上述设计。如果不懂 Tile、同步、寄存器与布局,看到库性能只会觉得神秘;掌握本篇后,你能把差距拆成可分析的方向。


四十二、什么时候应该自写 GEMM

标准的连续矩阵乘,优先考虑 cuBLAS、cuBLASLt 或上层框架已有算子。自写 Kernel 的合理场景通常不是"我不想调用库",而是标准接口无法高效表达完整工作流。

可能值得自写或使用更低层模板库的情况:

  • GEMM 前后有可融合的轻量操作;
  • 输入布局特殊,单独转换成本很高;
  • 矩阵尺寸非常特殊且高度固定;
  • 稀疏结构或掩码有业务语义;
  • 需要自定义量化、解量化或缩放;
  • 需要把多个小操作合并为一个 Kernel;
  • 研究新的数值格式或算法;
  • 需要教学、分析或验证硬件行为。

即使这些场景,也应先建立库基线。一个合理决策记录可以写成:

text 复制代码
需求:标准 SGEMM 后还需偏置、激活和裁剪。
基线:cuBLAS + 三个独立 Kernel。
瓶颈:小矩阵下启动与中间写回占比高。
方案:评估 cuBLASLt Epilogue 或融合自写 Kernel。
验收:端到端时间、误差、维护成本、跨 GPU 稳定性。
回退:尺寸不匹配时使用成熟库。

自写的价值必须由完整应用收益证明,而不是只由 Kernel 名字证明。


四十三、Tiled 更快时,如何找到证据

CUDA Event 告诉你"快了多少",不能独立证明"为什么快"。原因分析应使用 Nsight Compute 等性能分析器。

可执行:

powershell 复制代码
ncu --set full `
  .\build\Release\matrix_multiply_tiled_benchmark.exe `
  1024 1024 1024 1

分析重点包括:

text 复制代码
Kernel Duration
Global Load/Store 吞吐
DRAM 吞吐
L1/L2 命中与请求
Shared Load/Store 吞吐
Shared Bank Conflict
Achieved Occupancy
Active Warps
寄存器与 Shared 资源
Warp Stall 原因
浮点指令吞吐

预期假设:

text 复制代码
Naive:程序可控的数据复用较少,更多依赖缓存;
Tiled:Global 输入请求被 Block 内 Shared 复用;
Register 2x1:每个 B Shared 值服务更多累加器;
cuBLAS:计算与片上复用更充分,调度更成熟。

Profiler 的作用是检验这些假设,不是寻找一个"越大越好"的单一指标。


四十四、如果 Nsight Compute 报权限错误

本文机器可以启动 ncu,但硬件计数器访问返回 ERR_NVGPUCTRPERM。这表示当前系统不允许普通进程读取 GPU 性能计数器,并不表示 Kernel 错误。

遇到这种情况:

  1. 保留 CUDA Event 正常计时结果;
  2. 明确说明硬件计数缺失;
  3. 按 NVIDIA 指引由系统管理员配置计数器权限;
  4. 不要伪造 Bank、缓存或 Stall 指标;
  5. 不要仅凭运行时间断言具体硬件原因。

教程中诚实报告"当前环境无法读取计数器"比填入来源不明的数据更重要。Event 已足够验证相对时间,但因果解释要等到权限与 Profiler 可用。

本文还尝试用 CUDA 11.6 自带 Compute Sanitizer 检查小型边界矩阵,但当前 Windows WDDM 环境无法初始化调试接口,并报告设备不受这一版 Sanitizer 支持。示例程序本身继续运行且全量数值验证通过,但这不等于 Sanitizer 已完成内存检查。读者应在自己的受支持环境重新执行;本文只报告已经获得的证据,不把工具初始化失败写成"零内存错误"。


四十五、常见错误一:把 Grid 的 M/N 写反

错误:

cpp 复制代码
dim3 grid(
    (M + block.x - 1) / block.x,
    (N + block.y - 1) / block.y);

若 x 映射 col、y 映射 row,正确是:

cpp 复制代码
dim3 grid(
    (N + block.x - 1) / block.x,
    (M + block.y - 1) / block.y);

正方形矩阵会掩盖这个错误,所以必须测试 M 不等于 N。


四十六、常见错误二:把 B 的行跨度写成 K

错误:

cpp 复制代码
B[k * K + col]

正确:

cpp 复制代码
B[k * N + col]

B 是 K×N,每行 N 个元素。只有 N 等于 K 时错误才被掩盖。

同理,C 是 M×N,写回是:

cpp 复制代码
C[row * N + col]

矩形测试是发现行跨度错误最便宜的方法。


四十七、常见错误三:只给 A 做边界保护

Tiled 装载需要分别判断:

text 复制代码
A:row < M 且 a_col < K
B:b_row < K 且 col < N
C:row < M 且 col < N

三者对应不同矩阵与坐标。不要用一个 row<M && col<N 同时保护 A、B 装载,因为 A 的第二维是 K,B 的第一维也是 K。

正确代码已经把条件写在各自赋值旁。若希望减少分支,可在保证正确后再研究 Predication 或专用边界 Kernel。


四十八、常见错误四:错误理解 Tile 的"只读一次"

一个 A Tile 被当前 Block 从 Global Memory 装载一次并在 Block 内复用,不代表整个 Grid 只读取一次。相邻的 C Block 可能需要相同 A 行区间但不同输出列,仍会再次装载;相邻输出行 Block 也会重复装载 B。

所以简单 Block Tiling 提升的是 Block 内复用,不是全局唯一读取。跨 Block 复用仍依赖缓存或更高级的持久化、集群与算法设计。

算术强度推导必须说明层级。把 T/4 直接当成整机 DRAM 实测强度,会忽略 Block 间重复和缓存行为。


四十九、常见错误五:认为 Tile 越大越好

Tile 增大能够提高理论复用,却同时增加:

  • 线程数;
  • Shared Memory;
  • Block 级寄存器需求;
  • 同步等待;
  • 边界浪费;
  • 编译展开体积。

本文小矩阵已经展示 32×32 慢于 16×16 和 Naive。即使大矩阵中 32×32 在 RTX 2060 略快,换一张 GPU 或换成瘦矩阵也可能改变顺序。

正确方法是建立候选集合,逐个编译、验证、测量,并记录资源。


五十、常见错误六:只比较毫秒,不比较语义

下面比较没有意义:

text 复制代码
自写 Kernel:只算 C=A×B,FP32;
另一个方案:包含 H2D、偏置、激活和 D2H,FP16;
然后只比较总毫秒。

公平比较要统一:

text 复制代码
数据类型
矩阵形状
布局
转置标志
alpha、beta
输入输出位置
预热
计时边界
迭代次数
正确性阈值
数学模式

若业务流程不同,应报告差异,而不是把它们伪装成同一个算子。


五十一、完整工程目录

本篇文件:

text 复制代码
blogs/
├── CUDA编程实战06_矩阵乘法优化_从朴素GEMM到共享内存分块.md
├── assets/
│   └── 06/
│       ├── 01-hero-gemm-tiled.png
│       ├── 02-gemm-shapes-mnk.svg
│       ├── 03-naive-gemm-redundant-reads.svg
│       ├── 04-tiled-gemm-two-barriers.svg
│       ├── 05-register-blocking-2x1.svg
│       └── 06-roofline-arithmetic-intensity.svg
└── code/
    └── 06/
        ├── CMakeLists.txt
        ├── matrix_multiply_basic.cu
        ├── matrix_multiply_tiled_benchmark.cu
        └── matrix_multiply_register_cublas.cu

三个程序分别承担不同教学目标:

程序 目的 默认尺寸
matrix_multiply_basic 打印、手算、理解线程映射 3×5×4
matrix_multiply_tiled_benchmark Naive、16 Tile、32 Tile、资源与 TFLOPS 1024³
matrix_multiply_register_cublas 2×1 寄存器分块与 cuBLAS 对照 1024³

不要把所有概念塞进第一个程序。分层示例让错误更容易定位,也让读者可以按学习阶段运行。


五十二、CMake 为什么要链接 CUDA::cublas

完整 CMake:

cmake 复制代码
cmake_minimum_required(VERSION 3.18)

project(cuda_blog_06 LANGUAGES CXX CUDA)

set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CUDA_STANDARD 14)
set(CMAKE_CUDA_STANDARD_REQUIRED ON)

find_package(CUDAToolkit REQUIRED)

add_executable(
    matrix_multiply_basic
    matrix_multiply_basic.cu)

add_executable(
    matrix_multiply_tiled_benchmark
    matrix_multiply_tiled_benchmark.cu)

add_executable(
    matrix_multiply_register_cublas
    matrix_multiply_register_cublas.cu)

target_link_libraries(
    matrix_multiply_register_cublas
    PRIVATE CUDA::cublas)

find_package(CUDAToolkit REQUIRED) 提供导入目标 CUDA::cublas。只有第三个程序需要链接 cuBLAS;前两个只使用 CUDA Runtime。

Windows 还加入 /utf-8,避免中文注释在 MSVC 与 nvcc 组合中出现编码警告。若你的项目不需要中文源码,可以省略。


五十三、Linux 下直接使用 nvcc

不使用 CMake 时,可以分别编译:

bash 复制代码
nvcc -O3 -std=c++14 \
  -arch=sm_75 \
  matrix_multiply_basic.cu \
  -o matrix_multiply_basic

nvcc -O3 -std=c++14 \
  -arch=sm_75 \
  matrix_multiply_tiled_benchmark.cu \
  -o matrix_multiply_tiled_benchmark

nvcc -O3 -std=c++14 \
  -arch=sm_75 \
  matrix_multiply_register_cublas.cu \
  -lcublas \
  -o matrix_multiply_register_cublas

运行:

bash 复制代码
./matrix_multiply_basic
./matrix_multiply_tiled_benchmark
./matrix_multiply_register_cublas

sm_75 替换为目标 GPU 架构。若编译多个架构用于分发,建议通过 CMake 的 CMAKE_CUDA_ARCHITECTURES 或适当 -gencode 组合生成,而不是盲目使用与设备不匹配的值。


五十四、建议的七轮动手练习

第一轮,只运行默认 3×4 与 4×5,手算 C[0,0]C[2,4]

第二轮,把 Block 改成 8×8、16×8 与 32×8,确认结果不变。观察 Grid 如何变化,不急于比较性能。

第三轮,独立重写朴素 Kernel,不复制文章。只看矩阵定义推导下标。

第四轮,在纸上画 4×4 输出 Tile 与两个 4×4 输入 Tile,模拟 K 为 6 时的两轮补零。

第五轮,删除第一道或第二道屏障,只在单独实验分支中运行 Compute Sanitizer,观察竞态风险;实验结束后恢复正确代码。

第六轮,实现 1×2 寄存器分块,让一个线程计算同一行的两列,比较它与 2×1 的装载与边界。

第七轮,记录至少五种尺寸:

text 复制代码
17 × 19 × 13
256 × 256 × 256
1024 × 1024 × 1024
4096 × 256 × 1024
256 × 4096 × 1024

为每个尺寸写下 Naive、Tiled、Register 与 cuBLAS 时间。你会发现矩阵形状不仅是数据规模,也会改变复用、Grid 数量、边界比例和库算法选择。


五十五、性能实验记录模板

每次修改 Kernel,建议填写:

text 复制代码
日期:
GPU:
Compute Capability:
驱动:
CUDA Toolkit:
编译器:
编译架构:
Release/Debug:

M:
N:
K:
数据类型:
输入分布:

Kernel 名称:
Block:
Grid:
每线程输出:
K Tile:
寄存器/线程:
静态 Shared/Block:

预热次数:
正式次数:
平均值:
中位数:
P90:
有效 TFLOPS:

Mismatch:
最大绝对误差:
最大相对误差:

Profiler 证据:
结论:
下一步假设:

优化最容易丢失的是上下文。几天后只剩"这个版本 0.4 ms",却不知道尺寸、时钟和编译参数,数据就无法复现。


五十六、遇到错误结果时的排查顺序

建议按因果关系排查:

text 复制代码
第一步:用 3×5×4 打印 A、B、C。
第二步:手算一个 C 元素。
第三步:确认 A/B/C 行跨度。
第四步:确认 Grid x 覆盖 N,Grid y 覆盖 M。
第五步:确认所有 Global Load 的边界。
第六步:确认越界 Shared 位置写 0。
第七步:确认所有线程经过两道屏障。
第八步:确认 K Tile 起点与内部 k 没有重复相加。
第九步:确认最终写回坐标。
第十步:运行非整块矩形尺寸。
第十一步:运行 Compute Sanitizer。
第十二步:最后才调整浮点容差。

不要一看到差异就放宽误差。若行跨度错误,误差阈值再大也只是隐藏 Bug。


五十七、遇到性能不升反降时的排查顺序

结果正确后,再看:

text 复制代码
矩阵是否太小,固定开销占主导?
Tile 是否让大量线程越界?
Block 是否达到设备线程上限?
寄存器是否显著增加?
是否发生 Spill?
Shared Memory 是否降低驻留 Block?
Global Load 是否真的合并?
Shared 访问是否出现 Bank Conflict?
同步是否过多?
循环是否被正确展开?
计时是否包含复制或首次初始化?
GPU 是否降频或被桌面任务占用?
是否只测了一次?

如果 Profiler 不可用,先用资源报告、尺寸对照与多轮 Event 建立有限证据,并明确哪些原因仍是推测。


五十八、本篇必须带走的二十五条结论

  1. A 为 M×K,B 为 K×N,C 为 M×N。
  2. M 决定输出行,N 决定输出列,K 决定点积长度。
  3. 行主序 A、B、C 的行跨度分别是 K、N、N。
  4. 基础映射让一个线程计算一个 C 元素。
  5. Grid x 覆盖 N,Grid y 覆盖 M。
  6. sum 应在寄存器中累加,最后只写一次 C。
  7. Naive 正确但没有显式保证 Block 级输入复用。
  8. Shared Tiling 让一个 Block 负责一个 C Tile。
  9. A Tile 与 B Tile 沿相同 K 区间推进。
  10. 协作装载应让相邻线程读取连续 Global 地址。
  11. 装载后屏障保护写后读。
  12. 计算后屏障保护读后覆盖。
  13. 所有线程必须一致穿过 Block 屏障。
  14. 边界 Tile 用零填充保持固定循环与数学正确。
  15. 简单 GEMM 的 Shared 访问不应机械照抄转置 Padding。
  16. Tile 增大同时提高复用与资源成本。
  17. 有效 TFLOPS 按 2MNK 与 Event 时间计算。
  18. CPU FP64 参考能提供更稳定的正确性对照。
  19. 误差验证应结合绝对与相对尺度。
  20. 寄存器分块让一个线程维护多个输出。
  21. 2×1 版本复用 B 值,但增加寄存器与 Shared 用量。
  22. cuBLAS 默认列主序,行主序紧凑矩阵可用转置恒等式转换。
  23. 大矩阵吞吐方案不一定适合极小矩阵。
  24. 成熟库通常远快于教学 Kernel。
  25. 自写优化必须由完整应用收益、正确性与可维护性共同证明。

一句话总结:

GEMM 优化的核心不是让更多线程各自去显存取数据,而是设计一条分层数据通路,让 Global Memory 搬入的 Tile 在 Shared Memory 中被 Block 复用,再让每个线程把片上值用于多个寄存器累加器。


五十九、学习成果验收

请在不看最终代码的情况下回答:

  1. C[row,col] 为什么遍历 K?
  2. B 的行跨度为什么是 N?
  3. Grid 为什么由 M、N 而不是 K 决定?
  4. Naive 中 A 与 B 分别呈现怎样的 Warp 需求?
  5. 16×16 C Tile 在一个 K 阶段需要哪些 A、B 元素?
  6. 为什么协作装载通常是合并访问?
  7. 第一道屏障和第二道屏障分别保护什么?
  8. 为什么越界线程不能提前 return?
  9. K 不整除 Tile 时为什么补零正确?
  10. 16 Tile 与 32 Tile 各使用多少静态 Shared?
  11. 为什么 GEMM 不必机械使用 32×33?
  12. #pragma unroll 可能增加什么资源?
  13. 为什么要用 CUDA Event 而不是 CPU 普通时钟测 Kernel?
  14. 有效 TFLOPS 的公式是什么?
  15. 为什么最大相对误差可能在接近零处很大?
  16. 2×1 寄存器分块如何复用 B?
  17. 2×1 为什么会增加寄存器?
  18. 行主序数据怎样映射到列主序 cuBLAS?
  19. cublasSgemm 调用中为什么先传 d_B?
  20. 为什么 17×19×13 上 cuBLAS 可能不是最快?
  21. 哪些 Profiler 指标能验证冗余 Global 访问减少?
  22. 哪些场景应优先使用成熟库?
  23. 哪些融合场景可能值得自写?
  24. 如何构造一个能暴露边界错误的测试矩阵?
  25. 如何证明优化在完整应用中有价值?

若你能重新写出任意 M/N/K 的 Tiled Kernel,在 1003×997×509 上零错误,并解释两道屏障与行主序 cuBLAS 转换,你已经不再只是会调用矩阵乘,而是掌握了 CUDA 分块计算的第一套完整方法。


六十、官方参考资料

官方资料会随 Toolkit 更新。本文代码在 CUDA Toolkit 11.6、MSVC 19.34、Compute Capability 7.5 上实际构建;文档链接指向当前在线版本,因此新增 API、默认算法与指标名称可能与 11.6 有差异。学习原则应保持一致:以目标 Toolkit 的头文件、库版本、目标 GPU 和实测为最终依据。


附录一:用 4×4 Tile 手工走完一次分块

很多人能够读懂 Kernel,却无法脱离代码解释一个 Tile 到底做了什么。下面用更小的数字完整模拟。

设:

text 复制代码
M = 5
N = 6
K = 7
Tile = 4

Grid:

text 复制代码
x 方向 ceil(6 / 4) = 2 个 Block
y 方向 ceil(5 / 4) = 2 个 Block

每个 Block 有 4×4 个线程。以 blockIdx=(0,0) 为例,它负责 C 的:

text 复制代码
row = 0 到 3
col = 0 到 3

K 为 7,所以需要两个阶段。

第一阶段 tile_k=0

text 复制代码
A Tile:A 的 row 0..3,col 0..3
B Tile:B 的 row 0..3,col 0..3

16 个线程各装载一个 A 与一个 B。同步后,每个线程做 4 次乘加。线程 (tx=2,ty=1) 对应 C[1,2],这一阶段累加:

text 复制代码
A[1,0] * B[0,2]
A[1,1] * B[1,2]
A[1,2] * B[2,2]
A[1,3] * B[3,2]

第二道屏障后,进入 tile_k=4

text 复制代码
A Tile:A 的 row 0..3,col 4..6 有效,col 7 越界
B Tile:B 的 row 4..6 有效,row 7 越界,col 0..3

对应 Shared 的最后一列或最后一行写零。线程 (2,1) 再累加:

text 复制代码
A[1,4] * B[4,2]
A[1,5] * B[5,2]
A[1,6] * B[6,2]
0 * 0

两阶段合并恰好得到长度 7 的完整点积。

再看 blockIdx=(1,1)。它的起点:

text 复制代码
row_base = 4
col_base = 4

输出 Tile 理论覆盖 row 4...7、col 4...7,但实际 C 只有 row 0...4、col 0...5。因此只有:

text 复制代码
row = 4
col = 4 或 5

六个之外的绝大多数线程不写结果,但仍必须参与装载补零、计算和同步。这个极端边界块展示了固定 Tile 的浪费,也说明为什么小矩阵或奇怪形状需要更合适的专用 Kernel。

请自己在纸上画四个 Block,并标出每个有效输出。若能完成这一步,就不会再把 blockIdx.xblockIdx.y、M 与 N 混淆。


附录二:从矩阵转置迁移来的能力

第五篇与本篇都使用 Shared Tile,但解决的问题并不相同。

矩阵转置:

text 复制代码
目标:改变数据排列。
矛盾:输入连续与输出连续方向冲突。
Shared 作用:中转并交换读取索引。
典型风险:转置后列访问造成 Bank Conflict。
主要指标:有效带宽。

矩阵乘:

text 复制代码
目标:对输入做大量重复乘加。
矛盾:相邻输出线程反复需要同一输入区域。
Shared 作用:显式缓存并让 Block 内线程复用。
典型风险:同步、资源、边界与复用不足。
主要指标:有效 TFLOPS,同时观察内存与计算吞吐。

二者共同方法:

text 复制代码
从一个 Block 负责的输出区域出发;
反推它需要的输入区域;
让线程协作做连续 Global Load;
在读别人写的数据前同步;
画出 Warp 在 Shared 中的地址;
处理最后一个不完整 Tile;
用 CPU 语义全量验证;
用 Event 测时间;
用 Profiler 解释原因。

这就是可迁移能力。学习 CUDA 不应把每种算法记成孤立模板,而应把它们归纳为"输出所有权、输入需求、片上合作、同步边界、地址映射、资源预算、正确性证据、性能证据"八个问题。

以后学习二维卷积时,一个输出 Tile 需要带 Halo 的输入 Tile;学习 Stencil 时,邻居点在 Shared 中复用;学习注意力时,Q、K、V Tile 沿归约维推进;学习 Softmax 时,线程共同产生最大值与和。算法变化,但这八个问题仍然存在。


附录三:小矩阵的实际解决方案不是反复微调一个大 Kernel

17×19×13 的实验告诉我们,工作量太小时,大 Tile、寄存器分块和成熟库都可能被固定开销支配。面对真实的小矩阵队列,可以考虑四条路线。

第一,批量接口。若有很多形状相同的小矩阵,使用 Batched 或 Strided Batched GEMM,让一次库调用处理一批任务。这样可以增加并行工作量并摊薄启动开销。

第二,融合。若小 GEMM 前后紧跟偏置、激活、归一化或布局转换,把中间结果写回显存再启动多个 Kernel 的成本可能比乘法本身更大。可优先研究 cuBLASLt Epilogue、框架融合或安全的自定义融合。

第三,CPU 路径。单个非常小的矩阵若数据本来位于 Host,搬到 GPU 再取回可能不划算。应比较端到端延迟,而不是只测 Device 上 Kernel。

第四,形状专用 Kernel。尺寸固定且调用极频繁时,可以为它设计准确线程数、无边界或轻边界的实现。但专用代码会增加维护、编译体积和测试矩阵,必须由业务收益支撑。

决策顺序可以是:

text 复制代码
数据现在在哪里?
一次有多少矩阵?
能否批处理?
能否融合前后操作?
成熟库是否已有对应接口?
端到端瓶颈是否真在 GEMM?
专用实现能带来多少稳定收益?

不要因为极小矩阵上 cuBLAS 单次调用慢一点,就草率得出"自写永远更好"。库往往提供不同接口;工程优化首先是选对抽象层。


附录四:从 2×1 走向更大的线程微块

假设每线程维护 2×2 输出:

text 复制代码
sum00 sum01
sum10 sum11

每个 k 阶段的内部一步,可以加载两个 A 值与两个 B 值:

cpp 复制代码
float a0 = ...;
float a1 = ...;
float b0 = ...;
float b1 = ...;

sum00 += a0 * b0;
sum01 += a0 * b1;
sum10 += a1 * b0;
sum11 += a1 * b1;

四个输出只需要两个 A 与两个 B 片上值,形成更高的线程内复用,也暴露多条彼此独立的 FMA 链。GPU 可以在某条依赖链等待时发射其他链,提高指令级并行。

但累加器从一个增加到四个,地址与临时变量也会占寄存器。若继续增加到 4×4,至少有 16 个部分和。寄存器过多可能减少驻留 Warp,甚至发生 Spill。Spill 会把看似局部的值放入 Local Memory,而 Local Memory 实际位于 Device Memory 层次,可能严重损害性能。

因此更大的微块需要联合设计:

text 复制代码
Thread Block Tile
Warp Tile
Thread Tile
K Tile
向量加载宽度
Shared 布局
寄存器预算
流水线阶段

本篇 2×1 的意义是展示第一步因果关系,不是给出最佳配置。若继续实验,建议每增加一种微块,就打印 numRegssharedSizeBytes,并在至少三种形状上测量。只在 1024³ 上最快的版本,未必适合 M 很大而 N 很小的矩阵。


附录五:为何基础 Tiled 的理论复用没有变成十几倍加速

算术强度推导显示 T 为 16 时,一个 Block 内每批输入可以支撑更多计算。但实测加速约两倍,不是十六倍。常见原因可以分层理解。

第一,Naive 不是每条源代码 Load 都访问 DRAM。硬件广播、L1 与 L2 缓存会吸收一部分重复请求,所以 Naive 的真实外存代价低于最悲观模型。

第二,Tiled 增加了 Shared Store、Shared Load、两道屏障和地址计算。这些指令也消耗周期。

第三,简单 Tiled 仍有 Block 间重复。一个 A Tile 可能被不同输出列 Block 再次装载,一个 B Tile 也可能被不同输出行 Block 再次装载。

第四,每线程只有一个累加器,指令级并行有限。连续 FMA 依赖同一个 sum,调度器需要其他 Warp 隐藏依赖延迟。

第五,Tile 与 Block 形状可能不是目标架构最佳点。32×32 使用 1024 个线程,资源与调度灵活性受到限制;16×16 复用较低但 Block 更轻。

第六,桌面 GPU 的时钟、温度、功耗与其他图形任务会让短基准波动。

第七,编译器可能对 Naive 做出比预期更好的优化,也可能让复杂版本使用更多寄存器。

所以理论模型用于建立方向,不用于承诺倍数。完整证据链应是:

text 复制代码
模型预测减少冗余;
地址推导证明装载连续;
全量验证证明语义正确;
Event 证明时间变化;
资源报告说明代价;
Profiler 验证缓存、DRAM、Shared 与 Stall;
多尺寸实验检查适用范围。

附录六:把本文程序改造成自己的基准框架

可以把三个示例抽象为统一接口:

cpp 复制代码
struct GemmProblem {
    int M;
    int N;
    int K;
};

struct GemmResult {
    std::string name;
    double milliseconds;
    double tflops;
    size_t mismatches;
    double max_abs_error;
    double max_rel_error;
};

每个实现只提供 Launch Lambda:

cpp 复制代码
auto launch = [&]() {
    kernel<<<grid, block>>>(...);
};

统一基准函数负责:

text 复制代码
预热
错误检查
Event 创建
正式循环
Event 同步
平均时间
复制输出
全量验证
结果打印

再增加 CSV 输出,就能在脚本中扫描不同 M/N/K 与 Tile。建议把正确性与性能分成两个阶段:先对几十个小型随机矩阵做回归,再对少量代表性大矩阵做稳定基准。这样性能实验不必每次计算巨大 CPU 参考,正确性也不会只依赖一个大尺寸。

还可以为每个 Kernel 查询:

cpp 复制代码
cudaFuncAttributes attributes{};
cudaFuncGetAttributes(&attributes, kernel);

保存寄存器、静态 Shared、最大动态 Shared 与二进制版本信息。建立这样的实验框架后,后续归约、卷积和 Stencil 文章都能复用。


附录七:给初学者的一张"是否继续优化"判断表

现象 优先动作 不建议立刻做
结果错误 小矩阵手算、矩形边界、Sanitizer 调 Tile、放宽容差
Naive 很慢 画 Warp 地址、检查编译配置 直接复制复杂汇编
Tiled 没变快 看尺寸、资源、同步与 Profiler 宣称 Shared 无用
32 比 16 慢 查线程数、占用与边界比例 只换成 64
Register 更慢 查寄存器与 Spill 无限制增加累加器
cuBLAS 很快 保留为生产基线 为了"纯手写"弃用
cuBLAS 小矩阵慢 批量、融合、端到端比较 用单次 Kernel 时间下结论
Profiler 无权限 记录 Event 与权限限制 编造硬件计数
Kernel 快但应用没快 查复制、中间结果与调用频率 继续只优化 Kernel

这张表的核心是先判断问题属于正确性、规模、资源、数据通路、固定开销还是端到端流程。分类正确,工具与实验才有方向。



六十一、下一篇预告:并行归约与 Warp Shuffle

下一篇建议进入:

CUDA编程实战07:并行归约优化------从 Shared Memory 到 Warp Shuffle

矩阵乘沿 K 累加,但本篇让每个线程独立完成自己的 K。归约会直接面对另一个问题:怎样让许多线程共同把一大批元素合并成一个或少量结果。

将继续解决:

  • 朴素原子累加为什么扩展性有限;
  • Interleaved 与 Sequential Addressing;
  • 分支发散与取模开销;
  • Shared Memory 树形归约;
  • 多元素每线程加载;
  • Warp 内 __shfl_down_sync
  • Block 级与 Grid 级结果组合;
  • 任意长度与非 2 次幂边界;
  • FP32 归约误差;
  • CUDA Event 与有效带宽;
  • Compute Sanitizer 与 Profiler;
  • 从 Sum 迁移到 Max、Min、范数和 Softmax 前置统计。

请保留本篇的计时、验证与资源报告代码。下一篇会复用同样的工程骨架,把"一个 Block 共同使用输入 Tile"升级为"一个 Block 共同产生一个结果"。


结语:从"会算矩阵乘"到"会设计数据复用"

本篇从最简单的公式开始:

C i , j = ∑ k = 0 K − 1 A i , k B k , j C_{i,j}=\sum_{k=0}^{K-1}A_{i,k}B_{k,j} Ci,j=k=0∑K−1Ai,kBk,j

然后建立了一条完整优化链:

text 复制代码
一个线程计算一个 C 元素
    ↓
结果正确,但输入需求在邻居间重复
    ↓
一个 Block 负责一个 C Tile
    ↓
线程协作装载 A/B 的当前 K Tile
    ↓
第一道屏障保证数据装完
    ↓
Shared 数据反复参与乘加
    ↓
第二道屏障保证旧 Tile 用完
    ↓
遍历全部 K Tile
    ↓
寄存器部分和写回 C
    ↓
一个线程进一步维护两个输出
    ↓
与 cuBLAS 做同口径比较
    ↓
用边界、误差、资源和时间共同验收

真正值得带走的不是 tile_A[ty][tx] 这一行,而是三层复用意识:

text 复制代码
Global 层:尽量合并地搬入连续数据;
Block 层:Shared Tile 让线程共同使用输入;
Thread 层:寄存器微块让一个线程用同一值更新多个输出。

你也看到了优化工程中必须接受的现实:

  • 32 Tile 不是永远优于 16;
  • Register Blocking 不是免费;
  • 小矩阵可能不适合大 Tile;
  • cuBLAS 大矩阵领先并不神秘;
  • 缓存会让源代码级访存推导与实际 DRAM 事务不同;
  • 没有硬件计数时,不能把推测伪装成证据;
  • Kernel 变快不等于端到端应用一定变快。

如果你只复制 Tiled Kernel,本篇价值有限;如果你能从 M/N/K、线程坐标、Global 地址、Shared Tile、两道屏障和寄存器累加器重新推导它,那么以后面对卷积、注意力、Stencil、图像块处理和融合算子时,你已经有了一套可迁移的设计语言。

现在请不要停在阅读完成。进入 blogs/code/06,先运行 3×5×4,再运行 17×19×13,最后运行 1024³。把你自己的 GPU、时间、TFLOPS、资源和误差写进实验记录。然后故意换成一个很瘦的矩阵,观察哪种 Tile 失去优势。

当你能解释"为什么这次更快"和"为什么另一个尺寸反而更慢",CUDA 性能学习才真正从模板记忆进入工程能力。

相关推荐
天天有money15 小时前
API中转站在社媒矩阵里的价值:多平台文案如何统一改写
gpt·线性代数·ai·chatgpt·矩阵
哈德森hh1 天前
Twitter矩阵号怎么搭建?如何通过矩阵运营提升引流效果?
矩阵·twitter
小小帅呀1 天前
CUDA编程实战09:CUDA Graph 实战——降低重复 Kernel 与流水线提交开销
c++·人工智能·矩阵
良木林1 天前
矩阵 - LeetCode hot 100
线性代数·算法·leetcode·矩阵
TKcloudmaster_H1 天前
规避风控稳运营!TK云大师定制脚本赋能TikTok矩阵规模化运营
大数据·人工智能·矩阵·自动化·新媒体运营·流量运营
cxr8281 天前
第3章 技术端——弹性矩阵与破格协议
线性代数·矩阵
Lsir10110_3 天前
【力扣hot100】矩阵题通关复盘
算法·leetcode·矩阵
东方佑3 天前
Ripple Model:用“矩阵涟漪”重新定义序列模型,四大定理直指SSM与Transformer的本质!
线性代数·矩阵·transformer
闪电悠米3 天前
力扣hot100-240.搜索二维矩阵2-单调性剪枝详解
数据结构·算法·leetcode·矩阵·哈希算法