一个梯度尖峰,六批数据后出现NaN:调试确定性神经网络训练失败
第一部分/第二部分
第一个可见的 NaN 出现在第 46 批次的最终线性层。
此时,模型实际上已被摧毁。
六个批次之前,所有张量仍是有限值。损失是有限的。梯度是有限的。动量缓冲区是有限的。没有任何东西跨越有效浮点值与 NaN 之间的清晰边界。
但训练状态已进入失控状态。
这就是一次聚合分析失败如何引导我回溯——从损坏的检查点,到一个可复现的批次,再到引发崩溃的有限梯度尖峰。
实验
我使用小型 CNN 在 MNIST 上运行了一个顺序训练实验。
该实验比较了两种训练历史:
- SABC: 数字 0–4,然后数字 5–9,随后是共享的平衡 0–9 松弛阶段。
- SBAC: 数字 5–9,然后数字 0–4,随后是相同的松弛阶段。
作为机制控制实验,我将现有的 ReLU 激活函数替换为 LeakyReLU,同时保持其余协议不变。
设置如下:
SimpleCNN- 无归一化
- LeakyReLU 斜率
0.01 - SGD
- 学习率
0.05 - 动量
0.9 - 无权重衰减
- 确定性种子:
101、202、303、404、505 - 每个种子两个历史
- 总共十次运行
十次运行中有四次变得数值无效:
- SABC seed303
- SBAC seed101
- SBAC seed202
- SBAC seed303
在所有四种情况下,第一个已知的损坏检查点都是第 11 个 epoch。
第 10 个 epoch 是第一个训练域的结束。第 11 个 epoch 是切换到第二个域后的第一个 epoch。
这个时间点很可疑,但不足以确定原因。不稳定性可能取决于激活函数、学习率、动量、域切换、模型状态、任务顺序和随机种子之间的相互作用。
验证器首先发现了它
我并非因为训练循环打印了 NaN 而发现问题。
后来的一次聚合分析验证器拒绝了两个本应代表相同模型状态的检查点:最终顺序训练检查点和松弛阶段的第零步检查点。
起初,这看起来像是序列化或记账问题。
一次直接的检查点审计显示,受影响的文件包含 NaN 和 Inf 值。
对于三次失败的运行,第一个损坏检查点中的所有 421,642 个参数都是 NaN。在 SABC seed303 中,检查点包含 421,610 个 NaN 和 32 个正无穷大。
验证器并未导致问题。它阻止了损坏的运行进入科学分析。
我最初搞错了什么
原始运行是在一个 GPU 上并行启动的。
因此,我首先怀疑环境因素:
- GPU 争用
- 多进程干扰
- 非确定性 CUDA 行为
- 瞬态调度或硬件问题
为了验证这一点,我使用详细的数值追踪器单独重新运行了一个已知失败的配置——seed101-SBAC。
它再次失败,且位置完全相同:
- 第 11 个 epoch
- 阶段 2
- 阶段数据 A
- 第 46 批次
- 前向传播之后
- 第一个失败的张量:
fc2_logits
隔离的重复运行也使用了相同的输入哈希、标签哈希、标签直方图和非有限计数。
失败的 logits 包含:
- 128 个 NaN
- 128 个正无穷大
- 384 个负无穷大
在隔离环境中,在相同的批次和相同的网络阶段复现相同的失败,使得一次性 GPU 冲突的可能性非常低。
实际发生了什么
第一个非有限张量出现在第 46 批次。
但失败更早开始了。
在第 39 批次,系统看起来仍然正常:
- 损失:约
3.03 - 全局梯度范数:约
9.11 - 参数幅度仍然很小
在第 40 批次,轨迹急剧变化:
- 损失跃升至约
44.59 - 全局梯度范数跃升至约
483.74 - 最大梯度达到约
266.7
所有值仍然是有限的。
在优化器步骤之后,最大动量缓冲区值从约 9.13 上升到约 269.93。
后续批次并未恢复。
| 批次 | 损失 | 全局梯度范数 | 最大 FC1 激活 | 解释 |
|---|---|---|---|---|
| 39 | 3.03 | 9.11 | 2.18 | 看似正常的状态 |
| 40 | 44.59 | 483.74 | 17.10 | 第一个大的有限梯度事件 |
| 41 | 1.48 × 10^3 | 988 | 913 | 失控增长变得可见 |
| 43 | 1.04 × 10^5 | 7.82 × 10^4 | 5.92 × 10^4 | 强烈放大 |
| 44 | 4.71 × 10^8 | 6.25 × 10^7 | 1.02 × 10^6 | 极端但有限 |
| 45 | 3.23 × 10^18 | 1.19 × 10^15 | 6.87 × 10^15 | 模型实际上已被摧毁 |
| 46 | — | — | 1.97 × 10^36 | 最终 logits 溢出 float32 |
在失败的前向传播之前:
fc1.weight幅度约为10^12fc2.weight幅度约为10^13- 动量缓冲区约为
10^14–10^15 - 最大 FC1 激活约为
1.97 × 10^36
最终线性层随后溢出 float32 并产生 NaN 和有符号 Inf 值。
追踪支持以下序列:
大但有限的梯度事件
→ 动量和参数规模的快速增长
→ 快速增长的激活值
→ 最终线性层中的 float32 溢出
动量似乎放大了不稳定性,但追踪并未确定是什么导致了最初的梯度尖峰。
同样,fc2_logits 是第一个非有限张量,但它是终端症状,不一定是失败的根源。
有限并不健康
一个标准的防护措施是:
if not torch.isfinite(tensor).all():
stop_training()
这个检查是必要的,但为时已晚。
在第 45 批次,模型具有:
- 损失约为
10^18 - 梯度范数约为
10^15 - 动量缓冲区约为
10^14 - 激活值约为
10^15
每个值仍然是有限的。
一个二元的有限/非有限检查会将此状态归类为有效。实际上,模型已经无法恢复。
这表明存在一个更有用的区分:
- 数值有限
- 数值极端
- 数值非有限
从第一种状态到第二种状态的转变,正是存在最有用的调试信息的地方。
等到 NaN 出现时,最初的触发因素可能已经过去了几个优化器步骤。
为什么调试工作流程很重要
调查遵循一个简单的顺序:
- 聚合分析失败。
- 一对验证器捕获了检查点不一致。
- 检查点审计发现了 NaN 和 Inf 值。
- 添加了一个快速失败的数值追踪器。
- 在隔离环境中复现了一个已知失败。
- 第一个非有限张量被定位到一个批次和一个网络阶段。
- 更早的批次揭示了实际的失控动态。
- 一个有限的控制运行表明追踪器不会自动导致失败。
这个工作流程是可复用的。
对于数值调试,我现在会监控:
- 全局梯度范数
- 最大绝对梯度
- 参数范数
- 动量缓冲区范数
- 激活幅度
- 优化器步骤前后的模型状态
- 输入和标签的确定性哈希
哈希特别有用。
“它又在第 46 批次失败了” 比以下证据更弱:
“它又在第 46 批次失败了,且输入和标签张量完全相同。”
证据支持什么
谨慎的结论是:
在此顺序训练协议下,使用学习率 0.05 和动量 0.9 的 SGD 的 LeakyReLU 在 10 次运行中有 4 次表现出确定性的、依赖于种子的数值不稳定性。
证据并未表明 LeakyReLU 普遍导致梯度爆炸。
它也没有表明动量、域切换、任务顺序或最终线性层独立地导致了失败。
结论
本次实验最有价值的产物并非NaN检查点,而是通向NaN的有限值轨迹。
第39批次时,系统表现正常。
第40批次时,梯度范数从约9跃升至约484。
第45批次时,模型在技术上仍保持有限值,但梯度已达约10^15。
第46批次时,隐藏层激活值达到约10^36,最终线性层溢出float32精度。
核心教训很简单:
有限值张量并不必然代表训练状态健康。
NaN检查至关重要,但它们是迟到的警报。
最终的NaN揭示了计算在何处停止可表示。
而在此之前六个批次的有限值,则展示了模型如何一步步走向崩溃。