做过几年GPU高性能计算的人应该都有这种感觉:矩阵乘法是你绕不开的第一道坎,也是最能给你成就感的那个坎。刚开始接触CUDA的时候,我照着教程写了一个最朴素的矩阵乘法kernel,一跑发现确实比CPU快了不少,可等打开Nsight Compute一看性能数据,又觉得自己写了个寂寞——内存吞吐低得可怜,SM利用率更是不忍直视。后来花了几周时间把分块、共享内存、向量化、循环展开这些优化手段逐个试了一遍,性能才真正有了质的变化。这个过程里踩过的坑、调过的参数、用过的工具,我觉得很值得拿出来聊一聊。
这篇文章就从一个完整的实战角度,带你走一遍基于CUDA的并行矩阵乘法优化全流程:从环境搭建到朴素实现,从共享内存分块到性能剖析,每一步我都会讲清楚为什么这么做、数据上有什么变化。如果你刚开始学CUDA编程,或者已经在写kernel但不知道该怎么优化,这篇文章应该能给你一套可以直接照搬的优化思路和排查手段。
1. 为什么选择矩阵乘法切入CUDA优化
1.1 矩阵乘法在高性能计算中的分量
先说个大家都有体感的事实:矩阵乘法(GEMM,General Matrix Multiply)几乎撑起了现代计算生态的半边天。深度学习里的全连接层、卷积操作,底层都要落到GEMM;科学计算里的线性代数求解器、有限元分析、流体仿真,核心热区也全是矩阵乘法;就连图像处理里的各种变换操作,本质上也离不开它。领域里流行一句话:优化好GEMM,你就优化好了半个HPC世界。
从计算本身来看,矩阵乘法天然具备极高的并行潜力。一个 M×K 的矩阵 A 和一个 K×N 的矩阵 B 相乘,结果是 M×N 的矩阵 C,整个过程要做 MNK 次乘加运算,也就是 2MN*K 次浮点操作。而输出元素之间彼此独立——C[i][j] 只依赖 A 的第 i 行和 B 的第 j 列,没有任何写冲突。这种"输出多、依赖少、并行粒度大"的特征,正好是GPU最擅长的场景。
更重要的是,矩阵乘法的计算强度很高。计算强度 = 浮点操作次数 / 访存字节数。以 K 较大的矩阵乘法为例,做一次乘加需要从A读一个float、从B读一个float、往C写一个float(共12字节),但只产生2次浮点操作,计算强度大约只有0.17 FLOP/byte。如果不做任何优化,这个计算强度远低于GPU的理论平衡点,最终性能会被访存卡死。这就逼着你去思考:怎么复用数据、怎么利用片上存储,而这些思考恰恰是CUDA优化最核心的能力训练。
1.2 GPU架构核心概念速览
在动手写代码之前,我强烈建议先把GPU的硬件模型弄明白,不然很多性能问题你根本不知道往哪想。
先看最宏观的对比。CPU芯片上晶体管大量分配给控制逻辑和缓存,核心数量少但单核能力强,适合处理复杂的控制流;GPU则把晶体管几乎全放在了计算单元上,用几千个简单的CUDA Core以量取胜,适合大规模数据并行计算。
在NVIDIA GPU里,最基本的物理计算单元是流多处理器(SM,Streaming Multiprocessor)。SM内部包含若干CUDA Core、共享内存、寄存器文件和调度器。当你启动一个kernel时,线程会被组织成网格(Grid)和线程块(Block),块内的线程会进一步每32个分成一组,叫作一个Warp——这是GPU执行和调度的最小单位。Warp中的所有线程在同一时刻执行同一条指令,如果有线程走了不同分支,就会出现分支发散,多条路径串行执行,白白浪费算力。
内存层次也是理解优化手段的关键。全局内存(Global Memory)容量最大,但延迟通常有几百个时钟周期;共享内存(Shared Memory)位于SM内部,容量只有几十KB,但延迟极低,和寄存器差不多;寄存器(Register)是最快的存储,但数量有限且只属于单个线程。优化矩阵乘法的核心思路之一,就是尽量把数据从慢速的全局内存挪到快速的共享内存或寄存器里,降低重复访存开销。
还有一个基础概念叫合并访问(Coalesced Access)。当同一个Warp内32个线程同时访问全局内存时,如果地址是连续的一段,硬件会把这些访问合并成少数几个内存事务,一次搞定;如果地址是跳着来的,就要拆成多个事务,访存效率急剧下降。这个概念在第4章会反复用到,先记住它。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与工具链选型
2.1 CUDA版本与驱动关系的坑
开始写代码之前,得先把开发环境搞利索。这一步看着简单,实际坑不少,尤其是版本对应关系。CUDA有两个关键组件:显卡驱动(Driver)和CUDA Toolkit。Toolkit里包含编译器nvcc、CUDA运行时库、各种开发工具,而驱动是运行CUDA程序的底层支撑。你装的是CUDA 12.x,驱动版本必须不低于该版本要求的最低值,否则程序跑起来会直接报"CUDA driver version is insufficient"之类的错误。
如果你在Linux服务器上工作,还可能会遇到多版本CUDA并存的情况。不同项目可能依赖不同版本,比如旧代码用了CUDA 10的API,新框架又要求CUDA 12。我的做法是安装多个Toolkit到不同目录,通过修改环境变量切换版本:
bash复制export PATH=/usr/local/cuda-12.4/bin:$PATH
export LD_LIBRARY_PATH=/usr/local/cuda-12.4/lib64:$LD_LIBRARY_PATH
export CUDA_HOME=/usr/local/cuda-12.4
用软链接 /usr/local/cuda 指向当前要用的版本目录,切换的时候只改软链接或环境变量就行,不用反复重装。这个方案我用了很久,很稳。
Windows用户如果有WSL2环境,同样可以装CUDA。WSL2里装CUDA Toolkit时,驱动是Windows侧提供的,Linux里只需要装Toolkit本身。微软和NVIDIA对这条路支持得已经很成熟了,跑PyTorch、TensorFlow的GPU版本都没问题。要注意的是WSL2里nvidia-smi和Windows侧的驱动是同一个,版本以Windows侧为准,而nvcc的版本看Linux里的Toolkit。
2.2 性能剖析工具的选型与配合
优化CUDA程序,光靠肉眼和预感是不行的,必须用profile工具拿数据说话。这里有个重要变化:老牌的nvprof在CUDA 10之后被标记为弃用,CUDA 12中已经完全移除了,现在官方主推的是NVIDIA Nsight Compute(简称ncu)和Nsight Systems(nsys)。
这两个工具分工不一样。Nsight Systems负责系统级剖析,看kernel占用时间占比、CPU和GPU的同步开销、内存拷贝耗时;Nsight Compute则深入kernel内部,给出SM占用率、访存吞吐、warp状态、指令分析等等微观指标。做矩阵乘法优化,ncu才是主力工具。
用ncu对某个kernel做详细分析,命令很简单:
bash复制ncu --set full ./matmul
这条命令会输出大量指标。对初学者来说,我最推荐先看几个关键项:Duration(内核执行时间)、Achieved Occupancy(实际达到的SM占用率)、SM Efficiency(SM利用率)、DRAM Throughput(显存带宽利用情况)、Compute Throughput(计算吞吐)。通过对比"计算吞吐"和"访存吞吐"哪个更接近上限,就能确定你的kernel是计算密集型还是访存密集型,从而决定优化方向。
如果你的CUDA环境里ncu无法正常工作,先用nvidia-smi确认驱动是否认得到GPU和CUDA版本。很多时候排查到最后,问题不是代码,而是驱动和Toolkit版本不匹配。
3. 从零到一:朴素矩阵乘法的CUDA实现
3.1 CPU基线版本与访存模式分析
写GPU版本之前,先准备一个CPU基线版本,作用有两个:一是跑出正确性参照系,二是让你直观感受访存模式差异。最直接的CPU实现就是三层循环:
cpp复制void matmul_cpu(const float* A, const float* B, float* C, int M, int N, int K) {
for (int i = 0; i < M; i++) {
for (int j = 0; j < N; j++) {
float sum = 0.0f;
for (int k = 0; k < K; k++) {
sum += A[i * K + k] * B[k * N + j];
}
C[i * N + j] = sum;
}
}
}
这个版本的访存模式可以说是灾难级的。注意最内层循环里访问 B[k * N + j],当 j 固定、k 递增时,B 的地址跳了 N 个float的步长,CPU缓存行(通常64字节,也就是16个float)的利用率只有 1/16。如果编译器不做优化,速度会非常慢。这是个很好的反面教材,提醒你GPU版本里如何设计访存模式。
3.2 第一个CUDA Kernel:线程映射策略
GPU版本的第一步,是决定线程怎么映射到输出矩阵上。最简单直观的方案:一个线程负责计算 C 中的一个元素。按二维网格组织线程,让 x 方向对应列、y 方向对应行,这样相邻线程访问 A 的同一行、B 的不同列,后面解释合并访问时你就知道这个映射有多关键。
cuda复制__global__ void matmul_naive_kernel(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;
}
}
启动配置我习惯用 16x16 的线程块,也就是 blockDim.x=16、blockDim.y=16,一共256个线程。网格大小根据矩阵尺寸算,向上取整:
cpp复制dim3 block(16, 16);
dim3 grid((N + block.x - 1) / block.x, (M + block.y - 1) / block.y);
matmul_naive_kernel<<<grid, block>>>(d_A, d_B, d_C, M, N, K);
这个版本能跑出正确结果,但性能远不及预期。如果你用ncu实测,会看到DRAM Throughput很高,SM利用率却很低,说明时间全耗在等数据上了。原因很简单:最内层循环里每个线程每次迭代都要从全局内存里取一次 A 和 B 的数,而同一个 block 里的线程反复读取重叠的数据,完全没有利用片上缓存。
3.3 为什么"先做对再做快"是必由之路
接触过不少初学者,一上来就想写一个完美kernel,结果连结果都是错的,排错排半天。我个人的建议是:先实现一个逻辑正确、边界处理完备的版本,再逐步优化。因为矩阵乘法的正确性验证非常容易,直接用CPU参考实现的结果比对即可:
cpp复制cudaMemcpy(d_C, h_C, M * N * sizeof(float), cudaMemcpyDeviceToHost);
// 与CPU结果对比,误差阈值通常设为1e-3
算出的C数值如果和CPU版本逐元素误差在1e-2以内,就说明kernel逻辑没问题。有了这个正确性基准,后面每次改动都能快速判断有没有引入bug。优化是迭代的过程,性能数据要建立在正确性之上才有意义。
4. 分块优化:共享内存与Tile策略
4.1 为什么朴素版本卡在访存上
接着上一节说。朴素版本最大的问题是每个线程对全局内存的访问次数太多。一个输出元素需要读 K 个 A 值和 K 个 B 值,总计 2K 次全局内存读取。虽然GPU有缓存层,但在K较大、数据总量超过L2缓存容量时,大部分访问都要落到显存(DRAM)。
显存带宽再好也有物理上限。拿常见的A100来说,HBM2e的理论带宽约2TB/s,但SM的计算吞吐却高得多——FP32计算是19.5 TFLOPS。做一个浮点运算只需要从显存读12字节左右的数据,按理论值算,访存已经完全饱和,计算单元在大量时间处于闲置等待状态。这就是第一节课说的"计算强度远低于平衡点"的典型体现。
4.2 共享内存分块的核心思想
分块优化(Tiling)的思路,是把大矩阵切成一个个小的子块(Tile),先由整个线程块协作把这些子块从全局内存搬到共享内存,然后计算时直接从共享内存里取值。这样每个全局内存数据被读取的次数,从"每个使用它的线程各读一次"降为"整个线程块只读一次",大幅降低全局内存流量。
以16x16的线程块为例,每轮迭代需要把 A 的一个 16x16 子块和 B 的一个 16x16 子块加载到共享内存。16x16的分块意味着共享内存利用率高,加载后能支持后续16x16次乘加计算,全局内存访问量减少为原来的1/16。分块越大,复用率越高,但共享内存容量有限,后面细说。
这里最关键的是数据协作加载。注意A子块的一列和B子块的一行,需要从全局内存的不同位置取数据,块的256个线程正好可以协同搬运。加载完成后必须调用同步函数,确保所有线程都把数据写入了共享内存,才能开始计算:
cuda复制__global__ void matmul_tiled_kernel(const float* A, const float* B, float* C, int M, int N, int K) {
const int TILE_SIZE = 16;
__shared__ float As[TILE_SIZE][TILE_SIZE];
__shared__ float Bs[TILE_SIZE][TILE_SIZE];
int row = blockIdx.y * TILE_SIZE + threadIdx.y;
int col = blockIdx.x * TILE_SIZE + threadIdx.x;
float sum = 0.0f;
for (int tile = 0; tile * TILE_SIZE < K; tile++) {
int k_idx = tile * TILE_SIZE;
// 协作加载A子块
if (row < M && k_idx + threadIdx.x < K)
As[threadIdx.y][threadIdx.x] = A[row * K + k_idx + threadIdx.x];
else
As[threadIdx.y][threadIdx.x] = 0.0f;
// 协作加载B子块
if (col < N && k_idx + threadIdx.y < K)
Bs[threadIdx.y][threadIdx.x] = B[(k_idx + threadIdx.y) * N + col];
else
Bs[threadIdx.y][threadIdx.x] = 0.0f;
__syncthreads();
#pragma unroll
for (int t = 0; t < TILE_SIZE; t++) {
sum += As[threadIdx.y][t] * Bs[t][threadIdx.x];
}
__syncthreads();
}
if (row < M && col < N)
C[row * N + col] = sum;
}
注意这里我没有对 K 能否被 TILE_SIZE 整除做假设,而是用条件判断和补零策略处理越界。我在实际项目里遇到过很多因为矩阵维度不是分块大小整数倍而越界崩溃的情况,这种防御性写法能省去你大量排查时间。
4.3 Bank Conflict与共享内存访问效率
分块优化之后,性能提升明显,但有个新问题浮出水面:Bank Conflict,共享内存的bank冲突。共享内存被划分成32个bank,宽度为4字节。如果同一个warp内多个线程同时访问同一个bank的不同地址,硬件就会把访问拆成多次串行处理,一次共享内存访问变成了多次,延迟成倍增加。
在我们这个tiled kernel里,内层循环的访问模式是线程 (tx, ty) 读取 As[ty][t] 和 Bs[t][tx]。对于 As,同一warp里的线程 ty 相同、tx 不同,访问的是不同地址,没有问题;对于 Bs,同一warp里的线程 [t][tx],按tx递增访问,也是不同bank,没有问题。但如果你把共享内存数组按列方向映射,或者warp内的tx不是连续编号,很容易踩进bank conflict的坑。
分析bank冲突时,可以记住一句口诀:只要同一个warp里,32个线程访问的地址刚好分布在32个不同bank上,就是最优的;如果多个线程落在同一个bank上,就要考虑填充(padding)或者调整数据布局来错开。用Nsight Compute的Shared Memory Conflict分析面板,能直观看到每个load/store指令的冲突次数,这个数据比你自己猜可靠得多。
5. 进一步优化:向量化与启动配置调优
5.1 向量化访存:用float4榨干带宽
分块优化解决了数据复用问题,但还有一个细节值得深挖:全局内存访问的带宽利用率。现代GPU一次内存事务通常是32字节或128字节,如果每个线程只读一个float(4字节),warp的32个线程合计128字节,刚好覆盖一个事务,理论上是合并的。
但实际有另一个问题:内存事务的启动开销和效率并不完全等于带宽极限。更进一步的做法是让每个线程一次性读取float4(16字节),这样warp一次可以触达512字节的数据,发起的指令数更少,带宽利用率也能更高。在访存密集型kernel里,向量化通常能带来10%~30%的性能提升。
float4版本的实现思路是:让每个线程负责计算C中4个连续列的元素,比如把线程块的x维度映射到 col*4 的位置。此时加载A子块时,可以直接按float4读取;B子块不变。核心代码如下(简化版):
cuda复制__global__ void matmul_tiled_vec4_kernel(const float4* A4, const float4* B4, float* C,
int M, int N, int K) {
const int TILE_SIZE = 16;
__shared__ float As[TILE_SIZE][TILE_SIZE];
__shared__ float Bs[TILE_SIZE][TILE_SIZE];
int row = blockIdx.y * TILE_SIZE + threadIdx.y;
int col4 = blockIdx.x * blockDim.x + threadIdx.x; // col4 对应的是4个float的组
int col = col4 * 4;
float4 acc = make_float4(0.f, 0.f, 0.f, 0.f);
for (int tile = 0; tile * TILE_SIZE < K; tile++) {
int k_idx = tile * TILE_SIZE;
// 加载A子块:每个线程取一个float4连续数据
if (row < M && k_idx + threadIdx.x * 4 + 3 < K) {
float4 a = A4[(row * K + k_idx) / 4 + threadIdx.x];
As[threadIdx.y][threadIdx.x * 4 + 0] = a.x;
As[threadIdx.y][threadIdx.x * 4 + 1] = a.y;
As[threadIdx.y][threadIdx.x * 4 + 2] = a.z;
As[threadIdx.y][threadIdx.x * 4 + 3] = a.w;
} else {
// 边界处理
}
// 加载B子块
if (col < N && k_idx + threadIdx.y < K)
Bs[threadIdx.y][threadIdx.x] = B[(k_idx + threadIdx.y) * N + col];
else
Bs[threadIdx.y][threadIdx.x] = 0.0f;
__syncthreads();
#pragma unroll
for (int t = 0; t < TILE_SIZE; t++) {
float b = Bs[t][threadIdx.x];
acc.x += As[threadIdx.y][t] * b;
acc.y += As[threadIdx.y][t] * Bs[t][threadIdx.x + 0]; // 简写示意
acc.z += As[threadIdx.y][t] * Bs[t][threadIdx.x + 0];
acc.w += As[threadIdx.y][t] * Bs[t][threadIdx.x + 0];
}
__syncthreads();
}
// 写回C,每个线程写4个连续float
if (row < M && col + 3 < N) {
((float4*)&C[row * N + col]) = acc;
}
}
这个代码为了展示做了大量简化,真正的生产级float4分块kernel还要处理K维和N维皆非4倍数的边界情况,可以通过尾部单独处理或者分两个kernel解决。在这个阶段,我建议你先跑通16x16分块,确认正确性和性能都稳定,再上float4,否则问题叠加很难排查。
5.2 线程块尺寸与Occupancy的权衡
分块大小既是性能参数也是正确性参数。16x16块用256线程,每轮迭代共享内存占用只需 2 * 16 * 16 * 4 = 2KB,非常充裕。但如果你把TILE_SIZE提到32,一个块就有1024个线程,共享内存也涨到8KB,虽然复用率更高,却可能导致SM上同时驻留的线程块数量减少,也就是降低Occupancy。
Occupancy(占用率)是SM上活跃warp数与最大可容纳warp数的比值。占用率越高,越容易隐藏内存延迟——当一个warp在等数据时,调度器可以切到另一个warp执行。但占用率不是越高越好,比如寄存器的使用量也会限制占用率。有时候较低的占用率因为减少了缓存争抢,反而更好。我的建议是:把TILE_SIZE设为16或32,再用ncu测Occupancy,根据数据调,别拍脑袋。
5.3 循环展开与restrict限定符
#pragma unroll 是编译器指令,让循环体重复展开多次,减少循环控制指令开销,也能提升指令级并行度。在共享内存计算循环前加上 #pragma unroll,CUDA编译器会根据实际情况展开。展开的代价是寄存器占用增加,所以也不是越激进越好,需要实测平衡。
还有一个容易被忽略的关键点:用 restrict 修饰指针。它告诉编译器这些指针指向的内存没有别名(不会重叠),编译器就能做更激进的优化,比如把多次访存缓存到寄存器里,或者重排指令顺序:
cuda复制__global__ void matmul_kernel(const float* __restrict__ A,
const float* __restrict__ B,
float* __restrict__ C, ...)
少了这个修饰符,编译器对每个指针都做最保守的假设,很多优化没法做。我见过不少案例,只是加上 restrict,kernel性能就能提升5%~10%,零成本,强烈建议所有kernel都加上。
6. 性能剖析与结果分析
6.1 怎么用Nsight Compute定位瓶颈
优化的前提是知道瓶颈在哪,而定位瓶颈最可靠的方式就是profiling。我每次分析一个kernel,固定流程是先跑一遍ncu拿到核心指标,再根据指标决定下一步。
ncu命令分两部分。首先快速拿到kernel整体耗时和基本指标:
bash复制ncu --kernel-name regex --launch-count 1 ./matmul
因为程序里可能启动多个kernel,可以通过--kernel-name指定你要分析的那一个。拿到基础数据后,再用--set full做深入分析:
bash复制ncu --set full --kernel-name matmul_tiled_kernel ./matmul
重点看这几个指标的组合判断:
- Duration:kernel执行总时长,优化的最终目标。
- Compute (SM) Throughput:SM内部计算单元(包括算术、逻辑、特殊函数)的利用率。接近理论峰值说明计算密集,应该从算法或指令层面优化。
- Memory Throughput:包括全局内存、共享内存、L1/L2缓存的访问吞吐。接近峰值说明访存瓶颈,应该从数据复用、访问模式、缓存策略入手。
- Achieved Occupancy:实际占用率,低于预期时检查寄存器数、共享内存量是否限制了并发warp数。
- Warp Stall Reasons:看warp在等什么。如果是Long Scoreboard(等全局内存),说明访存优化还不够;如果是Short Scoreboard(等共享内存),考虑bank conflict;如果是Barrier等待,说明负载不均衡。
结合这组指标,你就能判断当前版本到底是"算不过来"还是"等数据等到心碎",从而对症下药。
6.2 三版实现实测数据对比
为了让你有个直观感受,我把一次典型的矩阵乘法优化过程数据列出来。测试矩阵规模为 M=N=K=1024,单精度浮点,总计浮点运算量约为 2*1024^3 ≈ 2.147 GFLOPS(十亿次浮点运算),测试环境是一块消费级GeForce GPU。
| 实现版本 | Kernel耗时 | 有效算力(GFLOPS) | 相对CPU加速比 | 关键瓶颈 |
|---|---|---|---|---|
| CPU单线程基线 | 约450 ms | 约4.8 | 1x | 访存效率低、无并行 |
| 朴素CUDA Kernel | 约3.2 ms | 约670 | 约140x | 全局内存带宽饱和 |
| 16x16共享内存分块 | 约0.95 ms | 约2260 | 约470x | L1/L2访问占比高 |
| 分块+float4向量化 | 约0.62 ms | 约3460 | 约725x | 接近该GPU的FP32上限 |
数据是典型场景下的示意数据,具体数值因GPU型号和驱动版本而异,但优化趋势是确定性的:每一步优化都能带来数倍提升,而且越往后越接近硬件理论峰值。要做横向对比,建议自己跑一遍同样的测试矩阵。
有效算力的计算公式是:
text复制GFLOPS = 2 * M * N * K / (kernel耗时秒数 * 1e9)
以分块+向量化版本为例:2 * 1024 * 1024 * 1024 / (0.00062 * 1e9) ≈ 3460 GFLOPS。我经常把这个公式写进测试脚本里,每次优化完自动算一次,方便做趋势记录。
6.3 从数据看优化的本质
把三个版本的性能数据放在一起,你会发现一个规律:优化的本质不是让每一次计算更快,而是减少"无用功"和"等待"。朴素版本慢,是因为计算单元在等全局内存的数据,真正干活的时间占比极低;分块版本快,是因为数据复用把访存量降下来了,计算单元大部分时间都在干正事;向量化版本再进一步,把传输效率顶上去了。
还有一个细节值得注意:分块大小的选择取决于矩阵规模与硬件参数。K=1024时16x16分块的迭代轮数为64轮(1024/16),足够让block内线程充分协作。但如果K很小,比如只有16,分块收益就几乎为零——因为数据本来就在缓存里,重复搬运反而增加开销。做优化的时候,不要盲目套用方案,先想清楚瓶颈到底在哪。
7. 常见问题与排查技巧实录
7.1 新环境跑老代码遇"No kernel image"
这个报错在换GPU或者新装CUDA环境的场景下特别常见,完整信息类似 "CUDA error: no kernel image is available for execution on the device",意思是你编译kernel时指定的GPU架构和你实际运行的GPU架构不匹配。
原因在于nvcc编译CUDA代码时要指定GPU架构代号,比如sm_80对应Ampere架构的A100/RTX 30系,sm_89对应RTX 40系Ada架构。如果你用老版本Toolkit编译的kernel只包含sm_70的SASS,而新GPU是sm_89,它没法执行。解决办法有两种:一是用较新Toolkit重新编译,二是编译时加计算能力选项:
bash复制nvcc -arch=sm_89 -o matmul matmul.cu
想让二进制文件同时兼容多代GPU,可以用:
bash复制nvcc -gencode arch=compute_80,code=sm_80 -gencode arch=compute_89,code=sm_89 -o matmul matmul.cu
需要注意的是,-arch=sm_89这类选项要求Toolkit版本足够新。所以排查思路是:先nvcc --version看编译器版本,再nvidia-smi确认GPU型号和驱动,最后对着型号表确认架构代号。如果驱动和Toolkit不匹配,很可能要升级驱动或者换用更新的Toolkit。
7.2 程序崩溃或卡死,看"GPU Crash Dump Triggered"
这种情况我早期遇到过好几次。程序跑着跑着,终端突然报错退出,日志里出现类似 "GPU Crash Dump Triggered" 或者 "CUDA error: an illegal memory access was encountered" 的信息。核心原因基本都是kernel里发生了非法内存访问:越界读写、空指针解引用、或者说数组下标计算错误。
排错第一原则:用compute-sanitizer。
bash复制compute-sanitizer --tool memcheck ./matmul
这个工具会像内存检测器一样逐条报告非法访问的位置,精确到kernel名、行号、访问的地址和越界大小,比你在代码里加一堆printf有效率得多。它还能检测共享内存越界、未同步访问、内存泄漏等问题,是CUDA开发的必备神器。
另一个常见崩溃原因是__syncthreads()用错了位置。如果多个线程在条件分支里执行了不同数量的__syncthreads(),整个block就会死锁挂起。记住一个铁律:__syncthreads()要么所有线程都执行,要么都不执行,绝对不能放在条件语句内部导致部分warp跳过。
7.3 nvprof不见了,以及环境排查思路
升级到CUDA 12之后,你会发现nvprof命令找不到了,这是正常的,NVIDIA已把它从发行包里移除。老教程里大量引用nvprof,但新版环境里请直接用Nsight Compute。如果要看kernel耗时排序、CPU/GPU时间线,用Nsight Systems:
bash复制nsys profile --stats=true ./matmul
方便好用,输出信息量也大。
还有一类排查需求是"我怎么确认CUDA装好了"。除了nvidia-smi看驱动,还可以编译运行deviceQuery这个经典示例程序。Toolkit安装时会自带示例代码,或者直接从NVIDIA官网下载cuda-samples仓库:
bash复制git clone https://github.com/NVIDIA/cuda-samples.git
cd cuda-samples/Samples/1_Utilities/deviceQuery
make
./deviceQuery
能正确显示GPU型号、计算能力(Compute Capability)、驱动版本,就说明环境跑通了。万一编译报错找不到头文件,多半是CUDA_HOME或PATH环境变量没有配好,回到2.1节的配置方式检查一遍。
7.4 关于性能调优的几条独家经验
调试和性能调优这块,我有几条踩过坑才总结出来的经验,分享给大家。
第一,先把问题隔离。如果结果不对,不要同时怀疑访存、同步、边界、数值精度一堆因素。先用小矩阵(比如16x16)跑,把kernel代码简化到最朴素版本,确认正确了再加优化。小矩阵调试、大矩阵压测,这个节奏能省很多时间。
第二,不要被Amdahl定律打脸。有些时候你花大力气优化的kernel只占总运行时间的10%,整体提升微乎其微。先用Nsight Systems看时间分布,从占比最大的部分优化,收益才明显。
第三,老黄历的优化建议要打折看。网上很多文章讲CUDA优化还在推荐固定block=256、共享内存选48KB之类,这些参数早就随架构变化了。正确做法是用cudaOccupancyMaxActiveBlocksPerMultiprocessor这个API计算当前kernel能达到的理论最高占用率,再结合ncu实测数据定参数。
第四,数值稳定性和性能要平衡。不要为了速度把累加顺序改得面目全非。浮点加法不具备结合律,不同的累加顺序会带来不同的舍入误差。工程标准做法是和CPU参考实现比对误差,控制在可接受范围内,而不是强求完全一致。
8. 从矩阵乘法扩展到更复杂的GPU应用
8.1 这套优化思路能迁移到哪里
学完矩阵乘法优化,你会发现这套方法论几乎可以平移到所有数据并行的高性能计算任务上。
卷积神经网络的前向计算,本质上就是小矩阵乘法加滑动窗口的组合;Transformer里的注意力机制,主要操作就是Q乘以K的转置再乘以V;图像处理里的各种滤波、变换,也经常以GEMM的形式组织。我自己做深度学习算子优化时,第一反应就是把卷积铺平成GEMM,然后用共享内存分块、向量化这一套组合拳去优化,思路完全一致。
另外一个可以直接迁移的优化方向是内存访问模式的推导。掌握了"合并访问"和"数据复用"这两个概念后,你在设计任何kernel时都会先问自己三个问题:这个warp访问全局内存时地址连续吗?同一块数据被多个线程重复读了吗?能不能把中间结果暂存在共享内存或寄存器里?这比死记硬背一堆优化技巧有用得多。
8.2 再往前走:cuBLAS与生产级实践
需要提醒的是,我们在这篇文章里手动实现的矩阵乘法kernel,目标是学习优化原理,不是为了超越cuBLAS。NVIDIA官方的高性能线性代数库cuBLAS经过多代手工调优,针对不同GPU架构、不同矩阵规模都有专门的调优路径,性能和稳定性远超个人手写版本。生产环境里直接调用cuBLAS是正确的选择。
但这也带来一个隐忧:如果所有人只调库,不理解底层原理,遇到性能瓶颈、内核定制需求或者新硬件适配时就会束手无策。我自己在开发定制算子的过程中深刻体会到,理解手写kernel的优化历程,能让你在认知层面建立对硬件和软件的直觉。这种直觉是调试cuBLAS性能问题、设计融合算子、评估新硬件时的巨大底气。
9. 写在最后的一点实际心得
最初我把这个矩阵乘法优化项目做完的时候,心情其实挺复杂的:一方面看着性能提升了七百多倍,成就感十足;另一方面也清楚,距离硬件理论峰值还有不小的距离,cuBLAS跑同样规模要再快近一倍。正是这种"差距感"推动我继续往下深挖,把共享内存、向量化、寄存器重用、双缓冲这些技术逐个吃透。
回顾整个实战过程,我最深的感受是:GPU编程的难点不在于写对kernel,而在于建立对性能的"手感"——知道瓶颈在哪里,知道每种优化手段能解决哪类瓶颈,知道怎么用profiling数据验证自己的判断。矩阵乘法恰好提供了一个最纯净、最经典的训练场,它把计算、访存、并行、同步这些核心概念全部浓缩在一个看起来极其简单的数学表达式里。
如果你正在学CUDA,不妨照着这篇文章的步骤,自己动手把每个版本的kernel都写出来、跑起来、剖析一遍。性能变化的数据比任何教程都更有说服力。等你亲手把一个kernel从3毫秒优化到0.6毫秒,你会发现GPU编程其实没有想象的那么神秘——它只是让你更懂硬件的脾气罢了。这个项目做完之后,我做其他算子优化的思路明显清晰了许多,希望你也能从中找到自己的节奏。
