逐层对数
主线位置:推理主线的第三步「前向,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
问: 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| | CPU | Metal | PyTorch f32 对 f64 | 去掉第 0 个 token:最大 |ref| | CPU | Metal |
|---|---|---|---|---|---|---|---|
emb | 0.6 | 0 | 0 | 0 | 0.62 | 0 | 0 |
layer.0 | 30.6 | 1.907e-5 | 8.106e-6 | 1.651e-5 | 21.45 | 1.907e-5 | 8.106e-6 |
layer.1 | 176.0 | 2.594e-4 | 9.155e-5 | 5.801e-4 | 45.28 | 2.480e-5 | 1.526e-5 |
layer.2 | 685.4 | 1.831e-4 | 1.221e-4 | 8.470e-4 | 45.89 | 2.289e-5 | 1.526e-5 |
layer.3 | 683.0 | 1.831e-4 | 1.221e-4 | 8.649e-4 | 51.97 | 2.289e-5 | 2.289e-5 |
layer.4 | 679.9 | 1.831e-4 | 1.221e-4 | 8.508e-4 | 58.25 | 1.907e-5 | 1.526e-5 |
layer.5 | 674.1 | 1.831e-4 | 1.221e-4 | 8.834e-4 | 55.03 | 1.907e-5 | 1.335e-5 |
layer.6 | 668.5 | 1.831e-4 | 1.221e-4 | 8.532e-4 | 60.31 | 2.289e-5 | 1.234e-5 |
layer.7 | 659.2 | 1.831e-4 | 1.221e-4 | 8.991e-4 | 68.97 | 2.670e-5 | 2.289e-5 |
layer.8 | 565.2 | 1.831e-4 | 1.221e-4 | 1.040e-3 | 77.44 | 3.433e-5 | 3.052e-5 |
layer.9 | 130.1 | 3.815e-5 | 3.433e-5 | 1.455e-3 | 86.14 | 3.815e-5 | 3.433e-5 |
layer.10 | 94.0 | 1.450e-4 | 6.866e-5 | 4.441e-4 | 94.00 | 3.815e-5 | 3.052e-5 |
layer.11 | 24136.0 | 2.148e-2 | 5.859e-3 | 1.735e-3 | 102.54 | 3.815e-5 | 2.360e-5 |
layer.12 | 24125.8 | 2.148e-2 | 5.859e-3 | 1.999e-3 | 96.22 | 3.433e-5 | 2.289e-5 |
layer.13 | 24111.8 | 2.148e-2 | 5.859e-3 | 1.801e-3 | 110.48 | 3.815e-5 | 3.052e-5 |
layer.14 | 24098.0 | 2.148e-2 | 5.859e-3 | 1.460e-3 | 124.72 | 6.104e-5 | 3.052e-5 |
layer.15 | 24080.3 | 2.148e-2 | 5.859e-3 | 1.463e-3 | 125.80 | 6.104e-5 | 3.815e-5 |
layer.16 | 24064.7 | 2.148e-2 | 5.859e-3 | 1.558e-3 | 133.50 | 6.104e-5 | 3.815e-5 |
layer.17 | 24050.0 | 2.148e-2 | 5.859e-3 | 1.776e-3 | 119.07 | 6.104e-5 | 3.815e-5 |
layer.18 | 24041.6 | 2.148e-2 | 5.859e-3 | 2.815e-3 | 136.47 | 9.155e-5 | 3.815e-5 |
layer.19 | 24034.1 | 2.148e-2 | 5.859e-3 | 3.007e-3 | 167.61 | 6.104e-5 | 3.242e-5 |
layer.20 | 24032.2 | 2.148e-2 | 5.859e-3 | 3.618e-3 | 201.55 | 6.104e-5 | 3.681e-5 |
layer.21 | 24041.0 | 2.148e-2 | 5.859e-3 | 3.268e-3 | 211.02 | 7.629e-5 | 5.722e-5 |
layer.22 | 24036.4 | 2.148e-2 | 5.859e-3 | 3.955e-3 | 225.55 | 1.068e-4 | 1.030e-4 |
layer.23 | 24036.4 | 2.148e-2 | 5.859e-3 | 2.971e-3 | 266.68 | 1.526e-4 | 1.335e-4 |
layer.24 | 23997.7 | 2.148e-2 | 5.859e-3 | 4.213e-3 | 275.53 | 1.221e-4 | 1.678e-4 |
layer.25 | 23999.3 | 2.148e-2 | 5.859e-3 | 5.353e-3 | 327.99 | 1.068e-4 | 1.526e-4 |
layer.26 | 23991.9 | 2.148e-2 | 5.859e-3 | 5.080e-3 | 353.21 | 1.526e-4 | 1.831e-4 |
layer.27 | 23799.1 | 2.148e-2 | 5.859e-3 | 4.830e-3 | 467.71 | 1.297e-4 | 1.831e-4 |
layer.28 | 8308.9 | 1.953e-2 | 2.930e-3 | 2.634e-3 | 583.21 | 1.450e-4 | 1.221e-4 |
layer.29 | 2982.8 | 1.465e-2 | 4.883e-4 | 2.173e-3 | 559.83 | 4.578e-4 | 3.204e-4 |
norm | 28.5 | 4.387e-5 | 3.338e-5 | 4.128e-5 | 24.79 | 4.387e-5 | 3.338e-5 |
logits | 20.6 | 2.980e-5 | 3.529e-5 | 5.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 层就越线。
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 的错。