先给结论:混合精度训练出 nan,多数不是模型写错了,是梯度在 fp16 里溢出了。搜索「梯度 NaN」落到这里的人,只需要先看一样东西:日志里那行 Loss scaling: 65536 -> 32768 (overflow) 还在不在更新。还在更新,说明缩放器在自救;下一步 loss 突然变 nan,才要把「梯度溢出导致的 nan」和「数据本身有 nan」分开处理。本篇用两个确定性算术实验复现可表示范围(是模型复现,不是在显卡上实测,原因写在文末),最后给一张先动哪个开关的判据表。
一、问题现场:那行日志长这样
训练机上第一次出 nan,日志通常只有这一行:Loss scaling: 65536 -> 32768 (overflow),前面一步 loss 还是数字,下一步就显示 nan,梯度打印出来也是 nan。这个报错讨厌的地方在于它不提示你去看别处:数据管道没坏、模型结构没坏,只是 fp16 的数值范围装不下这一批梯度了。
fp16 能表示的最大数是 65504,而缩放的初始尺度常取 65536——比上限还大一圈。所以日志第一次出现 overflow 这个词时,缩放器把尺度对半砍到 32768,然后是 16384、8192:数字在变,训练还没死。真正需要反应的是另一种:这几行日志安静下来之后,nan 不再收场。
二、机制:四条,每条一句结论
第一,fp16 的可表示范围窄。最大规格数 65504.0,最小规格数 6.103515625e-05:超出上界的梯度变 inf,掉到下界的被刷成 0,inf 和 0 参与梯度运算之后,nan 就是必然结果。
第二,Loss Scale 的作用是把梯度整体搬进能表示的区间:loss 先乘尺度放大梯度,优化器更新前再除以同一个尺度。一次 overflow 就把尺度砍一半,微小梯度才不会被清零。
第三,bf16 是另一套账:它保留 fp32 的指数位,不容易上溢,通常不需要缩放器;代价是尾数只有八位,精度比 fp16 粗。bf16 训练还出 nan,第一嫌疑人就不是尺度,而是数据本身。
第四,nan 的时间线能分家:溢出型的 nan 出现在训练若干步之后,且前面一定有 Loss scaling 的 overflow 记录;数据型的 nan 第一步就出,缩放器还没动过,日志里找不到一行 overflow。搜索词「梯度 NaN」命中的其实是这两种的混合,读日志就是在把它们拆开。
三、实验一:可表示范围算术模型
先把话说死:本篇两个实验都是确定性算术模型复现。本机 Python 3.12.10、numpy 2.5.3,where nvidia-smi找不到文件,没有 CUDA 设备也没有 torch,所以正文里没有任何数字来自显卡实测——下面每个数都来自纯标准库脚本的 stdout,连跑两次逐字节一致。脚本把 fp16 区间与缩放平移写成判定函数:大于 65504 记 OVERFLOW,小于最小规格数记 UNDERFLOW。
FP16_MAX=65504.0FP16_MIN_NORMAL=2.0**-14defjudge(g_abs_max,scale):scaled=g_abs_max*scaleifscaled>FP16_MAX:returnscaled,"OVERFLOW"ifscaled<FP16_MIN_NORMAL:returnscaled,"UNDERFLOW"returnscaled,"IN_RANGE"print("fp16 区间模型:最大规格数=%r 最小规格数=%r"%(FP16_MAX,FP16_MIN_NORMAL))v_a,tag_a=judge(3.2e4,65536.0)print("场景A 梯度绝对值上限=%r 尺度=%r 缩放值=%r 判定=%s"%(3.2e4,65536.0,v_a,tag_a))scale_b=2.0**-32v_b,tag_b=judge(3.0e3,scale_b)print("场景B 梯度绝对值上限=%r 尺度=2**-32=%r 缩放值=%r 判定=%s"%(3.0e3,scale_b,v_b,tag_b))marks=[tfortin(tag_a,tag_b)iftin("OVERFLOW","UNDERFLOW")]print("标记行统计 OVERFLOW=%d UNDERFLOW=%d 合计=%d/2"%(marks.count("OVERFLOW"),marks.count("UNDERFLOW"),len(marks)))运行输出:
fp16 区间模型:最大规格数=65504.0 最小规格数=6.103515625e-05 场景A 梯度绝对值上限=32000.0 尺度=65536.0 缩放值=2097152000.0 判定=OVERFLOW 场景B 梯度绝对值上限=3000.0 尺度=2**-32=2.3283064365386963e-10 缩放值=6.984919309616089e-07 判定=UNDERFLOW 标记行统计 OVERFLOW=1 UNDERFLOW=1 合计=2/2场景 A 就是最常见的上溢原形:梯度绝对值上限 3.2e4 乘尺度 65536 得 2097152000.0,远超 65504,判定行打出 OVERFLOW——和日志里那行 overflow 对得上。场景 B 换到另一头:如果尺度从 65536 一路对半砍四十八次落到 2.3283064365386963e-10,这时上限只有 3.0e3,缩放值 6.984919309616089e-07 也掉到了最小规格数下面,判定行打出 UNDERFLOW。两条标记行在 stdout 里各占一行,随机重跑都在。
四、实验二:尺度衰减序列
第二个模型回答「尺度是怎么掉下来的」。规则照需求口径写死:从 65536 起,每遇到一次 overflow 就把尺度砍半,固定输入八步,逐字打印八行 scale 值。期望落点只有一个:第 4 行等于 8192。
FP16_MAX=65504.0START_SCALE=65536G_MAX=[3.2e4,1.6e4,8.0e3,4.0e3,2.0e3,1.0e3,5.0e2,2.5e2]EXPECTED_ROW4=8192print("起始尺度=%d 规则=每遇overflow减半 固定步数=%d fp16上限=%r"%(START_SCALE,len(G_MAX),FP16_MAX))scale=START_SCALE row_values=[]foriinrange(len(G_MAX)):scaled=G_MAX[i]*scale overflow=scaled>FP16_MAX row_values.append(scale)print("第%d行 scale=%d 梯度上限=%r 缩放值=%r 判定=%s 下一步=%d"%(i+1,scale,G_MAX[i],scaled,"OVERFLOW"ifoverflowelse"IN_RANGE",scale//2))ifoverflow:scale=scale//2row4=row_values[3]print("第4行检查 实算=%d 期望=%d 判定=%s"%(row4,EXPECTED_ROW4,"MATCH"ifrow4==EXPECTED_ROW4else"MISMATCH"))print("八行尺度序列=%s"%",".join(str(x)forxinrow_values))运行输出:
起始尺度=65536 规则=每遇overflow减半 固定步数=8 fp16上限=65504.0 第1行 scale=65536 梯度上限=32000.0 缩放值=2097152000.0 判定=OVERFLOW 下一步=32768 第2行 scale=32768 梯度上限=16000.0 缩放值=524288000.0 判定=OVERFLOW 下一步=16384 第3行 scale=16384 梯度上限=8000.0 缩放值=131072000.0 判定=OVERFLOW 下一步=8192 第4行 scale=8192 梯度上限=4000.0 缩放值=32768000.0 判定=OVERFLOW 下一步=4096 第5行 scale=4096 梯度上限=2000.0 缩放值=8192000.0 判定=OVERFLOW 下一步=2048 第6行 scale=2048 梯度上限=1000.0 缩放值=2048000.0 判定=OVERFLOW 下一步=1024 第7行 scale=1024 梯度上限=500.0 缩放值=512000.0 判定=OVERFLOW 下一步=512 第8行 scale=512 梯度上限=250.0 缩放值=128000.0 判定=OVERFLOW 下一步=256 第4行检查 实算=8192 期望=8192 判定=MATCH 八行尺度序列=65536,32768,16384,8192,4096,2048,1024,512第 4 行正好落在 8192,脚本自己把期望值打成了标记行:判定=MATCH。如果你的日志里尺度一路减半、八步之后还在减,说明梯度上限长期压不住当前尺度,该动的是梯度和数据,不是继续等缩放器。
五、判据链:先动这个开关,再查你的数据
能区分「梯度溢出导致的 nan」和「数据本身有 nan」,才知道先动哪个开关。按三步走。
第一步,看 Loss scaling 的 overflow 行是否还在更新。还在更新,是溢出型:先降学习率、收紧梯度裁剪阈值,尺度会让梯度回到区间里;同时留意它砍到 256 以下还不停,这对应实验二里越砍越小的那条序列。
第二步,把梯度绝对值上限打出来,套本篇模型跑一遍:上限在 3.2e4 这个量级,上溢坐实;缩放值已经在 6.103515625e-05 这个量级以下,是下溢,减批次大小或者换 bf16,别再折腾尺度。
第三步,排除数据本身:第一步就 nan、日志里一行 overflow 都没有,别怀疑 fp16,直接查标签有没有无穷值、归一化参数有没有算错。数据型 nan 不会因为尺度砍半而好转,溢出型 nan 换个尺度就能活——这是「那行日志怎么读」唯一实用的地方。
为什么坚持在这个方向写:后台 2026-10-18 10:04:56 +0800 实读,账号原创 402 篇、总阅读 10.4w、昨日阅读 +539;按「阅读 ≥300 且 赞+藏 ≥10」的达标口径,在发系列大多只有 1 篇达标,而近 7 天 38 篇新稿评论数全为 0——阅读够了、互动不够。所以这一系列每篇都固定留一份可复制清单和一个有唯一答案的问题。
六、带走的东西:可复制清单与一个有唯一答案的问题
先给可直接复制的判据清单(复制这段贴进你的笔记都行):
混合精度 nan 排查顺序(fp16 适用;bf16 直接看第三条) 1 看 Loss scaling overflow 行:还在更新=溢出型;一行都没有=数据型 2 打梯度绝对值上限 G:G×尺度 > 65504 → 降学习率、收紧梯度裁剪 G×尺度 < 6.103515625e-05 → 下溢:减批次或换 bf16 3 数据型:查标签无穷值、空值、归一化参数——第 0 步就 nan 的都在这一类 4 尺度连砍八步不回头:梯度的问题,等缩放器没用再留一个问题,答案唯一、就在本篇里:实验二打印的八行尺度序列,第 5 行的数值是多少?(一个数字,不是「你怎么看」。)
七、合集与复现说明
本稿属于合集《GPU 与 CUDA 环境排障实战》。两个实验的脚本与全部 stdout 逐字存在随稿证据里:无显卡的机器上,用 Python 3.12.10 跑同一段命令会得到逐字节相同的数字;本篇是模型复现,不是实机实测,涉及的后台统计数字均为本机实读。
参考来源
NVIDIA CUDA C++ Programming Guide,Arithmetic Instructions 一节(docs.nvidia.com/cuda/cuda-c-programming-guide/)
PyTorch 官方文档:Automatic Mixed Precision(pytorch.org/docs/stable/amp.html)
Wikipedia:Half-precision floating-point format(en.wikipedia.org/wiki/Half-precision_floating-point_format)
Wikipedia:IEEE 754(en.wikipedia.org/wiki/IEEE_754)
¥9.9 付费专栏《Python 自动化接单实战:从脚本到第一单》共 10 篇、已完结不再更新,一次拿走:点此直达