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 / transformers | candle |
|---|---|
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。