彻底搞懂 torch.manual_seed:从随机种子原理到完整复现配置指南 有一段时间我被一个现象折磨得不轻同一份训练代码连超参数都没动下午跑出来的模型和早上跑的模型准确率能差上两个点。排查到最后问题根源锁定在所有随机数的源头——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, generatorg)这样做的好处是隔离。全局默认生成器是被所有模块共享的你在一个地方调用了随机操作全局状态就前进了如果你希望某一路随机流完全不干扰全局就给它单独配一个 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(seed42): 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 显式传入一个 Generatorg torch.Generator() g.manual_seed(0) train_loader DataLoader( dataset, batch_size64, shuffleTrue, num_workers4, generatorg, )这样 shuffle 的随机序列由g这条独立流水线控制不跟模型训练里的全局随机数抢状态。还有个细节如果想保证每个 worker 里的增强顺序也稳定可以配合worker_init_fndef 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_size64, shuffleTrue, num_workers4, generatorg, worker_init_fnworker_init_fn, )torch.initial_seed()返回的是当前进程 PyTorch 生成器的初始种子worker 进程会根据它派生出自己的种子我们再用这个派生种子把 NumPy 和 Python 的随机状态对齐。这套组合拳打下来DataLoader 侧的随机性就基本可控了。3.3 cuDNN 的确定性开关与性能代价很多人设完种子后发现loss 曲线还是对不上问题往往出在 cuDNN。深度学习的卷积在 GPU 上有多种算法实现cuDNN 默认会做一个快速基准测试选一个当前输入形状下最快的算法。torch.backends.cudnn.benchmark True时这个基准测试会在运行时执行而基准测试的结果在不同运行环境下可能不同导致同一份代码在两次运行里选择了不同的卷积算法数值结果就有细微差异。所以做严格复现时要关掉 benchmark打开 deterministictorch.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 选了不同算法deterministicTrue 同时 benchmarkFalseDataLoader 多进程每个 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 的输入输出、核对数据预处理逻辑先把shuffleTrue关掉或者固定一个极小的数据集。否则你每次调试看到的数据顺序都不一样很容易把代码 bug 误判成数据问题。我自己有个很笨但很有效的做法写一个小的调试脚本用同样的种子跑同一个 batch把输入 tensor 的哈希值打印出来对比两次运行是否一致。如果这个都不一致说明随机控制还有漏洞根本没有必要去观察模型权重。5.4 我现在固定执行的三个检查习惯经过这些坑之后我沉淀出了三个固定习惯每次训练任务启动前都会过一遍第一所有实验统一走set_seed()封装函数不允许在业务代码里散落调用。这样种子逻辑只维护一份也不会出现“这里设了那里又覆盖”的问题。第二在实验配置里记录种子和环境信息。训练日志里把 seed、PyTorch 版本、CUDA 版本、cuDNN 版本写清楚方便几个月后回看结果时还原现场。第三关键实验提交前跑一次确定性自检固定种子、固定 batch、固定 worker 数跑两个 step对比模型输出是否完全一致。不一致就直接定位不要带着隐患进入长时间训练。最后再分享一个小技巧排查随机性问题时先把各种开关全部打开包括torch.use_deterministic_algorithms(True)、deterministicTrue、benchmarkFalse把所有能暴露的非确定性都暴露出来等代码链路稳定且运行无误后再根据性能需求逐个关掉。这样既不会一开始就被报错淹没也能在性能与可复现之间找到平衡。随机种子这件事看着简单但真正把每个环节都控制住靠的是对 PyTorch 随机体系这套底层机制的理解而不是盲目复制一行代码。