1. GPU 基础架构与内存层次

做深度学习训练,说白了就是在跟 GPU 的内存打交道。模型越大、batch 越大,内存就越是命门。我最早做 175B 模型训练时,就因为没搞懂 SM 和 L1 cache 的配合,导致利用率死活上不去。今天就先把地基打牢。

1.1 从 SM 说起

GPU 的核心计算单元叫 SM(Streaming Multiprocessor),你可以把它理解成一个小型 CPU 核的集合。每个 SM 里有几十到几百个 CUDA 核心,还有自己的寄存器文件、共享内存、L1 缓存和调度器。

  • SM 数量——越多并行能力越强,但显存带宽不够也会喂不饱。
  • 每个 SM 上能跑的线程块(block)数量有限,取决于寄存器、共享内存等资源。
  • Warp 调度——32 个线程组成一个 warp,SM 以 warp 为单位发射指令。warp 内的线程必须走同一分支,否则就出现分支发散,效率暴跌。

我的小习惯: 调 kernel 参数时,我先看每个 block 用了多少寄存器——如果超过 32 个,SM 上能同时激活的 block 数量就会减半,占用率直接掉一截。我曾经一个 attention 算子就因为寄存器超标,性能直接腰斩。

1.2 内存层次全图

GPU 的内存像一个金字塔,从上到下容量越来越大、延迟越来越高。你得知道每层用来干什么。

GPU内存层次结构 GPU 内存层次金字塔 寄存器 (Register) ~ 256KB/SM 共享内存 / L1 Cache ~ 128KB/SM L2 Cache ~ 20–40 MB 显存 (HBM / GDDR) ~ 16–80 GB 带宽:HBM2E ~ 2 TB/s | GDDR6 ~ 1 TB/s 系统内存 / NVLink / 跨节点网络 越上层容量越小、速度越快;越下层容量越大、延迟越高。训练瓶颈常在显存带宽和容量。

1.3 L1 / L2 缓存:自动但别依赖

L1 缓存和共享内存共用一块物理 SRAM,你可以通过编译器配置切分比例。L2 缓存则是所有 SM 共享的,对训练来说,它主要用来缓解显存上的随机访问压力。

但要注意:L1 缓存是 per-SM 的,不同 SM 不能互相访问对方的 L1。如果你让两个 block 通过全局内存通信,那它们必须走显存,L1 帮不上忙。

我曾经踩过的坑: 写一个 layer norm 的 CUDA 实现时,以为把数据放在全局内存然后靠 L1 自动缓存就能快。结果实测发现 L1 命中率极低,因为每个线程访问的元素间隔过大,导致 cache line 浪费。后来改成手动用共享内存分块,速度提升了 3 倍。

1.4 显存:HBM vs GDDR

特性 HBM2E HBM3 GDDR6 GDDR6X
典型容量 16–80 GB 16–144 GB 8–32 GB 8–48 GB
带宽 (TB/s) ~2.0 ~3.4 ~0.7 ~1.0
功耗 较高 高 较低 中
Die Stacking 是 (3D) 是 (3D) 否 否
典型 GPU A100, V100 H100, B200 RTX 30/40 系列 RTX 3090, 4090

HBM 通过 3D 堆叠把多个 DRAM die 叠在一起,用微凸块和 TSV 连起来,带宽是 GDDR 的两三倍。但成本高、容量受限。GDDR 走 PCB 走线,容量好加,但带宽和功耗不如 HBM。

对训练来说:带宽决定计算能多快被喂饱,容量决定你能装多大的模型。如果一个 70B 模型光权重就要 140 GB(FP16),一片 A100 80G 根本装不下,就得上模型并行或者梯度 checkpointing。

我建议你这样记: 训练时最宝贵的不是显存容量,而是 显存带宽。很多算子(比如 attention)不是算力不够,是带宽不够——数据搬不过来。优化的时候优先考虑减少全局内存访问次数,比如用 fused kernel 或算子融合。

1.5 带宽 vs 延迟:两个不同维度

带宽是单位时间能搬多少数据,延迟是从发起请求到拿到数据的时间。GPU 的显存带宽很大(TB 级),但延迟也不低(几百个 cycle)。所以 GPU 靠大量并行 + 隐藏延迟来干活:当一批线程在等数据时,SM 切换去执行另一批 warp。

这也是为什么 SM 上要同时跑足够多的 warp——你想想看,如果只跑一个 warp,那每次等显存数据时 SM 就空转了。占用率(occupancy)不够,性能就会打折扣。

1.6 显存带宽的实测小工具

你想知道自己 GPU 的真实带宽?可以用 CUDA 自带的 bandwidthTest:

cd /usr/local/cuda/samples/1_Utilities/bandwidthTest
make
./bandwidthTest

也可以用更细粒度的工具测 memcpy 和 kernel 中的带宽:

nvprof --metrics dram_read_throughput,dram_write_throughput ./my_kernel

或者直接在 Python 里用 torch:

import torch
a = torch.randn(1024, 1024, device='cuda')
b = torch.randn(1024, 1024, device='cuda')
c = a @ b
torch.cuda.synchronize()
# 配合 torch.cuda.Event 算时间

你还可以看 nvidia-smi 的 Memory-Usage: 如果显存占用率很高但 GPU-Util 只有 20%,那大概率是带宽瓶颈了。这时候就该检查你的 kernel 是否做了充分的算子融合。

1.7 了解你的 GPU 内存拓扑

写代码之前,我建议你查一下目标 GPU 的规格:

nvidia-smi --query-gpu=name,memory.total,memory.bandwidth --format=csv

对于 A100,你会看到类似:

name,memory.total [MiB],memory.bandwidth [GB/s]
NVIDIA A100-SXM4-80GB,81152 MiB,2039 GB/s

记住一个关键数字:需要多高的浮点算力才能打满这个带宽? 以 A100 80G(带宽 2 TB/s)为例,如果计算密度是 1:1(每个浮点操作读 1 个字节),那算力需要 2 TFLOPS 才能打满带宽。而 A100 的 TF32 算力是 156 TFLOPS,所以实际计算密度远高于 1:1,多数 kernel 都是带宽受限而非计算受限。

1.8 本章核心结论

  • SM 是计算单元,L1/L2 是自动缓存,但别过度依赖——手动分块共享内存往往效果更好。
  • 显存带宽是训练的主要瓶颈之一,优先减少全局内存访问次数。
  • HBM 带宽高但容量小,GDDR 容量大但带宽低,选 GPU 时根据模型规模和工作负载权衡。
  • 用 nvidia-smi / bandwidthTest 了解你自己的 GPU 实际带宽。

记住:大多数训练优化,本质上都是在“减少数据搬运”和“提高搬运效率”之间做文章。 下一层知识——比如共享内存、Bank Conflict、线程束调度——都会围绕这个核心展开。先把这个宏观图刻在脑子里,后面再填细节。