先说一个我自己的经历。之前跑一个图像分割实验,模型在本地机器上训到了83.4%的mIoU。两周后要在另一台机器上复现,同样的代码、同样的超参数、同样的数据集,结果只有82.1%,差了1.3个点。我当时第一反应是数据没传完整,反复比对了半天发现数据完好;又怀疑是GPU不同导致,换了卡再试还是对不上;最后一步步对比环境,才定位到问题是PyTorch从1.13.0悄悄变成了1.13.1——一个patch版本号的变化,就能在长训练流程中累积出可观的数值漂移。
那次之后我把"可复现性"当成一个系统工程来处理,不再只是跑前随手设置一个随机种子。PyTorch实验真正要做到可复现,需要同时管住三件事:随机种子的完整设定、依赖环境的彻底锁定、实验配置与代码状态的系统性归档。这篇文章就围绕这三条线展开,把我实际用过的方案、踩过的坑和验证方法完整说一遍,适合所有用PyTorch做深度学习实验、有论文复现或工程交接需求的同学参考。
1. 设了种子就能复现?先盘点随机性的五个来源
1.1 一个反直觉的事实:很多"复现失败"根本不是代码问题
大部分人理解的可复现,就是"同一个脚本跑两次,结果一样"。这句话听起来简单,但里面的隐藏条件远比想象中多。我见过很多同学在实验运行前设置了torch.manual_seed(42),然后自信满满地跑实验,等到第二次运行时结果差了一点点,就开始怀疑模型初始化、怀疑数据顺序、怀疑优化器实现,实际上问题往往出在那些"没有被接管"的随机源上。
神经网络训练中的随机性来源至少包括五类:第一,PyTorch自身算子的随机行为,比如权重初始化、Dropout、随机采样;第二,Python标准库的random模块和NumPy的numpy.random状态,很多数据增强库和数据加载工具底层依赖它们;第三,DataLoader多进程模式下的Worker进程随机状态;第四,cuDNN和cuBLAS在GPU卷积、矩阵乘等操作上的算法选择,这属于"看起来应该确定但实际上取决于运行环境"的随机性;第五,浮点数运算本身的并行归约顺序,在GPU上这个顺序往往不可控。
把这五个来源摆出来,你就能明白为什么单纯设一个torch.manual_seed几乎没把问题解决干净。下面我逐个说明它们的原理和接管方式。在复现实验中,"设了种子"只是入场券,真正的工作从确认这五个随机源都被控制住才开始。
1.2 每个随机源各自有什么脾气
先看PyTorch层。torch.manual_seed()会同时设置CPU和GPU上PyTorch算子使用的随机数生成器状态,例如用torch.rand做初始化和采样、用nn.Dropout做掩码生成,这些都归它管。但注意,它只管PyTorch自己的随机数生成器,不管Python的random模块,也不管NumPy的全局随机状态。
再看NumPy和Python标准库。数据增强库如albumentations、imgaug,甚至一部分自定义的Dataset代码,常常直接调用np.random来制造噪声或做随机裁剪。如果代码里混用了这些库而没有固定它们的种子,那么即使PyTorch层固定了,数据增强结果也会每次不同,模型看到的训练样本序列就不一致。
DataLoader的多进程是最大的暗坑。当num_workers > 0时,每个Worker进程都是从主进程fork出来的子进程,它们会继承主进程"那一刻"的随机数生成器状态。如果主进程的随机状态在每次Epoch开始时都不一致,那么各个Worker做数据Shuffle和采样时的序列也会不一致,最终造成训练数据顺序漂移,这在较长训练里会累积出明显的精度差异。
cuDNN和cuBLAS的算法选择相对隐蔽。GPU上的卷积和矩阵乘法在cuDNN和cuBLAS库中有多种实现,自动调优器会按当前硬件和库版本选择"最快的算法",而这个选择在不同GPU型号、不同cuDNN版本之间并不稳定。
最后一个来源是浮点运算顺序。GPU上的并行归约,比如LayerNorm、Softmax、批量矩阵乘中的累加,会因为线程调度顺序不同而出现微小数值差异。这种差异在单次前向后可能根本看不出来,但经过几百轮迭代会被放大。明白这一点,你就能理解为什么有些实验即使所有随机源都固定了,不同GPU上跑出的结果还是会有细微不同——这不是代码问题,是并行计算本身的特性。
2. 种子接管:把每个随机源抓到同一节奏上
2.1 一个set_seed函数该包含哪些内容
我第一次认真写随机种子的控制代码,是在排查上面那个1.3个点的复现问题时。当时我把set_seed从一个工具箱里直接拷贝过来用,结果发现问题没有解决——因为那个函数只调用了torch.manual_seed。后来我重新写了完整版本,才算真正稳住了大多数场景。
下面这段是适用于单机单卡训练的种子设定模板,也是我现在每个项目都会保留的公共函数:
import os import random import numpy as np import torch def set_seed(seed: int) -> None: random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) # 让cuDNN使用启发式算法选择,而不是跑基准测速 torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False这里有几个关键点值得说清楚。torch.manual_seed(seed)在PyTorch 1.8之后的版本里其实已经会同时设置CPU和所有GPU设备的种子,所以torch.cuda.manual_seed_all(seed)这行更多是历史遗留和保险,写上没坏处。
torch.backends.cudnn.benchmark = False是很多人容易忽略的。默认情况下这个值是True,cuDNN会针对每个卷积层跑一次基准测速并选择最快的算法。问题在于"最快"的判定结果会因显卡型号、显存布局、cuDNN版本差异而不同,在复现场景里这会引入算法层面的不确定度。把它设为False后,cuDNN使用启发式规则选择算法,虽然可能慢一点点,但选择策略更稳定。
上述代码的顺序也有讲究:先设置Python标准库,再设置NumPy,最后设置PyTorch。因为很多第三方库在导入时就会消耗随机数状态,比如某些数据增强库在import阶段会调用一次np.random,所以合理的做法是在项目入口处的第一件事就调用set_seed,越早越好。
注意:在代码内通过
os.environ['PYTHONHASHSEED'] = str(seed)设置并不会对当前Python进程起作用,因为哈希随机化在解释器启动时就已经固定了。要在命令行启动时设置:PYTHONHASHSEED=42 python train.py。这个坑我在后面单独讲。
2.2 DataLoader多进程的种子:worker_init_fn的正确用法
把set_seed放到训练脚本开头后,你以为随机源已经全部控制住了,其实还差最重要的一环:DataLoader的Worker进程。官方文档和建议里都明确提到,当num_workers > 0时,需要在DataLoader里传入worker_init_fn,在每个Worker进程中重新设置随机种子。
原因不复杂:主进程设好了全局随机状态,但fork出的每个Worker进程继承的是"fork那一刻"的随机状态快照,不同Worker之间会共享几乎相同的随机数序列。这会导致一个严重问题:多个Worker使用相同的随机增强序列,批量数据之间缺乏多样性,而且跨Epoch的行为也不稳定。更关键的是,直接从主进程继承的状态会随着主进程自身的随机消耗而改变,导致每次启动训练的Worker随机状态都不一样。
我的标准写法是在训练脚本里加一个seed_worker函数,并配合generator参数一起使用:
def seed_worker(worker_id: int) -> None: worker_seed = torch.initial_seed() % 2**32 np.random.seed(worker_seed) random.seed(worker_seed) g = torch.Generator() g.manual_seed(SEED) loader = DataLoader( dataset, batch_size=BATCH_SIZE, num_workers=NUM_WORKERS, shuffle=True, worker_init_fn=seed_worker, generator=g, )torch.initial_seed()返回的是主进程在创建Worker时使用的种子,它在每个Worker里都是不同的,因为PyTorch会基于基础种子和Worker索引生成一个独特的初始种子。这里用% 2**32是为了兼容NumPy的种子上限,虽然现代NumPy支持64位种子,但统一约束到32位更稳。
generator=g的作用是控制DataLoader内部的索引采样器用的随机数生成器。不设置它时,采样器会使用全局随机状态,然后被Worker进程中的随机消耗影响。显式传入一个固定种子的torch.Generator,可以保证每次Epoch开始时数据索引的Shuffle策略是一致的。
这里还有一个容易出错的细节:worker_init_fn里设置的是每个Worker自己的随机状态,与主进程的全局random/np.random状态完全独立。所以不要在worker_init_fn里依赖主进程已经设好的种子值,要像上面那样通过torch.initial_seed()来获取每个Worker特有的种子。
2.3 确定性模式:use_deterministic_algorithms 背后的取舍
在很多较新版本的项目里,你会发现torch.backends.cudnn.deterministic = True已经不够用了,因为PyTorch里还有一批操作在默认实现下就是不确定的,比如某些CUDA上的scatter、index_add以及原子操作。从PyTorch 1.8开始,官方引入了torch.use_deterministic_algorithms(True)这个开关,它会让PyTorch在检测到非确定性操作时直接抛出异常,而不是静默用不确定实现跑完。
我倾向于在论文复现和需要精确比较结果的实验中开启它,但要知道它带来的代价和边界。首先它可能让代码崩溃,因为有些算子目前没有确定性实现,比如某些形态的torch.nn.functional.interpolate在特定配置下会触发告警。另外它对cuDNN卷积的算法选择也有影响,相当于把cudnn.deterministic也强制设为True,所以两者一般说用use_deterministic_algorithms就足够,不需要重复设置。
但有个很实际的问题是:某些模型的训练速度会明显下降,而且下降幅度在不同显卡上不一样。我在一个注意力机制的模型上实测过,开启确定性模式后单步训练时间增加了大概15%到30%,原因是确定性算法放弃了部分并行优化。所以我的建议是分场景处理:如果是在做超参数搜索、快速验证想法,可以不开;如果要生成最终提交的结果、做消融对比或论文复现,一定要开,并且把开了确定性模式这件事记录到实验配置里。
还有一个经常踩的坑是cuBLAS的workspace配置。如果PyTorch版本较新且CUDA版本不低于10.2,要确保cuBLAS的确定性矩阵乘正确工作,需要设置环境变量CUBLAS_WORKSPACE_CONFIG=:4096:8。这个配置不设的话,即使开启了torch.use_deterministic_algorithms(True),某些batched GEMM操作依然可能表现出非确定性。我一般会在启动脚本里加上:
export CUBLAS_WORKSPACE_CONFIG=:4096:8这个变量设置成本极低,但对最终数值稳定性的贡献很大。
2.4 PYTHONHASHSEED 与第三方库的隐藏随机源
Python的哈希随机化是很多人完全没意识到的随机源。Python中字符串、字典等对象的迭代顺序,依赖字符串的哈希值;而哈希值每次解释器启动时都会随机化。这意味着,如果你的代码里有for key in dict:这种依赖字典顺序的逻辑,或者用集合存储了类别标签、路径等字符串,那么不同次运行之间,数据的组织顺序可能不同。
解决方法是启动环境变量PYTHONHASHSEED。前面的代码块里写了在Python内部设置无效,必须在解释器启动前设置。在Linux或WSL环境下,启动训练命令时这样写:
PYTHONHASHSEED=42 python train.py如果用的是Windows原生环境(不推荐在Windows下跑PyTorch训练),可以在系统的环境变量里加,或者在运行命令前用临时环境变量设置。
第三方增强库也有自己的随机状态管理。albumentations有自己的replay机制和全局随机状态,imgaug则维护了一套独立的random.Random实例。比较稳妥的做法是:尽量让第三方库直接使用np.random作为底层随机源(很多增强库确实这样设计),这样只要固定了np.random的种子,大部分情况就能覆盖。如果某个库实在顽固,就在它的文档里找seed参数,显式传入。
3. 依赖锁定:环境漂移是怎么吃掉你的精度的
3.1 一个patch版本的升级,能造成多大的影响
第一个章节里提到了1.13.0到1.13.1的差异导致精度掉1.3个点,这个案例不是个例。很多人误以为PyTorch的补丁版本只是修bug,不会影响数值行为,但实际并非如此。补丁版本可能改动算子底层实现,比如更换某个CUDA kernel的分块策略,更接近真实情况的是调整了融合逻辑,以及修正了某些边缘情况的数值处理。这类改动几乎不会出现在release note的显著位置,但对长训练流程的影响会通过迭代累积。
依赖漂移不只是PyTorch一个包的问题。CUDA驱动、cuDNN版本、Python版本、NumPy版本,甚至操作系统底层数学库(比如MKL、OpenBLAS)都会影响浮点运算结果。越深层的库越容易被忽略,因为它们不是"项目依赖"的直接体现,而是依赖链的隐藏节点。
在复现PyTorch实验时,我的经验是:必须把环境冻结到补丁级别。补丁级别指的是torch==1.13.1而不是torch==1.13,并且同时记录Python版本、CUDA构建版本、cuDNN版本与GPU驱动版本。
3.2 conda env export 与 pip freeze:两张清单怎么配合
锁定环境的最直接方法是导出依赖列表。社区里最常用的是两条命令:conda env export和pip freeze。它们各有侧重,不能互相替代。我见过很多项目只保存了一份requirements.txt,结果在conda环境下,里面没有记录conda第三方渠道安装的包的版本和构建号,复现时依然能装上但装的版本往往对不上。
对比一下这两者的特征:
| 命令 | 覆盖范围 | 关键优势 | 主要问题 |
|---|---|---|---|
| conda env export | conda渠道安装的包 | 包含build string,可精确到构建方式 | 生成文件中有prefix绝对路径,直接跨机器导入要处理;不包含pip安装的包(或混着备注) |
| pip freeze | 当前Python环境中pip可识别的所有包 | 覆盖面广,能包含PyPI源安装的包 | 会列出环境中所有包,包括非项目直接依赖的间接依赖;在conda环境下会混入conda安装但pip可识别的包 |
实际操作时,我会在激活目标conda环境后同时生成两个文件,并记录生成时间:
conda env export > conda_env_$(date +%Y%m%d).yml pip freeze > requirements_$(date +%Y%m%d).txtconda env export生成的YAML文件里有一行prefix: /path/to/env,它是本机环境的绝对路径。别人导入时需要去掉这一行,或者用conda env create -f environment.yml --name new_env来指定新环境名称。pip freeze的问题在于它会把环境里所有的包都列出来,包括一些从没被项目使用过的间接依赖;但反过来说,这恰好保证了最大程度的完整,宁可多锁不可漏锁。
如果项目依赖比较复杂,比如需要区分直接依赖和间接依赖,我还会在环境目录下维护一个requirements-in.txt,只记录我显式安装的顶层包,同时把pip freeze的结果作为锁文件存档。这样既保留了依赖的可读性,也保证了锁定环境的可复现性。
3.3 系统级依赖:CUDA驱动、cuDNN 与 WSL 的特殊情况
依赖锁定不能只停留在包管理器层面。PyTorch的GPU运算在运行时依赖系统CUDA驱动和cuDNN库。我在环境指纹记录时会采集三个关键字段:
import torch print(f"PyTorch版本: {torch.__version__}") print(f"PyTorch构建时的CUDA版本: {torch.version.cuda}") print(f"cuDNN版本: {torch.backends.cudnn.version()}") print(f"是否有CUDA: {torch.cuda.is_available()}") if torch.cuda.is_available(): print(f"GPU名称: {torch.cuda.get_device_name(0)}") print(f"GPU计算能力: {torch.cuda.get_device_capability(0)}")注意torch.version.cuda代表的是PyTorch二进制构建时使用的CUDA版本,而不是系统驱动支持的CUDA版本。后者需要看nvidia-smi输出的Driver Version和CUDA Version字段。这两者经常被混淆:系统驱动版本决定运行时CUDA runtime的兼容上限,而PyTorch内部链接的CUDA库版本是另一回事。复现时两个字段都要记录。
还要记录GPU型号,因为不同GPU在并行归约顺序上的行为不同,即使在完全相同的环境和依赖下,A100与4090跑同样的实验,也可能出现小数点后几位到后几位的差异。这不代表复现失败,而是说明"浮点一致性"不等于"完全相等"。这一点在第五章再详细说。
WSL环境的场景在社区里越来越常见,因为很多人生成式AI和本地推理实验都在WSL里跑。WSL下有个容易晕的点:WSL里看到的GPU驱动是Windows宿主机提供的,nvidia-smi能正常输出,但你不能在WSL内单独安装或更新GPU驱动,能安装的只是CUDA Toolkit。这意味着在WSL环境做依赖锁定时,驱动版本字段反映的是Windows宿主机的驱动状态,跨机器复现时要注意两边WSL版本的差异也会带来潜在差异。
3.4 一条命令生成环境指纹
为了不手动复制粘贴上面那些信息,我写了一个轻量环境指纹脚本env_fingerprint.py,每次实验开始时自动运行并把输出追加到实验归档目录:
import json import platform import subprocess import sys def run(cmd): try: return subprocess.check_output(cmd, shell=True, stderr=subprocess.DEVNULL).decode().strip() except Exception: return "[unavailable]" def main(): import torch info = { "platform": platform.platform(), "python": sys.version.split()[0], "torch": torch.__version__, "torch_cuda_build": torch.version.cuda, "cudnn": torch.backends.cudnn.version() if torch.backends.cudnn.is_available() else "n/a", "gpu_name": torch.cuda.get_device_name(0) if torch.cuda.is_available() else "cpu-only", "gpu_capability": torch.cuda.get_device_capability(0) if torch.cuda.is_available() else "n/a", "driver": run("nvidia-smi --query-gpu=driver_version --format=csv,noheader"), } print(json.dumps(info, indent=2, ensure_ascii=False)) if __name__ == "__main__": main()这个脚本产生的JSON文件可以直接并入实验目录,与超参数配置放在一起。它不能替代依赖锁文件,但能在后续排查时快速定位"环境是否发生了变化"——比如你换了驱动、换了GPU、升级了PyTorch,用指纹文件一比对就清楚了。
4. 配置归档:把"实验现场"完整封存
4.1 归档的不只是超参数,还有代码状态和数据版本
依赖锁定解决的是"运行环境一致"的问题,随机种子解决的是"随机过程一致"的问题,但还缺一环:实验本身的状态。很多项目把所有超参数写进一个args.json就声称做了归档,这远远不够。一次实验现场包含的信息远比超参数多,我一般按这个清单收集:
- 代码仓库的commit hash和是否dirty(有未提交改动)
- 未提交改动的patch文件
- 训练脚本的命令行入口和所有参数
- 随机种子(包括
PYTHONHASHSEED) - 数据集的版本标识、文件数量、总大小,以及至少一个代表文件的哈希值
- 模型结构定义(通常以打印模型结构的文本保存)
- 训练日志和checkpoint路径
为什么代码状态要记录到commit hash和patch级别?因为版本号可以被覆盖,commit则不可变。如果实验代码里有一处临时改动忘了提交,只记录git rev-parse HEAD显然无法复现当时的代码。保存git diff生成的patch文件,配合commit hash,才能在之后恢复出当时的工作区快照。这也是团队协作中经常被忽略的一环:你以为归档了,实际归档的是一个不完整的代码状态。
4.2 一次实验一个目录:归档结构范本
我自己会为每个独立实验创建这样一个目录结构:
experiments/ └── seg_resnet50_20240512_1530/ ├── env_fingerprint.json # 环境指纹 ├── config.yaml # 超参数全量配置 ├── args.json # argparse解析后的参数 ├── git_info.json # commit hash + dirty 状态 ├── patch.diff # 未提交改动 ├── model_structure.txt # 模型结构打印 ├── data_info.json # 数据集版本和哈希 ├── train.log # 训练日志 └── checkpoints/ # 模型权重(按需保存)目录名采用"项目名+日期时间"的格式,保证唯一性。有人喜欢在目录名里塞一堆超参数,反而不利于检索,因为最终你查实验记录时,靠的是配置文件而不是目录名。
config.yaml里除了常规超参数,我还会额外记录这些字段:
seed: 42 python_hash_seed: 42 deterministic_algorithms: true cudnn_benchmark: false cublas_workspace_config: ":4096:8" data_version: "v1.2" mixed_precision: false这些字段看起来像是"环境参数",但它们对数值结果的影响和超参数同等重要。把它们放进来,排查实验差异时就能直接对比两个实验到底差了什么。
4.3 自动化归档的落地技巧
手动维护上面这套目录结构非常痛苦,所以我把归档动作跟训练脚本绑定,跑实验时自动完成。核心是在训练入口处增加一段初始化逻辑:
import argparse import json import time import subprocess parser = argparse.ArgumentParser() parser.add_argument("--exp_name", type=str, required=True) parser.add_argument("--seed", type=int, default=42) args = parser.parse_args() exp_dir = f"experiments/{args.exp_name}_{time.strftime('%Y%m%d_%H%M%S')}" os.makedirs(exp_dir, exist_ok=True) # 1. 保存参数 with open(f"{exp_dir}/args.json", "w") as f: json.dump(vars(args), f, indent=2, default=str) # 2. 保存Git信息 git_commit = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode().strip() git_dirty = subprocess.check_output(["git", "status", "--porcelain"]).decode().strip() with open(f"{exp_dir}/git_info.json", "w") as f: json.dump({"commit": git_commit, "dirty": bool(git_dirty)}, f, indent=2) if git_dirty: subprocess.run(["git", "diff", ">", f"{exp_dir}/patch.diff"], shell=True) # 3. 保存环境指纹 subprocess.run(["python", "env_fingerprint.py", ">", f"{exp_dir}/env_fingerprint.json"], shell=True)这里有几个实用细节。json.dump的default=str参数很关键,因为argparse解析出的参数里可能有Path对象、元组、自定义类型,直接序列化会报错;加了这个参数保证参数能被兜底转成字符串。在代码崩溃时,实验目录可能没有完整建成,所以我用try/finally包裹整个训练流程,在finally里确保日志落盘。
另一个技巧是利用Git的git status --porcelain判断工作区是否有未提交改动,而不是只看commit hash。很多做了归档的项目,代码库里其实有未提交的临时修改,如果不生成patch文件,后续根本无法还原真正跑过的代码。自动生成patch.diff这一步成本极小,价值却极大,强烈建议纳入每个项目的默认流程。
提示:如果训练脚本在集群或Docker里运行,归档逻辑要放在容器内且挂载持久化卷,否则容器销毁后实验记录也跟着消失。我见过不止一次容器内跑实验、结束时忘了拷贝结果的情况。
5. 复现校验与我的实践心得
5.1 三个级别的复现验证,缺一不可
做完种子设定、依赖锁定和配置归档之后,还需要一套验证流程来确认"复现机制真的有效"。我的做法分三个级别:
第一级,同一台机器、同一个环境连续跑三次相同实验,观察最终指标的波动范围。不要指望三次得到完全一样的数值,在GPU上由于浮点归约顺序的不可控性,通常会有非常小的波动,但这个波动应当在极小的范围内,比如mIoU在0.1个点以内。如果波动超过这个范围,说明还有随机源没被控制住,回到前两章排查。
第二级,换一台机器,在完全相同的锁文件下重建环境,重跑实验。这一级验证的是环境锁定的可靠性。如果两次结果差异较大,优先检查GPU型号、驱动版本和cuDNN版本是否记录完整。不同GPU之间的微小数值差异是正常的,但量级不应超出第一级波动的太多倍。
第三级,在不改变代码和超参数的前提下,故意升级一个点版本(比如从2.2.1升到2.2.2),观察结果变化。这个验证的意义在于确认你的实验对PyTorch版本的敏感度,如果敏感度很高,说明实验本身处于一个比较脆弱的数值状态,更需要在归档时严格锁定版本。这一步不是每次实验都做,但做一次能给后续复现提供重要参考。
5.2 怎么判断"复现成功":数值相等不是唯一标准
很多人追求两次实验结果完全相等,其实在GPU并行计算下,这既不现实也没必要。我更关注两个层面:一是训练曲线的形态是否一致,比如损失下降的趋势、波动模式是否吻合;二是最终指标的差异是否在统计波动范围内。只要两者都满足,即使损失值在小数点后几位有区别,我也认为实验复现成功。
具体到数值判断上,分类任务可以看验证集准确率差异,回归任务可以看RMSE差异,一般以1个标准差以内作为可接受范围。如果你做的是需要精确比较消融实验的场景,比如比较两个模型结构的性能差异只有0.2个点,那就必须把环境和随机控制做得非常严格,否则0.2个点的差异会淹没在实验噪声里。这种情况我会开启确定性模式,并在同一GPU型号上完成全部对比。
5.3 分布式训练和多卡场景:精确复现是奢侈品
最后说一个现实问题:单卡实验做到上面这些,基本可以稳定复现;分布式训练和多卡并行则是另一回事。多卡训练涉及AllReduce、梯度累积、数据并行采样器的跨进程顺序,随机性来源更多,包括每个Rank上DataLoader的Shuffle顺序、AllReduce的归约顺序,以及多卡之间的同步时机。在多卡场景下,我通常放弃"逐比特复现",转而关注"统计等价复现":确保随机种子、依赖、配置完全一致,然后接受卡间调度导致的小幅度数值波动,只验证最终指标在可接受范围内。
混合精度训练也有类似的特性。开启了torch.cuda.amp后,数值行为对GPU型号和cuDNN版本更加敏感,因为FP16和BF16的舍入策略在不同硬件上差异更大。混合精度实验的依赖锁定要更严格,最好连GPU型号都固定。
回到开头那个1.3个点的案例。那次排查让我建立起一套完整的实验管理机制,后面再没被"复现不出来"折磨过。现在每次实验开跑前,我确认三件事:随机种子体系完整覆盖了torch/numpy/random/Dataloader/PYTHONHASHSEED;依赖锁文件和环境指纹已经生成;实验目录里包含了代码状态、配置、日志和patch。这套流程看起来繁琐,但一旦养成习惯,几乎不需要额外时间成本。我强烈建议你从下一个实验开始就试试:哪怕先只做环境锁定这一件事,你也会立刻感受到它对实验质量的提升。