1. 复现实验是基本功:一行 seed 背后解决的“薛定谔的模型”
1.1 从一次“跑三遍结果各不同”的深夜说起
我印象最深的一次踩坑发生在两年前。那天晚上十一点,一个分类模型跑完最后一个 epoch,validation loss 是 0.312,F1 刷到了 0.8847。我随手把数字记在本子上,关电脑回家。第二天早上想把这个结果复现出来做消融对比,于是原封不动地拉回代码、用同样的超参数、同样的机器重新启动。结果第一轮跑出来 0.3098,第二轮 0.3215,第三轮又变成 0.3102。同一份代码,同一个环境,跑出来的指标像开盲盒。
当时我第一反应是“是不是显卡坏了”,第二反应是“是不是数据读错了”,折腾了大半天才意识到,问题出在我压根没对随机数做任何约束。模型权重初始化是随机的、DataLoader 的 shuffle 是随机的、dropout 的 mask 是随机的,连某些 cudnn 算子选哪个算法都是随机的。这一连串随机源叠加在一起,模型最终长什么样自然每次都不一样。
torch.manual_seed() 就是用来解决这个问题的第一道闸门。但注意,它只是“第一道”。很多人以为写了这一行就等于给实验上了保险,实际上它只管住了 PyTorch 自己的默认随机数生成器,numpy、Python 的 random、cudnn 的算法选择、DataLoader 多进程 worker 的内部随机状态,它都管不着。这篇内容我就从原理到实操,把跟它相关的整套随机性控制方案一次讲清楚。适合刚入坑深度学习、开始做实验对比的新手,也适合那些被“复现不了结果”折磨过、想彻底搞明白随机性从哪来的同学。
1.2 训练流程里到底藏着多少随机源
很多新手理解不了为什么“同样的代码”结果不同,因为他们潜意识里把“代码”等同于“确定性过程”。但实际上,一次神经网络训练从头到尾能拆出至少四五处随机源,每一处都在消费随机数序列。
首先是模型权重初始化。nn.Linear、nn.Conv2d 在创建时会调用 PyTorch 内部的 uniform / normal 分布采样,这一步最容易感知,因为变量初始化变了,整个模型的优化轨迹就全变了。
其次是数据顺序。DataLoader(shuffle=True) 会在每个 epoch 开始前打乱样本索引。看似只是影响“先看哪张图”,但实际上梯度更新的顺序变了,优化器走过的路径就完全不同。这也是为什么小 batch 下复现比大 batch 更难。
第三是模型内部的随机层。Dropout 每个前向传播都会生成一个 0/1 掩码,只要随机序列不同,每次 forward 的输出都不一样。BatchNorm 本身不随机,但它依赖统计量,如果前面数据顺序变了,它的均值和方差也跟着变。
第四是数据增强。随机翻转、随机裁剪、颜色抖动,这些算子可能用的不是 PyTorch 的 RNG,而是 numpy 或者 Python 自带的 random 库。很多人只在代码开头写了 torch.manual_seed(42),完全没碰 numpy,数据增强这一环就是彻底“裸奔”的。
最后还有一层是新手通常意识不到的:GPU 算子执行的随机性。cudnn 在卷积计算时可能从多个算法中选一个,不同算法在数值上会有微小差异;如果你想用 CPU 做归约,多线程的计算顺序也会影响最后几位小数。这一层不归种子管,需要单独用开关去固定。
理解了这些随机源,你再看“复现”这件事,就会发现它本质上是一个“把训练过程中每一个随机源头都接管过来”的工程问题。torch.manual_seed() 是接管第一处也是最关键一处的手段,但它不是唯一手段。
1.3 复现到底解决了什么问题
复现不是学术洁癖,它直接关乎工作效率。我做消融实验时,如果 A 方案跑到 0.88,B 方案跑到 0.86,但这两次跑之间随机性带来的波动本身就可能有 0.5 个点,那我根本没法判断到底哪个方案有效。只有把随机性固定下来,跑出来的差异才真正来自模型改动。
还有个很实际的应用是线上问题排查。模型在测试集上表现异常时,如果能用同一个 seed 把训练过程复现一遍,你就能精确地回放“数据在哪个 step、哪个 batch 被喂给了模型”,逐层检查前向输出的分布,而不是对着已经训完的权重猜来猜去。
另外,如果你写过需要提交给社区的开源代码,别人 clone 下来跑不出论文里的数字,第一件事就是查你有没有给 seed。这是“代码可用性”的一部分,跟代码本身能不能跑通同样重要。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 拆开种子机制:PyTorch 的随机数生成器到底是怎么工作的
2.1 manual_seed 做了什么
torch.manual_seed(seed) 做的事情说起来很简单:把 PyTorch 在当前设备上的默认随机数生成器重新设置到一个确定的状态。之后你再调用 torch.rand、torch.randn、torch.randint,以及 nn.init 里带随机性的初始化方法时,它们消费的就是这个生成器吐出的序列。
这里有个容易被忽略的细节:PyTorch 里随机数生成器是一个叫 torch.Generator 的对象。每个生成器内部维护一个独立的随机状态,你可以同时创建多个生成器,各玩各的。PyTorch 在每个进程中替你维护了一个默认的 Generator,而 torch.manual_seed() 操作的就是它。这个函数会返回一个 torch.Generator 对象,指向被重新设置过的默认生成器。
如果你想知道刚才设进去的种子是什么,可以调 torch.initial_seed() 拿回来。如果你想知道当前生成器的“实时状态”,可以用 torch.get_rng_state() 拿到一个字节串,之后用 torch.set_rng_state() 把状态恢复回去。这在做“训练到一半保存随机状态以便续跑”这种需求时非常有用。
python复制import torch
torch.manual_seed(42)
# 查看刚才设置的种子
print(torch.initial_seed()) # 42
# 拿到当前 RNG 状态,保存起来
state = torch.get_rng_state()
# 生成一段随机数
a = torch.rand(3)
# 恢复 RNG 状态
torch.set_rng_state(state)
# 再生成一次,结果和 a 完全一致
b = torch.rand(3)
print(torch.equal(a, b)) # True
注意,torch.manual_seed() 不是“把随机状态清零”,而是“把随机状态设置到某个特定初始位置”。在同一个脚本里,如果设完 seed 后消费了若干随机数,再回头重新设同一个 seed,之后的序列会和第一次设 seed 之后完全一致。这个特性正好被用来做“分段式复现”——在关键节点重置 seed,保证从该节点开始的行为可重复。
2.2 CPU 和 CUDA 用的是完全不同的随机算法
很多人以为设了 torch.manual_seed(42),CPU 上的随机序列和 GPU 上的随机序列应该是一样的,因为“都来自同一个种子”。实际上不是这样。PyTorch 在 CPU 上用的默认生成器是基于 MT19937(也就是 C++ 标准库里常见的梅森旋转算法),而在 CUDA 设备上用的是 Philox4_32_10 这类并行随机数生成算法。两者的数学结构完全不同,即使初始种子相同,吐出的随机序列也完全是两套。
这意味着两件事。
第一,不要在代码里假设“CPU 上先算出来的随机数”和“GPU 上算出来的随机数”有一一对应关系。如果有某个随机变量既需要在 CPU 上生成一份、又需要在 GPU 上生成一份,你需要分别保存和指定生成器,而不是指望同一个 seed 两边自动对齐。
第二,CUDA 上的随机序列还跟 GPU 架构、PyTorch 版本相关。Philox 算法虽然本身是确定的,但 PyTorch 在实现并行采样时,每个线程块分配到的随机数子区间、采样顺序可能随架构调整而改变。所以在不同的 GPU 型号上,同一种子并不保证生成同样的序列。这解释了为什么你在 2080Ti 上能复现的结果,换到 A100 上就复现不了——不是你的代码错了,是随机数生成的底层实现变了。
也正是因为这个原因,PyTorch 官方对“跨版本、跨设备完全复现”一直持保守态度。你能做的是在固定的环境里实现完全复现,跨环境的复现只能尽量逼近,不能保证逐位一致。
2.3 torch、numpy、Python random 是三个互不相通的随机世界
这是新手最容易踩、也最隐蔽的坑。在同一个 Python 进程里,torch.manual_seed() 只会影响 torch 自己的生成器;numpy 的全局随机状态在 np.random 模块里;Python 标准库 random 模块又有自己的一套 Mersenne Twister 状态。三者互不相干,你设了 torch 的种子,numpy 和 random 依然在“自由飞翔”。
举个具体场景。你用 torchvision 的 transforms 做数据增强,其中 RandomResizedCrop 在较新版本里用的虽然是 PyTorch 的张量操作,但如果你自己写 Dataset,或者在 transform 里混用了 PIL.Image 的旋转、OpenCV 的仿射变换,就很容易在某个角落调用了 np.random 或 random.random。这部分随机性完全不受 torch 种子控制。
所以稍微规范一点的复现模板,从来不会只写一行 torch.manual_seed,而是 torch、numpy、random 三个一起设。这也是我下面要讲的核心代码模板的第一条原则。
3. 正确打开方式:一套经过验证的 seed 设置代码模板
3.1 单机单卡:先这样把“主随机源”全部锁死
我把一个经过多次验证、可以直接抄走的 set_seed 函数放在下面。注释里我写了每一行在干什么,以及为什么要写。
python复制import torch
import numpy as np
import random
def set_seed(seed: int = 42):
# 1. torch CPU 默认生成器
torch.manual_seed(seed)
# 2. torch 所有 CUDA 设备的生成器(显式设置一次更保险)
torch.cuda.manual_seed_all(seed)
# 3. numpy 全局随机状态
np.random.seed(seed)
# 4. Python 标准库 random 模块
random.seed(seed)
# 5. cudnn 卷积算法选择固定为确定性算法
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
关于第 2 步多说一句。较新版本的 PyTorch 里,调用 torch.manual_seed() 本身就会同时设置 CPU 和所有 CUDA 设备的默认生成器,所以严格来说第 2 步是“冗余”的。但我在老版本代码里吃过亏,那时候 torch.manual_seed() 只管 CPU,CUDA 上必须额外调用 torch.cuda.manual_seed() 或 torch.cuda.manual_seed_all()。为了避免在同事的老环境上翻车,显式写一行成本很低,收益很稳。
第 5 步要单独解释。cudnn 在计算卷积时,会根据输入尺寸、显存带宽等因素,在一批候选算法里选一个最快或者最省显存的。benchmark = True 意味着允许它“根据输入情况动态选算法”,这本身就是一种随机源——同一个模型,如果输入 shape 在训练中途发生变化,可能是算法 A,换一次又变成算法 B。deterministic = True 强制 cudnn 走确定性算法,benchmark = False 关闭动态搜索。两者配合,才算把 GPU 计算这一层的随机性按住。
写完 set_seed 之后,在训练脚本最开头调用一次就够了。注意不要在每个 epoch 里反复调用,否则会破坏随机序列的连续性,反而让模型退化成“每轮都在用同一套初始化重训”。
3.2 多卡 DDP:每个进程的种子分配策略
单卡场景下 set_seed(42) 一把梭没问题,但到了多卡分布式训练,事情就没那么简单了。你启动 N 个进程,如果每个进程都机械地调用 set_seed(42),就会出现下面这个问题:所有 rank 的模型初始权重相同,所有 rank 的 DataLoader shuffle 序列也相同(尽管 DDP 会给每个 rank 分配不同的数据子集,但子集内部的数据顺序模式是一样的)。模型权重相同其实没问题,因为分布式训练本来就要保证模型初始状态统一;但数据顺序、数据增强完全一致就有问题了——每个 rank 处理的数据太过同质化,相当于多卡训练的“多样性”被削弱了。
标准做法是让每个 rank 的种子错开。常见方案是在基础 seed 上加上 rank 编号:
python复制import torch
import torch.distributed as dist
import numpy as np
import random
def set_seed_for_ddp(base_seed: int = 42):
rank = dist.get_rank() if dist.is_initialized() else 0
seed = base_seed + rank
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
np.random.seed(seed)
random.seed(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
这样每个 rank 的 PyTorch 和 numpy 随机状态都不同,数据增强自然不同,而模型初始化可以通过固定 torch 种子统一。如果你的框架里初始化模型发生在 seed 设置之后,且只希望模型初始化完全一致,那么你可以拆开处理:先 torch.manual_seed(base_seed) 完成模型构建,再在创建 DataLoader 前把 seed 改成 base_seed + rank。这种精细控制方式在需要“初始化一致、数据增强分叉”的场景下非常实用。
3.3 DataLoader worker 的种子:worker_init_fn 不是可选项
这是“设了 seed 但结果还是随机”的最大嫌疑之一。当你给 DataLoader(num_workers=4) 开启多进程数据加载时,PyTorch 会把每个 worker 的 torch 随机数生成器重设到一个从主进程派生出来的种子。也就是说,每个 worker 内部的 torch.rand 序列是可控的,不会出现四个 worker 拿到同一套随机数的惨剧。
但 numpy 和 random 呢?PyTorch 不会替你去设。如果 Dataset 的 __getitem__ 里用了 np.random 做数据增强,或者用了 random.choice 选负样本,那么这些操作在 worker 进程里用的还是“继承自主进程的随机状态”,因为没有重新设置。而多进程 fork 之后,子进程的随机状态和父进程在 fork 那一刻是相同的。这意味着如果不处理,你所有 worker 里的 numpy 增强可能产生完全相同的模式,或者表现得像“主进程没设 seed”一样无法复现。
解决方式是给 DataLoader 传一个 worker_init_fn:
python复制import torch
import numpy as np
import random
def worker_init_fn(worker_id):
# 每个 worker 拿到一个从 torch 当前种子推导出来的唯一值
worker_seed = torch.initial_seed() % 2**32
np.random.seed(worker_seed)
random.seed(worker_seed)
dataloader = torch.utils.data.DataLoader(
dataset,
batch_size=32,
shuffle=True,
num_workers=4,
worker_init_fn=worker_init_fn,
)
这里的逻辑是:torch.initial_seed() 返回的是当前进程(也就是 worker)的 torch 初始种子,PyTorch 已经为每个 worker 派生好了不同的值。我们把它取出来,再赋给 numpy 和 random,于是三个随机库在同一个 worker 内部就都对齐了。这个函数虽然叫“可选的”,但只要你的数据 pipeline 用了 numpy 或 Python random,它就不是可选项,是必需品。
3.4 配合 cudnn.deterministic 与 benchmark 开关,把随机性锁死
我在 3.1 里已经把两个开关写进了 set_seed,这里单独拆出来讲清楚它们的边界,因为很多人误解“设了这两个就完全确定”。
torch.backends.cudnn.deterministic = True 只影响 cuDNN 的卷积、池化等操作。它选择确定性算法之后,计算速度和显存占用通常比非确定性算法差一些,这是复现的代价。但对大多数模型来说,影响在可接受范围,不需要过度担心。
torch.backends.cudnn.benchmark = False 是告诉 cuDNN 不要做运行时基准测试。如果 benchmark = True,cuDNN 会针对第一次遇到的输入 shape 跑一轮微基准,选个最快的算法;之后如果输入 shape 变了,它会再重新测。这个“测试-选择”过程在相同环境下通常是稳定的,但跨机器或者跨 CUDA 版本时就可能出现算法选择差异。
还有一个更严格的开关:torch.use_deterministic_algorithms(True)。调用之后,PyTorch 会在遇到“没有确定性实现”的操作时直接抛错,帮你把隐藏的随机性挖出来,而不是静默地用一个不确定的实现。在 PyTorch 2.x 里,torch.set_deterministic(True) 这种老写法已经不推荐了,统一用 use_deterministic_algorithms。调试阶段可以加上它跑一遍,排掉所有不支持确定性运算的算子,正式训练时再考虑要不要关掉换性能。
提醒一句:use_deterministic_algorithms 对某些操作(比如一些 CUDA 上的 scatter 类操作)会直接报错,你需要在“完全确定性”和“功能可用性”之间做取舍。我通常的做法是:先开它跑一遍验证全流程,确认没有非确定性算子之后关掉,靠 deterministic + benchmark=False 保证常规训练的稳定复现。
4. 我踩过的 seed 失效坑与完整排查过程
4.1 现象一:seed 设了,loss 第二次跑就对不上
我最早写复现代码时,set_seed 里只写了 torch.manual_seed(42),没有管 numpy。当时训练一个图像分类模型,第二次跑的时候 loss 从第一个 step 就出现肉眼可见的差异,大概在第三位小数开始分叉,到第几十个 step 就完全不一样了。
排查过程是这样的:我先确认模型权重初始化是否一致。在第一次 forward 之前,把模型的第一个 conv 层的权重 print 出来,对比两次运行,发现完全一致。这说明 torch 的随机序列被正确控制了,问题不在模型初始化。
然后我用 torch.save 把第一个 batch 的输入存下来对比,发现输入数据本身一致(当时还没做数据增强)。但是 loss 第一次反向之后梯度就不同了。于是怀疑问题出在 dropout 或者 BatchNorm 之外更早的地方。最终定位到 Dataset 的 __getitem__ 里有一段用 np.random 做数据噪声注入的代码——它不在我第一次的对比范围内。
教训很清楚:复现不完整时,先确认所有随机库都被锁死。不要只盯着 torch,numpy 和 random 很可能在数据管线里偷摸影响结果。
4.2 现象二:单卡好使,一上多卡就“薛定谔”
另一个我印象很深的案例是在单卡上跑得好好的,一切换到多卡 DDP,同样的 seed 设置,每次运行结果都不同。单卡上明明已经能精确复现了,为什么多卡不行?
先排查了每个进程的 seed,发现所有 rank 都调用的是同一个 set_seed(42)。前面说了,这会导致 worker 之间的 numpy 随机状态高度相似。更麻烦的是,我用 DDP 后每个 rank 的数据子集虽然靠 DistributedSampler 做了切分,但抽样顺序在不同运行里并不稳定——这其实和采样器的随机状态有关。Debug 时我在 worker_init_fn 里改用 torch.initial_seed() % 2**32 给 numpy 设种子,再把 rank 偏移加进去,问题解决了大半。
还剩最后一处差异,是因为我把 torch.backends.cudnn.benchmark 留成了默认值 True。多卡环境下,每个 rank 的 GPU 负载不同,cuDNN 的基准测试可能跑出不同结果,导致同一个算子在两个 rank 上选择了不同算法。把 benchmark 关掉之后,多卡终于和单卡一样稳定了。这个案例给我的教训是:多卡环境里 check 的顺序应该是“seed -> worker seed -> cudnn 开关”,三步缺一不可。
4.3 现象三:换了 PyTorch 小版本,历史实验无法复现
还有一个“不是 bug 但比 bug 还烦”的情况。项目从 PyTorch 1.10 升到 1.13,或者从 2.0 升到 2.1,老实验无论如何都复现不回原来的指标。我最初怀疑是自己代码里有什么隐式随机,花了一整天逐个排查,最后在 PyTorch release note 里看到一句话:随机数生成器的实现细节、某些算子的内部算法可能在不同版本间变化。
这是官方都不承诺完全复现的边界。PyTorch 的随机数引擎保证了“在同一环境、同一版本、同一设备”下的复现,但不保证跨版本一致。你设了同一颗种子,版本升级后 MT19937 的消费顺序、Philox 的参数都可能变化。
对此我的建议是:把环境当代码一样管理。训练脚本对应的依赖版本要记录在案,用 requirements.txt 或者 conda env.yaml 锁住;重要的实验最好在 Docker 里跑,镜像的 tag 就是环境指纹。一旦跑出了关键指标,记录下来的不只是参数,还有 torch 版本、CUDA 版本、显卡型号。跨版本复现做不到 100%,但至少能保证你换机器后能在同等环境里找回结果。
4.4 排查链路:把每个随机源逐个固定
如果你也遇到了“seed 设了但结果不稳定”,按下面这个表一项一项排查,比瞎猜快得多。这也是我现在遇到复现问题时的标准检查链路。
| 随机源 | 由什么控制 | 如果没固定会怎样 | 检查方式 |
|---|---|---|---|
| torch CPU 随机数 | torch.manual_seed() |
初始化、dropout、采样全变 | torch.initial_seed() 是否一致 |
| CUDA 随机数 | torch.cuda.manual_seed_all() |
GPU 上的随机采样不可复现 | torch.cuda.get_rng_state_all() |
| numpy 随机数 | np.random.seed() |
数据增强、预处理中有 numpy 就会变 | 在数据管线入口 print np.random.get_state() |
| Python random | random.seed() |
用到 random.choice、random.shuffle 时会变 |
检查代码里 random. 调用 |
| DataLoader worker | worker_init_fn |
worker 内 numpy/random 状态失控 | 两次运行对比第一个 batch 是否相同 |
| cuDNN 算法选择 | deterministic = True、benchmark = False |
卷积算法不同,数值微小差异累积 | 固定后对比两次运行逐层输出 |
| 并行归约顺序 | 线程数、CPU 调度 | 多线程求和顺序不同导致尾数差异 | 必要时 torch.set_num_threads(1) |
这个表的前三项对应的是“主随机源”,第四项经常被忽略,第五项只在多进程数据加载时出现,第六项是 GPU 上最容易漏的。如果按这个链路排查完,两次运行的前向输出还是不一致,那就得考虑是不是某个第三方库内部用了自己的随机状态,这种情况需要去翻库的源码或者 issue。
5. 进阶玩法:Generator 对象与部分随机化控制
5.1 当你只想固定一部分随机性:给指定操作传 generator
torch.manual_seed() 操作的是全局默认生成器,它的特点是“简单粗暴”——一旦设置,影响范围是整个进程里的所有随机操作。但实际工作中常常只需要固定某一个环节,其他环节保持随机。比如你可能想让数据增强保持随机(增加多样性),但让某个生成对抗网络的噪声向量可以被重复生成;或者你在做对比实验,想让模型初始化固定,但 DataLoader 的 shuffle 顺序每跑一次换一次。
这种“部分固定”的需求,靠全局 seed 是做不到的,需要用 torch.Generator 显式控制。Generator 可以手动创建、手动设种子、传给特定操作:
python复制import torch
# 创建一个独立的 CPU 生成器
g1 = torch.Generator()
g1.manual_seed(123)
# 创建一个 CUDA 生成器
g2 = torch.Generator(device='cuda')
g2.manual_seed(456)
# 只让这些操作使用 g1 / g2
a = torch.randn(4, 4, generator=g1)
b = torch.randn(4, 4, generator=g2)
DataLoader 也支持传 generator。最典型的应用是:你希望数据 shuffle 的顺序固定,但又不想因为 shuffle 操作消耗全局 RNG 的随机数,从而影响模型初始化和其他随机操作。
python复制g = torch.Generator()
g.manual_seed(2024)
dataloader = torch.utils.data.DataLoader(
dataset,
batch_size=32,
shuffle=True,
generator=g,
)
这样固定下来的是“打乱算法用的随机状态”,全局默认生成器完全不受影响。你可以在同一个脚本里,用多个 Generator 分别管数据顺序、噪声注入、模型初始化,彼此之间互不干扰。这是可复现性设计里更进阶、也更工程化的做法。
5.2 数据增强与噪音注入:哪些该随机,哪些该固定
用 Generator 做部分随机化的时候,核心问题是搞清楚“哪些环节需要固定,哪些环节需要随机”。这个没有标准答案,取决于实验目的。
如果是精确复现已跑完的实验,那所有随机源都要固定,包括数据增强。做法是把 worker_init_fn 里的 numpy 随机状态设成从 torch 种子派生,同时让 DataLoader 使用一个专门的 generator,从源头上控制 shuffle。
如果是跑基准测试、调超参数,那数据增强其实应该保持随机,不然每个 seed 跑出来的数据轨迹完全一样,你测不出超参在“不同数据随机性”下的平均表现。这也是为什么很多论文里会跑 3~5 个 seed,取平均值和方差——他们追求的不是单次复现,而是统计意义上的稳定性。
如果是生成对抗网络、扩散模型这类需要注入固定噪声向量的场景,建议给噪声单独建一个 Generator,专门负责采样。这样即使你为了数据增强改变全局 seed,也不会影响预设好的噪声序列,生成结果和真实图片的对应关系不会被破坏。
5.3 完全确定性训练的限制:不是所有算子都支持
最后给你泼一盆冷水:即使种子设置全部到位,也不代表所有模型都能实现完全确定性训练。torch.use_deterministic_algorithms(True) 会在你用到某些没有确定性实现的操作时直接抛异常。常见的“问题操作”包括一些 CUDA 上的原子性累加、某些 scatter/gather 操作、部分插值算法,以及自定义的 CUDA kernel。
在 PyTorch 官方文档里有一个“Deterministic Algorithms 不支持列表”,里面明确列了哪些后端、哪些操作不支持确定性模式。如果你用的模型或算子恰好落在列表里,你有三个选择:换用支持确定性的等价实现(比如把某个自定义算子改成原生算子组合)、接受这一层微小随机性、或者换到 CPU 上运行(但性能损失很大)。
另外要提醒一点,即使所有的随机源都固定了,“完全确定”也只在固定 batch size、固定输入 shape的前提下成立。训练中途如果因为某个 batch 不满导致输入 shape 变化,某些算子的执行路径可能随之变化,数值又会对不上。有些团队专门做了按长度动态 padding,保证每个 batch 的 shape 都一致,就是为了把这一层变量也按住。
我自己在实际项目里的折中方案是:训练阶段要求梯度可以精确复现,但允许前向推理时的算子级微小误差;每次出实验报告时,把主 seed、PyTorch 版本、GPU 型号、cudnn 开关状态全写进实验记录。这样既保证了绝大部分工作可以复现,又没有被“逐位一致”这个目标拖死。
回到文章开头那个深夜,现在我已经不会因为“复现不出来”而怀疑自己的代码了。每次新建实验脚本,第一行就先写 set_seed();每次换环境,第一件事就是记录版本;每次跑完关键实验,顺手把 seed 写进日志文件名。这些习惯养成了之后,“薛定谔的模型”基本不会再出现。如果你正在被复现问题折磨,按这篇里的链路一项项排查,大概率能找回你想要的那个数字。
