PyTorch 里的 GPU 加速这件事,说穿了就是把绝大多数矩阵运算从几十个串行核心挪到几千个并行核心上,同时把数据搬运、算子调度、显存分配这些“杂活”压到最低。可现实里我见过太多训练脚本,卡是好卡,代码也跑得通,GPU 利用率却常年趴在 20% 上下——钱花了,模型训练速度没起来。这篇就围绕 GPU 加速对模型训练速度的实际影响,把我这几年在单机单卡、单机多卡上反复验证过的东西摊开讲:哪些环节真的吃掉时间,哪些参数值得调,哪些“加速”其实是心理安慰。适合已经能跑通训练脚本、想让每一步更快一点的人,也适合刚接手别人代码、准备做性能优化的人。全文不讲概念科普,讲的是我改代码时按什么顺序动手、按什么标准判断“这次优化到底有没有用”。
1. GPU 加速到底把时间花在哪了
1.1 一次前向传播拆成三段:计算、搬运、调度
很多人对 GPU 加速的理解停在“并行度高所以快”,这个判断只对了一半。一次前向传播在设备侧实际由三段组成:第一段是真正的数值计算,卷积、矩阵乘、归一化、激活函数,这部分 GPU 相对 CPU 有几十倍到上百倍的理论优势;第二段是数据搬运,从主机内存拷到显存、从显存拷到片上缓存、kernel 之间读写中间结果;第三段是调度开销,每个算子都要由 CPU 侧下发一次 kernel launch,下发本身有固定成本,通常在几微秒量级。
三段里只有第一段是真正被加速的。如果你的模型是小算子堆叠出来的,比如大量view、permute、逐元素加法、小尺寸 slice,那么第二段和第三段的占比会迅速上升,GPU 大部分时间在等指令、等数据,而不是在算。我接手过一份时序模型代码,作者把特征拆成三十多个小张量分别过一层线性层再做拼接,改成一次大矩阵乘之后,单步耗时直接降了将近一半,模型结构一点没变。
所以判断一个训练任务值不值得上 GPU,看两个指标就够了:单步里大尺寸矩阵运算(维度上千)占比高不高,以及 batch 是否足够大能摊薄调度开销。两个都满足,GPU 加速就是立竿见影的;都不满足,先把算子合并、把 batch 提上去,比换卡更有效。
1.2 加速比为什么不是线性的:Amdahl 与 kernel launch 开销
假设一个训练步骤在 CPU 上 1000ms,其中 90% 是可并行的计算,理论上限加速比是 10 倍。但实际跑到 8 倍就已经很好,剩下的差距来自三类无法并行的部分:数据加载与预处理、优化器更新里的小规模逐元素操作、以及日志与检查点写盘。
更隐蔽的是 kernel launch 的累积效应。一个典型的 Transformer 层在 PyTorch 里会被拆成上百个 kernel,每层几十微秒的调度开销累加起来,在 batch 较小的时候会明显拖慢整体。这也是为什么同样的模型,batch 从 8 提到 64 之后,单样本耗时常常能掉 30% 以上——不是因为算得更快,而是因为每次下发的指令摊到了更多样本上。
我在做性能评估时习惯用“单样本耗时”而不是“单步耗时”做横向对比,因为单步耗时会被 batch 大小干扰,而单样本耗时能直接反映你这套配置的真实效率。另外要做计时,务必在计时前后各加一次torch.cuda.synchronize():
import time, torch torch.cuda.synchronize() t0 = time.perf_counter() train_step(batch) torch.cuda.synchronize() dt = time.perf_counter() - t0少了同步,你测到的是 kernel 下发时间,不是执行完成时间,结果会好看得离谱但完全没有参考价值。这个坑我早年踩过,一份“优化后快了 4 倍”的报告,实际上是异步执行没等结果。
1.3 先判断你的训练是不是“GPU 友好”
动手调参之前,先花十分钟做个判断:跑 50 步,用nvidia-smi -l 1盯住利用率曲线。三种典型形态对应三种完全不同的优化方向。
第一种,利用率稳定在 85% 以上,曲线平滑。这种情况说明 GPU 已经是瓶颈,优化空间在算子层面——混合精度、编译优化、换更高效的实现,或者干脆加卡。
第二种,利用率呈规律锯齿状,周期性掉到个位数。这是典型的输入管道阻塞,GPU 算完一批就得等下一批数据。方向很明确:DataLoader 的 worker 数、pin_memory、预处理逻辑。
第三种,利用率长期在 20% 到 40% 之间缓慢波动,显存占用也不高。这种最常见,成因也最杂:可能是小算子太多,可能是同步点太频繁(每步都.item()打日志),可能是优化器状态频繁在 CPU 和 GPU 之间来回拷。要定位就得上 profiler,光看 nvidia-smi 是看不出所以然的。
提示:不要凭直觉判断瓶颈。我见过有人一上来就把 batch 翻倍、把 worker 调到 32,结果显存爆了、CPU 被抢光,耗时反而涨了 15%。先测,再改,改完再测。
2. 环境对齐与基线测量:优化的第一步不是改代码
2.1 驱动、CUDA runtime 与 PyTorch 版本的三方对齐
版本没对齐导致的各种玄学问题,占了我在性能排查里遇到问题的三成以上。需要理清的关系是:系统驱动提供最底层的支持能力,PyTorch 官方 wheel 包里自带一份 CUDA runtime,两者只要满足“驱动支持的最高版本不低于 runtime 版本”就能跑。所以装 PyTorch 时其实不需要单独装完整 CUDA Toolkit,除非你要自己编译扩展。
先跑一条命令把家底摸清:
python -c "import torch; print('torch', torch.__version__); \ print('cuda runtime', torch.version.cuda); \ print('cudnn', torch.backends.cudnn.version()); \ print('available', torch.cuda.is_available()); \ print('count', torch.cuda.device_count())"输出里available是 False,八成是驱动版本太低或者装成了 CPU 版本。装成 CPU 版这个错误特别常见:pip install torch在某些源上默认给的就是 CPU wheel,跑起来不报错,只是慢得离谱,很多人以为是模型问题,查半天查不出来。判断方法很直接,看torch.version.cuda是不是 None。
还有一点容易被忽略:同一个环境里如果混装了多个 CUDA 相关的包(比如某个第三方库自带了另一份 runtime),可能触发符号冲突,表现为随机崩溃或者某些算子退化到 CPU 实现。遇到“同样的代码昨天快今天慢”,先pip list | grep -i cuda看一遍有没有多出来的东西。
2.2 搭一个可复现的计时基线
没有基线就没有优化。我给自己定的规矩是:任何改动之前,固定一组条件跑三次,取中位数,记下来。固定条件包括随机种子、数据顺序、batch 大小、是否开 benchmark 模式、是否清空缓存。
import torch, random, numpy as np def set_seed(s=42): random.seed(s); np.random.seed(s) torch.manual_seed(s); torch.cuda.manual_seed_all(s) def benchmark(step_fn, loader, warmup=5, iters=20): for i, batch in enumerate(loader): step_fn(batch) if i >= warmup - 1: break torch.cuda.synchronize() ts = [] for i, batch in enumerate(loader): torch.cuda.synchronize(); t0 = time.perf_counter() step_fn(batch) torch.cuda.synchronize(); ts.append(time.perf_counter() - t0) if i >= iters - 1: break ts.sort() return sum(ts) / len(ts), ts[len(ts)//2]warmup 不能省。第一次执行时 CUDA context 要初始化、cuDNN 要选算法、显存池要预热,前几步的耗时通常是稳定态的几倍。我一般 warmup 5 到 10 步,测量 20 到 50 步。
关于是否固定种子,有个小分歧:固定种子会让 cuDNN 走确定性算法路径,可能比默认路径慢一点,测出来的绝对值偏低。我的做法是性能测试时不固定种子、只固定输入形状,功能验证时才固定种子。混在一起测,你会得到一个既不代表最快也不代表最稳的数字。
2.3 用 profiler 和 nvidia-smi 读出真实瓶颈
nvidia-smi只能告诉你“忙不忙”,torch.profiler才能告诉你“忙在哪”。我常用的配置是这样:
from torch.profiler import profile, ProfilerActivity with profile( activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], schedule=torch.profiler.schedule(wait=1, warmup=2, active=5, repeat=1), on_trace_ready=torch.profiler.tensorboard_trace_handler("./prof_log"), record_shapes=True, profile_memory=True, ) as prof: for i, batch in enumerate(loader): train_step(batch) prof.step() if i > 10: break print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=20))看输出时我关注三件事。第一,cuda_time_total排前几名的算子是什么,如果是一堆elementwise或者copy,说明算子太碎,考虑合并或者换实现。第二,Self CPU time里有没有某个非计算函数占大头,比如数据预处理、日志格式化、item()取值,这些是纯 CPU 开销,会直接拖慢 GPU 的供数节奏。第三,profile 里的显存曲线有没有尖峰,尖峰往往对应临时张量分配,是 OOM 的前兆。
record_shapes=True会让开销变大,只在定位问题时开,日常压测关掉。另外 profiling 本身会带来 10% 到 30% 的额外开销,别拿 profiling 跑出来的绝对数值当性能指标,它只用来找相对位置。
3. 从张量上卡到混合精度:完整加速链路落地
3.1 张量搬运的正确写法与三个容易忽略的细节
最基础的写法大家都知道,但细节决定成败:
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") model = model.to(device) for x, y in loader: x = x.to(device, non_blocking=True) y = y.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) out = model(x) loss = criterion(out, y) loss.backward() optimizer.step()第一个细节是non_blocking=True必须配合 DataLoader 的pin_memory=True才有意义。锁定内存页允许拷贝与计算重叠,缺了任何一半,这个参数都是摆设。第二个细节是zero_grad(set_to_none=True),它把梯度置为 None 而不是填零,省掉一次全量写显存的操作,在参数多的时候每步能省几毫秒,白捡的。第三个细节是不要在训练循环里频繁.item()、.cpu()、print(tensor),每一次都是强制同步,会把前面所有异步收益清空。
还有个新手常犯的错:把模型搬到 GPU 之后又忘了把新建的张量搬过去。比如在 forward 里用torch.zeros(...)造了个 mask,没指定 device,就会留在 CPU 上,第一次运算直接报设备不匹配;如果运气不好遇到隐式转换,就会莫名其妙地慢。我的习惯是模型里所有新建张量都写成torch.zeros(..., device=x.device, dtype=x.dtype),跟着输入走,永远不写死。
3.2 DataLoader 的 num_workers、pin_memory 与 batch size 估算
输入管道是单卡训练里最常见的瓶颈。一个配置合理的 DataLoader 大概长这样:
loader = torch.utils.data.DataLoader( dataset, batch_size=64, shuffle=True, num_workers=8, pin_memory=True, persistent_workers=True, prefetch_factor=4, drop_last=True, )num_workers的经验值:从 4 开始,每次翻倍测一次单样本耗时,直到耗时不再下降或者 CPU 打满为止。开太多反而更慢,因为 worker 之间会抢 CPU、抢内存带宽,还会让每个 worker 的预取队列变浅。persistent_workers=True能让 worker 在 epoch 之间存活,避免每个 epoch 重建进程的开销,在数据集较大、epoch 较短时收益明显。
prefetch_factor控制每个 worker 预取多少批,默认 2,我在 IO 较慢的场景(读大量小图片、读远程存储)会调到 4 到 6,代价是内存占用上升。drop_last=True除了避免最后一个不完整 batch 导致的形状抖动,还有个隐藏好处:形状固定之后cudnn.benchmark才能真正发挥作用。
batch size 的估算我一般用显存反推。粗略公式:单卡可用显存减去模型参数、梯度、优化器状态占用,剩下的留给激活值和临时张量。以 Adam 为例,每个参数需要 4 字节参数 + 4 字节梯度 + 8 字节两个状态,FP32 下就是每参数 16 字节;一个 1 亿参数的模型光这块就要 1.6GB。激活值大致正比于batch × 序列长度 × 隐藏维度 × 层数,实际系数跟具体结构强相关,只能实测。
实操建议是从一个肯定不 OOM 的小 batch 开始,按 1.5 倍递增试,找到临界点后回退一档,留出 15% 左右余量。别贴着上限跑,训练中途遇到长样本或者显存碎片就会崩。
3.3 混合精度:AMP 的收益、代码与三个坑
混合精度是单卡提速里性价比最高的一招,改动小、收益稳定,常见区间是 1.3 到 2 倍,同时显存占用能降三到四成。原理不复杂:矩阵乘和卷积用 FP16 或 BF16 算,累加和权重更新仍用 FP32,GradScaler负责把 loss 放大,避免 FP16 下梯度下溢成 0。
scaler = torch.amp.GradScaler("cuda") for x, y in loader: x, y = x.to(device, non_blocking=True), y.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) with torch.amp.autocast("cuda", dtype=torch.float16): out = model(x) loss = criterion(out, y) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()三个坑得说清楚。第一,autocast只包住前向和 loss 计算,反向由scaler.scale接管,别把optimizer.step()也包进去。第二,有些算子不适合半精度,比如 softmax 前的大数相加、距离计算、exp,PyTorch 会自动回退到 FP32,但你自己写的手工实现的算子不会,遇到 loss 变 NaN 先检查这里。第三,用 BF16 的话通常不需要GradScaler,因为 BF16 的指数位和 FP32 一样宽,动态范围足够,代码里可以直接scaler = None分支处理。BF16 在支持它的卡上是我现在的默认选择,省心。
判断混合精度是否真的生效,别只看速度,看一眼类型:在 forward 里print(out.dtype),是float16才说明 autocast 起作用了。有次我改完发现速度没变化,一查是模型里手动写了.float(),把半精度又转回去了。
3.4 torch.compile、cudnn.benchmark、TF32 这些开关该不该开
torch.backends.cudnn.benchmark = True会让 cuDNN 在第一次遇到某个输入形状时跑一轮算法搜索,挑最快的那个。输入形状固定时必开,收益在卷积类模型上通常有 5% 到 15%。形状经常变的话反而更慢,因为每次都要重新搜索,这时候关掉,或者配合drop_last=True把形状固定住。
TF32 是另一档容易被忽略的加速:在 Ampere 及之后的卡上,把 FP32 的矩阵乘内部降精度到 TF32 计算,通常能带来 1.2 到 1.5 倍提速,精度损失在大多数任务里感知不到。
torch.backends.cuda.matmul.allow_tf32 = True torch.backends.cudnn.allow_tf32 = True如果你的任务对数值极其敏感(比如小量级的物理仿真、某些强化学习的 value 估计),先关掉 TF32 验证一遍再决定。
torch.compile是这两年最值得试的一招,尤其对 Transformer 类结构,融合算子之后常见 1.2 到 1.8 倍提升:
model = torch.compile(model, mode="default") # 追求最快可用 max-autotune代价是首次编译要几十秒到几分钟,且输入形状变化会触发重编译。我的用法是:训练脚本里包一层环境变量开关,调试时关掉避免等编译,正式长跑时打开。另外 compile 和 AMP 可以叠加,先 compile 再 autocast,顺序别反。
一个实用细节:mode="max-autotune"会做更激进的 kernel 搜索,编译时间可能到十几分钟,适合那种一跑就是一两天的大任务;小实验用default就够了。
3.5 显存换速度:梯度累积与激活检查点
这两个技术方向相反,一个用时间换显存空间,一个用小 batch 换大等效 batch,得看你的目标是什么。
梯度累积是为了在显存受限时模拟大 batch:
accum_steps = 4 for i, (x, y) in enumerate(loader): with torch.amp.autocast("cuda", dtype=torch.bfloat16): loss = criterion(model(x.to(device)), y.to(device)) / accum_steps loss.backward() if (i + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad(set_to_none=True)关键点是 loss 要除以累积步数,否则等效学习率会放大。另外 BatchNorm 在累积模式下统计量是按小 batch 算的,效果和大 batch 不一致,用 LayerNorm 或者 GroupNorm 的结构没这个问题,CNN 类模型要留意。
激活检查点(torch.utils.checkpoint)是把中间激活丢掉,反向时重新算一遍,用大约 30% 的额外计算换 60% 到 70% 的激活显存。它适合“显存不够但算力有余”的场景,比如想跑更大的图或者更长的序列。我的经验是优先调 batch 和精度,这两招用尽还 OOM 再上检查点,因为重算带来的时间损失在大模型上并不小。
4. 训练提速过程中的高频故障与排查实录
4.1 显存 OOM 的六种成因与对应解法
OOM 报错信息给的信息很少,但成因其实就那么几类,我整理了一张对照表,按出现频率排序:
| 成因 | 典型表现 | 解法 |
|---|---|---|
| batch 或序列过长 | 一开始就 OOM,改小就好 | 降 batch、梯度累积、序列切分 |
| 激活值未释放 | 训练几步后逐渐 OOM | 检查是否有张量被 list 持有、用 checkpoint |
| 推理没加 no_grad | 验证阶段 OOM | torch.no_grad()或inference_mode() |
| loss 累加保留了计算图 | 每步显存线性增长 | 用loss.item()或total += loss.detach() |
| 显存碎片 | 总空闲够但分配失败 | PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True |
| 多进程重复加载模型 | 多卡或多 worker 各占一份 | 每个进程只在本 rank 建模型 |
第二行和第四行是最隐蔽的。我见过一份代码把每个 step 的 loss 存进列表,准备最后画曲线,结果显存一路涨到爆。修法很简单:存loss.item(),或者loss.detach().cpu()。判断方法也简单,打印torch.cuda.memory_allocated(),如果它随步数单调上升,基本就是有张量没释放。
第五行的碎片问题值得展开一句。PyTorch 的缓存分配器会预留显存块,长时间训练、形状变化多的时候容易产生碎片,表现为“明明还有 2GB 空闲但就是分配不出来”。设置expandable_segments:True能明显缓解,我现在的训练脚本默认都加上这个环境变量。
4.2 GPU 利用率上不去的排查顺序
利用率上不去,我按固定顺序查,一般三步之内能定位。
第一步,看是不是在等数据。临时把 DataLoader 换成随机生成的假数据(形状和 dtype 对就行),再测一次耗时。如果假数据下快了一大截,问题就在输入管道,回去调 worker、预处理、存储读取。这一步能过滤掉一半的问题,且几乎零成本。
第二步,看是不是同步点太多。搜一遍代码里的.item()、.cpu()、print(、.numpy()、torch.cuda.synchronize(),每一处都是潜在阻塞。日志改成每 N 步打一次,指标先在 GPU 上累加、最后统一取值。
第三步,看 CPU 侧有没有重活。用 profiler 看Self CPU time的分布,常见元凶是预处理写在__getitem__里做了复杂变换、每步都做一次完整验证、优化器里有 Python 层的循环。数据增强尽量放到 worker 里做,验证频率从每 epoch 改成每 N 个 epoch。
三步都排除完还是上不去,那就是算子层面的问题了,回到 1.1 节讨论的算子碎片化,考虑合并算子或者上torch.compile。
4.3 开加速后精度掉点、loss 不降的排查
加速手段带来精度问题,责任通常不在加速本身,而在配置。我遇到的几类:
混合精度下 loss 出现 NaN,第一位检查是不是没有GradScaler,第二位检查模型里有没有手工实现的对精度敏感的算子。如果前几步就 NaN,可以把GradScaler的init_scale调小,比如从默认的 65536 降到 8192。
开了 TF32 之后验证集指标掉了一两个点,这种一般是累积误差,在长训练里才显现。判断方法:固定种子跑两个短周期,一个开 TF32 一个关,对比 loss 曲线前 1000 步的走势,差异在千分位以内就不用管。
torch.compile引起的数值差异比较少见但确实存在,尤其在动态形状和自定义算子混合的情况下。遇到“编译后结果对不上”,先关编译确认原始代码没问题,再检查是否有依赖 Python 副作用的逻辑(比如在 forward 里改全局变量),这类代码在编译图里行为会变。
还有一个反直觉的经验:batch 调大之后学习率没跟着调,表现出来也是“loss 不降”。这条不算加速导致的精度问题,但每次调完 batch 都要顺手检查学习率,按线性缩放或者平方根缩放试一档。
4.4 那些“看起来更快”的假优化
有几个优化看起来很美,实测收益接近零甚至为负,我列出来省得你白费功夫。
把num_workers从 8 调到 32。除非你的数据读取是真的慢(网络存储、超大视频解码),否则 CPU 争抢会让整体变慢,内存占用还翻几倍。
把 batch 翻倍但不调学习率。单步耗时确实涨了但没翻倍,看起来“更高效”,实际收敛变慢,总训练时长反而增加。衡量标准应该是“达到目标精度需要多久”,不是“每步多快”。
盲目开cudnn.benchmark且输入形状不固定。每次形状变了都要重新搜索算法,第一轮可能慢得让你怀疑人生。
把所有张量都提前放到 GPU 上常驻。小数据集这么做确实省了拷贝,但数据集一大就 OOM,而且占着显存会让 batch 上不去,得不偿失。
用DataParallel做多卡。这个下面单独说,反正在我这里是负优化。
5. 多卡扩展与吞吐核算:加速比的真实天花板
5.1 DDP 的基本写法与通信开销
单卡调到没空间了,自然要上多卡。PyTorch 里多卡训练的唯一推荐选择是DistributedDataParallel,DataParallel只在单进程多线程下工作,有 GIL 争抢和主卡负载不均的老问题,四卡以上的加速比经常还不如两卡。
DDP 的最小改动是这样:
import os, torch.distributed as dist dist.init_process_group("nccl") local_rank = int(os.environ["LOCAL_RANK"]) torch.cuda.set_device(local_rank) model = model.to(local_rank) model = torch.nn.parallel.DistributedDataParallel( model, device_ids=[local_rank], gradient_as_bucket_view=True )启动用torchrun --nproc_per_node=4 train.py,记得在 DataLoader 外面套DistributedSampler,并把shuffle交给 sampler 处理,否则每个进程都会读全量数据,等效 batch 翻倍但梯度重复。
通信开销是大模型多卡的主要变数。gradient_as_bucket_view=True能让梯度桶复用显存,减少一次拷贝。如果单机内卡间互联带宽一般,通信会成为瓶颈,表现为卡数增加但吞吐不涨。判断方法是看 profiler 里ncclAllReduce的占比,超过 20% 就要考虑用梯度累积换取更少的同步次数。
5.2 扩展效率怎么算,什么时候该停手
扩展效率的定义很朴素:N 卡吞吐除以单卡吞吐再除以 N。四卡跑到 3.4 倍(85%)算良好,跑到 2.8 倍(70%)算正常,掉到 2 倍以下就说明有问题。
global_bs = per_gpu_bs * world_size * accum_steps throughput = global_bs / step_time # 单位 samples/s这个指标我建议每换一次配置就记一笔,因为它是判断“该不该继续加卡”的唯一依据。当边际收益低于 60% 时,加卡的钱不如花在别处——比如换更大的显存、优化数据管道、或者干脆缩减模型规模。
顺带说个学习率的换算:全局 batch 变化后,等效学习率要跟着动。我的习惯是保持lr / global_bs在一个区间内浮动,改 batch 时按比例调整,然后跑两百步看 loss 曲线斜率是否和之前接近。
5.3 一张吞吐与成本的核算表
优化做完,最后一步是把数据整理成一张能拿给别人看的表。我在项目里习惯这么记:
| 配置 | 单步耗时 | 全局 batch | 吞吐(样本/s) | 相对基线 | 备注 |
|---|---|---|---|---|---|
| 基线 FP32 单卡 | 420 ms | 32 | 76 | 1.00x | worker=4 |
| + 数据管道调优 | 310 ms | 32 | 103 | 1.36x | worker=8, pin_memory |
| + AMP(BF16) | 195 ms | 64 | 328 | 4.31x | 学习率同步调整 |
| + torch.compile | 158 ms | 64 | 405 | 5.33x | 编译耗时 90s |
| + DDP 4 卡 | 210 ms | 256 | 1219 | 16.0x | 扩展效率 75% |
这张表的价值在于每一行都是可复现的单变量改动,谁接手都能按图索骥复现一遍。我特别建议把“相对基线”这一列留着,它是判断后续优化还有没有空间的最直接依据——当你连续三次改动都只带来 3% 以内的提升,说明这套硬件配置基本吃满了,该停下来考虑换思路而不是继续抠参数。
注意:这张表里的绝对数字只对特定模型和特定硬件成立,直接搬数字没有意义,搬的是记录方法和评估维度。
最后分享一点我自己的体会。这几年调训练速度,真正带来大幅提升的往往只有两三个动作:把数据管道修好、打开混合精度、必要时上多卡,剩下的都是在小数点上抠。而最容易浪费时间的是拿到代码就开始改参数,改完之后也没个对照,最后自己都不确定到底哪一版更快。所以我现在强迫自己每改一处就留一行记录,跑完三次取中位数再决定留不留。这个习惯听起来笨,但它比任何“性能优化技巧清单”都管用——因为性能这件事,唯一可信的裁判是同一台机器上、同一个脚本里、前后两次严格的测量。