跳转至

看似 NCCL 故障,实际是 Host Memory OOM

分布式训练报错时,最后一行日志往往最有欺骗性。

这次现场看到的是 TensorFlow 在初始化 NCCL Communicator 时失败:

UnknownError: ncclCommInitRank failed: unhandled cuda error
(run with NCCL_DEBUG=INFO for details)

第一反应很容易是 NCCL、RDMA、GPU P2P 或驱动出了问题。实际沿着节点证据继续向下查,根因却是训练容器的 Host Memory 达到了 cgroup 硬限制。部分训练进程被 OOM-kill,NVIDIA 驱动也无法继续锁定大块用户态内存,最后才在 NCCL 初始化边界表现成 unhandled cuda error

本文是一次完全脱敏的生产排障记录。集群、节点、Namespace、Pod、任务、镜像、地址和账号信息均已删除或替换为占位符;保留的硬件量级、内核签名和计算过程用于说明判断方法。

1. 先说结论

这次故障的证据链是:

多 Rank 训练进程启动
数据缓存、共享内存和 Host Pinned Memory 快速增长
Pod memory cgroup 达到约 640 GiB 硬限制
NVIDIA os_lock_user_pages 无法再锁定 4/16 GiB 大块内存
部分训练 Rank 被 memcg OOM-kill
其他 Rank 初始化 NCCL Communicator 失败
TensorFlow 最终只显示 ncclCommInitRank: unhandled cuda error

节点侧没有发现 NVIDIA Xid、GPU 掉卡、不可恢复 ECC 或 PCIe AER 错误;八张 GPU 均可识别,P2P Read/Write 也全部正常。因此,“节点 NCCL 硬件故障”不是与证据最吻合的解释。

更准确的结论是:NCCL 是错误暴露的位置,不是最早发生故障的位置。

2. 为什么最后一行会把人带偏

ncclCommInitRank 需要各 Rank 完成 CUDA Context、拓扑发现、共享内存或网络 Transport、内存注册和 Peer 建联。任何一个 Rank 在此之前因 CUDA 调用失败、进程退出或内存注册失败,其他 Rank 都可能只看到 Communicator 初始化异常。

因此,下面三句话不能画等号:

日志里出现 NCCL
≠ NCCL 库本身有 Bug
≠ RDMA 网络发生故障

排障时要找“最早的异常”,而不是只盯着“最后抛出的异常”。这次最早的有效信号来自宿主机内核,而不是 TensorFlow Traceback。

3. 第一步:确认 GPU 是否真的坏了

先进入目标节点的宿主机命名空间。不同平台可以使用 kubectl debug node,也可以通过已有的特权 Node Agent 执行 nsenter

kubectl exec \
  -n <NODE_AGENT_NAMESPACE> \
  <NODE_AGENT_POD> -- \
  nsenter --mount=/proc/1/ns/mnt -- bash

先检查 GPU 身份、驱动和当前状态:

nvidia-smi -L

nvidia-smi \
  --query-gpu=index,name,uuid,pci.bus_id,driver_version,pstate,\
temperature.gpu,memory.used,memory.total,compute_mode \
  --format=csv,noheader

再检查 GPU 拓扑和 P2P 能力:

nvidia-smi topo -m
nvidia-smi topo -p2p r
nvidia-smi topo -p2p w
nvidia-smi nvlink --status

现场是一台八卡 Blackwell 节点,没有 NVLink;同 NUMA 内 GPU 拓扑为 NODE,跨 NUMA 为 SYS。这意味着通信主要依赖 PCIe,但“没有 NVLink”本身不是故障。更重要的是,所有 GPU Pair 的 P2P Read/Write 都返回 OK

随后检查真正能够支持硬件故障判断的内核签名:

dmesg -T | grep -Ei \
  'NVRM: Xid|Xid \(|GPU has fallen|PCIe Bus Error|AER:.*(error|fatal)'

本次没有匹配到 Xid、掉卡和 PCIe AER 错误。nvidia-smi -q 中也没有待处理的 GPU Recovery Action 或 Page/Row Repair。到这里还不能证明应用一定正常,但已经明显降低了“GPU 硬故障”的优先级。

4. 第二步:别把 RDMA 标签当成 RDMA 设备

目标节点带有类似“启用 RDMA Device Plugin”的标签,很容易让人继续沿 RDMA 故障排查。但 Kubernetes Label 只是控制面元数据,可能过期、误打或只表示允许部署某个组件。

真正的设备证据来自宿主机:

ls -la /dev/infiniband
ls -la /sys/class/infiniband
rdma link show
ibdev2netdev
lspci -nn | grep -Ei 'Mellanox|Infiniband|Ethernet controller'

现场没有 /dev/infiniband/sys/class/infiniband 为空,PCIe 设备中也没有 RDMA NIC。因此,这台机器实际上不具备可供 NCCL 使用的 IB/RoCE 设备。

但这仍然不是本次错误的直接根因:

  • 单节点八卡集合通信不依赖跨节点 RDMA;
  • 多节点任务在没有 IB/RoCE 时,可以按环境和配置退回 Socket;
  • 真正紧邻报错时间出现的是内存锁页失败和 cgroup OOM。

需要确认 Transport 时,可以临时设置以下变量做 A/B:

export NCCL_DEBUG=INFO
export NCCL_DEBUG_SUBSYS=INIT,BOOTSTRAP,ENV,GRAPH,P2P,SHM,NET,ALLOC_HOST,REG
export NCCL_DEBUG_FILE=/tmp/nccl.%h.%p.log

若要验证“IB 自动探测是否干扰初始化”,可以做一次受控对照:

export NCCL_IB_DISABLE=1

根据 NVIDIA NCCL 环境变量文档,该变量会禁用 IB/RoCE Transport 并回退到 IP Socket。它只适合作为诊断变量或明确的系统配置,不应在没有性能回归测试的情况下长期遗留。

5. 第三步:内核日志给出了真正的突破口

扩大 dmesg 的检索范围后,出现了比 NCCL 错误更早的日志:

train_process: page allocation failure
...
get_user_pages
os_lock_user_pages [nvidia]
...
Cannot map memory with base addr <REDACTED> and size of 0x100000 pages
Cannot map memory with base addr <REDACTED> and size of 0x400000 pages

在默认 4 KiB Page Size 下:

0x100000 pages × 4 KiB = 4 GiB
0x400000 pages × 4 KiB = 16 GiB

也就是说,训练进程正在让 NVIDIA 驱动注册或锁定大块 Host Memory,但内核无法完成分配。随后同一 Pod cgroup 出现明确的 OOM:

memory: usage 671088640kB, limit 671088640kB
Memory cgroup out of memory: Killed process <PID> (train_process)

把 KiB 换算为 GiB:

671088640 KiB ÷ 1024 ÷ 1024 = 640 GiB

usagelimit 完全相等,这是比“机器看起来还有空闲内存”更直接的证据:触发的是 Pod/Container memory cgroup OOM,不一定是整台节点的 Global OOM。

所以即使 Kubernetes Node Condition 仍然显示 MemoryPressure=False,容器也完全可能已经因为自己的内存上限而被杀死。

6. 第四步:用 kubelet 和 Runtime 补齐时间线

Pod 已经被控制器删除时,kubectl get pod 和 Event 往往不再保留证据。此时可以从宿主机 Journal 中用 Pod UID、容器 ID 或相对时间窗口回溯:

journalctl -u kubelet \
  --since '<START_TIME>' \
  --until '<END_TIME>' \
  --no-pager | grep -E '<POD_UID>|OOM|ContainerDied'

journalctl -u containerd \
  --since '<START_TIME>' \
  --until '<END_TIME>' \
  --no-pager | grep -E '<CONTAINER_ID>|TaskOOM|shim disconnected'

脱敏后的现场时间线如下:

相对时间 证据
T+0 Pod Sandbox 创建,训练容器开始启动
T+约 1 分钟 训练主容器进入 Running
T+数秒 NVIDIA os_lock_user_pages 开始出现大块内存映射失败
T+十余秒 memory cgroup 达到约 640 GiB,内核开始 OOM-kill 训练 Rank
T+数分钟 containerd 连续记录 TaskOOM
T+结束 容器退出码为 137,kubelet 清理 Pod Volume

这一时间线解释了为什么某些 Rank 只看到 NCCL 初始化失败:同一个分布式任务中的其他 Rank 已经因 Host Memory 压力进入异常甚至被杀死。

7. Host Memory 到底被谁吃掉了

GPU 训练容器的 Host Memory 不只包括 Python Heap。至少要拆开观察:

  • 每个 Rank 的匿名内存;
  • Dataset Cache、样本解压和数据预处理缓冲区;
  • DataLoader/Reader Worker 的进程副本;
  • Prefetch 队列;
  • CUDA Host Pinned Memory;
  • /dev/shm 和其他 tmpfs;
  • 文件 Page Cache;
  • 通信库注册的 Host Buffer;
  • Sidecar 与同 Pod 其他容器。

多 Rank 场景尤其容易发生“单进程配置看起来不大,乘以八之后打满节点”的问题:

单 Rank 缓存 × Rank 数
+ Worker 缓存 × Worker 数 × Rank 数
+ Prefetch 深度
+ Pinned Buffer
+ tmpfs/shmem
= Pod Host Memory 峰值

如果 /dev/shm 使用 emptyDir.medium: Memory,其中的文件也会计入容器内存,而不是普通临时磁盘。Kubernetes 官方的容器资源管理文档明确说明,tmpfs emptyDir 会被 kubelet 作为容器内存使用量跟踪;没有设置 sizeLimit 时,它可能一直增长到 Pod 的内存上限。

建议在 YAML 中同时约束 /dev/shm

volumes:
  - name: dshm
    emptyDir:
      medium: Memory
      sizeLimit: 64Gi

containers:
  - name: trainer
    resources:
      requests:
        memory: <MEASURED_STEADY_STATE_PLUS_HEADROOM>
      limits:
        memory: <VALIDATED_PEAK_PLUS_HEADROOM>

这里不能直接照抄 64Gi 或把 Limit 提到节点 Allocatable。正确值应来自目标任务的峰值测量,并为 kubelet、Runtime、Device Plugin、文件系统客户端和内核留出节点级余量。

8. 修复顺序:先降低峰值,再讨论加内存

直接提高 Pod memory limit 可能暂时绕过 OOM,但也可能把 memcg OOM 变成整机 OOM。建议按以下顺序处理:

  1. 统计每个 Rank 的 RSS、匿名内存、shmem 和 Pinned Memory 增长;
  2. 降低 Dataset Cache、Reader Worker、Prefetch 深度和单批缓冲区;
  3. 检查是否每个 Rank 都重复加载了整份索引、词表或样本缓存;
  4. 给 memory-backed emptyDir 设置 sizeLimit
  5. 确认 Sidecar 没有共享并放大同一个 cgroup 的压力;
  6. 在峰值可解释后,再调整 Request/Limit,并保留节点系统余量;
  7. 用完全相同的数据规模和 Rank 数重新验证。

重跑时应同时保存:

NCCL INFO 日志
各 Rank 第一条异常及时间
Pod memory working set / RSS / cache / shmem
cgroup memory failcnt 或 memory.events
kubelet、containerd 和 dmesg 时间线
GPU Xid/ECC/PCIe/P2P 状态

9. 如何证明 NCCL 和节点已经恢复

应用内存问题处理完以后,再做两层验证。

第一层是最小集合通信测试:

./all_reduce_perf -b 8M -e 1G -f 2 -g 8

观察所有 Rank 是否完成、带宽是否稳定,以及 NCCL 最终选择了 P2P、SHM、Socket 还是 IB/RoCE。多节点测试必须固定节点、网卡、MTU、GID 与 NCCL 版本,不能拿单节点成功替代跨节点验证。

第二层才是原训练任务回放:逐级恢复 Worker、Prefetch 和 Batch,记录内存峰值与首次 Collective 的时间。只跑一次小 Batch 成功,不能证明训练数小时后不会再次累积到 OOM。

10. 这次排障留下的五条经验

  1. ncclCommInitRank 是错误边界,不是根因定位结果。
  2. 没有 Xid、AER、掉卡和 P2P 异常时,不要急着复位 GPU 或重启节点。
  3. Node MemoryPressure=False 不能排除 Container memory limit OOM。
  4. RDMA Label、Device Plugin 配置和真实 RDMA 设备是三件不同的事。
  5. 分布式任务必须按“单 Rank 成本 × Rank 数 × Worker 数”估算 Host Memory 峰值。

最终,真正有用的不是给这次错误贴上“NCCL 问题”标签,而是建立一条固定排查链路:

应用首错
  → Rank 生命周期
  → cgroup 内存
  → NVIDIA/内核日志
  → GPU 与 PCIe
  → SHM/P2P
  → RDMA/Socket
  → 最小 nccl-tests

只要顺序正确,很多看似复杂的 NCCL 故障,最终都能被还原成更具体、也更可修复的资源或拓扑问题。