系列 《PyTorch 深度实践:从 Tensor 到深度学习运行时》 第 8 / 11 篇 深入
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
学习地图 — AI-Infra 工程师学习地图 第 3 / 14 个系列
上一篇:编译执行与图优化下一篇:分布式 PyTorch
前面七篇建立了 PyTorch 的执行模型:Tensor 如何存储(第二篇),Autograd 如何记录反向(第三篇),算子如何分发到 Kernel(第五篇),编译器如何把多个算子融合成更少的 Kernel(第七篇)。每一篇都在某处留下一句”性能问题第八篇讨论”。
这一篇兑现这些承诺。它围绕一个问题展开:
如何判断一个 PyTorch 程序慢,以及如何定位它为什么慢?1
这个问题比”如何让它变快”更基础。AI-Infra 工作中大量的性能优化失败,不是因为不知道优化手段,而是因为没有正确地测量,或者对着错误的瓶颈用力:GPU 利用率只有 30% 时去优化 Kernel,显存碎片导致 OOM 时去减 batch,Python 开销占主导时去换更快的 cuBLAS 版本。
所以本文的重心是性能模型和测量方法,优化手段是模型推导出来的结论。
性能优化的第一步不是修改代码,而是回答”时间花在哪一层”。答错这个问题,之后所有努力都在错误的地方。
本文的数字(延迟、带宽、利用率)除明确标注的硬件规格外均为示意,用于说明数量级和比例关系;具体值随硬件、驱动、PyTorch 版本变化。测量方法和分析框架比数字本身持久。
一、总览:一次 step 的时间去哪了
1. 两条时间线
理解 PyTorch 性能的起点是一个事实:CPU 和 GPU 是两个独立运行的处理器,通过一条队列连接。
CPU 时间线 Python 解释 → Dispatcher → launch Kernel A → launch Kernel B → launch Kernel C → ...
│ │ │
▼ 入队 ▼ 入队 ▼ 入队
GPU 时间线 ──[Kernel A]──[Kernel B]──────[Kernel C]──...
CPU 执行 Python 代码,走完第五篇的入口和分发,把 Kernel 提交到 GPU 的队列(CUDA Stream)后立即返回,继续执行下一行 Python。GPU 从队列中按序取出 Kernel 执行。两者异步。
这意味着一次训练 step 的耗时由两条时间线中较长的那条决定,而且两条线上各有不同的瓶颈:
| 时间线 | 可能的瓶颈 | 典型表现 |
|---|---|---|
| CPU 侧 | Python 解释开销、Dispatcher 开销、Kernel launch 开销、数据加载 | GPU 空闲等待 CPU 提交,利用率低 |
| GPU 侧 | Kernel 计算受限、Kernel 访存受限 | GPU 忙,CPU 等待 GPU 完成 |
| 两者之间 | 同步点:CPU 必须等 GPU 结果才能继续 | 两条线都有空闲 |
2. 时间维度:五类瓶颈
把上表展开,一个 PyTorch 程序慢,几乎总是以下五类之一或其组合:
| 位置 | 类别 | 时间花在 | 判断依据 | 主要处方 |
|---|---|---|---|---|
| CPU 侧 | Python-bound | Python 解释、框架逻辑 | GPU 利用率低,CPU 100%,Profiler 中 Python 函数占主导 | 减少 Python 层操作次数、torch.compile、向量化 |
| CPU 侧 | Launch-bound | 提交大量小 Kernel | GPU 利用率低,Kernel 平均耗时几微秒,数量多 | 增大 batch、算子融合、CUDA Graphs |
| GPU 侧 | Memory-bound | 搬运数据 | Kernel 达到显存带宽上限,算力利用低 | 融合减少中间结果、降低精度、改变数据布局 |
| GPU 侧 | Compute-bound | 做算术 | Kernel 达到算力上限 | 用 Tensor Core(低精度)、更好的算法、更少的计算量 |
| 两侧之间 | Sync-bound | CPU 等 GPU 或 GPU 等 CPU | 时间线上两侧都有空洞,同步调用频繁 | 消除隐式同步点、异步数据传输、重叠通信与计算 |
同一个程序在不同条件下会落入不同类别:batch 很小时 launch-bound,batch 大了变 memory-bound,换成低精度后可能变 compute-bound。优化就是不断把瓶颈从一类推到另一类,直到达到硬件上限或成本上限。
3. 空间维度:显存
时间不是唯一的维度。显存决定了 batch 能开多大、模型能放多大——而 batch 大小反过来决定了上面五类中的哪一类是瓶颈。所以显存分析是性能分析的一部分,不是独立话题。
显存问题有自己的分类:
- 真的不够:参数 + 梯度 + 优化器状态 + 激活值 超过物理显存
- 碎片:allocated 远小于 reserved,有空间但不连续
- 泄漏:allocated 随 step 单调增长
- 峰值:平均占用不高,某一时刻(如反向开始时)尖峰 OOM
两个维度之间可以交换:Activation Checkpointing 用时间换空间;混合精度同时省时间和空间,代价是数值精度。
4. 工具地图
每种工具只能观察模型中的某几层。用错工具,看到的是噪声。
| 工具 | 观察什么 | 看不到什么 |
|---|---|---|
time.time() + synchronize |
端到端墙钟时间 | 时间花在哪一层 |
torch.utils.benchmark |
单个操作的稳定耗时 | 整体程序的行为 |
torch.profiler |
CPU 算子 / GPU Kernel 时间线、调用栈、显存事件 | Kernel 内部为什么慢 |
| Nsight Systems | 系统级时间线:CPU 线程、CUDA API、Kernel、内存拷贝、NVTX 标记 | Kernel 内部为什么慢 |
| Nsight Compute | 单个 Kernel 的硬件指标:占用率、带宽利用、指令吞吐 | 程序整体 |
torch.cuda.memory_* / memory snapshot |
显存分配的时间线与调用栈 | 时间性能 |
顺序通常是:先用 Profiler 或 Nsight Systems 确定哪一类瓶颈,再决定是否需要 Nsight Compute 深入某个 Kernel。绝大多数问题在第一步就能定位。
5. 诊断流程
所有性能工作遵循同一条流程,第九章的案例会完整走一遍:
- 建立基线 可复现的测量脚本,固定输入、固定环境,记录数字
- 设计正确性测试 改之前就要有:与参考实现比对,容差明确
- 采集性能数据 Profiler / Nsight,看时间线而不是猜
- 定位瓶颈 归入五类之一,确认是主要矛盾
- 修改实现 一次只改一件事
- 重新 Benchmark 同一脚本、同一环境
- 确认没有回归 正确性测试通过;显存、精度、编译时间是否变差
其中”定位瓶颈”一步是一棵决策树:先看 GPU 泳道的形态,再看是谁在占时间,最后落到五类之一及其处方(工具与处方的完整对照见 §4 的表格和第四章 §6):
%% 图:定位瓶颈的决策树:先看 GPU 泳道形态,再看谁占时间,落到 Launch / Python / Sync / Memory / Compute 五类之一
flowchart TB
tl["采集时间线:torch.profiler / nsys"] --> q1{"GPU 泳道的形态?"}
q1 -->|"稀疏,大段空闲"| q2{"CPU 在忙什么?"}
q1 -->|"与 CPU 泳道交替空洞"| sync["Sync-bound<br/>第七章"]
q1 -->|"密集,Kernel 首尾相接"| q3{"CUDA 时间被谁占?"}
q2 -->|"cudaLaunchKernel 占大头<br/>Kernel 多而短"| launch["Launch-bound<br/>第五章"]
q2 -->|"Python 函数 / 框架逻辑"| py["Python-bound<br/>第五章"]
q3 -->|"add / layer_norm<br/>softmax 等逐元素与归约"| mem["Memory-bound<br/>第六章"]
q3 -->|"mm / bmm / conv"| comp["Compute-bound<br/>第六章"]
launch --> launch_rx["增大 batch、融合<br/>fused 优化器<br/>CUDA Graphs"]
py --> py_rx["向量化、compile<br/>预处理移出循环"]
sync --> data["特例:数据加载<br/>GPU 大段空白,CPU 停在 DataLoader"]
data --> sync_rx["批量 .item()<br/>pinned + non_blocking<br/>num_workers、prefetch"]
mem --> mem_rx["融合 / SDPA<br/>bf16、数据布局"]
comp --> comp_rx["Tensor Core<br/>bf16 / TF32<br/>减少计算量"]
classDef cpu fill:#fde9d9,stroke:#c0392b;
classDef gpu fill:#e8f5e9,stroke:#1e8449;
classDef mid fill:#fff4d6,stroke:#b9770e;
classDef rx fill:#f4f4f4,stroke:#888;
class launch,py cpu;
class mem,comp gpu;
class sync,data mid;
class launch_rx,py_rx,sync_rx,mem_rx,comp_rx rx;
6. 本文的章节安排
| 章 | 主题 | 内容 |
|---|---|---|
| 度量与工具地图:归类之前必须先测对 | ||
| 二 | 度量(1):异步执行模型 | 正确计时的前提:Stream、同步是测量的边界 |
| 三 | 度量(2):Benchmark 方法 | 怎么得到可信的数字:warmup、口径、正确性先于性能 |
| 四 | 度量(3):Profiler 与 Nsight | 怎么看出瓶颈属于哪一类:表格、时间线、nsys、ncu |
| 时间维度:五类瓶颈 | ||
| 五 | 时间维度(1):CPU 侧 | Python-bound 与 Launch-bound,六个处方 |
| 六 | 时间维度(2):GPU 侧 | Memory-bound 与 Compute-bound(含处方:融合、低精度、数据布局) |
| 七 | 时间维度(3):两侧之间 | Sync-bound(含数据加载) |
| 八 | 空间维度:显存 | 构成、Caching Allocator、碎片、峰值、泄漏、时间换空间 |
| 九 | 完整案例 | 一个 Transformer block 的训练 step |
| 十 | Java 对照 | |
| 十一 | 本文小结 | |
| 十二 | 自测 | 5 道题 |
二、度量(1):异步执行模型——正确计时的前提
1. 为什么 time.time() 会骗你
import time
start = time.time()
y = model(x) # 提交几百个 Kernel 到队列,立即返回
elapsed = time.time() - start # 测到的是"提交"耗时,不是"执行"耗时
如果 GPU 侧的工作比 CPU 侧多,elapsed 会远小于真实耗时;如果之后某处发生了同步,那一行代码会显得异常慢——但慢的不是它,是它在等前面所有 Kernel 完成。
这是性能分析中最常见的误读来源。修正方法是在计时前后显式同步:
torch.cuda.synchronize()
start = time.time()
y = model(x)
torch.cuda.synchronize() # 等待队列中所有 Kernel 完成
elapsed = time.time() - start
或者用 CUDA Event 在 GPU 时间线上打点,避免 CPU 参与:
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
start.record()
y = model(x)
end.record()
torch.cuda.synchronize()
elapsed_ms = start.elapsed_time(end)
Event 记录的是 GPU 执行到该位置的时刻,因此测量的是 GPU 侧的真实区间,包括其间所有 Kernel 的执行时间,也包括 GPU 在两个 Event 之间空等 CPU 提交下一个 Kernel 的空档。所以它不等于”纯 Kernel 时间之和”:模型小、launch 密集时,Event 区间会被 CPU 侧拖长——这恰恰是第五章要诊断的 launch-bound;要分开两者,看 Profiler 时间线里 GPU 行的空隙。
2. Stream:队列本身
CUDA Stream 是 GPU 的一条命令队列。同一 Stream 内的 Kernel 按提交顺序串行执行;不同 Stream 之间可以并发。PyTorch 默认所有操作提交到当前设备的默认 Stream,这保证了 y = f(x); z = g(y) 的顺序正确——g 的 Kernel 排在 f 的后面。
多 Stream 是第七章 §5 重叠传输与计算的基础。跨 Stream 的依赖需要用 Event 或 wait_stream 显式表达,否则会读到未完成的数据——这是多 Stream 最常见的 bug。
3. 同步是测量的边界
任何需要 CPU 拿到 GPU 数据的操作都会阻塞 CPU 直到队列排空——这就是同步。对测量而言,同步是必要的:没有同步就测不到 GPU 时间。对性能而言,同步是有代价的:它让两条时间线互相等待。
把 §1 的三段代码放到两条时间线上,就能看清 time.time() 到底测到了什么、synchronize() 在哪里把 CPU 拖住:
%% 图:time.time() 测到了什么:launch 立即返回,只有 synchronize() 把 CPU 拖住等 GPU 队列排空
sequenceDiagram
participant CPU as CPU 线程(Python)
participant GPU as GPU default stream
Note over CPU: t0 = time.time()
CPU->>GPU: launch Kernel A(入队,立即返回)
Note over GPU: 开始执行 A
CPU->>GPU: launch Kernel B(入队,立即返回)
CPU->>GPU: launch Kernel C(入队,立即返回)
Note over CPU: t1 = time.time()<br/>t1 - t0 ≈ 三次 launch 的 CPU 开销(几十微秒)
Note over GPU: A 完成,执行 B
CPU->>GPU: torch.cuda.synchronize()
activate CPU
Note over CPU: CPU 阻塞,等队列排空
Note over GPU: B 完成,执行 C
GPU-->>CPU: 队列排空,synchronize 返回
deactivate CPU
Note over CPU: t2 = time.time()<br/>t2 - t0 = 真实耗时(CPU 提交 + GPU 执行 + 等待)
CUDA Event 则把”打点”这件事交给 GPU 自己做——Event 也是入队的一条命令,GPU 执行到它时记下时间戳,CPU 全程不必参与:
%% 图:CUDA Event 计时:打点由 GPU 自己做,elapsed_time 是纯 GPU 区间,不含 CPU 提交时间
sequenceDiagram
participant CPU as CPU 线程(Python)
participant GPU as GPU default stream
CPU->>GPU: start.record()(入队一个 Event)
CPU->>GPU: launch Kernel A、B、C
CPU->>GPU: end.record()(入队一个 Event)
Note over CPU: CPU 立即继续,不阻塞
Note over GPU: 执行到 start Event,记录时间戳 Ts
Note over GPU: 依次执行 A、B、C
Note over GPU: 执行到 end Event,记录时间戳 Te
CPU->>GPU: torch.cuda.synchronize()
GPU-->>CPU: 队列排空
Note over CPU: start.elapsed_time(end) = Te - Ts<br/>纯 GPU 区间,不含 CPU 提交时间
哪些操作会隐式同步、代价何时显现,是第七章 Sync-bound 的内容。这一章只需记住:测量必须同步,且只在测量边界同步。
三、度量(2):Benchmark 方法——怎么得到可信的数字
1. 用 torch.utils.benchmark
手写计时循环需要处理 warmup、同步、多次采样、统计。torch.utils.benchmark 把这些封装好了:
import torch.utils.benchmark as benchmark
x = torch.randn(4096, 4096, device="cuda")
w = torch.randn(4096, 4096, device="cuda")
t = benchmark.Timer(
stmt="x @ w",
globals={"x": x, "w": w},
label="matmul 4096",
description="fp32",
)
m = t.blocked_autorange(min_run_time=1.0)
print(m)
matmul 4096: fp32
Median: 8.21 ms
IQR: 0.03 ms (8.20 to 8.23)
122 measurements, 1 runs per measurement, 1 thread
它自动在每次测量前后 synchronize,自动决定运行次数直到累计时间达到 min_run_time,报告中位数和四分位距(IQR)而不是均值——性能数据通常是右偏分布(偶发的慢),中位数更稳健。
Compare 可以把多个 Measurement 排成表格对比不同实现、不同 dtype、不同 shape。
2. warmup 为什么必须
第一次运行总是慢的,原因来自多层:
- CUDA 上下文初始化:首次使用 GPU 时创建,数百毫秒
- cuBLAS / cuDNN 句柄与算法选择:首次调用时初始化,cuDNN 可能对多个算法测速(benchmark 模式)
- Caching Allocator 预热:首次分配要向驱动申请,之后复用(第八章)
- torch.compile 冷编译:第七篇:秒级到分钟级
- Python 层的惰性初始化:模块属性缓存、参数展平等
Benchmark 必须先跑几次不计时的迭代把这些排除,否则测到的是初始化成本而不是稳态性能。torch.utils.benchmark 会做 warmup;手写循环要自己做。
3. 测什么:口径决定结论
同一个模型,可以报告很多种数字,结论可能相反:
| 口径 | 含义 | 陷阱 |
|---|---|---|
| 单次前向延迟 | batch 固定,一次 model(x) 耗时 |
batch=1 时几乎总是 launch-bound,不反映训练性能 |
| 吞吐(samples/s) | 单位时间处理的样本数 | 大 batch 吞吐高,但延迟和显存也高 |
| 训练 step 时间 | 前向 + 反向 + 优化器更新 | 反向通常是前向的 2 倍,优化器对大模型不可忽略 |
| 端到端 epoch 时间 | 含数据加载、日志、checkpoint | 数据加载可能是真正的瓶颈 |
| MFU(模型 FLOPs 利用率) | 实际 FLOPs / 硬件峰值 FLOPs | 需要正确计算模型 FLOPs |
报告 Benchmark 时必须写明口径、batch、序列长度、dtype、硬件、PyTorch 版本。缺任何一项,数字不可比。
4. 正确性测试先于性能
优化必然改变实现。改之前就要有测试证明改之后结果仍然正确:
def check(fn_new, fn_ref, *inputs, rtol=1e-3, atol=1e-3):
out_new = fn_new(*inputs)
out_ref = fn_ref(*inputs)
torch.testing.assert_close(out_new, out_ref, rtol=rtol, atol=atol)
容差要有依据:fp32 实现之间的差异通常在 1e-5 量级;涉及 bf16 时 1e-2 是常见起点(第六章 §6 解释为什么);融合改变了浮点结合顺序,1e-6 的容差几乎必然失败。训练场景还应比对若干 step 后的 loss 曲线,而不只是单次前向。
没有正确性测试的”加速”不是优化,是待发现的 bug。
5. 控制变量
- 固定随机种子和输入数据;
- 关闭其他 GPU 进程(
nvidia-smi确认); - 注意 GPU 时钟:连续高负载会触发降频,长 Benchmark 的后半段可能比前半段慢;
torch.backends.cudnn.benchmark = True会让 cuDNN 为每个新 shape 试跑多个算法——对固定 shape 有益,对动态 shape 有害,且首次调用极慢;- 一次只改一个变量。
四、度量(3):Profiler 与 Nsight——怎么看出瓶颈属于哪一类
Benchmark 告诉你”多慢”。要知道”为什么慢”,必须看时间线。
1. torch.profiler
from torch.profiler import profile, ProfilerActivity, schedule, record_function
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
schedule=schedule(wait=1, warmup=2, active=3), # 跳过 1 步,预热 2 步,记录 3 步
record_shapes=True,
profile_memory=True,
with_stack=True,
on_trace_ready=torch.profiler.tensorboard_trace_handler("./log"),
) as prof:
for step, batch in enumerate(loader):
with record_function("forward"):
loss = model(batch).sum()
with record_function("backward"):
loss.backward()
optimizer.step()
optimizer.zero_grad()
prof.step()
if step >= 6:
break
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=15))
它同时记录 CPU 侧的算子调用(第五篇的入口、分发)和 GPU 侧的 Kernel 执行,并把两者关联起来。record_function 在时间线上打自定义标记,schedule 避免记录 warmup。
2. 表格怎么读
key_averages().table() 输出类似:
Name Self CPU % Self CPU CPU total Self CUDA CUDA total # of Calls
aten::mm 2.1% 1.2 ms 1.8 ms 42.3 ms 42.3 ms 96
aten::add_ 1.5% 0.9 ms 0.9 ms 8.1 ms 8.1 ms 384
aten::layer_norm 0.8% 0.5 ms 2.1 ms 6.4 ms 6.4 ms 48
aten::gelu 0.4% 0.2 ms 0.2 ms 4.9 ms 4.9 ms 24
cudaLaunchKernel 38.2% 22.6 ms 22.6 ms 0.0 ms 0.0 ms 1840
...
Self CPU time total: 59.1 ms
Self CUDA time total: 71.5 ms
几个关键读法:
Self CUDA time totalvs 墙钟时间:如果 3 个 step 墙钟 210 ms、CUDA 总时间 71.5 ms,GPU 只忙了三分之一——瓶颈在 CPU 侧或同步。cudaLaunchKernel的 Self CPU:1840 次 launch 花了 22.6 ms,平均 12 µs 一次,占 CPU 时间 38%——典型的 launch-bound 信号。# of Calls大、Self CUDA小的算子:aten::add_384 次共 8.1 ms,每次 21 µs——小 Kernel,融合的候选。CPU total远大于Self CPU:说明这个算子内部调用了别的算子(第五篇的 Composite 路径),例如layer_norm。
3. 时间线怎么读
导出的 trace 用 Chrome 的 chrome://tracing 或 Perfetto 打开,能看到两条(或更多)泳道:
CPU 线程 ▓▓ aten::mm ▓▓ aten::add_ ▓ aten::gelu ▓▓ aten::mm ▓▓ ... ← 密集,几乎无空隙
GPU Stream ░░░[mm]░░░░░░[add]░░░░[gelu]░░░░░░░░[mm]░░░░░░░ ... ← 稀疏,Kernel 之间大量空白
看一眼时间线的形态,比读任何数字都更快地判断瓶颈类型。
4. Nsight Systems:系统级时间线
torch.profiler 看的是 PyTorch 眼中的世界。Nsight Systems(nsys)看的是操作系统眼中的世界:所有线程、CUDA API 调用、Kernel、内存拷贝、甚至 NCCL 通信(第九篇),以及 PyTorch 之外的进程。
nsys profile -t cuda,nvtx,osrt -o report python train.py
PyTorch 用 NVTX 在时间线上标记算子,也可以手动标记:
torch.cuda.nvtx.range_push("attention")
...
torch.cuda.nvtx.range_pop()
用 Nsight Systems 而非 torch.profiler 的场景:怀疑瓶颈在 PyTorch 之外(数据预处理进程、文件 I/O、其他进程抢 GPU);多 GPU 训练看通信与计算的重叠;需要看 CUDA 驱动层的行为(内存拷贝方向、Stream 依赖)。
5. Nsight Compute:单个 Kernel 的内部
前两个工具回答”哪个 Kernel 慢”。Nsight Compute(ncu)回答”这个 Kernel 为什么慢”:它重放单个 Kernel,采集硬件计数器。
ncu --set full --kernel-name regex:triton_poi_fused_add_relu -c 1 python demo.py
关键指标:
| 指标 | 含义 | 判断 |
|---|---|---|
| DRAM Throughput | 显存带宽利用率 | 接近 100% → memory-bound |
| SM Throughput / Compute Throughput | 算力利用率 | 接近 100% → compute-bound |
| Achieved Occupancy | 实际驻留的 warp 数 / 最大值 | 过低 → 无法隐藏延迟 |
| Registers / Shared Memory per Block | 资源占用 | 是限制 occupancy 的原因 |
| Memory Coalescing / L2 Hit Rate | 访存效率 | 低 → 访存模式差(如非连续 stride) |
这是最深的一层,只在已经确认某个 Kernel 是瓶颈、且要动手写或改 Kernel(第六篇)时才需要。理解它的指标需要第六章的性能模型。
6. 从形态到归类
本章到此结束。把测量结果对应到五类瓶颈,是进入第五至七章的入口:
| 时间线形态 | Profiler 表格特征 | 归类 | 章节 |
|---|---|---|---|
| CPU 泳道密集无空隙,GPU 泳道稀疏、Kernel 短 | cudaLaunchKernel 占 CPU 大头;Kernel 数多、平均几微秒 |
Launch-bound | 五 |
| CPU 泳道密集,但大部分时间不在算子上 | Python 函数、DataLoader、框架逻辑占 CPU 大头 |
Python-bound | 五 |
| GPU 泳道密集首尾相接,逐元素算子占 CUDA 时间大头 | add、mul、layer_norm、softmax 等累计占比高 |
Memory-bound | 六 |
| GPU 泳道密集,矩阵乘占 CUDA 时间大头 | mm、bmm、conv 占比高 |
Compute-bound | 六 |
| 两条泳道周期性交替空洞 | cudaStreamSynchronize、cudaMemcpy 频繁出现 |
Sync-bound | 七 |
| GPU 泳道大段空白,CPU 停在数据加载 | DataLoader.__next__ 耗时长 |
Sync-bound 的特例:数据加载 | 七 |
五、时间维度(1):CPU 侧——Python-bound 与 Launch-bound
CPU 侧的两类瓶颈有同一个症状——GPU 在等 CPU——但原因不同:Python-bound 是 CPU 在做与 Kernel 提交无关的事;Launch-bound 是 CPU 在提交 Kernel,但每个 Kernel 太小,提交的固定成本超过了 GPU 执行它的时间。
1. 每个算子在 CPU 上的固定成本
回到第五篇:一次 torch.add(x, y) 在 CPU 上要经过 Python Binding 解析参数、Dispatcher 合并 DispatchKeySet 并查表、Autograd 包装记录反向节点、TensorIterator 构造、最后 cudaLaunchKernel。这一串在现代 CPU 上大约需要10~30 µs,与 Tensor 大小无关。
对比 GPU 侧:对 [128, 64] 的 fp32 Tensor 做一次 add,Kernel 执行时间约 3~5 µs。CPU 提交它的时间是执行时间的几倍。此时 GPU 大部分时间空转,等下一个 Kernel 到达。
CPU |--提交 add 20µs--|--提交 relu 20µs--|--提交 mm 25µs--|
GPU |add 4µs| |relu 3µs| |mm 8µs| ← 利用率 ~25%
只要单个 Kernel 的 GPU 时间低于 CPU 提交它的时间,就是 launch-bound。这个阈值大约在 10~20 µs。
2. 小 Kernel 从哪来
- 小 batch / 小 shape:每个算子处理的数据少;推理 batch=1 几乎必然 launch-bound;
- 逐元素算子链:LayerNorm → 残差加 → GeLU → Dropout → cast,每个一到几个 Kernel;
- 优化器更新:每个参数 Tensor 一组 Kernel;100 个参数 Tensor × Adam 的 ~10 个算子 = 1000 次 launch;
- Python 循环中的 Tensor 操作:
for i in range(seq_len),每次迭代 launch 几个 Kernel; - autocast 的类型转换:每次进出低精度区域插入 cast Kernel。
3. Python-bound:不在提交上的 CPU 时间
Launch-bound 的 CPU 至少在提交 Kernel。Python-bound 的 CPU 在做别的:
- Python 解释:纯 Python 的循环、条件、字典操作、对象构造
- 框架逻辑:nn.Module 的 call 钩子、参数遍历、shape 检查
- 数据预处理在主进程:tokenize、augmentation、collate 在训练循环里同步执行
- 日志与监控:每步 .item()、格式化字符串、写 TensorBoard
Profiler 里的信号是:CPU 泳道密集,但顶部的耗时项不是 aten::* 算子,而是 Python 函数或 DataLoader。with_stack=True 能看到具体是哪行代码。
4. 处方一:增大 batch,让 Kernel 变大
Launch-bound 的本质是 Kernel 的 GPU 时间低于 CPU 提交时间。最直接的解法是让每个 Kernel 处理更多数据:batch 从 8 到 64,add Kernel 的执行时间从 4 µs 变成 30 µs,超过了提交它的 20 µs,GPU 不再空转。
CPU |--提交 add 20µs--|--提交 relu 20µs--|
GPU |------ add 30µs ------|------ relu 25µs ------| ← GPU 成为长边,CPU 提交被隐藏
这一步不减少 Kernel 数量,只是让 GPU 时间线变长直到超过 CPU 时间线。前提是显存允许(第四部分)。推理场景 batch 由请求决定,对应的手段是动态 batching——把时间窗口内到达的多个请求合并成一个 batch 执行。
它也是最应该先做的一步:batch 太小时,后面所有 GPU 侧优化的效果都测不出来,因为 GPU 本来就在等。
5. 处方二:融合,减少 Kernel 数量
第七篇讨论的算子融合在这里的价值是 CPU 侧的:N 个逐元素 Kernel 融合成 1 个,提交成本从 N × 20 µs 变成 20 µs。
compiled_model = torch.compile(model) # Inductor 把 LayerNorm → 残差 → GeLU → cast 链融合
对第九章案例里的 Transformer block,torch.compile 把每 step 的 Kernel 数从 2100 降到 640。融合同时也是 GPU 侧 memory-bound 的处方(第六章 §4),所以它在两侧都有收益——这是 torch.compile 对小模型也常常有效的原因,即使小模型的 GPU 侧根本不是瓶颈。
6. 处方三:fused 优化器,合并参数更新
优化器是 launch-bound 的高发区,且常被忽略。一个有 100 个参数 Tensor 的模型,朴素的 Adam 实现每个参数要做约 10 个逐元素操作:
100 个参数 × 10 个操作 = 1000 次 launch,每次处理一个参数 Tensor(可能只有几千个元素)
PyTorch 提供两级合并:
torch.optim.AdamW(params, foreach=True) # 默认:把同一操作对所有参数的调用合并成一次多 Tensor Kernel
torch.optim.AdamW(params, fused=True) # 更进一步:整个 Adam 更新一个 Kernel,只支持 CUDA 上的浮点参数
foreach 把 1000 次 launch 降到约 10 次(每个操作一次,内部遍历所有参数);fused 降到个位数。对参数 Tensor 多而小的模型,优化器从 step 时间的 20% 降到 2% 是常见的。
7. 处方四:向量化,消除 Python 循环
Python 循环里的 Tensor 操作是 launch-bound 和 Python-bound 的叠加:每次迭代既有解释开销,又有几次 launch。
# 慢:seq_len 次迭代,每次 3 个 Kernel,seq_len=512 时 1500 次 launch
out = []
for t in range(seq_len):
h = torch.tanh(x[:, t] @ W + h @ U)
out.append(h)
# 快:能并行的部分一次算完
xW = x @ W # [B, T, H],一个大 mm 代替 T 个小 mm
for t in range(seq_len):
h = torch.tanh(xW[:, t] + h @ U) # 循环里只剩真正有时序依赖的部分
真正的循环依赖(RNN 的 h @ U)无法消除,但循环体外的部分应全部提前批量计算。更彻底的做法是换成没有时序依赖的结构(Transformer 取代 RNN 的动机之一就是这个),或用 torch.compile 让 Inductor 把循环体融合成一个 Kernel。
Python 侧的其他细节同理:x.shape[0] 比 x.size(0) 慢一点、torch.tensor(...) 在循环里反复构造、用 Python 标量参与 Tensor 运算触发隐式转换——单个微不足道,在每 step 几千次的循环里累积起来可观。with_stack=True 的 Profiler 能定位到行。
8. 处方五:CUDA Graphs,消除提交本身
前四条处方减少 Kernel 数量或增大 Kernel。CUDA Graphs 走另一条路:不减少 Kernel,但让提交它们几乎免费。
原理是把一段固定的 Kernel 序列(含参数、依赖关系)录制成一张图,之后每次只需一次 cudaGraphLaunch 就能整体重放,CPU 侧成本从”每 Kernel 十几微秒”降到”每图几微秒”。GPU 侧也受益:图内 Kernel 之间的调度间隙更小。
compiled_model = torch.compile(model, mode="reduce-overhead") # Inductor 自动录制并重放
手动用 torch.cuda.CUDAGraph 也可以,但需要自己处理静态输入 buffer 和 warmup。
限制来自”录制”这个前提:图内的 shape、控制流、内存地址都必须固定。动态 shape 会触发重新录制;图内不能有 CPU 同步(第七章);每张图占用一份固定的显存池。它最适合推理——shape 固定、无反向、launch 占比高——是 LLM 推理引擎的标配。
9. 处方六:把非计算工作移出训练循环
Python-bound 的处方与 launch 无关,是把 CPU 在做的”别的事”挪走:
数据预处理 交给 DataLoader 的 worker 进程(第七章 §4),主进程只负责取现成的 batch
日志与监控 每 N 步一次;.item() 批量取回(第七章 §3);TensorBoard 写入放到后台线程
框架开销 推理用 torch.inference_mode() 而非 no_grad(),跳过更多 Autograd 簿记;
torch.compile 消掉 nn.Module 的 __call__ 钩子、参数遍历、shape 检查
Python 对象 避免在循环里构造 Tensor、dict、字符串;预先分配、复用
一个判断标准:训练循环的每次迭代里,除了 model(x)、loss.backward()、optimizer.step() 之外的所有代码,都应该问一句”它必须在这里、每步都做吗”。
10. 处方的顺序
- 增大 batch:→ 先做,否则后面的效果测不出来
- fused 优化器:→ 一行改动,零风险
- torch.compile 融合:→ 收益大,代价是编译时间和 shape 约束
- 向量化:→ 需要改代码结构
- CUDA Graphs:→ 约束最多,收益在前几条做完后才明显
- 移出非计算工作:→ 与上面并行进行
11. 何时停止
当 Profiler 显示 GPU 利用率超过 90%、cudaLaunchKernel 占 CPU 时间不到 10%,CPU 侧不再是瓶颈。此时继续融合、继续减 launch 已无收益,瓶颈转移到 GPU 侧——进入下一章。
六、时间维度(2):GPU 侧——Memory-bound 与 Compute-bound
GPU 已经满负荷,问题变成:它在忙什么?GPU 执行一个 Kernel 只做两件事——搬数据、做算术。哪一件先到上限,Kernel 就受哪一件限制。
1. 一个 Kernel 的时间下界
本章会用到 warp、SM、occupancy、shared memory 这几个 CUDA 执行模型的概念,第六篇第七章 §2 有一节最小集介绍;这里默认读者知道它们指什么。
从显存读入数据、写出结果是访存;对数据做算术是计算。两者在硬件上由不同单元执行,可以重叠。因此一个 Kernel 的时间下界是:
T ≥ max( 数据量 / 显存带宽 , 运算量 / 峰值算力 )
哪一项更大,Kernel 就受哪一项限制。这就是 memory-bound 与 compute-bound 的定义。
2. Arithmetic Intensity 与 Roofline
把两项的比值定义为 Arithmetic Intensity(算术强度):
AI = 运算量 (FLOPs) / 数据量 (Bytes)
硬件也有一个对应的比值:峰值算力 / 显存带宽,称为 ridge point。以 A100 为例(取整):
| 指标 | A100(取整) |
|---|---|
| FP32 算力 | 19.5 TFLOPs |
| FP16/BF16 算力 | 312 TFLOPs(Tensor Core) |
| 显存带宽 | 2 TB/s |
| ridge point(FP32) | 19.5e12 / 2e12 ≈ 10 FLOP/Byte |
| ridge point(FP16) | 312e12 / 2e12 ≈ 156 FLOP/Byte |
Kernel 的 AI 低于 ridge point → memory-bound,能达到的算力 = AI × 带宽;高于 → compute-bound,能达到的算力 = 峰值。画成图就是 Roofline:横轴 AI,纵轴可达算力,一条斜线接一条水平线。下图用上面的 A100 数字画出两条 roof(FP32 与 BF16 Tensor Core 共用同一条带宽斜线,ridge point 不同),并把 §3 将要分析的几个算子标在图上:
逐元素算子挤在斜线最左下角——它们能达到的算力只有峰值的百分之一以下,唯一的出路是沿斜线向右(减少访存、提高 AI);大矩阵乘落在水平线上,只能靠抬高水平线(Tensor Core)或减少运算量。
3. 把第七篇的三个算子归类
第七篇的 f 里有 mm、add、relu。用 fp32、x: [128, 32]、weight: [32, 64]:
| 算子 | 运算量 | 数据量 | AI | 类别 |
|---|---|---|---|---|
mm |
2 × 128 × 32 × 64 = 524k FLOPs | (128×32 + 32×64 + 128×64) × 4 B ≈ 57 KB | ≈ 9 | 接近 ridge point,但规模太小,实际为 launch-bound(第五章) |
add |
128 × 64 = 8k FLOPs | 3 × 128 × 64 × 4 B ≈ 98 KB | ≈ 0.08 | 严重 memory-bound |
relu |
8k 次比较 | 2 × 128 × 64 × 4 B ≈ 65 KB | ≈ 0.12 | 严重 memory-bound |
换成大模型尺度,mm 为 [4096, 4096] × [4096, 4096] fp16:
| 项 | 值 |
|---|---|
| 运算量 | 2 × 4096³ ≈ 137 GFLOPs |
| 数据量 | 3 × 4096² × 2 B ≈ 100 MB |
| AI | ≈ 1370 ≫ 156 → compute-bound |
结论:
- 逐元素算子几乎总是 memory-bound。它们的 AI 在 0.1 量级,与硬件 ridge point 差两到三个数量级,让它们变快的主要途径是减少访存。例外是小 shape(launch 与延迟主导,见第五章)和含
exp/erf等特殊函数、按 SFU 吞吐而非 FMA 吞吐计算的算子——Roofline 的两条线只覆盖显存带宽与 FMA 算力,不覆盖所有瓶颈。 - 大矩阵乘是 compute-bound。让它变快的途径是用更高算力的单元,或减少计算量。
- 小矩阵乘两头不靠,规模不足以填满 GPU,实际受 launch 开销和低 occupancy 限制。回到第五章。
归约类算子(sum、softmax、layer_norm)介于两者之间但更接近 memory-bound:每个元素读一次,做常数次运算。Attention 的 softmax(q @ k.T) @ v 手写时 score 矩阵要写出再读回,是 memory-bound 的典型;融合实现(FlashAttention)不物化它,把整个 attention 变成 compute-bound。
4. 处方一:融合,减少访存
对 memory-bound 算子,融合的收益是精确可算的。对 N 个元素的 add + relu:
| 访存 | |
|---|---|
| 分开 | add:读 2N、写 N;relu:读 N、写 N —— 合计 5N 次 |
| 融合 | 读 N + bias、写 N —— 合计约 2N 次 |
时间几乎按访存量线性下降。第七篇讨论了编译器如何自动融合逐元素链;本章要补充的是算法级融合——编译器发现不了、需要人知道存在的融合算子:
- F.scaled_dot_product_attention:不物化 attention score 矩阵
- 融合的 LayerNorm / RMSNorm:归约与归一化一个 Kernel
- 融合的优化器(fused=True):所有参数的 Adam 更新一个 Kernel
这类算子的收益往往比编译器融合大一个量级,因为它们改变的是算法的访存模式,不只是消掉中间结果。
5. 处方二:低精度,同时提升算力上限和降低数据量
对照 Roofline,降低精度同时移动两条线:
- Compute-bound 的矩阵乘:Tensor Core 的 bf16 算力是 fp32 的 16 倍,水平线上移
- Memory-bound 的逐元素:bf16 数据量减半,斜线上的位置右移,时间大致减半
- Launch-bound:没有作用,Kernel 数量不变;autocast 插入的 cast Kernel 反而增加 launch
所以低精度对 launch-bound 的小模型几乎无效甚至变慢。先解决第五章的问题,再上低精度。
三种低精度格式:
| 格式 | 指数位 | 尾数位 | 范围 | 精度 | 用途 |
|---|---|---|---|---|---|
| FP32 | 8 | 23 | ~1e±38 | ~7 位十进制 | 基线;主参数、优化器状态、归约 |
| TF32 | 8 | 10 | 与 FP32 相同 | ~3 位 | Tensor Core 内部格式,fp32 矩阵乘的免费加速 |
| FP16 | 5 | 10 | ±65504,最小正规数 6.1e-5,非正规数可到 6e-8 | ~3 位 | 需 GradScaler 防下溢 |
| BF16 | 8 | 7 | 与 FP32 相同 | ~2 位 | 大模型训练默认 |
- TF32 不是存储格式:输入输出仍是 fp32 Tensor,矩阵乘内部把尾数截到 10 位。
torch.set_float32_matmul_precision("high")开启。对用户几乎透明,代价是矩阵乘精度降到 fp16 水平。 - FP16 指数位少,容易溢出和下溢:梯度小于 6.1e-5 就进入非正规数区间、有效位逐位丢失,小于约 3e-8 才真正归零(
torch.tensor(1e-5).half()仍是 1.0014e-5,不是 0);激活值大于 65504 变 inf。第四篇的GradScaler为它存在:把 loss 放大后反向,让小梯度不下溢。 - BF16 范围与 fp32 相同,不需要 GradScaler,但尾数只有 7 位——相近的数相减会损失大部分有效位。它是当前大模型训练的默认,因为范围问题比精度问题更难处理。
第四篇讲过 autocast 的用法。它不是把所有 Tensor 变成 bf16,而是按算子分类:
| autocast 的处理 | 算子 | 为什么 |
|---|---|---|
| 转到低精度 | matmul、conv、linear、bmm | compute-bound,受益最大,对精度最不敏感 |
| 保持 fp32 | softmax、log_softmax、layer_norm、loss、sum、exp、pow | 涉及大范围或累加 |
| 跟随输入 | add、mul、relu 等逐元素算子 | 输入是什么就算什么 |
因此在 autocast 下,Profiler 里会出现大量 aten::to / aten::_to_copy——精度转换 Kernel。它们是逐元素 memory-bound 算子,torch.compile 可以把它们融合进相邻算子。
6. 处方二的代价:数值稳定性
低精度不是免费的。它放大了三类数值问题,这些问题在 fp32 下往往被掩盖:
溢出。Attention score q @ k.T / sqrt(d) 在长序列、大 hidden 下容易超过 fp16 范围。softmax 前减去最大值是标准做法,PyTorch 的 softmax 内部已经这样做,但手写的 exp(x) / exp(x).sum() 没有。
下溢与精度丢失。fp16 梯度小于 6.1e-5 就落进非正规数区间开始丢有效位,深层网络的早期层梯度容易落入此区间。bf16 的相邻可表示数在 \([1, 2)\) 区间内相距 \(2^{-7}\),加法按最近舍入,所以 x + delta 当 delta 小于半个 ulp(约 x / 256,且与 x 在二进制区间里的位置有关)时直接等于 x——优化器更新 lr × grad 相对参数值极小,用 bf16 存参数会导致更新丢失,这是 fp32 主参数这份 recipe 的由来(另一条路是随机舍入,见 Transformer 系列第十一篇)。
累加误差。对 N 个 bf16 数求和,如果累加器也是 bf16,误差随 N 增长。PyTorch 的 CUDA 归约 Kernel 内部用 fp32 累加,矩阵乘的 Tensor Core 同样以 fp32 累加,但用户手写的循环累加不会。
几个规则:
- loss 计算保持 fp32;用 F.cross_entropy(logits) 而不是 log(softmax(logits))
- 归约(sum、mean、norm)在 fp32 中做,或确认算子内部已用 fp32 累加
- 优化器状态和主参数保持 fp32
- 对比 bf16 与 fp32 的 loss 曲线,而不只是单步输出
- 出现 NaN 时用 torch.autograd.detect_anomaly() 定位第一个 NaN 的算子
这也解释了第三章 §4 的容差依据:bf16 有效精度约 2~3 位十进制,实现之间的差异在 1e-2 量级是正常的,1e-5 的容差对 bf16 没有意义。
什么时候不该用低精度:模型很小、launch-bound(没有收益,有 cast 开销);数值敏感的任务;推理时精度要求高于速度;还没有 fp32 基线和正确性测试。
7. 处方三:数据布局
第二篇讲过 stride 和连续性。对 memory-bound 的 Kernel,访存模式直接决定实际带宽:warp 内相邻线程访问相邻地址才能合并成整扇区,跨 stride 的访问浪费带宽。但”非连续就慢”是一个常见的误解——TensorIterator 会按物理布局重排遍历顺序(第五篇),一个转置 view 单独参与运算时,输出会按同样的 stride 分配,遍历仍然沿着内存连续方向走,速度与连续情形相当。真正吃亏的是多个输入布局互相打架的情形:
x = torch.randn(4096, 4096, device="cuda")
y = x.t() # view,非连续
z = y + 1 # 单输入:TensorIterator 沿 y 的物理布局遍历,输出也按该布局分配,不慢
w = y + x # 两个输入的连续方向正交:无论按谁的顺序走,另一个都跨 stride,带宽掉一半以上
w = y.contiguous() + x # 先复制一次(一读一写),之后逐元素按同一布局跑满带宽
哪种更快取决于后续用几次:只用一次时让 TensorIterator 直接遍历,之后还要与连续 Tensor 反复运算时先 contiguous()。channels_last 内存格式对卷积网络的加速也是同一原理——让 cuDNN 的访存模式与数据布局匹配。
8. Occupancy:为什么达不到 Roofline
GPU 靠大量并发线程隐藏访存延迟:一个 warp 等数据时,调度器切换到另一个 warp。能同时驻留多少 warp 称为 Occupancy,受每个线程的寄存器数、每个 Block 的共享内存、Block 大小限制。
Occupancy 低的 Kernel 即使 AI 合适也达不到 Roofline,因为访存延迟暴露出来了。这是第四章 §5 Nsight Compute 里 “Achieved Occupancy” 指标的意义。它只在自己写 Kernel 时(第六篇)需要直接关心;用 PyTorch 原生算子和 Inductor 生成的 Kernel 时,这一层通常已经处理好。
9. 何时停止
当矩阵乘 Kernel 的 Tensor Core 利用率接近峰值、逐元素算子已融合到不能再融合、Nsight Compute 显示主要 Kernel 的 DRAM 或 SM Throughput 接近 100%——GPU 侧到了硬件上限。剩下的路只有减少计算量本身(模型结构、序列长度、稀疏化),或增加硬件(第九篇)。
七、时间维度(3):两侧之间——Sync-bound
前两章各自假设另一侧不是问题。Sync-bound 是两侧互相等待:CPU 等 GPU 排空,然后 GPU 等 CPU 重新填队列。时间线上的形态是两条泳道交替出现空洞。
1. 隐式同步点
显式的 synchronize() 容易发现。危险的是隐式同步:任何需要 CPU 拿到 GPU 数据的操作都会阻塞 CPU,直到队列排空。
| 操作 | 为什么同步 |
|---|---|
tensor.item()、float(tensor)、int(tensor) |
把一个值传回 CPU |
tensor.cpu()、tensor.numpy()、tensor.tolist() |
把数据传回 CPU |
print(tensor) |
内部调用 .cpu() |
if tensor > 0: |
需要 bool,即 .item() |
tensor[mask](布尔索引)、torch.nonzero |
输出 shape 依赖数据,CPU 必须知道 shape 才能分配 |
torch.cuda.empty_cache() |
等待所有使用中的 Block 释放 |
非 pinned memory 的 .to("cuda") |
同步拷贝 |
训练循环里一行看似无害的 loss.item() 累加日志,每个 step 都会把 CPU 拖住等 GPU 排空,然后 GPU 再等 CPU 重新提交。
2. 同步的代价何时显现
同步点本身不消耗 GPU 时间,它只是让 CPU 等待。代价取决于同步时队列的深度:
| 场景 | 发生了什么 | 同步的代价 |
|---|---|---|
| GPU-bound | CPU 领先 GPU 很多,队列深;同步让 CPU 等一会儿,GPU 一直有活干 | 小 |
| CPU-bound | 队列本来就浅;同步后 GPU 立即空闲,等 CPU 重新提交 | 大 |
| 高频同步 | 每步一次,队列永远填不深,两侧交替空闲 | 最坏 |
所以 sync-bound 的信号不是”有同步”,而是同步频繁且发生时队列很浅。它常与第五章的 launch-bound 叠加:小 Kernel 让队列浅,频繁 .item() 让队列反复排空。
3. 处方一:批量取回,降低同步频率
同步的代价与频率成正比。把每步的 loss 留在 GPU 上累加,每 N 步 .item() 一次:
running = torch.zeros((), device="cuda")
for step, batch in enumerate(loader, start=1):
loss = train_step(batch)
running += loss.detach() # GPU 上累加,不同步
if step % 100 == 0:
log(running.item() / 100) # 每 100 步同步一次(start=1,第一次 log 时确实累了 100 步)
running.zero_()
if step % 100: # 收尾:不足 100 步的余数也要汇报
log(running.item() / (step % 100))
同步次数从每步一次降到每百步一次,队列在绝大多数 step 里保持深度。同样的思路适用于梯度范数、准确率等所有监控指标:在 GPU 上累积,定期取回。
4. 处方二:pinned memory 与异步拷贝
Host 到 Device 的传输默认是同步的:CPU 发起拷贝后等它完成。让它异步需要两个条件:源内存是 pinned memory(页锁定,不会被操作系统换出,GPU 的 DMA 引擎可以直接读),且调用时指定 non_blocking=True:
loader = DataLoader(dataset, batch_size=64, pin_memory=True, num_workers=4)
for batch in loader:
batch = batch.to("cuda", non_blocking=True) # 拷贝在传输引擎上进行,CPU 不等
...
缺任一条件,non_blocking 就不能保证异步——不报错,只是可能退化:源在 pageable 内存时,CUDA 先把数据搬进一块内部的 pinned 中转区,H2D 调用要等这一步完成才返回(大 batch 时就是一次同步拷贝的大部分时间),真正的 DMA 才是异步的。pin_memory=True 让 DataLoader 的 worker 把 batch 放进 pinned memory,主进程拿到的已经是可以异步传输的数据。反方向(Device 到 Host)同理:目标是 pinned Tensor 且 non_blocking=True,之后需要显式同步再读值。
5. 处方三:多 Stream 重叠传输与计算
non_blocking 让 CPU 不等拷贝,但拷贝仍在默认 Stream 上排队,与计算 Kernel 串行。要让传输与计算真正并发,需要把它们放到不同的 Stream:
copy_stream = torch.cuda.Stream()
compute_stream = torch.cuda.current_stream()
it = iter(loader)
with torch.cuda.stream(copy_stream):
current = next(it).to("cuda", non_blocking=True) # 预取第 0 个 batch
for batch in it:
with torch.cuda.stream(copy_stream):
next_batch = batch.to("cuda", non_blocking=True) # 在另一条队列上传输第 i+1 个
compute_stream.wait_stream(copy_stream) # 第 i 个的拷贝完成后计算才能开始
current.record_stream(compute_stream) # 告知 allocator:这块内存在 compute_stream 上还在用
y = model(current) # 默认队列上计算第 i 个,与第 i+1 个的传输并发
current = next_batch
compute_stream.wait_stream(copy_stream) # 最后一个 batch:拷贝完再算
current.record_stream(compute_stream)
y = model(current)
注意 wait_stream 让 compute_stream 同时等到了刚提交的第 \(i+1\) 个拷贝——这条依赖比必要的强,把它拆开需要每个 batch 一个 Event;作为演示,先保证正确。
把这段代码里四个角色的交互画出来,两条 Stream 上并发的部分和必须串行的依赖就一目了然:
%% 图:多 Stream 重叠传输与计算:copy_stream 搬 batch i+1 时 compute_stream 算 batch i,wait_stream 与 record_stream 表达依赖
sequenceDiagram
participant W as worker
participant P as pinned buf
participant CS as copy_stream
participant MS as compute_stream
W->>P: batch i+1 collate<br/>进 pinned memory
P->>CS: to(cuda, non_blocking)<br/>入队 H2D 拷贝
par copy_stream
Note over CS: DMA 搬 batch i+1
and compute_stream
Note over MS: 前向/反向 batch i<br/>Kernel 队列不断
end
MS->>CS: wait_stream(copy_stream)
Note over CS,MS: compute_stream 里插一个 Event<br/>之后的 Kernel 等拷贝完成
CS-->>MS: 拷贝完成
MS->>CS: record_stream(compute_stream)
Note over CS,MS: 告知 allocator:<br/>这块内存 compute_stream 还在用
Note over MS: 前向/反向 batch i+1
W->>P: 准备 batch i+2,循环
wait_stream 表达跨 Stream 依赖;record_stream 解决另一件事:这块显存是在 copy_stream 上分配的,current = next_batch 之后 Python 引用一丢,caching allocator 就认为它空闲、可以给 copy_stream 上的下一次分配复用——而 compute_stream 上的前向可能还没读完它。record_stream 让 allocator 等 compute_stream 也走过这个点再回收。这两行漏掉任何一行都会产生难以复现的数据错误——这是多 Stream 的主要风险。DataLoader 加 pin_memory 加 non_blocking 在大多数情况下已经足够,显式多 Stream 用于传输时间与计算时间同量级的场景。
6. 处方四:消除数据依赖的 shape
tensor[mask]、torch.nonzero、torch.unique 的同步来自输出 shape 未知:CPU 必须等 GPU 算出有多少个元素,才能分配输出。这类同步无法用异步手段消除,只能改写算法让 shape 固定:
# 同步:输出长度取决于 mask 中 True 的个数
selected = x[mask]
loss = selected.pow(2).mean()
# 不同步:输出 shape 与 x 相同,用 0 填充未选中的位置
loss = torch.where(mask, x.pow(2), 0.0).sum() / mask.sum()
变长序列同理:用固定长度加 padding 加 attention mask,代替按实际长度切分。这也是第七篇 Dynamic Shape 里”数据依赖的 shape 会导致 graph break”的同一个问题——在 Eager 下表现为同步,在编译下表现为无法捕获。
7. 处方五:set_sync_debug_mode,找出所有同步点
前四条处方针对已知的同步点。未知的同步点用调试模式找:
torch.cuda.set_sync_debug_mode("warn") # 每次隐式同步打印警告和 Python 调用栈
# 或 "error":直接抛异常,用于 CI 中确保热路径无同步
在训练循环上跑几步,警告列表就是完整的同步点清单。逐条判断:是必要的(如每百步的日志),还是意外的(如某个工具函数里的 .item())。
8. 特例:数据加载
数据加载是 sync-bound 的一种常见形态:GPU 泳道大段空白,CPU 停在 DataLoader.__next__。GPU 不是在等同步,而是在等数据到达。
- 症状:GPU 利用率周期性掉到零,周期等于一个 batch 的加载时间;
- 原因:
num_workers不足;预处理太重;磁盘或网络 I/O 慢;collate 在主进程做了大量工作; - 处方:增加
num_workers;预处理离线化(提前 tokenize、缓存为二进制格式);prefetch_factor增大预取深度;pin_memory=True;把 augmentation 移到 GPU 上做。
判断方法:单独 Benchmark DataLoader 的迭代速度(不带模型),与训练 step 时间对比。如果加载一个 batch 的时间接近或超过训练一个 batch 的时间,数据加载就是瓶颈——此时优化模型毫无意义。
八、空间维度:显存
时间维度上的每一类处方几乎都与显存有关:增大 batch 消耗显存,融合减少中间 Tensor 节省显存,低精度减半激活值。所以显存分析不是独立话题,它决定了时间维度上有多少操作空间。
1. 训练时显存的构成
- 参数:P × 每参数字节数
- 梯度:与参数同形状、同 dtype
- 优化器状态:Adam:两份 fp32 状态(m、v);SGD with momentum:一份
- 激活值:前向保存供反向使用的中间结果,∝ batch × seq × hidden × layers
- 临时工作区:cuBLAS / cuDNN workspace,Attention 的 score 矩阵
- Caching Allocator 的保留量:已向驱动申请但当前未分配给 Tensor 的部分
前三项是静态的,与 batch 无关;激活值随 batch 线性增长,是单卡训练中可调节的主要部分。
2. 混合精度对显存的两面
第六章把低精度作为时间维度的处方。它对空间的影响不是单向的:
- 激活值:bf16 减半——主要收益,∝ batch;
- 参数与梯度:bf16 减半;
- 主参数:必须保留 fp32 副本(第六章 §6:bf16 存不住小的更新量)——额外开销;
- 优化器状态:保持 fp32。
以 Adam 为例,每个参数的静态占用,取两种常见 recipe:
纯 fp32 fp32 参数 4 + fp32 梯度 4 + m 4 + v 4 = 16 B/参数
PyTorch 原生 autocast fp32 参数 4 + fp32 梯度 4 + m 4 + v 4 = 16 B/参数
(参数 leaf 仍是 fp32,autocast 只在算子内部临时转 bf16;.grad 也是 fp32)
Megatron 式 bf16 主流程 bf16 参数 2 + bf16 梯度 2 + fp32 主参数 4 + m 4 + v 4 = 16 B/参数
三种写法的静态部分都是 16 B/参数,没有减少,收益全在激活值上(原生 autocast 连参数副本都没省,只是省了算子内部的临时 Tensor)。7B 参数的模型仅静态部分就是 112 GB,单卡放不下——这是第九篇分布式的起点。要把静态部分真正压下去,得换 recipe:bf16 参数 + bf16 的 m/v(DeepSeek-V3 的做法,Transformer 系列第十一篇)、8-bit 优化器、或把 optimizer 状态分片到多卡(ZeRO)。
3. Caching Allocator:allocated 与 reserved
cudaMalloc / cudaFree 很慢(微秒到毫秒级,且 cudaFree 隐式同步)。PyTorch 的 CUDA Caching Allocator 在中间加了一层:向驱动申请大块(Segment),切成 Block 分配给 Tensor;Tensor 释放时 Block 回到缓存池,不还给驱动。
torch.cuda.memory_allocated() # 当前 Tensor 实际占用
torch.cuda.memory_reserved() # 已向驱动申请的总量(allocated + 缓存中的空闲 Block)
torch.cuda.max_memory_allocated() # 峰值,OOM 分析的关键数字
torch.cuda.reset_peak_memory_stats()
nvidia-smi 显示的是 reserved(加上 CUDA 上下文本身),不是模型真正在用的量。reserved 高而 allocated 低不是泄漏,是缓存。
4. 碎片
缓存池中的空闲 Block 可能总量足够但没有一块连续的足够大:
Segment: [ 已用 1MB ][ 空闲 3MB ][ 已用 1MB ][ 空闲 3MB ]
请求 5MB → 空闲总量 6MB,但无连续 5MB → 向驱动申请新 Segment → 可能 OOM
OOM 报错信息里能直接看到这个状态:
- CUDA out of memory. Tried to allocate 5.00 GiB. GPU 0 has a total capacity of 79.15 GiB
- of which 2.31 GiB is free. Process has 76.84 GiB memory in use. Of the allocated memory
- 68.12 GiB is allocated by PyTorch, and 7.91 GiB is reserved by PyTorch but unallocated.
“reserved but unallocated” 接近 8 GB 是碎片的线索而不是证明:这个数还包含刚释放、等着下一步复用的正常缓存,以及峰值过后留下的空闲块。判断办法是看它在稳态里是否持续偏大、且 OOM 时请求的大小小于空闲总量——那才是”有空间但不连续”。下面把一个 Segment 内部画出来(1 MB = 2 格),并标出两种常见”处方”各自作用在哪一层:
一个 20 MB 的 Segment(reserved 20 MB,allocated 11 MB,空闲 9 MB)
┌────────┬──────┬──────────┬──────┬────┬──────┐
│ used 4 │free 3│ used 5 │free 3│ u2 │free 3│
└────────┴──────┴──────────┴──────┴────┴──────┘
0 4 7 12 15 17 20 MB
请求 5 MB:三块空闲各 3 MB,无一块 >= 5 MB
→ 空闲总量 9 MB 也帮不上 → 向驱动 cudaMalloc 新 Segment → 可能 OOM
expandable_segments:True Segment 尾部可原地扩展(映射新物理页),
新请求接在尾部,而不是再开一个独立 Segment:
┌────────┬──────┬──────────┬──────┬────┬──────┬──────────┐
│ used 4 │free 3│ used 5 │free 3│ u2 │free 3│ new 5 │
└────────┴──────┴──────────┴──────┴────┴──────┴──────────┘
empty_cache() 只把"整块全空"的 Segment 还给驱动;
上面这个 Segment 里还有 used 块,一字节也还不了
→ 它不解决碎片,只减少 reserved
缓解手段:
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True:让 Segment 可以扩展而不是新申请,显著减少碎片(2.x 引入);- 让 shape 稳定:动态 shape 是碎片的主要来源,因为每种 shape 的 Block 大小不同;
torch.cuda.empty_cache()归还缓存给驱动——它归还的是整块全空的 Segment,对上图这种”块内碎片”无能为力;只在多进程共享 GPU、或想让驱动重新分出一整块大 Segment 时有用(官方文档措辞是”某些情况下可能有帮助”),且会引入同步(第七章)。
5. 峰值在哪里
平均占用不高但 OOM,说明某一时刻有尖峰。典型位置:
- 反向开始时:所有激活值都还在,第一个反向 Kernel 又要分配梯度
- 优化器 step 时:foreach 优化器可能一次性为所有参数分配临时 Tensor
- Attention:score 矩阵 batch × heads × seq × seq,seq=8k 时单层就是 GB 级
- evaluation:忘了 torch.no_grad(),保存了不需要的激活值
用 memory snapshot 精确定位:
torch.cuda.memory._record_memory_history(max_entries=100000)
train_step()
torch.cuda.memory._dump_snapshot("snapshot.pickle")
把 snapshot.pickle 拖进 pytorch.org/memory_viz,得到每个分配的时间线、大小和分配时的 Python 调用栈。这是 OOM 排查最有效的工具:直接看到峰值时刻哪几个 Tensor 占了显存、是哪行代码分配的。
6. 泄漏
allocated 随 step 单调增长,常见原因都是无意中持有了计算图:
total_loss += loss # loss 带 grad_fn,整张图被引用,激活值无法释放
total_loss += loss.item() # 正确,但是同步点(第七章)
total_loss += loss.detach() # 正确且不同步
其他来源:把 Tensor 存进跨 step 的 list 或 dict;hook 中保存了中间结果;异常路径没有走到 zero_grad。
7. 用时间换空间:Activation Checkpointing
激活值占用 ∝ 层数。Activation Checkpointing 只保存部分层的输入,反向时重新计算这段前向:
from torch.utils.checkpoint import checkpoint
y = checkpoint(transformer_block, x, use_reentrant=False)
两种模式下前向保存什么、反向到达时做什么,对比如下(L 层,每 k 层一个 checkpoint block):
%% 图:Activation Checkpointing 的两种模式:默认保存每层激活 O(L),checkpoint 只保存 block 入口 O(L/k) 并在反向时重算
flowchart TB
subgraph normal["默认:前向保存每层激活,显存 O(L)"]
direction TB
n_in["输入 x"] --> n_l1["层 1 前向<br/>保存激活 a1"]
n_l1 --> n_l2["层 2 前向<br/>保存激活 a2"]
n_l2 --> n_l3["… 层 L 前向<br/>保存激活 aL"]
n_l3 --> n_bw["反向:直接读 aL … a1<br/>时间 = 1 次前向 + 1 次反向"]
end
subgraph ckpt["checkpoint:只保存 block 入口,显存 O(L/k)"]
direction TB
c_in["输入 x"] --> c_b1["block 1 前向(k 层)<br/>只保存入口 x1,内部激活即弃"]
c_b1 --> c_b2["… block L/k 前向<br/>只保存入口"]
c_b2 --> c_arrive["反向到达 block j"]
c_arrive --> c_re["用入口 xj 重算 block j 前向<br/>临时得到 k 层激活"]
c_re --> c_bw["对 block j 做 backward<br/>释放重算的激活"]
c_bw -->|"下一个 block j-1"| c_arrive
c_bw --> c_done["时间 ≈ 2 次前向 + 1 次反向<br/>约 +30%"]
end
classDef save fill:#fde9d9,stroke:#c0392b;
classDef light fill:#e8f5e9,stroke:#1e8449;
classDef recompute fill:#fff4d6,stroke:#b9770e;
class n_l1,n_l2,n_l3 save;
class c_b1,c_b2 light;
class c_re recompute;
代价是被 checkpoint 的段前向算两遍,通常增加约 30% 计算时间,换来激活显存从 O(层数) 降到 O(√层数) 或更低。第七篇提过,AOTAutograd 的切分器在编译时自动做局部版本的这个权衡。
它是否值得取决于:省下的显存能否换来更大的 batch,更大的 batch 是否让 Kernel 从 launch-bound 变成 compute-bound(第五章)——如果能,总吞吐反而上升;如果 GPU 已经饱和,就是纯损失。第九章的案例会给出一个”不值得”的实例。
8. 用空间换时间
反方向的交换同样常见:
- KV Cache:推理时缓存已计算的 key/value,避免重算,显存 ∝ 序列长度
- cuDNN workspace:给 cuDNN 更多工作区,它能选择更快的算法
- 更大的 batch:本身就是用显存换 GPU 利用率
- 预分配:提前分配固定大小的 buffer,避免运行时分配与碎片
9. 内存:CPU 侧的空间什么时候重要
时间维度上 CPU 与 GPU 对称,空间维度上不对称:CPU 内存很少成为”时间去哪了”意义上的瓶颈,但在几个场景下是硬约束。
- DataLoader 多 worker:每个 worker 是一个进程;用 Python list 持有的数据集会因引用计数触发 copy-on-write,N 个 worker 复制 N 份。用 numpy 数组、内存映射文件或 Arrow 格式避免;
- pinned memory:页锁定内存不能换出,总量受操作系统限制;
pin_memory=True加大 prefetch 时可能耗尽; - CPU offload:把优化器状态或部分参数放到内存(第九篇 ZeRO-Offload),用 PCIe 带宽换显存容量;此时 CPU 内存容量和 PCIe 带宽同时成为约束;
- 数据集缓存:把预处理结果全部放进内存加速 epoch,前提是放得下。
诊断手段是操作系统级的(free、/proc/<pid>/status、psutil),PyTorch 没有对应 memory_allocated 的 API——因为 CPU Tensor 用的是系统分配器,没有 Caching Allocator 那一层。
九、一个完整案例:Transformer block 的训练 step
把前面的工具和模型串起来,走一遍第一章 §5 的流程。数字为示意,比例关系反映真实规律。
1. 对象
一个标准的 Transformer block(Pre-LN):
class Block(nn.Module):
def __init__(self, d=512, heads=8):
super().__init__()
self.ln1 = nn.LayerNorm(d)
self.qkv = nn.Linear(d, 3 * d)
self.proj = nn.Linear(d, d)
self.ln2 = nn.LayerNorm(d)
self.fc1 = nn.Linear(d, 4 * d)
self.fc2 = nn.Linear(4 * d, d)
self.heads = heads
def attention(self, x):
B, T, D = x.shape
q, k, v = self.qkv(x).split(D, dim=-1)
q = q.view(B, T, self.heads, -1).transpose(1, 2)
k = k.view(B, T, self.heads, -1).transpose(1, 2)
v = v.view(B, T, self.heads, -1).transpose(1, 2)
att = (q @ k.transpose(-2, -1)) / math.sqrt(q.size(-1))
att = att.softmax(dim=-1)
y = (att @ v).transpose(1, 2).reshape(B, T, D)
return self.proj(y)
def forward(self, x):
x = x + self.attention(self.ln1(x))
x = x + self.fc2(F.gelu(self.fc1(self.ln2(x))))
return x
model = nn.Sequential(*[Block() for _ in range(12)]).cuda()
训练 step:前向 → loss.backward() → optimizer.step()(AdamW)→ zero_grad()。序列长度 512。
2. 基线与正确性测试
def step(model, x):
loss = model(x).float().pow(2).mean()
loss.backward()
optimizer.step()
optimizer.zero_grad(set_to_none=True)
return loss
# 正确性:保存 fp32、batch=8、固定种子下 20 步的 loss 序列作为参考
ref_losses = run_steps(model_ref, 20)
# 基线:Benchmark 一个 step
t = benchmark.Timer(stmt="step(model, x)", globals={...})
print(t.blocked_autorange(min_run_time=5))
基线 batch=8 fp32 eager step: 48.2 ms 吞吐: 166 samples/s 峰值显存: 3.1 GB
3. 第一轮:采集与归类
Profiler 表格
Self CUDA total: 21 ms / step ← GPU 只忙了 44%
cudaLaunchKernel: 2100 次 / step ← 平均 12 µs CPU 成本,共 25 ms
aten::mm: 平均 18 µs ← 小 Kernel
时间线形态:CPU 泳道密集,GPU 泳道稀疏
对照第四章 §6 的表:Launch-bound(第五章)。batch=8、d=512 下每个 Kernel 太小,GPU 等 CPU。此时优化任何 Kernel 都无效。
4. 修改一:增大 batch(第五章处方一)
最直接的处方。显存有余量(3.1 GB),把 batch 提到 64:
batch=64 fp32 eager step: 118 ms 吞吐: 542 samples/s 峰值显存: 19.6 GB
吞吐提升 3.3 倍。再看 Profiler:
Self CUDA total: 112 ms / step ← GPU 忙 95%
aten::mm: 68 ms(61%) ← 矩阵乘主导
aten::softmax + aten::_softmax_backward_data: 14 ms
aten::layer_norm / gelu / add / mul 等逐元素与归约: 22 ms
瓶颈从 launch-bound 变成了 GPU-bound,其中矩阵乘占六成——Compute-bound(第六章)。这才是可以谈 Kernel 性能的起点。
5. 修改二:低精度(第六章处方二)
矩阵乘 compute-bound,fp32 只能用 19.5 TFLOPs 的算力。切到 bf16 autocast:
batch=64 bf16 eager step: 51 ms 吞吐: 1255 samples/s 峰值显存: 12.8 GB
aten::mm: 21 ms(Tensor Core,约 3 倍)
逐元素与归约: 13 ms(数据量减半)
aten::_to_copy: 4 ms ← 新出现:autocast 的 cast Kernel
正确性:bf16 的 20 步 loss 与 fp32 参考的相对差异在 1% 以内,曲线趋势一致。通过。显存:激活值减半,静态部分不变(第八章 §2)。
6. 修改三:torch.compile(第六章处方一,兼第五章处方二)
剩余时间里逐元素算子链(LayerNorm → 残差 → GeLU → cast)仍是一串独立的 memory-bound Kernel。交给 Inductor 融合:
batch=64 bf16 compile step: 38 ms 吞吐: 1684 samples/s 峰值显存: 11.9 GB
首次调用: 47 s(冷编译)
- Kernel 数: 2100 → 640 / step
- aten::_to_copy: 消失(融合进相邻 Kernel)
- triton__fused_: 9 ms(替代了原来 17 ms 的逐元素与归约)
- aten::mm: 21 ms(不变,Extern Kernel)
代价:47 秒冷编译;输入 shape 变化会触发重编译(第七篇)。训练场景 shape 固定,可接受。
7. 修改四:算法级融合(第六章处方一)
Profiler 里 softmax 及其反向仍占 8 ms,且 memory snapshot 显示峰值处最大的 Tensor 是 att:64 × 8 × 512 × 512 × 2 B = 268 MB 每层,12 层前向保存 3.2 GB。手写的 attention 把 score 矩阵完整写到显存再读回来做 softmax,是典型的 memory-bound 模式。
换成融合的 scaled_dot_product_attention:
def attention(self, x):
...
y = F.scaled_dot_product_attention(q, k, v) # 内部选择 FlashAttention 等融合实现
...
batch=64 bf16 compile+sdpa step: 29 ms 吞吐: 2207 samples/s 峰值显存: 8.4 GB
score 矩阵不再物化,显存降 3.5 GB,时间降 9 ms。这是编译器发现不了的融合。
8. 第二轮:时间换空间是否值得(第八章 §7)
峰值显存降到 8.4 GB 后,可以考虑用 checkpointing 换更大的 batch:
batch=64 + checkpoint 每个 Block step: 36 ms 峰值显存: 4.1 GB
batch=128 + checkpoint 每个 Block step: 68 ms 吞吐: 1882 samples/s 峰值显存: 7.6 GB
checkpoint 后加大 batch 的吞吐(1882)反而低于不 checkpoint 的 batch=64(2207)——GPU 在 batch=64 时已经饱和(第六章 §9),再加 batch 没有 launch 收益,而重算增加了 30% 计算。这个案例里不值得。如果基线是 batch=8 时显存就满了,结论会反过来。
9. 优化报告
按大纲的要求,每项改动不只说”快了多少”:
| 改动 | step 时间 | 节省的是 | 显存 | 精度 | shape 依赖 | 转移的成本 |
|---|---|---|---|---|---|---|
| batch 8→64 | 48 → 118 ms(吞吐 ×3.3) | Launch 开销 | +16.5 GB | 无 | — | 无 |
| bf16 autocast | 118 → 51 ms | 计算(Tensor Core)+ 访存(减半) | −6.8 GB | loss 相对差 <1% | — | 无 |
torch.compile |
51 → 38 ms | 访存(融合)+ Launch | −0.9 GB | 浮点结合顺序 | shape 变化触发重编译 | 47 s 冷编译 |
| SDPA | 38 → 29 ms | 访存(不物化 score) | −3.5 GB | 与手写实现容差内一致 | 要求 head_dim 等满足 Kernel 约束 | 无 |
| checkpoint | 29 → 36 ms | — | −4.3 GB | 无 | — | +30% 计算,未采用 |
把这几步的 step 时间与峰值显存画在一起,能看到两条曲线并不同向:第一步 batch 加大让 step 变长、显存暴涨,却是吞吐提升最大的一步;之后三步在 batch=64 不变的前提下同时压低时间和显存。
从 166 到 2207 samples/s,13 倍。其中没有一项是”优化某个 Kernel”,全部是改变瓶颈类别:先消灭 launch-bound,再对 compute-bound 用 Tensor Core,再对 memory-bound 做融合。
10. 这条路径的通用性
案例的顺序不是偶然:
- 先看 GPU 利用率:低 → CPU 侧或同步(第五章、第七章),先解决它,否则其他优化无法测量;
- 再看 Kernel 时间分布:矩阵乘主导 → 低精度;逐元素主导 → 融合(第六章);
- 再看显存峰值组成:大 Tensor 能否不物化;激活值能否重算;是否值得(第八章);
- 每步回到正确性测试和 Benchmark。
不同模型的具体数字不同,但顺序几乎总是这样。跳过第一步直接做第二步,是性能优化中最常见的浪费。
十、Java 工程师如何理解 PyTorch 性能分析
1. 异步与测量:CompletableFuture
long start = System.nanoTime();
CompletableFuture<Result> f = executor.submit(task); // 立即返回
long elapsed = System.nanoTime() - start; // 测的是提交,不是执行
Java 工程师一眼能看出这段代码测错了。PyTorch 的 time.time() 围住 model(x) 犯的是同一个错——model(x) 就是 submit,synchronize() 就是 f.get()。CUDA Event 相当于在任务内部打时间戳。
2. Benchmark:JMH
JMH 解决的问题与 torch.utils.benchmark 完全对应:
| JMH | torch.utils.benchmark |
|---|---|
@Warmup 排除 JIT 编译、类加载 |
warmup 排除 CUDA 初始化、cuDNN 算法选择、编译 |
@Fork 隔离 JVM 状态 |
固定种子、清理 GPU 进程 |
Blackhole 防止死代码消除 |
GPU 没有此问题,但 torch.compile 可能消除无用计算 |
| 报告分位数而非均值 | 报告中位数与 IQR |
Mode.Throughput vs Mode.AverageTime |
吞吐 vs 延迟口径 |
用过 JMH 的人知道”微基准测试很容易测错”,这个直觉在 GPU 上同样成立,且多了异步这一层陷阱。
3. Profiler:JFR 与 async-profiler
torch.profiler 的角色是 JFR 加 async-profiler:采样调用栈、记录事件、导出时间线。区别是它要同时观察两个处理器并对齐时间轴。Nsight Systems 更像 perf 加 Linux 系统级追踪——看操作系统眼中的所有线程和设备。Nsight Compute 没有 Java 世界的直接对应物,最接近的是用 perf stat 看单个热点函数的硬件计数器(cache miss、IPC)。
4. 五类瓶颈的 Java 对应
| PyTorch | Java 世界的相似物 |
|---|---|
| Launch-bound | 每次调用开销远大于工作量:细粒度 RPC、逐条 JDBC 而非 batch insert |
| Python-bound | 业务逻辑在 GC 或反射、序列化上花的时间超过真正的计算 |
| Memory-bound | cache miss 主导:遍历链表 vs 遍历数组、伪共享 |
| Compute-bound | 真正的 CPU 密集,perf 显示 IPC 高、热点在算术 |
| Sync-bound | 锁竞争、Future.get() 在错误位置、同步 I/O 阻塞线程池 |
5. 内存:GC 与 Caching Allocator
| JVM | PyTorch |
|---|---|
-Xmx 堆上限 |
物理显存 |
| committed vs used | reserved vs allocated |
| GC 回收后堆不立即归还 OS | 释放的 Block 留在缓存池,不 cudaFree |
| 老年代碎片,G1 的 humongous 分配失败 | 大 Block 碎片,”reserved but unallocated” |
System.gc() 通常无益 |
torch.cuda.empty_cache() 通常无益 |
| 堆 dump + MAT 找泄漏 | memory snapshot + memory_viz 找泄漏 |
| 持有对象引用导致无法回收 | 持有带 grad_fn 的 Tensor 导致整张图无法释放 |
一个关键差别:JVM 有 GC,对象生命周期由可达性决定;PyTorch 靠引用计数(第二篇),Tensor 引用归零立即释放。所以 PyTorch 的”泄漏”几乎总是有一个明确的引用还活着,比 JVM 的泄漏更容易定位——只要找到那个引用。
6. 精度:float vs double
Java 工程师默认用 double,把 float 当作节省内存的特例。PyTorch 反过来:fp32 是基线,还要继续往下降到 bf16。原因是 GPU 的算力和带宽对精度极其敏感——Tensor Core 在 bf16 上的算力是 fp32 的 16 倍。这不是”够用就行”的取舍,而是 Roofline 直接推出的结论。
但 Java 工程师熟悉的浮点陷阱(0.1 + 0.2 != 0.3、大数吃小数、累加误差)在 bf16 下会被放大百倍。第六章 §6 的规则——归约用高精度、主参数用 fp32、避免相近数相减——都是这些陷阱的对策。
十一、本文小结
1. 两个维度、一种交换
- 时间——两条时间线:CPU 提交 / GPU 执行,异步,由较长者决定 step 时间。
- CPU 侧:Python-bound / Launch-bound(第五章);
- GPU 侧:Memory-bound / Compute-bound(第六章,\(T \ge \max(\text{数据量}/\text{带宽},\ \text{运算量}/\text{算力})\));
- 两侧之间:Sync-bound(第七章)。
- 空间——显存 = 参数 + 梯度 + 优化器状态 + 激活值 + 工作区 + 缓存(第八章)。
- 交换——时间 ↔ 空间:checkpointing 用时间换空间、加大 batch 用空间换时间;低精度同时省两者,代价是数值精度。
2. 症状 → 归类 → 工具 → 处方
| 症状 | 归类 | 观察工具 | 处方 | 位置 |
|---|---|---|---|---|
| GPU 利用率低,CPU 100%,Kernel 多而小 | Launch-bound | Profiler 表格(cudaLaunchKernel 占比)、时间线形态 |
增大 batch、融合、CUDA Graphs、fused 优化器 | 五 |
| GPU 利用率低,CPU 时间不在算子上 | Python-bound | Profiler with_stack |
向量化、torch.compile、预处理移出循环 |
五 |
| GPU 忙,逐元素算子占比高 | Memory-bound | Profiler 按 CUDA 时间排序、ncu DRAM Throughput | 融合、算法级融合、bf16、数据布局 | 六 |
| GPU 忙,矩阵乘占比高 | Compute-bound | Profiler、ncu SM Throughput | Tensor Core(bf16 / TF32)、减少计算量 | 六 |
| 时间线两侧交替空洞 | Sync-bound | set_sync_debug_mode、时间线 |
批量取回、pinned memory + 异步拷贝、避免数据依赖 shape | 七 |
| GPU 大段空白,CPU 停在 DataLoader | 数据加载 | 单独 Benchmark DataLoader | num_workers、离线预处理、prefetch |
七 |
reserved ≫ allocated,OOM |
碎片 | OOM 信息、memory_reserved |
expandable_segments、稳定 shape |
四 |
allocated 单调增长 |
泄漏 | memory snapshot | 找到持有 grad_fn 的引用 |
四 |
| 平均显存不高但 OOM | 峰值 | memory snapshot 时间线 | 不物化大 Tensor、checkpoint、no_grad |
四 |
3. 测量的纪律
- 计时必须同步,或用 CUDA Event;
- warmup 后再测,报告中位数与分布;
- 写明口径:batch / seq / dtype / 硬件 / 版本;
- 正确性测试先于性能改动,容差有依据;
- 一次只改一件事,每次回到同一个 Benchmark。
4. 优化报告的五个问题
- 节省的是计算、访存、同步还是 launch?
- 是否增加了显存?
- 是否改变了数值精度?
- 是否只对特定输入 shape 有效?
- 是否把成本转移到了编译或初始化阶段?
5. 与前几篇的连接
| 本篇的结论 | 在哪 | 依赖前几篇的哪个机制 |
|---|---|---|
| 访存模式决定 memory-bound Kernel 的实际带宽 | 第六章 §7 | 第二篇:stride / contiguous |
泄漏几乎总是持有了带 grad_fn 的 Tensor |
第八章 §6 | 第三篇:grad_fn 持有计算图 |
| 混合精度为什么快、快在哪类瓶颈、代价的边界 | 第六章 §5–6 | 第四篇:autocast / GradScaler |
| 每个算子在 CPU 上的固定成本从哪来 | 第五章 §1 | 第五篇:入口 / 分发 / 执行 |
| 融合是 memory-bound 算子的处方 | 第六章 §4 | 第七篇:融合与内存规划 |
6. 本篇涉及的源码位置
本篇讨论的机制在源码中的位置(对应第一篇第七章的代码地图):
| 路径 | 内容 |
|---|---|
torch/utils/benchmark/ |
Timer、Compare:处理预热、同步与统计的 Benchmark 工具 |
torch/profiler/profiler.py、torch/csrc/profiler/、third_party/kineto/ |
Profiler 的 Python 接口、C++ 采集与 CUPTI 集成 |
c10/cuda/CUDAStream.h、torch/cuda/streams.py |
Stream 与 Event:异步执行模型的实现 |
c10/cuda/CUDAFunctions.cpp |
set_sync_debug_mode 检测隐式同步的实现 |
c10/cuda/CUDACachingAllocator.cpp、torch/cuda/memory.py |
缓存分配器:block 池、碎片、allocated / reserved、memory snapshot |
torch/cuda/graphs.py、aten/src/ATen/cuda/CUDAGraph.cpp |
CUDA Graphs:消除 launch 开销 |
torch/utils/checkpoint.py |
Activation Checkpointing:用 saved_tensors_hooks 实现的重算 |
aten/src/ATen/native/transformers/ |
scaled_dot_product_attention 的 FlashAttention / 内存高效实现选择 |
到这里,单卡上的 PyTorch 已经讲完:Tensor、Autograd、Module、算子、扩展、编译、性能。第八章算过,7B 模型仅静态显存就要 112 GB,单卡放不下。下一篇进入多卡:
当一张卡放不下模型或跑不完数据时,PyTorch 如何把计算和状态切分到多个设备,并让通信与计算重叠?
十二、自测
-
不加
torch.cuda.synchronize()用time.time()量一个 GPU 算子,量到的是什么?答案
只量到 CPU 把 kernel 提交进 stream 的时间(微秒级),GPU 还没算完;必须同步后再读时间,或用 CUDA Event。
torch.utils.benchmark.Timer自动处理这件事以及预热与多次重复。 -
Profiler 时间线上 GPU 大部分时间空闲、CPU 一直忙、每个 kernel 只有几十微秒——是哪类瓶颈?三个处方?
答案
launch-bound:每个 kernel 太小,CPU 提交的速度跟不上 GPU 执行;增大 batch(让每个 kernel 更大)、算子融合 /
torch.compile(减少 kernel 数)、CUDA Graphs(一次提交整段);优化器用fused=True/foreach。 -
memory_reserved()40 GB 但memory_allocated()只有 20 GB 且报 OOM,发生了什么?开哪个开关?答案
碎片化:caching allocator 缓存的块被切成不连续的小段,新的大请求放不进任何一段;
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True让段可增长,或让每步的 shape 稳定。 -
训练时 GPU 时间线每隔一段就有对称的空洞、
nvidia-smi利用率上下跳——最可能是什么?怎么抓到源头?答案
sync-bound:循环里有
.item()、print(loss)、tensor.tolist()或依赖数据的 shape(nonzero、masked_select)强制 CPU 等 GPU;torch.cuda.set_sync_debug_mode("warn")在每次隐式同步处打印栈。 -
逐元素算子(
add、gelu、layernorm)在 profiler 里占了 40% 的 GPU 时间,能靠换更快的 GPU 解决吗?答案
收益有限——它们是 memory-bound,时间由字节数 / 带宽决定;处方是融合(
torch.compile把相邻逐元素算子合进一个 kernel)、用 bf16 减半字节、或算法级融合(FlashAttention 不物化中间矩阵)。
-
判断慢:先量而不是猜——用
torch.utils.benchmark.Timer处理预热、同步与统计,与一个理论下界比(FLOPs / 峰值算力、字节数 / 带宽),离下界几倍才叫慢(第二章、第三章)。定位为什么慢:用torch.profiler的时间线把症状归到六类——GPU 空闲、CPU 忙、kernel 多而小是 launch-bound(融合、CUDA Graphs);CPU 时间不在算子上是 Python-bound(with_stack找到、向量化或 compile);GPU 忙且逐元素算子占比高是 memory-bound(融合、bf16、布局);GPU 忙且 GEMM 占比高是 compute-bound(Tensor Core、减算量);时间线两侧交替空洞是 sync-bound(.item()、nonzero,用set_sync_debug_mode抓);GPU 大段空白、CPU 停在 DataLoader 是数据加载(第四至七章)。显存另一张表:reserved ≫ allocated是碎片、allocated单调涨是泄漏(memory snapshot 找持有grad_fn的引用)、平均不高但 OOM 是峰值(第八章)。原则:先分层再动手,改完再量。第九章在一个 Transformer block 上完整走一遍。 ↩
系列 《PyTorch 深度实践:从 Tensor 到深度学习运行时》 第 8 / 11 篇 深入
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
学习地图 — AI-Infra 工程师学习地图 第 3 / 14 个系列
上一篇:编译执行与图优化下一篇:分布式 PyTorch
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/pytorch-performance-optimization-and-debugging.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。