过去一个多月我基本都在和一段矩阵乘法较劲。起因是学弟给我看他的 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 连续变化,所以这 32 个线程访问的是连续地址,合并访存做得很好。但 A 的访问是 A[row * K + k],同一 warp 里存在两个不同的 row,而每个 row 之间相隔 K 个 float,于是 warp 的访存请求被拆成两部分,产生多余的 memory transaction。
更麻烦的是数据复用完全没写出来。A 的同一行会被 N 个输出元素用到,B 的同一列会被 M 个输出元素用到。理论上这些数据应该反复被利用,但朴素版本里每个线程每次循环都从全局内存读一遍,只能指望 L1/L2 cache 做隐式复用。Cache 确实会起作用,但它的复用策略不受你控制,数据什么时候被踢掉、被多少线程命中,都是黑盒。
只有当你显式地把复用数据放进共享内存,性能才能真正上一个台阶。
3. Shared Memory + Tiling:把访存次数压下去,性能才有机会上来
3.1 Shared Memory 是 GPU 的“灶台”,不是“仓库”
理解 shared memory 最简单的方式是类比做菜。全局内存是冰箱,冷冻区很大但存取很慢,shared memory 是案板,容量小但随拿随用。矩阵乘法里,一个输出 tile 的计算需要反复使用对应的 A tile 和 B tile,如果每次都去冰箱拿,大部分时间都花在路上。正确做法是:先把这一轮需要的食材一次性搬到案板上,再开始炒菜。
这就是 tiling 优化的核心:一个线程块负责计算输出矩阵中一个 TILE×TILE 的小块,每次从全局内存加载对应的 A tile 和 B tile 到 shared memory,块内所有线程反复使用这些数据。共享内存的容量有限,但延迟远低于全局内存,而且通过 __syncthreads() 控制同步后,数据的复用完全是可控的。
3.2 16×16 Tile 版本的完整写法
一个带边界处理的 shared memory 版本大概长这样:
cuda复制#define TILE 16
__global__ void matmul_tiled(
const float* __restrict__ A,
const float* __restrict__ B,
float* __restrict__ C,
int M, int N, int K)
{
const int bx = blockIdx.x;
const int by = blockIdx.y;
const int tx = threadIdx.x;
const int ty = threadIdx.y;
const int row = by * TILE + ty;
const int col = bx * TILE + tx;
__shared__ float As[TILE][TILE + 1];
__shared__ float Bs[TILE][TILE + 1];
float sum = 0.0f;
for (int k0 = 0; k0 < K; k0 += TILE) {
if (row < M && k0 + tx < K)
As[ty][tx] = A[row * K + k0 + tx];
else
As[ty][tx] = 0.0f;
if (k0 + ty < K && col < N)
Bs[ty][tx] = B[(k0 + ty) * N + col];
else
Bs[ty][tx] = 0.0f;
__syncthreads();
#pragma unroll
for (int k = 0; k < TILE; ++k) {
sum += As[ty][k] * Bs[k][tx];
}
__syncthreads();
}
if (row < M && col < N)
C[row * N + col] = sum;
}
这段代码里有两个 __syncthreads()。第一个保证所有线程都把数据写进 shared memory 之后,才开始读;第二个保证所有线程都读完了,下一轮循环才能安全地覆盖 shared memory 里的旧数据。
很多人会漏掉第二个 __syncthreads(),然后跑出随机错误,这是很典型的入门坑。
3.3 为什么 shared memory 数组要声明成 [TILE][TILE + 1]
这个 +1 是我特别想强调的细节。shared memory 在硬件上分成 32 个 bank,每个 bank 每周期可以服务一个地址。如果一行正好是 16 个 float,那么某些访问模式下,同一 warp 的多个线程会撞到同一个 bank,发生 bank conflict,硬件会把一次访问拆成多次,性能直接打折。
把行宽从 16 改成 17,等于给每一行末尾加了一个 padding,让行与行在 bank 上错开。这样做只多用一点点共享内存,却经常能把 bank conflict 彻底消掉。这个细节在 TILE=16 或 TILE=32 这类 2 的幂尺寸下特别重要。
不过 padding 也不是万灵药。TILE 大小本身就有取舍:TILE 越大,数据复用越高,但 shared memory 占用也越高,块内线程数也会变多,可能压低占用率。我在 RTX 3060 上试过 32×32 的 tile,shared memory 占用直接翻倍,一个 block 就能占满一个 SM 的线程槽位,结果反而更慢。后来回到 16×16 tile,再用寄存器分块继续压榨计算效率。
3.4 改成 shared memory 之后,性能发生了什么变化
同样 1024×1024 矩阵,这个版本耗时大约 0.65ms,算下来 3.3 TFLOPS。相比朴素版本的 1.34 TFLOPS,提升了约 2.5 倍。ncu 显示 DRAM 吞吐从接近 90% 降到了 40% 左右,SM 吞吐开始上来了。这说明瓶颈正在从“显存带宽”往“计算/共享内存访问”转移,接下来要针对新的瓶颈继续优化。
4. 用 Nsight Compute 剖析瓶颈:指标怎么读、坑怎么避
4.1 比 printf 好用的方式:ncu 命令速查
很多刚接触性能优化的人喜欢在 kernel 里加 clock64() 或者 printf 打点,这样也能看个大概,但信息太碎。NVIDIA 官方的 Nsight Compute(ncu)更适合系统性地看瓶颈。
常用命令是这样:
bash复制# 只分析一次 kernel 启动,避免 ncu 反复重跑
ncu --launch-count 1 -k matmul_tiled ./sgemm
# 只挑几个关心的 metric
ncu --launch-count 1 \
-k matmul_tiled \
--metrics gpu__time_duration.sum,\
dram__throughput.avg.pct_of_peak_sustained_elapsed,\
sm__throughput.avg.pct_of_peak_sustained_elapsed,\
l1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_ld.sum \
./sgemm
如果 kernel 名太怪,可以用正则 -k regex:matmul 匹配。还有 nsys profile 可以看整个程序的 CPU/GPU timeline,适合先看 kernel 有没有等待、数据拷贝是不是可以重叠。
4.2 核心指标怎么读
刚开始不需要把 ncu 所有 Section 都看一遍,先盯几个关键指标:
| 指标 | 含义 | 优化到什么程度算合理 |
|---|---|---|
gpu__time_duration.sum |
kernel 实际执行时间 | 最终优化效果的硬指标 |
sm__throughput.avg.pct_of_peak_sustained_elapsed |
SM 计算流水线利用率 | 越高说明计算越饱和 |
dram__throughput.avg.pct_of_peak_sustained_elapsed |
显存带宽利用率 | 高说明 memory bound |
l1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_ld.sum |
shared memory load 的 bank conflict 次数 | 理想情况是 0 |
sm__warps_active.avg.pct_of_peak_sustained_active |
每个 SM 平均活跃 warp 数 | 反映 latency hiding 能力 |
我的习惯是先看 dram__throughput 和 sm__throughput。如果 DRAM 高、SM 低,说明代码是访存密集,下一刀应该砍访存;如果 SM 高、DRAM 不高,说明计算或 shared memory 指令已经是瓶颈,可以考虑寄存器分块、减少冗余指令、降低 bank conflict。
4.3 一个让我“当场打脸”的优化教训
有段时间我迷信 TILE 越大越好,把 tile 从 16 改到 32,觉得全局内存访问次数少了,肯定更快。结果 kernel 不但没变快,反而慢了 10% 以上。ncu 一看,sm__warps_active.avg.pct_of_peak_sustained_active 只有 15% 左右,因为 32×32 的 block 是 1024 个线程,SM 能同时驻留的 block 数很少,线程块之间的 latency hiding 能力被掏空了;shared memory 又占得多,多个 block 根本塞不进去。
后来我改成 16×16 tile,每个线程算 2×2 的输出子块,用寄存器把数据复用起来。block 内线程数回到 256,同一个 SM 可以驻留更多 block,ncu 里的 active warp 数明显回升,时间才真正降下来。
所以性能剖析的核心逻辑不是“把某个指标调到 100%”,而是先判断当前瓶颈在哪个子系统,再针对它动手。调完一版立刻重新 profile,看瓶颈会不会转移到新的地方。优化是一个不断追着瓶颈跑的过程。
5. 再进一步:寄存器分块、向量加载,以及一次假优化教训
5.1 shared memory 版本的瓶颈转移到了 LSU 和共享内存带宽
shared memory tiling 解决的是“全局内存访问太频繁”的问题。但 16×16 tile 版本里,每个线程计算一个输出元素,内层循环每做一次 FMA,就要读一次 As、一次 Bs,也就是两个 shared load 才换来一个 FMA。冗长的循环里,LSU(load-store unit)和 shared memory 带宽反而先被吃满了。
这也是为什么真正的高性能 GEMM 一定会做寄存器分块:让一个线程算多个输出元素,把从 shared memory 里读出来的数据多次使用。这就像从案板上拿一次食材,可以同时炒好几道菜。
5.2 用 2×2 或 4×4 寄存器分块降低每次 FMA 的访存指令数
寄存器分块的示意代码大概是这种感觉:
cuda复制float c00 = 0.0f, c01 = 0.0f;
float c10 = 0.0f, c11 = 0.0f;
for (int k = 0; k < TILE; ++k) {
float a0 = As[ty * 2 + 0][k];
float a1 = As[ty * 2 + 1][k];
float b0 = Bs[k][tx * 2 + 0];
float b1 = Bs[k][tx * 2 + 1];
c00 += a0 * b0;
c01 += a0 * b1;
c10 += a1 * b0;
c11 += a1 * b1;
}
2×2 分块下,4 个 FMA 对应 4 个 shared load,等于每 FMA 一个 load。如果做到 4×4 分块,8 个 load 可以喂给 16 个 FMA,每 FMA 只要 0.5 次 shared load。数据复用的效率一下子就上来了。
代价是寄存器占用会变高。如果分块太大,寄存器溢出到 local memory,性能反而崩。通常消费级显卡上,4×4 到 8×8 是在寄存器压力、占用率、代码复杂度之间比较均衡的范围。
5.3 float4 向量加载和 __restrict__ 让编译器把指令数再降一截
除了寄存器分块,还有一个容易拿到的优化点:向量化内存访问。把全局内存读取改成 float4,一次指令搬 4 个 float,可以减少指令条数、提高访存效率。
cuda复制const float4* A4 = reinterpret_cast<const float4*>(A);
const float4* B4 = reinterpret_cast<const float4*>(B);
这种做法要求矩阵的内存是 16 字节对齐的,K 也最好是 4 的倍数。如果你发现自己写的 kernel 在 K 不是 4 的倍数时性能断崖式下降,大概率就是没处理向量化边界。
另外,kernel 参数里的 const float* __restrict__ A 也非常重要。__restrict__ 告诉编译器 A、B、C 三个指针不会指向同一块内存,编译器才能放心做重排、向量化,甚至生成只读缓存相关的指令。很多第一次写 CUDA 的人会忽略这个关键字,结果同一个 kernel,只是加个 __restrict__,性能就差 5% 到 10%。
5.4 假优化案例:盲目加并行度,反而掉回 memory bound
前面提到的 32×32 tile 算是一次假优化。还有一个更隐蔽的版本:我当时为了让每个线程只算一个输出,把 block 设成 32×32,以为线程多了并行度就高,结果事与愿违。
ncu 给出的 profile 数据很清晰:sm__throughput 只有 50% 左右,dram__throughput 更低,active warp 数也很差。问题不是计算不够,而是 block 太大,SM 能同时调度的 block 太少,延迟没法被隐藏。
从那以后我养成了一个习惯:每次改完布局,先看 active warp 数和寄存器/共享内存使用量,再谈峰值算力。如果硬件根本没有足够的 warp 在“排队”,再高的理论并行度也是纸面数据。
6. 和 cuBLAS 对比之后,手写 GEMM 还剩什么价值
6.1 实测数据:从朴素到寄存器分块再到 cuBLAS
我在 RTX 3060 上做的一组对比,矩阵规模 1024×1024,FP32,所有结果都按 2*M*N*K 算 FLOPs:
| 版本 | 耗时 | 折算算力 | 说明 |
|---|---|---|---|
| Naive 1 thread per element | 1.60 ms | 1.34 TFLOPS | 正确但内存瓶颈严重 |
| Shared memory 16×16 | 0.65 ms | 3.30 TFLOPS | 数据复用开始生效 |
| Shared + 寄存器分块 | 0.42 ms | 5.11 TFLOPS | 降低 shared 带宽压力 |
| 再加上向量加载与 restrict | 0.32 ms | 6.71 TFLOPS | 指令数进一步降低 |
| cuBLAS SGEMM | 0.18 ms | 11.93 TFLOPS | 接近这张卡的 FP32 峰值 |
手写版本追到 cuBLAS 的一半到六成,我觉得已经是一个很健康的学习进度了。cuBLAS 背后是 NVIDIA 工程师专门针对各种架构调过的 kernel,还会根据矩阵规模、对齐方式、内存布局自动选择不同的实现,普通项目没有必要从零手写一个超越它的 GEMM。
但手写 GEMM 的价值从来没有消失。它让我真正理解了 shared memory 是什么、bank conflict 怎么产生、寄存器为什么能缓解内存带宽压力。这些能力迁移到卷积、Attention、算子融合上时特别有用。
6.2 动手优化前先确认 CUDA 环境,避免“内核编译了但设备用不了”的玄学报错
如果你还没跑起来,先别急着优化。很多入门者遇到的问题不是 kernel 逻辑错,而是 CUDA 环境匹配问题。
最基础的三条检查命令:
bash复制nvidia-smi # 看驱动版本和驱动支持的 CUDA version
nvcc --version # 看 CUDA Toolkit 版本
./deviceQuery # 看显卡 compute capability,比如 sm_86
编译时最好显式指定架构,比如 RTX 30 系用 -arch=compute_86 -code=sm_86。如果你不指定 arch,有时候 nvcc 会生成一个比较保守的 PTX 版本,运行时 JIT 又没有合适的驱动支持,就会报类似 no kernel image is available for execution on the device 的错误。这个错误跟代码本身没关系,纯粹是编译目标和硬件不匹配。
如果只是跑 PyTorch 这类框架,不需要自己装 CUDA Toolkit,PyTorch 自带了 CUDA runtime。但你要是自己写 CUDA kernel 并且要链接 cuBLAS/cuDNN,toolkit 版本和驱动支持的版本就要对齐,不然容易出现运行时 libcublas 版本不对导致的 cublas_status_execution_failed 之类的问题。
6.3 这套优化思路能平移到哪里
矩阵乘法只是载体。tiling、合并访存、bank conflict 规避、寄存器分块、向量化加载、占用率与延迟隐藏,这套方法论几乎适用于所有访存密集的算子。
我看 CUTLASS 里的 kernel 源码时,最大的感受就是:它把上面这些手段全部“工业化”了。CUTLASS 会拆成 GMMA / TMA / warp-level cooperative fetch 等更复杂的机制,但底层的思考路径仍然是:数据放哪、复用几次、每条指令喂给多少个计算单元。先把手写 GEMM 的完整优化链路走一遍,再看那些高性能模板库,会轻松很多。
7. 写在最后:性能优化的三个硬性习惯
文章写到这里,最后分享三个我踩过不少坑之后才养成的硬性习惯。
第一,每次改 kernel 都要重新跑一遍正确性检查。矩阵乘法很适合用 CPU 参考实现做对拍,随机生成几百个不同形状的矩阵,比较误差。否则优化半天,结果算出来是错的,所有性能数字都没有意义。
第二,每改一版,必须留一份 ncu profile 报告。不要只在最后看一次总时间。如果你说不清这次优化快在哪、瓶颈转移到了哪里,那这个“优化”很可能只是另一种形式的运气。性能优化如果不是建立在剖析数据上,很容易被一两组偶然的 benchmark 带偏。
第三,先优化访存结构,再打开激进编译器选项。-O3、--use_fast_math 当然有用,但它们不能替代数据复用和访存模式的设计。如果 kernel 本来就是 memory bound,编译器再激进也救不了。
我自己的体会是,手写 GEMM 这件事,最大的回报不是跑出一个接近 cuBLAS 的数字,而是让你在性能问题面前不再靠猜。学会用 CUDA 的硬件视角看问题之后,再回去看很多曾经觉得“玄学”的性能问题,都会变得特别具体:无非就是数据放在哪、一个 warp 在干什么、下一轮循环还需要什么。希望这篇实战记录,能帮你少走一些弯路。
