PyTorch 训练框架内核深度解析
第21章 Profiler 与性能调优
"An experimental config for Kineto features. Please note that backward compatibility is not guaranteed."
——
torch/csrc/profiler/python/init.cpp:384对_ExperimentalConfig的说明;profiler 最有用的那些开关,恰恰都在这行警告后面
本章要点
torch.profiler.profile是 PyTorch 内置 profiler,能同时记录 CPU 函数调用、CUDA kernel 执行、显存分配、autograd 事件- 底层是 Kineto + CUPTI:Kineto 是 PyTorch 自家的库(
third_party/kineto指向github.com/pytorch/kineto),CUPTI 才是 NVIDIA 的 Profiling Tools Interface - 三种主要输出:text summary(
key_averages())、chrome trace(拖进chrome://tracing看时间线)、TensorBoard 插件(更友好的可视化) - Memory Snapshot:
torch.cuda.memory._record_memory_history()捕获每次 alloc/free 的栈,配pytorch.org/memory_viz可视化(第 4 章 §4.11) - Schedule API 让 profiler 在多 step 训练里"录制几步、跳过几步、重复",避免 trace 体积失控
record_function是用户级注解:在自定义代码段插入 marker,让 trace 有可读名字
21.1 训练性能问题的诊断思路
训练性能问题大概分四类:
| 症状 | 可能原因 |
|---|---|
| GPU 利用率低(< 80%) | DataLoader 跟不上 / 通信瓶颈 / 主机端瓶颈 |
| GPU 利用率高但吞吐低 | 算子选错 dtype / kernel 调用频繁(调度开销) |
| OOM 显存爆 | activation 太多 / 碎片化 / state 没释放 |
| 训练突然慢 | 某 step 触发隐式同步(如 .item())、CUDA Graph 失效 |
诊断这四类问题的标配工具是 profiler。
21.2 一个最简 profiler 用法
from torch.profiler import profile, ProfilerActivity
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
record_shapes=True,
profile_memory=True,
with_stack=True,
) as prof:
for i, batch in enumerate(loader):
loss = model(batch).sum()
loss.backward()
optimizer.step()
optimizer.zero_grad()
if i >= 4:
break
print(prof.key_averages().table(sort_by='cuda_time_total', row_limit=20))
prof.export_chrome_trace('trace.json')
输出表格大致如下(只节选几列,数字为示意;完整表还有 Self CPU %、Self CUDA、profile_memory=True 时的 CPU Mem / CUDA Mem、# of Calls 等列):
--------------------------- ------------ ------------- ----------
Name CUDA total CUDA time avg CPU total
--------------------------- ------------ ------------- ----------
aten::mm 1.234s 12.34ms 0.123s
aten::add 0.567s 5.67ms 0.056s
aten::layer_norm 0.234s 2.34ms 0.023s
...
sort_by='cuda_time_total' 在 v2.11 里仍可用,排序时会被换成 device_time_total(torch/autograd/profiler_util.py 里 sort_by.replace("cuda", "device"))。
关键开关:
record_shapes=True:记录每次算子调用的输入 shape,方便对比"形状相同的 op 加起来多少时间"profile_memory=True:记录每次 alloc/free 的字节数with_stack=True:附带 Python 栈,让你知道某算子是从哪行代码调的(开销更大、慢一些)with_flops=True:按公式估算 matmul / conv 等少数算子的 FLOPs,表里多出一列Total KFLOPs(单位随量级自动换成 MFLOPs / GFLOPs 等)
注意 prof.step_num 只在你显式调 prof.step() 时才递增(torch/profiler/profiler.py:891),上面这种没有 schedule 的用法里它一直是 0 —— 想按步数停就用普通计数器。要按步录制见 §21.5。
21.3 chrome trace:时间线可视化
prof.export_chrome_trace('trace.json') 输出一个 JSON 文件。把它拖进 chrome://tracing/ 或者 https://ui.perfetto.dev/,能看到:
CPU thread: [Python] [aten::mm] [aten::relu] [Python] [aten::add] ...
↓ launch ↓ launch
CUDA stream: ........ [mm kernel] [relu kernel] ........ [add kernel]
横轴是时间,纵轴是不同 thread / stream。每个矩形是一个事件,鼠标悬停显示详细信息(duration / args)。
诊断思路:
- GPU stream 上有大段空白 → CPU 端追不上 GPU,可能是 DataLoader / Python 慢
- CPU 上有 dispatcher 密集小事件 → 调用太多小算子,考虑 fuse / torch.compile
- kernel 之间有间隙 → cudaStreamSynchronize 等同步操作(找隐式同步的
.item()/.cpu()) - memcpy H2D / D2H 异常多 → 数据没 pin / 频繁跨 device
21.4 Kineto:底层数据采集
这里要澄清一个常见误解:Kineto 是 PyTorch 自家的项目(.gitmodules 里 third_party/kineto 指向 https://github.com/pytorch/kineto),不是 NVIDIA 的。它是一层跨后端的性能数据采集抽象;真正属于 NVIDIA 的是它调用的 CUPTI(CUDA Profiling Tools Interface)。Kineto 经由 CUPTI 拿到:
- 每个 CUDA kernel 的 launch / completion 时间
- memcpy 的 H2D / D2H 时间
- NVTX range 标记
- GPU 上 SM utilization、DRAM bandwidth 等硬件计数器
PyTorch 的 torch/csrc/autograd/profiler_kineto.cpp(1183 行)把 Kineto 数据接进 PyTorch 的事件 timeline,与 CPU 端的 dispatcher 调用、autograd 事件合并成统一 trace。Kineto 这层抽象也是 ROCm / XPU 等后端能复用同一套 profiler API 的原因(§21.35)。
开销不可忽略:开 profiler 后每个算子多一次 RecordFunction 回调(§21.12),CUPTI 那边还要额外缓冲 GPU 事件。具体拖慢多少取决于算子密度——小算子越多、相对开销越大。结论是一样的:生产代码不要长开 profiler,用 schedule 录几步就停。
21.5 Schedule API:录几步停几步
朴素 profile 整个 epoch,trace 文件会大到根本打不开。Schedule API:
from torch.profiler import schedule
# 注意 schedule 是 keyword-only, wait / warmup / active 三个必填
my_schedule = schedule(
skip_first=1, # 最前面跳过 1 步
wait=1, # 然后等 1 步不录
warmup=1, # 预热 1 步 (开采集但丢弃)
active=3, # 真正录 3 步
repeat=2, # 循环 2 次 (默认 0 = 一直循环到 profile 结束)
)
with profile(
activities=[...],
schedule=my_schedule,
on_trace_ready=lambda p: p.export_chrome_trace(f'/tmp/trace_{p.step_num}.json'),
) as prof:
for step, batch in enumerate(loader):
# 训练循环
prof.step() # 必须每步调一次让 schedule 推进
prof.step() 推进 profiler 状态机。ProfilerAction 只有四个取值(torch/profiler/profiler.py:490):NONE / WARMUP / RECORD / RECORD_AND_SAVE;skip_first 与 wait 都映射到 NONE,一个 cycle 的最后一步是 RECORD_AND_SAVE,在它切走时触发 on_trace_ready。另有一个 skip_first_wait 参数:非零时让第一个 cycle 跳过 wait 阶段(想 profile 早点开始但后续 cycle 间隔大时有用)。这种"采样式"录制让 trace 体积可控。
21.6 TensorBoard 插件
PyTorch 提供 TensorBoard 集成(独立包 torch_tb_profiler)。先用 on_trace_ready=torch.profiler.tensorboard_trace_handler('/tmp/profiler_output') 把每个 cycle 的 trace 写成 <worker_name>.<时间戳>.pt.trace.json,再:
pip install torch_tb_profiler
tensorboard --logdir=/tmp/profiler_output
打开浏览器看 GPU Kernel View / Distributed View / Memory View / Trace View。比 chrome trace 多一层自动分析 ——"GPU idle 时间占比"、"top kernels"、"memory timeline"。不过这个插件在 PyPI 上的最后一版是 0.4.3(2023-10),之后没有再发布;新环境里装不上或显示异常时,直接用 Perfetto 打开 chrome trace 更稳妥。
21.7 Memory Snapshot:显存调试利器
第 4 章 §4.11 提过:
torch.cuda.memory._record_memory_history(max_entries=100000)
# 跑训练几个 step
for step, batch in enumerate(loader):
train_step(batch)
if step >= 5: break
torch.cuda.memory._dump_snapshot('mem.pickle')
torch.cuda.memory._record_memory_history(enabled=None)
把 mem.pickle 拖进 https://pytorch.org/memory_viz(PyTorch 官方 viewer),能看到:
- 每次 alloc 的时间、大小、调用栈
- 每次 free 的时间
- 显存随时间的曲线
- 哪些 alloc 长期占用、哪些短期
- 碎片化情况
诊断 OOM / 显存膨胀的金钥匙。第 4 章 §4.13 有完整诊断流程。
21.8 record_function:用户级注解
profiler 默认只看到 ATen 算子。如果你想给"自定义代码段"加 marker:
from torch.profiler import record_function
with record_function("my_data_preprocess"):
data = preprocess(raw)
with record_function("my_attention_block"):
out = my_attention(data)
record_function 在 trace 里产生一个父事件,包住下面所有子事件。在 chrome trace / TB 里能看到自定义名字,让 trace 易读得多。对复杂 pipeline 强烈建议加 record_function 标注关键阶段。
NVTX range 是面向 NVIDIA 工具的对应物(在 Nsight Systems 里能看到):
torch.cuda.nvtx.range_push("my_section")
do_work()
torch.cuda.nvtx.range_pop()
21.9 Distributed View:DDP/FSDP 的通信分析
profiler 在多 rank 训练时每个 rank 各自录一个 trace。把所有 rank 的 trace 文件夹喂给 TensorBoard,开 Distributed View 能看到:
- 每个 rank 的 timeline 对比
- collective 通信的时间分布
- 各 rank 在 AllReduce 上等的时间("等最慢的 rank"现象)
- communication / computation overlap 比例
DDP / FSDP 调优靠这个 view 找瓶颈。"哪个 rank 慢"是分布式诊断第一问,distributed view 直接告诉你。
21.10 _ExperimentalConfig:高级 profiler 选项
torch/csrc/profiler/python/init.cpp:368 注册的 _ExperimentalConfig 暴露 12 个高级开关:
| 字段 | 用途 |
|---|---|
profiler_metrics |
CUPTI 硬件计数器列表(如 sm__cycles_active.avg.pct_of_peak_sustained_elapsed),让 profiler 进 CUPTI profiling mode 拿 GPU 占用率 / DRAM 带宽等 |
profiler_measure_per_kernel |
硬件指标按 kernel 测还是整段测 |
verbose |
trace 文件里是否带 Call stack 字段 |
performance_events |
进一步定制要测的事件 |
enable_cuda_sync_events |
把 CUDA stream / event 同步操作也放进 trace(默认关)—— 调试隐式同步用 |
adjust_profiler_step |
让 profiler.step 时长与父 Python 事件对齐 |
disable_external_correlation |
关闭 CPU-GPU 事件关联 |
profile_all_threads |
在所有线程上挂 callback(默认只挂在开启 profiler 的那个线程上,torch/csrc/profiler/orchestration/observer.cpp 的 pushGlobalCallbacks()) |
capture_overload_names |
trace 里出现 aten::add.Tensor 而不只是 aten::add |
record_python_gc_info |
把 Python GC 事件加入 trace(诊断 GC 卡顿) |
expose_kineto_event_metadata |
暴露 Kineto 事件原始 metadata 到 Python 端 |
custom_profiler_config |
自定义 backend(如某厂商自家硬件)的配置字符串 |
from torch.profiler import _ExperimentalConfig, profile
config = _ExperimentalConfig(
enable_cuda_sync_events=True, # 看隐式同步
capture_overload_names=True, # 区分 aten::add.Tensor / .Scalar
record_python_gc_info=True, # 找 GC pause
)
with profile(activities=[...], experimental_config=config) as prof:
train_step()
诊断高级问题(如训练偶尔卡顿)时这些开关价值很高 —— 能看到默认 trace 里被隐藏的 GC pause、stream 同步、overload 切换等"非典型"事件。
21.11 chrome trace 的 JSON schema
prof.export_chrome_trace('trace.json') 输出符合 Trace Event Format(Google Chrome / Perfetto 共用)的 JSON:
{
"traceEvents": [
{"name": "aten::add", "cat": "cpu_op", "ph": "X", "ts": 12345, "dur": 8,
"tid": 0, "pid": 12345,
"args": {"Input Dims": [[4, 4], [4, 4], []], "Input type": ["float", "float", "Scalar"]}},
{"name": "ProfilerStep#1", "ph": "X", "ts": 100, "dur": 50000, "tid": 0},
{"name": "Memcpy HtoD", "ph": "X", "ts": 200, "dur": 30,
"tid": 99, "args": {"bytes": 4096}}
]
}
每条事件的核心字段:name / ph(X = duration、B/E = begin/end pair、i = instant)/ ts(开始时间,微秒)/ dur(持续时长)/ pid 与 tid(进程 / 线程,CUDA stream 也用 tid 表达)/ args(额外属性);PyTorch 还会写 cat 区分事件类别(cpu_op、python_function、user_annotation 等)。record_shapes=True 时输入形状与 dtype 记在 args 的 Input Dims / Input type 里(本机 CPU 实跑所见)。
tid / pid 的具体取值由 Kineto 决定、随版本变化,别去背魔数。可靠的做法是看 trace 里的 metadata 事件:Chrome Trace Format 用 {"name": "thread_name", "ph": "M", ...} 与 "process_name" 这类 ph: "M"(metadata)事件给每条泳道命名,CPU 线程会被命名成 thread 12345 (python) 这样的可读名字(本机 CPU 实跑所见),GPU stream 泳道同样有 Kineto 写入的 thread_name。
# 先看有哪些泳道
python -c "
import json; d=json.load(open('trace.json'))
for e in d['traceEvents']:
if e.get('ph')=='M' and e.get('name') in ('thread_name','process_name'):
print(e['pid'], e.get('tid'), e['args'])
" | sort -u
拿到名字与 tid 的对应关系,再按 tid grep 具体事件 —— 比在 viewer 里滚动快得多。
21.12 record_function 的 C++ 实现
Python 端 with record_function("foo"): 进 C++ 是 at::RecordFunction 类(aten/src/ATen/record_function.h)。它的工作:
- 构造时调注册的 callbacks(profiler 会在这里挂钩,记录 begin 时间)
- 析构时调注册的"end" callbacks(profiler 记录 end 时间)
- profiler 的线程状态挂在
c10::ThreadLocalDebugInfo的PROFILER_STATE槽上(torch/csrc/profiler/orchestration/observer.cpp),callback 据此找到当前线程该写入的事件缓冲
dispatcher 主路径上每个算子调用都可能触发一次 RecordFunction。实现在 aten/src/ATen/core/dispatch/Dispatcher.h:Dispatcher::call 先取 at::StepCallbacks,只有非空时才走 callWithDispatchKeySlowPath(:710),在那里构造 RecordFunction guard 并调 runRecordFunction(:741 / :753 / :756)。这就是 profiler 能"看到所有算子"的实现。
关键是这个快慢路径分离:没注册 callback 时,call 连 RecordFunction 对象都不构造,代价只有一次"callbacks 是否为空"的检查;开了 profiler 才落到慢路径。所以 profiler 的 overhead 与算子数量成正比 —— 小算子越密集,相对开销越大,这也解释了为什么"很多小 op 的模型"开 profiler 后掉速最明显。
runRecordFunction 有两个重载:一个带 inputs(callback 声明了 needsInputs(true) 时走它;typed 路径里由 :725 的 if (guard.needsInputs()) 选择,boxed 路径 callBoxed 里是 :876 起的三元表达式),一个不带。record_shapes=True 走的就是前者,所以它比默认更慢。
21.13 CUPTI 硬件计数器:从 kernel 时长走到 SM 利用率
profile() 默认只记 kernel 时长。要看更深的硬件指标(如 SM 占用率、DRAM 带宽),用 _ExperimentalConfig.profiler_metrics:
from torch.profiler import _ExperimentalConfig
config = _ExperimentalConfig(
profiler_metrics=[
"sm__cycles_active.avg.pct_of_peak_sustained_elapsed", # SM 活跃率
"smsp__sass_thread_inst_executed_op_fadd_pred_on.sum", # FP add 总数
"dram__bytes.sum", # DRAM 读写字节
"lts__t_sectors.sum.per_second", # L2 cache traffic
],
profiler_measure_per_kernel=True,
)
CUPTI(CUDA Profiling Tools Interface)暴露了上千个 metric,分几大类:
| 类别 | 代表 metric | 看什么 |
|---|---|---|
| Compute | sm__cycles_active, smsp__inst_executed |
SM 是否在算 vs idle |
| Memory | dram__bytes, lts__t_sectors |
DRAM/L2 traffic |
| Latency | gpu__time_active.avg, sm__warps_active |
warp 占用率(latency-hiding 程度) |
| Tensor Core | sm__inst_executed_pipe_tensor |
TC 是否被用 |
实战分析路径:
flowchart TB
Slow[kernel 执行慢]
Slow --> Q1{SM 利用率 > 80%?}
Q1 -->|否| MB[memory bound:<br/>DRAM 带宽是否打满?]
Q1 -->|是| CB[compute bound:<br/>是否用 Tensor Core?]
MB -->|是| OK1[已极限, 看能否减少 traffic<br/>fuse / 量化]
MB -->|否| Latency[latency bound:<br/>warp occupancy 太低]
Latency --> Block[调 block size / num_warps]
CB -->|否| Force[force Tensor Core<br/>用对齐 shape / fp16 mm]
CB -->|是| Try[已极限, 考虑算法改进]
style MB fill:#fef3c7
style CB fill:#dcfce7
一个典型的排查形态:某个 GEMM kernel 看着比同尺寸的别的 GEMM 慢很多,metric 显示 Tensor Core 相关计数很低 → 去看 input shape,发现某一维不是 Tensor Core 要求的对齐倍数(各代要求不同,fp16 常见是 8 或 16 的倍数),kernel 只好回落到非 TC 路径。padding 到对齐尺寸后 TC 计数上来、kernel 变快。
理解 CUPTI 让你从"猜"过渡到"测":每个性能问题都有具体 metric 暴露根因。metric 名字与语义随 CUDA 版本变化,用 ncu --query-metrics 查当前环境支持哪些,别抄固定列表。Nsight Compute / nsys 是更专业的 viewer,PyTorch profiler 内置的够覆盖日常调优。
21.14 profile × torch.compile:看 fused kernel 的真面目
torch.compile 让多个 ATen 算子 fuse 成一个 Triton kernel。profile 时这种 kernel 名字看起来像 triton_poi_fused_add_mul_relu_0——通过名字就能看出 fused 了哪些 op(默认按原始 aten 算子名排序拼接,见 torch/_inductor/utils.py 的 get_fused_kernel_name)。
@torch.compile
def f(x, w):
return torch.relu(x @ w + 1.0).mul(2.0)
with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof:
f(input, weight)
print(prof.key_averages().table(sort_by='cuda_time_total'))
输出(精简):
Name CUDA total
triton_poi_fused_add_mul_relu_0 0.234 ms <- add / relu / mul 三个 op 融进 1 个 Triton kernel
aten::mm 0.523 ms <- matmul 仍交给 cuBLAS(extern kernel)
对比不开 compile:
Name CUDA total
aten::mm 0.523 ms
aten::add 0.078 ms
aten::relu 0.156 ms
aten::mul 0.043 ms
一眼看出 fusion 有没有发生:原本 4 个独立 kernel(各自一次 HBM 读写)变成 2 个 —— 不开 max_autotune 时 Inductor 默认把 mm 交给 aten 的 extern kernel(torch/_inductor/kernel/mm.py 的 tuned_mm),三个 elementwise 融成 1 个,中间结果留在寄存器 / shared memory 里不落 HBM —— 收益大小取决于被省掉的那几趟显存往返占原时间多少,memory-bound 的 elementwise 链收益最大,GEMM 主导时收益有限。
如果某个预期会 fuse 的 op 没 fuse(trace 里仍是 aten::xxx 而非 triton_yyy),说明 fusion pattern miss → 看 output_code.py 找原因(§15.11.2)。
调优路径:
- profile 看每个 fused kernel 时长 + 内含 op 名
- 找耗时大头 kernel
- 看 Inductor
output_code.py对应 Triton 源码 - 决定是否能进一步优化(如改 dtype、加 mark_dynamic、调 max_autotune)
这套流程让 compile 调优有迹可循。没有 profile 时 compile 是黑盒,开 profile 后每个决策都有数据支撑。
21.15 配合 py-spy:Python 端火焰图
profile 看 PyTorch 内部,但Python 端的瓶颈(如 Python 循环慢、numpy 操作多)profile 看不到。互补工具是 py-spy:
# 在训练进程跑起来后,单独终端
pip install py-spy
py-spy record --pid <pid> -o flame.svg --duration 60
输出 SVG 火焰图:每个矩形宽度 = 该函数 CPU 占用时间。互补 PyTorch profiler 的视角:
| 工具 | 看什么 | 不看什么 |
|---|---|---|
| torch.profiler | ATen 算子 / CUDA kernel / Python 调用栈关联 | 纯 Python 循环细节 |
| py-spy | Python 函数级 CPU 时间分布 | GPU kernel |
| nsys | 硬件级 timeline | 高层 ATen 语义 |
典型场景:GPU 利用率上不去,torch.profiler 看 GPU stream 上没有明显异常、但 kernel 之间大段空白 —— 说明时间花在 profiler 看不见的地方。py-spy 一挂,发现某个 Python 循环里在做大量 numpy 小操作。改成 batched 的 torch op,CPU 就不再是瓶颈。
py-spy 优势:
- 不需要修改代码:直接 attach 到 running process
- 采样式:按固定频率读取目标进程的栈,不插桩,对被测进程影响很小
- 支持子进程:
py-spy record --pid <pid> --subprocesses自动跟 fork 出的 worker
生产建议把 py-spy 加进训练 toolkit,与 PyTorch profiler 配合用。一个看"PyTorch 内部"、一个看"Python 端",共同覆盖完整调优空间。
21.16 真实案例:FSDP all_gather × forward overlap 调优
第 18 章 §18.4 讲了 FSDP 用 prefetch + 独立 stream 让 AllGather 与 forward 计算重叠。怎么验证 overlap 真的发生了?用 profiler。
with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof:
for _ in range(3):
out = fsdp_model(input)
loss = out.sum()
loss.backward()
prof.step()
prof.export_chrome_trace('fsdp_trace.json')
打开 chrome trace,找到几条关键 stream(下面括号里的泳道名只是示意,实际名字以 trace 的 thread_name 元数据为准,见 §21.11):
- default stream (CUDA:0:0):跑 forward / backward 计算 kernel
- comm stream (CUDA:0:1):跑 NCCL all_gather / reduce_scatter
- CPU thread:dispatcher 调用
理想情况:
default stream: [layer0 fwd] [layer1 fwd] [layer2 fwd] ...
comm stream: [ag layer1 ] [ag layer2 ] [ag layer3 ] ...
↑ 与 layer0 同时跑 ↑ 与 layer1 同时跑
如果看到 [ag layerN] 与 [layer(N-1) fwd] 并行、相邻两层 forward 之间几乎没有空隙 → overlap 成功。
排查形态 1:每层 forward 前都有一小段空隙 → prefetch 没赶上。FSDP-1 侧检查 forward_prefetch(默认 False)与 backward_prefetch(默认 BackwardPrefetch.BACKWARD_PRE);FSDP-2 侧则用 set_modules_to_forward_prefetch / set_modules_to_backward_prefetch 显式指定 prefetch 目标(第 18 章 §18.20)。注意没有 prefetch_policy / prefetch_size 这两个参数,网上流传的这类写法是错的。
排查形态 2:comm stream 上某次 all_gather 明显长于同伴 → 通常是 wrap 粒度不对,某个 unit 的参数量远大于其他,或者同一批参数被反复 unshard。回到第 18 章 §18.13 调 wrap 粒度。
这种"profile 看 stream 关系"是 FSDP 调优最直接的工具。没看 trace 之前的 FSDP 调优都是猜测。
21.17 真实案例:Transformer Attention 性能优化
最经典的 profile-driven 优化案例:transformer attention 从"naive 实现"到"FlashAttention"的演进。每一步都用 profile 数据驱动。
baseline (naive attention):
def attention(q, k, v):
scores = q @ k.transpose(-2, -1) / sqrt(d) # [B, H, L, L]
attn = softmax(scores, dim=-1)
return attn @ v
profile 会给出这样的形状(B=4, H=32, L=2048, d=128):
Name 说明
aten::matmul Q @ K^T <- 大头
aten::div /sqrt(d) <- 纯 elementwise, 但要完整读写一遍 scores
aten::softmax exp + reduction + div
aten::matmul @ V <- 另一个大头
真正致命的不是时间而是显存:scores 的形状是 [4, 32, 2048, 2048],fp16 下就是 4×32×2048×2048×2 字节 = 1 GB,而且这个中间张量必须完整实例化(softmax 要沿最后一维归一化)。
优化 1:torch.compile 自动 fuse
Inductor 的 pattern matcher(torch/_inductor/fx_passes/fuse_attention.py,如 _sfdp_pattern_1)能认出 matmul → div → softmax → matmul 这类朴素写法,直接替换成 scaled_dot_product_attention(本机 CPU 实跑上面这段 attention,counters["inductor"]["fuse_attention"] 计数为 1)。写法不匹配这些模板时,compile 才只是把 div 与 softmax 融进一趟、省掉一次对 scores 的完整读写,1 GB 的 scores 张量仍然要实例化。
优化 2:FlashAttention(手写算子)
FlashAttention 把 attention 做成"分 block 的 streaming 计算":
for block_K, block_V in chunks(K, V):
scores_block = Q @ block_K.T
softmax 一部分
output += softmax_part @ block_V
中间 scores 永不完整实例化,显存占用从与 L² 成正比降到与 block 大小成正比。省下的不只是显存,还有那 1 GB 数据反复过 HBM 的时间 —— 这才是 FlashAttention 提速的主因。
PyTorch 用 F.scaled_dot_product_attention 自动选 backend:
out = F.scaled_dot_product_attention(q, k, v)
# 内部按输入 dtype / shape / mask 情况在几个 backend 之间选
# (flash attention / memory-efficient / cuDNN / math fallback 等)
顺带一提:scaled_dot_product_attention 本身在 autocast 的 AT_FORALL_LOWER_PRECISION_FP 白名单里(第 20 章 §20.6),amp 下会自动走低精度。
profile 一对比,三种实现的 kernel 数量、名字与显存占用差异一目了然。这种"测量-优化-再测量"的循环是性能工程的标准动作。至于具体快多少,取决于 B / H / L / d 与硬件,必须自己测。
理解这个案例让你看到 PyTorch 内置 SDPA 不是"魔法 API"——它是几年研究 + 工程优化的结果,profile 是验证每个优化真的有效的关键工具。
21.18 memory_viz 实战阅读
memory_viz 是 PyTorch 官方显存可视化(pytorch.org/memory_viz)。把 dump 出的 pickle 拖进去看到的核心视图:
Active Memory Timeline(横轴时间、纵轴活跃显存):
显存
↑
80GB ─────────────────────────────────────
70GB ╱╲ ╱╲ ╱╲
60GB ╱ ╲ ╱ ╲ ╱ ╲
50GB ─────╱ ╲──────╱ ╲─────╱ ╲──── (训练 step 边界)
40GB ────fwd───bwd───fwd───bwd───fwd───bwd
30GB ──── (常驻 weight + optim state)
→ 时间
每个尖峰对应"forward 累积 activation",回落对应"backward 释放 activation"。常驻部分是 weight + optim state。
实战诊断:
- 尖峰持续上涨(每 step 比上一 step 高几 GB):activation 没释放、内存泄漏。检查代码里有没有把 tensor 加到 list / 缓存里
- 常驻部分在第 1 步之后抬高一截:多半是 optimizer state 在第一次
step()时才分配,属正常;之后还在逐步上涨才是问题 - 怀疑碎片化:这张图画的是活跃(allocated)显存,看不出碎片;要切到 Active Cached Segment Timeline 或下面的 Allocator State History,看 segment 总量与块之间的空洞
- 常驻部分大:weight 太大 → 用 FSDP / 量化降低;optim state 大 → 用 8-bit Adam
时间线上每一块色带就是一次 alloc,鼠标悬停会显示它的大小和分配时的 Python / C++ 调用栈 —— 直接定位到哪个文件、哪一行。某块特别宽的色带若落在 attention 的 softmax 附近,多半就是 naive attention 的 scores,应换 FlashAttention。
Allocator State History:
按事件顺序回放 allocator 的每一次 segment 分配、alloc、free:左侧是事件列表,右侧画出选中时刻各 segment 里块的占用情况,下方显示该事件的调用栈。看碎片化、看哪些 segment 被长寿小块钉住,用的就是这个视图。
(v2.11 的 viewer 源码在 torch/utils/viz/MemoryViz.js,下拉框里共有 Active Memory Timeline / Allocator State History / Active Cached Segment Timeline / Allocator Settings 四项。)
理解 memory_viz 这几个视图让你在 OOM 时快速定位根因。第 4 章 §4.11 给了机制,本章给"怎么读"。两章配合让显存调试不再玄学。
21.19 .item() / .cpu() / .tolist():隐式同步陷阱
trace 里看到 cudaStreamSynchronize 频繁出现 → 几乎肯定是隐式同步。常见触发点:
loss = compute_loss(out, target)
# 隐式同步!
print(loss) # str() 触发 .item()
loss_value = loss.item() # 显式触发
losses.append(loss.item()) # 训练循环里累积 metric
# 也是隐式同步
if loss < 1e-4: break # bool() 触发 .item()
preds = out.cpu().numpy() # GPU → CPU + Python conversion
每次 .item() 让 CPU 等 GPU stream 完成 → 打破 async pipeline。在 hot loop 里反复调用,吞吐会明显下降。
修复模式:
# 错误:每 step 同步
losses = []
for batch in loader:
loss = train_step(batch)
losses.append(loss.item()) # ← 每 step 同步
# 正确:把 metric 累积在 GPU 上, epoch 末再 .item()
loss_sum_gpu = torch.zeros(1, device='cuda')
for batch in loader:
loss = train_step(batch)
loss_sum_gpu += loss.detach()
mean_loss = (loss_sum_gpu / len(loader)).item() # 只同步 1 次
profile 验证:修复前 trace 里每 step 之间夹着 cudaStreamSynchronize、GPU stream 有空白;修复后这些事件消失、空白合拢。收益大小取决于原来每步同步几次、单次要等多久。
判断规则很简单:任何要把 GPU 上的"数值"变成 Python 值的操作都会同步;只读"元信息"的不会。
tensor.numel()/tensor.shape/tensor.dtype/tensor.device:不同步(这些存在 TensorImpl 里,不碰显存)tensor.item()/float(tensor)/int(tensor):同步if tensor:/while tensor > 0:(隐式调bool()):同步tensor.tolist()/tensor.numpy()/tensor.cpu():同步assert tensor.allclose(...):allclose返回 bool,同步print(tensor):要格式化数值,同步
"避免在 hot loop 里调 .item() / .cpu()" 是 PyTorch 训练性能 No.1 经验法则。理解 trace 上的 cudaStreamSynchronize 就是这套法则的可视化体现。
21.20 NVTX range + Nsight Systems 实战
torch.cuda.nvtx 比 record_function 更专业(在 Nsight Systems 看到):
import torch.cuda.nvtx as nvtx
for step, batch in enumerate(loader):
nvtx.range_push(f"step_{step}") # 嵌套层 1
nvtx.range_push("forward") # 嵌套层 2
out = model(batch)
nvtx.range_pop() # forward end
nvtx.range_push("backward")
out.sum().backward()
nvtx.range_pop() # backward end
nvtx.range_push("optimizer")
optimizer.step()
nvtx.range_pop() # optimizer end
nvtx.range_pop() # step end
用 nsys 录制:
nsys profile --trace=cuda,nvtx,osrt -o report python train.py
# 新版本产物是 report.nsys-rep, 老版本是 .qdrep; 用 nsys-ui 打开
Nsight Systems 比 chrome trace 强大得多:
- 硬件级 timeline:含 GPU SM 占用、DRAM 带宽、PCIe traffic 实时曲线(GPU metrics 采样要在
nsys profile时另开选项) - NVTX range 层级:你定义的 step / forward / backward 自动嵌套展开
- stream 关系:跨 stream 依赖以箭头显示
- 分析报告:自动生成"top kernel by occupancy / latency"等
生产调优 LLM 训练时 nsys 是终极工具。chrome trace 适合快速看一眼、nsys 适合深入分析。
实战工作流:
- 训练异常 → torch.profiler 录 trace 看大概问题
- 怀疑 GPU 端瓶颈 → nsys 录详细 trace 看 SM/DRAM 利用率
- 怀疑 Python 端 → py-spy 看火焰图
三个工具组合用,能覆盖大多数性能问题。每个都精通需要时间,但理解各自定位让你在不同场景选对工具。
21.21 profile 数据后处理:自定义聚合分析
prof.events() 返回所有事件列表,可以自己写分析逻辑:
from collections import defaultdict
from torch.autograd import DeviceType
events = prof.events()
# 按 kernel 名聚合 GPU 时间(device 侧事件的 name 是 kernel 名)
kernel_total = defaultdict(int)
for e in events:
if e.device_type == DeviceType.CUDA:
kernel_total[e.name] += e.device_time_total # cuda_time_total 已 deprecated
# 按 (op, shape) 聚合:相同 op 不同 shape 各自时间(需 record_shapes=True)
# 注意 CPU op 的 device_time_total 含子 op 的 kernel,嵌套 op 会重复计入;
# 只看"自身"时间用 self_device_time_total
op_shape_total = defaultdict(int)
for e in events:
if e.device_type == DeviceType.CPU:
key = (e.name, str(e.input_shapes))
op_shape_total[key] += e.self_device_time_total
按 shape 聚合其实也有现成的:prof.key_averages(group_by_input_shape=True) 会多出一列 Input Shapes(torch/profiler/profiler.py 的 key_averages,另有 group_by_stack_n / group_by_overload_name)。自己写循环的价值在于内置分组给不了的维度。
实战聚合:
- Top kernel by total time:哪个 op 占总时间最多
- Top kernel by call count:哪个 op 调用次数最多(可能小但累积大)
- Per-shape analysis:发现"某个 shape 的 mm 特别慢"
- Time per layer:用
record_function标 layer 名后聚合
这种"自定义分析"在大规模训练里价值很高。预定义 view(chrome trace / TB 插件 / key_averages)能解决大部分问题,剩下的需要自己写脚本聚合。
例子:某个 GEMM 在总时间里占比很高,但它分散在几百次调用里,viewer 里一个个看没意义。按 (op name, input_shapes) 聚合之后,立刻能看出某一类 shape 明显偏慢 —— 通常是没对齐到 Tensor Core 要求的倍数(§21.13)。这种按维度聚合的分析是 profile 的高级用法。
21.22 H2D / D2H 拷贝优化案例
profile 看到 Memcpy HtoD 占了总时间的一大块 → 多半是数据传输方式有问题。诊断 + 修复流程(下面两段输出是示意,数字随硬件与 batch 而变):
症状:
Name CUDA total
Memcpy HtoD (Pageable→Device) 250 ms <- 占整 step 30%
aten::add 50 ms
aten::mm 300 ms
Pageable→Device 是不走 pinned memory 的慢路径:驱动要先把数据拷进内部的 pinned 暂存区再 DMA,这次拷贝对 host 是同步的。
诊断步骤:
- 检查 DataLoader 配置:
pin_memory=True?没开就立刻开 - 检查代码:
tensor.cuda()vstensor.cuda(non_blocking=True)?后者必须配 pin_memory 才有效 - 检查 source dtype:CPU tensor dtype 与 GPU 一致吗?不一致触发 implicit cast → 在 CPU 端做 cast 后才能 DMA
- 检查 copy 频率:是否每 step 多次
.cuda()同一份数据?应该只 cuda 一次然后复用
修复案例:
# 错误:每 step pageable copy
for batch in loader: # batch 在 CPU pageable memory
batch = batch.cuda() # 触发 pageable → device, 慢路径
out = model(batch)
# 修复 1: pin_memory + non_blocking
loader = DataLoader(dataset, ..., pin_memory=True)
for batch in loader:
batch = batch.cuda(non_blocking=True) # pinned → device, 异步
out = model(batch)
# 修复 2: 预分配 GPU buffer, 每步只 copy_ 新数据 (batch_cpu 仍需是 pinned 才异步)
buffer_gpu = torch.empty(shape, device='cuda')
for batch_cpu in loader:
buffer_gpu.copy_(batch_cpu, non_blocking=True) # 只填新数据
out = model(buffer_gpu)
profile 修复后:
Name CUDA total
Memcpy HtoD (Pinned→Device) 50 ms <- 快路径, async, 与计算 overlap
aten::add 50 ms
aten::mm 300 ms
pinned 走的是可异步的快路径,H2D 能与计算 overlap,这段拷贝时间大部分被隐藏掉 —— 具体省多少要在自己的机器上量。这种"profile 看到问题 → 立刻修复 → profile 验证"是性能调优最爽的循环。
21.23 长跑训练的滚动 profile + 异常检测
生产 LLM 训练跑几天到几周,偶发慢 step 不易复现。解法:rolling profile + 异常检测。
import time
from collections import deque
step_times = []
profile_buffer = deque(maxlen=5) # 只留最近几次 sample
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
schedule=schedule(wait=100, warmup=2, active=3, repeat=10000),
on_trace_ready=lambda p: profile_buffer.append((time.time(), p.events())),
) as prof:
for step, batch in enumerate(loader):
t0 = time.time()
train_step(batch)
torch.cuda.synchronize() # CUDA 是异步的, 不同步就只量到了 launch 时间
step_time = time.time() - t0
step_times.append(step_time)
prof.step()
# 异常检测
if step > 100:
mean = sum(step_times[-100:]) / 100
if step_time > mean * 2:
# 这步比近 100 步均值慢 2x → 触发详细 dump
dump_full_profile(prof, batch_id=step)
send_alert(f"slow step at step {step}: {step_time:.2f}s vs mean {mean:.2f}s")
整套机制:
- 大约每 100 步 sample 一次 profile(schedule 控制:wait 100 + warmup 2 + active 3,105 步一个周期)
- 保留最近几次 sample 在内存(不上传)
- 异常时立刻 dump + 上报
- 正常时 sample 数据可丢弃 / 抽样上传
这类机制最常抓到的根因是 IO 抖动:网络文件系统偶发变慢,DataLoader 的 worker 卡在读数据上,trace 里表现为 GPU stream 一大段空白而 CPU 侧某个线程一直在等。没有这套采样 + 异常触发,这种"十小时一次"的问题基本无从复现。
理解这套 monitoring 模式让你能在生产主动发现问题,而非等到训练失败再回头查。是大模型 SRE 的标配能力。
21.24 Inductor output_code + profile 联合调试
profile 看到某 fused kernel 慢,怎么深入?联合 TORCH_COMPILE_DEBUG=1(§15.11.2)拿到 output_code,对照 profile 看每个 kernel 的实际行为。
工作流:
import os
os.environ['TORCH_COMPILE_DEBUG'] = '1'
@torch.compile
def f(x, w):
return torch.nn.functional.gelu(x @ w)
with profile(activities=[ProfilerActivity.CUDA]) as prof:
f(input, weight)
# 1. profile 里看到某个 triton_poi_fused_gelu_0 出乎意料地慢 (x @ w 本身默认走 cuBLAS)
# 2. 看 torch_compile_debug/.../ 下对应的 Triton 源码
# 3. 看 output_code.py 里 wrapper 是怎么调它的 (grid / 参数)
两类常见诊断结论:
并行度不足 —— output_code 里看到 grid 只有几十上百个 block,远小于 GPU 的 SM 数量,大部分 SM 空着。这时开 max_autotune 让 Inductor 试更小的 block size、把 grid 撑大。
dtype 没降下来 —— Triton 源码里全是 tl.float32,说明 autocast 没覆盖到这段(比如 forward 根本没包在 autocast 里,或者这个算子归在 fp32 policy,第 20 章 §20.6)。
理解这种"profile + output_code"联合调试让你能深入到 Inductor 内部优化。两层信息缺一不可:profile 给"哪个 kernel 慢"、output_code 给"为什么慢"。
21.25 step time 异常检测的"指纹"
每个训练任务都有特定 step time 指纹(mean / variance / outlier 模式)。学会识别"指纹异常":
graph TB
subgraph Healthy[正常]
H1[每 step 5.0 ± 0.1 s<br/>偶尔 5.5 s 是正常]
end
subgraph Pattern1[周期性突变]
P1["每 100 step 出 5.5s"]
P1 --> R1[Eval / ckpt 周期]
end
subgraph Pattern2[逐渐变慢]
P2[step time 从 5s 涨到 8s]
P2 --> R2[memory leak / 显存碎片化]
end
subgraph Pattern3[随机 spike]
P3[偶尔 30s spike]
P3 --> R3[NCCL straggler / IO hang]
end
subgraph Pattern4[loss 爆炸 + 慢]
P4[loss spike + step time 长]
P4 --> R4[GradScaler 减半重试 / inf 出现]
end
style R1 fill:#dcfce7
style R2 fill:#fef3c7
style R3 fill:#fee2e2
style R4 fill:#fecaca
每种 pattern 对应不同根因,profile 能验证:
- Pattern1:trace 显示 100 step 一次有 ckpt write 阻塞(async ckpt 没开)
- Pattern2:memory_viz 显示常驻显存涨 1GB/step → 找代码里的 leak
- Pattern3:distributed view 显示某 rank trace 在 AllReduce 上等 25s
- Pattern4:trace 含 GradScaler.skip 事件、inf check 触发
把这个 pattern 表存为 SOP,新人遇到性能问题对照查 → 节省时间。"性能问题不是无规律的,每种症状有对应根因"。
21.26 多 rank trace 时序对齐:distributed view 的内部机制
torch_tb_profiler 的 distributed view 让多 rank trace 可视化对齐。关键问题:不同 rank 的 CPU 时钟不一致(NTP 同步总有误差),怎么把它们的时间线对齐?
对齐的通用思路是"找共同事件当锚点":多个 rank 上必然同时发生、且语义唯一的事件(典型就是同一次 collective 的两端),可以用来估算各 rank 时钟之间的偏移,再把所有时间戳平移到同一基准。具体用哪个锚点、误差多大,取决于 torch_tb_profiler 的版本实现 —— 它是独立于 pytorch/pytorch 的包(pip install torch_tb_profiler),细节要去它自己的仓库看,别想当然。
需要知道的实用结论是:跨 rank 的绝对时间不可尽信,跨 rank 的"谁先谁后"和"等了多久"才是可用信号。distributed view 用对齐后的 trace 做几件事:
- 计算每个 collective 的 straggler:每 rank 进入 AllReduce 的时间差。差异大 → 有 rank 慢
- overlap 比例:通信时间占总 step 时间多少
- wait time on rank:每 rank 在 collective 上等了多久(暴露慢 rank)
典型排查形态:某个 rank 持续比其他晚进入 collective,其他 rank 都在 AllReduce 上干等它。这时问题不在通信而在那个 rank 自身 —— 可能是硬件降频 / ECC 纠错、可能是它分到的数据更重(变长序列没做 balanced batching)、也可能是它上面还跑着别的进程。collective 上的等待时间是症状,慢 rank 才是病因,别去调 NCCL 参数。
理解这点让你看到 distributed profile 的价值不在"绝对时间有多准",而在于它把"谁在等谁"摆到了同一张图上。
21.27 自定义 profiler callback:Hook 进 RecordFunction
§21.12 提了 RecordFunction 是 profiler 实现机制。用户可以注册自家 callback 看每次 op 调用:
// C++ 端
#include <ATen/record_function.h>
at::addThreadLocalCallback(
at::RecordFunctionCallback(
[](const at::RecordFunction& fn) -> std::unique_ptr<at::ObserverContext> {
// 算子开始时调 (StartCallback 只收一个参数, 返回 ObserverContext)
std::cout << "op: " << fn.name() << " inputs: " << fn.inputs() << "\n";
return nullptr;
},
[](const at::RecordFunction& fn, at::ObserverContext* ctx) {
// 算子结束时调
std::cout << "op done: " << fn.name() << "\n";
}
).needsInputs(true)
);
aten/src/ATen/record_function.h:689 的 addThreadLocalCallback 与它旁边的 addGlobalCallback 是这套机制的公开入口;needsInputs(true) 会让 dispatcher 走带 inputs 的那个 runRecordFunction 重载(§21.12)。这是 C++ 侧的扩展点,Python 端没有对应的公开 API —— 想用得写 C++ 扩展(第 22 章)。
应用场景:
- 统计每个 op 调用次数:自家训练 monitor,不依赖完整 profile
- kernel-level 数据校验:每个 GEMM input 的 hash 上报,验证训练数据正确性
- 自定义 trace 后端:把 trace 推到自家观测系统(如 Prometheus / 自家 timeline 平台)
- op-level alert:某 op 输出 NaN 立刻报警(在 callback 里检测)
PyTorch 自己的 profiler 就是这套 callback 的一个使用者,它没有走任何私有通道。这意味着当内置 profiler 的输出形态不满足需求(比如要把 op 级事件实时推给自家观测系统)时,你可以在同一层挂自己的 callback,而不必去 fork profiler。
理解 RecordFunction callback 让你看到 PyTorch 的 profile 不是封闭系统,是开放的扩展点。
21.28 训练 vs 推理 profile 的不同关注点
profile 训练与推理用的指标 + 分析方法完全不同:
| 维度 | 训练 profile | 推理 profile |
|---|---|---|
| 关注 | step time、GPU util、显存、通信 overlap | latency、throughput、kv cache 利用率 |
| 主要瓶颈 | 通信 / DataLoader | kernel launch / kv cache miss |
| 优化目标 | 总训练时间最短 | 单 query 延迟 / 集群 QPS |
| 关键 metric | step/sec、samples/sec | tokens/sec、TTFT、ITL |
| 调优工具 | torch.profiler + chrome trace + py-spy | nsys + vLLM 内置 metric + Nsight Compute(ncu) |
训练 profile 重点:
- step time 分布(mean / p99 / outlier)
- forward / backward / optim 各阶段占比
- 通信 / 计算 overlap 比例
- 显存峰值
推理 profile 重点:
- TTFT (Time To First Token):prefill 阶段时长
- ITL (Inter-Token Latency):decode 阶段每 token 时长
- KV cache hit rate:prompt 复用情况
- batched throughput:单 GPU tokens/sec
- prefill / decode 调度均衡(continuous batching)
实战:调推理服务时,vLLM 暴露的服务级 metric 往往比单跑 torch.profiler 更直接 —— 它专门为推理服务设计、暴露 TTFT / ITL、调度队列状态等。
理解两条路径让你不会"用训练经验调推理"或反过来。两套 mindset、两套工具。
21.29 profiler 的分工格局
比起背版本号,更有用的是记住 v2.11 这一刻的分工格局:
| 组件 | 位置 | 归属 |
|---|---|---|
torch.profiler Python API |
torch/profiler/ |
PyTorch 主仓 |
| RecordFunction(op 级采集) | aten/src/ATen/record_function.h + Dispatcher.h |
PyTorch 主仓 |
| Kineto(跨后端采集与 trace 格式) | third_party/kineto |
pytorch/kineto,独立仓 |
| CUPTI(GPU 硬件事件) | CUDA Toolkit | NVIDIA |
| TensorBoard 插件 | torch_tb_profiler |
独立 pip 包,不随 PyTorch 发布 |
| Memory Snapshot viewer | pytorch.org/memory_viz | 网页端,独立 |
这张表解释了很多困惑:为什么 profiler 的 API 稳定但 TB 插件常年滞后(不同仓库、不同发布节奏);为什么 _ExperimentalConfig 的文档里明写着不保证向后兼容(它直接透传给 Kineto);为什么换个后端(ROCm / XPU)profiler 还能用(Kineto 这层做了抽象)。
老的 torch.autograd.profiler.profile 仍在 torch/autograd/profiler.py(key_averages 在 :544、export_chrome_trace 在 :515),torch.profiler 是包在它外面的新一层。新代码用 torch.profiler。
21.30 大 trace 文件的处理:filter + split
trace 的体积与事件数成正比,而事件数约等于"步数 × 每步算子数"。大模型训练随便录几步就能到几百 MB 甚至 GB 级,浏览器端的 viewer 加载会明显吃力。生产实战需要切割与过滤:
Filter 工具:
import json
def filter_events(input_path, output_path,
min_duration=10, # 微秒,过滤短事件
exclude_names=None): # 过滤特定 op
exclude_names = set(exclude_names or [])
with open(input_path) as f:
data = json.load(f)
filtered = []
for e in data['traceEvents']:
# 只过滤 ph == 'X' 的时长事件; metadata(M) / instant(i) / flow(s,f) 没有 dur, 要保留
if e.get('ph') == 'X' and e.get('dur', 0) < min_duration:
continue
if e.get('name', '') in exclude_names:
continue
filtered.append(e)
data['traceEvents'] = filtered
with open(output_path, 'w') as f:
json.dump(data, f)
filter_events('big_trace.json', 'small_trace.json',
min_duration=100,
exclude_names=['aten::contiguous', 'aten::view'])
实战常见 filter:
min_duration=100us:去掉 dispatcher 内部小事件- 过滤
aten::view这类只改元数据的算子;aten::contiguous只在输入已连续时不拷贝,输入不连续时会真的复制,过滤前先确认 - 过滤
[Scheduler]等运行时内部事件
Split 工具:
def split_by_step(input_path, output_dir):
"""按 ProfilerStep 切分 trace, 每步独立文件"""
with open(input_path) as f:
data = json.load(f)
meta = [e for e in data['traceEvents'] if e.get('ph') == 'M'] # 泳道命名, 每个文件都带上
steps = []
current_step = []
for e in sorted(data['traceEvents'], key=lambda x: x.get('ts', 0)):
if e.get('ph') == 'M':
continue
if e.get('name', '').startswith('ProfilerStep#'):
if current_step:
steps.append(current_step)
current_step = [e]
else:
current_step.append(e)
if current_step:
steps.append(current_step)
for i, step_events in enumerate(steps):
out = {'traceEvents': meta + step_events, 'displayTimeUnit': 'ns'}
with open(f"{output_dir}/step_{i}.json", 'w') as f:
json.dump(out, f)
按步切开后每个文件只剩原来的几分之一,viewer 打开就顺畅了。注意这里按事件开始时间切:某步 launch、下一步才执行完的 GPU kernel 会落进下一步的文件。这套 filter / split 脚本是生产 profile 的必备工具。
Perfetto 比 chrome tracing 处理大文件能力强得多(用 sqlite 后端),生产推荐用 Perfetto(ui.perfetto.dev)。
21.31 profiler 的内部架构图
把全章实现细节合起来看完整架构:
graph TB
User["用户代码<br/>with profile():"]
User --> PyAPI["Python: torch.profiler"]
PyAPI --> CppPyBind["C++ pybind: profiler bindings"]
CppPyBind --> RF[RecordFunction<br/>每 op 触发]
CppPyBind --> Kineto[Kineto 库]
RF --> CB[Profiler callback<br/>begin / end 时间]
CB --> Buffer[Event Buffer<br/>thread-local]
Kineto --> CUPTI[NVIDIA CUPTI]
CUPTI --> Kernel[CUDA kernel events]
CUPTI --> Mem[memcpy events]
CUPTI --> HW[硬件 metric]
Buffer --> Merge[合并 / 时间对齐]
Kernel --> Merge
Mem --> Merge
HW --> Merge
Merge --> Out{输出选择}
Out --> Table[key_averages]
Out --> Chrome[chrome trace JSON]
Out --> TB[TensorBoard]
Out --> Custom[用户自定义后处理]
style RF fill:#fef3c7
style Kineto fill:#dcfce7
style Merge fill:#dbeafe
每层职责:
- Python API:用户接口,控制 profile 生命周期
- pybind:C++ 与 Python 桥接
- RecordFunction:在 dispatcher 主路径捕获每个 op 调用
- Kineto + CUPTI:拿 GPU 端原始数据
- Merge 层:把 CPU events + GPU events 按时间对齐合并
- Output:多种 view 共用同一份 merged data
理解架构让你看到 profile 不是单一组件,是 PyTorch 主仓 + Kineto 独立仓 + NVIDIA CUPTI + 用户代码四方协作的产物(分工见 §21.29)。每层独立可扩展,让 profiler 能持续演进而不破坏上层接口。
21.32 第三方工具综合对比
PyTorch profiler 不是唯一选择,根据场景选不同工具:
| 工具 | 强项 | 弱项 | 用法 |
|---|---|---|---|
| torch.profiler | 与 PyTorch 集成最好,看 ATen + GPU | 大 trace 卡顿 | 训练调优 first choice |
| Nsight Systems (nsys) | 硬件级 timeline,含 PCIe/DRAM | 学习曲线陡 | 深入 GPU 性能 |
| Nsight Compute (ncu) | kernel 级硬件分析(occupancy / cache) | 慢,每个 kernel 要重放多遍 | 微调单个 kernel |
| py-spy | Python 火焰图,零代码改动 | 看不到 GPU | Python 端瓶颈 |
| scalene | CPU + GPU + 内存综合分析 | PyTorch 集成不完整 | 通用 Python profile |
| line_profiler | 行级 CPU 时间 | 只 CPU | 找慢 Python 函数 |
| memory_profiler | 行级内存 | 只 RAM 不看 GPU | 找内存泄漏 |
| viztracer | 时间线 + 火焰图,开箱即用 | 不看 GPU | Python 应用整体分析 |
| vLLM profile | 推理 LLM 专用,看 KV cache | 仅 vLLM | LLM 服务调优 |
实战工具组合:
- 训练慢:torch.profiler → 看不出再用 nsys
- 训练 OOM:memory_viz → 找不到再用 memory_profiler 看 CPU
- 推理慢:vLLM 内置 metric → 看不出再用 nsys
- kernel 慢:torch.profiler 找哪个 kernel → ncu 分析硬件 metric
这种"分层使用"是性能工程师的标配。每个工具学精都需要时间,但理解定位让你能为问题选对武器。
21.33 训练监控 dashboard 集成
profile 是"事件驱动"调优工具,dashboard 是"持续观测"工具。生产 LLM 训练通常两者结合:
graph LR
Train[训练进程]
Train --> Metrics[训练 metric<br/>loss, lr, step time, throughput]
Train --> System[系统 metric<br/>GPU util, mem, network]
Train --> Profile[采样 profile<br/>每 N step 一次]
Metrics --> Wandb[Weights & Biases]
Metrics --> TB[TensorBoard]
System --> Prom[Prometheus + Grafana]
Profile --> Storage[S3 / 公司存储]
Wandb --> Alert[异常检测<br/>step time > 2x mean → alert]
Prom --> Alert
Storage --> OnDemand[需要时下载 trace 分析]
style Alert fill:#fee2e2
实战集成:
import wandb
from torch.profiler import profile, schedule
wandb.init(project="llm-training")
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
schedule=schedule(wait=500, warmup=2, active=3, repeat=1000),
on_trace_ready=lambda p: upload_trace(p, wandb.run.id),
) as prof:
for step, batch in enumerate(loader):
t0 = time.time()
loss, throughput = train_step(batch)
step_time = time.time() - t0
# 持续 metric → wandb
wandb.log({
'loss': loss,
'lr': lr,
'step_time': step_time,
'throughput': throughput,
'gpu_mem': torch.cuda.memory_allocated() / 1e9,
}, step=step)
# 偶发 profile → 上传 S3
prof.step()
dashboard 上看:
- 持续 metric:实时 loss 曲线、step time 曲线、显存曲线
- 采样 trace:每 500 step 一次完整 profile,按需下载分析
- 异常告警:step time / loss / 显存超阈值触发 PagerDuty / Slack
生产 LLM 训练 7×24 跑,把 profile 嵌进 monitoring 体系让团队能在故障扩大之前介入,而不是事后翻日志。
理解这套集成让你看到 profile 不只是"调试时用的工具",是生产观测的核心数据源。
21.34 典型案例:定位训练偶发 hang
最难的性能问题是"训练偶尔卡死"——10 小时一次、每次卡一分钟左右然后自己恢复。临时开 profiler 抓不到、py-spy 也等不到现场。怎么办?(下面的数字都是示意。)
步骤 1:开 rolling profile + 异常检测
§21.23 的方案。每 1000 步采样、step time > 5x mean 触发 dump。
步骤 2:异常 trace 拿到后定位
正常 step: 5s
异常 step: 60s (12x)
打开异常 trace,对比正常 trace 看差异:
正常: forward 2s + backward 2.5s + optim 0.5s = 5s
异常: forward 2s + backward 2.5s + optim 55.5s <- optim 暴涨!
optim 内部 profile 看:
正常 optim: Adam.step (0.4s) + zero_grad (0.05s) = 0.45s
异常 optim: cudaMemcpy DtoH (54.9s) + Adam.step (0.4s)
cudaMemcpy DtoH 卡 54.9 秒? 这是 GPU → CPU 拷贝。检查代码:
# 偶尔触发的代码:
if step % 100 == 0 and loss.item() > 1.0: # ← .item() 触发同步!
log_extra_metrics(loss)
注意这里有个陷阱:loss.item() > 1.0 这个条件本身就已经同步了 —— 无论 loss 大小,每 100 步都会同步一次。真正偶发的是进入 if 之后的 log_extra_metrics,它内部又调了若干次 .item() / .cpu(),每次都是一轮 D2H + 等待。平时不进这个分支所以看不出来,偶尔进去就卡住。
(顺带:trace 里这类事件是 DtoH(device → host),不是 HtoD —— 把数值从 GPU 读回 CPU。看错方向会把排查引到 DataLoader 上去。)
步骤 3:修复
# 修复: 把 metric 累积改成 batched
log_buffer.append(loss.detach())
if step % 100 == 0:
log_extra_metrics(torch.stack(log_buffer)) # 一次同步, batched
log_buffer.clear()
修复后偶发 hang 消失。能这样定位,靠的是 profile 数据 + 异常检测让"偶发"变成"可观测"。
理解这种 case 让你看到 profile 不是"碰运气抓 bug",是"长期监控 + 异常时拿到精确数据"的工程闭环。这是大模型训练 SRE 的核心方法论。
21.35 不同硬件后端的 profile 支持
PyTorch profiler 不只 CUDA,其他后端也支持但能力差异:
| 后端 | profile 能力 | 限制 |
|---|---|---|
| CUDA (NVIDIA GPU) | 完整:CUPTI + Kineto + Memory Snapshot | 最成熟 |
| ROCm (AMD GPU) | 较完整:rocprof + Kineto | 部分 metric 缺失 |
| XPU (Intel GPU) | 基础:oneapi-pti + Kineto | 还在迭代 |
| MPS (Apple Silicon) | 基础:torch.mps.profiler(OS Signpost,在 Xcode Instruments 里看) |
不走 torch.profiler 的 activities(ProfilerActivity 里没有 MPS),缺硬件 metric |
| CPU | 基础:函数级 timing | 没有硬件 metric(用 perf 替代) |
| PrivateUse1 (国产芯片) | 厂商自家 callback | 各家实现不一 |
跨后端代码:
# 自适应不同后端
activities = [ProfilerActivity.CPU]
if torch.cuda.is_available():
activities.append(ProfilerActivity.CUDA)
elif torch.xpu.is_available():
activities.append(ProfilerActivity.XPU)
with profile(activities=activities) as prof:
train_step()
实战:在 ROCm + AMD GPU 上 PyTorch profiler 能正常工作但 metric 比 CUDA 少;在 Apple M 系列上用 torch.mps.profiler 能在 Instruments 里看到 kernel 时长,但看不到 SM 占用率这类硬件指标。生产部署如果跨硬件,profile 流程要为每个后端验证。
理解后端差异让你看到 PyTorch 的 profile 不是"NVIDIA 专属",是抽象层 + 各后端实现。但 NVIDIA 仍是事实标准(CUPTI 最成熟),其他后端在追赶。
21.36 一段总结:从 profile 到性能工程
把全章合起来看:profile 不是单一技能,是一套性能工程方法论:
graph TB
Goal[确定优化目标<br/>throughput / latency / memory]
Goal --> Baseline[Baseline 测量<br/>profile 当前性能]
Baseline --> Hyp[假设瓶颈<br/>看 trace 形成 hypothesis]
Hyp --> Fix[实施修复]
Fix --> Verify[再次 profile<br/>验证 hypothesis]
Verify --> Q{有改善?}
Q -->|是| Next[找下一个瓶颈]
Q -->|否| Hyp
Next --> Goal2{达到目标?}
Goal2 -->|是| Done[结束]
Goal2 -->|否| Hyp
style Baseline fill:#fef3c7
style Verify fill:#dcfce7
style Q fill:#dbeafe
每一步的关键技能:
- 目标量化:不要"模糊地追求快",定具体 metric("step time 从 5s 降到 3s")
- Baseline 测量:修改前先 profile,知道改善多少
- Hypothesis 驱动:看 trace 后形成"我认为是 X 慢",不是凭直觉
- 小步验证:每改一个修复就测,不要批量改后再 profile(不知道哪条改进有效)
- 成本约束:性能优化是工程成本权衡,不是无限投入
这套方法论与软件工程的"测量驱动开发"一脉相承。理解 profile 工具是基础、形成工程闭环才是更高一层的能力。
实战:调好一个 LLM 训练任务的性能往往要持续迭代一段时间,每天 profile 几次、改几行、再 profile。最后总结成 SOP 给团队复用。这种"测量-优化-再测量"的工作模式是性能工程师的日常。
21.37 一个完整调优案例的复盘
最后给一个构造出来的示意案例(数字是为了讲清方法论编的,不是某次真实测量):
目标:Llama-7B 训练 step time 从 1.2s 降到 0.8s(提升 33%)。
Day 1: Baseline + 第一轮分析
profile 录 5 步, step time 1.2s
breakdown:
- DataLoader: 200ms (17%)
- forward: 350ms (29%)
- backward: 500ms (42%)
- optim: 150ms (12%)
最大头是 backward。trace 显示 backward 含大量小 kernel、launch 密集 → 怀疑没用 compile。
修改:加 @torch.compile。
Day 2: 第二轮 profile
step time 0.95s (改善 21%)
- DataLoader: 200ms (21%)
- forward: 250ms (26%, fused)
- backward: 350ms (37%, fused)
- optim: 150ms (16%)
DataLoader 现在占比上升。trace 看 GPU 偶尔 idle 200ms → DataLoader 跟不上。
修改:num_workers=4 → 16、prefetch_factor=2 → 4、加 persistent_workers=True。
Day 3: 第三轮 profile
step time 0.85s (改善 11%)
- DataLoader: 50ms (6%)
- forward: 250ms
- backward: 350ms
- optim: 200ms (24%) <- optim 占比上升
optim 不降反升(150ms → 200ms),占比涨到 24%。trace 看 optim 阶段是一长串读写 fp32 optimizer state(exp_avg / exp_avg_sq)的 memory-bound kernel。
修改:换 bnb.optim.AdamW8bit(8-bit Adam,optimizer state 以 8 bit 存储,读写字节数约为 fp32 的四分之一)。
Day 4: 第四轮 profile
step time 0.78s (达成目标!)
- DataLoader: 50ms
- forward: 250ms
- backward: 350ms
- optim: 130ms
总改善 35%(1.2s → 0.78s)。每天一次 profile-修改-验证循环,4 天达标。
复盘:
- 3 个独立优化叠加:compile + DataLoader + 8-bit Adam
- 每个优化都有 profile 数据驱动,不是凭直觉
- 每次只改一处:知道哪条改善多少
- 每次改动都可回滚:知道哪条有效、哪条没用
如果不 profile 直接猜着改,很容易:
- 改了不该改的(增加复杂度)
- 漏了关键优化
- 没法量化每条的贡献
- 总时间花更久
这是 profile 工具的真正价值 —— 让性能工程从猜测变成科学。这套 mindset + 工具应用比任何具体技术都更值得带走。
21.38 ProfilerStep 与 Schedule 状态机的源码细节
prof.step() 的内部 state machine 由 ProfilerAction 这个枚举驱动(torch/profiler/profiler.py:490):
class ProfilerAction(Enum):
NONE = 0
WARMUP = 1
RECORD = 2
RECORD_AND_SAVE = 3
每次 prof.step()(torch/profiler/profiler.py:884)执行的逻辑,写成 if-else 大致是(真实实现在 :890-:894,用 (prev_action, current_action) 这个二元组去查 __init__ 里建好的动作表 action_map,由 _transit_action 执行,不是下面这串 if;下面只列常见转移):
def step(self):
prev = self.current_action
self.step_num += 1
self.current_action = self.schedule(self.step_num)
cur = self.current_action
if prev == ProfilerAction.RECORD_AND_SAVE:
self.stop_trace() # 上一个 cycle 录完了
self.on_trace_ready(self) # 在"切出" RECORD_AND_SAVE 时才触发用户回调
if cur != ProfilerAction.NONE:
self.prepare_trace() # 紧接着下一个 cycle
if prev == ProfilerAction.NONE and cur != ProfilerAction.NONE:
self.prepare_trace() # 进入 WARMUP: 开采集但数据不要
if prev in (ProfilerAction.NONE, ProfilerAction.WARMUP, ProfilerAction.RECORD_AND_SAVE) \
and cur in (ProfilerAction.RECORD, ProfilerAction.RECORD_AND_SAVE):
self.start_trace() # 真正开始录
schedule(step_num) 返回当前应该的 action。schedule(wait=1, warmup=2, active=3, repeat=2) 的状态序列:
step 0: NONE
step 1: WARMUP
step 2: WARMUP
step 3: RECORD
step 4: RECORD
step 5: RECORD_AND_SAVE <- repeat 第 1 次结束
step 6: NONE <- 进入下一 cycle 的 wait
step 7: WARMUP
...
理解状态机让你能写自定义 schedule:
def my_schedule(step_num):
# 1005 步一个周期: 1 步 warmup + 录 5 步, 其余 999 步不录
cycle_pos = step_num % 1005
if cycle_pos == 0:
return ProfilerAction.WARMUP
elif cycle_pos < 5:
return ProfilerAction.RECORD
elif cycle_pos == 5:
return ProfilerAction.RECORD_AND_SAVE
else:
return ProfilerAction.NONE
with profile(activities=[...], schedule=my_schedule) as prof:
for batch in loader:
train_step(batch)
prof.step()
这种自定义 schedule 让 long-running 训练能精确控制 profile 频率,不浪费空间也不漏掉关键步。生产 LLM 训练常用 1000+ 步一次的稀疏 schedule。
21.39 profile 与 CI/CD:性能回归检测
成熟工程团队把 profile 加进 CI 流水线 —— 每次 commit 自动跑 benchmark + profile,发现性能回归直接 block。
# CI workflow 片段
- name: Run perf benchmark
run: |
python benchmark.py --output results.json
python compare_with_baseline.py results.json baseline.json
- name: Detect regression # 伪代码: 实际判断写在 compare_with_baseline.py 里, 超阈值 exit 1
run: |
if perf_regression > 5%:
exit 1
benchmark.py 内部用 profile:
import time
import torch
from torch.profiler import profile, ProfilerActivity
def benchmark():
model = build_model()
input = make_input()
# warmup
for _ in range(5):
_ = model(input)
torch.cuda.synchronize()
# 测时间: 总时长用墙钟, profile 只用来拆解
t0 = time.perf_counter()
with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof:
for _ in range(20):
out = model(input)
torch.cuda.synchronize()
total_time = time.perf_counter() - t0
# 提取关键 metric (key_averages() 本身不排序, 要自己按 device 时间排)
top = max(prof.key_averages(), key=lambda e: e.self_device_time_total)
return {
'total_time': total_time, # 含 profiler 自身开销
'top_op': top.key,
'top_op_device_time_us': top.self_device_time_total,
'memory_peak': torch.cuda.max_memory_allocated(),
}
CI 上每次 commit 跑这个、与 baseline 对比、回归 > 5% 报警。
实战收益:
- 早期发现:性能回归在 commit 当时就暴露,而不是几周后在生产上被发现 —— 那时候要从一堆改动里倒查根因
- bisect 简单:知道哪个 commit 引起,git bisect 直接定位
- 持续优化:team 看到 perf 回归压力大、自然写更优代码
PyTorch 自家也有这套(pytorch/benchmark repo,即 TorchBench),用一批真实模型持续跟踪性能回归。把 profile 工具化用到 CI 是工程成熟度的标志。
理解这种用法让你看到 profile 不只是"调试工具",是质量保障基础设施的一部分。生产 ML team 必备能力。
21.40 几条实战调优经验
1. 训练慢先开 profiler:不要凭直觉猜瓶颈。3 行代码加 schedule 录 5 步、看 chrome trace 通常能立刻发现问题
2. GPU 利用率波动 (sawtooth) 多半是 DataLoader:第 11 章 §11.38 完整诊断流程
3. trace 里 cudaStreamSynchronize 多 = 隐式同步:找代码里的 .item() / .cpu() / print(tensor),挪到训练 hot loop 外
4. autograd profiler vs profiler:老 torch.autograd.profiler.profile 仍在(torch/autograd/profiler.py),torch.profiler 是它外面的新一层,新代码用后者
5. profiler overhead:record_shapes=True 让 dispatcher 走带 inputs 的 runRecordFunction 重载、with_stack=True 要抓 Python 栈,两者都比默认更贵。只在真需要那份信息时开
6. flame graph for Python:补充 py-spy record -- python train.py 看 Python 端火焰图,与 profiler 互补
7. nsys(Nsight Systems)是更专业的工具:NVIDIA 官方的系统级 profiler,能把 CUDA API、kernel、NVTX、OS 线程放在一条时间线上,还能采 GPU 硬件指标。生产调优需要 nsys 时再上
8. 长跑训练 profiler 滚动落盘:每 1000 步触发一次 on_trace_ready 落盘 + 上传,监控异常步骤
9. trace 上传到对象存储 + URL 分享:team 协作 profile 数据。把 trace.json 上 S3 / GCS、配合 perfetto.dev 的 URL 加载,让团队成员零本地 setup 就能查看
10. profile 配合 unit test:核心算子写 assert kernel_time < threshold 单测,让性能回归在 PR review 阶段就暴露。注意阈值要留足余量,否则 CI 机器抖动会造成大量假阳性
11. profile 数据进 wandb / mlflow:与训练 metric 一起跟踪,可以在事后回看"哪个 commit 引起 step time 涨"
12. 多 GPU 调优先单卡再扩:单卡 profile 找 forward / backward 瓶颈,扩到多卡再看通信瓶颈。混在一起调很难
13. 训练 vs 推理:训练用 torch.profiler、推理用 vLLM / SGLang 内置 metric。两套 mindset 不同(§21.28)
21.41 完整调优流程
flowchart TB
Start[训练慢 / OOM]
Start --> P1[1 加 profiler 录 5 步]
P1 --> Look{看 chrome trace 哪类问题}
Look -->|GPU sawtooth idle| DL[DataLoader 优化<br/>第 11 章 §11.38]
Look -->|kernel gap 多| Sync[找隐式同步<br/>.item / .cpu 挪走]
Look -->|kernel 调用密集| Comp[torch.compile<br/>fuse 小算子]
Look -->|GPU bound 但慢| Algo[换 dtype<br/>第 20 章 AMP]
Look -->|OOM| Mem[Memory Snapshot<br/>第 4 章 §4.11 / §4.13]
Mem --> Frag{碎片化?}
Frag -->|是| EXP["PYTORCH_CUDA_ALLOC_CONF=<br/>expandable_segments:True"]
Frag -->|否| Save[activation_checkpoint<br/>FSDP / cpu_offload]
style P1 fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
这是一条常用的排查路径。每一步对应本专栏前面具体章节,profiler 是把它们串起来的诊断工具。OOM 分支这里只画到"是不是碎片化"这一层;按 cudaMalloc retries、reserved 与 allocated 之差、snapshot 里的长寿小块逐步排查的完整步骤,在第 4 章 §4.13。
21.42 相关章节
- 第 4 章 §4.11 Memory Snapshot:本章的显存分析底层
- 第 11 章 §11.38 DataLoader 瓶颈诊断:实战流程
- 第 17 章 §17.28 TORCH_NCCL_BLOCKING_WAIT vs ASYNC_ERROR_HANDLING:分布式 profile 与 NCCL 调试开关配合
- 第 14 章 Inductor:profile 看 fused kernel 与 output_code 联合调试
- 第 15 章 §15.11.2 TORCH_COMPILE_DEBUG:profile 与 compile debug 双工具配合
- 第 20 章 §20.18 量化精度调试:量化模型 profile 看 INT8 GEMM kernel 是否真的生效
21.43 设计启示
profiler 设计的核心思想:
第一:多源数据合并到统一 timeline:CPU 函数 + CUDA kernel + 显存事件 + 用户标注同步在一条时间轴上,让"为什么这里慢"能一眼看清
第二:Schedule API 让 profiler 可在生产用:录全程不可行,schedule 录采样让它适合长跑训练
第三:多层可视化:text 表(快速看 top)、chrome trace(看时间线)、TB 插件(自动分析)。每个层级解决不同诊断问题,组合起来覆盖完整诊断空间
第四:用户级标注 + 系统级采集合并:record_function 让 profiler 能体现用户的代码结构,与底层 ATen 算子事件融合
第五:profile 与编译栈共生:torch.compile 让 fused kernel 出现在 trace 里,profile 反过来验证 compile 收益。两套机制互相 feedback —— 改 compile 后 profile 看效果、profile 发现的瓶颈再回到 compile 调。这种循环是 v2.x 时代性能优化的核心 workflow
第六:测量是优化的前提:本章每个 case 都先 profile 测、再优化、再 profile 验证。这是与"凭直觉调"的根本差异。"没测量就不要优化"是性能工程的第一原则,profile 是这条原则的具体实现工具 —— 每次性能调优前先跑 5 步 profile、看 trace 形成 hypothesis,比直接改代码更快达到目标
下一章拆自定义算子 —— 当 PyTorch 内置算子不够用时,怎么写自家的 kernel 接进 dispatcher / autograd / torch.compile,把每一层都注册得完整、让 profile / autograd / compile 都能正确处理你的算子。