看似 NCCL 故障,实际是 Host Memory OOM¶
分布式训练报错时,最后一行日志往往最有欺骗性。
这次现场看到的是 TensorFlow 在初始化 NCCL Communicator 时失败:
第一反应很容易是 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 初始化异常。
因此,下面三句话不能画等号:
排障时要找“最早的异常”,而不是只盯着“最后抛出的异常”。这次最早的有效信号来自宿主机内核,而不是 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 能力:
现场是一台八卡 Blackwell 节点,没有 NVLink;同 NUMA 内 GPU 拓扑为 NODE,跨 NUMA 为 SYS。这意味着通信主要依赖 PCIe,但“没有 NVLink”本身不是故障。更重要的是,所有 GPU Pair 的 P2P Read/Write 都返回 OK。
随后检查真正能够支持硬件故障判断的内核签名:
本次没有匹配到 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 自动探测是否干扰初始化”,可以做一次受控对照:
根据 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 下:
也就是说,训练进程正在让 NVIDIA 驱动注册或锁定大块 Host Memory,但内核无法完成分配。随后同一 Pod cgroup 出现明确的 OOM:
memory: usage 671088640kB, limit 671088640kB
Memory cgroup out of memory: Killed process <PID> (train_process)
把 KiB 换算为 GiB:
usage 与 limit 完全相等,这是比“机器看起来还有空闲内存”更直接的证据:触发的是 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。建议按以下顺序处理:
- 统计每个 Rank 的 RSS、匿名内存、shmem 和 Pinned Memory 增长;
- 降低 Dataset Cache、Reader Worker、Prefetch 深度和单批缓冲区;
- 检查是否每个 Rank 都重复加载了整份索引、词表或样本缓存;
- 给 memory-backed
emptyDir设置sizeLimit; - 确认 Sidecar 没有共享并放大同一个 cgroup 的压力;
- 在峰值可解释后,再调整 Request/Limit,并保留节点系统余量;
- 用完全相同的数据规模和 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 和节点已经恢复¶
应用内存问题处理完以后,再做两层验证。
第一层是最小集合通信测试:
观察所有 Rank 是否完成、带宽是否稳定,以及 NCCL 最终选择了 P2P、SHM、Socket 还是 IB/RoCE。多节点测试必须固定节点、网卡、MTU、GID 与 NCCL 版本,不能拿单节点成功替代跨节点验证。
第二层才是原训练任务回放:逐级恢复 Worker、Prefetch 和 Batch,记录内存峰值与首次 Collective 的时间。只跑一次小 Batch 成功,不能证明训练数小时后不会再次累积到 OOM。
10. 这次排障留下的五条经验¶
ncclCommInitRank是错误边界,不是根因定位结果。- 没有 Xid、AER、掉卡和 P2P 异常时,不要急着复位 GPU 或重启节点。
- Node
MemoryPressure=False不能排除 Container memory limit OOM。 - RDMA Label、Device Plugin 配置和真实 RDMA 设备是三件不同的事。
- 分布式任务必须按“单 Rank 成本 × Rank 数 × Worker 数”估算 Host Memory 峰值。
最终,真正有用的不是给这次错误贴上“NCCL 问题”标签,而是建立一条固定排查链路:
只要顺序正确,很多看似复杂的 NCCL 故障,最终都能被还原成更具体、也更可修复的资源或拓扑问题。