过去一个多月我基本都在和一段矩阵乘法较劲。起因是学弟给我看他的 CUDA 入门项目:一个很标准的 SGEMM kernel,在 RTX 3060 上跑 1024×1024 矩阵,耗时 1.6ms 左右,他自己觉得“还挺快”。结果我拿 cuBLAS 一对比,0.17ms 做完,差距接近十倍。再让 Nsight Compute 跑一轮 profile,SM 计算单元的利用率只有 17%,显存带宽却快被拉满了。
这个问题很有代表性。很多刚接触 GPU 编程的人以为“把循环拆给几千个线程”就算并行化,实际上并行度、访存模式、数据复用、内存延迟隐藏,每一环都可能变成瓶颈。这篇文章就用矩阵乘法这条线,把从朴素 kernel 到 shared memory 分块、寄存器分块,再到 ncu 性能剖析的完整过程捋一遍。内容主要围绕 GPU编程、CUDA、并行矩阵乘法、性能优化和性能剖析 这几个关键词展开,适合刚开始写 CUDA、或者已经写过一些 demo 但不知道怎么继续优化的人。
1. 为什么 GEMM 是 CUDA 新手的第一道分水岭
1.1 GEMM 的数学形态和 CUDA 线程模型刚好能对上
矩阵乘法 C(M×N) = A(M×K) × B(K×N),每个输出元素 C[i][j] 都是 K 次乘累加的结果,而且不同位置的输出彼此独立。这个性质完美匹配 GPU 的并行模型:你可以把每个输出元素的计算分给一个线程,也可以让一个线程算一小块输出矩阵。
CUDA 的层级关系在这里体现得很直接:gridDim 决定把输出矩阵切成多少块,blockDim 决定块内线程如何协作,warp(32 个线程)是硬件真正调度和执行的基本单位。矩阵乘法天然就是块与块之间互相独立、块内又需要反复读取相同数据,所以它很适合用来理解 GPU 编程的三个核心概念:线程组织、显存访问、数据复用。
1.2 不要只盯着 TFLOPS,先算一算算术强度
很多教程喜欢直接甩峰值算力,比如 “这张卡有 12 TFLOPS”,然后让你把矩阵乘法跑到接近这个值。但实际优化前,更值得算的是算术强度,也就是“平均每读一个字节的数据,能做多少次浮点运算”。
对于 naive GEMM,如果不做任何 tiling,每个输出元素要读 K 个 A 的元素和 K 个 B 的元素,假设 float 是 4 字节,算术强度只有约 0.25 FLOP/Byte。RTX 3060 的 FP32 峰值算力大约 12.7 TFLOPS,显存带宽大约 360 GB/s,两者的交点,也就是 roofline 上的 ridge point,大约在 35 FLOP/Byte 附近。算法算术强度远低于这个值时,性能瓶颈一定是显存带宽,而不是计算单元。
这也解释了一个新手常有的困惑:为什么看着一张卡算力很高,实际 kernel 却跑得很慢?因为代码根本没有喂足够的“可复用数据”给计算单元,GPU 大部分时间都在等内存搬数据。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先写一个“能跑”的朴素内核:正确性只是及格线
2.1 一版最容易想到的代码
我学弟写的大概是这个样子的:
cuda复制__global__ void matmul_naive(
const float* A,
const float* B,
float* C,
int M, int N, int K)
{
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
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;
}
}
调用方式也很标准:
cuda复制dim3 block(16, 16);
dim3 grid((N + 15) / 16, (M + 15) / 16);
matmul_naive<<<grid, block>>>(A, B, C, M, N, K);
这个版本逻辑完全正确,边界条件也考虑了。但正确性只是及格线。把它跑在 RTX 3060 上,1024×1024 矩阵 FP32,耗时大约 1.6ms,算下来只有 1.34 TFLOPS,大约是峰值算力的 10%。
2.2 一个 warp 的访存行为是怎么被“拆散”的
要理解为什么慢,得看一个 warp 里 32 个线程在内存层面做了什么。
以 16×16 线程块为例,一个 warp 包含 threadIdx.x 从 0 到 15、threadIdx.y 从 0 到 1 的 32 个线程。内层循环里,B 的访问是 B[k * N + col],col 连续变化,所以这
