Rust in Everything
基于 candle 0.11.0

F.gelu 搬成 x.gelu(),隐状态差 1.93e-2

主线位置:推理主线的「建模块」和「前向对齐」两步。手写 FFN 时选激活函数,再拿隐状态和 transformers 比。
版本:PyTorch 2.15.0 / transformers 5.22.0(CPU)、candle 0.11.0(CPU,accelerate;Metal)
flowchart TB
  pt["nn.GELU 默认"] --> erf["erf 精确版"]
  cg["x.gelu"] --> tanh["tanh 近似"]
  ce["x.gelu_erf"] --> erf2["erf 精确版"]
  erf --> cmp["和 transformers 比"]
  tanh --> cmp
  erf2 --> cmp
  cmp -->|tanh| bad["差 1.93e-2"]
  cmp -->|erf| ok["差 1.7e-5"]

本页的对照:PyTorch 侧是实验代码里的 p13.py 和 p13_bert_ref.py(BertModel,attn_implementation="eager"),candle 侧是实验 crate pita-t 的 p13_gelu 和 p13_bert_gelu(features metal accelerate,release),模型是本地的 bert-base-chinese 权重。平台 M2 Max、macOS 27、rustc 1.99.0。

PyTorch 的写法

x = torch.tensor([-2.694, 0.5, 2.685])
torch.nn.GELU()(x)
F.gelu(x, approximate="tanh")
torch.nn.GELU().approximate
nn.GELU()(x)               ['-0.0095098', '0.3457312', '2.6752629']
F.gelu(x, approximate=tanh) ['-0.0090366', '0.3457140', '2.6757360']
nn.GELU().approximate      'none'

nn.GELU() 和 F.gelu(x) 不传参数时是精确的 erf 版本,approximate="tanh" 才是近似版。BERT 系的 config.json 写 "hidden_act": "gelu",对应的也是 erf 版。

搬到 candle 的现象

手写 FFN 时照名字找方法:

x.gelu(); x.gelu_erf(); Activation::Gelu.forward(&x);
cpu x.gelu()               ["-0.0090366", "0.3457140", "2.6757360"]
cpu x.gelu_erf()           ["-0.0095098", "0.3457312", "2.6752629"]
cpu Activation::Gelu       ["-0.0095098", "0.3457312", "2.6752629"]
metal x.gelu()               ["-0.0090367", "0.3457140", "2.6757360"]
metal x.gelu_erf()           ["-0.0095100", "0.3457312", "2.6752627"]
metal Activation::Gelu       ["-0.0095100", "0.3457312", "2.6752627"]

x.gelu() 的三个数和 PyTorch 的 approximate="tanh" 一致,不是 nn.GELU()。单点差最大在 x ≈ ±2.7 附近,约 4.7e-4。进了 12 层的 BERT,差会放大。用 candle-transformers 的 BERT(它的 HiddenAct::GeluApproximate 就是 xs.gelu())跑两句中文,比 last_hidden_state 的非填充位置:

[Cpu] candle Gelu vs transformers gelu: max_abs_diff=1.7166138e-5 (max |ref|=12.881842)
[Cpu] candle GeluApproximate vs transformers gelu: max_abs_diff=1.9316673e-2 (max |ref|=12.881842)
[Cpu] candle GeluApproximate vs transformers gelu_pytorch_tanh: max_abs_diff=1.6212463e-5 (max |ref|=12.880053)

Metal 上三行是 1.44e-5、1.93e-2、1.81e-5。激活选对时差 1.7e-5;选成 tanh 版,差 1.93e-2,是 1e-4 容差的近两百倍,和 transformers 自己把 hidden_act 换成 gelu_pytorch_tanh 之后的差一样大(p13_bert_ref.py 打印的 tanh vs erf in torch: max 0.01931476593017578)。第三行说明 candle 的 tanh 版和 transformers 的 tanh 版是对得上的,差别全在选了哪个公式。

原因

名字是反着的。candle 的 Tensor::gelu 由 unary_op!(gelu, Gelu) 生成,Gelu 的公式是 0.5 * v * (1.0 + tanh(SQRT_TWO_OVER_PI * v * (1.0 + 0.044715 * v * v)));精确版是下一行的 gelu_erf(GeluErf)。PyTorch 那边不带参数的名字给了精确版。

按 config.json 走的路径没有这个问题:candle_nn::Activation 把 "gelu" 解析成 Gelu,它的 forward 调 gelu_erf,把 "gelu_new" 和 "gelu_pytorch_tanh" 解析成调 gelu 的两个变体,和 transformers 的含义一一对应。问题只出在照着 PyTorch 代码手写 x.gelu() 的时候。两个公式在 [-8, 8] 上的逐点对照、Activation 的来历(#969 之前 Activation::Gelu 也是 tanh 版),见常用层的「激活函数:gelu 的两个版本」。BERT 在 candle 里的结构见源码卷的 BERT。

改法

PyTorch 写法到 candle 写法的对应:

PyTorch / transformerscandle
nn.GELU()、F.gelu(x)、hidden_act: "gelu"x.gelu_erf()、Activation::Gelu
nn.GELU(approximate="tanh")、"gelu_new"、"gelu_pytorch_tanh"x.gelu()、Activation::NewGelu / GeluPytorchTanh

上面 BERT 的第一行就是改对之后的结果:差 1.7e-5(CPU)和 1.44e-5(Metal),max |ref| 12.88,在 1e-4 的容差之内;不是 0,是 12 层 f32 矩阵乘的累加顺序不同。

最省事的是不自己选:模块里存一个 candle_nn::Activation,从 config.json 的 hidden_act 反序列化出来,forward 里 xs.apply(&self.act)。

怎么检查

  • 在自己的移植代码里搜 .gelu():每一处都确认原模型用的是不是 tanh 近似。
  • 前向对齐时逐层比隐状态(逐层检查),容差按 A3 的 1e-4 量级定,并写出 max |ref|。1.93e-2 这种差在只看「生成的文字一样不一样」时可能看不出来,在数值对比里一眼就能看出来。
  • 拿不准时做一次反向验证:在 transformers 那边把 hidden_act 换成 gelu_pytorch_tanh 再比(上面第三行)。如果换了之后反而对上了,就是激活选反了。

小结

  • PyTorch 的 nn.GELU() / F.gelu(x) 是 erf 精确版;candle 的 x.gelu() 是 tanh 近似,精确版是 x.gelu_erf()。
  • 写反了不报错:单点差 4.7e-4,bert-base-chinese 的隐状态差 1.93e-2(容差 1e-4)。
  • 改法:手写用 gelu_erf,或者从 hidden_act 反序列化 candle_nn::Activation。

💬 关联讨论

发起讨论
加载中...