讲一个我最近被反复折腾的问题:ROCm 平台上的多卡集合通信,尤其是 all-reduce 这种操作,怎么才能保证两次运行的结果完全一致。之前在一个 8 卡 ROCm 节点上调分布式训练,同一个脚本跑两次,损失曲线肉眼看着几乎重叠,但只要脚本里写了“结果必须严格相等”的断言,就会被残忍地打脸。这个问题往小里说是浮点舍入的抖动,往大里说会直接毁掉回归测试、科学计算复现和模型调试的整个流程。如果你也遇到过“明明什么都没改,输出 hash 却对不上”的情况,这篇文章就是写给你看的。
1. 确定性集合通信面临的问题:为什么同样的代码在 ROCm 上反复运行会对不上
1.1 多卡 all-reduce 的“隐形无序”
先明确一下,单卡上跑一个矩阵求和,只要输入不变,结果一般每次都是 bitwise 一样的。但一上多卡,情况就变了。集合通信把一个大 tensor 分发到多张 GPU 上,每张卡只持有其中一块,然后通过 all-reduce 把各卡的局部结果汇总成全局求和结果。这里的核心问题是:浮点数加法不满足结合律。
打个比方,0.1 + 0.2 + 0.3 在浮点世界里并不是小学算术里的 0.6,每加一次都会做一次舍入。假设三张卡上的数值分别是 a、b、c,那么 (a + b) + c 和 (a + c) + b 的结果可能差出几个 ULP(最小精度单位)。单卡时加法顺序是编译器帮你定死的,可多卡时这个顺序却不由你控制,而是由 RCCL 的调度、网络传输完成时机、通道分配策略共同决定。于是同一份代码跑两次,中间每个 chunk 的归约顺序大概率会漂移,最终输出自然就不一致。
这个不一致在大部分模型训练场景里是可容忍的,因为梯度下降本身对噪声不敏感,损失曲线还是能收敛。但要调试、要跑 CI、要做可复现性实验,这种问题最磨人。你以为模型改动引入了 bug,结果只是浮点顺序在捣乱,一查就是好几个小时。
1.2 三个确定性破坏者:chunk 调度、channel 数量、协议类型
在我的测试中,RCCL 的抖动主要来自三个层面:
第一,数据被切成的 chunk 数量与调度顺序。RCCL 会把输入 tensor 切割成多个 chunk,每个 chunk 按某种规则分配到通信环上的不同位置去加工。如果 chunk 的划分粒度变化,那么不同卡之间的求和先后顺序就会彻底洗牌。
第二,channel 的并行度。RCCL 为了跑满带宽,会同时开多条独立的通信流水线,每条流水线被称为一个 channel。多个 channel 之间的完成时机互相独立,它们在最终写入输出 buffer 时没有强制的全局顺序。通道数一多,谁能先把自己的部分归约完、谁后到,就变成了随机事件。
第三,协议的差异。RCCL 在底层支持 SIMPLE、LL、LL128 这些协议。SIMPLE 协议下数据是原样传输,控制信息靠额外机制;LL 和 LL128 则会把数据打散进更小的 packet,以换取更低的同步开销。packet 越小,实际进行浮点相加的中间片段越多,顺序敏感度几何级增长。因此在追求确定性时,协议的选择不能一并忽略。
1.3 影响范围:不只是“差几个小数点”
不确定性的影响比想象中更大。你的梯度更新是逐层的,反向传播每层都会调用 all-reduce,一层的结果漂移到下一层,误差累积。我在一个 100 层左右的模型上测过,跑两次训练到第 10 个 epoch,模型权重的 L2 范数差异已经能达到 1e-1 量级。对于只看 loss 曲线的团队,这不算大事;但对于要校验 checkpoint 是否一致的团队,这就是灾难。
我还见过更隐蔽的坑:分布式训练框架本身有自测用例,期望在固定随机种子下输出固定 hash。因为没有关闭确定性,这些用例在单卡模式下全绿,一迁移到多卡 ROCm 集群就随机红。所以,确定性集合通信不是性能优化项,而是工程质量的底线需求。
2. RCCL 的底层秩序:ring、tree 与 channel 对确定性到底有何影响
2.1 ring 模式:循环接力,顺序天然容易漂移
RCCL 与大家熟悉的 NCCL 在算法设计上一脉相承,都提供 ring 和 tree 两类核心算法。先看 ring:所有 GPU 被连成一个环,每个节点只与前后邻居通信。all-reduce 被拆成两个阶段,先是 reduce-scatter,后是 all-gather。
reduce-scatter 阶段,每个节点把自己的数据切成 N 份,然后把第 i 份传给环上的下一个节点。节点每收到一段数据,就与本地对应段相加,然后把结果继续传给下一个。经过 N-1 轮之后,每个节点都拥有一份包含了所有节点贡献的局部结果。随后 all-gather 阶段再把这个结果在环上广播,让每个节点拿到完整的总和。
这里的关键在于,“每个节点最终持有的那一份 chunk”对应的是输入数据的哪个区域,取决于算法在建立通信图时给节点分配的 offset。只要这个 offset 在初始化时被确定下来,并且 chunk 尺寸固定,ring 模式理论上是可以保持确定性的。问题在于,RCCL 默认会根据消息大小和拓扑评分,在多个算法和通道数之间动态切换,导致每次运行时 offset 的分配路径、数据块大小都不一样。这就等于把浮点求和的顺序交给了运行时决策,稳定性也就无从谈起。
2.2 tree 模式比 ring 更稳的原因
tree 模式的结构更接近于一个二叉树:叶子节点把自己的数据发给父节点,父节点完成归约后继续向上传递,根节点拿到完整全局结果后再广播回所有叶子。
tree 模式有个天然优势:每次归约的路径是固定的树形拓扑,叶子到根的路径在初始化后不会变。也就是说,在算法层面,哪份数据经过哪几个节点、在哪一层完成相加,都是确定的。只要你不去动 chunk 的划分规则和通道数,tree 模式的时序敏感度比 ring 低很多。这也是为什么很多公开讨论在回答“如何让 NCCL/RCCL 结果可复现”时,第一反应是先切换到 tree 模式。
这里要澄清,不是说 tree 模式一定保证确定性,而是 tree 模式把“顺序漂移”的主要变量从环的旋转偏移变成了固定的层级关系,更容易通过环境变量锁死。我的实测结论是:锁定 tree + SIMPLE + 单 channel 之后,重复运行的哈希几十次都完全一致。
2.3 协议与内存访问模式对结果的影响
RCCL 的协议选择也参与了结果排序。LL(Low Latency)协议把一个数值拆散到多个 8 字节单元中,传输的同时携带控制位;LL128 则按 128 字节对齐。这两种协议把大的数据块切得更碎,每个碎片的归约顺序更多样化,而且为了吞吐量,它们会让多个通道同时在碎片级别上竞争。
相比之下,SIMPLE 协议直接走大块 buffer,不搞细粒度分包,归约顺序中的“可变因素”少。因此在需要确定性的场景里,我会把协议固定为 SIMPLE,哪怕牺牲一些带宽。
关于内存访问模式,还有一点容易被忽略:RCCL 在初始化阶段会测量拓扑并选择最快的路径,这个测量结果受当时 GPU 频率、PCIe 链路状态影响。每次运行测量的结果可能有一点点差别,最终影响通道数和 chunk 选择。固定死环境变量,其实也是在绕开这段“自适应测量”。
3. 方案落地:在 ROCm 上配置确定性集合通信的完整步骤
3.1 第一步:检查 RCCL 环境与版本支持
动手改配置前,先确认当前环境。用下面这组命令收集基本信息:
# 查看 ROCm 驱动与 GPU 型号 rocminfo | grep "Marketing Name" # 查看 RCCL 库位置与链接关系 ldd /opt/rocm/lib/librccl.so | grep -E "hip|amd|rccl" # 检查 PyTorch 的 ROCm 后端是否可用 python -c "import torch; print(torch.__version__, torch.version.hip)" # 如果你用的 PyTorch 带 dist 模块,确认 backend 类型 python -c "import torch.distributed as dist; print(dist.is_available())"接着,跑一次带 debug 日志的初始化,看看 RCCL 实际选择了什么算法和协议:
RCCL_DEBUG=INFO python your_training_script.py 2>&1 | grep -E "RCCL|NCCL|ALGO|PROTO|CHANNEL"RCCL 的调试日志会输出类似Setting Algo : Ring, Protocol : SIMPLE或Using 8 channels的信息。这些输出是你判断问题是否出在集合通信层的直接依据。
3.2 第二步:用环境变量把算法、协议和通道数全部固定
在启动分布式训练脚本之前,设置以下环境变量:
export RCCL_ALGO=TREE export RCCL_PROTO=SIMPLE export RCCL_MIN_NCHANNELS=1 export RCCL_MAX_NCHANNELS=1 export RCCL_BUFFSIZE=8388608每个变量的作用如下表所示:
| 环境变量 | 设置值 | 作用 |
|---|---|---|
RCCL_ALGO | TREE | 固定使用 tree 算法,禁止 Auto 模式自动切换 |
RCCL_PROTO | SIMPLE | 固定使用简单协议,避免 LL/LL128 把数据切得过碎 |
RCCL_MIN_NCHANNELS | 1 | 限制至少一个通道 |
RCCL_MAX_NCHANNELS | 1 | 强制只使用单通道,消除多通道间完成顺序的不确定性 |
RCCL_BUFFSIZE | 8388608 | 固定通信 buffer 大小,避免因消息尺寸触发内部重分配 |
关于RCCL_BUFFSIZE,默认值在不同版本里不太一样,我给出的 8MB 是我测试后选定的值。固定 buffer size 的目的是让 RCCL 内部的数据分块行为保持一致,这也是一个容易被忽略的可变因素。当你遇到“变量都锁了,哈希还是对不上”的情况时,检查一下是不是 buffer size 没有固定导致的。
还有一点要说明:RCCL_ALGO=TREE在某些超节点、混杂网络拓扑的环境下可能不被支持。如果你的合法网络拓扑下 RCCL 日志明确提示不支持 tree,那就退回到RCCL_ALGO=RING,同时保持单通道和固定协议。Ring 在单通道下也能复现,只是对环境变量的一致性要求更苛刻。
3.3 第三步:编写一个验证确定性的最小脚本
这是一个用 PyTorch 写的最小验证脚本,它会在每个 rank 上生成不同的数据,执行 all-reduce,然后对结果做哈希:
import os import hashlib import argparse os.environ["RCCL_ALGO"] = "TREE" os.environ["RCCL_PROTO"] = "SIMPLE" os.environ["RCCL_MIN_NCHANNELS"] = "1" os.environ["RCCL_MAX_NCHANNELS"] = "1" os.environ["RCCL_BUFFSIZE"] = str(8 * 1024 * 1024) import torch import torch.distributed as dist def main(): parser = argparse.ArgumentParser() parser.add_argument("--size", type=int, default=1 << 20) args = parser.parse_args() dist.init_process_group(backend="nccl") rank = dist.get_rank() world = dist.get_world_size() torch.manual_seed(42 + rank) x = torch.randn(args.size, device=f"cuda:{rank}") * (rank + 1) # 分布式 all-reduce,求和归约 dist.all_reduce(x, op=dist.ReduceOp.SUM) # 把结果转为 bytes 后做 sha256 data = x.cpu().numpy().tobytes() digest = hashlib.sha256(data).hexdigest() if rank == 0: print(f"digest={digest}") if __name__ == "__main__": main()运行命令:
MASTER_ADDR=127.0.0.1 MASTER_PORT=29500 \ python -m torch.distributed.run --nnodes=1 --nproc_per_node=8 \ det_check.py --size 8388608连跑 5 次,每次的digest应该完全一致。我这边在 8 卡 ROCm 节点上的结果是稳定的。如果你的输出 hash 每次都在变,说明集合通信层仍存在时序竞争,往下看排查清单。
3.4 第四步:在训练脚本中应用同样的配置
训练脚本里的环境变量设置必须在任何分布式初始化之前完成,尤其是不能放在dist.init_process_group()之后。你可以在import torch之前设置,也可以放在脚本最顶部:
import os os.environ["RCCL_ALGO"] = "TREE" os.environ["RCCL_PROTO"] = "SIMPLE" os.environ["RCCL_MAX_NCHANNELS"] = "1" os.environ["RCCL_MIN_NCHANNELS"] = "1" os.environ["RCCL_BUFFSIZE"] = str(8 * 1024 * 1024) import torch import torch.distributed as dist这里多说一句:ROCm 版 PyTorch 的分布式后端名称仍然叫nccl,但它内部实际加载的是 ROCm 生态里的librccl.so。所以很多网上针对 NVIDIA NCCL 的建议,在 ROCm 上可以用,只是环境变量前缀从NCCL_换成RCCL_而已。
4. 实测效果:不同配置组合下的复现性对照
4.1 测试环境说明
我在一个 8 卡 ROCm 节点上做了对照测试。硬件是 PCIe 4.0 拓扑,每张卡通过 PCIe 交换机互通,没有高速卡间互联。软件栈是 ROCm 5.7、配套 RCCL、PyTorch 2.0 的 ROCm 版本。测试数据是每张卡独立生成的随机向量,长度 8388608 个 float,约 32MB。
4.2 复现性对照表
我分别测试了默认配置、固定算法不固定通道、以及全固定三种模式,每组重复 5 次,记录输出 hash 的稳定性:
| 配置组合 | 第一次 hash | 第二次 hash | 第三次 hash | 结果是否一致 |
|---|---|---|---|---|
| 默认 Auto 算法 + Auto 通道 | f89c1a2b... | 1ac4f6de... | f89c1a2b... | 不稳定 |
RCCL_ALGO=TREE+ Auto 通道 | 5c0eab01... | 5c0eab01... | 5c0eab01... | 较稳定 |
RCCL_ALGO=TREE+RCCL_PROTO=SIMPLE+ 单通道 | a1b2c3d4... | a1b2c3d4... | a1b2c3d4... | 稳定 |
RCCL_ALGO=RING+RCCL_PROTO=SIMPLE+ 单通道 | a1b2c3d4... | a1b2c3d4... | a1b2c3d4... | 稳定 |
注意,表格中的 hash 值是我为了示意随便写的,你实际跑出来的 hash 一定不同。你需要关注的不是 hash 的具体内容,而是“同一个配置下多次运行是否一致”这个性质。
有意思的是,只固定RCCL_ALGO=TREE、但保留 Auto 通道时,我在这个节点上已经能得到稳定结果。这是因为 tree 算法本身的拓扑结构对通道时序不敏感。但我不推荐你依赖这一点,一旦换到多机或不同拓扑,行为就可能变化。
4.3 性能损失与取舍
打开确定性模式当然要付代价。在我测试的 32MB 消息场景里,全固定配置的带宽大约是默认配置的 60% 到 75%。损失主要来自单通道限制,多通道的优势就在于可以同时跑多路通信流水线,让 PCIe 带宽吃得更满。单通道后,整个归约变成串行度更高的过程。
不同消息大小下的趋势也不一样。小消息(小于 1MB)场景里,单通道损失不明显,因为小消息本身不足以撑满多通道;大消息(64MB 以上)场景下,带宽损失会更加明显,有时能到 40%。作为对比,固定TREE+ 不限制通道这种“弱确定性”方案,性能只损失不到 10%,但可复现性会受拓扑影响。
我的建议是,如果你的上游实验不需要严格 bitwise 复现,可以先从固定算法和协议的“弱确定性”方案入手,性能更友好。如果确实要做回归验证,那就要接受单通道带来的性能下降,在 CI 环境里跑一个较小的数据子集,尽量避免全量训练性能被拖累。
5. 常见问题与排查技巧实录
5.1 环境变量设置后完全没生效
这是最常见的问题。检查方法很简单:运行时打开RCCL_DEBUG=INFO,在日志里找有没有你设置的关键信息。如果日志显示的还是 Auto 模式,说明你的变量名写错了、版本不支持,或者变量被脚本里更靠后的代码覆盖了。
有个典型场景:你在命令行export RCCL_ALGO=TREE,但脚本内部又通过 Python 的os.environ重新设置成了别的值。或者反过来,你在脚本里设置了,但dist.init_process_group()之前又重新加载了某个配置文件。排查时,在初始化之前加一行打印:
print("RCCL_ALGO =", os.environ.get("RCCL_ALGO")) print("RCCL_PROTO =", os.environ.get("RCCL_PROTO")) print("RCCL_MAX_NCHANNELS =", os.environ.get("RCCL_MAX_NCHANNELS"))还有一点,部分 RCCL 版本的环境变量名可能不是RCCL_MAX_NCHANNELS,而是沿用了NCCL_MAX_NCHANNELS。我的经验是,更新一点的 ROCm 版本两者都认,但老版本可能只认其中一种。最稳妥的办法是看官方文档或直接strings librccl.so | grep RCCL确认。
5.2 固定了所有配置,结果还是有一两次不稳定
如果你已经锁定了算法、协议、通道数和 buffer size,但仍然极少数情况不匹配,那问题很可能不在 RCCL,而在应用层。
首先,检查数据加载顺序。DataLoader如果开启了多进程采样,且没有固定seed,每个 epoch 的数据顺序都可能变化。训练脚本要做确定性验证,必须把torch.manual_seed、torch.cuda.manual_seed_all、numpy.random.seed、甚至random.seed全部固定。
另外,torch.backends.cudnn.benchmark = True会自动选择最快的卷积实现,这个选择在不同运行中可能不同,进而影响梯度计算顺序。做确定性测试时,要显式设置:
torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False我在自己的项目里就吃过这个亏:RCCL 层面已经完全稳定,但模型权重更新前后的 hash 还是不一致。排查了两天后才发现,问题出在某个自定义算子里用了原子加,而原子加在多线程下天生无序。这种案例怎么排查?可以做一个对比实验:先用一个只调 all-reduce 的最小脚本确认 RCCL 是否波动,再逐步加入模型前向、反向、优化器更新,用二分法缩小范围。
5.3 多机场景下树算法失效
tree 算法在单机内运行良好,但扩展到多机时,叶子节点和根节点之间的路径会跨越网络。如果网络设备不支持对应的通信模式,或者交换拓扑不对称,RCCL 的 tree 模式可能直接报错或回退到 ring。
我在多机测试时遇到过一次:单机内 tree 模式一切正常,但两台机器间一拉通,RCCL 日志就显示Tree algorithm not supported。最终回退到RCCL_ALGO=RING加单通道,仍然获得了稳定结果。这说明确定性的核心不在于一定用 tree,而在于把算法固定下来,让运行时不要自动变来变去。
5.4 如何验证到底是不是 RCCL 在捣乱
给你一个排查流程。先固定一切,跑一个只有 all-reduce 的纯通信脚本:
- 用同一个固定输入,连续跑 20 次,打印每次的输出 hash。
- 如果 hash 不稳定,说明问题在集合通信内部,继续查变量和版本。
- 如果 hash 稳定,再逐步加入模型训练的前向和反向过程。
- 每加一层,就打印一次关键张量的 hash,直到找出泄露不确定性的环节。
我还习惯在脚本里同时打印“输入进 all-reduce 之前的 hash”和“all-reduce 之后的结果 hash”。如果输入前的 hash 一致、输出后不一致,问题就在 RCCL;如果输入前已经不一致,那问题在数据的生成或采样环节。
5.5 多版本 RCCL 的兼容性问题
ROCm 版本升级后,RCCL 的行为会有变化。同一个环境变量在旧版本里有效,在新版本里可能被改名或废弃。我遇到过RCCL_MIN_NCHANNELS在某个版本里还生效,升级后却完全不读的情况。
在做平台迁移或版本升级时,建议把当时的 RCCL 日志完整保留一份,里面记录了实际生效的算法、协议和通道数。升级后跑一遍同样脚本,对比日志就能发现潜在行为变化。不要只靠环境变量“看起来设了”就放心,RCCL 日志里的实际参数才是最终真相。
结尾
聊了这么多,最后说点我在实践里最深的体会。确定性集合通信不是一个“开了就能一劳永逸”的功能,它更像一套工程习惯:固定算法、固定协议、固定通道,然后在关键节点打印哈希做校验。ROCm 上的工具链成熟度一直在快速提升,但环境的自适应能力越强,隐藏的不确定性因素反而越多。每次开工前花几分钟跑一个最小通信脚本,确认基础行为,再开启长训练,既省心又安全。希望这套方法能让你少走一些我踩过的弯路。