跳到主要内容
AI 系统性能工程方法论

第7章:PyTorch Profiling 与 Tuning

PyTorch profiler / torch.compile / CUDA Graph / TF32-AMP-FP8 决策树 / channels_last / DataLoader 调参——把性能工程方法论落到 PyTorch 框架层

PyTorch torch.compile TorchInductor CUDA Graph AMP FP8 Profiler

CUDA kernel 写得再好,框架调度不对也跑不快。这一章把 Ch5-6 的”硬件视角”和上层 PyTorch 框架层串起来——从 profiler 看 timeline、torch.compile 工作机制、CUDA Graph 决策、AMP 精度选型、内存布局调优,到 DataLoader 系列参数——给出一份可直接照着改的 PyTorch 性能调优决策树。

📑 目录


1. PyTorch Profiler:框架层的时间线

1.1 PyTorch Profiler vs Nsight Systems

工具视角强项
Nsight SystemsOS / 硬件看 CPU/GPU/通信全貌
PyTorch Profiler框架层看 op 级 / module 级时间分布

两者互补:通常先用 PyTorch Profiler 找出最慢的 op,再用 Nsight Systems 看 op 之间的间隙是不是被 CPU/IO 占了。

1.2 基本用法

from torch.profiler import profile, record_function, ProfilerActivity

with profile(
    activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
    schedule=torch.profiler.schedule(wait=1, warmup=1, active=3, repeat=1),
    on_trace_ready=torch.profiler.tensorboard_trace_handler('./logs'),
    record_shapes=True,
    profile_memory=True,
    with_stack=True,
) as prof:
    for step, batch in enumerate(loader):
        with record_function("training_step"):
            train_step(batch)
        prof.step()

输出可以直接打开 TensorBoard 看 timeline。

1.3 该看什么

打开 TensorBoard Profiler 后按这个顺序看:

1. Overview 页

  • GPU Kernel time / CPU time / Other time 比例
  • “GPU Idle Time” → 严重时说明 GPU 在等
  • 推荐操作:直接列出”top 10 GPU ops by cuda time”——找最大头

2. Operator 页

  • 每个 op 的累计时间、调用次数
  • self CPU time vs cumulative CPU time(包含子 op)
  • shape 列:检查每个 op 实际的输入 shape

3. Trace 页

  • 时间线视图——可以放大看 step 内每一段
  • CPU 行 + CUDA stream 行同时显示
  • 通过 NVTX 标签可以加自定义层次

1.4 实战经验

  • 只 profile 几个 step:activity 设 3-5 步就够,profile 本身有开销
  • 配合 NVTX:在自己代码里加 torch.cuda.nvtx.range_push("xxx"),让时间线更可读
  • memory profilingprofile_memory=True 能看每个 op 分配 / 释放多少显存——OOM 排查必备

2. torch.compile 工作机制

2.1 三件套:Dynamo + AOTAutograd + Inductor

torch.compile(model) 背后是三个组件:

Python 代码


┌─────────────────────────────────────┐
│ Dynamo                              │
│ - 在 Python bytecode 层面 trace    │
│ - 把 model 切成 "fx graph" 子段     │
│ - 遇到 Python 控制流就 graph break  │
└──────────────┬──────────────────────┘


┌─────────────────────────────────────┐
│ AOTAutograd                         │
│ - 把 forward graph 自动加上 backward│
│ - 输出 forward + backward 的整图    │
└──────────────┬──────────────────────┘


┌─────────────────────────────────────┐
│ Inductor                            │
│ - 后端编译器:把 fx graph → Triton  │
│ - 自动做 fusion / vectorize / tiling│
│ - 生成 CUDA / CPU / ROCm kernel    │
└─────────────────────────────────────┘

2.2 三种编译模式

model = torch.compile(model, mode="default")        # 平衡
model = torch.compile(model, mode="reduce-overhead") # 用 CUDA Graph
model = torch.compile(model, mode="max-autotune")   # 极致优化(编译慢)
模式编译时间运行性能推荐场景
default几十秒1.2-1.5x大多数训练
reduce-overhead1-2 分钟1.5-2x推理 / 小 batch
max-autotune10-30 分钟2-3x长跑生产任务

2.3 Graph break:torch.compile 的最大杀手

Dynamo 遇到不可 trace 的代码会 graph break——把图切成多段,每段单独编译,段之间退到 eager mode:

触发 graph break 的常见操作替代方案
if x.item() > 0用 mask + select
print(x)删掉,profiling 用 NVTX
自定义 torch.autograd.Function(旧 API)@torch.compile(fullgraph=True) 强制报错定位
.tolist() / .cpu() 中间出现推到模型外

调试 graph break

import torch._dynamo as dynamo
dynamo.config.verbose = True
torch.compile(model)(input)
# log 会列出所有 graph break 的位置和原因

2.4 一个真实陷阱:dynamic shape

如果输入 shape 经常变(比如不同长度的序列),torch.compile 会反复重新编译——每个新 shape 一次。两条解决路径:

  • Padding 到固定 shape:丢一点效率换稳定性
  • torch.compile(model, dynamic=True):让编译器生成支持 dynamic 的 kernel(性能略差但稳定)

3. CUDA Graph:什么时候开、什么时候不开

3.1 CUDA Graph 是什么

CUDA Graph 把一段固定的 GPU 操作序列录制成一张图,每次 launch 这张图只需要一次系统调用——把 N 个 kernel launch 的 CPU 开销摊薄到 1 个。

适合:

  • 训练 / 推理的核心 step(每次 step 操作序列固定)
  • batch size 固定、shape 固定的负载

3.2 怎么用 CUDA Graph

PyTorch 里有几种方式:

1. torch.compile(mode="reduce-overhead"):自动开 CUDA Graph

2. 手动 record + replay

g = torch.cuda.CUDAGraph()
with torch.cuda.graph(g):
    output = model(static_input)
    loss = criterion(output, static_target)
    loss.backward()

# 后续每个 step 只需 replay
for batch in loader:
    static_input.copy_(batch)
    g.replay()
    optimizer.step()

3. torch.cuda.make_graphed_callables:自动版本

3.3 什么时候不开

CUDA Graph 不适合:

  • ❌ Shape / control flow 经常变的负载(每次重 record 反而慢)
  • ❌ kernel 单次很大的负载(launch 开销占比小)
  • ❌ debug 阶段(graph 内部不能 print)

🌟 经验:CUDA Graph 适合单步 kernel 数 > 50 且每个 kernel < 100 µs的场景——典型是小 batch size 推理。

4. 精度决策树:TF32 / BF16 / FP16 / FP8

4.1 五种精度的对照

精度指数 / 尾数位数值范围精度损失Tensor Core 加速
FP328 / 23极宽1x(基线)
TF328 / 10同 FP322x
BF168 / 7同 FP324x
FP165 / 10较窄4x
FP84-5 / 2-3极窄巨大8x(H100)

4.2 决策树

你在做什么?
├─ 训练
│  ├─ 默认:BF16 + FP32 master weight (AMP)
│  ├─ 不稳定(loss spike):FP16 + FP32 master weight + GradScaler
│  ├─ H100+ 上的大模型:FP8(用 transformer-engine)
│  └─ 全精度调试:FP32
└─ 推理
   ├─ 默认:BF16 / FP16
   ├─ 极致延迟:FP8 (H100+)
   ├─ 量化部署:INT8 / INT4
   └─ 高精度业务(金融 / 医疗):FP32 / TF32

4.3 BF16 vs FP16 的选择

  • BF16 数值范围和 FP32 一致——几乎不会 overflow / underflow——适合大部分训练
  • FP16 数值范围窄——可能 overflow,但精度位多——适合特定 inference / 老硬件
  • 2024+ 默认选 BF16

4.4 FP8 的工程现实

H100 引入 FP8 (E4M3 + E5M2 两种格式)。开 FP8 的方法:

from transformer_engine.pytorch import Linear, fp8_autocast

with fp8_autocast():
    output = my_linear(input)

实测在大模型训练上能拿到 1.3-1.6x 加速 vs BF16——但需要 H100、需要 transformer-engine、需要 calibration。

5. AMP 默认策略与坑

5.1 AMP 是什么

AMP (Automatic Mixed Precision) 让一段计算自动用低精度跑:

from torch import autocast, GradScaler

scaler = GradScaler()
for batch in loader:
    with autocast(device_type="cuda", dtype=torch.bfloat16):
        output = model(batch)
        loss = criterion(output, target)
    
    scaler.scale(loss).backward()
    scaler.step(optimizer)
    scaler.update()

autocast 自动决定每个 op 用什么精度。

5.2 AMP 内部的精度规则

PyTorch AMP 把 op 分三类:

  • Always FP32torch.softmax / log / exp / pow 等数值敏感的
  • Always BF16/FP16matmul / conv 等吞吐密集的
  • Match input:随上下游而定

工程含义:不要担心 AMP 把所有东西都 cast 到 FP16——它会自动保留数值敏感 op 的精度。

5.3 三个常见的 AMP 坑

坑 1:自定义 op 不在 AMP 列表

如果你写了 custom CUDA op,AMP 默认会按”match input”处理——可能导致它在 BF16 输入上跑——而你的 kernel 没考虑 BF16。

对策

@torch.amp.custom_fwd(cast_inputs=torch.float32)
def my_op_forward(...):
    ...

坑 2:Loss 是 NaN

FP16 的 overflow 是常见原因。诊断:

  • 看 loss 是不是几个 step 后才变 NaN
  • 看 GradScaler 的 scale 值(连续报告 inf 时会自动减半)
  • 解决方法:换 BF16

坑 3:不需要 GradScaler 的 BF16

GradScaler 是 FP16 的伴生工具,BF16 范围足够大不需要。用 BF16 时不要用 GradScaler——会引入 host-device sync。

6. Memory snapshot 与 OOM 排查

6.1 PyTorch 显存模型

PyTorch 有自己的显存分配器(caching allocator)——不是直接调 cudaMalloc。这意味着:

  • nvidia-smi 看到的”显存使用”≠ 实际程序真正用的
  • PyTorch 会保留释放的显存以备下次用

6.2 Memory snapshot 用法

torch.cuda.memory._record_memory_history(max_entries=10000)

# 跑你的训练 / 推理代码
train()

# 导出 snapshot
torch.cuda.memory._dump_snapshot("memory_snapshot.pickle")

然后用 PyTorch 提供的可视化工具打开 pickle——能看到:

  • 每个 tensor 的分配 / 释放时间线
  • 哪些 tensor 一直没被释放(潜在泄漏)
  • peak memory 时谁占用最大

6.3 OOM 的三类常见根因

根因表现排查方法
Tensor 泄漏显存随 step 数增长snapshot 看哪些 tensor 长期不释放
中间激活值过大单 step 内 peak 超看 forward 阶段哪个 op 输出最大
优化器状态过大模型和优化器各占一半用 ZeRO / FSDP 分摊

6.4 减少显存的常见手段

按”好用 → 改动大”排序:

  1. gradient checkpointing:用 30% 计算换 50%+ 激活值显存
  2. AMP:BF16 直接砍一半激活值显存
  3. torch.compile(mode="reduce-overhead"):编译时做 memory planning
  4. fused optimizeroptim.AdamW(fused=True) 减少中间 buffer
  5. batch size halving:最朴素但最 reliable
  6. FSDP / ZeRO:分布式分摊优化器状态和模型参数

7. channels_last 内存布局

7.1 NCHW vs NHWC

PyTorch 默认用 NCHW(通道在前),但 cuDNN / Tensor Core 在 NHWC 上更快——尤其卷积。

# 改成 channels_last (NHWC)
model = model.to(memory_format=torch.channels_last)
input = input.to(memory_format=torch.channels_last)

# forward 走 channels_last 路径
output = model(input)

7.2 性能收益

  • 卷积训练 / 推理:通常 1.3-2x 加速
  • ResNet-50 / EfficientNet 这种典型 CV 模型:直接开 channels_last
  • 对 transformer:基本没用(没有空间维度)

7.3 一个常见坑

并不是所有 op 都支持 channels_last——如果中间某个 op 不支持,PyTorch 会自动 cast 回 NCHW,然后输出又 cast 回 NHWC——两次 layout 转换比不开还慢

调试:用 PyTorch Profiler 看是不是有大量 _to_copy op——那就是 layout 转换的体现。

8. DataLoader 调参清单

8.1 关键参数

loader = DataLoader(
    dataset,
    batch_size=128,
    num_workers=8,           # CPU worker 数
    pin_memory=True,         # 用 pinned memory 加速 H2D
    prefetch_factor=2,       # 每 worker 预读 2 个 batch
    persistent_workers=True, # epoch 之间 worker 不重启
    drop_last=True,          # 避免最后一个不完整 batch
)

8.2 num_workers 的经验值

  • CPU 数 ≥ GPU 数 × 4:num_workers = CPU 数
  • CPU 数有限:num_workers = max(2, CPU/2)
  • 从 0 开始:先验证模型本身正确,再开 workers

8.3 pin_memory + non_blocking

input = batch.to(device, non_blocking=True)  # H2D 异步

pin_memory=True 配合 non_blocking=True 能让 H2D 拷贝异步进行——和上一 step 的 GPU 计算重叠。

8.4 prefetch_factor

每个 worker 预先读 prefetch_factor 个 batch 准备着。太小(=1)→ workers 经常 idle;太大(>4)→ 内存占用大。默认 2 通常够。

9. 一份 PyTorch 调优决策树

把上面所有内容压缩成一份可直接照着改的决策树:

你的模型 / 训练 / 推理慢吗?

├─ 先用 PyTorch Profiler 看
│   ├─ GPU Idle Time 高 → DataLoader / IO / CPU 是瓶颈
│   │   ├─ num_workers ↑ + pin_memory + prefetch_factor
│   │   ├─ 数据预处理移到 GPU(torchvision.transforms.v2)
│   │   └─ 文件 IO 调优(NVMe / RDMA / shm)
│   │
│   └─ GPU 利用率高但慢
│       ├─ 用 Nsight Systems 看 op 间隙
│       └─ 用 Nsight Compute 看具体 kernel

├─ 模型本身可优化
│   ├─ 默认开 AMP(BF16)
│   ├─ 默认开 channels_last(CV 模型)
│   ├─ 训练:torch.compile(mode="default")
│   ├─ 推理:torch.compile(mode="reduce-overhead") 或 CUDA Graph
│   └─ 长跑生产:torch.compile(mode="max-autotune")

├─ 显存压力
│   ├─ AMP / FP16 / BF16
│   ├─ Gradient checkpointing
│   ├─ Fused optimizer
│   ├─ 减 batch size
│   └─ 上 FSDP / DeepSpeed

└─ 还慢
    └─ 自己写 kernel(Ch6 + Triton)

🎯 自我检验清单

  • PyTorch Profiler 和 Nsight Systems 在调优工作中各自的角色是什么?
  • torch.compile 的三件套(Dynamo / AOTAutograd / Inductor)各自做什么?什么是 graph break?
  • BF16 和 FP16 的区别是什么?在训练中默认选哪个?为什么?
  • AMP 内部的”Always FP32 / Always BF16 / Match input”三类 op 划分背后的工程理由是什么?
  • DataLoader 慢的几种典型表现是什么?num_workers / pin_memory / prefetch_factor 各自调什么?

📚 参考资料


下一章预告:Ch8 把视角从”单卡 / 单机”扩到”万卡集群”——3D 并行、ZeRO/FSDP、MoE、Sequence Parallelism、容错——给一份完整的超大规模分布式训练运维指南。