05 · CUDA 执行模型:stream / event / 显存分配 / AMP¶
前几篇讲的所有 op,最终都要落到 CUDA 上以异步的方式执行。这一篇讲的是 GPU 执行的真实模型:kernel 是怎么排队的、stream 怎么用来做 overlap、caching allocator 有哪些观测 API、混合精度又是怎么配置的。至于 allocator 的三级结构、碎片化的成因、CUDA VMM 与
expandable_segments的细节,会单独放到 06 里展开。这些内容共同决定了一个框架的性能上限。
1. CUDA 的异步执行模型¶
在 Python 侧调用一个 op(比如 y = x @ w)时,CPU 只是把对应的 kernel 塞进 stream 队列就立刻返回了,GPU 在后台慢慢把它执行完。也就是说,CPU 和 GPU 是并行推进的,两者互不等待。
y = model(x) # 立即返回,kernel 还在 GPU 上排队/执行
loss = y.sum() # 继续往队列里塞,CPU 不等 GPU
print(loss.item()) # .item() 触发同步!CPU 在这里阻塞等 GPU 算完
理解了这一点,有几条推论是框架开发时必须记在心里的:
- 计时必须先同步:要在
torch.cuda.synchronize()之后再读时间,否则测到的只是"把 kernel 塞进队列"所花的时间,而不是真实的计算耗时。 - 任何把数据读回 CPU 的操作都会构成一个同步点,比如
.item()、.cpu()、.tolist()、print(tensor)、bool(tensor)、.nonzero()(因为它的输出形状依赖数据本身)。在热路径上,每一个这样的同步点都会让 GPU 的流水线打一次嗝。 - OOM 报错给出的调用栈可能并不准确。因为执行是异步的,错误往往要等到后面某个同步点才会真正暴露出来。想定位到真正出错的 op,可以用
CUDA_LAUNCH_BLOCKING=1强制 kernel 同步执行(仅用于调试,正式运行不要开)。
# 正确计时
torch.cuda.synchronize(); t0 = time.perf_counter()
for _ in range(100): y = model(x)
torch.cuda.synchronize(); t1 = time.perf_counter() # 必须同步!
2. Stream:并发执行的通道¶
CUDA 的 stream 本质上是一个 kernel 队列:同一个 stream 内的 kernel 严格按照提交顺序执行,而不同 stream 上的 kernel 只要硬件资源足够,就可以并发执行。如果不特别指定,所有操作默认都在 default stream 上进行。
所谓 overlap,本质上就是把可以并行的工作分别放到不同的 stream 上,让它们在 GPU 上同时跑起来。其中最典型的场景就是计算和通信的 overlap:
s_comm = torch.cuda.Stream()
# 在通信 stream 上发起 all-reduce
with torch.cuda.stream(s_comm):
dist.all_reduce(grad, async_op=True)
# 同时在 default stream 上做别的计算
other = compute_next_layer()
# 同步:让 default stream 等通信 stream 完成(见下节 event)
torch.cuda.current_stream().wait_stream(s_comm)
这里有一个跨 stream 使用内存时容易踩的坑:caching allocator 是按 stream 来跟踪每个张量的生命周期的。如果一个张量在 stream A 上分配,却被 stream B 使用,就必须用 record_stream 显式告诉 allocator 这件事,否则 allocator 可能在 stream B 还在用这块内存的时候,就把它当作空闲内存回收并复用给别的分配,造成数据损坏:
buf = torch.empty(..., device='cuda') # 在 default stream 分配
with torch.cuda.stream(s_comm):
dist.all_gather_into_tensor(buf, shard)
buf.record_stream(s_comm) # 告知 allocator buf 也被 s_comm 用了
这个坑在手写通信 overlap 的代码里非常隐蔽,往往只是偶发的数据错误,不容易复现。FSDP 和 Megatron 内部对此都做了小心的处理。
3. Event:跨 stream 同步与精确计时¶
torch.cuda.Event 是插在某个 stream 里的一个标记点,主要用途有两个:让一个 stream 等待另一个 stream 到达某个特定位置,或者精确测量 GPU 上的执行时间。
# 跨 stream 同步:B 等 A 的某个点
ev = torch.cuda.Event()
with torch.cuda.stream(s_a):
do_work_a()
ev.record(s_a) # 在 A 上打标记
with torch.cuda.stream(s_b):
s_b.wait_event(ev) # B 等到 A 的标记点才继续
do_work_b()
# GPU 计时(比 synchronize + perf_counter 更精确,只量 GPU 时间)
start, end = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
start.record()
model(x)
end.record()
torch.cuda.synchronize()
ms = start.elapsed_time(end) # 毫秒
stream.wait_stream(other) 是一个常用的快捷方式,作用是让当前 stream 等待 other stream 上当前已提交的所有工作完成。stream 和 event 这两个概念,是 CUDA Graph、通信 overlap、双缓冲 prefetch 等更高层机制共同依赖的底层积木。
4. Caching allocator:显存是怎么管理的¶
PyTorch 并不会每次都直接调用 cudaMalloc / cudaFree,那样的开销很大而且会引入同步。它内部维护了一个 caching allocator:第一次申请内存时,会一次性向系统申请一大块(cudaMalloc),之后 free 掉的内存不会真的还给系统,而是进入缓存池以便复用。由此带来两个直接后果:
del tensor或者变量出了作用域之后,对应的显存会回到 PyTorch 自己的缓存池,而不是还给操作系统。所以用nvidia-smi看到占用没有下降,是完全正常的现象。- 只有调用
torch.cuda.empty_cache(),才会把缓存池里的空闲块真正还给系统。这个操作一般只在需要把显存让给别的进程时才用,平时训练不需要调用它,而且它本身会引入一次同步。
排查 OOM 或者显存泄漏时,下面这几个显存观测 API 很常用:
torch.cuda.memory_allocated() # 当前张量实际占用
torch.cuda.max_memory_allocated() # 峰值(找峰值在哪一步)
torch.cuda.memory_reserved() # allocator 向 OS 要的总量(含缓存)
torch.cuda.reset_peak_memory_stats()# 重置峰值统计,分段测量
print(torch.cuda.memory_summary()) # 人类可读的详细分块报告
如果想搞清楚显存究竟被谁吃掉了,显存历史快照是最终的排查手段:
torch.cuda.memory._record_memory_history(max_entries=100000)
# ... 跑一段训练 ...
torch.cuda.memory._dump_snapshot("snap.pickle")
# 上传到 https://pytorch.org/memory_viz 可视化每块内存的分配栈
还有一个常见现象是碎片化:跑长序列或者变长 batch 时,经常会出现 reserved 远大于 allocated、但依然 OOM 的情况。根本原因在于 cudaMalloc 出来的各个段彼此之间无法合并,再加上段内部被存活的 block 占住之后留下的空洞越来越多。碎片化的准确定义、CUDA VMM 怎么把虚拟地址和物理地址拆开,以及 PYTORCH_ALLOC_CONF=expandable_segments:True 的完整机制,留到 06 详细讲。现在训练大模型,打开 expandable segments 基本已经是标配做法。
5. Pinned memory 与异步 H2D 拷贝¶
普通的 CPU 内存(pageable 内存)要拷贝到 GPU 时,必须先经过一次中转,而且这个过程无法真正和 kernel 执行重叠。pinned(page-locked)内存则可以被 GPU 直接 DMA 访问,配合 non_blocking=True 就能实现 H2D 拷贝和计算的重叠:
# DataLoader 里开 pin_memory,拿到的 batch 在 pinned 内存
loader = DataLoader(ds, pin_memory=True)
# 异步拷到 GPU:拷贝在后台进行,CPU 不等
x = batch.to('cuda', non_blocking=True) # 仅对 pinned 源 + 不同 device 有效
# ... 这之间可以做点 CPU 工作 / 上一个 batch 的计算 ...
这里有几个要点要记住:
non_blocking=True只有在源内存是 pinned 时才真正生效;如果源是普通的 pageable 内存,它会静默退化为同步拷贝,不会报错但也不会有加速效果。- 一个经典的 prefetch 模式是:在一个独立的 copy stream 上用
non_blocking方式提前拷贝下一个 batch,让这个拷贝和当前 batch 的计算重叠起来,从而把数据加载的延迟隐藏掉。 - D2H 拷贝(也就是
.cpu())同样可以异步进行,但要注意,读取拷回 CPU 的结果之前必须先同步。
6. 混合精度(AMP):autocast + GradScaler¶
LLM 训练通常用 bf16 或 fp16 做实际计算,同时用 fp32 保存主权重和 optimizer state。torch 的 AMP 机制会自动决定每个 op 该用哪种精度。
6.1 autocast:自动选精度¶
with torch.autocast(device_type='cuda', dtype=torch.bfloat16):
out = model(x) # matmul/conv 等自动用 bf16,归一化/softmax/loss 留 fp32
loss = F.cross_entropy(out, y)
# 反向在 autocast 区外,梯度按各 op 前向的 dtype 走
loss.backward()
autocast 内部维护着一张 op 白名单:像 matmul、linear、attention 这类计算量大、数值上也比较鲁棒的 op 会被转成低精度;而 reduction、norm、softmax、loss、exp 这类对精度比较敏感的 op 则会保留在 fp32 上计算。正因为如此,不需要手动调用 .half() 把整个模型转成低精度——那样会把本不该降精度的部分也一起降了,容易带来数值问题。
6.2 GradScaler:只有 fp16 需要¶
fp16 的动态范围比较小,很小的梯度值容易直接下溢成 0。GradScaler 的做法是把 loss 乘上一个较大的 scale 再做反向传播,这样梯度会按同样的比例放大,从而避免下溢;到 optimizer step 之前再把梯度除回去,同时自动跳过出现 inf 或 nan 的那些 step:
scaler = torch.cuda.amp.GradScaler()
with torch.autocast('cuda', dtype=torch.float16):
loss = model(x)
scaler.scale(loss).backward() # 放大 loss 再反向
scaler.unscale_(optimizer) # (可选)裁剪前要先 unscale
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(optimizer) # 内部 unscale + 检查 inf/nan + step
scaler.update() # 动态调整 scale
bf16 因为动态范围和 fp32 相同,不会出现下溢问题,所以不需要 GradScaler。这也是大模型训练默认选择 bf16 的重要原因之一——整个训练流程可以更简单。
| fp16 | bf16 | |
|---|---|---|
| 动态范围 | 小(易溢出/下溢) | 同 fp32 |
| 需要 GradScaler | 是 | 否 |
| 精度(尾数) | 略高 | 略低 |
| 适用 | 老硬件、推理 | 训练默认 |
6.3 FP8¶
torch.float8_e4m3fn / e5m2 配合 per-tensor 或 per-row 的 scaling,可以用来做 FP8 的 GEMM 和通信,是 Hopper 架构之后训练和推理提速的重要手段。torch 原生对 FP8 的支持还比较有限,生产环境一般会用 TransformerEngine 或者 torchao 提供的 FP8 recipe。相比 bf16,FP8 需要额外管理 scaling factor 和 amax 历史,工程复杂度更高一些。
7. cuDNN / cuBLAS 后端开关¶
下面这几个全局开关会同时影响数值结果和性能:
# TF32:用 tensor core 加速 fp32 matmul(牺牲少量精度),A100+ 默认开
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
# cuDNN autotuner:固定 shape 时自动选最快 kernel(变长 shape 反而变慢)
torch.backends.cudnn.benchmark = True
# bf16 GEMM 的累加精度(reduced precision reduction)
torch.backends.cuda.matmul.allow_bf16_reduced_precision_reduction = False
cudnn.benchmark=True 在输入尺寸固定的场景(比如典型的 CNN 训练)下能帮你选到更快的 kernel;但 LLM 场景里序列长度经常变化,每出现一个新的 shape 都要重新做一次 autotune,反而会拖慢速度。通常的权衡做法是配合 bucketing,把实际出现的 shape 限制在有限的几种之内。
8. 设备管理杂项¶
torch.cuda.is_available()
torch.cuda.device_count()
torch.cuda.current_device()
torch.cuda.set_device(local_rank) # 每进程绑卡
with torch.cuda.device(1): ... # 临时切设备
torch.cuda.get_device_properties(0) # SM 数、显存、算力
torch.cuda.synchronize() # 等当前设备所有 stream 完成
这一篇提到的显存"池子",下一篇会被进一步拆开成 segment、block、pool 三层结构,讲清楚碎片化到底是怎么产生的、为什么有三种情况都合并不了,以及 CUDA VMM 和 expandable_segments 的完整工作机制,见 06 · Caching Allocator 与 CUDA VMM;再往后是 07 · CUDA Graph,讲 CUDA Graph 怎么把地址固定在一个 private mempool 上,以及 08 · torch.compile / profiler 里的 compile 和 profiler。