在 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)协同完成。

迁移的时机通常有几种:

  1. 时间片耗尽与抢占:一个进程用完了分配给它的 CPU 时间片(在 CFS 中更准确地说,是 vruntime 超过了阈值或执行被中断),调度器会在当前核心上触发抢占(Preemption),把它放回当前核心的可运行队列尾部。这个操作本身不会导致跨核迁移
  2. 新进程创建:fork 出的子进程会被放到当前负载最低的核心上,不一定是父进程所在的核心
  3. 负载均衡触发:当一个核心的可运行队列长度明显高于其他核心时,负载均衡器(Load Balancer)会主动把任务 push 出去;或者在核心空闲时通过 pull 拉取任务。此外,进程休眠后重新唤醒时(Wake-up Balancing),内核会重新评估并选择最合适的核心来放置该进程
  4. 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,而 xy 恰好落在同一个缓存行内。核心 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_FIFOSCHED_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)。这样做的收益是:

  1. 缓存局部性:每个 worker 的数据集分片大概率能被 L1/L2 缓存容纳,不会被其他 worker 的迁移打断
  2. 避免超线程竞争:如果绑定到物理核心而不是超线程逻辑核心,可以避免同一个物理核心上的两个超线程争抢执行单元

一个细节:绑定前需要确认哪些是物理核心、哪些是超线程逻辑核心。可以通过 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 配合使用

在命令行层面,最简洁的方式是用 tasksetnumactl

# 使用 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 架构——能帮助你判断什么时候该绑、怎么绑、绑到什么程度。不是所有场景都需要绑,但在需要的地方,它是最便宜的性能优化手段之一。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐