设了种子结果还是不一样
主线位置:两处。求导主线的起点:
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 上同样不可设种子。
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 位。