ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyTorch训练中inf与nan的排查、防护与修复实战指南

PyTorch训练中inf与nan的排查、防护与修复实战指南

1. 为什么inf和nan总在loss曲线里冒出来

做深度学习训练的人,几乎都遇到过这样的场景:模型跑了几十个epoch,loss曲线本来稳稳下降,突然某一轮直接跳到inf,或者变成nan,然后整个训练就废了,后续所有参数全变成无效值。更让人抓狂的是,这种问题往往不是每次都复现,有时候换个随机种子就好了,有时候跑一晚上才崩,排查起来极其痛苦。

inf和nan是浮点数运算里的两个特殊值。inf表示无穷大,通常是数值溢出导致的,比如除以一个极小的数、指数函数爆炸、梯度累积过大。nan表示"不是一个数",它的来源更隐蔽,常见的是0/0、inf - inf、inf * 0这类未定义运算,也可能是对负数开方、对负数取对数。在PyTorch里,这两个值一旦出现在计算图中,就会像病毒一样传播——任何和nan做运算的结果都是nan,梯度回传时整个参数张量都会被污染。

这篇内容想解决的问题很具体:当你的PyTorch训练出现inf或nan时,怎么快速定位源头、怎么在代码层面做防护、怎么在事后做补救。适合已经能跑通基础训练流程、但被数值稳定性问题卡住的中级使用者,也适合刚入门、想提前建立防御意识的新手。我不会只告诉你"用torch.nan_to_num修一下",而是把产生机制、排查链路、防护策略、以及几个容易被忽略的坑都拆开讲清楚,让你下次遇到同类问题时能自己顺藤摸瓜。

需要先明确一个认知:inf和nan不是bug,是浮点运算的正常产物。IEEE 754标准里它们就是合法值,问题在于你的模型和训练流程没有对它们做处理。所以思路不是"消灭它们",而是"控制它们的产生条件"加"在关键节点拦截它们"。

2. inf与nan在PyTorch里的行为特征

2.1 两个特殊值的运算传播规则

要排查问题,先得知道这两个值在运算里怎么"传染"。PyTorch遵循IEEE 754的传播规则,我整理了一张表,实测下来和标准一致:

运算结果说明
inf + 有限值inf无穷大吸收有限值
inf - infnan未定义,这是最常见的nan来源之一
inf * 0nan同样是未定义运算
inf / infnan未定义
有限值 / 0inf或-inf取决于符号,PyTorch不抛异常
nan + 任何值nannan具有绝对传染性
nan == nanFalse所以不能用等号判断nan

最后一条特别关键。很多人写if x == float('nan')来做判断,永远为False。正确做法是用torch.isnan(x)和torch.isinf(x)。这两个函数返回布尔张量,配合.any()就能快速检测。

还有一个容易忽略的点:torch.isinf对正负无穷都返回True,如果你需要区分,得额外判断符号,用x == float('inf')和x == float('-inf')。在实际排查中,正inf多半是溢出,负inf往往是除以负数或者log的边界问题,方向不同,排查路径也不同。

2.2 反向传播如何被一个nan彻底污染

前向计算出nan还只是局部问题,真正致命的是反向传播。假设某个中间张量h里有一个元素是nan,那么损失函数对h的梯度里对应位置也会是nan。由于链式法则,这个nan会沿着计算图往回传,经过加法、乘法、矩阵乘,一路污染到所有相关的参数梯度。

我做过一个最小复现:一个两层全连接网络,手动把第一层输出的某一个元素设成nan,然后正常做loss.backward()。结果发现第二层的权重梯度整个张量都变成了nan,第一层也是。原因在于矩阵乘法y = Wx中,x的某个元素是nan,那么y的所有元素都含nan(因为每个输出都累加了这一项),梯度自然全污染。

这解释了一个现象:你看到参数变成nan,不代表问题出在那个参数上,源头可能在很早的某一层。所以排查不能从报nan的地方往回猜,得用系统性的方法定位第一个出现异常的位置。

提示:torch.autograd.set_detect_anomaly(True)可以在反向传播时检测到产生nan的算子并抛出错误,定位精度比事后看参数高得多,但会显著拖慢训练速度,只在调试时开。

2.3 混合精度训练放大了溢出风险

现在很多人用torch.cuda.amp做混合精度训练,fp16的动态范围比fp32小得多。fp16能表示的最大值约65504,最小正规数约6e-5。这意味着在fp32里完全正常的中间结果,在fp16里可能直接溢出成inf。

典型的触发点是softmax之前的logits、attention的分数、以及各种归一化前的激活值。我遇到过的情况是:模型在fp32下训练完全正常,切到amp后第三个epoch就出inf,查下来是某一层的attention score在长序列上累加超过了65504。

GradScaler的设计初衷就是缓解这个问题——它把loss放大若干倍再回传,避免梯度下溢,同时在发现inf/nan时自动跳过该step并降低scale。但要注意,GradScaler只能保护梯度,保护不了前向的激活值。如果前向就算出了inf,scaler也无能为力。所以混合精度下的防护要分两层:前向的数值裁剪 + 梯度的scaler机制。

3. 定位第一个异常值的完整排查链路

3.1 从loss反推:先确认异常出现的时间点

排查第一步不是改代码,是搞清楚异常是什么时候出现的。我的习惯是在训练循环里加一段轻量检测,每步检查loss是否有限:

loss = criterion(output, target) if not torch.isfinite(loss): print(f"step {step}: loss = {loss.item()}") # 保存现场,方便事后分析 torch.save({ 'step': step, 'input': input.detach().cpu(), 'output': output.detach().cpu(), 'target': target.detach().cpu(), }, f'crash_step_{step}.pt') break

torch.isfinite对inf和nan都返回False,一个函数搞定。关键是保存现场——把出问题那一步的输入、输出、target都存下来,事后可以离线复现,不用重新跑训练。这个习惯帮我省过无数次重跑的时间。

如果loss本身是有限的,但参数更新后变成nan,那问题在梯度或优化器。这时候要在loss.backward()之后、optimizer.step()之前插入检测:

loss.backward() for name, param in model.named_parameters(): if param.grad is not None and not torch.isfinite(param.grad).all(): print(f"bad grad in {name}") break optimizer.step()

这样能定位到具体是哪个参数的梯度先出问题,缩小范围。

3.2 逐层hook:找到前向传播中第一个inf/nan

确认了异常出现在前向还是反向之后,下一步是定位到层。PyTorch的register_forward_hook可以挂在每个模块上,检查输出是否有限:

def make_hook(name): def hook(module, input, output): if isinstance(output, torch.Tensor): if not torch.isfinite(output).all(): print(f"first bad output at: {name}") # 打印异常比例,判断是局部还是整体 bad_ratio = (~torch.isfinite(output)).float().mean().item() print(f"bad ratio: {bad_ratio:.4f}") raise RuntimeError(f"stop at {name}") return hook for name, module in model.named_modules(): module.register_forward_hook(make_hook(name))

这里有个技巧:用raise中断而不是继续跑。因为一旦某层出了inf,后面所有层都会受影响,继续跑只会看到一堆报错,反而干扰判断。第一个抛出的就是源头。

bad_ratio也很有用。如果比例是1.0(整个张量都坏),多半是上游传下来的;如果是很小的比例(比如0.001),说明是本层某个特定位置的计算问题,比如某个通道的权重异常。

3.3 二分法缩小范围:大模型下的实用策略

模型层数多的时候,给每层挂hook会产生大量输出,而且hook本身有开销。更实用的做法是二分法:先只在模型前半部分和后半部分的交界处挂检测点,判断异常在前半还是后半,然后对可疑的那一半再二分。

具体操作是把model.named_modules()的输出按顺序取中间位置,只在那几个位置挂hook。跑一次,看第一个报错点落在哪个区间,然后缩小到那个区间继续。一个50层的模型,大概5到6次就能定位到具体层,比全量hook快得多。

这个思路对反向传播同样适用,用register_full_backward_hook挂在模块上,检查梯度输出。不过要注意,反向hook的触发顺序和前向相反,二分的时候区间要反过来算。

3.4 常见源头清单与对应特征

定位到层之后,还得知道这一层为什么会出问题。我按经验整理了一份高频源头对照表:

源头位置典型特征常见原因
softmax / log_softmax输出含nan输入含inf,或全为-inf的一行
log / log1p输出-inf输入含0或负数
除法 / 归一化输出inf分母接近0
exp输出inf输入过大(fp16下>11就溢出)
attention score大batch长序列时inf点积累加溢出
BatchNorm训练后期nan某个batch方差为0
梯度累积参数nan多步梯度累加溢出

拿softmax举例,如果输入某一行全是-inf(比如mask把所有位置都屏蔽了),softmax的分母是0,结果就是nan。这种问题在变长序列的padding处理里特别常见——mask写错一位,整行被屏蔽,就炸了。

4. 代码层面的防护与修复手段

4.1 输入侧:在数据进模型前做一次清洗

最省事的防护是在数据管道里加一道检查。图像数据里出现nan,往往是归一化时除以了标准差为0的通道,或者增强操作里有除零。文本数据里出现inf,可能是embedding查表越界返回了异常值。

我的做法是在collate_fn或者__getitem__返回前加一句断言:

def safe_collate(batch): out = default_collate(batch) for k, v in out.items(): if isinstance(v, torch.Tensor) and v.is_floating_point(): if not torch.isfinite(v).all(): # 记录并替换,而不是直接崩 print(f"non-finite in batch key={k}") v = torch.nan_to_num(v, nan=0.0, posinf=1e4, neginf=-1e4) out[k] = v return out

这里用nan_to_num做兜底替换而不是直接报错,是因为训练中途崩掉重跑的代价太高,先替换、记录日志、继续跑,事后分析日志更划算。替换值的选择有讲究:posinf和neginf不要设成特别大的数(比如1e30),否则后续运算还是会溢出,设成1e4这种量级比较安全。

4.2 前向侧:在敏感算子前后加裁剪

对于已知的高风险算子,可以在其前后插入裁剪。最典型的是exp和log:

# 不安全的写法 attn = torch.exp(scores) # 安全写法:先减最大值再exp,等价于数值稳定的softmax scores = scores - scores.max(dim=-1, keepdim=True).values attn = torch.exp(scores)

减最大值这个操作不改变softmax的数学结果(分子分母同乘一个常数),但把exp的输入压到了(-inf, 0]区间,输出被限制在(0, 1],彻底杜绝上溢。这是softmax数值稳定实现的标准做法,自己手写attention的时候一定要带上。

log的情况类似,torch.log(x)在x接近0时趋向-inf,可以改成torch.log(x + eps),eps取1e-8这种小量。但要注意eps不能太大,否则会改变数值精度,在需要高精度的场景(比如概率计算)要谨慎。

4.3 反向侧:梯度裁剪与GradScaler的正确配合

梯度爆炸是inf的另一个主要来源。torch.nn.utils.clip_grad_norm_是标配,但用法有细节:

loss.backward() # 先检查再裁剪,顺序不能反 total_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) if not torch.isfinite(total_norm): print("grad norm is inf/nan, skip this step") optimizer.zero_grad() continue optimizer.step()

注意clip_grad_norm_返回的是裁剪前的总范数,如果这个值本身是inf,说明梯度已经坏了,裁剪也救不回来,只能跳过这一步。很多人直接裁剪不检查,结果inf被裁成一个有限值,看起来正常了,实际上梯度方向已经完全错误,训练会慢慢跑偏。

混合精度下,GradScaler的unscale_要在裁剪之前调用,否则裁剪的是放大后的梯度,阈值就不对了:

scaler.scale(loss).backward() scaler.unscale_(optimizer) # 先还原 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) scaler.step(optimizer) scaler.update()

这个顺序错了不会报错,但会让裁剪失效,是个隐蔽的坑。

4.4 事后修复:nan_to_num的适用边界

torch.nan_to_num是最直接的修复工具,但它有明确的适用边界。它适合处理已经产生的、孤立的异常值,比如某个特征偶尔出现nan,替换成0不影响整体。但它不适合处理系统性的数值问题——如果一层输出里30%都是nan,替换成0等于把这一层的功能废了,模型学不到东西。

我的判断标准是:异常比例低于1%,可以用nan_to_num兜底;超过5%,必须回头查根因,替换只是掩盖问题。另外nan_to_num对梯度的处理要小心,它对梯度做替换会破坏梯度的统计特性,一般只在推理阶段或者数据预处理阶段用,训练中的梯度不建议直接替换。

5. 几个反直觉的踩坑记录

5.1 检测代码本身引入了nan

有一次我在调试时加了一段统计代码,用x.mean()算均值,结果这个均值是nan,反而把原本正常的流程搞坏了。原因是x里有一个inf,inf参与求均值会让整个结果变成nan(因为inf / n还是inf,但如果同时有-inf就是nan)。检测代码必须用isfinite这类不会传播异常的函数,不能用会做算术运算的聚合函数。

5.2 zero_grad的位置影响nan的清除

optimizer.zero_grad()如果放在loss.backward()之后、step()之前,某些实现下梯度里的nan不会被清掉,下一步继续累积。正确的位置是在每次backward()之前清零。这个顺序问题在自定义训练循环里特别容易写错,标准写法是:

optimizer.zero_grad() loss.backward() optimizer.step()

5.3 学习率过大导致的"假nan"

有时候参数变nan不是数值问题,是学习率太大,一步更新把参数推到了极端值,下一步计算就溢出了。这种情况调小学习率或者加warmup就能解决,不需要改模型结构。判断方法是看nan出现前一步的梯度范数,如果异常大(比如正常值的100倍),基本就是学习率问题。

5.4 保存模型时nan被静默写入

torch.save不会检查张量里有没有nan,坏参数会被原样存进checkpoint。等你想从checkpoint恢复时,加载进来就是坏的。所以保存前最好加一句检查,或者在训练循环里维护一个"最近一个健康checkpoint"的机制,发现异常就回滚到上一个健康状态,而不是从当前坏状态继续。

6. 把防护做成训练流程的固定环节

排查和修复都是被动的,真正省心的是把防护前置。我现在搭训练脚本,会固定加三个环节:数据加载后的isfinite检查、每个epoch结束时的参数健康检查、以及基于健康checkpoint的回滚机制。

参数健康检查很简单,一个epoch跑完扫一遍所有参数:

def check_model_health(model): bad = [] for name, param in model.named_parameters(): if not torch.isfinite(param).all(): bad.append(name) return bad

返回空列表就是健康。如果有坏参数,结合之前保存的现场和hook日志,基本能在几分钟内定位到问题层。

回滚机制则是每N个step存一次checkpoint,同时记录当时的loss。发现异常时,加载最近一个loss正常且参数健康的checkpoint继续训练,而不是从头再来。这套组合下来,数值问题从"训练事故"降级成了"可恢复的小插曲",对长时间训练尤其重要。

最后分享一个我常用的调试小工具函数,把检测、记录、中断三件事打包在一起,挂在训练循环里几乎零成本:

def guard(tensor, name, step, raise_on_bad=False): if not torch.isfinite(tensor).all(): bad = (~torch.isfinite(tensor)).sum().item() total = tensor.numel() print(f"[{step}] {name}: {bad}/{total} non-finite") if raise_on_bad: raise RuntimeError(f"non-finite detected in {name} at step {step}") return False return True

raise_on_bad在调试时开,正式训练时关,只记录不中断。这个函数我用了很久,几乎覆盖了所有需要监控的张量,配合前面说的hook和二分法,定位效率比盲目打印高一个数量级。数值稳定性这件事,防大于治,把检查点埋进流程里,比出了问题再救火划算得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进