服务器 64 核全开却跑不满?你可能忽略了 CPU 亲和性
在 Linux 上跑过大规模数据预处理脚本的人,可能都见过这种情况:服务器明明有 64 个核心,脚本也开了多进程,但 htop 里进程的线程像跳蚤一样在核心之间跳来跳去,CPU 利用率忽高忽低,整体吞吐量比预期差了一截。有时候不是代码写得慢,而是操作系统把进程调度到了"不该去"的核心上。
CPU 亲和性(CPU Affinity)就是用来解决这个问题的。它的核心思想很简单:告诉操作系统"这个进程只许在这几个核心上跑",不让调度器随便迁移。听起来是一个系统级的调优手段,但在 AI 离线训练和数据预处理的场景里,它的影响远比"调个参数"要大——它直接决定了一个 Python 进程能不能充分利用 L1/L2 缓存,也决定了离线任务会不会半夜把线上服务的核心抢光。
这篇笔记从操作系统调度器、CPU 缓存体系、NUMA 架构一路讲到 Python 层的实现和工程实践。
一、操作系统调度器为什么要迁移进程
Linux 默认使用的调度器是 CFS(Completely Fair Scheduler,完全公平调度器)。它的设计目标不是让每个进程跑得最快,而是让所有进程"公平地"分享 CPU 时间。CFS 维护一个红黑树(red-black tree),每个可运行的任务按虚拟运行时间(vruntime)排序,每次调度时选择 vruntime 最小的那个来执行。
当系统中同时有几十个进程在竞争 CPU 时,CFS 会动态地把进程从一个核心迁移到另一个核心,试图达到"负载均衡"(load balancing)。这个过程由每个 CPU 核心上的调度域(scheduling domain)和负载均衡器(load balancer)协同完成。
迁移的时机通常有几种:
- 时间片耗尽与抢占:一个进程用完了分配给它的 CPU 时间片(在 CFS 中更准确地说,是 vruntime 超过了阈值或执行被中断),调度器会在当前核心上触发抢占(Preemption),把它放回当前核心的可运行队列尾部。这个操作本身不会导致跨核迁移
- 新进程创建:fork 出的子进程会被放到当前负载最低的核心上,不一定是父进程所在的核心
- 负载均衡触发:当一个核心的可运行队列长度明显高于其他核心时,负载均衡器(Load Balancer)会主动把任务 push 出去;或者在核心空闲时通过 pull 拉取任务。此外,进程休眠后重新唤醒时(Wake-up Balancing),内核会重新评估并选择最合适的核心来放置该进程
- CPU 热插拔或电源管理:系统为了节能(比如 Intel 的 SpeedStep 或 AMD 的 Cool’n’Quiet)会动态调整核心频率,甚至关闭空闲核心,这会触发调度器重新分配任务
对于交互式应用和通用服务器负载,这种迁移是合理的。但对于缓存敏感型计算(比如大规模矩阵运算、数据预处理),频繁迁移会带来严重的性能惩罚。
二、跨核心迁移的开销:不只是上下文切换
很多人以为进程在不同核心之间切换的开销就是"上下文切换"(context switch)本身。实际上,上下文切换只是开销的一部分,甚至不是最昂贵的部分。
2.1 上下文切换的直接开销
上下文切换时,操作系统需要保存当前进程的运行状态:
- 通用寄存器(RAX, RBX, RCX, RSP, RBP 等,x86-64 上约 16 个 64 位寄存器)
- 程序计数器(RIP)
- 状态寄存器(RFLAGS)
- 浮点/SIMD 寄存器(XMM/YMM/ZMM,AVX-512 下是 32 个 512 位寄存器)
- 内存管理相关寄存器(CR3 页表基址)
这些状态被打包成一个 task_struct(Linux 内核中的进程描述符),存到内核栈里,然后加载下一个进程的状态。在 x86-64 上,一次上下文切换大约需要 1-3 微秒,具体取决于硬件和内核版本。
2.2 缓存失效:真正的杀手
上下文切换的开销虽然可测量,但在微秒级别,通常不会成为瓶颈。真正严重的问题是缓存失效(cache invalidation)。
现代 CPU 有三层缓存:
| 层级 | 大小(典型值) | 访问延迟 | 共享范围 |
|---|---|---|---|
| L1 | 32-64 KB(指令)+ 32-64 KB(数据) | 4 个时钟周期 | 每个核心独享 |
| L2 | 256-512 KB | 10-15 个时钟周期 | 每个核心独享 |
| L3 | 8-64 MB | 40-50 个时钟周期 | 整个 CPU 插槽共享 |
当一个进程在核心 A 上运行时,它频繁访问的数据会被加载到核心 A 的 L1 和 L2 缓存中。如果调度器把它迁移到核心 B,这些数据在核心 B 的缓存中不存在(cold cache),必须从核心 A 的缓存或主内存中重新加载。
更严重的是,如果核心 A 和核心 B 位于不同的 CPU 插槽(socket)上,数据甚至要跨插槽传输——走 CPU 之间的 UPI(Intel Ultra Path Interconnect)或 Infinity Fabric(AMD)总线,延迟比同插槽内的缓存访问高一个数量级。
一个直观的例子:假设一个预处理脚本在遍历一个 10 GB 的数据集,数据被加载到 L3 缓存中。如果进程每 10 毫秒就被迁移一次,每次迁移后都需要重新 warm up 缓存,有效计算时间被反复打断。实测中,一个密集计算型进程被频繁迁移后,有效吞吐量可能下降 20%-40%。
2.3 TLB 刷新
除了缓存,还有一个常被忽略的组件:TLB(Translation Lookaside Buffer)。TLB 是 CPU 内部的页表缓存,用来加速虚拟地址到物理地址的转换。当一个进程被迁移到另一个核心时,如果两个核心属于不同的调度域或NUMA 节点,目标核心的 TLB 中可能没有该进程的页表项,需要重新走页表遍历(page table walk)。页表遍历通常要访问 4-5 级页表,每一级都可能触发一次内存访问,延迟在几十到上百个时钟周期。
2.4 CPU 缓存行的伪共享(False Sharing)
在多线程场景中,如果两个线程频繁修改同一条 64 字节缓存行(cache line)中不同的独立变量,就会触发伪共享(false sharing)。这个问题和 CPU 亲和性密切相关,但处理方式很容易踩坑。
假设线程 A 在核心 0 上修改变量 x,线程 B 在核心 1 上修改变量 y,而 x 和 y 恰好落在同一个缓存行内。核心 0 修改后,根据 MESI 缓存一致性协议,它必须向核心 1 发送 Invalid 信号,让核心 1 上对应缓存行失效。核心 1 下次读取 y 时发生 cache miss,必须去 L3 或内存重新拉取。反过来也一样。缓存行在两个核心之间来回横跳(cache line bouncing),这就是伪共享最致命的性能惩罚。
这里有一个反直觉的事实:把两个线程放到不共享 L1/L2 的不同物理核心上,恰恰是伪共享惩罚最严重的场景——因为每次修改都要走跨核心的总线通信(Ring Bus 或 Mesh)。如果把它们放到同一个物理核心的两个超线程上,由于共享同一个 L1 数据缓存,修改操作都在 L1 内部完成,根本不会触发跨核心的 cache invalidation,惩罚反而小几个数量级。
所以 CPU 亲和性不能用来逃避伪共享。正确的做法是在代码层面对数据结构做缓存行对齐(cache line padding),确保不同线程修改的变量落在不同的缓存行上:
import ctypes
class PaddedCounter(ctypes.Structure):
_fields_ = [
("value", ctypes.c_int64),
("_padding", ctypes.c_int64 * 7), # 8 * 8 = 64 字节,填充满一个缓存行
]
或者用 __attribute__((aligned(64)))(C/C++)或 np.zeros(16, dtype=np.int64) 这样的方式在内存中留出间隔。绑核解决不了伪共享,代码层面的内存布局才是根本。
三、CPU 缓存亲和性 vs 内存亲和性:NUMA 的影响
现代服务器 CPU 普遍采用 NUMA(Non-Uniform Memory Access,非统一内存访问)架构。在这种架构下,每个 CPU 插槽(socket)有直连的本地内存(local memory),访问本地内存比访问其他插槽的远程内存(remote memory)快得多。
Socket 0 Socket 1
┌─────────────┐ ┌─────────────┐
│ CPU Core │──→ Local │ CPU Core │──→ Local
│ L1/L2/L3 │ Memory 0 │ L1/L2/L3 │ Memory 1
└──────┬──────┘ └──────┬──────┘
│ │
└──────── UPI / IF ──────────┘
跨插槽互联
Linux 内核通过 numactl 提供了 NUMA 感知调度。如果进程在 Socket 0 上运行,但分配的内存在 Socket 1 上,每次内存访问都要走跨插槽总线,延迟可能是本地访问的 2-3 倍。
CPU 亲和性和 NUMA 亲和性通常是配合使用的。在 AI 训练场景中,一个常见的做法是把训练进程绑定到某个 socket 的全部核心上,同时通过 numactl --membind 确保内存分配在该 socket 的本地内存上。这样进程既能享受本地缓存的好处,也能享受本地内存的低延迟。
四、CPU 亲和性的内核实现:sched_setaffinity
CPU 亲和性在内核层面通过 CPU mask(位掩码)来实现。每个进程(或线程)有一个 cpus_allowed 掩码,表示它可以被调度到哪些核心上。Linux 提供了 sched_setaffinity() 系统调用来修改这个掩码。
// Linux 内核头文件(简化)
#define __NR_sched_setaffinity 203
long sched_setaffinity(pid_t pid, unsigned int len, unsigned long *user_mask_ptr);
user_mask_ptr 指向一个位掩码数组,每一位对应一个 CPU 核心。如果第 n 位为 1,表示该进程允许在第 n 个核心上运行。
例如,要把 PID 为 1234 的进程绑定到核心 0 和 1:
unsigned long mask = 0b00000011; // bit 0 和 bit 1 置 1
sched_setaffinity(1234, sizeof(mask), &mask);
内核收到这个系统调用后,会修改目标进程的 task_struct 中的 cpus_allowed 字段。后续的调度决策中,CFS 调度器只会把这个进程放到掩码允许的核心上。
需要注意的是,sched_setaffinity 设置的是允许集合(allowed set),不是"强制集合"。进程仍然可以在允许集合内的核心之间迁移,只是不会跑出这个集合。如果要进一步减少集合内的迁移,还需要配合调度策略(比如 SCHED_FIFO 或 SCHED_RR),或者通过 cgroup 的 cpuset 子系统来做更细粒度的控制。
五、Python 层实现 CPU 亲和性
Python 标准库中的 os 模块在 Linux 上直接暴露了 sched_setaffinity:
import os
# 获取当前进程允许的核心集合
os.sched_getaffinity(0) # {0, 1, 2, 3, ...}
# 绑定到核心 0 和 1
os.sched_setaffinity(0, {0, 1})
os.sched_setaffinity(pid, mask) 中 pid=0 表示当前进程,mask 是一个集合(set),元素是核心编号。底层通过 ctypes 封装了 sched_setaffinity 系统调用。
更常见的做法是用 psutil 库,它提供了跨平台的封装:
import psutil
p = psutil.Process()
# 获取当前进程允许的核心
print(p.cpu_affinity()) # [0, 1, 2, 3, ...]
# 绑定到核心 4-7
p.cpu_affinity([4, 5, 6, 7])
# 只绑定到核心 0
p.cpu_affinity([0])
psutil 在 Linux 上最终也是调的 sched_setaffinity,但它做了跨平台抽象——在 Windows 上它会调用 SetProcessAffinityMask,在 macOS 上调用 thread_policy_set。
5.1 多进程场景中的绑定策略
在 Python 的 multiprocessing 中,常见的做法是在每个 worker 进程的初始化函数中绑定核心:
import multiprocessing as mp
import psutil
import os
def worker_init(core_id):
"""每个 worker 绑定到一个独占的核心"""
p = psutil.Process()
p.cpu_affinity([core_id])
print(f"Worker PID {os.getpid()} bound to CPU {core_id}")
def preprocess_chunk(data_chunk):
# 数据预处理逻辑
return processed_data
if __name__ == "__main__":
data = load_large_dataset()
chunks = split_into_chunks(data, n_chunks=8)
with mp.Pool(
processes=8,
initializer=worker_init,
initargs=... # 为每个 worker 传递不同的 core_id
) as pool:
results = pool.map(preprocess_chunk, chunks)
这里的关键是每个 worker 独占一个核心(one-to-one mapping)。这样做的收益是:
- 缓存局部性:每个 worker 的数据集分片大概率能被 L1/L2 缓存容纳,不会被其他 worker 的迁移打断
- 避免超线程竞争:如果绑定到物理核心而不是超线程逻辑核心,可以避免同一个物理核心上的两个超线程争抢执行单元
一个细节:绑定前需要确认哪些是物理核心、哪些是超线程逻辑核心。可以通过 lscpu 查看:
$ lscpu -e
CPU NODE SOCKET CORE L1d:L1i:L2:L3 ONLINE MAXMHZ MINMHZ MHZ
0 0 0 0 0:0:0:0 yes 4600.0000 800.0000 3400.000
1 0 0 0 0:0:0:0 yes 4600.0000 800.0000 3400.000
2 0 0 1 1:1:1:0 yes 4600.0000 800.0000 3400.000
CORE 列相同的两个 CPU 就是同一个物理核心上的两个超线程。如果要独占物理核心,应该每对超线程中只绑定一个。
5.2 与 numactl 配合使用
在命令行层面,最简洁的方式是用 taskset 或 numactl:
# 使用 CPU 列表绑定到核心 0-3
taskset -c 0-3 python train.py
# 绑定到 Socket 0 的核心,且内存分配在 Socket 0
numactl --cpunodebind=0 --membind=0 python train.py
# 绑定到具体核心,同时指定本地内存
numactl --physcpubind=0,1,2,3 --membind=0 python train.py
numactl --membind=0 确保进程的内存分配都在 NUMA Node 0 上,和 CPU 绑定到同一个 socket,避免跨 NUMA 访问内存。
六、AI 预处理场景中的实测收益
在 AI 文档预处理和数据集清洗这类 CPU 密集型任务中,CPU 亲和性的收益主要来源于两个方面:
6.1 缓存命中率提升
数据预处理通常涉及大量的字符串操作、正则匹配、分词、编码转换。这些操作的特征是对同一块数据反复访问(比如对一个文档做清洗、分词、截断、编码)。如果进程被固定在同一个核心上,这些中间数据会被 L1/L2 缓存命中,避免了重复从 L3 或内存加载。
一个具体的例子:用 transformers 的 tokenizer 对 100 万条文本做分词。每条文本的分词过程会反复访问 tokenizer 的词汇表(vocabulary)和归并规则(merge rules)。词汇表通常有几十 MB,L3 缓存可以容纳,但 L1/L2 只能容纳当前处理的文本和局部状态。如果进程被迁移,L1/L2 中的局部状态失效,下一条文本的处理需要重新 warm up。
6.2 消除调度抖动
在多进程并行预处理时,如果不绑定核心,多个进程的线程可能在核心之间来回跳,导致:
- 核心 A 上的缓存刚被进程 1 warm up,进程 2 被调度过来,进程 1 的缓存被驱逐
- 负载均衡器频繁触发,增加了额外的调度开销
- 某些核心过载(多个进程同时跑在上面),某些核心空闲
绑定核心后,每个进程有固定的"领地",调度器不再介入迁移,CPU 时间片被稳定地分配给计算本身。
6.3 实测数据
在一台双路 Intel Xeon Gold 6248(40 核心/80 线程,256 GB RAM)上,对 500 万条文档做清洗和 tokenizer 预处理:
| 配置 | 总耗时 | 相对耗时 |
|---|---|---|
| 不绑定核心,默认 CFS 调度 | 18.5 分钟 | 100% |
| 绑定到物理核心(40 进程,每个独占一个物理核心) | 14.8 分钟 | 80% |
| 绑定 + NUMA 本地内存 | 13.2 分钟 | 71% |
绑定物理核心提升了约 20% 的吞吐量,加上 NUMA 本地内存后总共提升了约 29%。这个提升幅度和具体 workload 有关——数据访问模式越局部、缓存命中率越敏感,收益越大。
七、资源隔离:离线任务不要抢线上的核心
CPU 亲和性的另一个重要用途是资源隔离(resource isolation)。在典型的 AI 平台架构中,训练服务器白天跑线上推理服务(低延迟、高并发),晚上跑离线训练或数据预处理(高吞吐、长耗时)。如果不做隔离,离线任务的进程可能抢占线上服务所在的核心,导致线上请求的 P99 延迟飙升。
7.1 用 cgroup 的 cpuset 做硬隔离
Linux 的 cgroup v1/v2 提供了 cpuset 子系统,可以创建一个控制组,只允许组内进程使用指定的核心集合。和 sched_setaffinity 不同,cpuset 是硬隔离——系统不会把指定核心分配给其他组的进程。
# 创建一个 cpuset,只使用核心 32-63
sudo mkdir -p /sys/fs/cgroup/cpuset/offline_training
sudo bash -c 'echo 32-63 > /sys/fs/cgroup/cpuset/offline_training/cpuset.cpus'
sudo bash -c 'echo 0 > /sys/fs/cgroup/cpuset/offline_training/cpuset.mems'
sudo bash -c 'echo 1 > /sys/fs/cgroup/cpuset/offline_training/cpuset.cpu_exclusive'
# 将当前 shell 加入该 cgroup
sudo bash -c 'echo $$ > /sys/fs/cgroup/cpuset/offline_training/tasks'
# 在这个 shell 中启动的进程都受 cgroup 约束
python train.py
cpuset.cpu_exclusive=1 表示这组核心不允许被其他 cpuset 共享,实现了真正的硬隔离。
需要注意的是,上面的命令基于 cgroup v1。在较新的 Linux 发行版(如 Ubuntu 22.04+、RHEL 9、Debian 11+)中,系统默认启用 cgroup v2,其文件层级和命名与 v1 不同(例如不再有独立的 cpuset 子目录,而是统一的层级结构,配置文件变为 cpuset.cpus,隔离策略通过 cpuset.cpus.partition 实现)。实际使用时需根据系统的 cgroup 版本调整路径和参数。
7.2 容器化环境中的隔离
在 Kubernetes 或 Docker 环境中,可以通过资源限制来实现类似效果:
# Kubernetes Pod spec
spec:
containers:
- name: training
resources:
limits:
cpu: "16"
# 通过 node affinity + pod anti-affinity 避免与在线服务共节点
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: online-service
topologyKey: kubernetes.io/hostname
在 Docker 中可以用 --cpuset-cpus:
docker run --cpuset-cpus="32-47" --memory=128g training-image:latest
7.3 优先级调整:nice 和实时调度
除了核心隔离,还可以通过调整进程优先级来减少离线任务对线上服务的干扰:
# 降低离线任务的 CPU 优先级(值越大优先级越低)
nice -n 19 python preprocess.py
# 或者把线上服务设为实时调度(需要 root)
sudo chrt -f 99 python online_service.py
nice 值影响 CFS 的 vruntime 计算,值越大的进程获得的 CPU 时间片越少。chrt -f(SCHED_FIFO)把进程设为实时调度,优先级高于普通 CFS 任务,适合对延迟极度敏感的在线服务。
八、什么时候不需要绑定核心
CPU 亲和性不是万能药。以下场景中,绑定核心可能适得其反:
IO 密集型任务。如果进程大部分时间都在等磁盘或网络(比如从 S3 下载数据、写日志),CPU 缓存命中率本身就很低,绑定核心的收益可以忽略。这时候更应该关注 IO 并行度和异步处理。
单进程轻量级任务。如果只有一个进程在跑,且计算量不大,调度器自然会把它放在一个核心上,很少迁移。手动绑定反而增加了复杂度。
需要利用超线程的场景。某些 workload(比如分支预测友好、指令级并行度高的代码)可以从超线程中获益。如果绑定策略把两个超线程都占满了,反而降低了整体吞吐。是否禁用超线程需要具体测试。
动态负载变化大的场景。如果同一台机器上跑多个不同优先级的任务,且负载随时间大幅波动,固定绑定可能导致某些核心过载、其他核心空闲。这时候让调度器动态调整可能更合理。
九、总结
CPU 亲和性在 AI 工程中的价值体现在两个层面:
性能层面,通过减少跨核心迁移和缓存失效,提升 CPU 密集型预处理的吞吐量。20% 的提升看似不大,但在大规模数据集上意味着节省数小时的等待时间。
稳定性层面,通过 cgroup 或容器参数把离线任务限制在特定核心集合上,避免抢占线上服务的 CPU 资源,保障生产环境的延迟 SLA。
从实现路径来看,最轻量的方式是 psutil.Process().cpu_affinity(),适合在 Python 脚本内部做进程级绑定。需要跨 NUMA 内存优化时,用 numactl。需要做硬隔离、防止其他进程抢占时,用 cgroup 的 cpuset。容器化环境中,用 Docker/K8s 的原生资源限制参数即可。
理解 CPU 亲和性的底层机制——CFS 调度器、缓存层次结构、NUMA 架构——能帮助你判断什么时候该绑、怎么绑、绑到什么程度。不是所有场景都需要绑,但在需要的地方,它是最便宜的性能优化手段之一。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)