AI Infra 学习路线 · 阶段二 · 模块二(下半部分)
目标:从一维向量加法 → 二维矩阵乘法 → 用共享显存优化 → 学会严谨测 GPU 性能
环境:WSL2 + CUDA Toolkit 12.8 (nvcc) + RTX 5060 Ti
1. 从一维到二维:矩阵乘法的并行思路
- 向量加法:数据是一条线(一维),线程排一条线,一个 idx 定位。
- 矩阵乘法:结果矩阵 C 是二维网格,让线程也排成二维网格,每个线程算 C 的一个元素 Crowcol。
CUDA 内置变量有 .x .y .z,二维时:
row = blockIdx.y * blockDim.y + threadIdx.y // 负责第几行
col = blockIdx.x * blockDim.x + threadIdx.x // 负责第几列
就是一维全局索引公式的"两份"。
矩阵乘法定义:C[row][col] = Σ A[row][k] * B[k][col] (k=0...N-1)
关键 :C 每个位置可独立计算(算 C00 不用等 C01)→ 大量独立小任务 → GPU 完美主场。
并行策略:一个线程算 C 的一个元素,自己读 A 的一行、B 的一列做求和。
二维数组按一维存储 :A[row][k] 在内存里写成 A[row*N + k](C 语言二维数组本质是一维连续内存)。GPU 编程几乎都手动做这个二维→一维映射。
朴素版 kernel:
cpp
__global__ void matmul(float *A, float *B, float *C, int N) {
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
if (row < N && col < N) { // 二维边界检查
float sum = 0.0f;
for (int k = 0; k < N; k++)
sum += A[row * N + k] * B[k * N + col];
C[row * N + col] = sum;
}
}
二维启动:
cpp
dim3 threadsPerBlock(16, 16); // 每 block 16x16=256 线程
dim3 blocks((N+15)/16, (N+15)/16); // 二维向上取整
matmul<<<blocks, threadsPerBlock>>>(d_A, d_B, d_C, N);
dim3 = CUDA 的三维尺寸类型(这里用二维 x,y)。
2. warp(线程束)★ + 为什么 block 选 16×16
warp :GPU 调度的真正最小单位 = 32 个线程一组。同一 warp 的 32 线程在同一时刻执行同一条指令(SIMT 的硬件落地)。
由此 block 线程数最好是 32 的整数倍,否则最后一个 warp 凑不满,部分线程空转浪费。
为什么常选 16×16 = 256:
- 不超上限:一个 block 最多 1024 线程,256 安全。
- 整除 warp:256 = 8 个完整 warp,不浪费。
- 喂饱 SM 的甜区:太小(如 8×8=64)喂不饱 SM,利用率低;太大(32×32=1024)占资源多、SM 上能并行的 block 少。256 是经验甜区。
诚实补充:block 最优值和具体 kernel、GPU 型号有关,无放之四海标准答案。真优化时拿不同大小实测挑最快 = autotuning。16×16 是好起点不是唯一解。
3. GPU vs CPU 实测(1024×1024 矩阵乘法)
| 版本 | 耗时 | 相对 |
|---|---|---|
| CPU 三层循环 | ~2423 ms | 基准 |
| 朴素 GPU(热机后真实值) | ~1.7-3.3 ms | 快几百倍* |
(*注:见第 5 节,首测含冷启动会严重失真;此处为热机后的量级感受)
CUDA 事件计时(测 GPU 的标准做法,比 clock() 准):
cpp
cudaEvent_t start, stop;
cudaEventCreate(&start); cudaEventCreate(&stop);
cudaEventRecord(start);
kernel<<<...>>>(...);
cudaEventRecord(stop);
cudaEventSynchronize(stop); // 等 GPU 算完
float ms = 0; cudaEventElapsedTime(&ms, start, stop);
为什么不用 clock():kernel 异步(CPU 发完命令就走),CPU 计时器测不准 GPU 真实时长。CUDA 事件在 GPU 时间线打标记,精确。
Infra 直觉:
- 矩阵越大,GPU 加速比越高(并行更充分 + 固定开销被摊薄)。
- 小矩阵 GPU 可能更慢(拷数据上下 GPU 的开销 > 计算)。GPU 不是万能加速器,数据太小时搬运开销主导。
- 测的 GPU 时间常只含 kernel,不含 cudaMemcpy 拷贝;真实场景"拷上+算+拷回"总时间才是用户感受,数据搬运常是隐藏瓶颈 → 真实训练想方设法减少 CPU-GPU 搬运。
4. tiling 优化(共享显存)★★ --- 模块二理论最高点
为什么朴素版慢
算 C 每个元素要从全局显存(慢仓库)读 A 整行、B 整列。相邻线程重复读大量相同数据(算 C00 和 C01 都要读 A 第 0 行)。整个矩阵算下来,每个元素被从慢仓库反复搬上千次。慢在"反复跑仓库"。
tiling 思路
把矩阵切成 16×16 小块。一个 block 算 C 的一个 16×16 小块,分阶段 :每阶段 block 内 256 线程协作把 A、B 的一个小块从全局显存搬进共享显存(快架子,快上百倍) ,然后都从快架子做乘加,累加;再搬下一块。
省在哪:每个数据从慢仓库只搬 1 次进快架子,之后多次访问全在快架子完成。慢仓库访问砍掉一个数量级 × 快架子快上百倍 = 大幅加速。
三个新机制
__shared__ float tileA[16][16];------ 声明住在共享显存的数组,block 内所有线程共享。- 分阶段搬运 + 累加。
__syncthreads()------ 同步栅栏,block 内所有线程都到这行才一起往下走。
两道栅栏缺一不可 ★
cpp
// 搬数据进 tileA/tileB ...
__syncthreads(); // 栅栏1:等所有线程搬完才开始算(防"还没搬好就读到垃圾")
// 从 tile 做乘加累加 ...
__syncthreads(); // 栅栏2:等所有线程算完才搬下一块(防"还在用就被覆盖")
少任一道 → 结果随机出错,且因依赖线程调度时序极难复现/排查。结果正确(C0=2048)说明两道栅栏都对。
完整 tiled kernel 见 matmul_tiled.cu(已存于 ~/cuda-practice)。
5. GPU 性能测量陷阱 ★★(自己踩出来的关键一课)
现象:同一个程序多次跑,加速比从 111 倍 → 2.85 倍 → 1.54 倍,差别巨大。
陷阱一:冷启动(头号陷阱)
GPU 第一次执行 kernel 有大量一次性开销:CUDA 上下文初始化、kernel 加载到 GPU、显存通道预热。只发生在第一次。
首测时第一个执行的 kernel(朴素版)替整个程序背了所有冷启动开销 → 显得极慢(113ms);紧随其后的 tiling 版 GPU 已热 → 显得极快(1ms)→ 虚高的 111 倍。
热机后朴素版只要 1.7-3.3ms,tiling 真实加速约 2-3 倍。
陷阱二:测量噪声
1024² 矩阵乘法太快(1-3ms),系统抖动、GPU 动态调频(boost clock)、后台占用都会显著影响百分比。对象越小越快,相对噪声越大。 所以连续几次还在跳。
正确测法:
-
warm-up :正式测量前先空跑几次不计时,让 GPU 进稳定状态。
cppfor (int i = 0; i < 3; i++) { kernelA<<<...>>>(...); kernelB<<<...>>>(...); } cudaDeviceSynchronize(); -
多次取平均:每个版本测 10 次取均值,而非测 1 次。
核心认知 :未经热身、只测一次的 GPU 数字几乎一定误导。拿没热身的数字汇报优化效果会闹笑话甚至误导决策。测量本身是需要严谨对待的功夫。
6. 模块二完整收获(贯穿后续)
性能阶梯(同一块 GPU、同样计算逻辑,只改"数据从哪读"):
CPU → 朴素 GPU → tiling GPU,层层加速。
最核心认知 :高性能 GPU 计算的本质,常常不是"算得更快",而是"数据搬得更少"。
计算量没变,纯靠减少数据在显存层级间搬运就拿到大幅提升 → 显存带宽往往比算力更先成为瓶颈。这个直觉贯穿后面所有训练/推理优化(KV cache、PagedAttention 等本质都是管显存、减搬运)。
已掌握:二维线程组织 / dim3 / 矩阵乘法 kernel / 二维→一维映射 / warp / block 大小取舍 / CUDA 事件计时 / tiling+共享显存+双栅栏 / GPU 测量陷阱与 warm-up。