Rust in Everything
基于 candle 0.11.0

整数张量配浮点数:掩码全变成 0

主线位置:推理主线的「前向」一步。把分词器给的 0/1 注意力掩码变成加到分数上的 -10000。

版本:PyTorch 2.15.0(CPU 和 MPS)、candle 0.11.0(CPU,accelerate;Metal)
flowchart TB
  m["U32 掩码 1 1 0"] --> sub["1.0 减掩码"]
  sub -->|CPU| c1["1 1 1"]
  sub -->|Metal| m1["0 0 1"]
  c1 --> mul["乘 -10000"]
  m1 --> mul
  mul --> zero["0 0 0"]
  zero --> bad["填充位没被遮"]
  m -->|"先转 F32"| ok["0 0 -10000"]

本页的对照:PyTorch 侧是实验代码里的 p04.py,candle 侧是实验 crate pita-t 的 p04_intfloat(features metal accelerate,release)。平台 M2 Max、macOS 27、rustc 1.99.0。

PyTorch 的写法

BERT 系模型把 0/1 掩码变成加性掩码。transformers 5.22.0 的 modeling_markuplm.py 里还是这样写的:

extended_attention_mask = attention_mask.unsqueeze(1).unsqueeze(2)
extended_attention_mask = extended_attention_mask.to(dtype=self.dtype)
extended_attention_mask = (1.0 - extended_attention_mask) * -10000.0

中间那句 .to(dtype=...) 在 PyTorch 里删掉也得到同样的值,所以手写时常常省掉:

m = torch.tensor([[1, 1, 0]])          # attention_mask from the tokenizer, int64
(1.0 - m) * -10000.0
a = torch.tensor([-7, 7])
a * 0.5; a + 0.5
cpu mask torch.int64 (1.0 - m) * -10000.0 torch.float32 [[-0.0, -0.0, -10000.0]]
cpu a * 0.5 [-3.5, 3.5] a + 0.5 [-6.5, 7.5]
mps mask torch.int64 (1.0 - m) * -10000.0 torch.float32 [[-0.0, -0.0, -10000.0]]
mps a * 0.5 [-3.5, 3.5] a + 0.5 [-6.5, 7.5]

整数张量和 Python 的 float 运算,结果提升成 float32,两个设备相同。

搬到 candle 的现象

Rust 的 tokenizers 给的掩码是 Vec<u32>。candle 给 f64 - Tensor 实现了运算符,所以这一行能照抄、能编译:

let mask = Tensor::new(&[[1u32, 1, 0]], &dev)?;
1.0 - &mask;
(1.0 - &mask)? * -10000.0;
((1.0 - &mask)? * -10000.0)?.to_dtype(DType::F32)?;
(1.0 - &mask.to_dtype(DType::I64)?)? * -10000.0;
&a * 0.5; &a + 0.5;                                  // a: I64 [-7, 7]
&Tensor::new(&[3u32, 5], &dev)? - 1.0;
== cpu
u32 1.0 - &mask                    U32 [1, 3] [1, 1, 1]
u32 ((1.0 - &mask)? * -1e4)        U32 [1, 3] [0, 0, 0]
  .to_dtype(F32)                   F32 [1, 3] [0.0, 0.0, 0.0]
i64 ((1.0 - &m)? * -1e4)           I64 [1, 3] [0, 0, -10000]
i64 a * 0.5                        I64 [2] [0, 0]
i64 a + 0.5                        I64 [2] [-7, 7]
u32 [3, 5] - 1.0                   U32 [2] [3, 5]
== metal
u32 1.0 - &mask                    U32 [1, 3] [0, 0, 1]
u32 ((1.0 - &mask)? * -1e4)        U32 [1, 3] [0, 0, 0]
  .to_dtype(F32)                   F32 [1, 3] [0.0, 0.0, 0.0]
i64 ((1.0 - &m)? * -1e4)           I64 [1, 3] [0, 0, -10000]
i64 a * 0.5                        I64 [2] [-3, 3]
i64 a + 0.5                        I64 [2] [-6, 7]
u32 [3, 5] - 1.0                   U32 [2] [2, 4]

U32 掩码最后在两个设备上都是 [0, 0, 0]:加到注意力分数上什么也不做,填充位照样参与注意力,没有任何报错。中间一步两个设备还不一样(CPU 的 1.0 - m 是 [1, 1, 1],Metal 是 [0, 0, 1])。I64 掩码碰巧对了,因为 -1 和 -10000 都能用 i64 表示。下面三行(a * 0.5、a + 0.5、u32 - 1.0)在 CPU 和 Metal 上各是一套结果,都和 PyTorch 不同。

原因

1.0 - &mask 是 impl Sub<&Tensor> for f64,展开成 mask.affine(-1., 1.0);* -10000.0 是 bin_trait! 生成的 affine(-10000., 0.)。结果的 dtype 永远是输入的 dtype(candle 没有类型提升),两个后端算 affine 的方式不同:

  • CPU 的 Affine 先用 T::from_f64 把系数转成元素类型。对 u32,-1.0 as u32 和 -10000.0 as u32 都饱和成 0,于是第一步是 0·m + 1,第二步是 0·x + 0。
  • Metal 的 affine_kernel 在 float 里算 fma(float(input[i]), mul, add) 再转回元素类型。第一步正确得到 [0, 0, 1];第二步算出 -10000.0,转成 uint 时本页实测得到 0。

i64 * 0.5 在 CPU 上是 0、在 Metal 上是 -3,u32 - 1.0 在 CPU 上不变,都是同一个机制,整数运算的「整数张量和浮点标量」一节逐行分析过 Affine 和 affine.metal,包括 Metal 上超过 2²⁴ 的整数丢精度。origin/main 上这几处都没有改。

改法

掩码在参与任何浮点运算之前转成模型的 dtype。candle-transformers 自己的 BERT 就是这个顺序:get_extended_attention_mask 先 attention_mask.to_dtype(dtype)?,再算 ones_like - mask 乘 f32::MIN。

(1.0 - &mask.to_dtype(DType::F32)?)? * -10000.0;
a.to_dtype(DType::F32)? * 0.5;
(1.0 - &mask.to_dtype(F32)?)? * -1e4 F32 [1, 3] [0.0, 0.0, -10000.0]
a.to_dtype(F32) * 0.5              F32 [2] [-3.5, 3.5]

CPU 和 Metal 上相同,和 PyTorch 一致(PyTorch 那边打印 -0.0 是因为 0 × -10000 在 IEEE 里带符号,加到分数上没有区别)。要整数结果的场合,用整数张量做运算数(&a + &a.ones_like()?),不要用浮点标量。

怎么检查

  • 搜代码里整数张量后面紧跟的 + 1.0、* 0.5、1.0 -、/ 2.0、.affine(:candle 不会因为右边是浮点数就把结果变成浮点,结果 dtype 看左边。
  • 掩码、位置、长度这些整数量,在离开分词器的地方就决定好 dtype:要和分数相加的,立刻 to_dtype 成模型的 dtype。
  • CPU 和 Metal 各跑一次同一段前向,比一比结果。两边不同,往往就是整数张量配了浮点系数。

小结

  • candle 的 整数张量 op f64 走 affine,结果仍是整数;CPU 先把系数转成整数(-1.0 as u32 是 0),Metal 在 float 里算再截断,两边结果不同,都和 PyTorch 不同。
  • BERT 的 (1.0 - mask) * -10000.0 照搬到 U32 掩码,两个设备都得到全 0,掩码失效且不报错。
  • 改法:先 mask.to_dtype(F32)(或模型 dtype)再做浮点运算。

💬 关联讨论

发起讨论
加载中...