[控场AI]
· 6 分钟阅读· 3,330 字

设了随机种子却训出不同模型?PyTorch可复现性真相

设了随机种子却训出不同模型?PyTorch可复现性真相

PyTorch设种子并不能保证可复现,真正的分叉来自GPU并行浮点运算,一行配置可修复且实测仅损失16%性能。

这篇文章揭示了深度学习训练中一个被广泛误解的陷阱:`manual_seed` 等随机种子只能控制显式的随机性(初始化、数据顺序),却无法消除 GPU 并行浮点运算带来的不确定性。作者通过实验发现,相同配置的 ResNet18 两次训练从第1步就开始分叉,200步后权重最大差异达0.237。根本原因是浮点加法不满足结合律,GPU多线程的调度顺序导致梯度累加结果不一致,误差随训练步数指数级放大。这种噪声对最终精度的影响(约±0.3–0.5%)类似一个额外的随机种子,在大效应量实验中无关紧要,但在调试、重构验证和小效应实验中会严重误导判断。通过三行配置可实现逐比特可复现,实测性能开销仅约16%,远低于流传的"2–3倍"说法,建议在需要对比两次运行结果时按需启用。

每个训练脚本的开头都有一套仪式:torch.manual_seed(0)、np.random.seed(0),然后便怀着一种踏实的信念——这次运行已经可复现了。这位博主坦言自己敲这几行代码敲了很多年。直到某次为了追查一个无法解释的 1 个百分点精度差异,他才第一次直接验证了这个信念:相同种子、相同数据顺序、相同模型初始化、相同机器、所有条件完全一致,把 ResNet18 训练 200 步,训练两次,然后逐一对比。

结果是:两条损失曲线在第 0 步完全重合,却在第 1 步就开始分叉。跑完 200 步后,对应权重之间的最大差异达到 0.237——对于那些数值大多落在 −0.1 到 0.1 之间的参数来说,这已经不是漂移,而是一个完全不同的模型。两次运行都设了种子,但没有一次是真正可复现的。

两条相同的赛道线从同一起点出发,逐渐分叉成不同路径

随机性究竟从哪里溜进来

种子确实尽到了它的职责:初始化、数据顺序以及每一次显式的随机采样都完全一致,这正是第 0 步能完美对齐的原因。真正的分歧发生在反向传播环节,而且它根本不是通常意义上的随机性——它是浮点运算遇上并行硬件的产物。

浮点加法不满足结合律:(a + b) + c 和 a + (b + c) 在最后一位上可能有差异。GPU 核函数会在成千上万个线程之间累加梯度贡献,其中许多使用原子操作,而这些操作的完成顺序取决于调度器——取决于在这一次运行、这一个时钟周期里哪些线程赢得了竞争。相同的数字,不同的相加顺序,梯度在最后一位上就产生了差异。

接下来 SGD 把它放大了:梯度上 1e-8 的分歧变成略微不同的权重,权重变成不同的激活值,差距随着每一步不断累积。到这位博主的训练结束时,最初只存在于浮点可表示的最后一位小数附近的噪声,已经增长成了 0.237。用博主的话说,这是"带着 commit hash 的蝴蝶效应"。

这一切都不是 PyTorch 的 bug,而是有文档记录的既定行为。而 cudnn.benchmark(博主此前测过并一直保持启用)又叠加了第二层不确定性——它会在每次运行时自动调优,可能选中不同的、非确定性的卷积算法。

浮点数的精度问题根植于 IEEE 754 标准的设计:32位浮点数(fp32)只有约7位有效十进制数字,64位(fp64)也只有约15位。当数十万个梯度值通过原子操作累加时,每一次中间结果的舍入都会引入一个最低有效位(ULP,Unit in the Last Place)级别的误差。GPU 中的原子加法(atomicAdd)不保证操作的全局顺序,不同的线程束(warp)在不同时钟周期完成累加,意味着即便输入完全相同,累加树的结构可能因运行而异,最终得到的梯度在最后一两位上产生差异。这并非偶发的硬件故障,而是并行浮点计算的数学本质——确定性与吞吐量之间存在内在张力,NVIDIA 为此专门在 cuDNN 和 cuBLAS 文档中说明了哪些操作是确定性的、哪些不是。

0.237 到底重不重要?只在你问小问题时才重要

对于最终精度而言,这种不确定性的行为就像又多了一个你没有选的种子。博主在之前的数据增强实验中测得,在小型 CIFAR 设置上普通的种子间波动约为 ±0.3–0.5 个百分点,而相同种子下的 GPU 噪声也落在同一个统计区间。如果你关注的效应量远远盖过这个波动,那这整篇文章你都可以忽略。

真正会被咬到的,是那些"小问题"——而调试中的绝大多数问题都是小问题。"我这次重构有没有改变数值?"——你无法拿它和重构前的运行做 diff,因为从来没有任何东西能干净地对上。"这 0.4 个百分点的提升,是我新加的损失项带来的,还是纯粹运气?"——在不确定性下,你连在切换损失项时把运气固定住都做不到。

那些需要"两边保持相同噪声"才能诚实回答的 A/B 对比,正是"设了种子却不确定"悄悄浪费你好几天时间的地方。博主深有体会——引发这篇文章的那个 1 个百分点谜题,最后被证明不过是同一份代码跑了两次而已。

修复方法,以及它真实的代价

PyTorch 自带这个开关,只是它并不是种子:

torch.use_deterministic_algorithms(True)
torch.backends.cudnn.benchmark = False

`torch.use_deterministic_algorithms(True)` 的工作原理是:PyTorch 为许多算子维护了两套实现——一套优先追求速度(可能使用非确定性的并行规约),另一套保证确定性(通常通过强制串行化累加或使用确定性的替代算法)。启用该标志后,框架会在调用算子时检查是否存在确定性实现,若不存在则抛出 `RuntimeError` 而非静默地使用非确定性路径。`CUBLAS_WORKSPACE_CONFIG=:4096:8` 环境变量告知 cuBLAS 为确定性算法预留一块固定大小的工作空间(4096 MB,最多8个缓冲区),因为某些确定性矩阵乘法实现需要额外内存来避免并发写冲突。该变量必须在 CUDA 上下文初始化之前设置,通常在 shell 环境或 `os.environ` 中完成,在程序内部 `import torch` 之后设置可能已经来不及生效。
# 对于 CUDA >= 10.2,在进程启动前设置:
# CUBLAS_WORKSPACE_CONFIG=:4096:8

设置这些之后,博主的两次 200 步运行实现了逐比特一致:最大权重差异正好为 0,每一个损失值都精确到最后一位相同。配置中有两个要点:CUBLAS_WORKSPACE_CONFIG 环境变量是强制的(不设会直接报出清晰的 RuntimeError);而 use_deterministic_algorithms 会拒绝运行那些根本没有确定性 GPU 实现的算子——ResNet18 顺利通过,但如果你的模型包含某个"钉子户"算子,这个标志会大声失败,你的选择只剩下 CPU 回退,或者为该算子放弃确定性。这一次,大声报错反而是个好事。

真实的性能开销

关于这件事流传的代价说法是"慢 2–3 倍",通常被当作"不值得折腾"的理由抛出来。但博主在 ResNet18 的训练步上实测得到的结果是:

精度默认确定性开销
fp3217.94 ms20.90 ms+16.5%
bf16 autocast12.42 ms14.37 ms+15.6%

只有 16%。而且其中一部分甚至不算纯粹的确定性开销——它包含了放弃 cudnn.benchmark,博主在此前测量中发现这项在该模型上约占 3%。那些"2–3 倍"的说法,大概来自某些特别倒霉的算子或更老的软件栈,也许你的模型里恰好有一个——而这正是花两分钟亲自测一下的意义。在博主的模型上,逐比特可复现的代价,大致相当于把 batch 调大一点点。

这个开关现在该放在哪

博主的默认配置依旧是快速且非确定性的——对训练吞吐量而言,16% 是实打实的成本,而普通训练并不需要逐比特精确。但这个标志已经为自己赢得了三个固定的使用场景:

  • 任何需要对比两次运行的调试会话(也就是说,几乎任何调试会话);
  • 对数值敏感的重构,当你想要 git diff 级别的确定性来保证输出没变时;
  • 小效应量实验,否则噪声底噪会直接吞掉结果。

决定是否开启的判断只有一句话:我是不是正要对比两次运行,并去解读它们之间的差异? 如果是,那 16% 就是整个优化系列里最便宜的保险——而且和设种子那套仪式不同,它真正买到了它声称能买到的东西。

#PyTorch可复现性#随机种子#确定性训练#GPU浮点运算#use_deterministic_algorithms#深度学习调试#cudnn benchmark#模型复现
分享:

相关推荐