优化器的确定性:同样的数据和种子,Adam 每次跑出来的结果一样吗?

我最早以为,训练神经网络只要把随机种子固定住,结果就应该一模一样。后来有次我在两台机器上跑同一个模型,同样的数据、同样的种子、同样的初值,损失曲线却像两条渐行渐远的河流。我当时对着屏幕愣了半天,心想:难道 Adam 每次都在抽签?

AI technology illustration

后来把各种文档翻了个底朝天,才意识到一个反直觉的事实:Adam 自己没有任何“随机”成分,它是一个完全确定的算法。真正让结果漂移的,是它吃进去的梯度。

Adam 是一台精确的机器,但它没有自主意识

Adam 更新参数的过程可以压缩成一句话:根据当前梯度,计算一阶矩和二阶矩的指数移动平均,再用这两个值去修正参数。每一步都用的是确定的算术公式,没有骰子。

你可以把它想象成一个严格配方的厨师。同样的食材、同样的火候、同样的加料顺序,做出来的菜一定一样。这里“食材”就是每个 batch 给它的梯度序列。也就是说,如果两份训练过程中,模型在每个 step 看到的梯度完全相同,Adam 的每一笔更新也就会完全相同——包括它的动量状态、参数值,全部一一对应。

所以问题就来了:你看到了“同样的数据和种子”,但梯度真的相同吗?答案通常是不。

随机性不是优化器决定的,是数据流水线决定的

训练一个神经网络,随机性源头多得让人头大。为了看清楚哪些地方在“搅局”,我列了一张表:

随机源 受随机种子控制吗? 怎么固定
权重初始化 固定框架种子
数据打乱(shuffle) 固定 DataLoader 种子
数据增强(裁剪、翻转) 固定增强随机种子
Dropout 固定种子
GPU 并行归约(如梯度累加) 使用确定性算法/单卡

前面四项都跟随机数生成器有关,你设置一个种子,理论上就能让它们每次产生相同序列。但问题在于,“设置种子”不等于“所有随机调用都按你想象的方式固定”。很多框架里有多个随机数流,比如 PyTorch 里面的 torch.manual_seed 只设置了全局的 CPU 种子,而 CUDA 上的种子需要额外设置。还有 DataLoader 的多线程 worker,如果你不为每个 worker 初始化随机状态,它们会用系统当前时间作为种子,导致每次训练数据顺序完全不同。

为什么我设了种子还是复现不了?

我最早就这样。设了 torch.manual_seed(42),然后开心地开始训练。结果第二次运行,loss 从 2.31 变成 2.29。我的第一反应是:PyTorch 是不是有 bug?后来才看到PyTorch 官方复现性文档里的一句话:为了完全可复现,你需要额外设置 torch.backends.cudnn.deterministic = True 和 torch.use_deterministic_algorithms(True)。

这是什么意思?GPU 上的许多运算(比如卷积、某些规约)默认会使用并行加速算法,而并行加速本质上就是拆成多个线程同时算,然后按某种顺序把结果加起来。不同的并行顺序会导致浮点数的舍入顺序不同,最后一位可能差一点点。正常情况下这点误差无所谓,但在深度网络里,误差会像蝴蝶效应一样被放大,最后变成明显的差异。

所以,设种子只解决了“随机数”问题,没有解决“计算顺序”问题。要解决后者,你必须让框架使用确定性的算法,即每次都使用相同的计算顺序。

要做到完全可复现,请按这个清单检查

我把我的复现清单贴出来,适用 PyTorch,TensorFlow 类似:

  1. 在代码最开头设置所有已知的随机种子:Python 的 random、numpy、torch、CUDA。
  2. 设置 PyTorch 使用确定性算法:torch.use_deterministic_algorithms(True)
  3. 关闭 cuDNN 的自动调优:torch.backends.cudnn.benchmark = False
  4. 设置 cuDNN 为确定性模式:torch.backends.cudnn.deterministic = True
  5. 为每一个 DataLoader worker 单独设置种子(通过 worker_init_fn),否则即使主种子固定,worker 里的 shuffle 仍是非确定的。
  6. 尽量使用单 GPU,且避免多线程竞争。如果你必须用多 GPU,那么分布式采样器的种子也要固定。

这六步做完,在同一台机器、同样的软件环境下,跑两次通常能获得完全一致的结果。注意“通常”——因为某些操作至今没有确定性的实现,例如某些原子操作或者 FlashAttention 的变体。PyTorch 会抛出警告,你需要留意。

跨机器复现?我把丑话说在前面

就算你把上面的清单全部执行,换一台电脑,结果还是可能不一样。原因很直接:不同的 GPU 型号、不同的 CUDA 版本、不同的 cuDNN 版本,对同一个矩阵乘法的浮点运算是按不同顺序和不同精度(例如 TF32 vs FP32)执行的。甚至在同一个 GPU 上,如果驱动版本升级,底层的 kernel 调度也可能变。

我之前在 A100 上用 TF32 跑过的实验,放到 V100 上重新训练,前几个 epoch 还好,后面 loss 曲线就慢慢岔开了。这不是因为 Adam 对初始条件敏感——优化器本身没有随机性,而是计算环境的差距。

所以,如果你在写论文需要跑基准,别想着“换台机器重训一遍能复现”。更靠谱的做法是保存所有模型的权重和评测细节,然后发布源码,让其他人自己在相同环境下重建。

这件事让我想明白了一件事

以前我总觉得“随机种子”是这个世界上最可靠的东西,后来才意识到它的本质:随机种子只是随机数生成器的起点。它让你在同一个宇宙里按同一个节奏掷骰子,但没法阻止别人用不同的骰子。

回到 Adam 本身。如果你问“Adam 每次跑出来的结果一样吗”,答案是:如果你只给定数据、种子、初值,但没有锁定计算环境和数据流水线的每个细节,那么不同环境下的结果大概率不一样。但这些差异不应该被算在 Adam 头上,因为 Adam 只是一个机械的更新规则。要怪就怪那些没被种子固定住的操作。

实际上,优化器的确定性在学术上有另一层含义:它指的是在给定完整状态时,输出的更新值是唯一的。Adam 满足这个条件。而随机梯度下降中的“随机”来自 batch 采样,不是来自 SGD 公式。所有优化器在这点上平等。

常见疑问

为什么我设置了一样的种子,结果还是不同?

检查是否设置了所有随机源,包括 numpy、Python random、torch.cuda.manual_seed_all。还要确认 PyTorch 设置了确定性模式。另外,如果你的 DataLoader 使用了多进程,每个 worker 的随机种子也需要固定。

Adam 和 SGD 在确定性上有什么本质区别吗?

没有。两者都是根据梯度做更新。如果梯度序列完全相同,它们的状态演化也完全相同。Adam 只是多了动量状态,但这也是由输入决定的。

为什么开启确定性后速度变慢了?

因为确定性算法放弃了一些并行优化,比如随机顺序的归约、自动调优的算法选择。它强制每个操作都按固定顺序执行,这是为了可复现付出的性能代价。

跨机器复现真的做不到吗?

很难。需要完全相同的硬件、底层库版本和确定性设置。即便如此,硬件批次差异也可能导致微小偏差。所以通常建议保存权重和指标,而不是依赖重训。

原创文章,作者:guanweilu,如若转载,请注明出处:https://guanweilu.cn/article/373.html

(0)
上一篇 4天前
下一篇 4天前

相关推荐