Linux EEVDF 换掉 CFS 之后:为什么我的编译变慢了,服务延迟反而变好了

🔑 关键词:EEVDF, CFS, Linux内核调度器, sched_base_slice, cgroup cpu.weight

📖 摘要:Linux 6.6 用 EEVDF 替换了用了十六年的 CFS,但大部分性能调优文章还在教你 renice。这篇从一台 32 核 EPYC 编译机掉 7% 吞吐的真实经历讲起,拆开 EEVDF 的 lag 和虚拟截止时间,给出 PSI、perf sched、cpu.stat 的观测步骤,以及 base_slice、cpu.weight、cpu.max 这几个真正会咬人的参数。

2024 年 3 月,我把一台跑 CI 的机器从 Ubuntu 22.04 升到 24.04。硬件没动,还是那颗 32 核的 EPYC 7542 配 128G 内存,负载也没动,还是那套并行度拉满的 C++ 编译加一堆 pytest。

图片

然后事情就变得很奇怪了。

升级前 make -j64 平均跑 6 分 40 秒左右,升级后变成 7 分 10 秒出头,慢了接近 7%。这个数字我反复测了三遍,不是噪声。但与此同时,跟我们共用这个节点的一个 Go 网关,P99 延迟从 12ms 掉到了 8ms 上下。吞吐掉了,延迟好了,一个方向变坏一个方向变好。

我第一反应是内核有 bug,在 LKML 的邮件列表里翻了两天,才意识到这玩意儿不是 bug,它就是 EEVDF 的设计目标本身。

CFS 在优化什么,EEVDF 又换掉了什么

图片

CFS(Completely Fair Scheduler)是 Ingo Molnar 在 2007 年的 2.6.23 里引入的,统治了 Linux 十六年。核心概念是 vruntime:每个任务维护一个虚拟运行时间,跑得越久 vruntime 越大,调度器永远挑 vruntime 最小的那个。为了不让切换太碎,又加了两个固定旋钮——sched_latency_ns 默认 6ms 是一个调度周期,sched_min_granularity_ns 默认 0.75ms 是单次最少跑多久,任务数超过 latency 除以 min_granularity 之后就开始分摊。

EEVDF 不是 Molnar 写的,它的理论基础要追到 Ion Stoica 和 Hussein Abdel-Wahab 在 1995 年 ACM SIGMETRICS 上发的一篇论文。核心是两件事:lag(这个任务应得但还没拿到的 CPU 时间)和 virtual deadline(虚拟截止时间)。一个任务只有在自己 eligible,也就是 lag >= 0、不欠账的时候,才有资格被调度;有资格的任务里,谁虚拟截止时间近谁先跑。

这个差别听起来很学究,但落到机器上非常实在。CFS 的调度周期是把所有任务摊在一起的,机器上任务越多,每个任务两次被唤醒之间的间隔越长,而且这个间隔不太好预测。EEVDF 给每个任务发的是一个 slice(默认 3ms 再按权重比例缩放),但你什么时候能轮到,不取决于机器上总共挂了几个任务,取决于你的 lag 和 deadline 在队列里的位置。

翻译成人话:CFS 保证的是长期公平,EEVDF 保证的是延迟可预测。代价就是当一台机器上同时塞了 64 个 CPU 密集任务的时候,EEVDF 更倾向于让每个任务按自己的 slice 老老实实跑完再让位,而不是像 CFS 那样在 vruntime 上做全局精算。这就是我那台编译机掉 7%、但网关 P99 变好的全部原因。

图片

真正会咬人的参数,第一个是它

先说一个特别普遍的误解:在容器时代,nice 值基本是个摆设。

renice -20 一个进程,如果它跑在 cgroup v2 环境里(Ubuntu 24.04、Fedora 40 以后默认都是),内核会把它换算成 cgroup 的权重。换算公式是 weight = 1024 / (1.25 ^ nice),nice 每降一级权重乘 1.25,nice 0 对应 1024,nice 1 是 819,nice -1 是 1280。但这个权重只在同一层级的兄弟 cgroup 之间抢 CPU 时才有意义,跨层级你是改不动的。

真正该看的是 cpu.weightcpu.maxcpu.weight 范围 1 到 10000,默认 100,是相对权重。cpu.max 是硬上限,格式是 "$MAX $PERIOD",单位微秒,比如 "50000 100000" 就是每 100ms 周期里最多用 50ms CPU,等于 0.5 个核,超了直接 throttle,跟权重没关系。

图片

坑在这儿:EEVDF 下被 cpu.max 限流的任务,恢复运行的时候惩罚更明显。限流期间任务的 lag 会被处理掉,重新 eligible 之后得重新排队,而它拿到的 slice 还是按原本权重算的。Kubernetes 用户应该对 CPU throttling 这个词不陌生,但在 6.6+ 内核上,如果你把 limit 设得比 request 高出一大截,throttle 带来的抖动会比 5.15 上更容易被观测到。

还有一个新旋钮叫 sched_base_slice。6.12 之前这个值在内核里是写死的,普通用户碰都碰不到;6.12(2024 年 11 月 17 日发布)之后把它暴露出来了,早期路径在 /sys/kernel/debug/sched/ 底下叫 base_slice_ns,后来社区又在讨论往 /proc/sys/kernel/ 搬,所以你 grep 一下自己这台机器最靠谱。默认是 3ms(早期内核里是 0.75ms,6.12 上调并开放了)。调小到 1ms,切换更碎、交互延迟更低、吞吐更差;调到 6ms,吞吐能回来一些,但尾巴延迟会涨。

怎么观测,怎么下手,给几个能直接抄的步骤

图片

第一件事是搞清楚你手上到底是哪套调度器。uname -r 看内核版本,6.6 以上基本就是 EEVDF,但注意发行版回迁——Ubuntu 22.04 的 HWE 内核是 6.8,那台机器其实也已经是 EEVDF 了,光看 22.04 这个版本号会骗你。更直接的办法是看 /sys/kernel/debug/sched/ 下有没有 base_slice_ns 这个文件。

接着看 PSI,也就是压力失速信息:cat /proc/pressure/cpu,盯着 some avg10、avg60、avg300 这三列。这个比 load average 有用得多,因为 PSI 直接告诉你有多少任务是因为抢不到 CPU 被卡住的,而不是单纯告诉你队列有多长。

然后用 perf sched latency 看每个任务的调度延迟分布,输出里 Maximum 那一列经常能把人吓一跳——有些任务的等待时间能到几百毫秒,而你可能一直以为它跑得好好的。容器里则去看 /sys/fs/cgroup/<你的cgroup>/cpu.stat,重点看 nr_throttledthrottled_usec 这两个计数。

真要动手调 base_slice,先用 echo 2000000 > /sys/kernel/debug/sched/base_slice_ns 临时试(路径按你机器上的实际位置来),观察完再决定要不要持久化。持久化别写 rc.local,写 /etc/sysctl.d/99-sched.conf

图片

我的看法,可能不太受欢迎

调度器从来不是越公平越好,也不是越低延迟越好,它是个拿来换的东西。EEVDF 在发行版里的落地速度比我预想中快,Ubuntu 24.04、Fedora 40、Arch 早就上了,但 Debian 12 停在 6.1、RHEL 9 停在 5.14,两家都还在 CFS 上。这不是他们懒,是 EEVDF 的调优经验积累得还不够,企业客户不愿意拿生产负载当小白鼠。

而且我觉得最值得警惕的是,现在大部分人调 Linux 性能,第一反应是改 nice、改 cpufreq governor、关超线程,但真正影响你的是 cgroup 的 weight 和 max 这两个参数,以及 base_slice 这个新旋钮。内核把调度接口从 nice 挪到 cgroup 已经挪了十几年,可绝大多数「Linux 性能优化」的文章还在教你怎么 renice。

最后一句:别在生产上瞎调 base_slice。3ms 这个默认值是 Peter Zijlstra 和一堆人拿各种负载跑出来的,你手上那套业务大概率没他们测试的样本多。真想动,先拿 perf sched 和 PSI 把问题量化出来,再动。

🏷️ 标签: