logits 里有 NaN,贪心选了别的 token
主线位置:推理主线的「生成」一步。对最后一个位置的 logits 取 argmax。
版本:PyTorch 2.15.0(CPU 和 MPS)、candle 0.11.0(CPU,accelerate;Metal)
flowchart TB
lg["logits 含 NaN"] -->|PyTorch| pn["返回 NaN 的位置"]
lg -->|"candle CPU"| c{"NaN 在第 0 位"}
c -->|是| c0["返回 0"]
c -->|否| cs["跳过 NaN"]
lg -->|"candle Metal"| ms["跳过 NaN"]
lg -->|"取样"| we["取样报错"]
lg -->|"先查 ne self"| ok["报出 NaN"]
本页的对照:PyTorch 侧是实验代码里的 p08.py,candle 侧是实验 crate pita-t 的 p08_nan(features metal accelerate,release)。平台 M2 Max、macOS 27、rustc 1.99.0。
PyTorch 的写法
半精度溢出、错误的掩码值、除以 0 的归一化,都会让 logits 里出现 NaN。两行 logits,NaN 分别在第 1 位和第 0 位:
logits = torch.tensor([[1.0, nan, 2.0, 0.5], [nan, 0.0, 5.0, 0.5]])
logits.argmax(-1); logits.max(-1).values
torch.multinomial(torch.softmax(logits, -1), 1)
cpu logits.argmax(-1) [1, 0] max [nan, nan]
cpu multinomial: RuntimeError probability tensor contains either `inf`, `nan` or element < 0
mps logits.argmax(-1) [1, 0] max [nan, nan]
PyTorch 把 NaN 当作最大值传播:max 是 NaN,argmax 指向 NaN 的位置,CPU 和 MPS 相同。贪心解码会生成那个 token,取样直接报错,问题马上能看见。
搬到 candle 的现象
let logits = Tensor::new(&[[1f32, n, 2., 0.5], [n, 0., 5., 0.5]], &dev)?;
logits.argmax(D::Minus1); logits.max(D::Minus1);
let mut greedy = LogitsProcessor::new(42, None, None);
let mut sample = LogitsProcessor::new(42, Some(0.8), None);
greedy.sample(&logits.i(r)?); sample.sample(&logits.i(r)?);
== cpu
logits.argmax(D::Minus1) U32 [2] [2, 0]
logits.max(D::Minus1) F32 [2] [2.0, NaN]
row 0: greedy Ok(2) sample Err("A weight is negative, too large or not a valid number")
row 1: greedy Ok(0) sample Err("A weight is negative, too large or not a valid number")
== metal
logits.argmax(D::Minus1) U32 [2] [2, 2]
logits.max(D::Minus1) F32 [2] [2.0, 5.0]
row 0: greedy Ok(2) sample Err("A weight is negative, too large or not a valid number")
row 1: greedy Ok(2) sample Err("A weight is negative, too large or not a valid number")
同样的 logits,三种结果:PyTorch 选 [1, 0],candle CPU 选 [2, 0],candle Metal 选 [2, 2]。贪心解码在 candle 上不报错,选出一个看起来正常的 token,生成继续往下走;Metal 上 max 甚至不返回 NaN,后面的检查也看不到它。取样那条路三边都报错,candle 的报错是 rand 的 WeightedIndex 给的,不提 NaN。
原因
- CPU 的
max、argmax由ReduceIndex::fold_impl实现:val从第 0 个元素开始,f(val, s)为真才替换,max的f是|x, y| x < y。和 NaN 的比较永远是假,所以 NaN 在第 0 位时换不出去(第二行得到 0 和 NaN),不在第 0 位时换不进来(第一行得到 2)。 - Metal 的
max用Max里的fast::max(a, b),argmax用indexed的operator>(lhs.val > rhs.val || ...),归约从最小值开始,NaN 永远比不过别的值,所以两行都跳过 NaN。 LogitsProcessor的贪心就是sample_argmax里的logits.argmax(D::Minus1)?,继承上面两种行为。取样路径先softmax,NaN 让整行概率都是 NaN,WeightedIndex::new拒绝;这条报错的另一个来源(温度为 0)见源码卷的 A weight is negative, too large or not a valid number。
归约与索引的「动脑筋」用同一组数据讲过 max 的这三种结果。origin/main 上 CPU 的 ReduceIndex 和 Metal 的 Max 没有改。
改法
NaN 是上游出了问题的信号,修的是产生 NaN 的地方;这里要做的是让它不被吞掉。在 argmax 之前数一下 NaN:
let bad = logits.ne(&logits)?.to_dtype(DType::U32)?.sum_keepdim(D::Minus1)?;
nan count per row (x.ne(&x)) U32 [2, 1] [1, 1]
CPU 和 Metal 都数出每行一个。x.ne(&x) 只在 NaN 处为 1(IEEE 规定 NaN 不等于自己),结果是 U8,转成 U32 再求和,避开 U8 求和回绕。生成循环里每步读回一个标量,代价是一次很小的归约:
let n_nan = logits.ne(&logits)?.to_dtype(DType::U32)?.sum_all()?.to_scalar::<u32>()?;
if n_nan > 0 {
anyhow::bail!("{n_nan} NaN in logits at step {step}");
}
只在调试构建里做也可以。和 PyTorch 对齐 logits 时(搬一个 LLM),比较之前也先做这一步。bin p08b 里 b = [1, 2.00001, NaN, 4],和 a = [1, 2, 3, 4] 比:
(&a - &b)?.abs()?.flatten_all()?.max(0)?.to_scalar::<f32>()?
cpu max|a - b| = 1.001358e-5
metal max|a - b| = 1.001358e-5
NaN 不在第 0 位,CPU 和 Metal 都把它跳过,报出一个看起来「对上了」的差。
怎么检查
- 生成结果在 CPU 和 Metal 上从某一步开始不同,而 logits 的差远小于第一名和第二名的差距,先查 NaN。
- 比较两组输出时,不要只用
max:Metal 上它看不见 NaN,CPU 上只有 NaN 在第 0 位时才看得见。数ne(self)的个数,或者读回主机用f32::is_nan。 - 半精度(半精度)和自己写的掩码(
-inf加-inf、0 × inf)是 NaN 最常见的来源。
小结
- PyTorch 的
max/argmax传播 NaN;candle CPU 只在 NaN 位于第 0 位时返回它,Metal 总是跳过它。 LogitsProcessor的贪心继承这一点,生成不报错地继续;取样路径报A weight is negative, too large or not a valid number。- 改法:argmax 之前用
x.ne(&x)数 NaN,发现就报错。