Rust in Everything
基于 candle 0.11.0

逐层对数

主线位置:推理主线的第三步「前向,logits 对上」之前的调试手段。最后的 logits 对不上时,要知道是从哪一层开始偏的;logits 对上了,也想知道中间每一层离得有多远。
版本:PyTorch 2.15.0 / transformers 5.22.0(CPU)、candle 0.11.0(CPU,accelerate;Metal)
flowchart TB
  subgraph pt ["PyTorch"]
    hook["register_forward_hook"] --> d1["每层输出 dict"]
  end
  subgraph cd ["candle"]
    copy["抄一份 forward"] --> v["每层 push 进 Vec"]
  end
  d1 --> sf["safetensors"]
  sf --> cmp["逐层比 最大差"]
  v --> cmp
  cmp --> acc["累积误差"]
  cmp --> loc["喂参考输入 单层误差"]

实验代码:PyTorch 侧 hooks.py;candle 侧 crate module-exp 的 bin check_layers(candle 0.11.0,features metal、accelerate,release)。模型是本地的 SmolLM2-135M-Instruct 权重,两边都载成 f32,transformers 用 attn_implementation="eager"。另外让 transformers 载成 f64 再跑一遍,当作「真值」。PyTorch 固定 torch.set_num_threads(4)。平台 M2 Max、macOS 27、rustc 1.99.0。两个 prompt:推理主线的 The capital of France is(5 个 token)和一段 69 个 token 的英文。

PyTorch:forward hook

register_forward_hook 在模块的 __call__ 末尾调用回调,拿到这一层的输出,不用改模型代码:

def capture(model, ids):
  outs = {}
  hooks = []

  def keep(name):
    def hook(module, args, output):
      outs[name] = output.detach().clone()
    return hook

  hooks.append(model.model.embed_tokens.register_forward_hook(keep("emb")))
  for i, layer in enumerate(model.model.layers):
    hooks.append(layer.register_forward_hook(keep(f"layer.{i}")))
  hooks.append(model.model.norm.register_forward_hook(keep("norm")))
  with torch.no_grad():
    res = model(ids, output_hidden_states=True)
  for h in hooks:
    h.remove()
  outs["logits"] = res.logits[0, -1].contiguous()
  return outs, res.hidden_states

transformers 5.22.0 的 LlamaDecoderLayer.forward 直接返回一个张量,所以回调里的 output 就是这一层的隐藏状态 [1, T, 576]。f32 和 f64 两份都写进 safetensors,candle 侧读。

transformers 自己也有 output_hidden_states=True,在 5.22.0 里它也是 forward hook:LlamaPreTrainedModel._can_record_outputs 写着 "hidden_states": LlamaDecoderLayer,utils/output_capturing.py 给这些模块 register_forward_hook。但它返回的东西和逐层 hook 不完全一样:

len(hidden_states) 31
hidden_states[0] == emb hook: True
hidden_states[1] == layer.0 hook: True
hidden_states[29] == layer.28 hook: True
hidden_states[30] == layer.29 hook: False == norm hook: True
📝NOTE
没有蠢问题

问: 30 层的模型,hidden_states 有 31 个。hidden_states[i] 是第 i 层的输出吗?

答: 差一位,而且最后一个不一样。hidden_states[0] 是词嵌入,hidden_states[i](1 ≤ i ≤ 29)是第 i−1 层的输出;hidden_states[30] 不是最后一层的输出,而是过了最终 norm 之后的值(等于 last_hidden_state)。拿它去比 candle 里第 29 层的输出,会差出一整个 RMSNorm,看起来像最后一层搬错了。逐层对数时用自己挂的 hook,名字和位置都清楚。

candle:没有 hook,把前向抄一份

candle 的模块没有 __call__,forward 只是一个方法(定义一个模块),没有地方挂回调。candle_transformers::models::llama 的 Llama 也只返回最后一个位置的 logits,中间结果在 Llama::forward 的局部变量里,拿不出来。

办法是把它的前向抄进自己的 crate,在循环里多存一份。Tensor 的 clone 只多一个 Arc 引用(引用与不可变),存 30 份不复制数据。抄的是 CausalSelfAttention::forward、Mlp、Block::forward,去掉 KV 缓存和 tracing,cos / sin 表照 Cache::new 的公式算,掩码用公开的 build_causal_mask。改动只在最外层:

/// returns (embeddings, one output per layer, after final norm, last-position logits)
fn forward_all(&self, ids: &Tensor) -> candle_core::Result<(Tensor, Vec<Tensor>, Tensor, Tensor)> {
  let (_, t) = ids.dims2()?;
  let cos = self.cos.narrow(0, 0, t)?;
  let sin = self.sin.narrow(0, 0, t)?;
  let mask = candle_transformers::utils::build_causal_mask(t, 0, ids.device())?;
  let emb = ids.apply(&self.embed_tokens)?;
  let mut hidden = Vec::with_capacity(self.layers.len());
  let mut x = emb.clone();
  for layer in &self.layers {
    x = layer.forward(&x, &cos, &sin, &mask)?;
    hidden.push(x.clone());
  }
  let normed = x.apply(&self.norm)?;
  let last = normed.i((.., t - 1, ..))?.contiguous()?;
  let logits = last.matmul(&self.embed_tokens.embeddings().t()?)?; // tied lm_head
  Ok((emb, hidden, normed, logits))
}

抄的东西先要证明抄对了:同一个 VarBuilder 再建一个库里的 Llama,两份 logits 比一下。

[Cpu] short: 5 tokens; copy vs candle_transformers::llama logits max diff 0e0
[Metal(MetalDevice(DeviceId(1)))] short: 5 tokens; copy vs candle_transformers::llama logits max diff 0e0

69 个 token 的 prompt 在两个设备上也是 0e0:抄的这份和库逐位相同,下面量到的差就是 candle 本身和 transformers 的差。

如果模型本来就是一串 Module,用不着抄:Sequential::forward_all 返回每一层的输出。transformer 的层要位置和掩码,不是 Module,只能自己写这个循环。

30 层逐层比

每一层的输出 [1, T, 576] 和 transformers f32 的 hook 比最大绝对误差。5 个 token 的 prompt:

输出最大 |ref|CPUMetalPyTorch f32 对 f64去掉第 0 个 token:最大 |ref|CPUMetal
emb0.60000.6200
layer.030.61.907e-58.106e-61.651e-521.451.907e-58.106e-6
layer.1176.02.594e-49.155e-55.801e-445.282.480e-51.526e-5
layer.2685.41.831e-41.221e-48.470e-445.892.289e-51.526e-5
layer.3683.01.831e-41.221e-48.649e-451.972.289e-52.289e-5
layer.4679.91.831e-41.221e-48.508e-458.251.907e-51.526e-5
layer.5674.11.831e-41.221e-48.834e-455.031.907e-51.335e-5
layer.6668.51.831e-41.221e-48.532e-460.312.289e-51.234e-5
layer.7659.21.831e-41.221e-48.991e-468.972.670e-52.289e-5
layer.8565.21.831e-41.221e-41.040e-377.443.433e-53.052e-5
layer.9130.13.815e-53.433e-51.455e-386.143.815e-53.433e-5
layer.1094.01.450e-46.866e-54.441e-494.003.815e-53.052e-5
layer.1124136.02.148e-25.859e-31.735e-3102.543.815e-52.360e-5
layer.1224125.82.148e-25.859e-31.999e-396.223.433e-52.289e-5
layer.1324111.82.148e-25.859e-31.801e-3110.483.815e-53.052e-5
layer.1424098.02.148e-25.859e-31.460e-3124.726.104e-53.052e-5
layer.1524080.32.148e-25.859e-31.463e-3125.806.104e-53.815e-5
layer.1624064.72.148e-25.859e-31.558e-3133.506.104e-53.815e-5
layer.1724050.02.148e-25.859e-31.776e-3119.076.104e-53.815e-5
layer.1824041.62.148e-25.859e-32.815e-3136.479.155e-53.815e-5
layer.1924034.12.148e-25.859e-33.007e-3167.616.104e-53.242e-5
layer.2024032.22.148e-25.859e-33.618e-3201.556.104e-53.681e-5
layer.2124041.02.148e-25.859e-33.268e-3211.027.629e-55.722e-5
layer.2224036.42.148e-25.859e-33.955e-3225.551.068e-41.030e-4
layer.2324036.42.148e-25.859e-32.971e-3266.681.526e-41.335e-4
layer.2423997.72.148e-25.859e-34.213e-3275.531.221e-41.678e-4
layer.2523999.32.148e-25.859e-35.353e-3327.991.068e-41.526e-4
layer.2623991.92.148e-25.859e-35.080e-3353.211.526e-41.831e-4
layer.2723799.12.148e-25.859e-34.830e-3467.711.297e-41.831e-4
layer.288308.91.953e-22.930e-32.634e-3583.211.450e-41.221e-4
layer.292982.81.465e-24.883e-42.173e-3559.834.578e-43.204e-4
norm28.54.387e-53.338e-54.128e-524.794.387e-53.338e-5
logits20.62.980e-53.529e-55.561e-5

最后一行就是 A3 的数:CPU 2.98e-5、Metal 3.53e-5(怎么读这一卷)。中间的绝对误差却到了 2.1e-2,是 logits 容差 1e-4 的两百倍。这不说明中间搬错了,要看量级。

误差跟着残差流的量级走。 隐藏状态的最大 |ref| 从第 0 层的 30 涨到第 11 到 27 层的 2.4 万。f32 有 24 位有效位,2.4 万附近相邻两个数差 0.00195;2.148e-2 是 11 个这样的间隔,相对误差 8.9e-7。把每层的最大差除以最大 |ref|,CPU 最大 4.91e-6(第 29 层),Metal 最大 1.71e-6(logits),30 层里没有一层超过 5e-6。

那个 2.4 万在一个固定的位置。 每一层最大的 |ref| 和最大的差都出现在第 0 个 token、第 507 个通道(diff@(0,507) ref@(0,507))。第 1 层它已经是 176,第 11 层一下跳到 24136,一直保持到第 27 层,最后两层降下来。去掉第 0 个 token,剩下 4 个 token 的量级从 21 涨到第 28 层的 583,CPU 的差从 1.9e-5 涨到 4.6e-4,相对误差同样在 1e-6 上下。

最后的 norm 把量级拿掉了。 最终的 RMSNorm 按每个 token 自己的均方根缩放,第 0 个 token 的大值被除掉,norm 的最大 |ref| 回到 28.5,差回到 4.4e-5;logits 是它和 lm_head 的点积,差 2.98e-5。

PyTorch 自己的 f32 离 f64 也是这个量级。 「PyTorch f32 对 f64」那一列:同一份 transformers 代码,只换 dtype,差 1.7e-5 到 5.4e-3。candle 和 transformers 之间的差,是两份 f32 实现各自的舍入之差,不是哪一边算错。只在第 0 个 token 的第 507 个通道上,candle CPU 离 f64 更远(第 11 层 1.98e-2 对 PyTorch 的 1.74e-3),相对误差 8e-7,仍在 f32 的范围里。

69 个 token 的 prompt 结论相同:第 0 个 token 是同一个词 The,因果掩码下它只看自己,所以第 11 到 27 层的最大 |ref| 还是 24136,CPU / Metal 的差还是 2.148e-2 / 5.859e-3。去掉第 0 个 token 后,第 2 到 7 层的最大 |ref| 也有 612(差最大的位置在第 20 个 token 的第 507 个通道);CPU 的差最大 7.9e-4(第 29 层,量级 837),相对误差最大仍是 4.91e-6。最后 norm 差 4.10e-5(CPU)/ 4.29e-5(Metal),logits(最大 |ref| 27.4)差 4.58e-5 / 3.43e-5。

单层误差和累积误差

上面每一层的输入是 candle 自己上一层的输出,误差是一路累积下来的。另一种比法是把 transformers 的上一层输出直接喂给 candle 的这一层(类似 teacher forcing),只量这一层自己引入的误差:

for (i, layer) in model.layers.iter().enumerate() {
  let prev = if i == 0 { "emb".to_string() } else { format!("layer.{}", i - 1) };
  let x = r32.get(&prev).context("prev")?.to_device(&dev)?;
  let y = layer.forward(&x, &cos, &sin, &mask)?;
  // compare y with r32["layer.{i}"]
}
name        max|ref|        local  accumulated
layer.0       30.573     1.907e-5     1.907e-5
layer.1      176.030     1.068e-4     2.594e-4
layer.2      685.403     1.221e-4     1.831e-4
layer.3      682.961     3.815e-6     1.831e-4
...
layer.10      93.998     1.335e-5     1.450e-4
layer.11   24135.963     1.758e-2     2.148e-2
layer.12   24125.762     1.526e-5     2.148e-2
...
layer.27   23799.068     3.052e-5     2.148e-2
layer.28    8308.926     2.930e-3     1.953e-2
layer.29    2982.804     6.104e-4     1.465e-2

(CPU,5 个 token。Metal 上第 11 层的单层误差是 1.95e-3,累积 5.86e-3。)

累积误差里最大的「台阶」是某一层自己造出来的:第 11 层单独就有 1.76e-2,正是它把第 507 个通道从 94 推到 24136;之后各层的单层误差回到 1e-5 量级,那个 2.148e-2 只是被残差连接原样带下去,第 11 到 27 层的累积误差一位都没变。误差没有逐层放大。这和 SmolLM2 的 pre-norm 结构一致:每层先归一化再算注意力和 MLP,只往残差流上加一个增量。

反过来验:故意搬错

一个只会显示「对上了」的比较等于没有比较。在抄的那份前向里埋两个真实的搬运错误:

  • BUG=eps:rms_norm_eps 写成 1e-6(config.json 是 1e-5)。
  • BUG=rope_i:RoPE 用 rope_i(相邻两维配对,GGUF 里的写法)代替 rope(前后两半配对,Hugging Face 格式)。两种配对见 candle 卷的常用层。
let (q, k) = if std::env::var("BUG").as_deref() == Ok("rope_i") {
  (candle_nn::rotary_emb::rope_i(&q, cos, sin)?, candle_nn::rotary_emb::rope_i(&k, cos, sin)?)
} else {
  (candle_nn::rotary_emb::rope(&q, cos, sin)?, candle_nn::rotary_emb::rope(&k, cos, sin)?)
};
name        max|ref|    cd-pt32  pt32-pt64    cd-pt64       rel
layer.0       30.573   1.256e-2   1.651e-5   1.256e-2   4.11e-4  ...
logits        20.595   1.433e-2   5.561e-5   1.437e-2   6.96e-4
name        max|ref|    cd-pt32  pt32-pt64    cd-pt64       rel
layer.0       30.573   8.982e-1   1.651e-5   8.982e-1   2.94e-2  ...
logits        20.595    7.890e0   5.561e-5    7.890e0   3.83e-1

(上面是 eps,下面是 rope_i,都是 CPU、5 个 token。)两种错误都在第 0 层就露出来:相对误差 4.11e-4 和 2.94e-2,而正确的版本 30 层里最大 4.91e-6。eps 的错只让 logits 差 1.43e-2,看最后一行也能发现(超出 1e-4),但看不出是哪里;逐层表直接指向第 0 层,再往下就是这一层里的 RMSNorm 或注意力。按相对误差 1e-5 划线,正确的四次运行(两个 prompt × 两个设备)全在线下,两个错误版本在第 0 层就越线。

❗IMPORTANT
动脑筋:rope_i 那个错误版本里,第 0 个 token 的隐藏状态和正确版本差多少?第 29 层呢?

提示:第 0 个位置的 cos 和 sin 是多少;因果掩码下第 0 个 token 能看到谁。

动脑筋答案

rope_i 版本按 token 拆开看(tok1.. 是第 1 到 4 个 token,tok0 是第 0 个):

layer.0       30.573   8.982e-1   1.651e-5   8.982e-1   2.94e-2  diff@(3,247) ref@(0,247)  tok1..: 8.982e-1 / 21.45  tok0: 9.537e-6
layer.1      176.030    4.538e0   5.801e-4    4.538e0   2.58e-2  diff@(4,507) ref@(0,507)  tok1..: 4.538e0 / 45.28  tok0: 2.594e-4
layer.29    2982.804    1.373e2   2.173e-3    1.373e2   4.60e-2  diff@(4,17) ref@(0,507)  tok1..: 1.373e2 / 559.83  tok0: 1.465e-2

正确版本的同一列:

layer.0       30.573   1.907e-5   1.651e-5   1.884e-5   6.24e-7  diff@(2,421) ref@(0,247)  tok1..: 1.907e-5 / 21.45  tok0: 9.537e-6
layer.29    2982.804   1.465e-2   2.173e-3   1.415e-2   4.91e-6  diff@(0,507) ref@(0,507)  tok1..: 4.578e-4 / 559.83  tok0: 1.465e-2
  • 第 0 个 token 在错误版本里和 transformers 的差(第 0 层 9.537e-6、第 29 层 1.465e-2)和正确版本完全一样:这个 token 根本没受影响。
  • 原因:位置 0 的旋转角是 0,cos = 1、sin = 0,不管哪两维配对,旋转都是恒等变换。因果掩码下第 0 个 token 只看自己,它的 q、k、v 都不经过错误的配对,30 层一路下来都一样。
  • 错误只在第 1 到 4 个 token 上,第 0 层就差 0.898。所以只用一个 token 测前向,或者只看第 0 个位置,RoPE 配对写反是查不出来的;逐层对数要用多于一个 token 的输入,看所有位置。

小结

  • PyTorch 用 register_forward_hook 拿每层输出;transformers 的 output_hidden_states 有 31 个,[0] 是词嵌入,最后一个是过了最终 norm 的值,不是第 29 层的输出。
  • candle 没有 hook。把 candle_transformers::models::llama 的前向抄进自己的 crate,循环里把每层输出 push 进 Vec;先确认抄的这份和库的 logits 差 0e0。纯 Module 串起来的模型可以用 Sequential::forward_all。
  • SmolLM2 f32 的 30 层:绝对误差随残差流的量级从 1.9e-5 涨到 2.1e-2(CPU)/ 5.9e-3(Metal),最大值都在第 0 个 token 的第 507 个通道(量级 2.4 万);相对误差全程不超过 4.91e-6,PyTorch 自己的 f32 离 f64 也是这个量级;最终 norm 后回到 4.4e-5,logits 2.98e-5。
  • 喂参考输入量单层误差:台阶来自个别层(第 11 层 1.76e-2),之后被残差原样带下去,不逐层放大。
  • 故意把 eps 写成 1e-6 或 RoPE 配对写反,第 0 层的相对误差就到 4.11e-4 / 2.94e-2。按相对误差 1e-5 划线能分开对错;输入要多于一个 token,第 0 个位置测不出 RoPE 的错。

💬 关联讨论

发起讨论
加载中...