Linux 内核 RT Group Scheduling 与 CPU cgroup 带宽控制:构建确定性 AI 推理延迟保障的工程实战
引言:为什么纯 RT 优先级不够
在 AI 推理生产环境中,单纯将推理线程设为 SCHED_FIFO 高优先级并不能解决尾延迟问题。典型反例:一个绑核的推理算子线程进入无限循环(算子 bug),将会饿死整个 CPU 上的所有其他线程——包括关键的 Agent 监控、请求调度、心跳上报,最终触发看门狗导致进程被 kill。
更隐蔽的问题来自邻居干扰:多租户推理容器即使各自线程优先级不同,高优先级租户的突发计算仍可能在同一物理核上抢占低优先级租户的关键路径。解决方案是 RT Group Scheduling + cgroup v2 CPU bandwidth controller 的组合——RT 提供优先级排序,而 cgroup bandwidth 提供硬性的 CPU 时间隔离。
本文将深入 RT 组调度在 CFS 上的实现、rt_runtime 与 rt_period 的语义陷阱、SCHED_DEADLINE 的替代方案、以及与 Intel RDT / ARM MPAM 的协同实践,全部以 AI 推理服务为部署场景。
RT 调度的三种资源限制机制
Linux 内核为 RT 任务提供三层防御机制:
| 层级 | 机制 | 作用域 | 控制对象 |
|---|---|---|---|
| 1 | sched_rt_runtime_us / sched_rt_period_us(全局) |
系统全局 | 所有 RT 任务的 CPU 占比上限 |
| 2 | cgroup v2 cpu.max |
cgroup 层级 | 组内所有任务的周期配额 |
| 3 | RLIMIT_RTTIME(rlimit) |
单进程 | 进程级 RT 运行时间硬上限 |
第一层是历史遗留的全局开关,第二层是现代推荐方案(细粒度隔离),第三层是兜底的进程级防爆protection。
全局 RT Throttling:危险的钝工具
在较老的内核文档中,你会看到这样的建议:
# 将 RT 任务限制在 95% 的 CPU 时间
echo 950000 > /proc/sys/kernel/sched_rt_runtime_us
echo 1000000 > /proc/sys/kernel/sched_rt_period_us
这段配置的语义是:在每 rt_period_us=1,000,000μs(1 秒)的时间窗口内,所有 RT 任务的累计运行时间不得超过 rt_runtime_us=950,000μs(950ms)。
关键陷阱:这个限制是对所有 CPU 上的 RT 任务全局累计的,而非每 CPU。在 64 核机器上,你实际上只保留了 5% 的总 RT 时间(即 3.2 个核·秒 / 秒),而不是预期的 5% per-CPU。
解决办法:永远不要修改全局 sched_rt_runtime_us(保持默认值 -1 即无限制),改用 cgroup v2 的 cpu.max。
cgroup v2 cpu.max:正确的 CPU 带宽隔离
cgroup v2 的 cpu.max 文件是 per-cgroup 的每周期 CPU 配额控制器,格式为 $MAX $PERIOD:
# 创建一个 AI 推理专用 cgroup,限制最多使用 2 个核的 100% CPU
mkdir -p /sys/fs/cgroup/ai-inference
echo "200000 100000" > /sys/fs/cgroup/ai-inference/cpu.max
# ^^^^^^^^
# 每 100ms 周期内最多运行 200ms(即 2 个核)
语义解析:$MAX 是 cgroup 内所有任务在 $PERIOD 微秒窗口内累计可运行的最长时间。$PERIOD 取值范围为 [1ms, 1s]。
当 cgroup 中所有任务在周期内消耗完配额后,它们将被节流(throttled)直到下一个周期开始——无论任务优先级是 SCHED_FIFO、SCHED_RR 还是 SCHED_NORMAL,一律平等受限。
配置验证:实测带宽限制效果
下面是一个 C++ 微基准,验证 cpu.max 的实际效果:
// cpu_max_bench.cpp
// 编译: g++ -O2 -std=c++20 -pthread -o cpu_max_bench cpu_max_bench.cpp
#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
#include <vector>
alignas(64) static std::atomic<uint64_t> iterations{0};
void worker(int cpu) {
// 绑核避免跨核调度噪声
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(cpu, &cpuset);
pthread_setaffinity_np(pthread_self(), sizeof(cpuset), &cpuset);
// 关闭 CPU 频率缩放影响
volatile uint64_t local = 0;
auto start = std::chrono::steady_clock::now();
while (std::chrono::steady_clock::now() - start < std::chrono::seconds(5)) {
for (int i = 0; i < 1000; ++i) {
local += i * 2654435761u; // Knuth multiply, 防止优化掉
}
iterations.fetch_add(1000, std::memory_order_relaxed);
}
(void)local;
}
int main(int argc, char** argv) {
int num_threads = argc > 1 ? std::atoi(argv[1]) : 4;
std::vector<std::thread> threads;
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back(worker, i);
}
for (auto& t : threads) t.join();
std::cout << "Total iterations: " << iterations.load() << "\n";
return 0;
}
# 实验:4 个 CPU-bound 线程,限制在 2 核带宽内
echo "200000 100000" > /sys/fs/cgroup/ai-inference/cpu.max
echo $$ > /sys/fs/cgroup/ai-inference/cgroup.procs
./cpu_max_bench 4
# 对比无限制的对照组,吞吐量应减半
三层架构:AI 推理服务的 CPU 拓扑感知隔离
以下是一个真实部署场景:双路 AMD EPYC 9654(192 核 / 384 线程),运行多模型 AI 推理 + 向量检索 + Agent 编排三层服务。
第一层:模型推理(最严格隔离)
# GPU 推理绑定在 NUMA node 0 对应的 CPU 核上
mkdir -p /sys/fs/cgroup/pytorch-serving
# 分配 32 个 CPU 核的 100% 带宽给推理服务
echo "3200000 100000" > /sys/fs/cgroup/pytorch-serving/cpu.max
# 设置 CPU 亲和性到 NUMA node 0
echo "0-31" > /sys/fs/cgroup/pytorch-serving/cpuset.cpus
echo "0" > /sys/fs/cgroup/pytorch-serving/cpuset.mems
# 禁用内存跨节点迁移(避免 inflight 推理被迁移到远端 NUMA)
echo 0 > /sys/fs/cgroup/pytorch-serving/memory.migrate
第二层:向量检索(半弹性配额)
mkdir -p /sys/fs/cgroup/vector-search
# 16 核基础配额,但允许在空闲时借用
echo "1600000 100000" > /sys/fs/cgroup/vector-search/cpu.max
echo "32-47" > /sys/fs/cgroup/vector-search/cpuset.cpus
echo "0" > /sys/fs/cgroup/vector-search/cpuset.mems
# 开启 memory protection 防止向量索引被回收
echo "5G" > /sys/fs/cgroup/vector-search/memory.min
echo "20G" > /sys/fs/cgroup/vector-search/memory.high
第三层:Agent 编排与监控(Best-effort)
mkdir -p /sys/fs/cgroup/agent-orch
# Agent 线程最低保证 4 核,最多使用 12 核
echo "1200000 100000" > /sys/fs/cgroup/agent-orch/cpu.max
# 关键:Agent 心跳线程使用 RT 优先级,但被 bandwidth 兜底限制
echo "48-63" > /sys/fs/cgroup/agent-orch/cpuset.cpus
// agent_beat_thread.cpp: Agent 心跳线程的 RT + cgroup 双重配置
#include <pthread.h>
#include <sched.h>
#include <unistd.h>
void configure_agent_thread(bool is_heartbeat) {
if (is_heartbeat) {
// 心跳线程:RT 优先级保障低延迟响应
struct sched_param param = {.sched_priority = 50};
pthread_setschedparam(pthread_self(), SCHED_FIFO, ¶m);
} else {
// 普通 Agent 线程:CFS 默认
struct sched_param param = {.sched_priority = 0};
pthread_setschedparam(pthread_self(), SCHED_OTHER, ¶m);
}
// 开启 CPU 带宽统计监控(通过 cgroup 的 cpu.stat)
// 关注 nr_throttled / throttled_usec 字段判断是否被节流
}
部署拓扑图
NUMA Node 0 (物理核 0-95)
├─ cpuset.cpus: 0-31 → pytorch-serving (GPU推理, 32核硬限)
├─ cpuset.cpus: 32-47 → vector-search (向量检索, 16核配额)
└─ cpuset.cpus: 48-95 → agent-orch (Agent编排, 弹性)
NUMA Node 1 (物理核 96-191)
├─ cpuset.cpus: 96-127 → data-pipeline (数据预取)
└─ cpuset.cpus: 128-191 → system-reserved (OS + daemon)
内核通过 cpuset.cpus 将 cgroup 限制在指定物理核上,与同级 cgroup 天然形成 CPU 隔离。即使 vector-search 突发消耗全部 16 核带宽,也不会侵入 pytorch-serving 的核。
cpu.stat 深度解读:用指标驱动容量规划
cgroup v2 的 cpu.stat 文件提供详细的带宽统计:
$ cat /sys/fs/cgroup/pytorch-serving/cpu.stat
usage_usec 18472930000 # 累计 CPU 时间(微秒)
user_usec 16210450000 # 用户态累计时间
system_usec 2262480000 # 内核态累计时间
nr_periods 184731 # 已通过的周期数
nr_throttled 2941 # 被节流的周期数
throttled_usec 294100000 # 累计节流时间
容量规划公式:
理论最大带宽 = MAX / PERIOD * 分配核数
实际利用率 = (usage_usec / (nr_periods * PERIOD_us)) * 100%
节流率 = nr_throttled / nr_periods * 100%
健康状态下 节流率 < 5% 视为配额充足;持续高于 20% 意味着需要扩容或优化算子。
下面用 Python 快速解析 cpu.stat 生成告警:
#!/usr/bin/env python3
# cgroup_cpu_monitor.py
import os
import time
CGROUP_BASE = "/sys/fs/cgroup"
SERVICES = ["pytorch-serving", "vector-search", "agent-orch"]
def read_cpu_stat(cgroup_path: str) -> dict:
stat = {}
with open(os.path.join(cgroup_path, "cpu.stat")) as f:
for line in f:
key, value = line.strip().split()
stat[key] = int(value)
return stat
def check_throttle(cgroup: str, threshold_pct: float = 10.0) -> bool:
stat = read_cpu_stat(os.path.join(CGROUP_BASE, cgroup))
if stat["nr_periods"] == 0:
return False
throttle_pct = (stat["nr_throttled"] / stat["nr_periods"]) * 100
if throttle_pct > threshold_pct:
print(f"[WARN] {cgroup}: throttle rate {throttle_pct:.1f}% "
f"(threshold {threshold_pct}%), "
f"throttled for {stat['throttled_usec']/1e6:.2f}s")
return True
return False
if __name__ == "__main__":
for svc in SERVICES:
check_throttle(svc)
SCHED_DEADLINE:硬实时的终极武器
当 cgroup bandwidth 仍不足以满足毫秒级尾延迟要求(如推理请求的 P99 < 10ms),可引入 SCHED_DEADLINE——内核的 EDF(Earliest Deadline First)调度类。
它的工作方式与 RT 优先级不同。RT 优先级是"优先运行谁",DL 是"在截止时间前完成":
// deadline_config.c
// 为关键推理线程配置 SCHED_DEEDLINE 参数
// 含义:每 10ms 周期内,保证 3ms 的专用计算时间,截止时间=周期
#include <sched.h>
#include <stdint.h>
#include <stdio.h>
#include <unistd.h>
struct sched_attr {
uint32_t size;
uint32_t sched_policy;
uint64_t sched_flags;
int32_t sched_nice;
uint32_t sched_priority;
uint64_t sched_runtime; // 运行时间 (ns)
uint64_t sched_deadline; // 截止时间 (ns)
uint64_t sched_period; // 周期 (ns)
};
int set_deadline(pid_t pid, uint64_t runtime_ns,
uint64_t deadline_ns, uint64_t period_ns) {
struct sched_attr attr = {
.size = sizeof(attr),
.sched_policy = SCHED_DEADLINE, // =6
.sched_runtime = runtime_ns,
.sched_deadline = deadline_ns,
.sched_period = period_ns,
};
// syscall 方式调用 sched_setattr (glibc 未封装)
return syscall(__NR_sched_setattr, pid, &attr, 0);
}
// 使用示例:推理线程分配 3ms 计算时间 / 10ms 周期
int main() {
// 在独立 cgroup 内使用,避免干扰
set_deadline(0, 3'000'000, 10'000'000, 10'000'000);
while (1) {
run_inference_batch();
// sched_yield() 让内核检查是否超出 runtime
sched_yield();
}
}
内核 DL 调度器的准入控制(Admission Control)会自动校验:
Σ(runtime_i / period_i) ≤ 1.0
如果所有 DL 任务的总利用率超过 100%,sched_setattr 将返回 EBUSY,拒绝新任务注册。
与 Intel RDT / ARM MPAM 的三级防护
cgroup bandwidth 控制的是时间维度(CPU 时隙),但现代 CPU 还有空间维度——LLC 末级缓存和内存带宽的干扰。邻居 P 的流式读取可能冲刷掉邻居 Q 的 LLM KV Cache 热页。
三级防护架构:
| 级别 | 机制 | 控制目标 |
|---|---|---|
| L1(时间) | cgroup cpu.max | CPU 时间配额 |
| L2(缓存) | Intel RDT CLOS / ARM MPAM PARTID | LLC 缓存分区 |
| L3(内存) | Intel RDT MB / ARM MPAM PMG | 内存带宽分区 |
// L2 配置:为推理容器分配 50% LLC 缓存
// 通过 resctrl fs (Intel CAT)
mkdir -p /sys/fs/resctrl/pytorch-serving
echo "L3:0=0x00ff" > /sys/fs/resctrl/pytorch-serving/schemata
// ^^^^^^^
// 核 0-7 使用 LLC ways 0-7(25/50 的缓存分区)
echo $(pidof pytorch-serving) > /sys/fs/resctrl/pytorch-serving/tasks
生产环境常见陷阱与解决方案
陷阱 1:cpuset 热迁移导致推理抖动
症状:容器启动后,P99 延迟在最初 5 分钟显著偏高。
原因:cpuset 绑核后,内核的 load balancer 会在 cpuset 边界触发跨 NUMA 的进程迁移,导致 cache 冷启动。
解决:
echo 0 > /proc/sys/kernel/sched_schedstats # 可选:关闭统计减少开销
# 关键:启动阶段 sleep 等待负载稳定,或设置 cpuset.sched_relax_domain_level
# -1 = 完全禁用负载均衡(适合独占核场景)
echo -1 > /sys/fs/cgroup/pytorch-serving/cpuset.sched_relax_domain_level
陷阱 2:io_uring 内核线程不受 cgroup bandwidth 限制
症状:GPU 推理通过 io_uring 与 DPDK 交互时,P99 延迟仍然波动。
原因:io_uring 的 IORING_SETUP_SQPOLL 内核线程在 io_sq_thread() 中运行,被纳入系统级 io cgroup,但可能绕过 cpu.max 的统计。
解决:
# 显式将 io_uring SQ 线程也纳入 cgroup
echo "200000 100000" > /sys/fs/cgroup/io-submit/cpu.max
# 通过 ioprio 限制 IO 带宽
echo "0" > /sys/fs/cgroup/io-submit/io.weight # 最低 IO 权重
陷阱 3:BPF 尾调用导致 cpu.stat 漏计
症状:cpu.stat 显示节流率低,但监控面板报告延迟升高。
原因:eBPF 程序在内核态执行时消耗的 CPU 时间归属到触发 eBPF 的进程,而不归属 eBPF 程序自身。当推理服务密集触发 XDP / kprobe 等 eBPF 程序时,cpu.stat 可能"超额计费"。
解决:在 cgroup 带宽配额中预留 10-15% 的 eBPF 内核开销余量。
小结:AI 推理的 CPU 隔离配置清单
# 生产级 AI 推理 CPU 隔离模板 (systemd 单元片段)
[Service]
CPUAccounting=yes
CPUQuota=3200% # systemd 语法 = 32 核
# 等价的 cgroup v2 原生设置:
# echo "3200000 100000" > /sys/fs/cgroup/$UNIT/CPU.max
# 关键:将 busy-polling 与异步 IO 分离到不同 cgroup
ExecStartPre=/bin/sh -c 'echo 4-7 > /sys/fs/cgroup/$UNIT/CPUset.cpus'
# 开启 PSI (Pressure Stall Information) 压力监控
# 配合 eBPF 采集 task_delay, schedstat 指标
RT Group Scheduling 与 cgroup CPU bandwidth 绝不是"设完即忘"的配置项。在 AI 推理场景中,它们是连接算子优化(减少关键路径 CPU 占用)与 SLA 保障(可预测的尾延迟)之间的桥梁。正确配置后,我们可以自信地说:这个推理服务的 P99 延迟是确定的——不是因为运气好没遇到邻居干扰,而是因为内核保证它不会被打扰。

发表评论 取消回复