← 写作归档

C1 · 2000

CUDA 学习笔记 Matrix Multiplication:LeetGPU FP32 矩阵乘法,从逐元素到分块复用

CUDA / GPU / 矩阵乘法 / Shared Memory / Kernel 优化15 分钟

从 LeetGPU FP32 矩阵乘法出发,记录一个线程计算一个输出、Shared Memory 分块,以及每线程四输出的 CUDA 优化过程。

1. 题目含义

根据 LeetGPU 题目原文,给定行优先存储的 float 矩阵 A∈RM×NA\in\mathbb{R}^{M\times N} 和 B∈RN×KB\in\mathbb{R}^{N\times K},计算 C∈RM×KC\in\mathbb{R}^{M\times K}:

Cm,k=∑n=0N−1Am,nBn,k,0≤m<M,  0≤k<K.C_{m,k}=\sum_{n=0}^{N-1}A_{m,n}B_{n,k},\qquad 0\le m<M,\;0\le k<K.

三个矩阵的一维地址分别为 A[m*N+n]、B[n*K+k] 和 C[m*K+k]。题目约束 1 ≤ M,N,K ≤ 8192,性能测量规模是 M=8192, N=6144, K=4096。

题目的两个样例如下,可以进行手算核对:

示例 1:M=2, N=2, K=2
A = [1 2; 3 4], B = [5 6; 7 8], C = [19 22; 43 50]

示例 2:M=1, N=3, K=1
A = [1 2 3], B = [4; 5; 6], C = [32]

2. 题目的规模和性能边界

按一次乘法和一次加法各算一次浮点运算,计算量约为 2MNK2MNK。在题目指定规模下是 412,316,860,416 FLOP,约 0.4123 TFLOP。A、B、C 分别占 192 MiB、96 MiB、128 MiB,三者合计 416 MiB。这些是矩阵本身的大小;它们不等于实际 DRAM 流量。

理想情况下只把 A、B 各从 DRAM 读一次、把 C 写一次,对应 416 MiB 的数据移动下界(不计缓存写策略等额外流量)。朴素源码表面上每个输出要读取 N 个 A 元素与 N 个 B 元素,但缓存可以消除部分重复的 DRAM 读取。

3. V1 最简单直接的方案:一个线程算一个输出

推导与代码

二维 Grid 中,threadIdx.y 对应输出行,threadIdx.x 对应输出列。每个有效线程从 n=0 累加到 N-1,最后只写一次 C[row*K+col]。越界线程在读取矩阵前返回,因此每个合法输出只会由一个线程写入。相邻 x 线程写相邻的 C 列,读取 B[n*K+col] 也沿列连续;同一行的 A[row*N+n] 则在这些线程间重复使用。 V1:一个线程计算一个输出元素 按图写代码时,可以从紫色的一个输出格反推输入:

  1. 定位输出格。 row = blockIdx.y*blockDim.y + threadIdx.y,col = blockIdx.x*blockDim.x + threadIdx.x。一个线程固定负责 C[row,col]。
  2. 排除无效线程。 图中矩阵尺寸是 M×K;当 row>=M 或 col>=K 时直接返回。输出列的边界是 K,不是归约长度 N。
  3. 找出输入。 蓝色是 A 的第 row 行,橙色是 B 的第 col 列。相同的 n 选出一对元素:A[row*N+n] 和 B[n*K+col]。
  4. 沿归约维计算。 n 遍历 0…N-1,sum 从 0.0f 开始,并在寄存器中反复做 fmaf(A[...], B[...], sum)。图中的乘加式表示同一步骤;代码使用融合乘加。
  5. 写回。 整个循环结束后,才将 sum 写入 C[row*K+col]。因此不需要先清零 Device 上的 C。 所以当前的kernel实现为

// LeetGPU notation: A is M x N, B is N x K, C is M x K.
__global__ void matmul_naive(const float* A, const float* B, float* C,
                             int M, int N, int K) {
    const int row = blockIdx.y * blockDim.y + threadIdx.y;
    const int col = blockIdx.x * blockDim.x + threadIdx.x;
    if (row >= M || col >= K) return;

    float sum = 0.0f;
    for (int n = 0; n < N; ++n) {
        sum = fmaf(A[row * N + n], B[n * K + col], sum);
    }
    C[row * K + col] = sum;
}

所以当前在网站执行的结果是:

Spinning up A100-80GB...
Running...
A = [[1.0, 2.0], [3.0, 4.0]]
B = [[5.0, 6.0], [7.0, 8.0]]
M = 2
N = 2
K = 2
✓ Test passed

笔记!

  • 这里的每个线程计算一个数据,主要是需要正确计算A和B矩阵的一维地址,这部分内容参考PMPP书上的内容来对应按照行优先的方式映射即可;
  • 这个实现中,A和B矩阵中部分元素会被重复读取,下一个版本可以考虑复用这部分内容。

4. V2:Shared Memory 分块

从输出块反推输入块

令一个 Block 计算 16×1616\times16 个输出。把归约维 N 分成长度为 16 的区间;每轮协作加载 AA 的 16×16 子块和 BB 的 16×16 子块。加载后全 Block 同步,确保所有元素可读;完成本轮乘累加后再同步,确保没有线程在别的线程读旧 tile 时覆盖 Shared Memory。这里__syncthreads() 只作用于同一个 Block,不能跨 Block 同步。NVIDIA CUDA Programming Guide:Shared Memory 与同步

尾块可能同时遇到 M、N 或 K 越界。加载时给越界位置填零;所有线程仍须经过两次同步,最后再由合法线程写回。这样 M=1,N=1,K=1 和 17×19×31 等非整块输入也有定义。NVIDIA 的 Best Practices Guide 矩阵乘法示例使用 Shared Memory 说明跨线程复用;此处的边界处理和代码是针对本题形状编写的。

其实这部分内容也在PMPP上有讲过,也有代码,主要内容在chapter 5中,分块的思路如下图所示:

V2:Shared Memory 分块复用 图中的 r0=blockIdx.y*16、c0=blockIdx.x*16,所以当前线程负责 row=r0+ty、col=c0+tx。n0 沿归约维每次移动 16 个元素。图上每个颜色框代表本轮的区域,不是整个矩阵的最终结果。

  1. 每个线程将 A[row,n0+tx] 放到 tileA[ty][tx],将 B[n0+ty,col] 放到 tileB[ty][tx]。图中黑框标出同一个 (ty,tx) 线程写的两个 Shared Memory 格子。
  2. 越过 M、N 或 K 边界时,加载目标格子仍须写 0.0f,避免随后读到未初始化的 tile 内容。此时线程不能提前退出,因为所有线程都要到达同步屏障。
  3. 第一次 __syncthreads() 保证本轮两个 tile 都已写好。随后,线程从蓝色的 tileA[ty][k] 横行和橙色的 tileB[k][tx] 竖列各取一个元素,按 k=0…15 累加到自己的 sum。
  4. 第二次 __syncthreads() 保证其他线程已经读完旧 tile,才能开始下一轮 n0 并覆盖 Shared Memory。所有 n0 轮结束后,合法线程才写 C[row,col]。

尾块可能同时遇到 M、N 或 K 越界。加载时给越界位置填零;所有线程仍须经过两次同步,最后再由合法线程写回。这样 M=1,N=1,K=1 和 17×19×31 等非整块输入也有定义。NVIDIA 的 Best Practices Guide 矩阵乘法示例使用 Shared Memory 说明跨线程复用;此处的边界处理和代码是针对本题形状编写的。 所以当前的代码实现:


// v2
const int TILE = 16;

__global__ void matrix_multiplication_kernel(const float* A, const float* B, float* C, int M, int N, int K) 
{
    // 首先创建共享内存数组
    __shared__ float tileA[TILE][TILE];
    __shared__ float tileB[TILE][TILE];

    // 计算出对应的坐标
    const int tx = threadIdx.x;
    const int ty = threadIdx.y;
    const int row = blockIdx.y * TILE + ty;
    const int col = blockIdx.x * TILE + tx;

    float sum = 0.0f;

    for (int n0 = 0; n0 < N; n0 += TILE) 
    {
        // 现在需要计算加载到共享内存中的元素位置的坐标
        const int aCol = tx + n0;
        const int bRow = ty + n0;

        tileA[ty][tx] = (row < M && aCol < N) ? A[row * N + aCol] : 0.0f;
        tileB[ty][tx] = (bRow < N && col < K) ? B[bRow * K + col] : 0.0f;

        // 同步屏障
        __syncthreads();

        // 现在开始计算一行一列的乘积
        for (int k = 0; k < TILE; ++k) 
        {
            sum = fmaf(tileA[ty][k], tileB[k][tx], sum);
        }
        __syncthreads();
    }

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

一个完整的 16×16×16 子问题大约做 8192 FLOP,从 Global Memory 显式加载 512 个 float,即 2048 字节;只按这些加载计,计算量与加载字节之比为 4 FLOP/byte。

5. V3:一个线程在寄存器中算四个输出

再提高每次加载的使用次数

一个 Block 现在覆盖 16×64 个 C 元素,线程布局仍为 16×16。每个线程负责同一行上的四列:col0、col0+16、col0+32、col0+48。同一个 A[row,n] 被用于这四个累加器,所以每线程保存四个 float 结果;B tile 扩展为 16×64。B 的每组 16 列仍由连续的 x 线程加载。 V3:每线程计算四个输出元素 图中 r0=blockIdx.y*16、c0=blockIdx.x*64。同一个 Block 的 256 个线程仍按 16×16 排列,但覆盖的输出区域已扩为 16×64。对选中的 (ty,tx) 线程,代码和图可以这样对应:

  1. row=r0+ty,col0=c0+tx;四个输出列为 col0+16*j,其中 j=0,1,2,3。这四列相隔 16,不是四个相邻列。
  2. 在每轮 n0 中,线程只加载一个 A[row,n0+tx] 到 tileA[ty][tx],再从 B 的四个 16 列子区各加载一个元素到 tileB[ty][tx+16*j]。若源坐标越界,就写零到相应 Shared Memory 格子。
  3. 第一次 __syncthreads() 后,线程在每个 n=0…15 读取同一个 a=tileA[ty][n],分别与 tileB[n][tx+16*j] 相乘并累加到 acc[j]。图中由同一个蓝色 a 分出的四条线对应这四次 fmaf。
  4. 第二次 __syncthreads() 后进入下一轮 n0。所有归约轮次结束,才把四个累加器分别写到 C[row*K+col0+16*j];每次写入单独检查列是否小于 K。

// v3
constexpr int TILE_M = 16;
constexpr int TILE_REDUCTION = 16;
constexpr int TILE_COLS = 64;


__global__ void matrix_multiplication_kernel(const float* A, const float* B, float* C, int M, int N, int K) 
{
    // 定义共享数组
    __shared__ float tileA[TILE_M][TILE_REDUCTION];
    __shared__ float tileB[TILE_REDUCTION][TILE_COLS];

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

    // 定义一个包含四个元素的数组,用来直接一次性计算四个元素
    float acc[4] = {0.0f, 0.0f, 0.0f, 0.0f};

    for (int n0 = 0; n0 < N; n0 += TILE_REDUCTION) 
    {
        const int aCol = n0 + tx;
        const int bRow = n0 + ty;
        tileA[ty][tx] = (row < M && aCol < N) ? A[row * N + aCol] : 0.0f;
        for (int j = 0; j < 4; ++j)
        {
            const int col_b = col + j * 16;     // 这是计算第几个块的列
            tileB[ty][tx + j * 16] = (bRow < N && col < K) ? B[bRow * K + col] : 0.0f;
        }

        __syncthreads();

        // 现在开始计算
        for (int n = 0; n < TILE_REDUCTION; n++) 
        {
            const float a = tileA[ty][n];
            for (int j = 0; j < 4; ++j)
            {
                acc[j] = fmaf(a, tileB[n][tx + j * 16], acc[j]);
            }
        }
        __syncthreads();
    }

    // 现在收尾
    if (row < M) 
    {
        for (int j = 0; j < 4; ++j)
        {
            const int col_in = col + j * 16;
            if (col_in < K) 
            {
                C[row * K + col_in] = acc[j];
            }
        }
    }
}

一个完整的 16×64×16 子问题约做 32768 FLOP,源码加载 16×16 + 16×64 = 1280 个 float,即 5120 字节;只按这些加载计,计算量与加载字节之比为 6.4 FLOP/byte。更高的数据复用需要更多 Shared Memory 和寄存器,可能影响 Occupancy 或增加指令开销。

6. 代码和资料

这部分内容主要参考以下资料: