Skip to main content

录屏回看

本章概要

即使 GPU kernel 和库已经调到极致,系统层的瓶颈仍会拖累大规模训练与推理。真正决定 GPU 利用率的,往往是操作系统、驱动运行时、容器和集群调度的配置。本章逐层来看这些配置:
  • CPU / 操作系统:保证 CPU 持续为 GPU 供给数据和任务——NUMA 绑定、pinned memory、透明大页、调度与中断亲和性、虚拟内存与 swap、文件系统缓存、CPU 频率与内存分配器;
  • GPU 驱动与运行时:提升单卡的性能与利用率——持久化模式、MPS / MIG 共享与切分、GPU 时钟与 ECC;
  • 容器运行时:让容器以接近裸机的性能访问 GPU——NVIDIA Container Toolkit、overlay FS 与网络开销;
  • Kubernetes:在集群层面分配 GPU——GPU Operator、Device Plugin、DRA、资源隔离与 QoS,以及从节点内 NUMA 到跨节点网络的拓扑感知调度。

1. CPU 与操作系统调优

GPU 利用率不足的一个常见根源在 CPU 端:CPU 没能及时、持续地为 GPU 供给数据和任务,GPU 于是被迫空等。训练和推理的具体形态不同,但影响 GPU 计算的 CPU 侧任务主要集中在几类:
  • 准备输入:训练时读取样本、tokenize、图像解码、数据增强和 batch 组装;推理时处理请求解析、tokenize、padding 和动态 batching。输入准备跟不上时,GPU 只能等待下一批数据或请求。
  • 提交 GPU 任务:CPU 调用 CUDA API 完成 kernel launch、memory copy 和 stream synchronization,并把这些任务排进 CUDA stream。只有 CPU 完成提交后,GPU 才能开始执行对应 kernel。大量小 kernel 或频繁同步会放大 CPU 开销,导致 GPU 间歇性空闲。
  • 搬运输入数据:输入通常先在 CPU memory 中生成,再通过 CPU → GPU copy 放入 GPU memory。
  • 分布式通信:多 GPU 训练或分布式推理中,CPU 侧会发起 collective 或点对点通信,并依赖专门的通信辅助线程维护通信进度。这些线程被调度打断或阻塞时,GPU 可能因为等待其他 rank 或 device 而闲置。
  • 处理设备中断:NIC/GPU 相关 interrupt 也需要 CPU 响应。如果中断落在远离设备或关键输入/通信线程的 NUMA node 上,或者频繁打断这些关键线程,都会增加访问延迟和调度开销,进而拉长 GPU 等待时间。
如果这些 CPU 侧任务变慢、出现抖动,或者 OS 调度不稳定,昂贵的 GPU 就可能长时间处于空闲,CPU 与操作系统调优的目标,是让输入管道、GPU 任务提交和通信辅助线程更稳定、更高效地运行,从而减少 GPU 等待时间。

1.1 NUMA 感知与 CPU 绑定

1.1.1 什么是 NUMA

数据中心服务器通常是多路的 NUMA(Non-Uniform Memory Access,非统一内存访问)系统:每颗物理 CPU 拥有自己的内存控制器,直接连接一部分本地内存。每个 CPU 可以通过自己的内存控制器快速访问本地内存,也可以通过节点间链路访问另一个 CPU 的远程内存,但本地和远程内存的访问延迟和带宽不同,这就是”非统一”的含义。 双 socket NUMA 架构:每个 CPU Package 拥有独立的内存(DIMM)和核心,构成一个 NUMA 节点 NUMA 节点是 CPU、GPU、NIC 和内存的逻辑分组,这些组件在物理上彼此靠近。访问同一 NUMA 节点内的资源比跨节点访问要快得多。例如,一个运行在 NUMA 节点 0 的 CPU 上的进程如果需要访问 NUMA 节点 1 的 GPU,数据就必须通过节点间链路传输,会产生更高的延迟。实际上,跨 NUMA 节点的内存访问延迟几乎可以翻倍

1.1.2 查看系统 NUMA 拓扑

你可以用以下两个命令结合查看系统的完整 NUMA 拓扑:
numactl -H 的输出示例(4 个 NUMA 节点的服务器):
nvidia-smi topo -m 的输出示例(5 个 GPU 的服务器):
  • 矩阵区域(GPU 之间的交叉格)— GPU 间的互连类型,与 Legend 对应:
    • X = 自己跟自己
    • SYS = 经过 PCIe + 跨 NUMA 节点的 CPU 互连,如 QPI/UPI(最慢)
    • NODE = 经过 PCIe + 同一 NUMA 节点内的 Host Bridge 互连
    • PHB = 经过 PCIe Host Bridge,即经过 CPU(同一 NUMA 节点内)
    • PXB = 经过多个 PCIe bridge,但不经过 Host Bridge
    • PIX = 经过最多一个 PCIe bridge(同一 PCIe switch 下)
    • NV# = 通过 # 条 NVLink 直连(最快,如 NV4 = 4 条 NVLink)
  • CPU Affinity 列 — 该 GPU 关联的 CPU 核心编号,如 GPU0 对应核心 48-63 和 112-127
  • NUMA Affinity 列 — 该 GPU 属于哪个 NUMA 节点,这就是你做 numactl 绑定时需要的值

1.1.3 使用 numactl 进行 CPU pinning

确认了 GPU 所在的 NUMA 节点后,需要显式指定 NUMA 亲和性(NUMA affinity)——将进程或线程分配到与 GPU 相同 NUMA 节点上的 CPU 核心。这种做法称为 CPU pinning。可以使用 numactl 实现,两个关键参数:
  • --cpunodebind=<node> — 将进程的 CPU 线程限制在指定 NUMA 节点的核心上运行,防止 OS 调度器把线程迁移到其他节点。
  • --membind=<node> — 将进程的内存分配限制在指定 NUMA 节点的本地 RAM 上,防止内存被分配到远程节点。
两者配合使用,确保 CPU 执行和内存访问都在 GPU 所在的本地 NUMA 节点内完成。

1.1.4 验证 NUMA 绑定的性能影响

可以使用下面的脚本对比 NUMA 绑定对 CPU→GPU 数据拷贝带宽的影响,保存为 numa_bench.sh 后运行:
在我们的测试机器上输出如下:
这台机器(AMD EPYC 7742)上两者差异很小(12.4 vs 12.3 GB/s,仅 ~0.3%),因为 4 个 NUMA 节点在同一个 CPU socket 内通过 Infinity Fabric 互连,节点间距离只有 10 vs 12,跨节点访问的额外开销很低。在真正的双 socket 系统上(节点间距离 10 vs 20+),差异会更显著。

1.1.5 在 PyTorch DataLoader 中绑定 NUMA

书中给出了一个完整的 Python 示例(约 100 行),配套代码 ch03/bind_numa_affinity.py 是其完整实现。整个流程分三步: 第一步:查询 GPU 所在的 NUMA 节点 启动时,进程需要知道当前 GPU 连接在哪个 NUMA 节点上。get_gpu_numa_node() 按优先级依次尝试四种方式:
  1. NVML 直接查询 — 调用 nvmlDeviceGetNumaNodeId(),直接获取 GPU 的 NUMA 节点编号。仅适用于 GPU 本身是 NUMA 节点的平台(如 Grace Hopper/Grace Blackwell 超级芯片)
在传统架构(A100、H100 等)中,GPU 是一个 PCIe 设备,它的显存(HBM)对 Linux 来说是”设备内存”,不在系统 NUMA 拓扑里——numactl -H 看到的只有 CPU 和 CPU 内存的 NUMA 节点,看不到 GPU:
在 Grace Hopper(GH200)架构中,CPU 和 GPU 通过 NVLink-C2C 连接,GPU 的 HBM 被 Linux 内核直接注册为一个 NUMA 节点。numactl -H 会多出一个节点,那个节点的”内存”就是 GPU 的 HBM:
参考资料:
  1. NVML CPU 亲和性推断 — 如果上一步不可用,调用 nvmlDeviceGetCpuAffinity() 获取该 GPU 关联的 CPU 掩码(bitmask),然后与 sysfs 中的 CPU→NUMA 映射表对照,取出现次数最多的 NUMA 节点
输出示例:
  1. sysfs 内核接口 — 如果 NVML 完全不可用,直接读 /sys/bus/pci/devices/<PCI_ID>/numa_node,这是 Linux 内核为每个 PCI 设备维护的 NUMA 节点信息
在我们的测试机器(AMD EPYC 7742 + 5 张 GPU)上输出如下:
可以看到 5 张 GPU 分布在 4 个 NUMA 节点上,其中 GPU 3 和 GPU 4 共享节点 0。这和前面 nvidia-smi topo -m 输出的 NUMA Affinity 列一致。
  1. 当前进程策略兜底 — 如果以上都失败,运行 numactl --show 读取当前进程的 preferred node。例如如果外部已经用 numactl 命令启动了脚本(如 numactl --cpunodebind=0),进程会继承这些策略,preferred node 就是正确的值
第二步:绑定主训练进程的 CPU 和内存 bind_process_to_node() 做两件事——限制 CPU 核心 + 限制内存分配节点。其中 _libnuma 是 Linux 系统库 libnuma.so 的 Python 绑定(通过 ctypes.CDLL("libnuma.so") 加载),numactl 命令行工具底层也是调用它。在 Python 进程内部需要用它来动态设置 NUMA 内存策略,因为 numactl 只能在启动进程时从外部指定策略,无法在已运行的子进程内部重新绑定:
第三步:在每个 DataLoader worker 中重新绑定 worker 是独立的子进程,不一定继承主进程的 NUMA 策略(尤其是 spawn 模式下)。通过 worker_init_fn 在每个 worker 启动时显式重新绑定:
把三步串起来:
这样主进程、DataLoader worker、GPU 三者都在同一个 NUMA 节点上,数据从磁盘 → CPU 内存 → GPU 全程本地访问。
在一台双 socket(2 个 NUMA 节点、每节点挂 2 张 GPU)的机器上,每个 rank 会绑到自己 GPU 所在的节点,输出类似:
前四行是 4 个 DDP rank 的主进程,各自绑到自己 GPU 所在的节点(rank 0、1 → node 0,rank 2、3 → node 1);后面的 Worker 是每个 rank 的 DataLoader worker 进程,由所属 rank 绑定,因此跟随该 rank 落到同一个节点。这样每个 rank 连同它的 worker 都和自己的 GPU 就近对齐。

1.2 页锁定内存(Pinned memory)

创建 CPU tensor 时,tensor 内容会被放入内存。这里的内存指的是由 MMU(内存管理单元,Memory Management Unit)管理的一套虚拟内存(virtual memory)抽象:
  • RAM 与交换空间(swap space) 共同组成虚拟内存。它让程序看到的可用空间大于单独的物理 RAM。
  • 普通 CPU tensor 默认是 pageable 的。Tensor 内容会被切成 page,这些 page 可以位于 RAM,也可以位于交换空间。
  • Page fault:通常,当程序试图访问不在 RAM 中的 page 时,就会发生 page fault。此时,OS 会将该 page 调入 RAM。相应地,为了给新 page 腾出空间,OS 可能不得不将另一个 page 调出 RAM。
Pinned memory 的作用是让这块内存稳定留在 RAM 中,不被换出到磁盘。
  • Pinned memory 也叫 page-locked 或 non-pageable memory。它不能被换出到磁盘,因此访问时间更快、更可预测。
  • 代价是容量更受限制。Pinned memory 会减少系统可自由分页的内存,不能无限制使用。
CUDA 从 CPU 向 GPU 拷贝 tensor 时,对 pageable memory 和 pinned memory 的处理方式不同:
  • 如果源数据在 page-locked memory 中,device 可以直接访问 RAM 中地址稳定的数据。
  • 如果源数据在 pageable memory 中,相关 page 必须先回到 RAM,然后才能发送到 GPU。更准确地说,CUDA 会先为 pageable data 创建一份 page-locked copy,再执行 CPU → GPU transfer。
下图展示了这条路径里的三类内存区域:
普通 pageable tensor 先被 pin 到 page-locked CPU memory,再从 pinned memory 传输到 GPU memory

Pinned memory(也称 page-locked 或 nonpageable memory)是一类不能被换出到磁盘的内存。

  • 浅黄色区域:Pageable memory。普通 CPU 内存,属于虚拟内存体系。它的页可能在 RAM,也可能被换出到 Disk,操作系统也可能移动这些页。
  • 浅绿色区域:Pinned memory / page-locked memory。仍然是 CPU RAM,但这些页被锁住,不能被 swap,也不能被随意迁移。
  • 图右侧区域:GPU memory。CUDA tensor 真正所在的显存。
图中的小块表示 tensor 当前所在的位置:
  • 蓝色和紫色小块:还在 pageable memory 里的 paged tensor。
  • 红色小块:已经 pin 住的 page-locked tensor。
  • 橙色小块:GPU 上的 CUDA tensor。
图中的箭头表示数据移动:
  • 蓝色箭头pin_memory() 把 pageable tensor 锁进 pinned memory。
  • 红色箭头.to("cuda") 把 pinned tensor 拷贝到 GPU。
在 PyTorch 的数据加载中,给 DataLoader 传入 pin_memory=True 会自动把取回的 tensor 放到 pinned memory 中,从而让它们更快地传输到支持 CUDA 的 GPU。 此外,一旦 tensor 位于 pinned memory 中,就可以使用异步 GPU copy。只需要在 .to().cuda() 调用中额外传入 non_blocking=True。这可以用来让数据传输与计算重叠。
参考资料:

1.3 透明大页(Transparent Hugepages)

Linux 内存管理通常使用 4 KB 的页;但当进程使用数十或数百 GB 内存时,例如深度学习数据集、预取批次、模型参数等,管理数以百万计的小页会非常低效。 透明大页(Transparent Hugepages,THP)是一种自动使用大页的机制。大页——2 MB 甚至 1 GB 的页——可以通过增大内存块来减少虚拟内存管理的开销。主要好处是减少缺页中断,并减轻 TLB(translation lookaside buffer)的压力。

1.3.1 TLB 地址转换

TLB 是 CPU 用来把虚拟地址映射到物理地址的缓存。页更少、更大时,同样数量的 TLB 条目可以覆盖更多内存,从而减少 TLB miss。
TLB hit一个虚拟内存地址(virtual memory address)进入后,需要被翻译成物理地址(physical address)。第一步总是把虚拟地址拆成两部分:虚拟页号(virtual page number)和页偏移(page offset)。页偏移由虚拟地址的最后几位组成。偏移位不会被翻译,而是直接传递到物理内存地址中;因为页表只负责把虚拟页号映射到物理页号,页内的相对位置保持不变。因此,偏移量会直接映射到物理内存层,而虚拟页号则与 TLB 中已有的标签相对应。这样一来,MMU 无需查询全局内存,就能立即知道该访问哪个物理内存页。
TLB hit:虚拟页号在 TLB 中找到匹配条目后直接得到物理页号
在上图的例子中,虚拟页号在 TLB 中被找到,并立即转换成物理页号。TLB miss当虚拟页码在 TLB 中找不到时,就会发生所谓的“TLB 未命中”情况。此时,TLB 必须查询系统的物理内存,以确定对应的物理页码是多少。与 TLB 命中相比,这种查询方式会导致更高的延迟。如果 TLB 已满且发生 TLB 未命中,那么 TLB 中最近最少使用的数据条目会被清除,新的数据条目则会取而代之。在下面的例子中,虚拟页码在 TLB 中不存在,因此 TLB 必须查询物理内存来获取该页码。
TLB miss:虚拟页号未命中 TLB 后需要访问页表并把映射写回 TLB
参考资料:How is Virtual Memory Translated to Physical Memory?

1.3.2 THP 配置

  • 收益幅度:Hugepages 通常带来的是中等幅度收益,吞吐提升常见在 3%–5% 左右。
  • Pinned memory 限制:使用大块 pinned memory 时,需要把 ulimit -l 调高或设为 unlimited;如果这个限制过低,pin memory 可能失败,进而回退到可交换内存,或者触发 OOM。
  • 推理延迟风险:THP 的后台 compaction 可能引入不可预测的暂停,这对延迟敏感的 LLM 推理是高风险。
  • Defrag 策略defrag 控制内核是否为了分配 THP 做内存整理。THP 需要连续的物理内存,如果当前内存碎片较多,内核可能触发 compaction 来凑出连续空间,这个过程会增加延迟抖动。
  • 训练与推理取舍:Linux 默认会尽可能自动分配 2 MB 的 THP。吞吐优先的训练 workload 通常启用 THP;延迟优先的推理 workload 通常完全禁用。
在内存池非常大的场景中,例如用于 I/O 的预分配固定缓冲区,也可以考虑使用显式 hugepages(HugeTLB),例如 vm.nr_hugepageshugetlbfs,以获得更可预测的性能。它和 THP 不同:THP 由内核自动尝试分配大页;显式 hugepages 需要提前预留,并由应用或 runtime 明确使用。

1.4 调度器与中断亲和性

CPU 侧抖动通常来自三个方面:
  • 线程调度:关键数据管道线程或通信辅助线程如果长时间排队、频繁被抢占,就会形成 CPU 侧调度抖动,进而让 GPU 因等待输入或通信而空闲。
  • CPU 隔离:这些线程如果和其他 workload 混跑在同一组 CPU 上,容易争抢 CPU core、cache 和内存带宽,响应时间会变得不稳定。
  • 中断亲和性:GPU/NIC 中断请求(Interrupt Request,IRQ)如果由远离设备的 CPU 处理,或者频繁打断关键线程,会增加额外延迟。

1.4.1 线程调度

在繁忙的系统上,需要确保数据管道线程等重要线程不会被频繁中断。Linux 默认使用完全公平调度器(Completely Fair Scheduler,CFS),对大多数情况都有效。 但如果有一个对延迟非常敏感、例如需要为 GPU 提供数据的线程,可以考虑对该线程使用实时的先进先出(FIFO)轮转(RR) 优先级调度。这样可以确保高优先级线程在不被普通优先级线程抢占的情况下运行。
CFS、FIFO、RR 三种调度策略的对比示意图
完全公平调度器(Completely Fair Scheduler,CFS) 面向普通线程,目标是让 runnable task 获得相对公平的 CPU 时间。它会跟踪每个 task 的虚拟运行时间(virtual runtime,vruntime);运行得越少的 task,vruntime 越小,越容易被选中。任务进入 runnable 状态后,会被放入红黑树(red-black tree)。nice 值通常范围是 -2019,数值越小,普通线程的调度优先级越高。每个 nice 值对应一个权重,nice=0 的权重是 1024。Linux 使用预定义的 nice 权重表,相邻 nice 级别大约相差 1.25 倍:nice 每降 1,权重约增加 25%;nice 每升 1,权重约减少 20%。nice 权重如下:关键公式可以近似写成:
例如两个线程都实际运行 10 ms
低 nice 值线程的权重更大,同样运行 10 msvruntime 增加更少,因此更容易保持在红黑树左侧,也更早再次被 CFS 选中运行。
先查看线程当前运行在哪些 CPU 上,以及调度策略、实时优先级和 nice 值:
示例输出:
字段含义:
  • PSR:线程当前运行过的 CPU 编号。
  • POL:调度策略。TS 是普通 CFS 时间共享策略,FF 对应 SCHED_FIFORR 对应 SCHED_RR
  • RTPRIO:实时优先级。普通 CFS 线程通常显示为 -
  • NI:nice 值,只影响普通 CFS 线程的权重。
普通 CFS 线程通常先通过 nice 调整权重:
对极少数延迟敏感的线程,可以用 chrt 设置实时 FIFO 或 RR 策略:
chrt -p <tid> 的输出示例:
不过要谨慎使用,因为实时线程如果管理不当,可能会使其他进程饥饿。实际中,如果已经将关键线程绑定到专用 CPU core,通常不需要再调整实时线程优先级。

1.4.2 CPU 隔离

CPU 隔离的目标,是将关键输入线程、通信辅助线程与其他工作负载尽量分开运行,减少它们受到调度器负载均衡、内核工作队列、定时器和中断请求等系统噪声的干扰。
  • 临时绑定taskset 适合排查和实验,可以把进程或线程绑定到指定 CPU。
  • 生产隔离:cgroup cpuset 更适合生产环境。cpuset.cpus 约束任务可运行的 CPU,cpuset.mems 约束可使用的 NUMA memory node。
  • 容器约束:容器环境中可以用 docker run --cpuset-cpus 固定 CPU 范围,用 --cpuset-mems 固定 memory node。
  • 启动参数isolcpusnohz_fullirqaffinity 可以做更强的启动期隔离,但运行期调整不如 cgroup 灵活,适合非常明确的低延迟节点。
如果需要比 cgroup 更强的启动期隔离,可以通过内核启动参数配置 isolcpusnohz_fullrcu_nocbsirqaffinity。这些参数需要写入 /etc/default/grub 中的 GRUB_CMDLINE_LINUX,更新 GRUB 并重启后生效。
参数含义可以这样理解:
  • isolcpus=8-15:让普通调度器尽量不要把普通任务放到 CPU 8-15 上。
  • nohz_full=8-15:减少这些 CPU 上的周期性调度时钟中断。
  • rcu_nocbs=8-15:把 RCU callback 从这些 CPU 上移走,减少内核后台干扰。
  • irqaffinity=0-7:默认把 IRQ 放到 CPU 0-7,让 CPU 8-15 更干净。
注意 isolcpus=8-15 不会自动把训练/推理进程放到 CPU 8-15。它只是减少普通系统任务对这些 CPU 的占用。真正把 workload 放进去,还是要配合 taskset、cgroup cpuset 或容器的 --cpuset-cpus

1.4.3 中断亲和性

IRQ(Interrupt Request)是硬件或内核子系统通知 CPU 处理事件的一种机制。CPU 收到中断后,会暂停当前正在执行的普通任务,转去执行对应的中断处理逻辑。 常见 IRQ 类型包括:
  • 设备中断:来自 NIC、GPU、NVMe 等硬件设备。例如网卡收到数据、发送完成、RDMA 操作完成事件,或者 GPU 任务完成、错误和事件通知。
  • 定时器中断:来自系统 timer,用于调度器计时、时间片管理和周期性内核任务。
  • 核间中断(Inter-Processor Interrupt,IPI):一个 CPU core 向另一个 CPU core 发送的中断,常见于 TLB shootdown、调度唤醒等场景。
  • IRQ affinity:控制某个 IRQ 由哪些 CPU core 处理。
中断亲和性的调优目标,是把 GPU/NIC IRQ 绑定到设备所在 NUMA node 上的 CPU core,避免由远端 NUMA node 处理设备中断。否则,设备在 NUMA node 0 上触发中断,却由另一个 NUMA node 上的 CPU 处理时,会引入跨节点通信和缓存一致性流量。

1.5 虚拟内存与 Swap

不言而喻,应尽量避免发生内存交换。一旦进程的部分内存被换出到磁盘,性能往往会出现灾难性的、跨数量级的下降。GPU 程序通常会分配大量主机内存用于数据缓存;如果操作系统将其中一部分换出到磁盘,那么当 GPU 相关流程再次需要访问这些数据时,就会遭遇巨大的延迟。
vm.swappiness 控制 Linux 在内存压力下使用 swap 的倾向,取值通常是 0100。数值越高,内核越倾向于把匿名页换出到 swap;数值越低,内核越倾向于把数据留在 RAM 中。建议设置 vm.swappiness=0,它告诉 Linux 除非遇到极端内存压力,否则应尽量避免 swap。需要强调的是,vm.swappiness=0 不等于完全禁用 swap;它只是降低内核使用 swap 的倾向,在极端内存压力下仍然可能发生 swap。 如果需要完全关闭 swap,需要使用 swapoff -a,或者在 cgroup v2 中设置 memory.swap.max=0

1.6 文件系统缓存

对于大型训练任务,最佳实践是频繁将 checkpoint 写入磁盘,以便在需要时能从已知的良好 checkpoint 重新启动失败任务。然而,在进行 checkpoint 写入时,大量数据的突发写入可能会填满操作系统 page cache 并导致停顿。 常见解决方案包括: 继续使用 page cache,但降低冲击
  • 调整写回阈值:调整 dirty page 阈值,控制内核什么时候开始后台写回,以及什么时候阻塞写入进程。
  • 异步写 checkpoint:使用 PyTorch Distributed Checkpoint(DCP)async_save,把 checkpoint 写入从训练主 loop 中移出去。async_save() 会返回 future,需要控制并发 checkpoint 数量,避免 CPU memory 压力叠加。
  • 分片写 checkpoint:使用 PyTorch Distributed Checkpoint(DCP),让多个 rank 写各自的 checkpoint 分片,避免单 rank 或单文件形成过大的突发写入。
  • 丢弃 checkpoint cache:写完后调用 posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED),提示内核尽快丢弃 checkpoint 对应的 page cache。
下面示例只保留训练 loop 中的核心结构。AppState 是对 model 和 optimizer 的 Stateful 包装,用来让 DCP 保存和恢复分布式 state dict。
DCP 的异步 checkpoint 会先把模型复制到内部 CPU buffer,保证 checkpoint 写入期间模型和 optimizer 权重不会继续变化;代价是 CPU memory 会随着 checkpoint_size_per_rank × rank 数量 增加。如果模型很大,普通 async_save() 的 GPU → CPU staging 仍可能阻塞训练 loop。PyTorch 2.9 引入了 DefaultStager,可以把 state dict 创建和 GPU → CPU copy 也放到后台线程中:
DefaultStager 会引入后台线程,占用额外 CPU 资源。训练节点需要预留足够 CPU core,否则异步 checkpoint 本身也可能影响输入管道或通信辅助线程。
在 Linux page cache 语境里,writeback 可以翻译为后台写回。它指内核把 page cache 里的 dirty page 异步写回到磁盘或后端存储。应用写文件时,数据通常会先进入 page cache,写调用可以较快返回;随后内核在后台把 dirty page 写回存储。当 dirty page 太多,或者后端存储写入跟不上时,内核可能开始限制新的写入,训练进程就会看到 checkpoint 写入停顿。
完全绕过 page cache
  • Direct I/O:延迟敏感的训练 workflow 可以评估 O_DIRECTio_uringGPUDirect Storage
  • 适用条件:这类方式需要文件系统、存储设备和 I/O 栈支持 direct I/O,适合已经验证过 I/O 路径的场景。

1.7 CPU 频率与 C-states

许多计算节点默认会让 CPU 运行在省电模式下:CPU 空闲时可能被降频,或者进入低功耗睡眠状态。这可以节省能耗、降低发热和成本。训练过程中,GPU 在处理当前 batch 时,CPU 不一定始终处于 100% 利用率;但当新的数据准备、kernel launch 或通信任务到来时,CPU 从低频或睡眠状态恢复会引入额外延迟。 为了获得更高且更稳定的性能,AI 系统通常会把 CPU frequency governor 配置为 performance 该模式会让 CPU 尽量保持在较高频率,减少频率切换带来的延迟抖动。这个配置可以通过 cpupower frequency-set -g performance 完成,也可以在 BIOS 中设置。 CPU 空闲状态(C-states)同样会影响延迟稳定性。C-states 是 ACPI 规范定义的 CPU 省电模式:CPU core 空闲时可以进入 C-state 来节省能耗。C0 表示 active 状态,C0 以上表示更深的睡眠状态。C-state 越深,省电越多,但新的工作到来时,core 唤醒所需时间也越长。限制或禁用深层 C-states 可以减少额外的延迟尖峰。
示例输出:
  • driver 表示 Linux 当前用来管理 CPU 频率的驱动,并执行升频、降频、切换 governor 这些操作,这里是 acpi-cpufreq
  • hardware limits 表示硬件支持的频率范围。
  • available cpufreq governors 表示可选 governor。
  • current policy 表示当前频率策略;示例中 governor 是 performance
  • current CPU frequency 表示当前 CPU 频率。
  • boost state support 表示 CPU 是否具备动态加速频率(boost)能力。动态加速频率是 CPU 官方支持的自动加速机制;在温度、功耗、电流等条件允许时,CPU 可以自动运行到更高频率。
    • Supported: yes 表示硬件和驱动支持动态加速频率。
    • Active: yes 表示动态加速频率已启用,CPU 在满足温度和功耗条件时可以临时把频率拉到更高档位。
    • Pstate-Px 列出了 CPU 可用的性能状态档位,频率从高到低;当前 governor 会根据自己的策略选择使用哪个频率档位。
有些系统安装 cpupower 后支持配置文件,可以把 governor 持久化到服务配置中:
设置:
然后启用服务:
示例输出:
  • CPUidle driver 表示 CPU idle 驱动,这里是 acpi_idle
  • CPUidle governor 表示 idle state 选择策略,这里是 menumenu 是常见的默认策略,会根据下一次 timer 事件、历史 idle 时长、延迟需求等信息,预测该进入哪个 idle state。
  • Available idle states 表示当前 CPU 可进入的 idle states:
    • POLL 表示轮询状态。有任务执行时 CPU 处于 C0;没有任务执行时,CPU 可以进入 POLL 这种 idle state 轮询等待新任务。它的 Latency: 0,唤醒延迟最低,但更耗电。
    • C1C2 表示更深的空闲状态;示例中 C2Latency: 400,唤醒延迟明显高于 C1
  • Usage 表示进入该 idle state 的次数,Duration 表示累计停留时间。
Linux 下可以通过内核启动参数限制深层 C-states。常见做法是在 GRUB 中加入 processor.max_cstate=1 intel_idle.max_cstate=0,限制 CPU 进入深层睡眠状态。

1.8 Host 内存分配器调优

在 GPU 计算中,CPU 需要持续准备 batch 并及时送给 GPU。优化 memory allocator 可以减少内存分配带来的卡顿和抖动,避免 GPU 等待数据,从而维持高 GPU 利用率和整体吞吐。
如果程序每次申请内存都直接向操作系统请求,会产生很大的系统调用开销,而且频繁分配和释放还会导致内存碎片、多线程锁竞争、cache 利用率低等问题。memory allocator 的作用就是在程序和操作系统之间做一层高效的内存管理:它先一次性向系统申请大块内存,再按需切分、复用和回收,从而减少系统调用、降低碎片、提升性能和并发效率。
Memory allocator 位于应用和操作系统之间,缓存并复用释放后的内存,减少频繁向 OS 申请和归还内存的开销
Linux 上常见的默认 allocator 是 glibc malloc,其底层实现通常称为 ptmalloc。它兼容性好,但在高并发数据管道中可能出现 arena 膨胀、碎片和 RSS(Resident Set Size,常驻集大小)不回收。jemalloctcmalloc 是常见替代 allocator,优势主要体现在降低多线程锁竞争、改善碎片控制,以及更灵活地管理释放后的内存。
ptmalloc、jemalloc 与 tcmalloc 在 arena、thread cache、central cache 和后台回收策略上的对比
ptmalloc(glibc malloc)分配流程:线程申请内存时,ptmalloc 会先尝试从当前线程的 tcache 命中。tcache 是线程本地缓存,主要缓存小对象;在 64-bit glibc 默认配置下,tcache 可服务的请求大小最大约为 1032B。如果 tcache 没命中,请求会进入对应的 arena,在 arena 的 bins 中查找合适的空闲 chunk。较小的 chunk 通常从 fastbins / small bins 复用,较大的 chunk 会从 large bins 查找。如果 bins 中没有合适空间,则通过 brk 扩展 heap,或通过 mmap 向操作系统申请新内存。大块分配通常更可能走 mmap,默认阈值从约 128KB 开始,并可能动态调整。释放流程:释放时,如果 chunk 大小适合且当前线程的 tcache 还有空间,会优先放回 tcache;否则回到对应 arena 的 bins。相邻空闲 chunk 在合适情况下会合并,较大的 mmap 内存块也可能直接归还给操作系统。锁竞争特点tcache 命中时很快,但 tcache misstcache flush、较大对象分配、跨线程释放等情况仍可能进入 arena。多个线程共享同一个 arena 时,即使操作不同 size class,也可能竞争 arena 相关锁,因此高并发下更容易出现性能抖动。tcmalloc(Google)分配流程:tcmalloc 的核心是让小对象优先走线程本地缓存。小对象常见口径是 ≤32KB,会优先从当前线程的 ThreadCache 按 size class 获取。如果 ThreadCache 不够,会从共享的 CentralCache 批量补充;如果 CentralCache 也不够,再从 PageHeap 获取 span。span 是一段连续 page,可以被切成小对象,也可以承载大对象。大对象通常指 >32KB 的请求,一般绕过 ThreadCache,直接走 CentralCache / PageHeap 路径。释放流程:小对象释放时,会根据地址找到所属 span 和 size class,然后优先放回当前线程的 ThreadCache。如果 ThreadCache 超过预算,会批量归还一部分对象给 CentralCache。大对象释放时,会回到 PageHeap,并尝试和相邻空闲 span 合并。锁竞争特点:tcmalloc 的优势是大量小对象分配可以在线程本地完成;缓存不够时,也是批量访问 CentralCache,不是每次 malloc / free 都访问共享结构。同时,CentralCache 按 size class 分散管理,不同 size class 通常不会竞争同一把锁,因此锁竞争通常比 ptmalloc 更低。jemalloc(FreeBSD / Facebook)分配流程:jemalloc 也有线程本地缓存,叫 TCache。小对象优先从 TCache 按 size class 获取;如果没有命中,再进入线程关联的 arena。jemalloc 不是每个线程一个 arena,多个线程可能共享同一个 arena。large object 不走 slab,而是由 arena 通过 extent 管理。释放流程:小对象通常先回到当前线程的 TCache;如果不能缓存,就回到对象所属 arena 的 bin / slab。large object 会回到所属 arena 的 extent 管理结构。释放的内存通常回到对象原本所属的 arena,而不是简单回到当前调用 free 的线程对应的 arena。锁竞争特点:jemalloc 和 ptmalloc 都可能多个线程共享 arena,但 jemalloc 的 arena 内部拆得更细:不同 size class 通常对应不同 bin,bin / slab / extent 的锁粒度也更细。因此两个线程即使共享同一个 arena,只要操作不同 size class,通常也不容易竞争同一把 arena 级别的大锁。
jemalloc 的小对象分配通常按 size class 进入对应 bin,再从这个 bin 管理的 slab / run 中取一个空闲 object。large object 不走 slab,通常由 extent 管理。
  • arena:allocator 内部的一个内存管理域,负责维护 bins、large allocation、锁和统计信息。多线程程序可以分散到多个 arena,减少锁竞争,但 arena 过多也可能让 RSS 变高。
  • bin:arena 内按 size class 划分的小对象管理结构。比如 64B bin128B bin,每个 bin 通常管理一批对应尺寸的 slab / run。
  • slab:一段被切成固定大小 object 的内存块。一个 64B slab 只切 64B object,一个 128B slab 只切 128B object。一个 slab 里会有多个 object,用来批量服务同一 size class 的小对象分配,减少频繁向 OS 申请内存的开销。
  • run:可以理解为 slab 的近似概念,常用于描述一段连续内存被切成多个同尺寸 object 的结构;在不同 allocator 或不同版本文档里,runslab 的命名可能不同。
  • object:slab / run 被切分后得到的固定大小内存槽位,也是最终返回给应用的一次小对象分配结果。
  • extent:jemalloc 中按 page 粒度管理的一段连续虚拟内存范围。小对象 slab / run 可以由 extent 支撑,large object 也可能直接由 extent 管理。
使用 jemalloctcmalloc 前,系统中需要有对应的动态库;随后可以通过 LD_PRELOAD 让目标进程在启动时优先加载对应 allocator。
安装后可以通过动态库缓存确认系统是否能找到它们:
jemallocMALLOC_CONFjemalloc 的配置环境变量。jemalloc 可以把分配分散到多个 arena,减少多线程分配时的锁竞争;也可以启用后台线程,在业务线程之外做内存清理。下面是一组常见配置:
jemalloc 中 active、dirty、muzzy page 的状态流转:应用释放内存后,allocator 可先保留页面等待复用,再按 decay 策略逐步清理
  • narenas:8 设置 arena 数量为 8。arena 多一些可以降低多线程锁竞争,但太多也可能增加内存占用。
  • background_thread:true 启用后台线程做内存清理,避免释放后的清理工作直接卡在业务线程上。
  • dirty_decay_ms:10000 控制 dirty pages 延迟多久再回收或归还,10000 表示 10 秒。dirty page 指应用已经释放、但内容还保留的页面,jemalloc 可以直接复用。
  • muzzy_decay_ms:10000 控制 muzzy pages 的回收延迟,也设成 10 秒。muzzy page 指应用已释放、旧数据不需要保留,但虚拟地址范围仍留在进程里的页面。与 dirty pages 相比,muzzy pages 再次被分配时往往需要重新触发 page fault 并补入物理页,因此复用成本通常更高。
  • 把 dirty / muzzy page 的回收延迟调长,可以减少频繁向 OS 申请/归还内存的开销,但 RSS 可能保持得更高。RSS(Resident Set Size)表示进程当前实际占用的物理内存;应用释放对象后,allocator 可能先把内存留在 arena 或 cache 里复用,不一定马上还给 OS。
tcmalloctcmalloc 使用自己的 TCMALLOC_* 环境变量。
  • TCMALLOC_MAX_TOTAL_THREAD_CACHE_BYTES 控制所有 thread cache 可占用的总大小。值越大,线程本地分配越容易命中 cache,但 RSS 可能更高。
  • TCMALLOC_RELEASE_RATE 控制 tcmalloc 把空闲内存归还给 OS 的积极程度。值越大,释放越积极;值太大可能增加向 OS 申请/归还内存的开销。
  • /proc/$PID/maps 查看某个进程实际映射到地址空间里的动态库。
  • 这里必须换成目标训练或推理进程的 PID;查 /proc/1/maps 只是在看 PID 1 是否加载了 allocator。
  • 如果没有输出,说明这个进程启动时没有加载 jemalloctcmalloc

2. GPU 驱动与运行时设置

GPU 驱动与运行时里有一些会影响性能的设置,尤其在多 GPU、多用户场景下。这些设置配置得当,能降低额外开销,也能让多个负载更好地共享同一张 GPU。

2.1 持久化模式(Persistence Mode)

默认情况下,如果没有应用正在使用 GPU,driver 可能让 GPU 进入更低功耗状态,并卸载部分驱动上下文。下一次应用重新使用 GPU 时,driver 需要重新初始化 GPU,这个冷启动过程可能带来一两秒级别的延迟。对于频繁启动/停止 job 的训练集群,或者低流量但延迟敏感的推理服务,这类初始化开销会影响整体性能。 在 Linux 上,client 通过打开 GPU device file 来 attach GPU;关闭 device file 时,相当于 detach GPU。只要还有 client 保持 device file 打开,GPU state 就会继续留在 driver 中。 nvidia-persistenced 的核心做法,是在后台运行并保持 GPU device file 打开。这样即使没有训练或推理进程正在使用 GPU,driver 也会继续保留 GPU state,从而避免下一个 job 到来时重新初始化。持久化模式不会让 GPU kernel 的数学计算更快,但能减少作业启动延迟idle 后首次 CUDA 调用的冷启动停顿;代价是 GPU 空闲时功耗会略高
在 Kubernetes 中,安装 GPU Operator 时启用 driver.enabled=true,GPU Operator 会部署 nvidia-driver-daemonset 来管理节点侧 NVIDIA driver,并启动 nvidia-persistenced,从而启用持久化模式。
参考资料:NVIDIA Driver Persistence

2.2 Multi-Process Service (MPS)

通常,当多个进程共享同一张 GPU 时,GPU 调度器会在它们之间做时间片切换。例如,如果两个 Python 进程各自都有一些 kernel 要在同一张 GPU 上运行,GPU 可能先执行一个进程的 kernel,再执行另一个进程的 kernel,如此往复。如果这些 kernel 很短,并且它们之间存在空闲间隔,GPU 就可能因为反复进行上下文切换、而不是把这些任务重叠执行,导致利用率不足。 NVIDIA MPS 提供了一种机制,让多个进程可以在同一张 GPU 上并发运行,而不必严格依赖时间片切换。启用 MPS 后,只要 GPU 资源可用,例如 streaming multiprocessors(SMs)、Tensor Cores 等,GPU 就可以同时执行来自不同进程的 kernel。MPS 本质上是把多个进程的上下文合并到一个调度上下文中。这样一来,就不必为独立进程之间的切换和空闲等待付出完整代价。
MPS 让多个 CUDA 进程通过共享调度上下文在同一张 GPU 上并发执行
MPS 由以下几个组件组成:
  • Control daemon process:control daemon 负责启动和停止 server,并协调 client 与 server 之间的连接。
  • Client runtime:MPS client runtime 内置在 CUDA Driver library 中,任何 CUDA 应用都可以透明地使用它。
  • Server process:server 是多个 client 共享的 GPU 连接,并负责在 client 之间提供并发执行能力。
MPS 的典型场景是:多个 MPI 进程或多个 CUDA 进程单独都无法占满 GPU 时,通过共享同一块 GPU 并发执行 kernel 来提高 GPU 利用率。 如果某个程序本身就将 GPU 完全占满到 100%,MPS 并不能让它变得更快,因为利用率无法超过 100%。 请注意,MPS 并不对 GPU 内存进行分区,因此所有进程将共享整个 GPU 内存空间。MPS 主要负责计算共享和调度。 问题在于,某个进程可能会申请大量 GPU 内存,导致 GPU 上出现 OOM 错误,从而终止在该 GPU 上运行的所有其他进程。
常用的 MPS 配置和查看命令如下:
基本 MPS 示例mps_demo.py 会启动多个 Python 子进程,让它们在同一张 GPU 上反复执行矩阵乘法,模拟多个 CUDA 进程共享 GPU 的场景。run_basic_comparison.sh 会跑两轮:第一轮不开 MPS,第二轮开启 MPS 后运行同一个 demo。两轮使用相同的 worker 数、矩阵大小和运行时长,最后比较所有 worker 完成的总矩阵乘法次数。
实际输出示例如下:
这段输出里有几个地方值得注意:
  • 吞吐提升:两轮实验都使用 4 个 worker、N=1024、运行 60 秒,因此可以用总矩阵乘法次数近似比较吞吐。不开 MPS 时完成 394603 次,开启 MPS 后完成 464780 次,对应吞吐约为不开 MPS 的 1.18x
  • MPS client:实际运行 CUDA work 的应用进程。这里就是 4python3 worker,它们的 SERVER 都是 3817822,说明它们连接到了同一个 MPS server。
  • MPS server:中间代理进程,也就是 nvidia-cuda-mps-server。它代表多个 client 统一持有 GPU context,并把这些 client 的 GPU work 提交给 GPU。
静态 SM 分区示例MPS 还支持静态 SM 分区(static SM partitioning),主要有以下好处:
  • 确定性的资源分配:可以显式控制每个 client 能访问哪些 SM,不再完全依赖 MPS 的动态调度。
  • client 之间的空间隔离:不同 client 可以被放到不同 SM partition,减少彼此干扰。
静态 SM 分区常用的配置和查看命令如下:
GPU Operator 通过 NVIDIA device plugin 暴露 MPS 共享 GPU 资源。GPU Operator 的 GPU sharing 文档使用 devicePlugin.config 指定 device plugin 的 ConfigMap;MPS 使用同一个配置入口,但 ConfigMap 里的共享字段是 sharing.mpsNVIDIA device plugin 文档说明,MPS 支持仍是 experimental,并且 MPS 共享当前不支持启用 MIG 的 device。下面示例把每张完整 GPU 切成 4 个 MPS 共享访问入口,并用 renameByDefault: true 暴露为 nvidia.com/gpu.shared
renameByDefault: true 表示共享后的资源会改名暴露。原始资源名是 nvidia.com/gpu,开启后会变成 nvidia.com/gpu.shared;Pod 也需要申请 nvidia.com/gpu.shared
工作负载申请 nvidia.com/gpu.shared 即可使用启用 MPS 共享的 GPU:
GPU Operator 不会监控 GPU sharing ConfigMap 的内容变化。后续如果修改 mps-config 这个 ConfigMap,需要手动重启 device plugin DaemonSet 让新配置生效:
参考资料:

2.3 Multi-Instance GPU (MIG)

MIG 会把支持的 GPU 划分为最多 7 个隔离实例,每个实例拥有专用计算和 GPU 内存资源。MIG 从 NVIDIA Ampere 架构开始支持,也就是 compute capability >= 8.0 的部分数据中心 GPU;具体型号以 NVIDIA 的 Supported GPUs 列表为准。

2.3.1 MIG 的资源隔离

MIG 适合多租户场景,不同用户、容器、虚拟机或进程可以运行在不同 GPU 实例上,一个进程不会直接影响另一个进程的调度和资源边界。对云服务和共享集群来说,这种隔离能把一张大 GPU 切成多个可独立分配的小 GPU。 每个 MIG 实例在 GPU 内部访问内存时都有独立路径,包括内部互连、L2 缓存分区、内存控制器和 DRAM 地址总线。这样即使其他实例的任务占满自己的缓存或 DRAM 接口,当前实例仍能获得更稳定的吞吐和延迟。MIG 也可以切分 SM、数据拷贝引擎、解码器等 GPU 引擎,为不同进程提供确定的服务质量(QoS)和故障隔离。
MIG 将一张 GPU 划分为多个彼此隔离的 GPU Instance

2.3.2 GPU Instance 和 Compute Instance

MIG 配置分两层:先创建 GPU Instance(GI),再创建 Compute Instance(CI)。
  • GPU Instance(GI):GI 由一组 GPU 切片和 GPU 引擎组成,决定这个 MIG 实例的 GPU 内存容量、带宽和 QoS。
  • Compute Instance(CI):CI 是在某个 GI 内继续划分出的计算实例,使用父 GI 的一部分 SM 切片。CI 主要隔离计算资源,也就是 SM;它不提供独立的 GPU 内存隔离。 多个 CI 可以共享同一个 GI 的 GPU 内存切片和 DMA、NVDEC 等引擎,但各自拥有独立的 SM 资源。
CUDA 应用会把 CI 及其所属 GI 视为一个 CUDA device;多数场景可以用 -C 参数直接创建覆盖整个 GI 的默认 CI。

2.3.3 MIG 设备命名规则

MIG 设备名称 描述了实例的资源形态。xg.ygb 表示一个 GPU Instance:xg 表示使用 x 份 GPU 计算切片,ygb 表示 GPU 内存容量档位。例如 3g.40gb 表示使用 3 份 GPU 计算切片和 40GB 档位的 GPU 内存。如果一个 GI 继续拆成多个 CI,名称会带上 c,格式为 xc.xg.ygb;例如 1c.3g.40gb 表示在 3g.40gb GI 内使用 1 份计算切片的 CI。
MIG profile 通过不同数量的计算切片和显存切片组合成 1g、2g、3g、7g 等 GPU Instance
如果想进一步提升并发,可以将一个 GI 拆成多个 Compute Instance(CI),把 SM 分配给多个 CUDA 程序。每个 CI 拥有独立的 SM 资源,适合多个小任务共享同一个 GI。
GI/CI 配置本身不会跨系统重启保留。生产环境通常使用 mig-parted 重建固定的 MIG 切分形态。下面示例以 A100-SXM4-40GB 为例保存多套候选配置。mig-devices 里的 profile 名称来自 nvidia-smi mig -lgip 输出的 Name 列,例如 MIG 3g.20gb 对应配置里的 "3g.20gb"
配置含义:
  • mig-configs 下面可以保存多套候选配置。每次执行 nvidia-mig-parted apply -c <config-name> 时,只会应用 -c 指定的那一套配置
  • all-disabledall-enabled2-3g-20gball-balanced 都是配置名称,后续 apply 时通过 -c 引用
  • devices: [0] 表示只配置 GPU 0
  • devices: all 表示配置节点上的所有 GPU
  • mig-enabled: true 表示启用 MIG mode
  • mig-devices 表示要创建的 GPU Instance profile 和数量,例如 "3g.20gb": 2 表示创建两个 3g.20gb
常用操作如下:
CUDA Multi-Process Service(MPS)可以让多个 CUDA 进程在 GPU 上并发执行。MPS 和 MIG 可以结合使用,进一步提高某些 workload 的 GPU 利用率。 具体做法是:先用 MIG 把一张 GPU 划分成多个隔离的 GPU Instance,再在某个 MIG device 内使用 MPS,让多个 CUDA 进程通过同一个 MPS server 并发提交任务。整体流程分三步:
  • 配置目标 MIG 切分形态,例如两个 3g.40gb MIG device
  • 为每个 MIG device 设置独立的 CUDA_MPS_PIPE_DIRECTORY,并启动一个独立的 MPS control daemon
  • 启动 CUDA 程序时,用 CUDA_VISIBLE_DEVICES=<MIG_UUID> 指定目标 MIG device
MPS 文档通常建议使用 EXCLUSIVE_PROCESS,确保一张 GPU 只有一个 MPS server。MIG mode 下不使用这个模式,因为每个 MIG GPU Instance 需要独立的 MPS server。
下面是一个完整脚本。它会在两个 MIG device 上运行一个 demo 程序:每个 MIG device 启动一个独立的 MPS control daemon,并在对应 MIG device 上启动 workload。
脚本运行后,可以用 nvidia-smi 看到每个 MIG device 上各有一个 MPS server,以及对应的 CUDA client:
NVIDIA GPU Operator 通过 MIG Manager 管理 Kubernetes 节点上的 MIG 配置。安装 GPU Operator 时需要启用 MIG strategy,常见取值是 singlemixed
  • single 适合节点上使用同一种 MIG profile 的场景,MIG device 通常继续通过 nvidia.com/gpu 这类资源暴露
  • mixed 适合同一节点上混合使用多种 MIG profile 的场景,资源会按 profile 暴露,例如 nvidia.com/mig-1g.5gb
MIG Manager 使用 mig-parted 配置文件。默认配置来源由 migManager.config 指定:
这里的含义是:
  • name: default-mig-parted-config 表示 MIG Manager 读取这个 ConfigMap 里的 config.yaml
  • default: all-disabled 表示默认使用 mig-configs 里的 all-disabled 这套配置
  • 节点上的 nvidia.com/mig.config=<config-name> label 会从这个 ConfigMap 里选择一套配置并应用
如果想要自定义 MIG 配置,可以参考 Example custom MIG configuration可以用下面的命令查看当前配置:
配置 MIG 时,只需要在节点打上 nvidia.com/mig.config label,MIG Manager 会监听到 node label 变化,然后调用 mig-parted 应用目标配置。
工作负载申请资源时,根据 MIG strategy 使用对应的资源名:

2.4 GPU 时钟频率与 ECC

NVIDIA GPU 的 GPU Boost 会在功耗和温度限制内自动调整核心频率。大多数时候,让 GPU 按默认策略动态调频即可。 需要确定性、可重复的结果时,比如做 benchmark,可以把核心频率锁到固定档位。日常训练和推理建议保持 auto-boost 开启;只有观察到明显的性能波动或 GPU throttling 时,再考虑锁频排查。 锁频前先查看 GPU 支持的频率档位。这里关注 Graphics,它对应核心频率:
下面的例子使用上面输出中的 1410MHz。实际操作时,应换成当前机器 SUPPORTED_CLOCKS 里列出的值。
ECC(Error-Correcting Code,纠错码)用于保护显存数据。比如宇宙射线导致单比特内存错误时,ECC 可以在运行中纠正;如果出现双比特错误,ECC 会检测到,并向调用代码抛出错误。NVIDIA 数据中心 GPU 通常默认启用 ECC。 ECC 会占用一部分显存容量,并带来少量带宽开销。长训练中,未纠正错误比这点性能损耗更危险。可以用下面的命令分别查看 ECC 错误计数:

3. 系统调优脚本

system_tuning.sh 脚本里包含 CPU 频率策略、swap、透明大页(THP)、网络参数、中断亲和性等调优项。

4. 容器运行时优化

AI 系统常用 Docker、Kubernetes 等容器运行时和编排工具来管理软件环境。容器能把 CUDA、Python package、系统库和推理/训练框架版本固定下来,减少“在我机器上能跑”的环境漂移问题。 容器会引入一点复杂度和少量开销,但配置正确时,GPU workload 可以接近裸机性能。容器不是传统虚拟机(VM)。和 VM 不同,容器共享宿主 OS kernel,CPU 和内存操作通常接近原生速度,没有额外的 hypervisor 虚拟化层。 配合 NVIDIA Container Toolkit,Docker 容器内的 GPU 访问是直接的,不会引入传统虚拟化开销。对于现代 GPU 和较新的 NVIDIA Container Toolkit,只要环境配置正确,容器内运行和直接在裸机宿主上运行的 GPU 性能通常几乎一致,差异通常可以控制在 2% 以内

4.1 NVIDIA Container Toolkit

NVIDIA Container Toolkit 允许用户构建和运行 GPU 加速容器。它包含 container runtime library 和相关工具,可以自动配置容器,让容器使用 NVIDIA GPU。 NVIDIA Container Toolkit 的组件可以分到两条路径里:传统的 OCI prestart hook 路径,以及标准化的 CDI 路径。 Legacy OCI 路径
  • NVIDIA Container Runtime(nvidia-container-runtime:OCI runtime wrapper。legacy mode 下,它会修改容器启动时传入的 OCI runtime spec,插入 NVIDIA prestart hook,再调用底层容器运行时(runc / crun)。
  • NVIDIA Container Runtime Hook(nvidia-container-runtime-hook:OCI prestart hook。在容器创建后、启动前读取 config.json,把 GPU device、driver capabilities 和约束转换成 nvidia-container-cli 参数。
  • NVIDIA Container CLI(nvidia-container-clilibnvidia-container 提供的命令行入口。runtime hook 会调用它完成 GPU device、driver libraries 和权限配置。
  • NVIDIA Container Library(libnvidia-container1:底层库,依赖 Linux kernel primitives,提供自动配置 GNU/Linux 容器以使用 NVIDIA GPU 的能力。
CDI 路径
  • NVIDIA CDI Hooks(nvidia-cdi-hook:CDI spec 中可调用的辅助工具,用于调整权限、创建 symlink、更新动态链接器缓存等额外操作。
公共组件
  • NVIDIA Container Toolkit CLI(nvidia-ctk:通用配置与管理工具。legacy 路径下用 nvidia-ctk runtime configure 把 NVIDIA runtime 写入 Docker / containerd / CRI-O 配置;CDI 路径下用 nvidia-ctk cdi generate 生成 CDI spec。
下图展示了这些组件在传统 OCI hook 路径和 CDI 路径中的关系:
NVIDIA Container Toolkit 组件关系图,展示 nvidia-ctk、nvidia-container-runtime、runtime hook、nvidia-container-cli、libnvidia-container1、nvidia-cdi-hook 和宿主机 NVIDIA driver stack 的关系
OCI(Open Container Initiative) 定义容器运行时规范。OCI runtime spec 是底层运行时创建容器时读取的配置文件,里面包含要启动的进程、rootfsmountsenvdeviceshooksnamespacescgroups 等运行时配置。runc / crun 这类底层运行时会按这个 spec 创建容器。CDI(Container Device Interface) 是设备注入规范。CDI spec 里的 containerEdits 字段描述需要对 OCI spec 做哪些修改,例如 deviceNodesmountsenvhookspermissions 等。支持 CDI 的 runtime 会读取 CDI spec,并把这些 edits 应用到 OCI runtime spec。两者的关系是:OCI 描述容器整体如何启动,CDI 描述设备如何注入容器。支持 CDI 的 runtime 会把 CDI spec 里的设备配置应用到 OCI runtime spec,最后仍由 runc / crun 按 OCI spec 创建容器。
GPU 访问本质上依赖 Linux 的设备文件。NVIDIA kernel driver(nvidia.ko)加载后,会在宿主机 /dev/ 下创建一组字符设备:
CUDA 程序不会直接和 GPU 硬件通信。它会打开 /dev/nvidia0 这类设备文件,并通过 ioctl() 系统调用进入宿主机 kernel driver,由 kernel driver 负责和 GPU 硬件交互。一个裸容器(bare container)本身没有 GPU 访问能力。它的 /dev/ 目录里只有标准设备,比如 nullzeropts。NVIDIA Container Runtime(nvidia-container-runtime)会在应用启动前向容器注入三类内容:
  • Device nodes:从宿主机 bind mount /dev/nvidia0(具体 GPU 设备文件)、/dev/nvidiactl(NVIDIA driver 控制设备)、/dev/nvidia-uvm(Unified Virtual Memory)。
  • Driver libraries:bind mount libcuda.so(CUDA Driver API)、libnvidia-ml.so(NVML,用于 nvidia-smi、DCGM 和监控工具查询 GPU 状态)和其他与宿主机 driver 匹配的库。
  • Device permissions:配置 cgroup device controller,允许容器访问 NVIDIA 主设备号(device major number)。主设备号是 Linux 用来把设备文件路由到对应内核驱动的编号。
下图展示了 GPU 软件栈如何在容器和宿主机之间协同工作:
  • 应用层PyTorchTensorRTJAX 等框架运行在各自容器里。它们会调用容器内的 CUDA 用户态库(libcudart.so),也可能直接使用 CUDA Driver API(libcuda.so)。
  • CUDA 用户态库libcudart.so、cuBLAS、cuDNN、NCCL 等通常来自容器镜像。不同容器可以携带不同 CUDA 版本,例如 CUDA 11.8、12.1、12.4。
  • CUDA Driver APIlibcuda.so 是 driver API 库,通常由宿主机 driver bind mount 进容器。容器内的 CUDA 用户态库和框架代码最终通过它进入宿主机 driver 路径。
  • 设备文件/dev/nvidia0 是 GPU 设备文件,也由宿主机挂进容器。libcuda.so 通过它发起 ioctl() 系统调用,跨过 user / kernel 边界。
  • Kernel drivernvidia.ko 运行在宿主机 kernel 中,负责处理 ioctl() 请求并和 GPU 硬件通信。所有容器共享同一个宿主机 kernel driver。
  • GPU hardware:真正执行计算的物理 GPU。
多个容器使用不同 CUDA runtime,通过宿主机挂载的 libcuda.so、/dev/nvidia0 和共享的 nvidia.ko kernel driver 访问 GPU
图源:How Docker Works with GPUs: Device Files, Bind Mounts, and Driver Stacks
安装步骤参考官方文档:Installing the NVIDIA Container Toolkit安装完成后,还需要把容器运行时接到 NVIDIA Container Runtime。NVIDIA Container Toolkit 支持 Docker、containerd、CRI-O 和 Podman。下面以 Docker 为例,使用 nvidia-ctk 命令配置 runtime:
nvidia-ctk 命令会修改宿主机上的 /etc/docker/daemon.json 文件,把 nvidia runtime 注册进去,让 Docker 可以使用 NVIDIA Container Runtime,配置类似:
重启 Docker daemon:
安装 GPU driver 并完成 runtime 配置后,可以运行一个 CUDA 容器验证 GPU 是否正确暴露给 Docker:
输出应类似下面这样:
CDI 是 vendor 中立的设备接口标准,也是 Podman、containerd 和较新 Docker 推荐的方式。用法分两步:先生成 CDI spec,再用 --device 按名字请求 GPU,不需要 --gpus--runtime=nvidia1. 生成 CDI spec(扫描本机 GPU / MIG 设备并写出 spec;/etc/cdi 为静态目录、/var/run/cdi 为临时目录):
spec 里每个设备都有一个完全限定名:nvidia.com/gpu=all(全部)、nvidia.com/gpu=gpu0(按索引)、nvidia.com/gpu=mig<GPU>:<MIG>(MIG 实例)。用 nvidia-ctk cdi list 查看:
从 Container Toolkit v1.18 起,nvidia-cdi-refresh systemd 服务会在安装、driver 升级、重启时自动重新生成 spec;但驱动卸载或 MIG 重新配置后需要手动重新生成2. 运行容器,用 --device 指定 CDI 设备名:
和 legacy 路径相比,CDI 不再需要 nvidia-container-runtime-hooknvidia-container-cli 这条启动时注入链——设备节点、驱动库、权限都由 CDI spec 声明,再由支持 CDI 的 runtime 直接应用到容器。
不同容器之间的 CUDA runtime、CUDA 版本可以不一样,但它们底层共享的是宿主机上的 NVIDIA driver。
CUDA Toolkit 与 NVIDIA driver package 的组成关系:CUDA Toolkit 位于上层,driver package 包含 libcuda.so 和 nvidia.ko
图源:CUDA Compatibility: CUDA Components一般规则是:宿主机 NVIDIA driver 版本必须至少达到容器内 CUDA 版本要求的最低 driver 版本根据 NVIDIA 的 CUDA Minor Version Compatibility 文档:
  • CUDA 13.x 需要 Linux 宿主机 NVIDIA driver R580 驱动分支或更新
  • CUDA 12.x 需要 Linux 宿主机 NVIDIA driver R525 驱动分支或更新
  • CUDA 11.x 需要 Linux 宿主机 NVIDIA driver R450 驱动分支或更新
当宿主机 driver 不方便整体升级,但应用或容器需要较新的 CUDA 用户态组件时,可以考虑通过 Forward Compatibility 的方式进行升级。具体做法是:在宿主机 kernel-mode driver(nvidia.ko)不变的情况下,通过安装 CUDA Forward Compatibility Package,补上较新的 user-mode driver libraries,例如 libcuda.so,让较新的 CUDA 应用在满足条件的旧 driver 环境上运行。正常升级 NVIDIA driver 会同时更新 user-mode libraries 和 kernel-mode driver。Forward Compatibility Package 只补 user-mode 组件,不替换 nvidia.ko。如果新功能依赖新的 kernel driver 能力,仍然需要升级宿主机 driver。
CUDA Forward Compatibility 通过升级 CUDA user-mode 组件和 libcuda.so,保持 kernel-mode driver nvidia.ko 不变
图源:CUDA Compatibility: Forward Compatibility Upgrade Path

4.2 避免 Overlay FS 开销

和直接在宿主机运行相比,Docker 容器更容易出现差异的地方通常是 I/O。容器常用联合文件系统(union filesystem),它的好处很直接:把只读镜像层和可写容器层透明叠加成一个统一视图,容器看起来像在使用一个完整的文件系统。 OverlayFS 在读写文件时会带来额外开销:
  • 读取文件时:文件系统需要检查只读层和可写层,判断应该返回哪个版本的文件。额外的 metadata lookup 和层合并逻辑,会比单一文件系统多一点延迟。
  • 修改文件时:如果修改的是只读层里的文件,会触发 copy-on-write。文件必须先复制到可写层,写入发生在这份副本上,而不是原始只读文件上。
OverlayFS 是 Linux 文件系统能力,可以把一个文件系统叠在另一个文件系统之上,并提供一个合并后的视图。可以把它理解成两个目录:upper directory 可读可写,lower directory 只读。upper directory 一开始通常是空的,只保存挂载之后新增或修改过的文件。OverlayFS 会把它们组合成一个 merged directory,让进程看起来像是在访问一个完整的文件系统。
OverlayFS 把只读 lower filesystem 和可写 upper filesystem 合并为 merged filesystem,并在写入 lower 文件时执行 copy-on-write
容器镜像层通常对应只读的 lower directory,容器自己的 writable layer 对应 upper directory。这样启动容器时不需要复制整份镜像,只需要引用底层只读文件。当你想写入或修改来自 lower directory 的文件时,OverlayFS 会使用一种叫写时复制(copy-on-write,COW) 的技术。它不会修改原始文件,而是在 upper directory 中创建该文件的一份副本,并把修改应用到这份副本上。因此,如果修改来自 lower directoryb.txt,OverlayFS 会在 upper directory 中创建一份 b.txt 副本,后续修改都发生在这份副本上,原始的 lower 文件保持不变。这样既能保证基础系统的完整性,也能减少需要复制的数据量。参考资料:Scaling Firecracker: Using OverlayFS to Save Disk Space
模型训练经常涉及重 I/O 操作,例如读取数据集、加载模型和写入 checkpoint。为了解决这个问题,可以使用 bind mount 把宿主机目录或网络文件系统挂进容器。 Bind mount 会绕过 overlay,因此性能接近直接在宿主机上进行磁盘 I/O。如果宿主文件系统是 NVMe SSD 或 NFS mount,就能获得底层存储设备本身的性能。
这里假设训练数据在宿主机的 /data/dataset-v /data/dataset:/mnt/dataset:ro 会把它挂载到容器内的 /mnt/dataset,其中 ro 表示只读挂载。训练脚本从 /mnt/dataset 读取数据,相当于直接从宿主文件系统读取。

4.3 HPC 场景的容器运行时

HPC 集群对容器运行时提出了更高要求。集群通常遵循最小权限原则:用户应以自身身份运行作业,不能提升权限,也不应接触计算节点上由 root 管理的系统守护进程。同时,HPC 容器运行时还需要更便捷地接入 GPU、高速网络和并行文件系统等宿主资源。因此,在 HPC 场景中,有时候会选择 ApptainerEnroot 这类面向 HPC 集群环境设计的容器运行时。 Apptainer 最初是为了在 HPC 集群上以简单、可移植、可复现的方式运行复杂应用而创建的。它最早由劳伦斯伯克利国家实验室(Lawrence Berkeley National Laboratory)开发,很快在其他 HPC 中心、学术机构和更多场景中流行起来。 Apptainer 的设计更贴近 HPC 集群的使用方式:
  • 可验证的复现性和安全性:支持加密签名、不可变容器镜像格式和内存中解密。
  • 默认强调集成而不是隔离:更容易使用集群上的 GPU、高速网络和并行文件系统。
  • 计算环境可移动:SIF 单文件镜像便于传输、共享和归档。
  • 简单的安全模型:容器内外默认是同一个用户,默认不能因为进入容器而获得宿主机额外权限。
Apptainer 的安装可参考官方文档:Installing ApptainerApptainer 运行 NVIDIA GPU 容器时,常用 --nv。这个选项会让容器看到宿主机的 NVIDIA device entries,并把基础 CUDA/NVIDIA driver libraries 绑定进容器。
如果启动的是长期运行的 instance,可以用 apptainer instance list 查看,用 instance://<name> 进入或执行命令:
控制 GPU 可见性时,常见方式是在运行容器前设置 APPTAINERENV_CUDA_VISIBLE_DEVICES。Apptainer 会把它映射成容器内的 CUDA_VISIBLE_DEVICES,影响遵循该变量的 CUDA 程序:
注意,APPTAINERENV_CUDA_VISIBLE_DEVICES 限制的是 CUDA 程序使用哪些 GPU,不一定限制 nvidia-smi 能枚举到的设备。Apptainer 也提供实验性的 --nvccli,通过 NVIDIA 的 nvidia-container-cli 完成 GPU 容器配置。它需要宿主机安装 nvidia-container-cli,并且通常要配合 --writable-tmpfs 或 writable sandbox 使用;在 setuid 安装模式下也有额外安全限制。--nvccli 模式下,如果需要更强的设备隔离,可以配合 --containNVIDIA_VISIBLE_DEVICES 控制实际绑定进容器的 GPU:
参考资料:
Enroot 是 NVIDIA 面向高性能环境的轻量容器工具。它的核心特点包括:
  • 遵循 KISS 原则和 Unix philosophy。
  • 独立运行,不需要 daemon。
  • 支持完全非特权和多用户使用:不需要 setuid binary,继承 cgroup,并使用每用户配置和容器存储。
  • 使用简单:镜像格式简单,便于脚本化,也支持 root remapping。
  • 几乎不做额外隔离:减少性能开销,也简化 HPC 部署。
  • 可组合、可扩展:支持系统级和用户级配置。
  • Docker 镜像导入速度快,官方 README 中提到大镜像导入可获得 3x 到 5x 加速。
  • 内置基于 libnvidia-container 的 GPU 支持。
  • 支持 bundles、in-memory containers 等协作和开发工作流。
安装 Enroot 可参考官方文档:Enroot Installation使用 Enroot 时,可以从 Docker/NGC registry 导入镜像,再创建并启动容器:
控制 GPU 可见性时,需要同时设置 NVIDIA_VISIBLE_DEVICESENROOT_RESTRICT_DEV 两个环境变量:
参考资料:Enroot README

5. Kubernetes 编排与调度

GPU 是集群里最贵、最稀缺的资源,调度方式直接影响利用率与训练/推理成本。

5.1 GPU Operator

在 Kubernetes 集群中管理 GPU 可能是一项艰巨的任务。传统方法往往需要手工安装和配置 GPU 驱动,既费时又容易出错。此外,要利用高级 GPU 特性、并确保 GPU 与其它系统组件之间的高效数据传输,还需要专门的知识和工具。缺少一套规范化的方法,这些挑战会拖累 AI/ML 工作负载的性能与可扩展性。 NVIDIA GPU Operator 提供了多种特性。它让在 Kubernetes 上设置 GPU 驱动及其配置变得轻而易举。在同一批节点上运行多个 AI 工作负载时,能够使用 vGPU、Multi-Instance GPU(MIG)、GPU Time-Slicing 等高级特性至关重要。此外,GPU 还需要与其它应用/GPU 以及存储之间具备快速通信能力,GPUDirect RDMA、GPUDirect Storage 和 GDR Copy 在其中扮演重要角色。GPU Operator 能帮助你轻松地把这些特性以及更多能力带到 Kubernetes 集群中。
NVIDIA GPU Operator 管理 Kubernetes 集群中的 NVIDIA driver、container runtime、device plugin、DCGM exporter、MIG Manager 等 GPU 软件组件
图源:NVIDIA Developer Blog: NVIDIA GPU Operator 它管理的主要组件: NVIDIA GPU Operator 的关键特性:
  • 自动安装和维护 GPU driver:NVIDIA GPU Operator 自动安装和维护 GPU driver,消除手动干预的需要。自动化可以确保 driver 始终保持最新并正确配置,让 AI/ML workload 平稳、高效地运行。
  • 配置高级 GPU 特性
    • vGPU(Virtual GPU) 允许多个虚拟机共享单张 GPU,提升资源利用率和灵活性。
    • MIG(Multi-Instance GPU) 允许单张 GPU 被划分成多个彼此独立的实例,每个实例拥有专用资源,从而改善 workload 隔离和效率。
    • GPU Time-Slicing 在多个任务之间切分 GPU 时间,保证 GPU 资源在不同 workload 之间公平、高效地分配。
  • 配置 GPUDirect RDMA 和 GPUDirect Storage
    • GPUDirect RDMA(Remote Direct Memory Access) 支持不同节点上的 GPU 直接通信,绕过 CPU 并降低延迟,这对高性能计算应用非常重要。
    • GPUDirect Storage 支持 GPU 与存储设备之间直接传输数据,显著加速数据密集型应用的数据访问和处理。
  • 配置 GDR Copy:GPUDirect RDMA(GDR)Copy 是一个基于 GPUDirect RDMA 技术的低延迟 GPU memory copy library,允许 CPU 直接 map 和访问 GPU memory。它可以提升 memory copy 操作的效率,降低开销,并改善整体性能。
安装步骤可以直接参考 Installing the NVIDIA GPU Operator 先添加 NVIDIA Helm repository:
然后使用 Helm 安装 GPU Operator:
参考资料:

5.2 Device Plugin

GPU Operator 部署的组件里,真正把 GPU 暴露给 Kubernetes 调度的是 device plugin。NVIDIA 的 k8s-device-plugin 以 DaemonSet 运行,向 kubelet 注册扩展资源 nvidia.com/gpu。启用 MIG 时,device plugin 可以按 profile 暴露 nvidia.com/mig-* 资源;启用 GPU sharing 时,则可以把共享后的访问入口暴露为 nvidia.com/gpu.shared 等资源。
Kubernetes Device Plugin 架构与工作流程:设备发现、资源注册、调度决策和设备分配挂载
虽然 device plugin 仍是 Kubernetes 集群中最常用的 GPU 资源管理方式,但它的资源模型仍有一些限制:
  • 仅支持整数计数:资源以不透明整数暴露,无法按显存容量、架构、compute capability 等属性筛选设备。
  • 共享只能节点级配置:默认整卡独占;time-slicing / MPS 共享只能按节点 ConfigMap 统一开启(time-slicing 无显存/故障隔离),无法在单个请求中按工作负载表达。
  • 缺乏属性级调度:调度器只感知数量,无法指定具体设备,需借助节点 label 与 nodeSelector 间接约束放置。
  • MIG 布局静态:MIG profile 须在节点侧预先配置,调整布局需重新配置节点,无法按工作负载动态切分。
  • 拓扑感知不足:对 NVLink、NUMA 等拓扑距离缺乏感知。

5.3 Dynamic Resource Allocation (DRA)

DRA 是 Kubernetes 的一项功能,用于在 Pod 之间请求和共享资源。这些资源通常是硬件加速器这类附加设备。 DRA 提供了一种灵活的方式,用于对集群中的设备进行分类、请求和使用。使用 DRA 可以带来如下收益:
  • 灵活的设备过滤:使用 Common Expression Language(CEL)对特定设备属性进行细粒度过滤。
  • 设备共享:通过引用对应的 ResourceClaim,让多个容器或 Pod 共享同一个资源。
  • 集中式设备分类:设备驱动和集群管理员可以使用 DeviceClass,为应用提供针对不同使用场景优化过的硬件类别。例如,可以为通用 workload 创建成本优化型 DeviceClass,为关键任务创建高性能 DeviceClass
  • 简化 Pod 请求:使用 DRA 时,应用不需要在 Pod resource request 中指定设备数量。Pod 会引用一个 ResourceClaim,该 ResourceClaim 中的设备配置会应用到 Pod。
DRA 使用下面这些 Kubernetes API kind 提供核心分配功能:
如果对 Kubernetes 比较熟悉,会发现 DRA 的设计思路和 CSI 很像:都把异构资源的管理拆成同样的三层——供给、分类、申请。
  • CSI(存储)PersistentVolume 代表可用存储 → StorageClass 分类 → PersistentVolumeClaim 申请。
  • DRA(设备)ResourceSlice 代表可用设备 → DeviceClass 分类 → ResourceClaim 申请。
CSI 与 DRA 的对比:PV / StorageClass / PVC 分别对应 ResourceSlice / DeviceClass / ResourceClaim,并从定位、管理对象、核心资源、典型用途、关注点、生态成熟度等维度对比两者
严格说 ResourceSlicePV 不能完全等同:PV 是一块具体的存储卷,ResourceSlice 更像节点上设备的目录清单。但从供需关系看,模式是一样的。
DRA 三者按数据流方向串起来:
  • ResourceSlice(供给):DRA driver 发布节点上的设备信息,包括型号、显存、驱动版本等属性,以及可用容量。
  • DeviceClass(分类):管理员用 CEL 表达式从设备属性里筛出一类设备,形成用户友好的分组,比如「所有 NVIDIA GPU」或「仅 A100」。
  • ResourceClaim(需求):用户通过 deviceClassName 引用某个 DeviceClass,声明需要几个该类设备。
  • 调度器(决策):综合 ResourceSlice 的库存和 ResourceClaim 的需求,选出满足条件的节点和具体设备,完成分配。
以单 Pod、单容器申请一张整卡为例,串起 ResourceSliceDeviceClassResourceClaim 与 Pod 之间的配合,看一次分配从设备注册到容器启动经历哪些阶段。阶段一:设备注册(DRA Driver → ResourceSlice)
DRA driver 以 DaemonSet 方式运行在每个节点上,持续 watch 设备状态。设备发生变化(新增、移除、健康状态变化)时,driver 更新对应的 ResourceSlice 并递增 pool.generation一份真实的 ResourceSlice(A100 节点,保留一个 MIG 实例和一张整卡的完整字段,其余设备省略):
阶段二:分类定义(Admin → DeviceClass)
DeviceClass 是管理员对 ResourceSlice 中设备的分类标准。driver 把原始设备信息发布到集群后,管理员用 DeviceClass 告诉用户「有哪些设备类别可用」:
用户不需要理解 CEL,只需知道 deviceClassName: gpu.nvidia.com 代表「NVIDIA GPU」——跟 CSI 里选 StorageClass 一个道理:管理员写规则,用户选 class。阶段三:声明需求(用户 → ResourceClaim / ResourceClaimTemplate)用户基于 DeviceClass 声明要什么设备、要多少。有两种写法:直接建一个 ResourceClaim,或用 ResourceClaimTemplate 让 K8s 为每个 Pod 自动生成 claim。直接建 ResourceClaim
ResourceClaimTemplate(同样的 devices 申请,只是被多包了一层 spec):
allocationModeExactCount 外还支持 All:不指定数量,直接占用该节点该 class 下的全部设备,适合需要独占整机 GPU 的分布式训练。阶段四:创建 Pod 并触发调度Pod 在 resourceClaims 里引用上面声明的资源,容器再通过 resources.claims 使用它。引用直接建的 claim 用 resourceClaimName,引用模板则用 resourceClaimTemplateName
两种引用方式对应不同场景:
调度器不只选节点,还选定了具体设备Filter 阶段为候选节点计算设备分配结果,Reserve 阶段在调度器内部缓存 pending allocation,PreBind 阶段通过 bindClaimResourceClaim.status.allocationResourceClaim.status.reservedFor 写回 API Server;之后 driver 和 kubelet 都以这份结果为准。三个组件分工如下:阶段五:设备准备与 Pod 启动

5.3.1 GPU 分配基本示例

最基础的用法是申请整卡:ResourceClaimTemplateResourceClaim 通过 deviceClassName 引用 gpu.nvidia.com 这个 DeviceClass,Pod 再引用该 claim 即可拿到一张 GPU。
两者的区别在于:
  • ResourceClaimTemplate 会为每个 Pod 各生成一个独立 claim、拿到不同 GPU;
  • 共享的 ResourceClaim(Pod 以 resourceClaimName 引用)则可以让多个 Pod 共享同一张卡。
两 Pod 各独占一卡:各自的 ResourceClaimTemplate 生成独立 claim,分到不同 GPU。
两个 Pod 各含一个容器,Pod 1 的容器连到 GPU 0、Pod 2 的容器连到 GPU 1,彼此不共享
一 Pod 两容器共享一卡:同一个 Pod 里的两个容器引用同一个 claim,映射到同一张 GPU。
一个 Pod 内的 container0 和 container1 两个容器通过箭头汇聚到同一张 GPU 0
两 Pod 共享一卡:两个 Pod 引用同一个全局 ResourceClaimresourceClaimName),落到同一张 GPU。
两个 Pod 各含一个容器,箭头汇聚到同一张 GPU 0,表示两个 Pod 共享一张卡

5.3.2 TimeSlicing 与 MPS

DRA 把共享策略直接写进每个 claim 的 GpuConfig,因此能按工作负载单独配置;而 device plugin 的 TimeSlicing / MPS 由整节点共用一份 ConfigMap 决定,同一节点上所有 Pod 只能套用同一套。DRA 的粒度还能更细——同一个 ResourceClaimTemplate 里就能给不同 request 配不同策略:一个 request 走 TimeSlicing、另一个走 MPS,各自落到不同的 GPU。下面这个示例用一个 Pod、4 个容器演示:ts-ctr0/ts-ctr1 共享一张走 TimeSlicing 的 GPU,mps-ctr0/mps-ctr1 共享另一张走 MPS 的 GPU。
一个 Pod 内 4 个容器:ts-ctr0/ts-ctr1 汇聚到 GPU A(TimeSlicing),mps-ctr0/mps-ctr1 汇聚到 GPU B(MPS)
GpuConfig 字段含义见官方 GpuConfig API 参考 启用 feature gate:这两种策略分别需要 TimeSlicingSettingsMPSSupport,未开启时 kubelet 会在 NodePrepareResources 阶段拒绝。安装 DRA driver 时用 Helm 打开:
TimeSlicingSettings 不与任何特性冲突,可与 MPSSupport 同时开启;但 MPSSupportDynamicMIG 互斥,两者不能同开(想跑动态 MIG 时需去掉 MPSSupport)。完整的互斥组合见官方 feature gate constraints

5.3.3 静态 MIG

静态 MIG 需要先在节点上把 GPU 提前做好 MIG 切分(比如用 nvidia-mig-parted),之后 ResourceClaim / ResourceClaimTemplate 只能申请这些已经切好的 MIG 规格。
静态 MIG:先用 nvidia-mig-parted 把 GPU 0 预切成 1g.10gb×2 + 2g.20gb + 3g.40gb,Pod 的 claim 再从中分到一个 1g.10gb MIG
第一步:预切 MIG。 用一份 nvidia-mig-parted 布局文件描述每张卡切成什么。下面把 GPU 0 切成 1g.10gb×2 + 2g.20gb + 3g.40gb(compute slice 合计 7g,刚好切满 A100 80GB):
在节点上开启 MIG 模式并切分 MIG:
第二步:申请 MIG 设备。 claim 引用 mig.nvidia.com DeviceClass,用 CEL selector 按 profile 选中已经切好的 MIG。完整清单见下:

5.3.4 动态 MIG

H100 及更新架构可按 claim 现切 MIG,无需提前切分:开启 DynamicMIG feature gate 后,DRA driver 会直接依据 ResourceClaim 在卡上动态创建对应的 MIG 设备。
动态 MIG:Pod 的 claim 声明 1g.10gb + 2g.20gb + 4g.40gb,DRA driver 按需在同一张 H100 上现切出这三个 MIG
安装 DRA driver 时需打开 DynamicMIG feature gate(另外 Kubernetes 1.33–1.35 还需在 kube-apiserver、kube-scheduler 手动开启 DRAPartitionableDevices,1.36 起该 feature gate 默认开启):
下面看怎么用动态 MIG 为 Pod 申请资源。对用户来说,写法和静态 MIG 完全一样——同样是在 ResourceClaim 里用 CEL selector 按 profile 选 MIG;区别只在于不用提前切分,driver 会根据申请按需现切出对应的 MIG。 一个 claim 里可以声明多个 request,一次拿到多块 MIG。constraints.matchAttribute: gpu.nvidia.com/parentUUID 是可选的:加上它会把这些 MIG 约束在同一张物理卡上;不加则各 request 独立匹配,可能分散到多张 GPU。
动态 MIG 靠 DRA 的 partitionable devices 机制(KEP-4815)实现:
  1. DRA driver 把一张 GPU 的有限资源(显存切片、SM 等)声明成一个 CounterSet(放在 ResourceSlicesharedCounters 字段里,通常一张物理卡一个),记录各项资源的总量;
  2. 再把这张卡上所有可能的 MIG 切分组合都作为设备列进 ResourceSlice,每个设备用 consumesCounters 声明要从对应 CounterSet 消耗多少;
  3. 调度时,调度器按「池总量 − 已分配 ResourceClaim 的消耗」核对每个 CounterSet 的余量,某项 counter 不够时对应设备就无法被分配。
下面借 KubeCon 2025 的演讲 Partitionable Devices: Putting the “Dynamic” Back in Dynamic Resource Allocation(Morten Jæger Torkildsen, Google & Jan-Philip Gehrcke, NVIDIA)的三张图看这套机制的实际过程。 图里是两个 ResourceSlice(同属一个资源池,按规范拆成两份):右边的 counterset-slicesharedCounters 声明每个 CounterSet 的总量;左边的 devices-slice 把所有可能的(互相重叠的)设备列出来,各用 consumesCounters 声明要从某个 CounterSet 扣掉多少。调度器为每个 CounterSet 跟踪可用 counter,只有余量足够时设备才可分配。
KubeCon 幻灯片:devices-slice 里的设备用 consumesCounters 引用 counterset-slice 里的 sharedCounters,调度器为每个 CounterSet 跟踪可用 counter
分配 small-device-1 时,从 counterset-1 扣掉 20Gi 显存 + 2 CPU,该 CounterSet 的可用 counter 相应减少。
KubeCon 幻灯片:分配 small-device-1 后从 counterset-1 消耗 20Gi 显存和 2 CPU,可用 counter 减少
扣减后 large-device 需要的 counter 已不够、无法再分配。注意 ResourceSlice 本身不会被改写——调度器是通过已分配的 ResourceClaim 反推每个 CounterSet 还剩多少。
KubeCon 幻灯片:counter 被 small-device-1 占用后,large-device 因余量不足变为不可分配;ResourceSlice 不被改写,调度器靠已分配 ResourceClaim 反推余量
早期 NVIDIA DGX 的做法是把尽可能多的 GPU 塞进一台服务器、用高带宽 NVLink 连起来:单机内扩展很强,但作业规模被限制在一台机器内。GB300 NVL72 和 GB200 NVL72 正是为打破这一限制而生:每一台都提供由 NVLink Switch 连接的密集 GPU fabric,在机架内支持 NVIDIA Multi-Node NVLink(MNNVL),并包含具备 IMEX 能力的 compute tray,实现跨节点的 GPU 显存共享——整个机架因此变成一块统一的 GPU fabric,成为超大规模分布式训练与推理的基础。
GB200 NVL72 机架构成:18 个 compute tray(每 tray 4 GPU)加 9 个 NVLink Switch tray(每 tray 2 个 72 端口 NVSwitch),GPU 间共 1296 条 NVLink 连接

DGX GB200 系统——顶部 10 个、底部 8 个 compute tray,中间通过 9 个 NVLink Switch 相连,把 72 张 GPU 经 Multi-Node NVLink 连成一个全互连 mesh(chip-to-chip 1.8 TB/s,累计带宽超过 130 TB/s)

作为 NVIDIA DRA driver for GPUs 的一部分,ComputeDomain 把底层的 GPU 构造(NVIDIA NVLinkNVIDIA IMEX)与现代 Kubernetes 原生的调度概念 DRA 桥接起来,为在现代 GPU 硬件上运行分布式、多节点工作负载提供必要的基础支撑。 那么,在 Kubernetes 上支持多节点 NVLink 需要哪些东西、ComputeDomain 又如何帮上忙?关键是 NVIDIA Internode Memory Exchange Service(IMEX)——运行在 GPU driver 层的软件,让 GPU 能够跨节点通信。有了 IMEX,每一次 GPU 显存的导出/导入操作都会受到细粒度的访问控制。IMEX 作用在一组节点上,这组节点被称为 IMEX domain
IMEX(Internode Memory Exchange Service)提供的能力是:让一组 GPU 通过高带宽 NVLink 直接读写彼此的显存。这种连接既可以是同一节点内 GPU 之间的直连,也可以是不同节点间经 NVSwitch 连接的 GPU。要让不同机器上的 GPU 互相通信,它们必须共享一个叫 IMEX channel 的构造——可以理解成一个跨节点资源,需要被多节点作业的每个 worker 共享。IMEX 共享的对象是 fabric-attached memory(fabric 显存)——被映射进 NVLink fabric 地址空间的 GPU 显存:除了本地的「虚拟地址 → 物理地址」映射,它还多了一个 fabric 地址(FA),因而同一 NVLink fabric 上的其它 GPU 都能经 NVLink 直接寻址、读写它(这正是 MNNVL「把整机架当作一块可共享的大显存」的底层)。一个 CUDA 进程要读写这样的 fabric 显存,需要同时满足:
  • 它与创建原始 handle 的进程处在同一个 IMEX domain
  • 它能访问创建原始 handle 的进程所用的同一个 IMEX channel
  • 它持有对该 sharedHandle 的引用(sharedHandle 的 export / import 具体方式见 Supporting GB200 on Kubernetes)。
IMEX 显存共享的 handle 模型:一端把本地 handle 导出为 sharedHandle,另一端导入后重建出 handle,两端的 ptr 指向同一块 fabric 显存
上图:一端的 CUDA 进程分配显存、拿到本地 handle(指向 ptr),把它导出成一个 sharedHandle;另一端的进程凭这个 sharedHandle 导入、在本地重建出 handle(同样指向那块 fabric 显存的 ptr)。于是两端进程虽然在不同节点、各有独立的地址空间,却通过 NVLink 读写到同一块物理显存——这就是跨节点显存共享的本质。
参考下图,可以更好地理解在多节点 NVLink 环境中,NVLink domain、IMEX domain 以及其它可能的 GPU 分区层级之间的关系:
NVLink Domain / NVLink Partition / IMEX Domain / IMEX Channel 的嵌套关系:Domain 是物理互连的全部 GPU,Partition 在 NVSwitch 层做硬件隔离,IMEX Domain 是运行 IMEX daemon 的节点组,IMEX Channel 是跨节点通信必须共享的软件通道
图源:Enabling Multi-Node NVLink on Kubernetes for NVIDIA GB200 NVL72 and Beyond
  • NVLink Domain(NVLink 域):通过 NVLink 物理互连的一组 GPU;GB200 上一个 rack(NVL72)就是一个 NVLink Domain(72 GPU),有相同的 Cluster UUID
  • NVLink Partition(NVLink 分区):由 NVSwitch 硬件隔离出的分区,分区之间互不相通、用于多租户隔离;GPU 的 Clique ID 就对应它所属的分区(在 K8s 里,GFD 据此给节点打上 nvidia.com/gpu.clique 标签)。
  • IMEX Domain:某个 NVLink Partition 内运行 nvidia-imex 的一组节点,必须共享同一 <ClusterUUID, CliqueID>;一个 NVLink Partition 内可以有多个 IMEX Domain。
  • IMEX Channel:IMEX Domain 内的一条软件通道,跨节点作业的各个进程拿到同一个 channel 才能互访显存;不同作业用不同 channel,互相隔离。
下面看看 ComputeDomain 的工作原理,过程如下:
  • 创建 ComputeDomain(图中 compute-domain-0)后,compute-domain-controller 会配套建出一个 DaemonSet 和一个 ResourceClaimTemplate,此时还没真正组网:
    • 一个 per-domain 的 DaemonSet,用来在节点上运行 nvidia-imex;它的 nodeSelector 只认带有 ComputeDomain UID 标签的节点——标签形如 resource.nvidia.com/computeDomain: <ComputeDomain 的 UID>
    • 一个供 workload 申请 channel 的 ResourceClaimTemplatecompute-domain-0-rct,背后是 compute-domain-default-channel.nvidia.com 这个 DeviceClass)。
    • 此刻还没有节点带这个标签,所以没有 daemon Pod 在跑,也还没有 IMEX domain。
  • workload Pod 引用该 ResourceClaimTemplate 并被调度到节点后,daemon 才被拉起来:
    • compute-domain-kubelet-plugin 给所在节点打上上述标签,DaemonSet 随即在这批节点上拉起 compute-domain-daemon
    • 每个 daemon 运行 nvidia-imex(配置目录 /imexd 由 kubelet-plugin 挂载进 daemon 容器),管理本节点的 NVLink fabric 连接。
    • daemon 通过 driver namespace 里的 ComputeDomainClique CR 公布自己的 IP、clique 归属和就绪状态。
  • daemon 起来后互相组网,channel 也随之注入容器:
    • 这些 daemon 组成一个 IMEX domain,也就是这批节点能互访显存的范围。
    • workload Pod 申请到 channel 后,compute-domain-kubelet-plugin 向其容器注入对应的 IMEX channel 设备(/dev/nvidia-caps-imex-channels/channel0)。
    • 同一 ComputeDomain 下所有 Pod 拿到同一个 channel(图中 channel 0),凭它跨节点读写彼此的 fabric 显存——真正能互访的,就是拿到同一 channel 的这批 Pod。
ComputeDomain 到 IMEX domain 的映射:左侧 Kubernetes 控制面里 ComputeDomain 创建 ResourceClaimTemplate 与 per-domain DaemonSet;右侧同一 NVLink Partition(同一 Clique ID)内的三个节点各跑一个 workload Pod、一个 compute-domain-daemon(nvidia-imex)和若干 GPU,daemon 组成一个 IMEX Domain,三个 Pod 拿到同一个 channel 0,底部经 NVLink/NVSwitch fabric 共享显存

一个 ComputeDomain 对应一个 IMEX domain:controller 先建出 ResourceClaimTemplate 和 per-domain DaemonSet,workload Pod 落到节点后由 nvidia-imex 组成一个 IMEX domain,并给这批 Pod 注入同一个 channel,跨节点经 NVLink/NVSwitch fabric 共享显存

参考资料

5.4 QoS

Kubernetes 按 Pod 的 requests / limits 把它分成三个 QoS 级别GuaranteedBurstableBestEffort。节点内存紧张时,kubelet 按 BestEffortBurstableGuaranteed 的顺序驱逐 Pod。 GPU 训练 Pod 应尽量落到 Guaranteed——每个容器的 CPU、memory requests 都等于 limits(CPU 取整数还能配合 CPU Manager static 独占核)。这样既不会在内存紧张时被优先驱逐,也是后续做 NUMA 保证分配的前提。

5.5 拓扑感知调度

为支撑延迟敏感、高吞吐的工作负载,Kubernetes 提供了一套 Resource Manager(资源管理器)。它们为那些对 CPU、设备、内存(hugepages)有特定要求的 Pod,协调并优化节点内资源的对齐。 Topology Manager 是 kubelet 的一个组件,负责协调这一组承担上述优化的管理器。这组管理器各管一类资源,都作为 Topology Manager 的 hint provider,与它协商来完成资源分配决策: 如下图,Topology Manager 自己不管具体资源,只在 Pod 准入阶段做协调:先向这三个 manager 各要一份 NUMA 提示(GetTopologyHints),合并后按策略决定是否准入该 Pod;准入后再让它们各自按选定的 NUMA 分配。这样 CPU、内存、设备就尽量落到同一个 NUMA 节点,减少跨 NUMA 访问。
Kubernetes Topology Manager 汇总 CPU Manager、Device Manager 等 hint provider 的拓扑提示,并决定 Pod 是否可以被 kubelet 准入

Topology Manager 在 Pod 准入阶段汇总 CPU / Memory / Device Manager 的 NUMA 提示,据此决定 Pod 能否被 kubelet 准入。图源:Kubernetes Topology Manager

Topology Manager 支持四种策略(通过 kubelet 的 --topology-manager-policy flag 或配置字段 topologyManagerPolicy 设置),对齐的严格程度依次递增:
  • none(默认):不做任何拓扑对齐。
  • best-effort:为每个容器算出首选 NUMA 亲和;即使对不齐到首选,也照常准入(尽力而为)。
  • restricted:算出首选亲和;若实际无法命中首选,则拒绝该 Pod。
  • single-numa-node:判断资源能否落在单个 NUMA node,能则准入,否则拒绝。
Topology Manager 还有两种作用域(通过配置字段 topologyManagerScope 设置),决定对齐是「按容器」还是「按 Pod」:
  • container(默认):逐容器分别对齐,容器之间不成组,各自被独立地对齐到 NUMA。
  • pod:把 Pod 内所有容器当作整体,一起对齐到单个 NUMA node 或同一组 NUMA node(Pod 的资源总量按 effective requests/limits 计算,即取「所有 app 容器请求之和」与「最大的单个 init 容器请求」中的较大值——也就是 Pod 同一时刻的峰值需求)。配合 single-numa-node 可把整个 Pod 放到一个 NUMA node 上,消除 Pod 内的跨 NUMA 通信开销,适合延迟敏感或高吞吐 IPC 应用。

5.5.1 CPU Manager

CPU Manager 是 kubelet 的组件,为 CPU 资源提供独占式分配能力。 默认情况下,kubelet 用 CFS quota(Linux CFS 调度器基于 cgroup 的 CPU 时间配额)来限制 Pod 的 CPU 上限;当节点上跑着很多 CPU 密集型 Pod 时,工作负载可能迁移到不同的 CPU 核上——取决于 Pod 是否被限流、以及调度时哪些核可用。多数负载对这种迁移不敏感,无需任何干预也能正常工作。但对 CPU cache 亲和、调度延迟明显影响性能的负载,kubelet 允许用 CPU 管理策略来影响核的放置。关键配置:
  • CPU Manager 有两种 policy(通过 cpuManagerPolicy 配置):
    • none(默认):不做额外绑核,沿用 OS 调度器默认的 CPU 亲和(CPU 上限仍由 CFS quota 限制)。
    • static:让 Guaranteed Pod 中「整数 CPU 请求」的容器独占节点上的 CPU 核(独占性由 cpuset cgroup 控制器、即写 cpuset.cpus 强制);其余容器共用 shared pool。
  • 启用 static 时,kubelet 要求预留一份大于零的 CPU(否则独占核被占满后 shared pool 会变空,非独占容器无核可跑);下面三个参数任选其一或组合,预留的核都会从独占分配池里排除:
cpuManagerPolicyOptions 用来控制 static 策略的行为,可选项(按字母序): 下面是一个启用 static 的 KubeletConfiguration 示例:

5.5.2 Memory Manager

Memory Manager 是 kubelet 的组件,为 Guaranteed QoS 的 Pod 提供内存 / hugepages 的 NUMA 保证分配:它生成内存的 NUMA 亲和 hint 交给 Topology Manager,并通过 cgroup 的 cpuset.mems 强制内存只从选定的 NUMA node 分配。关键配置:
  • Memory Manager 有两种 policy(通过 memoryManagerPolicy 配置):
    • None(默认):不做内存 NUMA 对齐,一律返回默认 hint(等于没有 Memory Manager)。
    • Static(仅 Linux):给 Guaranteed Pod 的内存 / hugepages 做 NUMA 保证分配并写 cpuset.mems;BestEffort / Burstable 仍返回默认 hint。
  • 启用 Static必须reservedMemory(逐 NUMA node 预留),且预留总量必须等于 kubeReserved + systemReserved + evictionHardmemory.available 三者之和,否则 kubelet 启动报错。计算公式如下:
i=0n1reservedMemory[i]  =  kubeReserved+systemReserved+evictionHard(memory.available)\sum_{i=0}^{n-1} \text{reservedMemory}[i] \;=\; \text{kubeReserved} + \text{systemReserved} + \text{evictionHard(memory.available)} 其中 ii 是 NUMA node 下标,nn 是 NUMA node 总数。左边是逐 NUMA node 预留量之和,右边是节点级别要留出的内存总量(K8s 组件 + OS 系统进程 + eviction 缓冲,memory.available 默认 100Mi)。含义是:节点总共预留多少内存,就得原样按 NUMA node 拆分下去,两边必须严格相等——多一点少一点 kubelet 都会启动报错。

5.5.3 Device Manager

Device Manager 通过 device plugin API 把设备(GPU、NIC 等)分配给 Pod。为了让设备也能参与 Topology Manager 的 NUMA 对齐,device plugin API 专门扩展出了一个 TopologyInfo 结构:plugin 在 ListAndWatch 里逐个上报设备时带上它的 NUMA 归属,Device Manager 据此把设备的 NUMA 偏好转成 hint 交给 Topology Manager。 plugin 上报的单个设备(Device)长这样:
  • ID:设备唯一标识,不透明字符串,由 plugin 自定义(GPU 常用 UUID / minor number)。
  • Health:健康状态(Healthy / Unhealthy),只有 Healthy 的设备才计入该资源的 allocatable。
  • Topology.Nodes:设备的 NUMA 归属。这里 {ID: 0} 表示这块设备挂在 NUMA node 0 上;可填多个表示能从多个 NUMA node 访问,留空(Topology: nil)则表示无 NUMA 偏好。

5.5.4 拓扑感知调度示例

下面就演示一个拓扑感知调度的完整示例。 节点拓扑假设(2 个 NUMA node) 1. 先在每个节点的 KubeletConfiguration 里同时打开 CPU、Memory、Topology 三个 manager,并用 single-numa-node 策略把一个容器的所有资源都对齐到同一个 NUMA node:
2. 假设要调度的 Pod 如下,申请 16 核 CPU、128Gi 内存和 4 张 GPU。它的 CPU 与内存都设了 requests == limits,因此属于 Guaranteed QoS(QoS 只看 CPU 和内存;GPU 这类扩展资源本身就要求 requests == limits,但不参与 QoS 判定)。只有 Guaranteed 的 Pod 才会触发三个 manager 的 NUMA 保证分配。
3. 三个 provider 各自上报 hint:每个 manager 只为自己那类资源生成 hint——把「哪些 NUMA node 组合能满足请求」列成候选掩码,其中用最少 node 数就能满足的标 Preferred: true{n} 是 NUMA 位掩码的简记,{0,1} 表示同时用到 node0 和 node1)。本例单个 node 就够任一资源(node0 有 46 个可分配核、约 510Gi 内存、4 张 GPU,都 ≥ 请求量),所以三者形状一致:单节点 {0}{1} 是首选,跨节点 {0,1} 也够但非最优。
4. Topology Manager 合并并决策:从每个 provider 各取一条 hint 组成一个组合,把三者的 NUMA 掩码按位(AND)、Preferred 也全部相与,得到合并结果,再按 single-numa-node 策略筛选。 合法的单节点方案有 {0}{1},Topology Manager 取最窄且最优的一个(宽度相同时选低位),即 NUMA node 0。于是准入该 Pod,并让三个 provider 都在 node 0 上分配:
  • CPU Manager:从 node0 的可分配核(2–47)里独占 16 核;
  • Memory Manager:128Gi 内存全部来自 node0;
  • Device Manager:分配 gpu0gpu3
最终 CPU、内存、GPU 全部落在 NUMA node 0,没有跨 NUMA 访问。 无法单节点满足则拒绝:若把请求改成 nvidia.com/gpu: 8,任何单个 node 都只有 4 张 GPU,合并后不存在成立的单节点组合,single-numa-node拒绝准入

5.5.5 DRA:把拓扑对齐提前到调度阶段

5.5.4 拓扑感知调度示例 的对齐发生在节点准入阶段:scheduler 选节点时只核对节点级资源总量(够几核 CPU、多少内存、几张 GPU),只管数量够不够,不管这些资源在节点内的 NUMA 位置和互联。结果即便节点数量够,Pod 落上去后也可能没按最优的单 NUMA 方式部署,资源被迫跨 NUMA、拖慢性能。 DRA 把这层判断提前到调度阶段:driver 把设备拓扑写成属性(如 resource.kubernetes.io/pcieRootgpu.nvidia.com/parentUUID),claim 用 constraints.matchAttribute 表达对齐要求,scheduler 选节点时就强制满足,直接挑一个能对齐的节点。GPU、NIC 这类设备之间的互联对齐现已可用;设备之外,CPU 和内存也在被逐步纳入 DRA(KEP-5517 DRANodeAllocatableResources,Kubernetes 1.36 alpha)。
  • dra-driver-cpu:用 DRA 替代 kubelet CPU Manager 做独占绑核,可按 core 类型、NUMA、LLC 等属性在调度阶段选核;与 CPU Manager 节点级互斥(需 cpuManagerPolicy: none)。
  • dra-driver-memory:同思路的 Memory Manager 替代,管内存 / hugepages 的 NUMA 分配。
参考资料

5.6 优化网络通信

多节点 GPU 作业里,Pod 之间要频繁通信。Kubernetes 默认给每个 Pod 分配独立 IP,跨节点 Pod 之间还可能隔着 overlay 网络或 NAT,给性能敏感的 GPU 通信带来额外开销。 对性能敏感作业,可以让 Pod 直接用宿主机网络(hostNetwork: true,Docker 里对应 --network=host),容器网络不再隔离,直接使用宿主机的网络接口:
这样容器就能像宿主机一样直接访问 InfiniBand 互连,没有额外的地址转换和防火墙层。对 MPI 作业尤其有用——省去为每个 MPI rank 配端口映射的麻烦。 跨节点通信的瓶颈往往在网络栈:RDMA(远程直接内存访问)让一台机器的网卡直接读写另一台的内存,绕过 CPU 和内核协议栈;GPUDirect RDMA 更进一步,让网卡直接 DMA 到 / 从 GPU 显存,省掉「GPU → CPU 内存 → 网卡」的中转拷贝——这对多机 NCCL all-reduce 的带宽至关重要。要在 Kubernetes 里启用 RDMA,可以装 Mellanox Kubernetes RDMA device plugin:它把 InfiniBand 与 GPUDirect RDMA 端点暴露到 Pod 的网络接口上,实现低延迟、零拷贝通信。

5.7 网络拓扑感知调度

数据中心网络是分层的交换机结构(节点 → 机架交换机 → spine → core),两个节点相距的交换机层级越多、通信越慢。分布式训练的多个 Pod 一旦被撒到不同机架、不同 spine 下,all-reduce、all-to-all 就会走慢速的跨交换机链路,拖垮整体吞吐。网络拓扑感知调度让调度器理解这套层级,把同一作业的 Pod(作为一个 gang)尽量放进同一个网络性能域,让集合通信走在跳数更少、带宽更高的近端链路上,降低延迟与拥塞。

5.7.1 Kubernetes 官方方案

Kubernetes 官方原生提供的网络拓扑感知调度方案是 Topology-Aware Workload Scheduling(v1.36 alpha):在 kube-scheduler 上开启 TopologyAwareWorkloadScheduling feature gate,用 PodGroup 表达 gang 调度和拓扑约束——拓扑约束基于某个 node label key(如 topology.kubernetes.io/rack),调度器的 TopologyPlacement 插件按该 label 的取值把节点分组,挑一个能装下整组 Pod 的域:
v1.36 的一个限制要留意:每个 PodGroup 只能写一个拓扑约束——即单层 label,不支持 block → rack → host 这样的多层级。需要多层级、或软约束(preferred)时,用下面的 KAI / Volcano。

5.7.2 NVIDIA KAI Scheduler

KAI Scheduler 支持网络拓扑感知调度,做法分两步:先用 Topology CRD 声明集群的拓扑层级,再在作业上用注解引用它、表达约束。第一步,Topology CRD 把一组 node label 从最外层(如 block)到最内层(单机)列成层级,scheduler 据此把节点组织成一棵树:
第二步,在作业(这里是 Job)上用注解引用这个 topology 并指定约束层级:kai.scheduler/topology 选择哪个 topology,topology-required-placement 是硬边界(必须落在该层内),topology-preferred-placement 是软优先(在硬边界内尽量收得更紧,只有比 required 更靠下的层级才有意义):

5.7.3 Volcano

Volcano 支持网络拓扑感知调度,做法是用 HyperNode CRD 把网络显式建成交换机树:一个 HyperNode 代表一个网络拓扑性能域,通常映射到一个交换机 / TOR;多个 HyperNode 按层级连成树。叶子 HyperNode(tier 1)的成员是真实节点,非叶 HyperNode(tier 2、3…)的成员是下层 HyperNode——tier 越低,域内节点间通信越快。
Volcano HyperNode 树状拓扑:Tier 2 的 s2 聚合两个 Tier 1 的 s0 和 s1,s0 含 Node0/Node1、s1 含 Node2/Node3

Volcano HyperNode 把网络建成交换机树:叶子(Tier 1,如 s0 / s1)含真实节点,上层(Tier 2,s2)逐级聚合。图源:Volcano Network Topology Aware Scheduling

节点间的通信效率取决于跨越的 HyperNode 层级:node0 与 node1 同属 s0,效率最高;node1 与 node2 要跨两层(s0 → s2 → s1),效率较低。下面把这棵树建出来——s0s1 是 tier 1 的叶子(各含两个节点),s2 是 tier 2、成员是 s0s1
作业用 networkTopology 声明要落在哪一层内。下面这个作业把所有 Pod 严格锁进同一个 leaf(tier 1,即单个 s0 或 s1)
参考资料

FAQ

MIG 是单卡内部的硬件切分:把一张 GPU 切成多个彼此隔离的实例(各有独立的 SM、显存和 cache),供多个小任务分别使用。多卡场景下可以在每张支持 MIG 的卡上分别开启 MIG,但每个 MIG 实例只属于它所在的那张物理卡,不能跨卡合并成更大的实例。此外,开启 MIG 会禁用该卡的 NVLink P2P,所以依赖多卡 NVLink 互联的大规模训练不适合用 MIG(应整卡使用)。一句话:MIG 是「把单卡切小」,不是「把多卡拼大」。
不会。renice(底层 setpriority / sched_setattr)可以对正在运行的进程 / 线程动态调整 nice,内核会立即按新权重重新参与 CFS 调度,无需重启进程。几点注意:提高优先级(nice 调为负)需要 root 或 CAP_SYS_NICE,降低优先级普通用户即可;nice 只影响 CFS 普通线程之间相对的 CPU 时间分配,不改变程序正确性,也不等于独占 CPU;但不要把关键线程(如 DataLoader、通信辅助线程)的优先级调得过低,否则容易被其他进程抢占,反而拖慢对 GPU 的数据供给。