02 · CUDA from a Systems Engineer’s Eyes · 报告 02
K6 比 K1 快 63.1 倍,达到 cuBLAS 的 71.5%。 但这份报告的重点不是这两个数,而是:教科书的那本账 (算术强度 → roofline)在七个 kernel 里失效五次。 预测得准的是第三本账:指令条数 × 分类单价, 外推误差在 ±10% 以内。 §3 一步一步演示八个指标怎么从代码数出来。
| kernel | 中位耗时 | GFLOP/s | 占 FP32 峰值 | 占 cuBLAS | 相对 K1 | 算术强度 | roofline 达成率 | 占用率(occupancy) | 寄存器 | 跨轮漂移 |
|---|---|---|---|---|---|---|---|---|---|---|
| K1 naive | 211.911 ms | 648.6 | 0.79% | 1.1% | 基准 | 0.250 | 257.4% | 66.7% | 40 | ±0.2% |
| K2 合并 | 25.815 ms | 5324.0 | 6.45% | 9.3% | 8.2× | 0.250 | 2112.7% | 66.7% | 40 | ±0.2% |
| K3 shared 分块 | 22.225 ms | 6184.0 | 7.49% | 10.8% | 9.5× | 7.969 | 77.0% | 66.7% | 38 | ±0.1% |
| K3c 书上的粗化 | 20.542 ms | 6690.6 | 8.10% | 11.7% | 10.3× | 12.720 | 52.2% | 66.7% | 38 | ±0.1% |
| K4 1D 寄存器 | 6.617 ms | 20770.6 | 25.16% | 36.3% | 32.0× | 7.969 | 258.6% | 75.0% | 54 | ±0.5% |
| K5 2D 寄存器 | 3.983 ms | 34506.4 | 41.79% | 60.3% | 53.2× | 12.720 | 269.1% | 33.3% | 72 | ±0.7% |
| K6 float4 | 3.358 ms | 40928.8 | 49.57% | 71.5% | 63.1× | 12.720 | 319.2% | 33.3% | 111 | ±0.9% |
| K0 cuBLAS | 2.401 ms | 57242.4 | 69.32% | 100.0% | 88.3× | — | — | — | — | ±0.6% |
这批数据最该记住的三件事
① 教科书那本账失效五次。 roofline 达成率超过 100%,说明 kernel 不在带宽墙上。 七个 kernel 里有五个超过 100%,K2 达到 2112.7%。 预测得准的是条数账,外推误差在 ±10% 以内。
② 收益最大的两步,算术强度都没变。 K1→K2 快 8.21×,只改了访存合并; K3→K4 快 3.36×,只改了指令条数。 K3c 的算术强度是 K3 的 1.6 倍,却只快 1.08×。
③ cuBLAS 自己也只达到峰值的 69.32%。 在 Ada 上,NVIDIA 重点优化的是 FP16/TF32,不是纯 FP32 的 SGEMM。 所以 cuBLAS 这个目标比想象中近:它只比 K6 快 1.40 倍。
K0 = 2.401 ms / 57242.4 GFLOP/s / 占峰值 69.32%,抽样校验通过。
两项设置让它只跑纯 FP32:源码里设
cublasSetMathMode(CUBLAS_PEDANTIC_MATH),环境变量设
NVIDIA_TF32_OVERRIDE=0。
如果 TF32 没禁掉,tensor core 会让结果超过
FP32 峰值。所以 69.32% 只可能是纯 FP32 的成绩。
// cuBLAS 是列主序,而我们的矩阵是行主序。不想转置数据,就转置运算: // 行主序的 C = A·B 等价于 列主序的 Cᵀ = Bᵀ·Aᵀ cublasSgemm(handle, CUBLAS_OP_N, CUBLAS_OP_N, N, M, K, &alpha, B, N, // ← B 在前 A, K, &beta, // ← A 在后 C, N);
恒等式和那行调用的关系,要从一个事实讲起: 同一段字节,按行主序读和按列主序读,得到的矩阵互为转置。 所以这里的"转置"不是一个操作。
CUBLAS_OP_N
都表示"不转置":我们从没让 cuBLAS 做过转置。
| 缓冲区 | 我们按行主序存的 | cuBLAS 按列主序读到的 |
|---|---|---|
| A | M × K 的 A | K × M 的 Aᵀ |
| B | K × N 的 B | N × K 的 Bᵀ |
| C | M × N 的 C | N × M 的 Cᵀ |
所以恒等式可以直接当参数表读:
Cᵀ = Bᵀ · Aᵀ
↓ ↓ ↓
写进 C 缓冲区 传 B 指针 传 A 指针
// 右边从左往右念一遍 —— Bᵀ 在前、Aᵀ 在后 —— 就是参数的先后顺序。
// 而 Bᵀ 和 Aᵀ 根本不用构造:把 B、A 的指针原样递过去,
// cuBLAS 用列主序一读,看到的就已经是 Bᵀ 和 Aᵀ 了。
维度和 ld 也自动对上。
cublasSgemm(h, …, m, n, k, …) 算的是
(m×k) · (k×n)。这里的结果是 N×M 的 Cᵀ,
所以 m=N, n=M,中间那一维仍是 k=K。
参数顺序 N, M, K 就是这样来的。
参数 值 为什么 ───────────────────────────────────────────────────────── m N 结果 Cᵀ 是 N×M,它的行数是 N n M Cᵀ 的列数是 M k K 中间那一维没动 第一个矩阵 B cuBLAS 读成 Bᵀ(N×K),正好是 m×k ✓ 它的 ld N Bᵀ 在列主序下有 N 行 → ld = N 第二个矩阵 A cuBLAS 读成 Aᵀ(K×M),正好是 k×n ✓ 它的 ld K Aᵀ 有 K 行 → ld = K 输出 C cuBLAS 写进去一个 N×M 的列主序矩阵 它的 ld N 同上 // 最后一步:cuBLAS 往 C 缓冲区里写的是列主序的 Cᵀ(N×M); // 我们回过头按行主序去读同一段字节,看到的就是 M×N 的 C。闭环。
一句话记法
行主序的代码调用列主序的库时,把两个输入矩阵的顺序对调、
m 和 n 对调,其余不变。
代价为零:不多占显存,不多启动 kernel,
不多搬一次数据。
如果在这里真的调用一次 transpose,就白白多搬了两遍矩阵。
对 4096² 的 A 和 B,这是 256 MiB 的无谓读写(每个矩阵 64 MiB,转置要读一遍、写一遍)。
| 规矩 | 为什么 |
|---|---|
| 八个 kernel 编进同一个可执行文件 | "K6 是 cuBLAS 的百分之多少"这类比值,只在同一个二进制、同一次开机下成立。 实测:跨批次的绝对耗时漂 2–3%,同批内的比值只漂 0.5%。 |
| kernel 顺序每轮轮转 | 避免热漂移总是拖慢排在最后的 kernel。 |
| CUDA event 计时,取中位数 | kernel 是异步下发的,主机计时器量不到。 用中位数而不用平均数:一次偶发卡顿就会把平均数带偏。 |
| 预热按 ITERS 分档,至少 1 次 | cuBLAS 第一次调用要惰性加载 kernel 库, 一次要几十毫秒。 这个问题实际出现过:K0 在 512 上报出过 55 ms。 |
| 先校验再看速度 | 每个 kernel 抽 100 个元素,和 CPU 参照结果比对。 没验证过的"快",通常是编译器把循环删掉了。 |
| 设备参数现读现用 | 峰值算力和带宽都从 cudaDeviceProp 读,不抄规格表。
这和 01 阶段"KV 池必须每台机器现读日志"是同一条原则。 |
FP32 峰值 128 SM × 128 core × 2 × 2.52 GHz = 82,575 GFLOP/s // 设备自报的 clockRate ← 主表用这个 128 SM × 128 core × 2 × 2.64 GHz = 86,508 GFLOP/s // 本批 1 Hz 采样的中位数(稳态 2625–2655) 显存带宽 10501 MHz × 2(DDR 双沿)× 384 bit ÷ 8 = 1008 GB/s
主表的"占 FP32 峰值"按 2.52 GHz 那一行算。
这个时钟来自 cudaDeviceProp,换一台 4090 也读到同一个数。
标称值的意思是"保证不低于"。实际频率由 GPU Boost 按功耗墙(450 W)和温度动态调整,本批稳定在 2640 MHz。 上限是 3105,但稠密 FP32 GEMM 功耗太高,达不到。 要按实测口径引用,把整列乘 0.955(K6 从 49.57% 变成 47.32%)。 换口径时,分子和分母要一起换。 这个时钟每台机器不同,不能跨批次沿用。
"占 cuBLAS"那一列不受口径影响: 分子和分母都是 GFLOP/s,峰值约掉了。 所以"K6 = cuBLAS 的 71.5%" 在哪套口径下都成立,这也是本报告把它当主结论的原因。
本节是整份报告的核心:八个指标里只有耗时是量出来的,其余七个都从代码数出来。 数完再和实测对账,对上了才说明理解是对的。
一条 load 是整个 warp 的指令,它的代价由 32 个地址的分布决定。
32 个线程同时执行这条指令,各自把自己的 tx 代进下标,算出一个地址。
所以"一条 load"实际上是 32 个地址。
bank = (地址 ÷ 4) mod 32。
一拍里每个 bank 只能服务一个地址。例外是 32 个线程读同一个地址,这种情况叫广播。
| 类型 | 代码长什么样 | 32 个地址 | 硬件怎么做 | 本批实测单价 |
|---|---|---|---|---|
| 广播 | 下标里没有 tx | 全部相同 | 取一次 4 字节,复制给全 warp | 0.53 周期 |
| 32 路 | 下标里 tx 的系数是 1 | 32 个连号地址 | 落在 32 个不同 bank,一拍全服务完 | 2.69 周期 |
| 撞 bank | tx 的系数是 s(≥ 2) | 只用到 1/s 的 bank | 拆成 s 拍(例如系数 4:1/4 的 bank、4 拍) | 更贵(本链未出现) |
LDS 指令,下标里有 tx 时的代价约是没有时的 5 倍。
K3 的内层语句 acc += Mds[ty][k] * Nds[k][tx]; 两种 load 各有一条,
所以 K3 的 dist 和 bcast 都是 1.000。
K4 的全部改进,是把贵的 32 路 load(Nds[k][tx],图里在左)从"每个 FMA 一条"降到"八个 FMA 共用一条"。做法是把 n 提进寄存器,让 8 个 Mds 共用;便宜的广播 load 一条没省。(注意左右顺序:代码里 Mds 写在左边,图里 32 路在左边。)
K3 的内层循环只有两行,八个指标全从这两行数出来:
for (int k = 0; k < TILE; ++k)
acc += Mds[ty][k] * Nds[k][tx];
// ↑ 下标没有 tx ↑ 下标含 tx 且系数为 1
// = 广播 = 32 路
dist(32 路条数 ÷ FMA)循环体里含 tx 的 shared 读:Nds[k][tx] —— 1 条
一次循环做几个 FMA: 1 个
dist = 1 条 ÷ 1 个 FMA = 1.000
除以 FMA 数,是为了把不同 kernel 放到同一把尺子上。 不同 kernel 一次循环做的 FMA 个数不同 (K3 做 1 个,K5 做 32 个)。除以之后,指标的含义统一为 "每做一次乘加,平均要发几条这类 load"。 这个商没有量纲,可以横向比较,这是"条数账"成立的前提。
bcast(广播条数 ÷ FMA)循环体里不含 tx 的 shared 读:Mds[ty][k] —— 1 条
bcast = 1 条 ÷ 1 个 FMA = 1.000
ldg(全局 load 条数 ÷ FMA)一个阶段里,每个线程从 global 搬 2 个元素进 shared(A 一个、B 一个)—— 2 条 LDG 同一个阶段里,内层循环跑 TILE = 32 步,每步 1 个 FMA —— 32 个 FMA ldg = 2 条 ÷ 32 个 FMA = 0.062
这 2 条为什么能摊给 32 个 FMA
因为搬进 shared memory 的数据被整个 block 复用。 一个线程只搬 2 个元素,但内层 32 步用到的是 32 个线程一起搬来的整行/整列。"我搬一个,我用三十二个", 这句话就是 tiling 的全部。
三步合起来:load/FMA = dist + bcast + ldg = 1.000 + 1.000 + 0.062 = 2.062。
bytesPerOut(每个输出要过多少字节)一个 block 算 32×32 = 1024 个输出,沿 K 走 N/32 = 128 个阶段 每个阶段搬进来:A 的 32×32 + B 的 32×32 = 2048 个 float = 8192 字节 128 个阶段合计:128 × 8192 = 1,048,576 字节 摊到 1024 个输出:1,048,576 ÷ 1024 = 1024 字节 再加上写回 C 自己那 4 字节: 1028 字节/输出
算术强度回答"这个 kernel 每搬一个字节,能换来多少次浮点运算"。
它把第 4 步的 bytesPerOut 换算成一个
和矩阵规模无关的比值,所以不同 kernel 可以直接比较。
算术强度 = 每个输出的浮点运算 ÷ 每个输出的字节
= 2K ÷ bytesPerOut
= 2×4096 ÷ 1028 = 7.969 FLOP/byte
// 分子的 2K:C 的每个元素要做 K 次乘加,一次乘加算 2 个浮点运算(1 乘 1 加)。
// 分母的 1028:第 4 步数出来的,每个输出摊到的显存字节。
roofline 上限 = 算术强度 × 峰值带宽
= 7.969 × 1008 = 8033 GFLOP/s
// 意思是:如果这个 kernel 把显存带宽跑满,它最快只能到 8033。
// 1008 GB/s 是 4090 的理论峰值带宽,口径见 §2·二。
下面的 6184.0 是「实测吞吐」,是八步里唯一一个量出来的数; 其余七个都从代码数出来。 所以下面把它单独拆开。
实测 GFLOP/s = 2·M·N·K ÷ 耗时(秒) ÷ 1e9 // ① M·N·K 是这次乘法一共要做多少次乘加(FMA) // C 有 M×N 个元素,每个元素要沿 K 方向累加 K 次 M·N·K = 4096 × 4096 × 4096 = 68,719,476,736 次 FMA // ② 乘以 2:一次 FMA 在 FLOP 的记法里算 2 个运算(1 次乘 + 1 次加) // 这是整个行业的统一口径 —— cuBLAS、Nsight、论文都这么算, // 否则跟别人的数字没法比 2 × 68,719,476,736 = 137,438,953,472 FLOP ≈ 137.44 GFLOP // ③ 除以耗时。22.225 ms 就是主表里那个中位耗时 —— // 和第 6 步算节拍用的是【同一个数】,同一次测量,没有第二次计时 137,438,953,472 ÷ 0.022225 s = 6,183,979,908,751 FLOP/s // ④ 换成 GFLOP/s(除以 1e9) 6,183,979,908,751 ÷ 1e9 = 6184.0 GFLOP/s 达成率 = 实测 ÷ 上限 = 6184.0 ÷ 8033 = 77.0%
达成率是在检验一个假设,不是在打分
8033 是推出来的(从代码数字节 → 算算术强度 → 乘带宽), 6184.0 是量出来的(跑三轮取中位数)。 分母的含义是:假如数出来的字节全部走显存, 而且带宽跑满,kernel 能跑多快。 达成率检验的就是这个"假如"。 结果分三档,不是两档:
≈ 100% 贴着屋顶 带宽真跑满了 —— 这才是"受带宽限制",想更快只能提高算术强度 ≪ 100% 离屋顶远 带宽没跑满 → 瓶颈不是带宽,在别处(指令吞吐 / 延迟 / 占用率) > 100% 超出屋顶 分母错了 —— 字节没全走显存,命中了 L2。模型在这里被证伪
⚠ "达成率低"不等于"受带宽限制"。 对同一个 kernel,分母是常数, 达成率只是换了刻度的速度:跑得慢,达成率就低。 但"慢"本身说明不了"慢在哪"。
K3 的 77.0% 没超过 100%,所以字节账在 K3 上没有被证伪,但这不等于字节账成立。 K2 的 2112.7% 才是直接证伪。
K1 257.4% K2 2112.7% K3 77.0% K3c 52.2% K4 258.6% K5 269.1% K6 319.2% // 七个里没有一个贴近 100% —— 最近的就是 K3 的 77.0%。 // 也就是说:这条链上没有一个 kernel 真的受带宽限制。 // 五个被直接证伪;K3/K3c 虽然没被证伪,但离屋顶太远,同样说明带宽不是它们的墙。
K3 真正的瓶颈是指令条数。 第 8 步用条数账算出 3.340,和实测 3.34 一致; §4 再把七个 kernel 并排对比。
节拍 = 32 × SM数 × 时钟 × 耗时(秒) ÷ (M·N·K)
= 32 × 128 × 2.52e9 × 22.225e-3 ÷ 4096³
= 3.338 周期/warp圈 (程序打印 3.34)
这个公式在算什么
分母 M·N·K 是这次乘法一共要做的 FMA 个数。
除以 32,得到要跑多少个 warp 圈。
一圈指一个 warp 的 32 个线程各做一次 FMA。
分子 SM数 × 时钟 × 耗时 是这段时间里全卡一共经过多少 SM-周期。
两者相除,得到每个 warp 圈平均占用多少 SM-周期。数值越小越快。 4090 上的物理地板是 0.25, 因为每个 SM 每周期能发 128 个 FP32 FMA,即 4 个 warp 圈。 K6 实测 0.50,是地板的 2 倍。
每条 load 周期 = 节拍 ÷ load/FMA = 3.34 ÷ 2.062 = 1.62
这是诊断量,不是预测量。 它把"这个 kernel 慢"换成"它每发一条 load 要付几个周期"。 如果这个数在 kernel 之间差很多,说明 load 的类型构成变了。 例如 K1 是 15.91:它每个 FMA 的两条 load 一共触发 33 个请求(A 32 个、B 1 个)。
预测节拍 = 2.69 × dist + 0.53 × bcast + 1.94 × ldg
= 2.69 × 1.000 + 0.53 × 1.000 + 1.94 × 0.062
= 3.340 实测 3.34 ✓
K3 是标定点之一,所以这里必然吻合。 真正的检验是用同样三个单价外推 K3c 和 K5,见 §3 · 六。
下面按 K1 → K2 → K3c → K4 → K5 → K6 的顺序补齐其余六个 kernel,每个都走同样的八步。 K3 是主例,上面已经逐步算过。 只有 K1/K2 多一步"请求账",因为从 K3 起访存全部合并,这本账归零,不再变化。
const int x = blockIdx.x * blockDim.x + threadIdx.x; // 行 ← 从 .x 来 const int y = blockIdx.y * blockDim.y + threadIdx.y; // 列 ← 从 .y 来 float acc = 0.0f; for (int i = 0; i < K; ++i) acc += A[x * K + i] * B[i * N + y]; // ← 1 个 FMA + 2 条 LDG(全局!) C[x * N + y] = alpha * acc + beta * C[x * N + y];
第 1 步 这个线程负责几个输出? 一个 acc → 1 个输出 // 和 K3 相同 第 2 步 内层每轮:几个 FMA、几条 shared load、各属哪类? acc += A[x * K + i] * B[i * N + y]; └─ 1 个 FMA ─┘ └ 2 条 LDG ┘ // 是 LDG(全局),不是 LDS 一条 shared load 都没有 → dist = 0 ÷ 1 = 0.000 bcast = 0 ÷ 1 = 0.000 第 3 步 全局 load 怎么摊到每个 FMA 上? 内层每轮 2 条全局 load、1 个 FMA,没有阶段可摊 → ldg = 2 条 ÷ 1 个 FMA = 2.000 // K3 是 0.062,差 32 倍 —— 那 32 倍正是"搬一次进 shared、被 32 个线程各用一次"换来的 第 4 步 bytesPerOut:每个输出摊多少字节? 每个输出要读 A 的一整行 + B 的一整列 = 2K = 8192 个 float 8192 × 4 字节 = 32768,再加写回 C 的 4 字节 → bytesPerOut = 32772 字节 // 代码:2.0 * K * 4.0 + 4.0 第 5 步 AI = 2K ÷ bytesPerOut 8192 ÷ 32772 = 0.250 FLOP/byte // 七个 kernel 里最低 roofline 上限 = 0.250 × 1008 GB/s = 252 GFLOP/s 达成率 = 648.6 ÷ 252 = 257.4% // 程序打印的就是这个数 // 648.6 是这九步里唯一一个【量】出来的数,其余全是从代码数出来的: // 实测 GFLOP/s = 2·M·N·K ÷ 耗时(秒) ÷ 1e9 // 分子 2 × 4096³ = 137,438,953,472 FLOP(一次 FMA 记 2 个运算) // 分母就是主表那个中位耗时 211.911 ms = 0.211911 s // 137,438,953,472 ÷ 0.211911 ÷ 1e9 = 648.6 GFLOP/s —— 逐项拆解见 §3·二 第 6 步 节拍(把实测耗时换算成周期) 32 × 128 × 2.52e9 × 0.211911 ÷ 4096³ = 31.83 周期/warp圈 第 7 步 每条 load 周期 load/FMA = 0.000 + 0.000 + 2.000 = 2.000 31.83 ÷ 2.000 = 15.91 周期 第 8 步 模型预测 2.69 × 0 + 0.53 × 0 + 1.94 × 2.000 = 3.880 实测 31.83,误差 -87.8% ← 模型在这里彻底失效 // 换哪组单价都救不回来:差的不是系数,是这个 kernel 根本不在条数账的适用区间里 第 9 步 请求账 —— 只有 K1 / K2 走这一步 warp 按 threadIdx.x 每 32 个切一刀 → warp 内 x 在变、y 不变。而 K1 里 x 是行、y 是列: A[x*K+i] 32 个线程读 32 个不同行,地址隔 K×4 字节 32 个请求 B[i*N+y] warp 内 y 相同,读同一地址 1 个请求 → reqPerFMA = 33 个/FMA 31.83 ÷ 33 ≈ 0.96 周期/请求 // 换成"请求"当单位,数量级立刻就回来了。
const int y = blockIdx.x * blockDim.x + threadIdx.x; // 列 ← 改成从 .x 来 const int x = blockIdx.y * blockDim.y + threadIdx.y; // 行 ← 改成从 .y 来 ... acc += A[x * K + i] * B[i * N + y]; // ← 和 K1 逐字相同
第 1 步 几个输出? 1 个 // 同 K1 第 2 步 dist / bcast 没有 shared → 0.000 / 0.000 // 同 K1 第 3 步 ldg 2 条 ÷ 1 个 FMA = 2.000 // 同 K1 第 4 步 bytesPerOut 2K × 4 + 4 = 32772 字节 // 同 K1 第 5 步 AI 8192 ÷ 32772 = 0.250,上限 252 GFLOP/s // 同 K1 达成率 = 5324.0 ÷ 252 = 2112.7% ← 第一个不同的数 // 前四步是人从代码里数出来的,代码没变所以数也没变; // 达成率带上了实测 GFLOP/s,所以从这里开始分家。 // 5324.0 和 K1 的 648.6 是同一个公式代不同的耗时: // 实测 GFLOP/s = 2·M·N·K ÷ 耗时(秒) ÷ 1e9 // 分子一模一样:2 × 4096³ = 137,438,953,472 FLOP —— 两版要算的乘加一次不多一次不少 // 分母才是变的:K1 211.911 ms → K2 25.815 ms // 137,438,953,472 ÷ 0.025815 ÷ 1e9 = 5324.0 GFLOP/s —— 逐项拆解见 §3·二 // 所以 5324.0 ÷ 648.6 = 8.21,正好就是耗时之比 —— 吞吐涨的全部来自"跑得快", // 而不是"算得多"。这也是为什么分母 252 不变,达成率却从 257.4% 跳到 2112.7%。 第 6 步 节拍 32 × 128 × 2.52e9 × 0.025815 ÷ 4096³ = 3.88 // K1 是 31.83 —— 同样的 2.000 条 load/FMA,差 8.21 倍 第 7 步 每条 load 周期 3.88 ÷ 2.000 = 1.94 周期 // K1 是 15.91 第 8 步 模型预测 2.69 × 0 + 0.53 × 0 + 1.94 × 2.000 = 3.880 // 和 K1 同一个数 实测 3.88,误差 +0.0% ← 模型对 K2 是准的(它是标定点) 第 9 步 请求账 —— 差别全在这里 warp 内 tx 在变、ty 不变。K2 把 y(列)接到 tx、x(行)接到 ty: A[x*K+i] x 由 ty 定 → warp 内行相同,32 个线程读同一地址 1 个请求(广播) B[i*N+y] y 由 tx 定 → 列连号,32 个地址首尾相接 1 个请求(合并) → reqPerFMA = 2 个/FMA // K1 是 33 3.88 ÷ 2 ≈ 1.94 周期/请求 // 和 K1 的 0.96 同一量级,说明"按请求算钱"是对的。
这里就是第三本账(请求账)诞生的地方
K1 和 K2 的代码只差两行(x 和 y 的来源对调),前面五个量一个都没变:
dist、bcast、ldg、
bytesPerOut、AI 全部相同,第 8 步的模型预测也都是
3.880。
但 K1 的实测耗时是 K2 的 8.21 倍。
第 1–5 步(全是人算) 第 6 步 第 7 步 第 8 步 第 9 步
节拍 每条load 模型/误差 请求账
K1 1 输出 · dist 0 · bcast 0 · 31.83 15.91 3.880 / -87.8% 33
K2 ldg 2.000 · 32772 B · AI 0.250 3.88 1.94 3.880 / +0.0% 2
↑ 一模一样,五个量一个都没变
// 指令条数都是 2 条,但硬件真正要处理的事务数差了 16.5 倍。
字节账(AI 0.250)和条数账(ldg 2.000)都看不出这个差别, 只有第 9 步的请求账看得出。 SASS 反汇编给出了决定性的证据:K1 和 K2 的指令构成逐项相同 (都是 30 FFMA / 59 LDG.32 / 216 条)。 所以在这 8.21 倍里, 指令条数的贡献严格为零。
两个 kernel 都让一个线程多算几个输出,但省下来的东西完全不同。 判断方法是看多出来的输出摊薄了哪一项。
float p0 = 0.0f, p1 = 0.0f, p2 = 0.0f, p3 = 0.0f; // ← 4 个累加器 = 4 个输出 for (int ph = 0; ph < K / TILE; ++ph) { // ← 128 个阶段 Mds[ty][tx] = A[Row * K + aCol]; // ← M tile 每阶段只搬一次(1 格) for (int c = 0; c < COARSE; ++c) { // ← 粗化循环,4 轮 Nds[ty][tx] = B[bRow * N + col]; // ← N tile 随列变,4 格 __syncthreads(); for (int k = 0; k < TILE; ++k) acc += Mds[ty][k] * Nds[k][tx]; // ← 内层和 K3 一字不差! ... } }
第 1 步 几个输出? p0…p3 四个累加器 → 4 个输出(同一行上,跨 4 个 tile) 第 2 步 内层每轮:几个 FMA、几条 LDS? acc += Mds[ty][k] * Nds[k][tx]; // 和 K3 一字不差 1 个 FMA、2 条 LDS → dist = 1 ÷ 1 = 1.000 bcast = 1 ÷ 1 = 1.000 ← 条数账一分没省,这是 K3c 的全部问题所在 第 3 步 全局 load 摊到每个 FMA? 每阶段每线程搬 1 格 M + 4 格 N = 5 格 每阶段做 4 个 c × 32 轮 = 128 个 FMA → ldg = 5 ÷ 128 = 0.039 // K3 是 0.062 第 4 步 bytesPerOut 每阶段 5 格 ÷ 4 个输出 = 1.25 格/输出 // K3 是 2 格 128 阶段 × 1.25 × 4 字节 + 4 = 644 字节 // 末尾那个 +4 不用再除以 4 —— 输出数和写回格数是 1:1 的: // 每线程 4 个输出 → 写回 4 格 = 16 字节 → 摊到每个输出仍是 4 字节。 第 5 步 AI 8192 ÷ 644 = 12.720 FLOP/byte // K3 是 7.969,提高 1.6 倍 上限 = 12.720 × 1008 = 12823 GFLOP/s 达成率 = 6690.6 ÷ 12823 = 52.2% // AI 越高,这个百分比反而越低 // 6690.6 从哪来 —— 程序里就一行: // double gflops = 2.0 * M * N * K / (ms / 1000.0) / 1e9; // = 2 × 4096³ ÷ 0.020542 s ÷ 1e9,即第 6 步那个耗时的另一种写法 第 6 步 节拍 32 × 128 × 2.52e9 × 0.020542 ÷ 4096³ = 3.09 // K3 是 3.34 第 7 步 每条 load 周期 load/FMA = 1.000 + 1.000 + 0.039 = 2.039 3.09 ÷ 2.039 ≈ 1.51 周期 // K3 是 1.62 第 8 步 模型预测 2.69 × 1.000 + 0.53 × 1.000 + 1.94 × 0.039 = 3.295 实测 3.09,误差 +6.6% // 它没参与标定,这是一次外推
账面结论
K3c 的 AI 涨到 K3 的 1.6 倍(字节账大动),条数账 2.062 → 2.039(几乎没动),实测只快 1.08×。 把第 3、4 步的两行写成一个式子,就能看出粗化省了什么, 以及还剩多少可省。
每阶段、每个输出摊到的格数 = (1 + COARSE) ÷ COARSE
↑ ↑
M 搬 1 格 N 搬 COARSE 格
COARSE = 4:(1 + 4) ÷ 4 = 1.25 格 // K3 相当于 COARSE=1:(1+1)÷1 = 2 格
// 分子那个 1 就是粗化的全部收益:同一行上的 COARSE 个输出共用同一块 M tile,
// 每阶段只搬一次。不粗化的话(4 个独立的 K3 block),这一格要被各搬一次,共 4 次。
// N 那一侧省不掉 —— COARSE 个输出的列号各不相同,每人都得要自己那一格。
再加下去还剩多少?让 COARSE 一路涨:
COARSE → ∞ 时 (1 + COARSE) ÷ COARSE → 1 // M 那 1 格被摊成 0,只剩 N 的 1 格
bytesPerOut → 128 阶段 × 1 格 × 4 字节 + 4 = 516 字节
AI 上限 → 8192 ÷ 516 = 15.876 // ≈ TILE ÷ 2 = 16
COARSE = 4 已经拿到 12.720 = 上限的 80.1%
把 COARSE 从 4 加到无穷,AI 最多再涨约四分之一(15.876 ÷ 12.720 = 1.25)。 已经拿到的 1.6 倍 AI 只换来 1.08× 的速度, 因为字节账本来就不是这条链上的瓶颈。 所以 K5 没有继续压字节账,而是转去砍条数账。
float a0…a7 = 0.0f; // ← 8 个累加器 = 8 个输出 for (int k = 0; k < TILE; ++k) { const float n = Nds[k][tx]; // ← 贵的那条(32 路)只读一次 a0 += Mds[i0 ][k] * n; // ↓ 8 个 FMA 共用它 a1 += Mds[i0 + 1][k] * n; // Mds 这 8 条全是广播(下标无 tx) ... a7 += Mds[i0 + 7][k] * n; }
第 1 步 几个输出? a0…a7 → 8 个输出(同一列上,连续 8 行) 第 2 步 内层每轮:几个 FMA、几条 LDS、各属哪类? 一轮 k 里:1 条 Nds[k][tx](含 tx → 32 路)+ 8 条 Mds[..][k](无 tx → 广播) 8 个 FMA → dist = 1 条 ÷ 8 个 FMA = 0.125 ← 这才是 K4 的全部收益 bcast = 8 条 ÷ 8 个 FMA = 1.000 // 和 K3 一样,没省 第 3 步 ldg 每阶段每线程搬 8 格 M + 8 格 N = 16 格 每阶段 32 轮 × 8 个 FMA = 256 个 FMA → ldg = 16 ÷ 256 = 0.062 // 和 K3 相同 第 4 步 bytesPerOut 每阶段 16 格 ÷ 8 个输出 = 2 格/输出 // 和 K3 一模一样 128 阶段 × 2 × 4 字节 + 4 = 1028 字节 第 5 步 AI 8192 ÷ 1028 = 7.969 FLOP/byte ← 和 K3 的 7.969 逐字相同!字节账一个字都没动 上限 8033,达成率 = 20770.6 ÷ 8033 = 258.6% // 达成率从 K3 的 77.0% 冲到 258.6% —— 不是因为字节变了,是因为速度变了 // 20770.6 怎么来的(唯一一个量出来的数): // 实测 GFLOP/s = 2·M·N·K ÷ 耗时(秒) ÷ 1e9 // 137,438,953,472 ÷ 0.006617 ÷ 1e9 = 20770.6 —— 逐项拆解见 §3·二 // 分子和 K3 是同一个数,分母从 22.225 ms 缩到 6.617 ms: // 20770.6 ÷ 6184.0 = 3.36,正好等于耗时之比 —— 这一步"算得"一点没多,纯粹是跑得快。 第 6 步 节拍 32 × 128 × 2.52e9 × 0.006617 ÷ 4096³ = 0.99 // K3 是 3.34 第 7 步 每条 load 周期 load/FMA = 0.125 + 1.000 + 0.062 = 1.188 0.99 ÷ 1.188 ≈ 0.84 周期 第 8 步 模型预测 2.69 × 0.125 + 0.53 × 1.000 + 1.94 × 0.062 = 0.990 实测 0.99,误差 -0.0% // 它是标定点之一,必然吻合
把 K3c 和 K4 并排放,这一章的主论点就立住了
| 输出/线程 | AI(字节账) | load/FMA(条数账) | 实测 | |
|---|---|---|---|---|
| K3 基准 | 1 | 7.969 | 2.062 | — |
| K3c 横向粗化 | 4 | 12.720 ↑1.6× | 2.039 几乎不变 | 1.08× |
| K4 纵向粗化 | 8 | 7.969 一字未变 | 1.188 ↓ | 3.36× |
同样是"一个线程多算几个输出",K3c 动字节账换来 1.08×,
K4 动条数账换来 3.36×。
区别在于
const float n = Nds[k][tx]; 这一行:它把贵的那类 load 提到 8 个输出的内层循环外面。
K3c 的 4 个输出横向排开,各用 Nds 的不同列,所以提不出来;
K4 的 8 个输出纵向排开,共用同一个 Nds 元素,所以提得出来。
float m[8], n[4], acc[8][4];
for (int k = 0; k < TILE; ++k) {
for (int r = 0; r < 8; ++r) m[r] = Ads[k][i0 + r]; // 8 条广播
for (int c = 0; c < 4; ++c) n[c] = Bds[k][tx + c * 32]; // 4 条 32 路
for (int r = 0; r < 8; ++r)
for (int c = 0; c < 4; ++c)
acc[r][c] += m[r] * n[c]; // 32 个 FMA
}
第 1 步 几个输出? acc[8][4] → 32 个输出(8 行 × 4 列) 第 2 步 内层每轮:几个 FMA、几条 LDS、各属哪类? 一轮 k 里:4 条 Bds[k][tx+c*32](含 tx → 32 路) 8 条 Ads[k][i0+r](无 tx → 广播) 32 个 FMA → dist = 4 条 ÷ 32 个 FMA = 0.125 // 和 K4 相同 bcast = 8 条 ÷ 32 个 FMA = 0.250 ← 这一项 K4 是 1.000,现在也摊薄了 // 每个 m 用 4 次、每个 n 用 8 次 —— 两个方向一起复用,这是 K5 相对 K4 的全部改动 第 3 步 ldg 每阶段每线程搬 8 格 A + 32 格 B = 40 格 每阶段 32 轮 × 32 个 FMA = 1024 个 FMA → ldg = 40 ÷ 1024 = 0.039 // 和 K3c 相同 第 4 步 bytesPerOut 每阶段 40 格 ÷ 32 个输出 = 1.25 格/输出 // 和 K3c 一样 128 阶段 × 1.25 × 4 + 4 = 644 字节 第 5 步 AI 8192 ÷ 644 = 12.720,上限 12823 GFLOP/s 达成率 = 34506.4 ÷ 12823 = 269.1% // 上限 = 算术强度 × 显存峰值带宽(口径见 §2·二) // 带宽 = 10501 MHz × 2(DDR 双沿)× 384 bit ÷ 8 = 1008.1 GB/s // 12.720 × 1008.1 = 12823 GFLOP/s // (程序乘的是未截断的 8192÷644 和 1008.1; // 拿印出来的 12.720 × 1008 手算会差一个零头,那是四舍五入) // 34506.4 是实测吞吐,唯一一个量出来的数: // 实测 GFLOP/s = 2·M·N·K ÷ 耗时(秒) ÷ 1e9 // 137,438,953,472 ÷ 0.003983 ÷ 1e9 = 34506.4 —— 逐项拆解见 §3·二 // 分子和 K4 仍是同一个数,分母从 6.617 ms 缩到 3.983 ms: // 34506.4 ÷ 20770.6 = 1.66,正好等于耗时之比。 第 6 步 节拍 32 × 128 × 2.52e9 × 0.003983 ÷ 4096³ = 0.60 // K4 是 0.99 第 7 步 每条 load 周期 load/FMA = 0.125 + 0.250 + 0.039 = 0.414 0.60 ÷ 0.414 ≈ 1.45 周期 第 8 步 模型预测 2.69 × 0.125 + 0.53 × 0.250 + 1.94 × 0.039 = 0.545 实测 0.60,误差 -9.2% ← 第二个外推点,同样在 ±10% 内
K5 同时动了两本账: 字节账 7.969 → 12.720(和 K3c 同一个手法), 条数账 1.188 → 0.414(把广播也摊薄了)。 占用率从 75.0% 掉到 33.3%, 速度反而快 1.66×:寄存器分块和占用率本来就互相对立。
float4 m01 = ((float4*)&Ads[k][i0])[0]; // 2 条广播覆盖 8 个 m float4 m23 = ((float4*)&Ads[k][i0])[1]; float4 nv = ((float4*)&Bds[k][tx * 4])[0]; // 1 条 32 路覆盖 4 个 n
第 1 步 几个输出? 32 个 // 和 K5 相同 第 2 步 内层每轮 1 条 128 位 32 路 + 2 条 128 位广播,仍是 32 个 FMA → dist = 1 ÷ 32 = 0.031 // K5 是 0.125 bcast = 2 ÷ 32 = 0.062 // K5 是 0.250 第 3 步 ldg 每阶段 10 条 float4 ÷ 1024 个 FMA = 0.010 第 4 步 bytesPerOut 10 条 × 4 个 float = 40 个 float —— 和 K5 的 40 格一模一样 → 644 字节,和 K5 同一个数 // 搬的字节一个没少,只是打包方式变了:40 个 float 从 40 条指令变成 10 条 第 5 步 AI 8192 ÷ 644 = 12.720 ← 和 K5 逐字相同 达成率 = 40928.8 ÷ 12823 = 319.2% // 七个里最高 // 上限 12823 和 K5 也是同一个数 —— 它只由算术强度和带宽决定,和跑多快无关: // 12.720 × 1008.1 GB/s = 12823 GFLOP/s // 带宽口径见 §2·二 // 40928.8 是实测吞吐,唯一一个量出来的数: // 137,438,953,472 ÷ 0.003358 ÷ 1e9 = 40928.8 —— 逐项拆解见 §3·二 // 分子和 K5 仍是同一个数,分母从 3.983 ms 缩到 3.358 ms: // 40928.8 ÷ 34506.4 = 1.19,正好等于耗时之比。 // 所以这一节的三个"逐字相同"(AI、上限、分母)之外,只有耗时在动 —— // 达成率从 269.1% 涨到 319.2%,涨的全是分子。 第 6 步 节拍 32 × 128 × 2.52e9 × 0.003358 ÷ 4096³ = 0.50 // 4090 的物理地板是 0.25 —— K6 是它的 2 倍 第 7 步 每条 load 周期 load/FMA = 0.031 + 0.062 + 0.010 = 0.104 // 每线程每阶段:内层 32 步 × 3 条 128 位读 + 全局 10 条 float4,共 32×32 = 1024 个 FMA // dist = 32 步 × 1 条(Bds[k][tx*4]) ÷ 1024 = 32 ÷ 1024 = 0.031 // bcast = 32 步 × 2 条(Ads[k][i0]、[i0+4]) ÷ 1024 = 64 ÷ 1024 = 0.062 // ldg = A 的 2 条 + B 的 8 条 ÷ 1024 = 10 ÷ 1024 = 0.010 // 合计 106 ÷ 1024 = 0.1035 → 0.104(三个数各自四舍五入再相加会得 0.103,差的是零头) 0.50 ÷ 0.104 ≈ 4.85 周期 ← 突然跳到 4.85,因为"一条"的含义变了:现在一条搬 4 个 float 第 8 步 模型预测 2.69 × 0.031 + 0.53 × 0.062 + 1.94 × 0.010 = 0.136 实测 0.50,误差 -72.8% // 三个单价都是用 32 位 load 标定的,套到 128 位 load 上必然不准
K6 的模型预测只能当下界,不能当预测
用实测反推,128 位 load 的隐含倍率 = 0.50 ÷ 0.136 = 3.68。 即一条 128 位 load 的代价约等于 3.68 条 32 位 load:条数降到 1/4,代价只省了约 8%。
SASS 进一步削弱了这个数字的可信度:
K5 本来就有 64 条 LDS.128,
编译器已经把 K5 三分之一的 shared load 向量化。
所以"K5 全是标量 load"这个前提不成立,K5 的 dist / bcast 被高估了。
真实的访存指令比是 264 ÷ 114 = 2.32 倍,不是条数账算出的 3.98 倍。
3.68 这个倍率混着这部分误差,它是上界,不是干净的单价。
| kernel | 32 路 dist |
广播 bcast |
全局 ldg |
load/FMA | bytesPerOut | 算术强度 | 节拍 周期/warp圈 |
每条 load 周期 |
|---|---|---|---|---|---|---|---|---|
| K1 | 0.000 | 0.000 | 2.000 | 2.000 | 32772 B | 0.250 | 31.83 | 15.91 |
| K2 | 0.000 | 0.000 | 2.000 | 2.000 | 32772 B | 0.250 | 3.88 | 1.94 |
| K3 | 1.000 | 1.000 | 0.062 | 2.062 | 1028 B | 7.969 | 3.34 | 1.62 |
| K3C | 1.000 | 1.000 | 0.039 | 2.039 | 644 B | 12.720 | 3.09 | 1.51 |
| K4 | 0.125 | 1.000 | 0.062 | 1.188 | 1028 B | 7.969 | 0.99 | 0.84 |
| K5 | 0.125 | 0.250 | 0.039 | 0.414 | 644 B | 12.720 | 0.60 | 1.45 |
| K6 | 0.031 | 0.062 | 0.010 | 0.104 | 644 B | 12.720 | 0.50 | 4.85 |
三个单价由 K2 / K3 / K4 三个实测点解三元一次方程组得到。
K2: ldg? × 2.000 = 3.88 → LDG = 1.940
K3: dist? × 1.000 + bcast? × 1.000 + 1.940 × 0.062 = 3.34
K4: dist? × 0.125 + bcast? × 1.000 + 1.940 × 0.062 = 0.99
↓ 两式相减,bcast 消掉
dist? × 0.875 = 3.34 − 0.99 − 0
→ 32 路 = 2.686
→ 广播 = 0.534
// 09-21 那一批解出的是 2.61 / 0.53 / 1.89 —— 换了机器、换了驱动,
// 三个单价的相对关系稳得住,绝对值差 3% 左右。
外推验收:两个没参与标定的点
K3c 误差 +6.6%,K5 误差 -9.2%,都在 ±10% 以内。 条数账只有在这里才可能被证伪:三个点定三个参数,必然吻合; 只有外推到没参与标定的点,模型才算经过检验。 K1(非合并访存)和 K6(128 位 load)在模型的已知边界外, 各缺一个参数,不算模型错。
| kernel | ① 字节账 roofline 达成率 |
② 请求账 请求/FMA |
③ 条数账 load/FMA |
实测节拍 | 条数账预测 | 误差 | 判定 |
|---|---|---|---|---|---|---|---|
| K1 | 257.4% | 33 | 2.000 | 31.83 | 3.880 | -87.8% | 请求账的地盘 |
| K2 | 2112.7% | 2 | 2.000 | 3.88 | 3.880 | +0.0% | 标定点 |
| K3 | 77.0% | — | 2.062 | 3.34 | 3.340 | +0.0% | 标定点 |
| K3C | 52.2% | — | 2.039 | 3.09 | 3.295 | +6.6% | 外推 ±10% 内 |
| K4 | 258.6% | — | 1.188 | 0.99 | 0.990 | -0.0% | 标定点 |
| K5 | 269.1% | — | 0.414 | 0.60 | 0.545 | -9.2% | 外推 ±10% 内 |
| K6 | 319.2% | — | 0.104 | 0.50 | 0.136 | -72.8% | 128 位未标定 |
| 这一步 | 字节账 | 请求账 | 条数账 | 实测 |
|---|---|---|---|---|
| K1→K2 | 不变 0.25 | 33 → 2 | 不变 2.000 | 8.21× |
| K2→K3 | 0.25 → 7.97 | 不变 | 2.000 → 2.062 | 1.16× |
| K3→K3c | 7.97 → 12.72 | 不变 | 2.062 → 2.039 | 1.08× |
| K3→K4 | 不变 7.97 | 不变 | 2.062 → 1.188 | 3.36× |
| K4→K5 | 7.97 → 12.72 | 不变 | 1.188 → 0.414 | 1.66× |
| K5→K6 | 不变 12.72 | 不变 | 0.414 → 0.104 | 1.19× |
原来的说法
"SGEMM 在 4090 上摸不到 FP32 峰值,因为 128×128 分块的算术强度只有 32, 而脊点是 82 —— 剩下的靠 L2 和寄存器复用补。"
实测之后应该这么说
"SGEMM 在 4090 上摸不到 FP32 峰值,不是因为带宽。 K6 的 roofline 达成率是 319%,它早已不在带宽这堵墙下。 真正的墙在 LSU:算力地板是 0.25 周期/warp圈, K6 实测 0.50。差的这一倍,是 3 条 128 位 shared load 占掉的发射槽。 要再往上走,只能继续砍 load 条数(warptiling),或者换张量核心。"
| kernel | N=1024 | N=2048 | N=4096 | N=8192 | 8192/4096 |
|---|---|---|---|---|---|
| K1 | 616 | 648 | 649 | 648 | 1.00 |
| K2 | 5162 | 5325 | 5324 | 4570 | 0.86 |
| K3 | 6449 | 6695 | 6184 | 5875 | 0.95 |
| K3C | 6628 | 6847 | 6691 | 6042 | 0.90 |
| K4 | 17044 | 19861 | 20771 | 14412 | 0.69 |
| K5 | 23599 | 32415 | 34506 | 20536 | 0.60 |
| K6 | 28256 | 39223 | 40929 | 35456 | 0.87 |
| K0 cuBLAS | 39768 | 53024 | 57242 | 58819 | 1.03 |
1024 测的不是显存带宽:它的 footprint 只有 12.6 MB,整个装得进 4090 的 75.5 MB L2。 到 4096(201 MB)才明显超出 L2。 主表取 4096 是折中:够大,能脱离 L2;又不会大到 K1 单次就要约 1.7 秒(8192 上的实测)。
8192 那一列说明两件事
① K6 的价值要到 L2 装不下的尺寸才显现。 K6 比 K5 在 4096 上只快 1.19 倍,在 8192 上快 1.73 倍。
② 在 8192 上,除了本来就慢的 K1(1.00×),只有 cuBLAS 不掉速(1.03×);我们最好的 K6 掉到 0.87,K5 掉到 0.60。K5 这一档三轮的离散度是 28.5%(其余各行都在 1% 以内),0.60 只能当参考。 差距在 double buffering 和更好的 L2 复用策略上。 这比"占 cuBLAS 71.5%"更具体地指出了缺口。
主表全是方阵,但生产里的 GEMM 几乎从来不是方阵。 prefill 是瘦长矩阵;decode 的 M 等于 batch size,常常只有 1。 N/K 取自 Qwen2.5-7B 的 FFN 维度(18944 × 3584)。
| kernel | 方阵基准 4096³ |
prefill 2048×18944×3584 |
decode-b32 32×18944×3584 |
decode-b1 1×18944×3584 |
|---|---|---|---|---|
| K0 cuBLAS | 57506 | 55586 | 13883 | 450 |
| K1 | 648 | 645 | 570 | 127 |
| K2 | 5320 | 4568 | 4057 | 284 |
| K3 | 6167 | 5806 | 5094 | 159 |
| K3C | 6678 | 5785 | 3274 | 104 |
| K4 | 20840 | 14186 | 14247 | 453 |
| K5 | 34558 | 20916 | 12668 | 407 |
| K6 | 41112 | 31743 | 13537 | 426 |
| 占 cuBLAS % | ||||
| K4 | 36.2% | 25.5% | 102.6% | 100.7% |
| K5 | 60.1% | 37.6% | 91.3% | 90.4% |
| K6 | 71.5% | 57.1% | 97.5% | 94.7% |
三个结论
① 「优化了 63.1 倍」只在方阵上成立。(63.1 倍来自主表;下面的形状扫描是另一次运行,方阵测得 63.4 倍。)
方阵 4096³ K1 → K6 63.4× prefill K1 → K6 49.2× decode b32 K1 → K6 23.8× decode b1 K1 → K6 3.4× ← 只剩零头
② decode 上 K6 反而比 K4 慢(0.95×)。 K6 比 K4 多出的改进全部用来砍指令条数,而 decode 撞的是带宽墙。 所以砍条数没用;M=1 时,更大的 tile 还会浪费更多线程。 这证实了"三本账只在算力受限一侧有效"。
③ decode 上我们几乎追平 cuBLAS,K4 还超过了它(102.6%)。 这和事前预测正好相反。原因是带宽墙对谁都一样高。 decode 的瓶颈是把 18944×3584 的权重完整读一遍,谁做都要付同样的读显存时间, cuBLAS 精巧的分块调度用不上。 cuBLAS 的优势只在算力受限一侧兑现:prefill 上它还保有方阵成绩的 96.7%,我们的 K6 只有 cuBLAS 的 57.1%。
给下一阶段的接口
cuBLAS decode b1 M=1 0.302 ms 449.6 GFLOP/s
decode b32 M=32 0.313 ms 13882.7 GFLOP/s
活多了 32×(M 从 1 变成 32),耗时只多 1.04×
→ 实测吞吐比 = 13882.7 ÷ 449.6 = 30.9× // = 32 ÷ 1.04
batch 32 和 batch 1 的耗时几乎相同,吞吐却是 batch 1 的 30.9 倍。 这是 continuous batching 必须存在的量化理由。 它也对上了 01 阶段在 vLLM 吞吐曲线上看到的宏观现象:那边是现象,这里是机制。 注意 30.9 不是 32: 耗时多出的那 1.04× 零头,正是"批到多大就开始付钱"的起点。
N=4096(2 的幂) N=4097(打破对齐) K1 211.911 ms 63.675 ms ← 快了 3.33 倍 K1/K2 8.209× 2.392× 8.209 ÷ 2.392 = 3.43 ← 2 的幂跨度的额外惩罚 回乘检验:2.392 × 3.432 = 8.209(3.43 是 3.432 四舍五入后的值)
N=4096 时,A 的行跨度恰好是 16384 字节 = 2¹⁴;N=4097 时变成 16388。 两次运行只改了这一个变量。 所以"合并访存是性价比最高的一步"要加注: 那 8.21 倍里,只有 2.392 倍来自合并本身,另外 3.43 倍来自顺带治好的跨度冲突。 对照组:同样从 4096 改到 4097,K2–K5 只慢 2.8%–7.1%,cuBLAS 慢 15.7%。K6 用 float4,要求 N 是 4 的倍数,所以测的是 4100,慢 7.5%。
机制没查出来,如实标注
"跨度是 2 的幂"是起因,这一点有单变量对照,靠得住。 但具体是哪个硬件结构在冲突,我们指认不了。
// 最顺手的解释是"warp 内 32 个地址撞同一个 L2 set"。把地址逐位算一遍: 行号(addr÷128) 行内偏移 sector 效率 N=4096 base + 128t 恒为 0 12.5% N=4097 base + 128t 0,4,8,… 12.5% ↑ 完全相同 ↑ 完全相同 // 16388 = 2¹⁴ + 2²,多出来的只影响第 2–6 位 —— 那是 128 字节行的【内部】, // 不参与任何 set / bank 的选择。所以那个解释解释不了 3.33 倍。
还剩两个候选:L2 slice 哈希退化,或 DRAM row buffer 颠簸。 判据:sector 数相同而 DRAM 吞吐差很多,就是后者; sector 数本身差很多,就是前者。 这两个指标都要用 ncu 测,这一批没有(见 §7)。 所以这里留作悬案,不给一个只是听起来合理的解释。 完整推导见第 6 章导学 §5 · 六。
kernel FFMA LDS.128 LDS.32 LDG.128 LDG.32 TOTAL k1 30 - - - 59 216 k2 30 - - - 59 216 ← 和 k1 逐项相同 k5 1056 64 128 - 72 1856 ← 预测说这里该是 0 k6 1056 96 - 18 - 1520
| 预测 | 结果 | 意味着什么 |
|---|---|---|
K6 的 LDS.128 > 0 | 成立 | float4 确实编成了 128 位访存,K6 那一步推导的前提成立 |
K5 的 LDS.128 = 0 | 推翻 | K5→K6 不是"标量 vs 向量化",是"部分 vs 完全向量化"。 实际指令比是 2.32×,不是手推的 3.98× |
K6 的 LDG.128 > 0 | 成立 | global memory 一侧也向量化了 |
LDL/STL = 0 | 成立 | 没有 spill,七个 kernel 比的就是 kernel 本身 |
如实标注:这一项失败了
==ERROR== ERR_NVGPUCTRPERM - The user does not have permission to access
NVIDIA GPU Performance Counters on the target device 0.
租用实例的容器没有 CAP_SYS_ADMIN,用 root 也没用。
这个能力在 docker run 创建容器时就定死了,
容器内部改不了。实测 CapEff = 0xa80405fb,和 Docker 的默认能力集(0xa80425fb)只差一个 CAP_NET_RAW,同样不含 CAP_SYS_ADMIN。
好在自检起了作用: 编译完成后 5 秒,脚本就探明了这件事,跳过了后两步。 前三步(SASS / 计时主表 / 形状扫描)的数据一个没丢。
| ncu 指标 | 它回答什么 | 有没有替代 |
|---|---|---|
| launch__registers_per_thread | 寄存器超预算没有 | 有 ptxas -v 编译时就打印了 |
| sm__warps_active | 理论占用率兑现没有 | 有 cudaOccupancyMaxActiveBlocksPerMultiprocessor |
| l1tex__data_bank_conflicts | shared memory 排布踩雷没有 | 半有 可从 SASS 的访问模式推 |
| sm__throughput | 算力用满没有 | 半有 「节拍」是它的等价物 |
| gpu__dram_throughput | 带宽用满没有 | 没有 |
| average_data_bytes_per_sector | 合并做对没有 | 没有 |
最后那个指标要证明"K1 的 8.21 倍劣势来自访存不合并"。没有它,这件事照样能证明:
| 证据 | 证明了什么 | |
|---|---|---|
| ① | K1 和 K2 的 SASS 逐项相同 | 8.21 倍里指令条数贡献严格为零,100% 是访存行为 |
| ② | 请求账 33 → 2 请求/FMA | 机制:一条 LDG 被拆成 32 个请求 vs 合并成 1 个 |
| ③ | N=4097 把 8.209× 拆成 2.392× × 3.43×,回乘对得上 | 进一步分出"不合并"和"2 的幂跨度"各占多少 |
实测 nsys 在这种容器里能跑(CUDA 时间线可用)。
但它的 --gpu-metrics-devices 走同一条权限路径,同样报 ERR_NVGPUCTRPERM。
# 测试机(RTX 4090) nvcc -O3 -arch=sm_89 -lineinfo -Xptxas -v sgemm_all.cu -o sgemm_all -lcublas export NVIDIA_TF32_OVERRIDE=0 bash run_p0.sh # 九个阶段,50~80 分钟 # 本地 python parse_results.py all_*.txt shapes_*.txt # 文本 → CSV
| 文件 | 是什么 |
|---|---|
| src/sgemm_all.cu | 八个 kernel + 测试脚手架(设备查询、占用率、三本账、抽样校验、中位数计时) |
| Scripts/run_p0.sh | 总驱动。顺序不能改:SASS 不占 GPU,放最前;两步计时排中间;ncu 放最后 |
| Scripts/run_all.sh | 计时引擎:kernel × 尺寸 × 轮次,含正确性、边界、racecheck、时钟采样 |
| Scripts/sweep_shapes.sh | 四组形状扫描 |
| Scripts/check_sass.sh | 按 kernel 分类统计反汇编指令。只用 CPU |
| Scripts/parse_results.py | 文本 → 两份 CSV:逐轮的是证据,中位数的是结论 |
| Results/Round1/ | 原始输出、时钟采样、SASS 全文、解析后的 CSV、环境指纹 |
注意
报告里的每个数都来自 Results/Round1/ 这一批,
没有跨批次拼接。
三个单价也用本批的 K2/K3/K4 现场标定,
没有沿用任何历史常数。
理由见 §2 · 一第一行:
绝对耗时跨批漂 2–3%,而本报告一半的结论建立在比值上。