Rust in Everything
基于 candle-core 0.11.0

设了种子结果还是不一样

主线位置:两处。求导主线的起点:VarMap 按初始化方式建 Var 时调 Tensor::randn / rand,用的是设备的随机数发生器(见优化器与训练循环)。推理主线的终点:LogitsProcessor 取样,大多数取法用自己的 StdRng,GumbelSoftmax 用设备的(见取样)。

flowchart TB
  s["set_seed"] --> d{"设备"}
  d -->|CPU| e["返回 Err"]
  e --> cpu["randn 用 rand::rng 每次不同"]
  d -->|Metal| m{"种子小于 2 的 32 次方"}
  m -->|是| ig["种子被忽略"]
  m -->|否| ok["种子生效"]
  fix["主机 StdRng 生成"] --> rep["每次相同"]

实验 crate exp-candle/pa-pit,bin p08_seed,release,同一个二进制连续运行两次;M2 Max、macOS 27、rustc 1.99.0。

现象

let cpu = Device::Cpu;
println!("cpu set_seed(42): {:?}", cpu.set_seed(42).map_err(|e| e.to_string()));
println!("cpu randn: {:?}", draw(&cpu)?);
for s in [42u64, 7, 42 << 32, 7 << 32] {
  let metal = Device::new_metal(0)?;
  metal.set_seed(s)?;
  println!("metal set_seed({s:>12}): {:?}", draw(&metal)?);
}

draw 是 Tensor::randn(0f32, 1.0, 3, dev) 读回、保留四位小数。之后对同一组 8 个 logits 用 LogitsProcessor::from_sampling(42, ..) 连抽 12 次。两次运行:

### run 1 (release)
cpu set_seed(42): Err("cannot seed the CPU rng with set_seed")
cpu randn: [-0.4815, 0.4884, 1.8643]
metal set_seed(          42): [1.5174, -0.6226, -1.2535]
metal set_seed(           7): [1.5174, -0.6226, -1.2535]
metal set_seed(180388626432): [0.3498, -0.2846, 0.5578]
metal set_seed( 30064771072): [-0.1066, -1.3167, 1.9307]
...
All { temperature: 1.0 }, seed 42, cpu: [1, 4, 1, 4, 6, 5, 7, 3, 7, 0, 4, 3]
GumbelSoftmax { temperature: 1.0 }, seed 42, cpu: [3, 2, 7, 7, 3, 7, 1, 6, 0, 5, 2, 4]
### run 2 (release)
cpu set_seed(42): Err("cannot seed the CPU rng with set_seed")
cpu randn: [-0.8059, -0.2521, -0.6599]
metal set_seed(          42): [1.5174, -0.6226, -1.2535]
metal set_seed(           7): [1.5174, -0.6226, -1.2535]
metal set_seed(180388626432): [0.3498, -0.2846, 0.5578]
metal set_seed( 30064771072): [-0.1066, -1.3167, 1.9307]
...
All { temperature: 1.0 }, seed 42, cpu: [1, 4, 1, 4, 6, 5, 7, 3, 7, 0, 4, 3]
GumbelSoftmax { temperature: 1.0 }, seed 42, cpu: [2, 5, 0, 6, 1, 0, 0, 1, 1, 7, 2, 7]

(180388626432 是 42 << 32,30064771072 是 7 << 32。)

  • CPU:set_seed 返回错误。代码里写 let _ = dev.set_seed(42); 或 .ok() 吞掉它的话,randn 每次运行都不同,初始化的权重也就每次不同。
  • Metal:种子 42 和 7 给出同一串数,两次运行也相同;只有大于等于 2³² 的种子才不同。看起来可复现,其实种子没起作用,换种子也换不出另一组初始化。
  • 取样:All 两次相同,GumbelSoftmax 同样设了种子 42,两次不同。

出处

CPU:CpuDevice::set_seed 只有一句 crate::bail!("cannot seed the CPU rng with set_seed");rand_normal 和 rand_uniform 每次调用都取 rand::rng()(线程本地、由系统熵初始化的发生器)。CPU 后端没有任何保存种子的地方。

Metal:MetalDevice::set_seed 把 u64 按小端序写进一个 8 字节的缓冲区,random.metal 读的时候却把前 4 个字节当高 32 位,小于 2³² 的种子在内核里变成 seed << 32,内核把它乘上常数后只留低 32 位,结果和种子无关。逐行的分析在取样的「Metal 的 set_seed」一节。每个新设备的初始种子是 299792458,也小于 2³²,所以「不设种子」也落在同一串上。这是 0.11.0 的上游 bug。

取样:LogitsProcessor 的 All、TopK、TopP 用自己的 StdRng::seed_from_u64(seed);GumbelSoftmax 走 candle_nn::sampling::gumbel_softmax,噪声来自 logits.rand_like(..),也就是设备的发生器,在 CPU 上同样不可设种子。

📝NOTE
冷知识:CPU 的 set_seed 生来就是一句报错

set_seed 是 PR #1056(「Make the cuda rng seedable.」)加进 BackendDevice trait 的。之前 BackendDevice 没有设种子的方法(那时还没有 Metal 后端);这个 PR 的目标是 CUDA,cuda_backend.rs 多了 6 行,锁住 self.curand 调它的 set_seed。trait 方法要求每个后端都实现,cpu_backend.rs 新增的 4 行就是现在这个 bail!("cannot seed the CPU rng with set_seed")。到 0.11.0,CPU 这一支没有变过。这个提交早于本地仓库最早的 tag,git tag --contains 只能查到 0.5.1。

修法

CPU:在主机上用带种子的发生器生成,再建张量。 加上 rand = "0.9"、rand_distr = "0.5"(candle-core 0.11.0 依赖的版本):

use rand::{rngs::StdRng, SeedableRng};
use rand_distr::{Distribution, StandardNormal};

fn seeded_randn(seed: u64, n: usize, dev: &Device) -> candle_core::Result<Tensor> {
  let mut rng = StdRng::seed_from_u64(seed);
  let v: Vec<f32> = (0..n).map(|_| StandardNormal.sample(&mut rng)).collect();
  Tensor::from_vec(v, n, dev)
}
### run 1 (release)
seeded_randn(42) cpu: [0.0694, 0.1329, 0.2626]
### run 2 (release)
seeded_randn(42) cpu: [0.0694, 0.1329, 0.2626]

对模型参数,可以先用 VarMap 建好结构,再逐个 var.set(&seeded_randn(..)?.reshape(var.shape())?)? 覆盖;或者在第一次运行后 varmap.save(..),之后 varmap.load(..)。在主机上生成的数据 to_device 到 Metal 也一样可复现,顺带绕开了 Metal 的问题。

Metal:种子放在高 32 位。 0.11.0 上 set_seed(s << 32) 能让不同的 s 给出不同的序列(上面的 42 << 32、7 << 32 两行),两次运行也相同。这依赖于一个 bug 的具体形态,升级 candle 前要重新验证。

取样:要可复现就别用 GumbelSoftmax。 All、TopK、TopP、TopKThenTopP 只依赖 LogitsProcessor 的种子;同一个种子、同一个模型和输入,两次生成逐 token 相同(取样一页有实测)。

怎么确认没中招

  • 代码里所有 set_seed 的返回值都用 ? 处理;CPU 上它会让程序直接停下,而不是假装设好了。
  • 同一个程序连跑两次,打印初始化后某个参数的前几个值和第 0 步的 loss,两次必须相同;再换一个种子跑,必须不同。只做第一个检查,发现不了 Metal 的问题。
  • 生成结果要复现时,检查 Sampling 是不是 GumbelSoftmax。

小结

  • CPU 的 set_seed 返回错误,randn / rand 每次运行都不同。
  • Metal 的 set_seed 对小于 2³² 的种子无效(0.11.0 的上游 bug),看起来可复现是因为每个新设备从同一状态开始。
  • LogitsProcessor 的种子管不到 GumbelSoftmax。
  • 要可复现:在主机上用 StdRng::seed_from_u64 生成再 from_vec;Metal 上种子放高 32 位。

💬 关联讨论

发起讨论
加载中...