02 · CUDA from a Systems Engineer’s Eyes · 报告 01

SGEMM 六级优化:核心代码的演化

同一块 RTX 4090、同一个可执行文件、同一次开机: 一个 FP32 矩阵乘从 naive 改到了 cuBLAS 的 71.5%。 本报告只讲代码:每一级改了哪一行、为什么这样改、原理图是什么样。 每一级只引入一个新概念, 所以每一步的收益归谁很清楚。 最重要的发现: 收益最大的两步(8.209× 和 3.359×),算术强度完全没变。

读法三本账,和一张总表↑

本报告只讲代码怎样一步步改成现在的样子,以及每一步的原理。 数字的来源、测量方法和八个指标的算法,见 第 02 份报告。

一、为什么是三本账↑

教科书只给一本账:算术强度 → roofline。 实测中,这本账在七个 kernel 里失效五次, 达成率最高到 2112.7%。 达成率 2112.7% 说明这个 kernel 不在带宽墙上: 它读的字节大部分命中了 4090 的 75.5 MB L2,没有走到显存。

账量什么什么时候它才是墙这条链里管用几次
字节账算术强度 → roofline 上限 显存带宽真的饱和时 七次里失效五次
请求账一条指令触发几个内存事务 访存不合并时 只在 K1→K2 成立一次
条数账指令条数 × 分类单价 前两者都不是墙时 K3 之后全靠它
账本没有对错,只有适用区间。 这条优化链最有价值的地方:它把三本账的适用边界都撞了一遍。 除 K5 同时动了条数账和字节账,其余每一级只动其中一本,所以收益归谁很清楚。

二、六级总表↑

六级 · 每一级只引入一个概念 · 实测取 2026-09-24 同批 N=4096
级引入的概念动了哪本账 算术强度load/FMA比上一级快占 cuBLAS
K1 naive一线程一输出,索引映射写反基线0.2502.000基准1.13%
K2 合并把 x/y 的映射换回来,其余一字不改请求账0.250 不变2.000 不变 8.21×9.30%
K3 shared memory 分块片上暂存,一块一块搬字节账7.969 ↑32×2.0621.16×10.80%
K3c 书上的粗化Fig. 6.13:一线程 4 个输出,复用 M tile字节账12.720 ↑1.6×2.0391.08×11.69%
K4 1D 寄存器分块一线程 8 行,N 元素进寄存器用 8 次条数账7.969 和 K3 相同1.188 ↓ 3.10×36.29%
K5 2D 寄存器分块8 行 × 4 列 = 32 个输出,两个方向一起复用条数账+字节账12.7200.414 ↓1.66×60.28%
K6 float4128 位访存,4 条并 1 条条数账12.720 不变0.104 ↓1.19×71.50%

要细看的两处

① K2 的算术强度和 load/FMA 都和 K1 相同,却快 8.209 倍。 两本账都说"没变",时间却变了。第三本账(请求账)就是从这里来的。

② K4 的算术强度和 K3 相同(都是 7.969),却比 K3 快 3.359 倍,是整条链上第二大的一步(仅次于 K2)。K4 是从 K3 改出来的,所以这里和 K3 比;总表「比上一级快」一列的 3.10× 是和上一行 K3c 比。 算术强度真正涨了的 K3c(7.969 → 12.720), 只快了 8%。 教科书"提高算术强度就能更快"的因果链,在这里断得很干净。

K1基线:一线程一输出,索引故意写反↑

一、代码↑

// ---------------------------------------------------------------------------
__global__ void sgemm_k1(int M, int N, int K, float alpha,
                         const float *A, const float *B, float beta, float *C) {
    // 这两行是 CUDA 里最常见的一句话:"我是谁"。
    // blockIdx 是我这个 block 在 grid 里的编号,blockDim 是 block 的宽度,
    // threadIdx 是我在 block 内的编号 —— 三者拼出全局唯一的坐标。
    const int x = blockIdx.x * blockDim.x + threadIdx.x;   // 行(M 方向)
    const int y = blockIdx.y * blockDim.y + threadIdx.y;   // 列(N 方向)

    if (x < M && y < N) {
        float acc = 0.0f;
        for (int i = 0; i < K; ++i)
            acc += A[x * K + i] * B[i * N + y];            // A 走一行,B 跨一列
        C[x * N + y] = alpha * acc + beta * C[x * N + y];
    }
}

二、错在哪一个字↑

算法和结果都正确,唯一的问题是 x(行)和 y(列)的分工。 同一个 warp 里,threadIdx.x 连号,threadIdx.y 相同。 K1 把 threadIdx.x 给了行,所以 32 个线程落在 A 的 32 个不同行上。 它们的地址间隔 K 个 float,落在 32 个不同的 128 字节段里。

M:取第 0 行 → 下标连续 0 1 2 3 45 67 89 1011 1213 1415 N:取第 0 列 → 每次跳 4 0 123 4 567 8 91011 12 131415 同一个内积循环: M 走 0→1→2→3 步长 1 N 走 0→4→8→12 步长 4 4×4 里跳 4, 4096×4096 里跳 4096 内存里其实是一条直线(row-major) M 第 0 行 0 1 2 3 45 67 89 1011 1213 1415 N 第 0 列 0 123 4 567 8 91011 12 131415 绿色是一整块连续的;红色被整整三格分隔 —— 同一个循环里,两个操作数的访存模式完全不同
矩阵格子里写的是元素在一维内存里的下标(row*4+col),不是元素的值。

一条 LDG 被拆成 32 个请求

请求账:每个 FMA 33 个内存请求
        A 的那条 load  →  32 个请求(32 个线程各落一段)
        B 的那条 load  →   1 个请求(32 个线程落同一段,合并)
                          ----
                          33

// 而字节账完全看不到这件事:它只数"逻辑上要读多少字节",
// 不管这些字节是一次取回来的,还是分 32 次、每次浪费 124 字节取回来的。

实测 211.911 ms,只有 cuBLAS 的 1.13%。 这是整条链的起点。

K2把索引换回来,其余一字不改↑

一、代码↑

// ---------------------------------------------------------------------------
__global__ void sgemm_k2(int M, int N, int K, float alpha,
                         const float *A, const float *B, float beta, float *C) {
    const int y = blockIdx.x * blockDim.x + threadIdx.x;   // 列(N 方向)
    const int x = blockIdx.y * blockDim.y + threadIdx.y;   // 行(M 方向)

    if (x < M && y < N) {
        float acc = 0.0f;
        for (int i = 0; i < K; ++i)
            acc += A[x * K + i] * B[i * N + y];
        C[x * N + y] = alpha * acc + beta * C[x * N + y];
    }
}

二、warp 在 C 上的形状变了↑

K1:threadIdx.x → 行 C 矩阵(行主序) 一个 warp = 一竖条 32 个不同的行,同一列 K2:threadIdx.x → 列 C 矩阵(行主序) 一个 warp = 一横条 同一行,32 个连续的列 这 32 个线程要的数据,落在内存这条直线上: 每个大格 = 一条 128 B 的 cache line K1 读 A 每条 line 只用了 4 B → 32 条 line 全要取回来 K2 读 B 32 个 float 挨在一起 = 128 B → 正好 1 条 line,全用上
循环体相同,只是 threadIdx.x 赋给了不同的变量, 一个 warp 的 32 个地址就从"撒在近 500 KB 里"变成"挤在 128 B 里"。 代码没变,形状变了。

三、这一步的 8.209 倍里,只有 2.392 倍是"合并"↑

边界测试意外抓到的东西

            N=4096(2 的幂)    N=4097(打破对齐)
K1           211.911 ms          63.675 ms     ← 快了 3.33 倍
K2            25.815 ms           26.615 ms     ← 只慢 3%
K1/K2          8.209×             2.392×

// 4097 上两者都不受跨度冲突影响,剩下的 2.392× 才是"不合并"本身的代价。
// 于是 8.209 ÷ 2.392 = 3.43 就是 2 的幂跨度的额外惩罚。
// 回乘检验:2.392 × 3.43 = 8.209,和实测逐位相同。

N=4096 时,A 的行跨度正好是 16384 字节,是 2 的幂。 这在"不合并"之上又叠了一层冲突。N=4097 打破了对齐,这层冲突就消失了。

但我们没能指认出具体是哪个硬件结构在冲突(L2 slice 哈希?DRAM row buffer?)。 把地址逐位算一遍,两种 N 在"行号"这一级的模式完全相同, 所以"32 个地址撞同一个 L2 set"这个解释站不住。 要判断到具体结构,需要 ncu 的 DRAM 指标,而这一批数据没有(见 报告 02 · §7)。

对照组:同样从 4096 改到 4097,其余 kernel 只慢 2.8%–7.1%, cuBLAS 慢 15.7%。所以不是"4097 让所有 kernel 都变快",而是只有 K1 被单独惩罚了。

所以"合并访存是性价比最高的一步"这句话要加个注: 那 8.209 倍里只有 2.392 倍来自合并本身, 另外 3.43 倍来自顺带治好的跨度冲突。 书里和多数教程都没提这一点。

K3shared 分块:第一次动字节账↑

一、代码↑

// ---------------------------------------------------------------------------
__global__ void sgemm_k3(int M, int N, int K, float alpha,
                         const float *A, const float *B, float beta, float *C) {
    __shared__ float Mds[TILE][TILE];
    __shared__ float Nds[TILE][TILE];

    const int tx = threadIdx.x, ty = threadIdx.y;
    const int Col = blockIdx.x * TILE + tx;
    const int Row = blockIdx.y * TILE + ty;

    float acc = 0.0f;

    for (int ph = 0; ph < (K + TILE - 1) / TILE; ++ph) {
        const int aCol = ph * TILE + tx;
        const int bRow = ph * TILE + ty;

        Mds[ty][tx] = (Row < M && aCol < K) ? A[Row * K + aCol] : 0.0f;
        Nds[ty][tx] = (bRow < K && Col < N) ? B[bRow * N + Col] : 0.0f;

        __syncthreads();

        #pragma unroll
        for (int k = 0; k < TILE; ++k)
            acc += Mds[ty][k] * Nds[k][tx];

        __syncthreads();
    }

    if (Row < M && Col < N)
        C[Row * N + Col] = alpha * acc + beta * C[Row * N + Col];
}

二、两块 tile 一个向右滑、一个向下滑↑

下图标的"行 96–127 × 列 64–95"是从 blockIdx 算出来的,不是随便挑的。 先把它落到一个具体的 block 上,图才好懂。

// K3 沿用 K2 的映射(见 §3):blockIdx.x → 列,blockIdx.y → 行
const int Col = blockIdx.x * TILE + tx;
const int Row = blockIdx.y * TILE + ty;

// 取 block(2,3),也就是 blockIdx.x = 2、blockIdx.y = 3:
Row = 3 * 32 + ty = 96 + ty    // ty = 0..31  →  行 96 … 127
Col = 2 * 32 + tx = 64 + tx    // tx = 0..31  →  列 64 … 95

// 所以这个 block 负责 C 的 行 96–127 × 列 64–95 这一块 32×32 的输出。
// 注意 (2,3) 里的 2 是 x(列)、3 是 y(行)—— 和"行在前列在后"的习惯正好相反,
// 这是 K1 那个索引 bug 的同一个来源(§2·二)。

再看这个 block 里的线程 (tx=5, ty=7), 它负责 C[103][69] (Row = 96+7 = 103,Col = 64+5 = 69)。 下表是它在 128 个阶段里搬什么、算什么:

这个线程搬什么然后用什么算
ph = 0 Mds[7][5] ← A[103][5]
Nds[7][5] ← B[7][69]
用 Mds[7][0..31] × Nds[0..31][5]
= A[103][0..31] · B[0..31][69]
ph = 1 Mds[7][5] ← A[103][37]
Nds[7][5] ← B[39][69]
用 Mds[7][0..31] × Nds[0..31][5]
= A[103][32..63] · B[32..63][69]
………
ph = 127 Mds[7][5] ← A[103][4069]
Nds[7][5] ← B[4071][69]
= A[103][4064..4095] · B[4064..4095][69]
搬运用的下标和计算用的下标完全不同。 搬的时候,它搬的是 A[Row][ph*32 + tx]:第 5 列、第 37 列、第 4069 列…… 算的时候,它读整行 Mds[7][0..31]。 这 32 个数是它和另外 31 个线程一起搬来的。 「我搬一个,我用三十二个」——这一句就是分块的全部。
B(4096×4096) ph = 0 ph = 1 ⋮ ph = 127 ↑ 第 64–95 列 A(4096×4096) ph=0 ph=1 ⋯ ph=127 ↑ 第 96–127 行 C(输出) block(2,3) 的 32×32 输出块 行 96–127 × 列 64–95
一个 block 的 128 个阶段: A 上的方块沿自己的行带向右滑, B 上的方块沿自己的列带向下滑, 每滑一格是一个阶段。 两个方块每次都搬进 shared memory, 供 block 里的 1024 个线程共用。

算术强度从 0.250 涨到 7.969,涨了 32 倍。 原因:在 32×32 的 tile 里,每个搬进来的元素被 32 个线程用了 32 次。

但实测只快 1.162×

字节账涨了 32 倍,时间只省了 14%。为什么?

K2 的 roofline 达成率 = 2112.7%

// 达成率超过 100% 二十倍,说明 K2 根本没在带宽墙上 ——
// 它那些"逻辑上要读的字节"绝大部分命中 L2,没走到 DRAM。
// 一个不受带宽限制的 kernel,你把它的 DRAM 算术强度提高 32 倍,换不来时间。

这 14% 可以用条数账算出来(三个单价的标定见报告 02 · §3):

K2 = 2.000 条 LDG × 1.94 周期                       = 3.88 周期
K3 = 1 条 32 路 LDS × 2.686
   + 1 条广播 LDS × 0.534
   + 0.062 条 LDG × 1.94                            = 3.34 周期

预测 3.88 ÷ 3.34 = 1.162×        实测 1.162×   ✓

在这台卡上,K3 的 shared memory 分块不是靠省带宽赢的。 它赢在把 2 条 global memory 的 load(每条 1.94 周期)换成 1 条 32 路和 1 条广播的 shared memory load (2.686 + 0.534)。

K3c书上的线程粗化(Fig. 6.13)↑

一、代码↑

// ---------------------------------------------------------------------------
__global__ void sgemm_k3c(int M, int N, int K, float alpha,
                          const float *A, const float *B, float beta, float *C) {
    __shared__ float Mds[TILE][TILE];
    __shared__ float Nds[TILE][TILE];

    const int tx = threadIdx.x, ty = threadIdx.y;
    const int Row      = blockIdx.y * TILE + ty;
    const int colStart = blockIdx.x * TILE * COARSE + tx;     // Fig. 6.13 第 13 行

    float p0 = 0.0f, p1 = 0.0f, p2 = 0.0f, p3 = 0.0f;         // 代替 Pvalue[4]

    for (int ph = 0; ph < (K + TILE - 1) / TILE; ++ph) {
        const int aCol = ph * TILE + tx;
        const int bRow = ph * TILE + ty;

        // ---- M tile:每阶段只搬一次 —— 粗化省下的就是这里 ----
        Mds[ty][tx] = (Row < M && aCol < K) ? A[Row * K + aCol] : 0.0f;

        for (int c = 0; c < COARSE; ++c) {                     // 粗化循环
            const int col = colStart + c * TILE;

            // ---- N tile:随列变,每个 c 各搬一次 ----
            Nds[ty][tx] = (bRow < K && col < N) ? B[bRow * N + col] : 0.0f;

            __syncthreads();                                   // ① 写后读

            float acc = 0.0f;
            #pragma unroll
            for (int k = 0; k < TILE; ++k)
                acc += Mds[ty][k] * Nds[k][tx];                // 内层和 K3 一字不差

            __syncthreads();                                   // ② 读后写

            // 寄存器轮转:p0 加上本轮结果后排到队尾
            const float t = p0 + acc;
            p0 = p1; p1 = p2; p2 = p3; p3 = t;
        }
    }

    if (Row < M) {
        const int c0 = colStart, c1 = c0 + TILE, c2 = c1 + TILE, c3 = c2 + TILE;
        if (c0 < N) C[Row * N + c0] = alpha * p0 + beta * C[Row * N + c0];
        if (c1 < N) C[Row * N + c1] = alpha * p1 + beta * C[Row * N + c1];
        if (c2 < N) C[Row * N + c2] = alpha * p2 + beta * C[Row * N + c2];
        if (c3 < N) C[Row * N + c3] = alpha * p3 + beta * C[Row * N + c3];
    }
}

二、省的是哪一块↑

粗化后,一个 block 横跨 TILE × COARSE = 128 列。 接着看 §4·二 的例子: K3 的 block(2,3) 只管行 96–127 × 列 64–95; K3c 的 block(0,3) 管列 0–127 共四个 tile, 其中第三块就是 K3 的那个 block。

K3 粗化前 4 个 block 各管一个 tile C 的 行 96–127 × 列 0–127 列 0–31 32×32 输出 列 32–63 32×32 输出 列 64–95 32×32 输出 列 96–127 32×32 输出 block(0,3) block(1,3) block(2,3) block(3,3) A 的 行 96–127 M tile 搬 4 次 一个线程 · 一个阶段搬几格 M N = 2 格 它负责 1 个输出 → 2 格/输出 K3c 粗化后 1 个 block 管 4 个 tile(COARSE = 4) C 的 行 96–127 × 列 0–127 列 0–31 32×32 输出 列 32–63 32×32 输出 列 64–95 32×32 输出 列 96–127 32×32 输出 block(0,3) 一个人全包 A 的 行 96–127 M tile 搬 1 次 ← 省下的就是这三条 一个线程 · 一个阶段搬几格 M N N N N = 5 格 它负责 4 个输出 → 1.25 格/输出 ↑ 这一块就是 §4·二 里的 block(2,3) 省下的只有 M 那一份 —— N 省不掉,4 个 tile 的列号各不相同,只能一块接一块地覆盖 Nds。 K3 128 阶段 × 2 格 × 4 字节 + 4 = 1,028 字节/输出 AI = 8192 ÷ 1028 = 7.969 K3c 128 阶段 × 1.25 格 × 4 字节 + 4 = 644 字节/输出 AI = 8192 ÷ 644 = 12.720 ↑1.6× 但内层循环一个字没动:每个 FMA 仍要 1 条 32 路 + 1 条广播 —— 条数账 2.062 → 2.039。
图 · 粗化省下的是那三条多余的红箭头。 同一行的 4 个输出 tile 共用同一块 M(行号相同),但各用不同的 N(列号不同)。 粗化前,4 个 block 各搬一份 M;粗化后,一个 block 只搬一份,4 个输出共用。 N 这一边省不掉,只能一块接一块地覆盖 Nds。

一个线程每阶段搬的格数变多了(2 → 5),但它负责的输出从 1 个变成 4 个。 相除后,每个输出摊到的格数从 2 降到 1.25, 算术强度从 7.969 升到 12.720,涨到 1.6 倍。

又是同一个剧本

字节账涨到 1.6 倍,实测只快 1.082×。 因为条数账几乎没动(2.062 → 2.039)。 K3c 省的是"搬进 shared memory"那一侧;内层循环里每个 FMA 仍然要发 1 条 32 路 + 1 条广播,一条都没少。 K3 → K3c 是整条链上唯一一次"只动字节账"的干净实验,结果只快 8%。

顺带:书上 Fig. 6.13 的写法会掉进 local memory

书里用数组 float Pvalue[COARSE_FACTOR], 上面的代码改用 p0…p3 四个标量加轮转。 数组下标 c 在运行期变化, 而寄存器不能动态寻址,编译器只好把 Pvalue 放进 local memory。 它物理上就在显存里。 于是内层每做一次 Pvalue[c] +=,就要多一次片外读和一次片外写 ,本来只该碰两条片内 LDS 的一行,凭空多出两条走显存的线。 换成标量后,累加全在寄存器里完成,ptxas 报 0 bytes spill。

K41D 寄存器分块:整条链上第二大的一步↑

一、代码↑

// ---------------------------------------------------------------------------
__global__ void sgemm_k4(int M, int N, int K, float alpha,
                         const float *A, const float *B, float beta, float *C) {
    __shared__ float Mds[TILE][TILE];
    __shared__ float Nds[TILE][TILE];

    const int tx = threadIdx.x, ty = threadIdx.y;
    const int Col  = blockIdx.x * TILE + tx;                  // 列:tx 连续
    const int i0   = ty * CF;                                 // tile 内第 i0..i0+7 行归我
    const int Row0 = blockIdx.y * TILE + i0;                  // 全局第 Row0..Row0+7 行

    float a0 = 0.0f, a1 = 0.0f, a2 = 0.0f, a3 = 0.0f,
          a4 = 0.0f, a5 = 0.0f, a6 = 0.0f, a7 = 0.0f;

    for (int ph = 0; ph < (K + TILE - 1) / TILE; ++ph) {
        const int aCol = ph * TILE + tx;

        // ---- 协同加载:128 个线程 × 每人 16 格 = 两块 32×32 的 tile ----
        for (int r = 0; r < CF; ++r) {
            const int row  = Row0 + r;
            const int bRow = ph * TILE + i0 + r;
            Mds[i0 + r][tx] = (row  < M && aCol < K) ? A[row  * K + aCol] : 0.0f;
            Nds[i0 + r][tx] = (bRow < K && Col  < N) ? B[bRow * N + Col ] : 0.0f;
        }

        __syncthreads();                                      // ① 写后读

        // ---- 内层:1 个 N 进寄存器,用 8 次 ----
        for (int k = 0; k < TILE; ++k) {
            const float n = Nds[k][tx];
            a0 += Mds[i0    ][k] * n;
            a1 += Mds[i0 + 1][k] * n;
            a2 += Mds[i0 + 2][k] * n;
            a3 += Mds[i0 + 3][k] * n;
            a4 += Mds[i0 + 4][k] * n;
            a5 += Mds[i0 + 5][k] * n;
            a6 += Mds[i0 + 6][k] * n;
            a7 += Mds[i0 + 7][k] * n;
        }

        __syncthreads();                                      // ② 读后写
    }

    if (Col < N) {
        if (Row0     < M) C[(Row0    ) * N + Col] = alpha * a0 + beta * C[(Row0    ) * N + Col];
        if (Row0 + 1 < M) C[(Row0 + 1) * N + Col] = alpha * a1 + beta * C[(Row0 + 1) * N + Col];
        if (Row0 + 2 < M) C[(Row0 + 2) * N + Col] = alpha * a2 + beta * C[(Row0 + 2) * N + Col];
        if (Row0 + 3 < M) C[(Row0 + 3) * N + Col] = alpha * a3 + beta * C[(Row0 + 3) * N + Col];
        if (Row0 + 4 < M) C[(Row0 + 4) * N + Col] = alpha * a4 + beta * C[(Row0 + 4) * N + Col];
        if (Row0 + 5 < M) C[(Row0 + 5) * N + Col] = alpha * a5 + beta * C[(Row0 + 5) * N + Col];
        if (Row0 + 6 < M) C[(Row0 + 6) * N + Col] = alpha * a6 + beta * C[(Row0 + 6) * N + Col];
        if (Row0 + 7 < M) C[(Row0 + 7) * N + Col] = alpha * a7 + beta * C[(Row0 + 7) * N + Col];
    }
}

二、复用从哪来:一个元素读一次,用 8 次↑

继续用 §4·二 的 block(2,3):行 96–127 × 列 64–95。 K3 里线程 (tx=5, ty=7) 只算 C[103][69] 一格; K4 把同一列上的 8 格 C[96..103][69] 交给一个线程。

K3 · 这 8 格归 8 个线程 一线程一输出,竖着排的 8 格互不相干 8 个线程 A[96][·] C[96][69] A[97][·] C[97][69] A[98][·] C[98][69] A[99][·] C[99][69] A[100][·] C[100][69] A[101][·] C[101][69] A[102][·] C[102][69] A[103][·] C[103][69] ×8 B[·][69] = Nds[k][5] 8 个线程 各读各的 同一个数读 8 遍 每 8 个 FMA:8 条 32 路 + 8 条广播 K4 · 这 8 格归 1 个线程 线程 (ty=0, tx=5) 独占 C[96..103][69] 1 个线程 A[96][·] C[96][69] A[97][·] C[97][69] A[98][·] C[98][69] A[99][·] C[99][69] A[100][·] C[100][69] A[101][·] C[101][69] A[102][·] C[102][69] A[103][·] C[103][69] ×1 B[·][69] = Nds[k][5] 1 个线程 读 1 次进寄存器 用 8 次 每 8 个 FMA:1 条 32 路 + 8 条广播 C(Row0+r, 69) = Σₖ A(Row0+r, k) · B(k, 69) —— r 一变,A 换一行;B 的那一列却一直不变。 K3 load/FMA 2.062 dist 1.000 bcast 1.000 K4 load/FMA 1.188 dist 0.125 bcast 1.000 ← 广播一条没省,省的全在 32 路那一侧 算术强度一个字没动(7.969 → 7.969)—— 这一步只动条数账,字节账完全没碰。
图 · 竖着排,复用就出现了。 这 8 个输出各用 A 的不同一行(省不掉),但共用 B 的同一列。 K3 把它们分给 8 个线程,B[k][69] 就被读了 8 遍; K4 交给 1 个线程,读一次进寄存器,供 8 次乘加。 每 8 个 FMA 的 shared load:8 条 32 路 + 8 条广播 → 1 条 32 路 + 8 条广播。

三、关键:两种 shared load 的单价差 5 倍↑

一条 load 是整个 warp 的指令:32 个线程各把自己的 tx 代进下标,算出 32 个地址。 这 32 个地址的分布决定这条指令便宜还是贵。 K4 砍掉的那 7 条全是贵的一类。

内层每一步 k,一个 warp 发 9 条 shared load —— 但它们不是一类东西 ① 8 条广播 a0 += Mds[i0+r][k] * n; 下标里没有 tx warp 的 32 个 lane(tx = 0…31) Mds[0][k] 32 个 lane 算出【同一个】地址 bank = (地址 ÷ 4) mod 32 只点亮 1 个 bank,但走的是广播通路:取 4 字节,复制给 32 个 lane,一拍。 ≈ 0.534 周期 ② 1 条 32 路 const float n = Nds[k][tx]; tx 的系数 = 1 warp 的 32 个 lane(tx = 0…31) Nds[k][0] … Nds[k][31]:32 个【连号】地址 bank = (地址 ÷ 4) mod 32 32 个 bank 各取一个字,凑出 128 字节,同样一拍 —— 但搬的数据多 32 倍。 ≈ 2.686 周期 ③ 若写成 Nds[k][tx*4]:tx 的系数 ≥ 2,只用到 8 个 bank,每个 bank 排 4 个线程,要拆成 4 拍 —— 更贵。
图 · 同样叫 "shared load",两条指令做的事差 32 倍。 广播只读出 4 个字节,沿广播总线复制给 32 个 lane; 32 路要从 32 个 bank 各读一个字,拼成 128 字节返回。 数据量差 32 倍,实测单价只差 5 倍(0.534 : 2.686),其余差距被固定开销填平了。 这两个单价是拿 K2、K3、K4 三个实测点反解出来的(推导见报告 02 · §4),不是官方规格。
K3 每 8 个 FMA:8 条广播 + 8 条 32 路 = 8×0.534 + 8×2.686 = 25.76 周期
K4 每 8 个 FMA:8 条广播 + 1 条 32 路 = 8×0.534 + 1×2.686 = 6.96 周期
                                                          ↓ 3.70×

// 注意广播那一列:K4 和 K3 按比例算是同一个数(bcast 都是 1.000),一点没省。
// 那 8 条广播不是 K4 的额外开销 —— K3 是"每配 1 个 FMA 就再搭一条贵的 32 路",
// K4 是"8 个 FMA 才搭一条"。省下来的全在 32 路那一侧。

四、几何:block 从 1024 缩到 128↑

tile 还是 32×32,但 block 只剩 32×4 = 128 个线程。 32 行按 ty 切成 4 条,每条 8 行。 线程 (ty, tx) 算自己那一条里第 tx 列的 8 个输出; 搬运时也在这一条里,沿第 tx 列给 Mds 和 Nds 各搬 8 格。

① 我算哪 8 格 block(2,3) 的输出 tile:行 96–127 × 列 64–95 ty=0 行 96–103 ty=1 行 104–111 ty=2 行 112–119 ty=3 行 120–127 tx=5 → 列 69 它的 8 个累加器(全程待在寄存器里) a0 ← C[96][69] a1 ← C[97][69] a2 ← C[98][69] a3 ← C[99][69] a4 ← C[100][69] a5 ← C[101][69] a6 ← C[102][69] a7 ← C[103][69] ← §5·二 那个线程算的就是这一格 128 个阶段跑完,才把 8 个 累加器一次性写回 C。 K3:32×32 = 1024 线程,每人 1 格 K4:32×4 = 128 线程,每人 8 格 ② 我搬哪 16 格 以 ph = 1 为例:aCol = 1×32 + 5 = 37 A 的 tile Mds B 的 tile Nds 原样搬 原样搬 A 侧:A[96..103][37] → Mds[0..7][5]  B 侧:B[32..39][69] → Nds[0..7][5]  四处的相对位置完全相同 —— 下标都是 [i0+r][tx],不转置。 注意 B 那 8 行(32–39)和我要算的输出行(96–103)毫无关系 —— B 的行号是"内积走到第几步",由阶段决定;我搬的给大家用,大家搬的我也用。 ③ 换成 warp 视角:同一次 r,32 个 lane 取的是连号的 32 个数 tx = 0 … 31 A[96][32] A[96][33] … A[96][63] 32 个连号地址 → 一条合并的 LDG 8 次迭代取走 8 行,4 个 warp 合起来填满 tile 的 32 行。 2048 格 ÷ 128 线程 = 16 格/人 —— 搬运总量和 K3 一格没变, 所以 K4 的算术强度和 K3 完全相同(7.969)。 也正因为 warp 按 tx 切,粗化只能沿【列】方向:沿行粗化会把 一个 warp 劈成 8 段,合并全毁。
图 · 一个线程算什么、搬什么,以及从 warp 视角看是什么样。 ① 8 个累加器从头到尾留在寄存器里,128 个阶段跑完才写回 C 一次。 ② 两块 tile 共 32×32×2 = 2048 格,128 个线程平摊,每人 16 格,一格不能少。 搬运总量和 K3 完全相同,所以两者的算术强度都是 7.969。 ③ 单看一个线程,它取的 8 个 A 元素在内存里相隔 4096; 但在同一次迭代里,一个 warp 的 32 个线程取的是同一行连续的 32 个数。

为什么是"每线程管一列上的 8 行",而不是"一行上的 8 列"

因为 warp 按 threadIdx.x 连号切分。 block 为 32×4 时,一个 warp 是同一个 ty 的 32 个线程, tx 从 0 到 31。所有 global memory 地址都以 tx 结尾,32 个连号,全部合并。 沿行方向粗化就得把 block 改成 4×32, 这时一个 warp 跨 8 个 ty,合并被劈成 8 段。 而且那样共用的是 A,A 本来就走广播,不值得省。

算术强度没变(7.969 → 7.969),dist 从 1.000 降到 0.125。 节拍从 3.34 降到 0.99,实测快 3.359×。 节拍比上面的 shared memory 账多出约 0.12 周期,是 global memory load 那一项(每个 FMA 0.0625 条 × 1.94 周期):3.22 + 0.12 = 3.34,0.87 + 0.12 = 0.99。这是整条链上第二大的一步(仅次于 K2),字节账全程旁观。

K52D 寄存器分块:两个方向一起复用↑

一、代码↑

// ---------------------------------------------------------------------------
__global__ void sgemm_k5(int M, int N, int K, float alpha,
                         const float *A, const float *B, float beta, float *C) {
    __shared__ float Mds[TILE][TILE];               //  4 KB
    __shared__ float Nds[TILE][TILE * TN];          // 16 KB

    const int tx = threadIdx.x, ty = threadIdx.y;
    const int i0      = ty * CF;
    const int Row0    = blockIdx.y * TILE + i0;
    const int colBase = blockIdx.x * TILE * TN;

    float acc[CF][TN];
    #pragma unroll
    for (int r = 0; r < CF; ++r)
        #pragma unroll
        for (int j = 0; j < TN; ++j) acc[r][j] = 0.0f;

    for (int ph = 0; ph < (K + TILE - 1) / TILE; ++ph) {
        const int aCol = ph * TILE + tx;

        // ---- 搬 A 的 tile:每线程 8 格(和 K4 一样)----
        #pragma unroll
        for (int r = 0; r < CF; ++r) {
            const int row = Row0 + r;
            Mds[i0 + r][tx] = (row < M && aCol < K) ? A[row * K + aCol] : 0.0f;
        }

        // ---- 搬 B 的 tile:每线程 8 行 × 4 段 = 32 格,段内仍以 tx 结尾 → 合并 ----
        #pragma unroll
        for (int r = 0; r < CF; ++r) {
            const int bRow = ph * TILE + i0 + r;
            #pragma unroll
            for (int j = 0; j < TN; ++j) {
                const int col = colBase + j * TILE + tx;
                Nds[i0 + r][j * TILE + tx] =
                    (bRow < K && col < N) ? B[bRow * N + col] : 0.0f;
            }
        }

        __syncthreads();                            // ① 写后读

        // ---- 内层:一次 8×4 的外积 ----
        #pragma unroll
        for (int k = 0; k < TILE; ++k) {
            float m[CF], n[TN];
            #pragma unroll
            for (int r = 0; r < CF; ++r) m[r] = Mds[i0 + r][k];          // 8 条广播
            #pragma unroll
            for (int j = 0; j < TN; ++j) n[j] = Nds[k][j * TILE + tx];   // 4 条 32 路

            #pragma unroll
            for (int r = 0; r < CF; ++r)
                #pragma unroll
                for (int j = 0; j < TN; ++j)
                    acc[r][j] += m[r] * n[j];                            // 32 个 FMA
        }

        __syncthreads();                            // ② 读后写
    }

    #pragma unroll
    for (int r = 0; r < CF; ++r) {
        const int row = Row0 + r;
        if (row >= M) continue;
        #pragma unroll
        for (int j = 0; j < TN; ++j) {
            const int col = colBase + j * TILE + tx;
            if (col < N) C[row * N + col] = alpha * acc[r][j] + beta * C[row * N + col];
        }
    }
}

二、8 × 4 = 32 个输出,每个 M 用 4 次、每个 N 用 8 次↑

K4 只把 n 读进寄存器,所以只能在一个方向上复用。 K5 两个方向一起复用:每步 k 先读 8 个 m 和 4 个 n, 再做一次 8×4 的外积。 12 条 load 换 32 个 FMA。

一步 k:取一竖条 m、一横排 n,做一次 8 × 4 的外积 还是 block(0,3) 的线程 (ty=0, tx=5) —— 它的 8 行是 96–103,4 列是 5 · 37 · 69 · 101。 n[j] = Nds[k][j*32+tx] 4 条 32 路 n[0] 列 5 n[1] 列 37 n[2] 列 69 n[3] 列 101 m[r] = Mds[i0+r][k] m[0] ← 行 96 m[1] ← 行 97 m[2] ← 行 98 m[3] ← 行 99 m[4] ← 行 100 m[5] ← 行 101 m[6] ← 行 102 m[7] ← 行 103 acc[0][0] acc[0][1] acc[0][2] acc[0][3] acc[1][0] acc[1][1] acc[1][2] acc[1][3] acc[2][0] acc[2][1] acc[2][2] acc[2][3] acc[3][0] acc[3][1] acc[3][2] acc[3][3] acc[4][0] acc[4][1] acc[4][2] acc[4][3] acc[5][0] acc[5][1] acc[5][2] acc[5][3] acc[6][0] acc[6][1] acc[6][2] acc[6][3] acc[7][0] acc[7][1] acc[7][2] acc[7][3] 8 条广播 ← m[7] 用 4 次 ↑ n[2] 用 8 次 每一步 k 做什么 for r=0..7: m[r] = Mds[i0+r][k]; for j=0..3: n[j] = Nds[k][j*32+tx]; acc[r][j] += m[r] * n[j]; 每个 m 横着用 4 次 → 沿右边一整行 每个 n 竖着用 8 次 → 沿下面一整列 这就是"二维"的含义。K4 只有一个 n, 所以只能在一个方向上复用。 acc[8][4] 全程住在寄存器里 —— 128 个阶段跑完才写回 C 一次。 acc[7][2] 就是 C[103][69],§4·二 那一格。 一步 k:K3 是 2 条 load → 1 个 FMA  K4 是 9 条 → 8 个  K5 是 12 条 → 32 个
图 · 一竖条 × 一横排 = 一次外积。 每个 m 沿自己那一行横着用 4 次,每个 n 沿自己那一列竖着用 8 次。 这就是"二维"的含义。 32 个累加器全程留在寄存器里,128 个阶段跑完才写回 C 一次。 acc[7][2] 对应的就是 §4·二 的那一格 C[103][69]。
归一到每 32 个 FMA:
K3  32 条广播 + 32 条 32 路  ≈ 103.0 周期
K4  32 条广播 +  4 条 32 路  ≈ 27.8 周期
K5   8 条广播 +  4 条 32 路  ≈ 15.0 周期      ← 两个方向一起省

// K4 砍的是 32 路那一类(32 → 4);K5 接着砍广播那一类(32 → 8)。
// 到这一步两类 load 都已经很少,下一堵墙是 FP32 管线本身的 0.25 周期。

三、几何:输出块横向拉长 4 倍↑

block 还是 32×4 = 128 个线程,没有改;变的是它负责的输出块大小。 输出块从 32×32 拉成 32×128,于是覆盖 C[103][69] 的 block 变成 block(0,3),正是 §5·二 里 K3c 的那个 block。

① 输出块从 32×32 拉成 32×128  block(0,3):行 96–127 × 列 0–127 列 0–31 列 32–63 列 64–95 列 96–127 ty=0 行 96–103 ty=1 行 104–111 ty=2 行 112–119 ty=3 行 120–127 列 5 列 37 列 69 列 101 线程 (ty=0, tx=5) 的 32 个输出 = 8 行 × 4 列 C[96..103][5 · 37 · 69 · 101] ↑ 第三段那一列就是 列 69 —— §4·二 那个 block 的第 5 列,acc[7][2] = C[103][69]。 注意:不是连着取 4 列,而是每隔 32 列取一个 ——段内仍是「段首 + tx」,32 个线程连号。 ② 一个 block 在三张矩阵上各占什么 A(M×K) 本 block 的 32 行 ph 向右滑 B(K×N) 本 block 的 128 列 ph 向下滑 C(M×N) 输出块 32×128 只被碰一次 阶段循环只推进 K 方向:每加一次 ph, A 的窗口向右滑 32 列、B 的窗口向下滑 32 行。 M 由 blockIdx.y 覆盖,N 由 blockIdx.x 覆盖, 都和阶段循环无关。 所以"32 vs 128"是 BK 和 BN 的差别,不是同一维度上的不一致。 block 的形状一个字没改 —— 变的只是"一个 block 负责多大一块输出" block(线程) 每 block 的输出块 每线程算几格 grid(4096³) shared K4 (32, 4) = 128 32 × 32 8 (128, 128) 8 KB K5 (32, 4) = 128 32 × 128 32 (32, 128) 20 KB 粗化只发生在列方向,所以只有 grid.x 被除以 4;grid.y 和 K4 一模一样。shared 从 8 KB 涨到 20 KB —— 占用率由它说了算。
图 · 4 个输出列每隔 32 列取一个,不是连着取 4 个。 这样每段内仍是"段首 + tx",32 个线程连号。 连着取,内层读 Nds 会变成跨步 4, 只用到 1/4 的 bank,撞成 4 拍。 右边是同一个 block 在 A、B、C 上各占的窗口: 阶段循环只推进 K 方向,M 由 blockIdx.y 覆盖,N 由 blockIdx.x 覆盖。

四、搬 40 格,用 384 格↑

tile 横向拉长后,搬运分工也跟着变:A 那边一格没多(还是 8 格), B 那边从 8 格变成 32 格。 每线程每阶段搬 40 格,是 K4(16 格)的 2.5 倍; 负责的输出则是 K4 的 4 倍。

同一个阶段(ph = 1),这个线程搬了 40 格,却要用到 384 格 ① 我搬进去的:40 格 分工要让同一个 warp 的地址连号 → 每人拿一竖条 Mds 32 × 32 8 格 行 0–7 × 列 5 Nds 32 × 128 32 格 行 0–7 × 4 列 ② 我读出来用的:384 格 用的时候要的是"我这 8 行"和"我这 4 列"的全部 Mds 32 × 32 256 格 行 0–7 × 全部 32 列 Nds 32 × 128 128 格 全部 32 行 × 4 列 搬 40 格 用 384 格 重合 40 格 —— 用到的里面 89.6% 是别人搬来的。 两个形状互相垂直,这正是 tile 必须放在 shared 而不是寄存器里的原因:我搬的那一竖条,另外 31 个线程等着用; 我要用的那一横带,也得等别人搬完 —— 屏障挡的就是这个跨线程依赖。 顺带:384 正好等于内层的 load 指令数(32 步 × 12 条),因为每一格每一步都要重新读一次。
图 · "搬"的形状和"用"的形状互相垂直。 搬的时候,分工要让同一个 warp 的地址连号,所以每人拿到一竖条; 用的时候,每人要的却是"我这 8 行"和"我这 4 列"的全部,是一横带加四竖线。 两个形状互相垂直,所以 tile 必须放在 shared memory 而不是寄存器里,那两次 __syncthreads() 也不能省。
每线程每阶段        K4        K5         为什么
搬 A                8 格      8 格       输出块的行数没变,还是 32 行
搬 B                8 格      32 格      输出块从 32 列拉成 128 列,B 的 tile 跟着大 4 倍
合计                16 格     40 格      搬运量多 2.5 倍
负责的输出          8 个      32 个      但输出多了 4 倍
摊到每个输出        2.00 格   1.25 格    ← 省下的就是 A 的那 3/4
算术强度            7.969     12.720     // 和 K3c 一模一样 —— 殊途同归

五、占用率下降,速度反而更快↑

kernel每线程寄存器占用率GFLOP/s比上一级快
K45475.0% 20770.6—
K572 33.3% 34506.41.661×
K611133.3% 40928.81.186×
占用率砍掉一半以上,反而快 1.661 倍。 多数教程建议提高占用率(occupancy);这里正确做法是降低它, 换取每线程更多的寄存器复用。 七个 kernel 全部 0 bytes stack frame / 0 spill。 K6 用到 111 个寄存器,仍未溢出(上限 255)。

K6float4:4 条并 1 条↑

一、代码↑

下面逐行读代码,仍用同一个例子:block(0,3) 的线程 (ty=0, tx=5),第 ph=1 阶段。 注释里的数字都属于这个线程: tid = 5、行 96..103、列 20..23。 下面三张图也用这组数字。

01  __global__ void sgemm_k6(int M, int N, int K, float alpha,
02                           const float *A, const float *B, float beta, float *C) {
03
04      __shared__ __align__(16) float Ads[TILE][TILE];        // 转置存:Ads[k][行](K5 是 Mds[行][k])
05      __shared__ __align__(16) float Bds[TILE][TILE * TN];   // 尺寸和 K5 的 Nds 一样,但列的排法变了
06                                                             // __align__(16):128 位读写必须 16 字节对齐
07      const int tx = threadIdx.x, ty = threadIdx.y;          // block 仍是 32×4
08      const int tid     = ty * TILE + tx;         // 新增:0..127,加载改成按"第几个 float4"分工
09      const int i0      = ty * CF;                // 我在 tile 里的行段起点:ty=0 → 0(同 K5)
10      const int Row0    = blockIdx.y * TILE + i0; // 我的 8 个输出行:96..103(同 K5)
11      const int colBase = blockIdx.x * TILE * TN; // 本 block 的 128 列起点:0(同 K5)
12      const int col0    = colBase + tx * TN;      // 变了:我的 4 列是"连着的"20..23
13                                                  //       K5 是 5/37/69/101,每隔 32 列一个
14      float acc[CF][TN];                          // 32 个累加器 —— 和 K5 一字不差
15      #pragma unroll
16      for (int r = 0; r < CF; ++r)
17          #pragma unroll
18          for (int j = 0; j < TN; ++j) acc[r][j] = 0.0f;
19
20      const int phases = (K + TILE - 1) / TILE;
21      for (int ph = 0; ph < phases; ++ph) {       // 阶段循环:和 K5 逐字相同
22
23          // ---- 搬 A:整块 32×32 = 256 个 float4,128 个线程各 2 条 ----
24          #pragma unroll
25          for (int s = 0; s < (TILE * TILE / 4) / 128; ++s) {    // = 2 轮
26              const int idx4 = tid + s * 128;                    // 我是第几个 float4:5、133
27              const int r    = idx4 / (TILE / 4);                // tile 内行号 = idx4/8 → 0、16
28              const int c    = (idx4 % (TILE / 4)) * 4;          // tile 内列号 = 20(必是 4 的倍数)
29              const int row  = blockIdx.y * TILE + r;            // 全局行 96、112 —— 不按我的输出行分
30              const int aCol = ph * TILE + c;                    // 全局列 52
31              float4 a4 = make_float4(0.0f, 0.0f, 0.0f, 0.0f);
32              if (row < M && aCol + 3 < K)                       // 整条一起判:不能只读半条 float4
33                  a4 = *reinterpret_cast<const float4 *>(&A[row * K + aCol]);
34              Ads[c + 0][r] = a4.x;   Ads[c + 1][r] = a4.y;      // 转置写:读进来是横着的 4 个,
35              Ads[c + 2][r] = a4.z;   Ads[c + 3][r] = a4.w;      // 写出去是竖着的 4 个 → 4 条 store
36          }
37
38          // ---- 搬 B:整块 32×128 = 1024 个 float4,128 个线程各 8 条 ----
39          #pragma unroll
40          for (int s = 0; s < (TILE * TILE * TN / 4) / 128; ++s) {   // = 8 轮
41              const int idx4 = tid + s * 128;                        // 5、133、261 … 901
42              const int r    = idx4 / (TILE * TN / 4);               // 行号 = idx4/32 → 0、4 … 28
43              const int c    = (idx4 % (TILE * TN / 4)) * 4;         // 列号 = tx*4 = 20(恒定)
44              const int bRow = ph * TILE + r;                        // 全局行 32、36 … 60
45              const int col  = colBase + c;                          // 全局列 20 = 我自己的 col0
46              float4 b4 = make_float4(0.0f, 0.0f, 0.0f, 0.0f);
47              if (bRow < K && col + 3 < N)
48                  b4 = *reinterpret_cast<const float4 *>(&B[bRow * N + col]);
49              *reinterpret_cast<float4 *>(&Bds[r][c]) = b4;          // 直着写:一条 128 位 store
50          }
51
52          __syncthreads();                        // ① 写后读:和 K5 同一道屏障,位置、作用都没变
53
54          #pragma unroll
55          for (int k = 0; k < TILE; ++k) {        // 内层 32 步 —— K6 的全部收益只发生在这里
56              const float4 m0 = *reinterpret_cast<const float4 *>(&Ads[k][i0    ]);  // 广播:行 96..99
57              const float4 m1 = *reinterpret_cast<const float4 *>(&Ads[k][i0 + 4]);  // 广播:行 100..103
58              const float4 n0 = *reinterpret_cast<const float4 *>(&Bds[k][tx * TN]); // 32 路:列 20..23
59              const float m[CF] = { m0.x, m0.y, m0.z, m0.w,     // 拆成 8 个标量:纯粹给寄存器改名,
60                                    m1.x, m1.y, m1.z, m1.w };  // 编译后不产生任何指令
61              const float n[TN] = { n0.x, n0.y, n0.z, n0.w };
62              #pragma unroll
63              for (int r = 0; r < CF; ++r)
64                  #pragma unroll
65                  for (int j = 0; j < TN; ++j)
66                      acc[r][j] += m[r] * n[j];   // 还是那 32 个 FMA —— 但这次只用了 3 条 load
67          }
68
69          __syncthreads();                        // ② 读后写
70      }
71
72      #pragma unroll
73      for (int r = 0; r < CF; ++r) {              // 写回:8 行,每行一条 float4
74          const int row = Row0 + r;
75          if (row >= M || col0 + TN > N) continue;   // 整条要么全写要么不写 → 边界粒度变粗成 4
76          const float4 old = *reinterpret_cast<const float4 *>(&C[row * N + col0]);  // beta 的读也向量化
77          float4 out;
78          out.x = alpha * acc[r][0] + beta * old.x;
79          out.y = alpha * acc[r][1] + beta * old.y;  // 四个分量对应 acc 的 j=0..3
80          out.z = alpha * acc[r][2] + beta * old.z;  // 顺序不能错:acc[r][j] ↔ C 的第 col0+j 列
81          out.w = alpha * acc[r][3] + beta * old.w;
82          *reinterpret_cast<float4 *>(&C[row * N + col0]) = out;   // 一条 128 位写
83      }                                           // 共 8 条写(K5 是 32 条),字节数一样多
84  }

二、12 条变 3 条↑

一条 128 位 load 一次搬 4 个 float。 K5 每步 k 发 12 条 32 位读,K6 发 3 条 128 位读。 两者搬的都是 48 个字节,换来的都是 32 个 FMA。

一步 k:搬的还是那 48 个字节,指令条数却除以了 4 仍是 block(0,3) 的线程 (ty=0, tx=5),取 k = 7 这一步。 K5 · 12 条 32 位读 行 96 行 97 行 98 行 99 行 100 行 101 行 102 行 103 8 条 LDS.32 广播 m[0..7] 8 个 float = 32 字节 列 5 列 37 列 69 列 101 4 条 LDS.32 32 路 n[0..3] 4 个 float = 16 字节 合计 12 条指令 · 48 字节 · 32 个 FMA K6 · 3 条 128 位读 行 96 行 97 行 98 行 99 行 100 行 101 行 102 行 103 2 条 LDS.128 广播 m[0..7] 8 个 float = 32 字节 列 20 列 21 列 22 列 23 1 条 LDS.128 32 路 n[0..3] 4 个 float = 16 字节 合计 3 条指令 · 48 字节 · 32 个 FMA 字节一分没省,省的全是指令条数 —— 所以 K6 是"纯条数账"的实验。 load/FMA 0.414 → 0.104  算术强度 12.720 → 12.720(不变) ⚠ 上面这 12 → 3 是手推的。反汇编数下来 K5 已经有 64 条 LDS.128 —— 编译器自己向量化了它三分之一,真实比值只有 2.32×(见 SASS 那一节)。
图 · 同一排格子,括号从 8 个变成 2 个。 格子是 shared memory 里的 float,括号表示几条指令读走它们。 字节数一个没省(算术强度仍是 12.720),省的全是指令条数。 所以 K6 是整条链上最干净的一次"纯条数账"实验。 但 12 → 3 是手推的, §8·八 的反汇编推翻了其中一半。

三、第 07–13 行:两个 built-in 变量推出全部坐标↑

K6 和 K5 的区别,看这七行就够了。 行的算法一字未改,K6 相对 K5 的改动只发生在列方向。 变的只有两处:多了一个 tid;col0 的算法变了。

第 07–13 行:两个 built-in 变量,推出这个线程的全部坐标 block(0,3) 的线程 (ty=0, tx=5) —— 绿色两行是 K6 才有的。 blockIdx = (0, 3) threadIdx = (tx=5, ty=0) tid = ty*32 + tx = 5 ← K6 新增:加载按"第几个 float4"分工 i0 = ty*8 = 0 Row0 = blockIdx.y*32 + i0 = 96 → 行 96..103(和 K5 一样) colBase = blockIdx.x*128 = 0 col0 = colBase + tx*4 = 20 ← K6 改了:4 列连着,20..23 它在本 block 的输出块上占哪一块 K6 连着的 4 列 20–23 K5 隔 32 列一条 5 · 37 · 69 · 101 行 96–127 × 列 0–127 浅蓝 = ty=0 那一条(行 96–103) 列映射一改,§4·二 那一格 C[103][69] 就归 tx=17 了 —— 换了主人。
图 · 绿色两行是 K6 才有的。 tid 让搬运改成按"第几个 float4"分工; col0 把输出列从"隔 32 一个"并成"连着的 4 个"。 后面所有向量化都建立在这一步上。 也因为这一步,§4·二 那一格 C[103][69] 换了主人。

这一改的方向,和 K5 当初的要求正好相反

K5 特意让 4 列"每隔 32 取一个",为的是避开读 Nds 时的 bank 冲突。 K6 必须改回连着的 4 列,否则 n[0..3] 在 shared memory 里不连续,合不成一条 float4。 同一件事,两版的最优解相反: K5 守的是 32 位访问的 bank 规则,K6 守的是 128 位访问的对齐规则。 这类「相反要求」的实际代价只能靠 ncu 量,推理定不了量。

四、第 04 行:A 的 tile 为什么必须转置存↑

向量化只有一个前提:要用的那几个数在内存里必须连着。 K5 的 Mds[行][k] 不满足这一条。 所以 K6 不能只"加个 float4",要先改数据的摆放。

第 04 行:A 的 tile 为什么必须转置存 向量化的前提只有一条 —— 同一步 k 要用的那 8 个 m,得在 shared 里连着。 K5:Mds[行][k] k = 7 这一列上的 8 个 m shared 线性地址(前 256 个 float) 每隔 32 个 float 一个 → 合不成 float4 K6:Ads[k][行] k = 7 这一行上的 8 个 m shared 线性地址(前 256 个 float) 连成一片 → 2 条 float4 读完 代价:搬进 shared 的时候要"横着读、竖着写" —— 那一步会撞 bank。
图 · 同一步 k 要用的 8 个 m,在 shared memory 的线性地址里是散的还是连的。 按 Mds[行][k] 存,它们每隔 32 个 float 才出现一次; 转置成 Ads[k][行] 后才连成一片,2 条 float4 读完。 代价是搬进 shared memory 时要横读竖写,这一步会撞 bank。 怎么撞的、有哪三条退路,见 §8·五; 它在反汇编里留下的痕迹(STS 从 40 条降到 16 条)见 §8·八。

五、第 23–50 行:10 条 float4↑

K4、K5 的搬运分工都是"我搬我自己那几行 / 那几列",K6 不是。 向量化要求连续 4 个 float 归同一个线程,并且地址 16 字节对齐。 所以 K6 改成按 tid 线性铺 float4。 一个线程搬的数据,和它自己要算的输出不再对应。

这种分法最容易数清楚:给 tile 里每一条 float4 编个号,128 个线程按号领。

第 25–28 行:怎么保证 A 的 tile 被不重不漏地搬完 给 tile 里每一条 float4 编个号,128 个线程按号领 —— 数一遍就清楚了。 c=0 c=4 c=8 c=12 c=16 c=20 c=24 c=28 行 0 行 31 idx4 = 5(s=0) idx4 = 133(s=1) 每格 = 一条 float4;绿色两格是线程 5 这一阶段搬的 虚线上半 = s=0 轮(行 0–15),下半 = s=1 轮(行 16–31) 256 条 float4 = 32 行 × 8 条/行 = 128 线程 × 2 轮 ① 编号不重不漏 idx4 = tid + s·128,tid 走 0..127、s 走 0..1 —— 步长正好等于线程数,所以 0..255 每个号恰好领一次 ② 编号还原成位置不重不漏 r = idx4 / 8、c = (idx4 % 8) · 4 —— 一个数除以 8 的商和余数唯一,256 个号 ↔ 256 个位置 ③ 一条 float4 铺满 4 格 Ads[c+0..c+3][r],256 × 4 = 1024 = Ads 的全部格子; A 的 (r, c+i) ↔ Ads 的 (c+i, r) 同样一一对应 三步串起来:线程×轮次 ↔ 编号 ↔ tile 位置 ↔ Ads 格子,全程一一对应。 所以"每个线程搬哪几格"根本不用去想。 搬 B 是同一套,只是数字变了 B 的 tile 是 32 × 128 = 1024 条 float4,1024 ÷ 128 = 8 轮;每行 32 条,所以 r = idx4 / 32、c = (idx4 % 32) · 4。 ⚠ 唯一要盯的是整除关系:tile 的 float4 条数必须被线程数整除。256 和 1024 都是 128 的倍数 —— 改 TILE / TN / 线程数都要重新数一遍。
图 · 数一遍就清楚了:256 条 float4,128 个线程各领 2 条。 三步映射每一步都是一一对应(编号 → 位置 → Ads 的格子)。 所以不会漏掉一格,也不会有两个线程撞在同一格上。 不用逐个去想每个线程搬哪几格: 只要总数对得上、映射是一一的,覆盖就自动成立。
第 23–50 行:10 条 float4 —— A 侧横读竖写,B 侧读写都是整条 线程 tid = 5,第 ph = 1 阶段。加载分工和"我算什么"已经没有关系了。 ① 搬 A:2 条 float4 A 的 tile 32×32 转置 Ads[k][行] 32×32 A[96][52..55] → Ads[20..23][0] A[112][52..55] → Ads[20..23][16] 蓝框 = 它自己要用的 8 行(tile 行 0–7)—— 它只碰到其中 1 行。 全局侧:8 个 lane 一组读满 128 字节 → 完全合并 128 字节 · 一条 LDG.128 shared 侧:转置写 Ads[c+i][r],bank = 行号 r mod 32 一个 warp 只有 4 个不同的 r → 只点亮 4 个 bank,每个排 8 个 lane ⚠ 疑似 8 路冲突,而且一条 128 位读要拆成 4 条 32 位写。 ② 搬 B:8 条 float4 B 的 tile 32×128 Bds 32×128 直着写 B[32 · 36 · … · 60][20..23] → Bds[0 · 4 · … · 28][20..23] 读写都是整条 float4,不转置。 行号被全 block 打散(0、4 … 28), 列号却恒定是自己的 20–23 —— 因为 c 展开正好等于 tx·4。 warp 视角:一个 warp 的 32 个 lane 各拿 4 列 = 一整行 128 列 = 512 字节连续 —— 合并得不能再好 10 条 float4 = A 的 2 条 + B 的 8 条  K5 是 40 条 32 位读 搬的 float 数一个不多不少,还是 40 个 —— 向量化不改变搬多少字节,只改变发几条指令。 ⚠ A 侧那个转置写是 K6 唯一的新风险。本报告没量 bank conflict(ncu 在这台机器上起不来),所以只是推断。
图 · A 侧横读竖写,B 侧读写都是整条。 A 侧在 global memory 那边 8 个 lane 一组读满 128 字节,完全合并。 但转置写进 Ads[c+i][r] 时 bank = r mod 32,而一个 warp 只有 4 个不同的 r, 所以 32 个线程挤在 4 个 bank 上。 B 侧没有这个问题:一个 warp 的 32 个 lane 各拿 4 列, 合起来正好是一整行 512 字节。

转置写入撞 bank:这是 K6 最可能出问题的地方,本报告没有量

根因是两个方向垂直:global memory 那边要"按行连续读"才能合并,shared memory 这边要"按列写"才能转置。 这和 §4 里 corner turning 那对矛盾是同一类。

// 三条候选退路,按省事程度排
A  padding:__shared__ float Ads[TILE][TILE + 4];
   // 必须加 4 不是 1 —— 加 1 行首就不再 16 字节对齐,内层那两条 float4 读会挂。
   // 加 4 之后 bank = (4(c+i) + r) mod 32,冲突从 8 路降到约 4 路,没有完全消除。
B  换 A 的加载分工,让一个 warp 的 r 取 32 个不同值
   // 但那样全局读就变成跨步 K,直接丢掉合并 —— 得不偿失。
C  A 侧干脆不转置、不向量化,内层退回 8 条 32 位广播读,只向量化 B 和写回
   // 广播本来就便宜(0.534 周期),A 侧向量化的收益最小 —— 这是最省事的退路。

不要先猜,先量: ncu --metrics l1tex__data_bank_conflicts_pipe_lsu_shared.sum K5、K6 各跑一次。如果 K6 明显更高,再按 A → C 的顺序试。 这台机器上 ncu 起不来,所以上面整段是推断,没有证据。

六、第 54–67 行:一步 k↑

K6 的全部收益只发生在这 14 行里。 前面的改造(转置、连续 4 列、线性 tid)都只为让这三条读成立。

第 54–67 行:一步 k 的三条 128 位读,各从 shared 的哪一行取 取 k = 7。两条从 Ads 的第 7 行取(广播),一条从 Bds 的第 7 行取(32 路)。 m0 m1 Ads[k][行] 32×32 Ads[7][0..3] 和 Ads[7][4..7] 两条 128 位广播读 Bds[k][列] 32×128 Bds[7][20..23] 一条 128 位 32 路读 m0 → m[0..3] = 行 96..99 m1 → m[4..7] = 行 100..103 n0 → n[0..3] = 列 20..23 acc[r][j] += m[r] * n[j] 8 × 4 = 32 个 FMA 第 59–61 行把 float4 拆成 8 个标量 —— 纯粹给寄存器改名,编译后不产生指令。 3 条 128 位读 · 48 字节 · 32 个 FMA
图 · 三条 128 位读各从 shared memory 的哪一行取。 Ads 的第 k 行取 2 条(广播),Bds 的第 k 行取 1 条(32 路),共 48 字节。 第 59–61 行那两句"拆成标量"看着像在拷贝 12 个数,但不产生任何指令。 float4 的四个分量本来就在 4 个连号寄存器里, 这两句只是给它们起名字,方便写下面的双重循环。 前提仍是两层 #pragma unroll: 不展开,m[r] 的下标就不是常量,一样会掉进 local memory。

七、第 72–83 行:写回↑

写回除了 alpha、beta 的缩放,没有别的计算,只是把寄存器里已经算完的 32 个数,按初始化时定好的坐标送回去。 所以这一段最容易出的错不是算错,而是坐标公式和前面三处 (搬 B、读 Bds、写回)不一致。不一致就会写到别人的格子上。

第 72–83 行:写回一行 —— acc 的 4 个数并成一条 float4 以 r = 3 为例:acc[3][0..3] 对应 C 第 99 行、第 20–23 列。 acc[3][0] acc[3][1] acc[3][2] acc[3][3] 32 个累加器里的第 3 行 out.x out.y out.z out.w out = alpha·acc + beta·old C[99][20] C[99][21] C[99][22] C[99][23] 一条 128 位写 ⚠ beta·old 要先把 C 读回来 —— 写回其实是"读 → 改 → 写",这条读也是向量化的。 8 行各一条,共 8 条 128 位写;整个 kernel 里 C 只被碰这一次。 写回(每线程) K4 K5 K6 输出个数 8 32 32 写指令 8 条 32位 32 条 32位 8 条 128位 合并 连号 ✓ 段内连号 ✓ warp 512 B ✓ 边界判断 行列各判 行1列4 整条一起判 搬的字节一样多,条数 ÷ 4。 但边界粒度从 1 列变成 4 列 —— 这就是 K6 要求 N % 4 == 0 的原因。 同一个线程,两版负责的输出格子根本不是同一批 K5 32 条细的 K6 8 条宽的 两边合起来覆盖的 128 列完全一样,只是分法不同 —— K5 是四条细竖线,K6 是一条宽 4 列的竖带。
图 · 一行的完整动作:4 个累加器 → out 的四个分量 → 一条 128 位写。 顺序不能错:acc[r][j] 对应 C 的第 col0+j 列。 下半是同一个线程在两版里负责的格子: K5 是四条细竖线,K6 是一条 4 列宽的竖带,合起来覆盖的 128 列完全一样。 注意边界粒度:K6 不能只写半条 float4, 判的是 col0 + 4 > N。这就是它要求 N % 4 == 0 的原因。

没算的一笔小账:beta 的那次读

C = alpha*acc + beta*C 里的 beta*C 要先把 C 读回来。 所以写回其实是"读 → 改 → 写",每个输出是 4 B 读 + 4 B 写。 本报告的 bytesPerOut 只算了写的那 4 B。 漏掉的 4 B 摊到 644 B 上不到 1%;但以后如果跑 beta ≠ 0 的基准,要补上这一笔。 这也说明了"写回只发生一次"的价值: 128 个阶段里 C 只被碰这一次。如果每个阶段都往 C 里累加,这 8 B 就要乘 128 倍, 算术强度会被打回原形。

八、SASS 验证:float4 真的编成 128 位了吗↑

"float4 编成了 128 位"是一条纯靠推理、没有直接证据的断言。 编译器完全可以把 float4 拆成 4 条 32 位 load, 而 ptxas -v 只报寄存器和 spill,看不到指令形态。 所以用反汇编数一遍(cuobjdump -sass,静态条数):

kernel     FFMA  LDS.128   LDS.32  LDG.128   LDG.32      STS  STG.128   STG.32    TOTAL
k5         1056       64      128        -       72       40        -       32     1856
k6         1056       96        -       18        -       16        8        -     1520

// ✓ 证实:K6 有 96 条 LDS.128 + 18 条 LDG.128,float4 确实编成了 128 位访存。
// ✗ 推翻:K5 竟然也有 64 条 LDS.128 —— 编译器自己把它三分之一的 shared load 向量化了。

第二条改写了 K5→K6 的解释

原来的说法是"标量 vs 向量化",实际是"部分向量化 vs 完全向量化"。

访存指令合计   K5 = 192 + 72 = 264 条
               K6 =  96 + 18 = 114 条      比值 2.32×

// 而手推的条数账说 load/FMA 从 0.414 降到 0.104,比值 3.98× —— 高估了。
// 根源就是手推时假设 K5 全是标量 load,没料到编译器已经替它向量化了三分之一。
// 两者 FFMA 条数完全相同(1056),说明展开程度一样,静态条数可以直接比。

另有一个没预测到的旁证:K1 和 K2 的 SASS 逐项相同 (都是 30 FFMA / 59 LDG.32 / 216 条总计)。 两者只差一个索引映射,指令构成完全一样。 所以 K1→K2 那 8.209× 里, 指令条数的贡献严格为零,100% 来自访存行为。 这是"请求账必须独立于条数账"最干净的证据。

全景六级并排看一次↑

一、每一步动了哪本账↑

这一步字节账
算术强度
请求账
请求/FMA
条数账
load/FMA
实测
K1→K2不变 0.25 33 → 2不变 2.000 8.209×
K2→K30.25 → 7.97不变 2.000 → 2.0621.162×
K3→K3c7.97 → 12.72不变 2.062 → 2.0391.082×
K3→K4不变 7.97不变 2.062 → 1.1883.359×
K4→K57.97 → 12.72不变 1.188 → 0.4141.661×
K5→K6不变 12.72不变 0.414 → 0.1041.186×
收益大的每一步都动了请求账或条数账;只动字节账的两步,收益只有 8%–16%。

二、一句话版本↑

最终:K1 → K6 共快 63.1 倍,做到 cuBLAS 的 71.5%。 siboehm 在 A6000 上做到 93%。差的 21 个点主要是 warptiling 和 double buffering。

延伸完整代码、实测数据、原始推导↑

要什么去哪
八个 kernel 的完整源码 + 测试脚手架 src/sgemm_all.cu(含 K0 cuBLAS 基准)
测试脚本 Scripts/ —— run_p0.sh 一条命令跑完全套
原始输出与解析后的 CSV Results/Round1/
八个指标各自怎么算出来的 报告 02 · §3
形状扫描(prefill / decode) 报告 02 · §5

这份报告的代码从哪来

K1 出自 PMPP 第 3 章 Fig. 3.11,K2 是第 4 章的修正, K3 是第 5 章的分块,K3c 是第 6 章 Fig. 6.13。 K4 / K5 / K6 书上没有,是照着 siboehm 那篇的思路自己写的。 所有 kernel 合并进同一个可执行文件,在同一次开机里跑。 因为"K6 是 cuBLAS 的百分之多少"这类比值,只有在同一个二进制下才成立。