跳到主要内容

L1.8 Linux cgroups、NUMA 与 IRQ 亲和

三维坐标 layer: L1(算力与编排)level: Seniorpillar: 硬件架构

L1.2 讲了 NVLink/PCIe 拓扑;L1.4 提到「GPU 挂在某 NUMA node 下」。本章把操作系统层的隔离与亲和性讲透:为什么 DataLoader 和 GPU 不在同一 NUMA 会吃掉 20%+ 吞吐、为什么网卡中断打在训练核上会让 GPU 利用率「虚高且锯齿」,以及如何用 cgroups / numactl / taskset / IRQ 亲和把这些系统侧损耗一一收口。读完你应当能回答:「同型号两台机器,为什么训练吞吐能差 30%」。

学习目标

  • 前置知识:读过 L1.2(PCIe/NUMA 拓扑)、L0.6(Linux 命令行);会用 htop / nvidia-smi。无需内核开发经验。
  • 学完产出:① 能解释 cgroups v2 如何限制 CPU/内存/IO,以及 K8s resources.limits 如何经 container runtime 落成 cpu.max / memory.max / cpuset.cpus;② 能用 numactl -Hnvidia-smi topo -m 对照 GPU 与 CPU 的 NUMA 归属,并用 /sys/bus/pci/devices/<PCI地址>/numa_node 编程化获取;③ 能写一条「DataLoader 进程绑 NUMA0 + GPU0 同域」的启动命令;④ 能说明 IRQ 硬中断/软中断打满错误 CPU 核时,GPU 利用率虚高、吞吐锯齿的成因与排查路径(/proc/interruptsmpstatperf);⑤ 亲手对比绑核前后 DataLoader 吞吐差异(CPU 即可,无需 GPU)。
  • 阅读姿势:盯住一条主线——「GPU 算力不是孤岛」。CPU 喂数、PCIe DMA、跨 NUMA 访存、网络 IRQ 都在同一条流水线上,任何一环失守都会把 MFU 上限往下拉。本章三个主题(cgroups / NUMA / IRQ)表面各自独立,本质都在回答同一个问题:「这段计算/这次搬运/这个中断,应该由哪个 CPU 核、哪块内存来承接?」

背景与现状

双路 CPU + 8 GPU 的服务器是 AI 训练标配。物理上:

  • 每颗 CPU socket 是一个 NUMA node,本地内存延迟 ~80ns,跨 socket(UPI/QPI/Infinity Fabric)~150ns+,跨节点不仅延迟高约 1.9 倍,可用带宽还受限于 socket 间互联而非内存通道本身
  • GPU 通过 PCIe 挂在某一 NUMA node 的 root complex 下(nvidia-smi topo -mNUMA Affinity 列)。H2D 拷贝的源内存若在错误 node,DMA 前先要走一趟跨 socket 搬运。
  • PyTorch DataLoader 默认在任意 CPU 核上跑,若与 GPU 跨 NUMA,每个 batch 的预处理与 H2D 都在为「错误的物理位置」付税。
  • 网卡(RoCE/IB/以太)与 NVMe 的中断默认可能集中在少数核上;这些核一旦与训练/DataLoader 重叠,软中断(NET_RX)会持续抢占用户态。

📅 时效性说明(截至 2026 年)cgroups v2 已是主流发行版默认——Ubuntu 22.04+、RHEL 9+、Debian 12+ 均默认挂载 unified hierarchy(/sys/fs/cgroup 下不再有 v1 的 cpu,cpuacct 分目录);K8s 自 1.25 起 cgroup v2 GA,1.31 起 v1 进入维护模式,新集群应默认按 v2 心智模型理解。本文所有 cgroup 路径与文件名(cpu.maxmemory.maxcpuset.cpus)均按 v2 口径书写;若你还在维护 CentOS 7 一类 v1 老机器,文件名与层级不同但原理相通。

业界信号:Megatron-LM、DeepSpeed 文档均推荐 numactl --interleave=all--membind 绑定;NVIDIA NGC 容器镜像常带 NVML NUMA affinity 提示;DGX OS / HGX 参考架构默认配置 IRQ 亲和脚本。忽略 NUMA 与 IRQ 是单机「MFU 莫名偏低」的头号系统原因。

两个真实业务症状(先记住现象,读完原理再回来对号入座)

症状 A:同型号两台机器,训练吞吐差 30%。 某团队两台同批次 8×A800 服务器跑同一份代码,A 机 5.2 it/s、B 机 3.6 it/s。nvidia-smi 显示 B 机 GPU 利用率反而「更高」(长期 95%+ 但功耗偏低)。最终定位:B 机上运维改过服务开机脚本,DataLoader worker 全部落在 node1,而 GPU0–3 挂在 node0;同时 irqbalance 被禁用后网卡 24 条 RX 队列的中断全部堆在 0–3 核——恰好是 DataLoader 的核。跨 NUMA 喂数 + IRQ 抢核两项叠加,吞吐掉 30%。修复只用了两条命令(numactl 绑域 + IRQ 亲和迁移),零代码改动。

症状 B:推理服务 P99 毛刺,每 100ms 一次。 在线推理 Pod 设了 limits.cpu: 8,压测时 P50 很好但 P99 周期性飙升,间隔恰好 ~100ms。这是 cgroups v2 cpu.max配额节流(throttling):tokenizer + 前后处理并发用核超过 8 核配额,每个 100ms period 的配额提前耗尽,剩余时间整组线程被冻结到下一 period。cpu.statnr_throttled 持续上涨即为实锤(详见思考题 2)。

原理与架构

2.1 cgroups v2:资源隔离的 Linux 底座

cgroups(control groups)是内核提供的按进程树分配/限制资源的机制。v2 的核心变化是统一层级(unified hierarchy):所有控制器挂在同一棵树上,一个目录就是一个 cgroup,规则不再互相打架。

cgroup v2 控制器关键接口文件作用AI 场景
cpucpu.max(配额/周期)、cpu.weight限制 CPU 时间片 / 按权重分配防止日志 Sidecar 抢 DataLoader 核;也是 P99 毛刺的常见来源
memorymemory.max / memory.high硬/软内存上限防止 host OOM 拖死 GPU 训练进程
ioio.max / io.weight磁盘 IO 限速/权重Checkpoint 写入不饿死数据读盘
cpusetcpuset.cpus / cpuset.mems指定可用 CPU 核与内存 nodeNUMA 亲和的核心手段;K8s CPU Manager 的落地层

两点最常被忽视:

  1. cpu.max 是「时间配额」不是「核的集合」limits.cpu: 8 写成 cpu.max = 800000 100000(每 100ms 最多用 800ms CPU 时间),线程仍可能在 64 个核上「游走」,只是总量受限——限量不限位。真正「圈核」的是 cpuset。
  2. cpuset.memscpuset.cpus 同样重要:只绑 CPU 不绑内存 node,第一次 page fault 时内存仍可能落到远端 node(Linux 默认 first-touch 策略),之后一直付跨 NUMA 税。

K8s 的 resources.requests/limits 最终由 container runtime(containerd/CRI-O → runc/crun)写入上述文件。不写 limits = 与邻居进程争抢整机资源;写了 limits 但不懂 throttling = 平白多一类 P99 毛刺(症状 B)。

2.2 NUMA 亲和:GPU 与 CPU 同域

# 查看 NUMA 拓扑
numactl -H
# 查看 GPU ↔ CPU/GPU 互联矩阵(NV# = NVLink 条数,NUMA Affinity = GPU 归属 node)
nvidia-smi topo -m
# 编程化获取某 GPU 的 NUMA 归属(-1 表示单 NUMA 或 BIOS 未上报)
cat /sys/bus/pci/devices/0000:17:00.0/numa_node

最佳实践

  1. 训练主进程 + DataLoader worker 绑到 GPU 所在 NUMA node 的 CPU 核--cpunodebind + --membind 成对使用)。
  2. 大页内存(可选)在同一 node 预分配,减少 TLB miss。
  3. 多 GPU 单机:每张卡一套 CPU 核区间,避免 8 个 DataLoader 抢同一组核;启动脚本按 local_rank 查 GPU 的 numa_node 后自动生成绑核参数。
  4. pinned memory(pin_memory=True)也要在正确 node 上分配——绑核先于分配,收益才不打折。
# 示例:GPU0 在 NUMA node 0,绑核 0-15,内存只从 node0 分配
numactl --cpunodebind=0 --membind=0 python train.py --local_rank=0

多卡机器上手写 8 条 numactl 既繁琐又易错,生产上通常在启动脚本里local_rank 自动推导

#!/usr/bin/env bash
# auto_numa_launch.sh <local_rank> —— 按 GPU 的 PCI 地址自动绑到同域 NUMA
RANK=${1:-0}
# 取第 RANK 张卡的 PCI 地址(形如 00000000:17:00.0 → 转小写去前导域)
PCI=$(nvidia-smi --query-gpu=pci.bus_id --format=csv,noheader | sed -n "$((RANK+1))p" \
| tr 'A-F' 'a-f' | sed 's/^0000//')
NODE=$(cat /sys/bus/pci/devices/${PCI}/numa_node)
[ "$NODE" -lt 0 ] && NODE=0 # 单 NUMA / BIOS 未上报时回退 node0
exec numactl --cpunodebind=$NODE --membind=$NODE \
python train.py --local_rank=$RANK

验证绑核是否生效numastat -p <pid> 看进程内存的 node 分布;taskset -cp <pid> 看 CPU 亲和掩码。若 numastat 显示大量内存在错误 node,多半是「先分配后绑核」——顺序反了。

验证命令看什么健康状态
numastat -p <pid>各 node 内存占比90%+ 落在 GPU 同域 node
taskset -cp <pid>CPU 亲和掩码只含目标 node 的核区间
nvidia-smi topo -mNUMA Affinity与绑定的 node 编号一致

2.3 IRQ 亲和与软中断

网卡(RoCE/IB)与 NVMe 的 IRQ 默认可能集中落在少数核(历史上常是 CPU0 附近)。若这些核同时跑 DataLoader,硬中断 + 软中断(NET_RX/ksoftirqd) 会持续抢占用户态,表现为 GPU 等数据、利用率锯齿。

# 查看 IRQ 分布(哪个核在吃中断)
cat /proc/interrupts | head
# 按核看软中断占比(%soft 列高 = 该核在给协议栈打工)
mpstat -P ALL 1
# 将某 IRQ 绑到专用核(需 root,生产用 irqbalance 配置文件或 tuned)
echo 2-3 > /proc/irq/123/smp_affinity_list

原则:为「网络 / 存储 IRQ」预留 1–2 个专用核(与 NIC 同 NUMA node),不与训练/DataLoader 重叠;多队列网卡把 RX 队列的中断摊开到这几个核而不是全堆一处。RoCE/IB 的 RDMA 数据面绕过内核协议栈,但控制面与以太回退路径仍走 IRQ,同样需要规划。

动手实践:NUMA 绑核对比 DataLoader 吞吐

无需 GPU,用 CPU 模拟「跨 NUMA 访存惩罚」。

# numa_dataloader_bench.py
import time, os
import torch
from torch.utils.data import DataLoader, TensorDataset

N = 100_000
D = 4096
data = TensorDataset(torch.randn(N, D))
loader = DataLoader(data, batch_size=256, num_workers=4, pin_memory=False)

def bench():
t0 = time.perf_counter()
for i, (x,) in enumerate(loader):
if i >= 200:
break
_ = x.sum()
return time.perf_counter() - t0

print(f"pid={os.getpid()} elapsed={bench():.3f}s")
# 基线:不绑核
python numa_dataloader_bench.py

# 绑到 node0(双路机上对比 node1 通常更慢)
numactl --cpunodebind=0 --membind=0 python numa_dataloader_bench.py
numactl --cpunodebind=1 --membind=1 python numa_dataloader_bench.py

预期:双路服务器上,错误 node 比正确 node 慢 10–30%(视内存带宽与 worker 数而定)。单路机差异较小。

踩坑预警

  1. --membind--interleave=all 混用:interleave 适合多 GPU 均分读盘;单卡训练应 membind 到 GPU 同域。
  2. K8s 未设 CPU manager static policy:容器内看到的 CPU 列表可能是整机,绑核需配合 CPU Manager 或 guaranteed QoS(详见思考题 2)。
  3. num_workers 过大:超过物理核数反而因上下文切换变慢;经验值 4–8 起步按 profiling 调整。
  4. pin_memory=True 但 CPU 不对域:pinned memory 仍在错误 NUMA 分配,H2D 收益打折。
  5. 容器里 numactlset_mempolicy: Operation not permitted:默认 seccomp/capability 拦了内存策略系统调用,需要 --cap-add SYS_NICE 或调整 seccomp profile。

配套代码ai-infra-labs numa_dataloader_bench.py

深入思考

下面三题每题先给题干,再用 <details> 折叠一份图文并茂的参考答案。建议先合上答案自己想 3 分钟,再展开对照。

思考题 1:把「跨 NUMA 喂数」的损失算出来

症状 A 里,B 机 DataLoader 跨 NUMA 后吞吐掉了约 30%,但跨节点内存延迟「只是」从 ~80ns 涨到 ~150ns——不到 2 倍。结合 2.2 节,从「带宽受限而非延迟受限」的角度,量化估算:一个 batch 的数据在预处理 + H2D 路径上要被搬多少字节?跨 socket 互联的有效带宽比本地内存低多少?为什么最终体现为整条流水线 30% 左右的吞吐损失而不是 2.7 倍变慢?

展开参考答案(含跨 NUMA 数据通路图 + 算一遍)

结论:DataLoader 是带宽型负载——跨 NUMA 后每字节都要挤 socket 间互联(UPI/xGMI),有效带宽可能只有本地内存的 1/3,喂数环节单看会慢 2 倍以上;但喂数只占整条训练流水线的一部分(且与 GPU 计算部分重叠),按 Amdahl 定律摊薄后,整体表现为 20–30% 的吞吐损失——「不致命但稳定亏损」,这正是它难被发现的原因。

用具体数字算一遍(数量级估算,脚本口径同 3.x 实验):

  1. 一个 batch:256 × 4096 × 4 字节(FP32)= 4 MiB;预处理读一遍 + 写一遍 + H2D 读一遍,按搬运 3 次算 → 每 batch 有效搬运 12 MiB;200 个 batch 共 ~2.34 GiB
  2. 本地路径按有效带宽 40 GB/s(多 worker 实际可用份额):2.34 GiB ÷ 40 GB/s ≈ 0.063s;跨 socket 有效带宽按 15 GB/s(UPI 若干条 lane、还要与其他流量共享):≈ 0.168s——喂数环节慢 2.7 倍
  3. 但训练 step 时间 = 「GPU 计算」与「喂数」的流水重叠。设喂数在基线中占关键路径的 30%:跨 NUMA 后整体时间 ≈ 0.7 + 0.3 × 2.7 = 1.5 倍,吞吐 ≈ 1/1.5 ≈ 67%——恰好是「掉 30%」的量级,与症状 A 吻合。
  4. 反过来读:延迟(80ns→150ns,1.9 倍)只解释小数据随机访问的恶化;DataLoader 这种顺序大块搬运是带宽受限,看的是 UPI 有效带宽与本地内存带宽之比。这也是为什么 numastat -p 里「错误 node 上的内存占比」比「延迟测试」更能直接预测损失。

排查动线nvidia-smi topo -m 拿 GPU 归属 → taskset -cp / numastat -p 查 worker 的核与内存分布 → 对不上就回 2.2 节numactl --cpunodebind --membind 成对绑定,并用 /sys/bus/pci/devices/<PCI地址>/numa_node 在启动脚本里自动化。

思考题 2:cgroups v2 cpuset 与 K8s CPU Manager 是怎么「握手」的?

K8s 里一个 limits.cpu: 8 的训练 Pod,容器内 nproc 却显示 64;另一个集群同样的 Pod nproc 显示 8 且核编号固定。结合 2.1 节,解释这两种行为分别对应 cgroup v2 的哪些接口文件、K8s CPU Manager 的 none / static 策略各写了什么,以及为什么「拓扑感知调度(L1.4)+ static CPU Manager + 容器内 numactl」三层要各管一段才能把 NUMA 亲和真正落地。

展开参考答案(含 K8s→cgroup 三层落地图 + 算一遍)

结论:none 策略只写 cpu.max(时间配额,限量不限位),所以容器看得见 64 核、线程四处游走还可能被 100ms 周期节流;static 策略(需 Guaranteed QoS + 整数 CPU)额外写 cpuset.cpus,把 8 个具体的核独占划给容器——但 K8s 只保证「给你哪 8 个核」,Pod 落在哪台机、这 8 个核与 GPU 是否同 NUMA、进程怎么用这 8 个核,分别是调度器、CPU Manager(配 Topology Manager)与容器内 numactl 三层各自的职责,缺一层 NUMA 亲和就断一环。

两种行为对号入座

现象策略cgroup v2 实际写入后果
nproc=64,limits.cpu: 8none(默认)cpu.max = 800000 100000cpuset.cpus 为整机线程可在 64 核游走(缓存/NUMA 不亲和),且超配额即节流
nproc=8,核编号固定static + Guaranteed QoS + 整数 CPUcpuset.cpus = 0-7(独占,从共享池剔除)核固定可绑 NUMA;无游走;仍需第 3 层摆放 worker

用具体数字算一遍cpu.max 节流如何制造症状 B 的 100ms 毛刺):

  1. limits.cpu: 8cpu.max = 800000 100000:每 100ms 周期内全组线程合计最多 800ms CPU 时间。
  2. 高峰时 tokenizer + 后处理并发起 16 个线程都想满载:需求 1600ms/周期,配额 800ms → 周期进行到 50ms 时配额耗尽,所有线程被冻结到下一周期。
  3. 一个需要 60ms CPU 时间的请求恰好跨过节流点:实际墙钟 = 前 50ms 运行 + 50ms 冻结 + 10ms 收尾 = 110ms,比理想的 60ms 慢 83%——P50 正常、P99 按 100ms 周期打卡飙升。cat cpu.statnr_throttled / throttled_usec 持续增长即可实锤。
  4. 对训练 Pod 的启示:要么给足整数核并用 static 圈死,要么盯住 cpu.stat;「配额略小于真实并发需求」是最隐蔽的慢性病。

为什么三层各管一段:调度器只决定「哪台机」;static CPU Manager(配 Topology Manager 的 single-numa-node 策略)决定「哪几个核、尽量与 GPU 同 NUMA」;但容器内 PyTorch 默认不会把 4 个 DataLoader worker 均匀摆在这些核上、更不会绑 cpuset.mems——最后一米仍要 2.1/2.2 节的 numactl/cpuset 手艺收尾。三层齐备,症状 A/B 都不会发生。

思考题 3:IRQ 风暴打在训练核上——症状、量化与排查

一台 100GbE 网卡的训练机,nvidia-smi 看 GPU 利用率 90%+ 但吞吐上不去,mpstat -P ALL 1 显示 0–3 核 %soft 高达 40%。结合 2.3 节,回答:① 100Gbps 线速收包对 CPU 到底是多大的负担(有无 GRO 差多少)?② 为什么 GPU 利用率会「虚高」?③ 给出从 /proc/interrupts 到修复的完整排查动线。

展开参考答案(含中断抢核时序图 + 算一遍)

结论:100Gbps 按 1500B MTU 线速是每秒 833 万包,无 GRO 时理论上要吃掉十几个核;GRO/LRO 聚合成 64KB 大帧后降到 ~3 个核——这几个核若与 DataLoader 重叠,用户态被软中断持续切走,喂数变慢;GPU 因「等数据的空转 kernel 也算 busy」而利用率虚高。修复 = 把 IRQ 迁到与 NIC 同 NUMA 的 1–2 个专用核,并让 DataLoader 避开它们。

(上图为示意:中断 → 软中断 → 抢占 → GPU 空等的因果链。)

用具体数字算一遍

  1. 100Gbps = 12.5 GB/s。按 1500B MTU:12.5e9 ÷ 1500 ≈ 833 万包/秒;每包协议栈处理按 ~1.5µs 算 → 8.33e6 × 1.5e-6 ≈ 12.5 个核——不开聚合根本喂不动
  2. 开 GRO/LRO 聚合成 ~64KB 大帧:12.5e9 ÷ 65536 ≈ 19.1 万帧/秒,每帧 ~15µs → 约 2.9 个核。这就是为什么「预留 1–2 核 + 多队列摊开」在 25–100GbE 上是可行解(25Gbps 时约 0.7 核)。
  3. 反过来:若这 ~3 核的软中断负载全堆在 0–3 核、又恰与 DataLoader 重叠(%soft 40% 正对应此量级),worker 有效 CPU 时间少了近一半——喂数管线直接退化。

为什么 GPU 利用率「虚高」nvidia-smi 的 GPU-Util 统计的是「采样窗口内有 kernel 在跑的时间占比」,不衡量算得满不满。喂数断供时,框架里的等待/小 kernel、通信轮询仍让 GPU 显得 busy,但功耗、SM 效率(ncu / DCGM 的 SM ActiveTensor Core Util)与 it/s 都低——「利用率高 + 功耗低 + 吞吐低」三联征基本可断定在等数据

排查动线(对照 2.3 节命令):

步骤命令看什么
1. 找中断堆在哪cat /proc/interrupts某几核计数远高于其他核(网卡多队列全落少数核)
2. 量化软中断占比mpstat -P ALL 1%soft 高的核编号是否与 DataLoader 绑核重叠
3. 确认抢占对象pidstat -t -p <dl_pid> 1 / perf top -C 0worker 的非自愿上下文切换、net_rx_action 热点
4. 修复echo 2-3 > /proc/irq/<n>/smp_affinity_list(或 tuned/irqbalance 策略)IRQ 迁到与 NIC 同 NUMA 的专用核
5. 复验mpstat + 训练 it/s训练核 %soft 归零,吞吐锯齿消失

延伸阅读

1. 一手文档

  • Linux 内核文档 Control Group v2Documentation/admin-guide/cgroup-v2.rst)— cpu.maxcpuset、统一层级的权威定义。
  • Kubernetes 官方文档 CPU Management PoliciesTopology Manager — static 策略、single-numa-node 的启用条件与限制。
  • NVIDIA DGX 系统性能调优指南 — IRQ 亲和、NUMA 绑定在参考机型上的官方配置。

2. 相关工具与源码路径

  • numactl / numastat / hwloclstopo 可视化 NUMA-PCIe 树)— 定位与验证亲和性的三件套。
  • kubernetes/kubernetespkg/kubelet/cm/cpumanager/,看 static 策略如何分配独占核并写入 cpuset。
  • tunedthroughput-performance / hpc-compute profile — 生产级 IRQ/调度参数模板。

3. 站内关联


下一篇 → 回到 L1 层目录:cgroups/NUMA/IRQ 是单机系统侧的最后一块拼图——把它与 L1.2 的拓扑、L1.4 的调度串起来,你就拥有了「从 Pod spec 到内存控制器」的完整视角。