【infra之路】阶段二 · 模块二:CUDA 编程入门(下)— 矩阵乘法、tiling 优化与测量陷阱

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:

  1. 不超上限:一个 block 最多 1024 线程,256 安全。
  2. 整除 warp:256 = 8 个完整 warp,不浪费。
  3. 喂饱 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)、后台占用都会显著影响百分比。对象越小越快,相对噪声越大。 所以连续几次还在跳。

正确测法:

  1. warm-up :正式测量前先空跑几次不计时,让 GPU 进稳定状态。

    cpp 复制代码
    for (int i = 0; i < 3; i++) { kernelA<<<...>>>(...); kernelB<<<...>>>(...); }
    cudaDeviceSynchronize();
  2. 多次取平均:每个版本测 10 次取均值,而非测 1 次。

核心认知 :未经热身、只测一次的 GPU 数字几乎一定误导。拿没热身的数字汇报优化效果会闹笑话甚至误导决策。测量本身是需要严谨对待的功夫。


6. 模块二完整收获(贯穿后续)

性能阶梯(同一块 GPU、同样计算逻辑,只改"数据从哪读"):

CPU → 朴素 GPU → tiling GPU,层层加速。

最核心认知 :高性能 GPU 计算的本质,常常不是"算得更快",而是"数据搬得更少"。

计算量没变,纯靠减少数据在显存层级间搬运就拿到大幅提升 → 显存带宽往往比算力更先成为瓶颈。这个直觉贯穿后面所有训练/推理优化(KV cache、PagedAttention 等本质都是管显存、减搬运)。

已掌握:二维线程组织 / dim3 / 矩阵乘法 kernel / 二维→一维映射 / warp / block 大小取舍 / CUDA 事件计时 / tiling+共享显存+双栅栏 / GPU 测量陷阱与 warm-up。


相关推荐
ZhengEnCi7 小时前
09bad-斯坦福CS336作业一-构建优化器
人工智能
ZhengEnCi7 小时前
09bac-斯坦福CS336作业一-实现训练损失计算
人工智能
冬奇Lab8 小时前
Skill 系列(01):Skill 评测体系——如何量化一个 AI Skill 的质量
人工智能
IT_陈寒10 小时前
Redis内存爆了,原来我漏掉了这个致命配置
前端·人工智能·后端
用户35218024547512 小时前
🎆从 Prompt 到 Skill:让 Spring AI Agent 学会"装新技能"
人工智能·spring boot·ai编程
米小虾13 小时前
手把手教你搭建第一个生产级AI Agent:从选型到实战的完整指南
人工智能·agent
任沫13 小时前
Agent之Function Call
javascript·人工智能·go
米小虾13 小时前
2026年AI Agent全面爆发:从开源生态到企业级应用的进化之路
人工智能·agent
用户69190268133913 小时前
Vibe Coding 开发项目的基本范式
人工智能·设计模式·代码规范
To_OC13 小时前
别再跟 AI 死磕 prompt 了,我写了个 Loop 让它自己改到满意为止
人工智能·aigc·agent