有一段时间我被一个现象折磨得不轻:同一份训练代码,连超参数都没动,下午跑出来的模型和早上跑的模型,准确率能差上两个点。排查到最后,问题根源锁定在所有随机数的源头——torch.manual_seed() 这个函数上。准确地说,是代码里压根没设置随机种子。这个问题在深度学习项目里太常见了:看教程时别人总是顺手写一行torch.manual_seed(42),初学者往往直接复制,却不知道这行代码到底在干什么、为什么能影响最终结果、以及只写这一行够不够。
我今天想把 torch.manual_seed() 这件事彻底讲透。内容包括它的底层机制、和 CUDA / NumPy / DataLoader 的关系、一套可以直接抄走的完整复现配置,以及我实际项目中踩过的那些“种子明明设了还是复现不了”的坑。这篇文章适合刚入门被随机性搞糊涂的读者,也适合写论文、做模型追踪、被线上模型波动困扰的老手,我会尽量把每个“为什么”都说明白。
1. 一次训练两次结果不同的根因:随机性藏在哪里
很多同学第一次遇到训练结果对不上时,第一反应是“代码是不是被改了”或者“数据是不是有问题”。其实大部分时候,代码和数据都没问题,问题出在神经网络训练过程本身天生就是一个“喝了酒爬山”的过程——每一步往哪走,都带随机性。如果连这些随机数的起点都没固定,那两次训练就是两次独立的探索,结果不同才是正常的。
1.1 权重初始化、shuffle、dropout 与数据增强的随机消耗
先数一下训练流程里到底有哪些环节在消耗随机数。最容易被忽略的是权重初始化。你用nn.Linear(128, 64)建一层网络,默认初始化不是固定值,而是从某个均匀分布或者凯明分布里采样出来的。采样用的就是随机数发生器,第一次运行和第二次运行,只要随机状态不同,初始化出来的权重就不同。别小看这个差异,它等于给整个网络设置了一个不同的起点。
第二个常用随机点是DataLoader 的 shuffle。每次迭代,DataLoader 都要把数据集重新打乱,生成一个新的 batch 组成顺序。打乱操作靠的是torch.randperm,它同样消耗随机数。shuffle 顺序不同,每个 batch 里装的是哪些样本就不同,梯度更新的轨迹自然完全不一样。
第三个是dropout。训练时 dropout 每次前向传播都会生成一个新的 mask,随机掐掉一部分神经元。mask 的生成由随机数发生器决定,两次训练里被掐掉的位置完全不同,模型学到的东西也就不会一样。
第四个是数据增强。翻转、裁剪、加噪、色彩抖动……这些增强操作几乎都依赖随机采样。同一张图,第一次训练被裁剪的位置和第二次可能完全不同,模型看到的训练样本在像素级别上就是“两套数据”。如果你用了外部库做增强,比如某个图像处理库的增强管线,它背后可能走的是 Python 的random模块或者 NumPy 的随机数生成器,这就牵扯到后面要讲的“多套种子都要设”的问题。
1.2 微小偏差是如何被训练过程放大成结果差异的
有人可能觉得:初始化差一点点没关系吧,反正训练会收敛到差不多的地方。这个想法在理论上部分成立,但实际中你会看到完全不同的结果。原因在于深度学习的训练过程有很强的误差放大效应。
我常用的一个比喻是打台球。开球时球杆偏移零点几毫米,球撞到库边反弹后的路线就完全变了,几次碰撞之后,整个桌面的局面跟你原本设想的截然不同。神经网络训练就是这样:初始权重差一点 → 前向输出差一点 → 损失对权重的梯度差一点 → 参数更新方向差一点 → 下一轮的差异更大。训练几十万个 step 之后,两个模型可能落在完全不同的局部最优点上。
所以在源头把随机状态钉死,是保证实验可复现的第一步。这也是 torch.manual_seed() 存在的意义:它把 PyTorch 自己的随机数发生器重置到一个确定的初始状态,让同一份代码在每次运行时,从头到尾生成的随机数序列完全一致。
2. torch.manual_seed() 的机制拆解:CPU、CUDA 与 Generator 的协作关系
这一节我们深入函数内部。很多人只知道torch.manual_seed(42)能固定随机数,但说不清楚它到底“固定”了什么,也不明白为什么有时候设了它,结果还是不稳定。
2.1 入口函数到底重置了谁的随机状态
PyTorch 的随机数体系不是一个整体,而是分成多个独立生成器的:CPU 上有一个默认生成器,每块 GPU 上也有自己的生成器,每个生成器维护一套独立的状态。你调用torch.manual_seed(seed),本质上是把这个 seed 作为初始值,重置 CPU 默认生成器的内部状态,并让后续所有使用该生成器的操作都按一个固定序列输出随机数。
举个例子,同样的两行代码:
import torch torch.manual_seed(0) a = torch.randn(2, 3) b = torch.randn(2, 3) torch.manual_seed(0) c = torch.randn(2, 3) d = torch.randn(2, 3) print(torch.equal(a, c), torch.equal(b, d)) # True True因为每次运行前都重新设置了同样的种子,a和c是同一个随机序列的第一个输出,b和d是第二个输出,所以完全相等。如果中间少调用一次manual_seed,第二次运行生成的c和第一次的a就对不上了——因为生成器的状态已经前进了好几步。注意,CPU 端的默认生成器和 CUDA 端的生成器底层实现并不相同,前者在主流版本里走的是一套通用生成算法,后者用的是另一套并行随机算法,但它们都遵循“相同种子 → 相同序列”的原则。
2.2 manual_seed_all 老兵是否还有必要
很多老教程会写这样一段:
torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)有人就问:torch.manual_seed()不是已经把 CPU 和 CUDA 都管了吗,为什么还要多写一行?这里有个容易被误解的细节。在常用版本的 PyTorch 实现里,torch.manual_seed()内部确实会联动设置 CUDA 的默认生成器,所以单卡环境下你只调它问题也不大。但torch.cuda.manual_seed_all()的语义更明确:它显式地把当前所有 CUDA 设备的默认生成器都设置一遍,覆盖多卡场景。我个人的建议是:不要依赖隐式联动,显式写出来。
理由有两个。第一,你的代码可能在老版本 PyTorch 上跑,也可能在别人精简过的环境里跑,隐式行为在不同版本之间有细微差异,显式调用能把意图锁死。第二,代码是给人看的。别人读到torch.cuda.manual_seed_all(seed)这一行,立刻就知道你考虑了多卡环境,而不是靠“某个版本里的默认行为”碰运气。多写一行不亏,少写一行可能埋雷。
2.3 Generator 对象:从全局暗号到显式随机流
torch.manual_seed()除了设置状态,还会返回一个torch.Generator对象,只不过大多数人没接住这个返回值。Generator 可以理解成一条独立的随机数流水线,你可以自己创建多条:
g = torch.Generator() g.manual_seed(2024) x = torch.randn(4, 4, generator=g)这样做的好处是隔离。全局默认生成器是被所有模块共享的,你在一个地方调用了随机操作,全局状态就前进了;如果你希望某一路随机流完全不干扰全局,就给它单独配一个 Generator。在 DataLoader 的 shuffle 场景里,这个用法特别有用,后面会详细讲。
3. 一套可直接抄走的完整复现配置:种子、DataLoader 与 cuDNN
只写一行torch.manual_seed(42)就指望完全复现,是不够的。因为 PyTorch 之外还有 NumPy、Python 内置的random模块,它们各自维护着独立的随机状态。很多第三方库在实现数据增强、采样策略时用的是它们,你要是只锁 PyTorch,这些库生成的随机数照样飘。
3.1 覆盖 Python、NumPy 与 PyTorch 的 set_seed 模板
我在项目里一直用的是下面这个模板,你可以直接复制:
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False把这个函数放在训练脚本入口处,所有 import 之后、任何模型定义和数据加载之前调用。random.seed(seed)锁住 Python 内置随机模块,np.random.seed(seed)锁住 NumPy,它们分别对应不同的库依赖。后面两行是 cuDNN 的配置,作用下一节单独讲。
我见过不少人把set_seed放在所有 import 之前,这是不对的。PyTorch 在 import 过程中就有可能有随机初始化行为,应该先完成 import,再设置种子。另外,如果代码里用了第三方库自带的随机采样器,比如某些自然语言处理的采样策略,最好确认一下它底层用的哪套随机源,然后针对性地设种子。
3.2 DataLoader 多进程下的随机序列怎么管
DataLoader 是复现配置里最容易被忽视的环节。默认情况下,DataLoader 的 shuffle 用的是全局默认生成器,通过torch.randperm生成索引顺序。你设置了全局种子之后,每次从头启动训练,shuffle 顺序是可以复现的。但一旦num_workers大于 0,事情就变复杂了:每个 worker 进程都会分配一个独立的随机数状态,如果这个状态没有被稳定地控制,不同运行之间 worker 内部的数据顺序就会有差异。
更稳妥的做法是给 DataLoader 显式传入一个 Generator:
g = torch.Generator() g.manual_seed(0) train_loader = DataLoader( dataset, batch_size=64, shuffle=True, num_workers=4, generator=g, )这样 shuffle 的随机序列由g这条独立流水线控制,不跟模型训练里的全局随机数抢状态。还有个细节:如果想保证每个 worker 里的增强顺序也稳定,可以配合worker_init_fn:
def worker_init_fn(worker_id): worker_seed = torch.initial_seed() % 2**32 np.random.seed(worker_seed) random.seed(worker_seed) train_loader = DataLoader( dataset, batch_size=64, shuffle=True, num_workers=4, generator=g, worker_init_fn=worker_init_fn, )torch.initial_seed()返回的是当前进程 PyTorch 生成器的初始种子,worker 进程会根据它派生出自己的种子,我们再用这个派生种子把 NumPy 和 Python 的随机状态对齐。这套组合拳打下来,DataLoader 侧的随机性就基本可控了。
3.3 cuDNN 的确定性开关与性能代价
很多人设完种子后发现,loss 曲线还是对不上,问题往往出在 cuDNN。深度学习的卷积在 GPU 上有多种算法实现,cuDNN 默认会做一个快速基准测试,选一个当前输入形状下最快的算法。torch.backends.cudnn.benchmark = True时这个基准测试会在运行时执行,而基准测试的结果在不同运行环境下可能不同,导致同一份代码在两次运行里选择了不同的卷积算法,数值结果就有细微差异。
所以做严格复现时,要关掉 benchmark,打开 deterministic:
torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = Falsedeterministic = True会让 cuDNN 优先选择确定性算法,代价是可能变慢。我在实际测试中见过训练速度下降 10% 到 30% 的情况,这可以理解成“为了可复现付出的性能税”。如果你只是训练阶段需要对齐结果,建议开;如果是长期大规模生产训练,性能也是刚需,那就要评估一下复现需求到底多严格。至少在调试和写论文的阶段,这个开关值得开着。
4. 设了种子结果还是抖:一次从 loss 曲线开始的完整排查
配置都做了,种子也设了,结果还是对不上,这是最让人崩溃的。我遇到这种情况时,会按一套固定链路排查,而不是瞎试。
4.1 先确认你要的是哪种“复现”
第一步不是翻代码,而是定义清楚你要什么级别的复现。我把它分成三层:
- 逐位复现:两次运行的所有张量数值完全一致,包括小数点最后一位。这是最严格的要求,论文实验和某些需要精确对比的场景才需要。
- 统计复现:两次运行的 loss 曲线趋势一致,最终指标在正常波动范围内接近。大部分工程场景只需要到这一层。
- 结论复现:实验结果指向同一个结论,比如哪个模型更好、哪个超参更优,结论不因随机性翻转。这其实是绝大多数业务场景的真实需求。
如果你只是想做结论复现,那随机种子没设齐可能根本不重要,因为模型性能对初始化通常不敏感。但如果你在逐位复现的执念里,那就必须把本文提到的所有开关全部打开,并且接受非确定性算子的存在。先想清楚目标,能省下大量无谓的排查时间。
4.2 五个排查入口的具体操作
确定要做严格复现后,按照下面的顺序排查:
第一,检查种子设置代码的位置。它必须在所有随机操作之前,包括任何 PyTorch 模型的实例化和任何 DataLoader 的创建。写在一个独立模块里并被 import 也很危险,因为 import 的顺序可能改变执行顺序。
第二,确认 cuDNN 两个开关确实生效。光在代码里写了还不够,我见过有人在训练脚本里写了 deterministic,但实验脚本又通过其他方式把 benchmark 打开了,配置覆盖关系非常隐蔽。
第三,检查 DataLoader 是否传入了独立 Generator,以及worker_init_fn是否真的被调用。多进程环境下,worker 内部的状态重置常常被遗漏。
第四,检查是否引入了不可确定算法。直接在入口处加一行:
torch.use_deterministic_algorithms(True)这个开关是个大杀器。它会强制 PyTorch 使用确定性实现,一旦代码路径里出现非确定性算子,就立刻抛RuntimeError,把问题暴露出来。虽然有时代码会跑不起来,但这恰恰是在帮你定位问题所在。
第五,多进程和多卡环境下检查每个进程的种子是否互相独立。如果所有进程都用同一个seed,它们的初始化虽然一致,但数据增强和 shuffle 也完全一致,这在分布式训练里不仅影响多样性,而且一旦需要跨进程对比日志,你很难看出谁是谁。
4.3 常见的非确定性算子与大杀器开关
CUDA 上有一些算子天然具有非确定性。原因是 GPU 上大量线程并行执行同一个操作时,计算完成顺序不固定,而浮点运算不满足结合律,顺序不同结果就有细微差别。比如index_add_、index_put_、scatter_add_这类涉及原子加法的操作,在不同运行里最终浮点结果可能在最后几位上不同。还有部分规约操作,因为并行归约树的形状差异,也会产生类似问题。
| 常见问题源 | 现象 | 处理办法 |
|---|---|---|
| CUDA 上的 index_add_ / scatter_add_ | 多次运行结果在低位浮点上不一致 | 改用确定性实现或移到 CPU 计算 |
| 卷积算法选择 | benchmark 选了不同算法 | deterministic=True 同时 benchmark=False |
| DataLoader 多进程 | 每个 worker 随机状态不受控 | 传 generator 并设置 worker_init_fn |
| 多卡数据并行 | 各 rank 数据顺序撞车 | 用 rank 参与种子生成 |
| 恢复 checkpoint 后继续训练 | 随机状态和原轨迹脱节 | 把 RNG 状态一并保存和恢复 |
这个表格基本覆盖了我遇到过的绝大部分情况。你只要在代码里加上torch.use_deterministic_algorithms(True),表里前两类问题基本都会以报错的形式直接暴露出来,剩下的就靠对种子和生成器的控制了。
5. 真实项目里反复踩过的复现细节与沉淀下来的习惯
技术原理讲得再多,都不如真踩一次坑记得牢。下面这几个是我在真实项目里遇到过的具体问题,它们看起来都很“蠢”,但每一个都花了我不少时间。
5.1 断点续训时随机状态没有跟着恢复
有一次训练任务跑到一半,因为集群要维护,我需要从 checkpoint 恢复继续训练。我把模型权重和 optimizer 状态都保存了,结果续训之后 loss 曲线跟“如果没中断”的预期轨迹完全对不上。排查了很久才发现:我只保存了模型和优化器,没保存随机数发生器的状态。
训练过程的随机序列是连续演进的,中断意味着随机状态断了,续训时生成器从新的状态继续,dropout mask、shuffle 顺序全都变了。解决方法是把 RNG 状态也序列化保存:
# 保存时 checkpoint = { 'model': model.state_dict(), 'optimizer': optimizer.state_dict(), 'rng_state': torch.get_rng_state(), 'cuda_rng_states': torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None, 'np_state': np.random.get_state(), 'py_state': random.getstate(), } # 恢复时 torch.set_rng_state(checkpoint['rng_state']) if checkpoint['cuda_rng_states'] is not None: for i, state in enumerate(checkpoint['cuda_rng_states']): torch.cuda.set_rng_state(state, i) np.random.set_state(checkpoint['np_state']) random.setstate(checkpoint['py_state'])这么一来,续训前后的随机序列就到同一个位置了。注意如果用了 DataLoader 的独立 Generator,也要把这个 Generator 的状态保存下来,方法是用g.get_state()和g.set_state()。
5.2 多卡并行里各进程的种子写成了一个数
另一个让我哭笑不得的坑是:我在分布式训练里调用了set_seed(42),结果四个进程全都用同一个种子。看起来没什么问题,但数据增强变成四份一模一样的流水线,每个 batch 在各个卡上看到的是同一批增强后的图片。这虽然不影响单卡基准对比,但影响了数据多样性,也让我在对比不同 rank 的日志时产生过困惑。
正确的做法是让每个 rank 派生自己的种子:
rank = dist.get_rank() set_seed(base_seed + rank)base_seed + rank是最简单的方案。要更讲究一点,可以用base_seed * world_size + rank,避免某些情况下不同配置之间种子冲突。这样既保证了全局可复现,又让每张卡的随机流相互独立。
5.3 调试阶段先把 shuffle 关掉
还有一条经验是:一旦你需要盯着一组样本排查问题,比如查看某个 batch 的输入输出、核对数据预处理逻辑,先把shuffle=True关掉,或者固定一个极小的数据集。否则你每次调试看到的数据顺序都不一样,很容易把代码 bug 误判成数据问题。
我自己有个很笨但很有效的做法:写一个小的调试脚本,用同样的种子跑同一个 batch,把输入 tensor 的哈希值打印出来,对比两次运行是否一致。如果这个都不一致,说明随机控制还有漏洞,根本没有必要去观察模型权重。
5.4 我现在固定执行的三个检查习惯
经过这些坑之后,我沉淀出了三个固定习惯,每次训练任务启动前都会过一遍:
第一,所有实验统一走set_seed()封装函数,不允许在业务代码里散落调用。这样种子逻辑只维护一份,也不会出现“这里设了那里又覆盖”的问题。
第二,在实验配置里记录种子和环境信息。训练日志里把 seed、PyTorch 版本、CUDA 版本、cuDNN 版本写清楚,方便几个月后回看结果时还原现场。
第三,关键实验提交前,跑一次确定性自检:固定种子、固定 batch、固定 worker 数,跑两个 step,对比模型输出是否完全一致。不一致就直接定位,不要带着隐患进入长时间训练。
最后再分享一个小技巧:排查随机性问题时,先把各种开关全部打开,包括torch.use_deterministic_algorithms(True)、deterministic=True、benchmark=False,把所有能暴露的非确定性都暴露出来,等代码链路稳定且运行无误后,再根据性能需求逐个关掉。这样既不会一开始就被报错淹没,也能在性能与可复现之间找到平衡。随机种子这件事,看着简单,但真正把每个环节都控制住,靠的是对 PyTorch 随机体系这套底层机制的理解,而不是盲目复制一行代码。