Sorry, your browser cannot access this site
This page requires browser support (enable) JavaScript
Learn more >

工程实践:实现单机 GPU/CPU 混部

这篇文章记录了我们在 GPU 训练机器上实现 CPU 任务混部的完整过程。读完之后,你会了解:GPU 训练任务的数据加载链路(DataLoader → 共享内存 → 锁页内存 → DMA → GPU HBM)中哪些环节对 NUMA 敏感;为什么 Linux 的 First-Touch 内存策略在混部场景下会产生问题,以及如何通过 MPOL_INTERLEAVE 解决;CPU 缓存的 L3 共享边界在哪里(CCD),以及为什么给 CPU 任务"绑核"反而不如让它跑 Burstable;最终如何通过 Kubernetes 的 CPU Manager、topology manager 以及应用层的 NUMA 策略,让两类任务在同一台机器上稳定共存而互不干扰。

一、背景

我们有一批 GPU 训练机器,硬件配置相当豪华。以其中一台为例:

  • CPU:384 核(2 socket × 96 物理核 × 2 超线程)
  • 内存:~2.2 TiB
  • GPU:8 张

部署训练任务之后,GPU 是满的,但 CPU 和内存还剩下相当大一部分。让这些资源闲置显然是浪费,于是我们开始考虑:能不能在同一台机器上跑一些 CPU 任务,把剩余资源利用起来?

听起来不复杂。实际做下来,踩了不少坑。


二、核心约束:GPU 任务是一等公民

混部最重要的原则只有一条:GPU 训练任务的性能不能因为 CPU 任务而下降。

一旦确立这条原则,问题就变得具体了。"CPU 任务影响 GPU 任务"可以通过三条路径发生:

  1. CPU 争抢:CPU 任务占用了训练进程需要的 CPU 核,导致训练线程被调度出去
  2. 内存争抢:CPU 任务占用了大量内存,或者导致内存在 NUMA 节点上分布不均,影响训练的内存访问性能
  3. 缓存污染:CPU 任务频繁读写数据,把 GPU 训练进程的热数据从 CPU 缓存中驱逐出去
  4. PCIe 带宽争抢:如果 CPU 任务有大量磁盘 IO,且 NVMe 设备挂在与 GPU 同侧的 PCIe root complex 上,IO 流量可能与 GPU DMA 传输争抢 PCIe 带宽。这条路径在实践中影响较小(训练任务的数据加载通常不是 PCIe 瓶颈),但在 IO 密集型 CPU 任务混部时值得关注

前三个问题是我们实际遇到并解决的,下面逐一展开。


三、CPU 分配

3.1 Kubernetes CPU Manager

Kubernetes 默认的 CPU 调度是"共享"模式——所有容器的 CPU 时间由 kernel 统一调度,容器之间会相互抢占。这对于 GPU 训练任务是不可接受的,训练进程对 CPU 的延迟非常敏感,一旦被抢占,通信同步就会出现抖动。

解决方案是开启 Kubernetes 的 Static CPU Manager。它的工作方式是:对于满足条件的 Pod,在启动时就把特定的物理 CPU 核心独占性地分配给它,其他 Pod 不能使用这些核心。

要启用 Static CPU Manager,需要在 kubelet 配置中设置:

1
2
cpuManagerPolicy: static
reservedSystemCPUs: "0,192,96,288" # 每个 NUMA 节点各保留一个完整物理核给系统

reservedSystemCPUs 必须配置,Static CPU Manager 要求为系统进程预留至少一个核心。

3.2 Guaranteed QoS:拿到独占核心的门票

不是所有 Pod 都能享受独占 CPU 核心。Static CPU Manager 只对 Guaranteed QoS 的 Pod 生效。

Kubernetes 对 Pod 有三种 QoS 等级:

  • Guaranteedrequests == limits,且 CPU 和内存都设了值
  • Burstable:设了 requests 但没有设 limits,或 requests != limits
  • BestEffort:既没有 requests 也没有 limits

只有 Guaranteed Pod,CPU Manager 才会为其分配独占核心;Burstable 和 BestEffort Pod 的 CPU 仍然由 kernel 在"共享池"里调度。

因此,我们将 GPU 训练任务和 CPU 任务都配置为 Guaranteed,让 CPU Manager 为它们分配互不重叠的核心。

3.3 跨 NUMA 均匀分配

仅仅分配独占核心还不够。本机有两个 NUMA 节点,如果 GPU 任务的 CPU 全集中在 NUMA0,会导致 NUMA0 和 NUMA1 的利用率严重不均,进而影响内存访问性能。

kubelet 提供了一个选项缓解这个问题:

1
2
3
cpuManagerPolicy: static
cpuManagerPolicyOptions:
distribute-cpus-across-numa: "true"

需要注意,这个选项只在单个 NUMA 节点放不下所申请的 CPU 数时才生效——此时 CPU Manager 会把核心跨 NUMA 节点均匀地拆分分配,而不是全部塞进一个节点。如果申请的 CPU 数在单个 NUMA 节点内就能满足,分配仍然会集中在一个节点上。因此,对于本机这样每个 NUMA 节点有 95 个可用逻辑核的配置,GPU 任务申请超过 95 个核心时,这个选项才会起作用。

配置完成后,GPU 任务和 CPU 任务各自拿到互不重叠的 CPU 核心,GPU 训练线程不会被 CPU 任务抢占。


四、内存分配

4.1 内存分配,真的是问题吗?

在混部场景下,内存分配是否有问题,取决于任务本身的行为。我们分三种情况来看。

要理解这个问题,需要先了解 NUMA 架构。如果你还不熟悉,推荐先读 《从第一性原理理解 NUMA》,这里只做简要补充。

本机的 NUMA 拓扑如下:

1
2
3
4
5
6
7
8
9
10
┌─────────────────────────────┐   ┌─────────────────────────────┐
│ NUMA Node 0 │ │ NUMA Node 1 │
│ │ │ │
│ 物理核: 0~95 │ │ 物理核: 96~191 │
│ 逻辑核: 0~95, 192~287 │ │ 逻辑核: 96~191, 288~383 │
│ │ │ │
│ 本地内存: ~1.1 TiB │ │ 本地内存: ~1.1 TiB │
└─────────────────────────────┘ └─────────────────────────────┘
Socket 0 Socket 1
└──────── UPI 总线 ──────────┘

两个 NUMA 节点之间通过 UPI 总线(Ultra Path Interconnect)互联。当 NUMA0 上的 CPU 访问挂在 NUMA1 上的内存时,请求必须先经过 UPI 跨越到对端 socket,再把数据传回来,延迟比访问本地内存高约 30%~40%,带宽也受 UPI 吞吐上限制约。

因此,内存应该尽量分布在两个 NUMA 节点,让每个 rank 都能访问本地内存,而不是全堆在一侧、一半 rank 全走远端。

这里先解释一下 rank 的概念。在分布式训练(如 PyTorch DDP)中,每张 GPU 对应一个独立的训练进程,这些进程通过 NCCL 等通信库协同完成梯度同步。每个进程都有一个唯一编号,称为 rank。8 卡训练中 rank 0–7 分别对应 GPU 0–7,彼此地位对等,共同维护一份模型参数的副本。

4.2 训练任务的数据流

在讨论内存分配问题之前,先梳理一次训练迭代里 CPU、内存和 GPU 的协作流程。

第一步:主进程启动 DataLoader

训练脚本调用 DataLoader(dataset, num_workers=N, pin_memory=True) 后,主进程 fork 出 N 个 worker 子进程负责数据加载。在 8 卡 DDP 训练中,每块 GPU 对应一个训练进程(rank 0–7),每个训练进程各自创建自己的 DataLoader,因此整台机器上共有 8 × N 个 DataLoader worker 进程同时运行。

这里有一个潜在问题:PyTorch 默认不做 CPU 亲和性绑定,OS 自由调度这些 worker 进程。这就可能出现 GPU 4(物理上连在 NUMA1 的 PCIe 上)的 DataLoader worker 被调度到 NUMA0 的 CPU 核上运行,后续数据搬运就会跨 NUMA。

第二步:Worker 进程读取并预处理数据

每个 worker 进程在循环里做三件事:从 index queue 收取样本索引、从磁盘读取原始数据(触发系统调用,数据经 page cache 进入进程地址空间)、在 CPU 上完成预处理(decode、resize、normalize、tokenize 等)。

关键在第二步:数据从磁盘读入后,Linux 的 First-Touch 策略决定了这块内存落在哪个 NUMA 节点——由 worker 进程当前运行的 CPU 核所属的 NUMA 节点决定

第三步:Worker 通过共享内存将数据交给主进程

处理好的 tensor 需要从 worker 传回主进程。PyTorch 使用共享内存(/dev/shm)而非管道拷贝:worker 将 tensor 底层存储映射到一个共享内存段,然后通过 multiprocessing Queue 只传递文件描述符和元信息(几十字节);主进程拿到后直接 mmap 同一块共享内存,零拷贝获取 tensor。

实际数据搬运量为零,但共享内存段的物理位置同样由 First-Touch 决定,通常在 worker 所在的 NUMA 节点上。

第四步:pin_memory 线程将数据拷贝到锁页内存

如果设置了 pin_memory=True,主进程内有一个专门的 pin_memory_thread,持续从 result queue 取出共享内存中的 tensor,调用 cudaHostAlloc() 分配一块锁页内存(page-locked,不会被换出,物理地址固定),再将数据 memcpy 进去。

锁页内存是 GPU DMA 可以直接访问的内存。这里有一个关键的 NUMA 问题:cudaHostAlloc() 底层通过 mmap + mlock 分配,物理页仍遵循调用线程的 NUMA 策略——默认是 First-Touch,即分配在 pin_memory_thread 当前运行的 CPU 核所属的 NUMA 节点上。如果训练框架通过 --cpunodebind=0 将 rank 0 绑定到 NUMA0,pin_memory_thread 也跑在 NUMA0 上,分配的锁页内存就落在 NUMA0。问题在于,GPU 4 物理上连在 NUMA1 的 PCIe root complex 上,此时 DMA 路径就变成:

1
NUMA0 锁页内存 → 跨 UPI → NUMA1 PCIe root complex → GPU4 HBM

而 NUMA-local 的正常路径是:

1
NUMA1 锁页内存 → NUMA1 PCIe root complex → GPU4 HBM

跨 NUMA 路径需要经过 UPI 互连(带宽约 40–60 GB/s),而本地 PCIe Gen4 ×16 单向约 32 GB/s,两条路径的实测带宽差距可达 30%~50%,且 UPI 成为多个 rank 的共用瓶颈。

第五步:异步 DMA 传输到 GPU HBM

训练循环中调用 batch.to(device, non_blocking=True) 时,PyTorch 发起 cudaMemcpyAsync,GPU 的 DMA 引擎通过 PCIe 直接从锁页内存拉取数据到 HBM,不经过 CPU。non_blocking=True 使传输与上一批的计算并行(compute-transfer overlap),数据到达 HBM 后,前向传播和反向传播就完全在 GPU 上进行,直到下一轮数据加载和梯度同步。


整个链路可以概括为:

1
2
3
4
磁盘 → page cache(NUMA-aware,First-Touch)
→ 共享内存(worker 所在 NUMA)
→ 锁页内存(cudaHostAlloc,需要 NUMA-aware)
→ GPU HBM(DMA,经 PCIe)

任何一个环节出现跨 NUMA 访问,都会带来额外的 UPI 流量和延迟。这就是为什么内存的 NUMA 分布不是一个无关紧要的细节。

4.3 情况一:简陋的训练任务(无框架优化)

假设训练任务没有任何框架层优化,直接启动一个进程跑训练。Linux 的默认内存分配策略是 First-Touch:物理页不在 malloc 时分配,而是在进程第一次访问时,分配在当前 CPU 所在的 NUMA 节点上。

训练进程通常由一个主线程完成初始化,初始化期间申请大量内存。如果这个主线程运行在 NUMA0,几乎所有内存都会落在 NUMA0。后续多线程展开之后,这些内存已经"定居"在 NUMA0,跑在 NUMA1 上的线程访问它们,走的全是远端 UPI,性能直接打折。

这种情况下,内存分配是个问题。

4.4 情况二:有框架优化的训练任务(无混部)

生产级训练框架(如 PyTorch DDP、DeepSpeed)不会让 First-Touch 随机发生。它们在启动各 rank 时,会显式地做 NUMA 绑定:

1
2
3
4
5
# rank 0 绑定到 NUMA0
numactl --cpunodebind=0 --membind=0 python train.py --rank 0

# rank 1 绑定到 NUMA1
numactl --cpunodebind=1 --membind=1 python train.py --rank 1

--membind=0 强制所有内存分配发生在 NUMA0,--membind=1 强制在 NUMA1。每个 rank 的内存严格绑在本地节点,两侧各占一半,First-Touch 问题被彻底规避,内存访问全是本地的。

这种情况下,内存分配没有问题。

4.5 情况三:有框架优化的训练任务 + CPU 任务(混部)

混部之后,问题重新出现,但根源变了——不是 GPU 任务自身的 First-Touch,而是 CPU 任务的 First-Touch 破坏了 GPU 任务的内存格局

先看两类任务的资源配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# GPU 训练任务
resources:
requests:
cpu: "192"
memory: "1600Gi"
limits:
cpu: "192"
memory: "1600Gi"

# CPU 任务
resources:
requests:
cpu: "90"
memory: "600Gi"
limits:
cpu: "90"
memory: "600Gi"

GPU 任务申请 1600Gi 内存,训练框架通过 numactl --membind 把各 rank 绑定到对应 NUMA 节点,每个 NUMA 节点上的 rank 预期可以使用约 800Gi 本地内存。本机每个 NUMA 节点共有约 1.1TiB 可用内存,800Gi 在单个节点内是可以满足的。

CPU 任务没有 NUMA 亲和性意识,完全依赖 First-Touch 随机分配内存。问题取决于两类任务的调度顺序

GPU 任务先调度:GPU 任务启动,各 rank 按 numactl --membind 绑定,NUMA0 和 NUMA1 各分配到约 800Gi。之后 CPU 任务启动,两侧各有约 300Gi 剩余,First-Touch 落在哪侧都不会导致严重失衡。没有问题。

CPU 任务先调度:CPU 任务先启动,申请 600Gi 内存,First-Touch 策略导致这 600Gi 集中落在某一侧,假设全部落在 NUMA0。此时 NUMA0 剩余可用内存约为 1100Gi - 600Gi = 500Gi,而 GPU 任务的 rank 0 需要在 NUMA0 上分配 800Gi——空间不够,直接 OOM;或者 Kubernetes 放宽约束,让 rank 0 跨到 NUMA1 分配,但这意味着 rank 0 的内存全部走远端 UPI,训练过程中每次数据加载都要跨 NUMA,性能持续下降。

这种情况下,取决于调度顺序,有可能有问题

4.6 你们的场景属于哪种?

如果你的环境是情况三,且能保证 GPU 任务总是先于 CPU 任务调度,那当前已经足够了。

但调度顺序在 Kubernetes 环境下往往难以保证。Pod 重启、节点驱逐、滚动发布都可能打乱顺序。需要一个不依赖调度顺序的解法。

4.7 解法:给 CPU 任务加上 MPOL_INTERLEAVE

思路是让 CPU 任务主动声明内存分配策略,而不是任由 First-Touch 随机决定。

Linux 提供了 set_mempolicy 系统调用,MPOL_INTERLEAVE 模式会强制内核在分配内存页时交替使用所有指定的 NUMA 节点,而不是集中在当前 CPU 所在的节点。

在 CPU 任务启动命令前加上:

1
numactl --interleave=all python cpu_task.py

或者在代码中调用:

1
2
3
4
5
6
7
8
9
10
11
import ctypes

libc = ctypes.CDLL("libc.so.6", use_errno=True)
MPOL_INTERLEAVE = 3
nodemask = (ctypes.c_ulong * 1)(0x3) # NUMA nodes 0 and 1
libc.syscall(
ctypes.c_long(238), # SYS_set_mempolicy
ctypes.c_int(MPOL_INTERLEAVE),
ctypes.cast(nodemask, ctypes.c_void_p),
ctypes.c_ulong(3)
)

这样无论调度顺序如何,CPU 任务的内存都会均匀地分布在两个 NUMA 节点,不会独占任何一侧,给 GPU 任务留出均衡的空间。实验验证,两侧 NUMA 内存比稳定在 1.00

GPU 任务本身不需要改动,框架的 numactl --membind 绑定已经足够,叠加 MPOL_INTERLEAVE 反而会破坏其精确的 NUMA 亲和性。

五、缓存隔离

5.1 CPU 缓存的层次与共享范围

CPU 缓存分三级,关键是搞清楚谁和谁共享哪一级。在介绍之前,先了解一个硬件概念:Core Complex Die(CCD)

现代高核数服务器 CPU(如 AMD EPYC)并不是把所有核心做在一个大芯片上,而是采用 Chiplet 架构,把 CPU 拆成多个小的裸片(die)再封装在一起。每个 CCD 就是这样一个独立的计算裸片,包含若干个物理核心以及这些核心共享的 L3 缓存。以本机为例,每个 CCD 包含 8 个物理核心,共享一块 L3。一个 socket(96 核)由 12 个 CCD 拼成,两个 socket 共 24 个 CCD。

这个物理结构直接决定了 L3 缓存的共享边界:L3 是 CCD 内的概念,不同 CCD 之间没有共享的 L3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
┌──────────────────────────────────────────────────────────────────────┐
│ NUMA Node 0 (Socket 0) │
│ │
│ ┌─────────────────────── CCD 0 ────────────────────────┐ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ 核心 0 │ │ 核心 1 │ ··· │ 核心 6 │ │ 核心 7 │ │ │
│ │ │ L1 L2 │ │ L1 L2 │ │ L1 L2 │ │ L1 L2 │ │ │
│ │ └───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘ │ │
│ │ └──────────┴───────┬─────────┴──────────┘ │ │
│ │ L3 (8核共享) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────── CCD 1 ────────────────────────┐ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ 核心 8 │ │ 核心 9 │ ··· │ 核心14 │ │ 核心15 │ │ │
│ │ │ L1 L2 │ │ L1 L2 │ │ L1 L2 │ │ L1 L2 │ │ │
│ │ └───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘ │ │
│ │ └──────────┴───────┬─────────┴──────────┘ │ │
│ │ L3 (8核共享) │ │
│ └────────────────────────────────────────────────────────┘ │
│ ···(共 12 个 CCD) │
└──────────────────────────────────────────────────────────────────────┘

具体来说:

  • L1、L2 缓存:每个物理核私有。一个物理核有两个逻辑核(超线程),这两个逻辑核共享同一组 L1/L2
  • L3 缓存:每个 CCD 内的 8 个物理核(16 个逻辑核)共享一块 L3。跨 CCD 的两个核心,L3 完全独立,互不影响。

5.2 缓存污染是怎么发生的

有了这个结构,污染的路径就很清晰了。

路径一:L1/L2 污染(最严重)

假设 CPU 任务的某个线程被调度到逻辑核 A,GPU 任务的某个线程运行在逻辑核 B,而 A 和 B 属于同一个物理核。那么:

  • 两个线程共享 L1/L2
  • CPU 任务读写数据时,会把 GPU 任务的热数据从 L1/L2 中驱逐
  • GPU 训练线程下次访问这些数据时,cache miss,需要重新从 L3 或内存加载

这种干扰是持续的、频繁的,对训练性能影响最大。

路径二:L3 污染

即使两个任务运行在不同物理核上,只要它们处于同一个 L3 组(即同 8 个物理核范围内),它们就共享 L3。CPU 任务的大数据量读写同样会把 GPU 任务的缓存行驱逐出 L3。

5.3 第一个思路:用 Guaranteed 绑核隔离

既然问题在于 CPU 任务的线程和 GPU 任务线程跑在同一个物理核(甚至同一个 L3 组),最直接的想法是:给 CPU 任务也绑核,让 CPU Manager 把它们分配到不同的核心。

这正是我们最初的做法——CPU 任务和 GPU 任务都配置为 Guaranteed,并在 kubelet 中额外开启 full-pcpus-only 选项:

1
2
3
4
cpuManagerPolicy: static
cpuManagerPolicyOptions:
distribute-cpus-across-numa: "true"
full-pcpus-only: "true"

仅有 Guaranteed QoS 还不够。Static CPU Manager 默认分配的是逻辑核(超线程),两个任务可能拿到同一个物理核的两个逻辑核,L1/L2 照样共享。full-pcpus-only: "true" 要求 CPU Manager 只分配完整的物理核——一个物理核的两个逻辑核必须同时分配给同一个 Pod,不允许拆开。这样才能真正做到物理核级别的独占。

5.4 绑核之后,缓存问题真的解决了吗?

配置完成后,CPU 任务和 GPU 任务各自独占不重叠的物理核,L1/L2 污染的问题解决了。

L3 污染依然存在,只要两类任务的核心落在同一个 L3 组(同一组 8 物理核)内,它们就共享 L3。CPU Manager 分配核心时,并不保证按 L3 边界对齐——比如 GPU 任务拿到了物理核 0~7,CPU 任务可能拿到了物理核 8~15,各自独立,但如果 GPU 任务拿到了 0~6,CPU 任务拿到了 7~14,那核 7 和核 8~14 就共享一个 L3,污染仍然存在。

更深的问题:即使按 L3 边界完美对齐,Guaranteed 绑核的 CPU 任务还有另一个副作用——被绑定的核心始终属于这个任务,哪怕任务没在跑计算,这些核心和它们的 L3 份额也被占用着。从缓存隔离的角度看,绑核是"重"的,实际上并不是最优解。

5.5 最佳实践:Burstable CPU 任务 + GPU 任务 CPU 数对齐 L3

最终方案是换一个思路:

CPU 任务改为 Burstable。

Burstable Pod 不会被 CPU Manager 分配独占核心,它的线程由 Linux kernel 调度,在共享 CPU 池里"游走"。这意味着 CPU 任务不会稳定地占据某个 L3 组——它的线程随时可能被调度到其他核心,不会持续地向某个 L3 写数据、驱逐 GPU 任务的缓存行。本质上,Burstable 任务的缓存影响是短暂、分散的,而不是持续、集中的。

GPU 任务的 CPU 数配置为 16 的倍数。

本机每 8 个物理核(16 个逻辑核)共享一个 L3。如果 GPU 任务申请的 CPU 数量是 16 的整数倍,CPU Manager 在分配时有更大概率对齐 L3 边界——因为 CPU Manager 倾向于从可用 CPU 集合中选取拓扑上连续的核心,在节点刚启动、没有碎片的情况下,分配结果通常与 CCD 边界自然对齐。但需要注意,这不是一个硬性保证:如果此前有 Pod 释放过核心导致可用集合出现碎片,分配结果可能跨 CCD。实际部署中,保持 GPU 任务最先调度、避免核心碎片化,可以让这个对齐更可靠。

1
2
3
4
5
6
GPU 任务申请 32 个逻辑核(= 16 个物理核 = 2 个完整 L3 组):

┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ···
│ L3 Group 0 │ │ L3 Group 1 │ │ L3 Group 2 │
│ (GPU 独占) │ │ (GPU 独占) │ │ (共享 CPU 池) │
└──────────────┘ └──────────────┘ └──────────────┘

GPU 任务独占的 L3 组,Burstable CPU 任务的线程不会被调度进去。这不是 kernel 的"倾向",而是硬性隔离:Static CPU Manager 在为 Guaranteed Pod 分配独占核心时,会将这些核心从默认的共享 cpuset 中移除,Burstable Pod 的 cpuset 中根本不包含这些核心,kernel 无法将其线程调度到上面。

两者结合,缓存污染问题从根本上得到控制。


六、最终方案

6.1 kubelet 配置

1
2
3
4
5
6
7
8
9
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cpuManagerPolicy: static
cpuManagerPolicyOptions:
distribute-cpus-across-numa: "true"
full-pcpus-only: "true"
reservedSystemCPUs: "0,192,96,288"
memoryManagerPolicy: None
topologyManagerPolicy: best-effort

关键点:

  • static + distribute-cpus-across-numa:保证 Guaranteed Pod 独占核心,且核心均匀分布在两个 NUMA 节点
  • full-pcpus-only:以完整物理核为单位分配,防止两个 Pod 共享同一物理核的两个逻辑核,杜绝 L1/L2 污染
  • memoryManagerPolicy: None:不让 Kubernetes 接管内存的 NUMA 分配,交给应用层自己处理
  • topologyManagerPolicy: best-effort:这里没有使用更严格的 restricted,是因为在混部场景下多个 Guaranteed Pod 竞争拓扑资源时,restricted 策略会导致 Pod 准入失败(UnexpectedAdmissionError)。best-effort 在尽量满足拓扑约束的同时,不会因为拓扑冲突拒绝 Pod 调度

6.2 GPU 任务 Pod 配置要点

1
2
3
4
5
6
7
resources:
requests:
cpu: "192" # 16 的倍数,对齐 L3 边界
memory: "1600Gi"
limits:
cpu: "192" # requests == limits,Guaranteed QoS
memory: "1600Gi"

启动命令中,由训练框架负责 NUMA 绑定:

1
2
3
4
# 各 rank 分别绑定到对应 NUMA 节点,由 launcher 自动处理
numactl --cpunodebind=0 --membind=0 python train.py --rank 0
numactl --cpunodebind=1 --membind=1 python train.py --rank 1
# ...

不需要 MPOL_INTERLEAVE,框架的 NUMA 绑定已经保证内存均衡。

6.3 CPU 任务 Pod 配置要点

1
2
3
4
5
resources:
requests:
cpu: "90"
memory: "600Gi"
# 不设 limits,或 limits > requests,Burstable QoS

启动命令加上 interleave 内存策略:

1
numactl --interleave=all python cpu_task.py

或者在代码中调用:

1
2
3
4
5
6
7
8
9
10
11
import ctypes

libc = ctypes.CDLL("libc.so.6", use_errno=True)
MPOL_INTERLEAVE = 3
nodemask = (ctypes.c_ulong * 1)(0x3) # NUMA nodes 0 and 1
libc.syscall(
ctypes.c_long(238),
ctypes.c_int(MPOL_INTERLEAVE),
ctypes.cast(nodemask, ctypes.c_void_p),
ctypes.c_ulong(3)
)

七、总结

混部这件事,看起来是"把剩余资源利用起来",实际上要对硬件架构有相当深入的理解才能做好。

几个关键结论:

CPU 隔离:用 Kubernetes Static CPU Manager + Guaranteed QoS 给 GPU 任务分配独占核心。distribute-cpus-across-numa 保证核心均匀分布在两个 NUMA 节点,避免单侧过载。

内存隔离:Kubernetes 的 Memory Manager 解决不了 Linux First-Touch 问题,必须在应用层介入。GPU 任务由训练框架自己做 NUMA 绑定,不需要额外干预;CPU 任务用 MPOL_INTERLEAVE 强制内存均匀分布,防止在 GPU 任务启动前独占某个 NUMA 节点。

缓存隔离:不要给 CPU 任务绑核——绑核解决了 L1/L2 问题,但引入了 L3 的持续占用。正确做法是让 CPU 任务保持 Burstable,利用 kernel 调度的分散性天然降低缓存竞争;同时 GPU 任务的 CPU 数配置为 16 的倍数,对齐 L3 边界,让 GPU 任务完整独占若干个 L3 组。

踩坑最深的地方是缓存隔离——直到发现 Guaranteed CPU 任务在不重叠的核心上仍然对训练有影响,才意识到问题出在 L3 共享上。"绑核"看起来是正确答案,其实是个中间态。最终让 CPU 任务变成 Burstable,反而是更好的选择。

评论