arm64: preempt: Simplify and optimize __preempt_count_dec_and_test()

arm64 架构 · preempt_count 递减热路径 · 消除特判分支 / 慢路径外提

💡 一句话总结

在 arm64 上,preempt_enable() / preempt_enable_notrace() 每次执行临界区出口时都要调用 __preempt_count_dec_and_test()——一个递减"禁止抢占计数器"并判断"是否该调度"的热函数。旧实现为"计数器恰好减到 0 且已有人请求调度"这种罕见情况特设了一条捷径,结果常见路径反而被编译器塞进 2 个条件分支,慢路径(preempt_schedule_notrace())也无法干净外提。补丁删掉这个特判,改成"先递减 count、再统一重读 64 位合一值判断是否为 0"的写法,使常见路径收敛为单个 not-taken 分支 + 慢路径整体外提,语义完全等价。作者明确表示尚未做基准测试(RFC 原话 "I haven't had the chance to benchmark this"),唯一证据是 GCC 15.2.0 编译前后代码生成对比:常见路径条件分支数从 2 降到 1。

📋 补丁基本信息

项目内容
补丁类型优化(性能 · 热路径减分支/减指令,行为等价重构)
性能类别热路径(preempt_count 递减热路径的代码生成优化,不改变语义)
状态In Review(RFC v1;截至 Linux 7.2-rc6 未合入 mainline,本地 git 仍为旧实现)
当前版本v1(RFC)· 当前版链接
版本演进首版 RFC(2026-07-28),暂无演进
系列共 13 补丁:[RFC PATCH 00/13] arm64: Preemptible this_cpu_*() operations
作者机构Mark Rutland(Arm)
提交日期2026-07-28
改动范围arch/arm64/include/asm/preempt.h,+11/-18 行,1 文件
核心函数__preempt_count_dec_and_test() / __preempt_count_sub() / should_resched()
原始链接lore Message-ID: 20260728123859.2911495-2-mark.rutland@arm.com
💬 讨论焦点(有回复,每条带来源)
Jinjie Ruan(Huawei,ruanjinjie@huawei.com)Reviewed-by(96db4406-5000-49df-a9ef-b4e49eb206cd@huawei.com,2026-07-29):逐段确认作者分析——"Indeed, that's true"(确认 need_resched 编码反逻辑),“At the moment of executing --pc, the probability of it coinciding with the process needing scheduling is indeed very small”(确认特判路径确实罕见),并给出 Reviewed-by: Jinjie Ruan <ruanjinjie@huawei.com>。
arm64 维护者 Will Deacon / Catalin Marinas 在本补丁下尚无公开回复(截至 lore 索引 2026-08-03)。

📊 速览卡片

核心机制
去特判分支
优化目标
减分支开销
适用场景
preempt 热路径
实测提升
未提供基准

🎯 解决什么问题

背景 / 原始动机
先给一个直觉锚点(简化理解):把 preempt_count 想成一把"防抢占锁"的重入计数——内核代码在进入临界区时 preempt_disable() 把计数 +1,离开时 preempt_enable() 把计数 -1。只有计数减到 0(锁完全释放)且"有更高优先级的任务正在等我让出 CPU"(need_resched 被置位)时,才需要主动调用调度器切换。这个"减一 + 判断"就是 __preempt_count_dec_and_test() 干的事。

本补丁是 13 补丁系列 "arm64: Preemptible this_cpu_*() operations" 的第 1 个(cover letter 明说"Patches 1 and 2 are complete as-is, and can be taken without the rest of the series")。系列整体目标是让 arm64 的 this_cpu_*()(per-CPU 变量访问)不再需要临时禁抢占,从而免掉每次 percpu 访问都要 preempt_disable()/preempt_enable() 的开销。本补丁负责把 preempt_enable() 的出口判断本身做干净——它是系列所有收益的地基。

动机里还有一个 s390 先例:Heiko Carstens 已在 s390 上优化过同名函数(s390/preempt: Optimize __preempt_count_dec_and_test()),系列 cover letter 引用其系列作为参考 [1]。arm64 因为 thread_info 把 count 和 need_resched 放在同一个 64 位 union 里,问题形态与 s390 不同(详见下)。
系统层面:旧实现的"罕见特判"污染了常见路径的代码生成
执行路径:任意 preempt_disable()...preempt_enable() 临界区出口 → preempt_enable[_notrace]() → unlikely(__preempt_count_dec_and_test()) → 若返回真则调用 preempt_schedule[_notrace]() 让出 CPU。
arm64 的 thread_info 布局:低 32 位是 count(禁抢占嵌套计数),高 32 位是 need_resched(0=需要调度,1=不需要,反逻辑编码)。两者拼在一个 64 位 preempt_count union 里,因此"减 count + 看 need_resched"可以用一次 64 位读写完成。
机制缺陷:旧实现 return !pc || !READ_ONCE(ti->preempt_count) 里的 !pc 特判——"递减后整值恰好为 0"(即 count 原本为 1 且 need_resched 原本为 0)——只在罕见情况下成立(作者分析:count 为 1 的嵌套临界区不存在 + 恰好同时有人请求调度)。但为了跳过这个罕见情况下的第二次重读,编译器不得不在常见路径上放一条 cbz 条件分支。结果常见路径变成"cbz(不跳) → 重读 → cbnz(跳走)"的 2 分支结构,慢路径也无法整体外提。
场景层面:任何以"禁抢占临界区"为高频操作的内核负载
场景特征:preempt_enable() 是内核最高频的出口之一。它由 preempt_disable() 的反向配对触发,而禁抢占几乎无处不在——自旋锁/位图锁的临界区、this_cpu_*() per-CPU 变量访问、RCU 读侧、本地中断保护、perf/tracing 计数、调度统计等。arm64 上 this_cpu_*() 每次访问都临时禁抢占(这正是本系列要消除的),因此 percpu 计数器、per-cpu 队列、NUMA 统计这类高频路径会反复执行这段递减判断。
为什么遇到缺陷:这段判断越高频,多出的那条条件分支(取指 + 预测 + 可能的推测执行)被重复的次数就越多;且 2 分支 + 慢路径内联混排会占用 I-cache,压低热路径命中率。注意收益属于 on-CPU 环节(减少指令执行/分支开销),不涉及锁等待、调度延迟等 off-CPU 因素——慢路径本身(真要去调度)没变快,变快的是"判断要不要走慢路径"这件事。
受影响负载:preempt 临界区高频的内核路径(percpu 访问 / 自旋锁 / RCU 读侧 / 统计计数)· 因果:常见路径分支数 2→1、慢路径外提 → 每次 preempt_enable 少一次分支执行与更好的 I-cache 局部性

🧩 核心机制

核心逻辑点(框架 B 识别):① 删除 !pc 特判捷径 ② 改为"递减 count + should_resched(0) 统一重读"——①是消除多余分支的根源,②是让慢路径可被干净外提的关键。

从系统层面看
逻辑点①:删掉特判,统一重读——旧代码在"递减后整值恰好为 0"时直接返回真(!pc),否则才重读 64 位合一值。新代码无条件走 __preempt_count_sub(1)(只减低 32 位 count),再 should_resched(0) 统一重读合一值判断 pc == 0。
为什么能支撑收益:删掉特判后,编译器看到"无论哪种情况都要重读合一值",于是:①不再需要为"跳过重读"发 cbz;②判断条件收敛为单一的 pc == 0,配合外层 unlikely() 提示,慢路径(preempt_schedule_notrace())可以被整体搬到函数末尾的冷区(out-of-line),常见路径是一条不跳转的 cbz + ret 直通。
语义为何不变:返回值真 ⟺ 递减后 count == 0 且 need_resched == 0(需要调度)。旧代码靠"!pc(罕见捷径)+ 重读后 !preempt_count"两条路返回真;新代码靠 should_resched(0) 一条路覆盖全部。作者在注释里保留了关键不变式:必须在递减 count 之后重读合一值——因为中断可能在"读 count"与"写 count"之间把 need_resched 清 0(有人请求调度),重读保证看到最新状态。
代价:罕见情况(count 原本为 1 且已有人请求调度)下,新代码会多执行一次 64 位重读 load;作者认为该路径本就快进入调度慢路径,成本被其他因素主导。
arm64 __preempt_count_dec_and_test()/preempt_enable_notrace() 代码生成 before/after 对比图
图 1:补丁前后 preempt_enable_notrace() 的生成代码对比(GCC 15.2.0)。左边旧实现常见路径有 cbz + cbnz 两条条件分支、慢路径混排;右边新实现常见路径只留一条不跳转的 cbz,慢路径整体外提到 1: 冷区。
来源:基于 lore 真实补丁 commit message 中的汇编示例(GCC 15.2.0)绘制
逻辑点操作目的
① 删特判__preempt_count_sub(1); return should_resched(0);去掉 !pc 罕见捷径,常见路径不再被"跳过重读"的分支污染
① 统一重读should_resched(0) → READ_ONCE(ti->preempt_count) == 0递减 count 后重读合一值,既看到最新 need_resched,又给编译器单一出口条件
② 慢路径外提配合 unlikely(),cbz x0, 1f 跳转到函数末尾冷区常见路径直通 ret,I-cache 局部性更好,分支预测压力更小

🔬 关键代码

diff 逐字来自 lore 真实补丁(RFC v1),按核心逻辑点组织:

核心逻辑点:删除旧实现 + 用 __preempt_count_sub()/should_resched() 重写
diff --git a/arch/arm64/include/asm/preempt.h b/arch/arm64/include/asm/preempt.h
index 932ea4b620428..ca2ad1a8db095 100644
--- a/arch/arm64/include/asm/preempt.h
+++ b/arch/arm64/include/asm/preempt.h
@@ -55,30 +55,23 @@ static inline void __preempt_count_sub(int val)
 	WRITE_ONCE(current_thread_info()->preempt.count, pc);
 }
 
-static inline bool __preempt_count_dec_and_test(void)
-{
-	struct thread_info *ti = current_thread_info();
-	u64 pc = READ_ONCE(ti->preempt_count);
-
-	/* Update only the count field, leaving need_resched unchanged */
-	WRITE_ONCE(ti->preempt.count, --pc);
-
-	/*
-	 * If we wrote back all zeroes, then we're preemptible and in
-	 * need of a reschedule. Otherwise, we need to reload the
-	 * preempt_count in case the need_resched flag was cleared by an
-	 * interrupt occurring between the non-atomic READ_ONCE/WRITE_ONCE
-	 * pair.
-	 */
-	return !pc || !READ_ONCE(ti->preempt_count);
-}
-
 static inline bool should_resched(int preempt_offset)
 {
 	u64 pc = READ_ONCE(current_thread_info()->preempt_count);
 	return pc == preempt_offset;
 }
 
+static inline bool __preempt_count_dec_and_test(void)
+{
+	/*
+	 * We must load the combined 'prempt_count' after decrementing
+	 * 'preempt.count' as an interrupt could modify 'need_resched' before
+	 * __preempt_count_sub() writes back to 'preempt.count'.
+	 */
+	__preempt_count_sub(1);
+	return should_resched(0);
+}
+
 #ifdef CONFIG_PREEMPTION
 
 void preempt_schedule(void);

▲ 为什么这么改:被删的旧实现里 u64 pc = READ_ONCE(ti->preempt_count) 读的是 64 位合一值,而 WRITE_ONCE(ti->preempt.count, --pc) 只写低 32 位 count——--pc 先整体减 1,写回时只取低 32 位。于是 !pc(减后整值为 0)只有在"count 原本为 1 且 need_resched 原本为 0"时才成立,编译器为这个罕见捷径在常见路径上多塞了一条 cbz。新实现用 __preempt_count_sub(1)(只读/写 count 一个 u32)+ should_resched(0)(重读合一值判断 == 0)表达完全相同的语义,但消除了特判。

关键一行:return should_resched(0)——返回值真 ⟺ 递减后合一值 == 0(count 归零且 need_resched 为 0,即"刚好可抢占且需要调度")。新注释里刻意保留了"必须在递减后重读合一值"的不变式(对应旧注释里"中断可能在 READ/WRITE 之间清掉 need_resched"的竞态说明),并保留了一个原样的拼写笔误 'prempt_count'。

📈 性能影响

场景/用例运行环境指标Before(旧)After(新)
preempt_enable_notrace() 常见路径GCC 15.2.0 编译(commit message 示例)常见路径条件分支数2(cbz + cbnz)1(cbz)
preempt_enable_notrace() 常见路径同上慢路径布局混排(fall-through 进入 1:)整体外提(out-of-line)
全系列 defconfig Image 大小defconfig,v7.2-rc4 基线(cover letter)Image 大小41,617,920 B41,552,384 B(-64K)
全系列 defconfig .text 大小同上.text 大小20,331,206 B20,323,522 B(约 -7.5K)

说明:本补丁未提供任何运行时基准数据——作者在 cover letter 的 RFC 原因 (1) 明确写 "I haven't had the chance to benchmark this. While I think the changes look good from inspection, we should obviously get some actual performance figures."(作者自报,未独立验证)。前两行是 commit message 里给出的代码生成对比(GCC 15.2.0,属"生成代码形态"而非端到端吞吐);后两行是 cover letter 里整个 13 补丁系列的 defconfig 尺寸对比(不是本补丁单独的效果),引用时已标注范围。

on-CPU vs off-CPU 视角:本补丁优化的是 on-CPU 环节——减少 preempt_enable 常见路径的条件分支数(2→1)并让慢路径外提,属于指令执行/分支预测/I-cache 范畴,不涉及锁等待或调度延迟。off-CPU 的调度切换成本(preempt_schedule_notrace() 本身)完全不变。端到端收益需独立基准验证(解读(AI 分析)):从路径推断,this_cpu_*()/自旋锁/RCU 读侧等 preempt 临界区高频负载最可能受益,但缺少实测数字,无法量化。

🔄 方案演进

本补丁目前为单版本(RFC v1),暂无演进;方案整体脉络放在系列上下文里说明:

v1(RFC,2026-07-28)→ 现状
v1(2026-07-28,+11/-18):作为 [RFC PATCH 00/13] 的第 1 个补丁提出。核心方案已完整(删特判、改 should_resched(0))。
已获 Jinjie Ruan Reviewed-by(2026-07-29)。作者在 cover letter 中说明 patch 1/2 可独立于系列先合入,其余补丁(实现 preemptible this_cpu_*() 的"PCPU GPR 临界区"方案,见 patch 6)仍在设计/讨论中;RFC 的 (2)(3) 点(SDEI 异常入口未接好、this_cpu_read 是否用 LDA[P]R)与本补丁无关。

⚠️ 风险与局限

潜在回归 / 并发 / 边界
罕见路径多一次 load(解读(AI 分析)):count 原本为 1 且已有人请求调度的罕见情形下,新代码比旧代码多执行一次 64 位重读(旧代码靠 !pc 捷径跳过)。作者主张该成本被"即将进入调度慢路径"的主导因素掩盖,此判断合理但未经测量。
依赖编译器与优化提示(解读(AI 分析)):收益的实现依赖编译器能否把慢路径外提——commit message 展示了 GCC 15.2.0 的效果,但 clang、老版本 GCC、不同 inline/unlikely() 处理下是否同样干净外提未验证;若编译器选择内联展开而非外提,收益形态可能变化。
语义等价性依赖"递减后重读"不变式:新实现把竞态保证集中到 should_resched(0) 的重读上。若未来有人把 __preempt_count_dec_and_test() 改成"减完不重读"或把 should_resched() 改成从低 32 位判断,会破坏"中断可能清 need_resched"的正确性(当前补丁注释已明确标注,风险低)。
并发/多核扩展性(解读(AI 分析)):本改动不引入锁、不新增共享状态、无原子操作差异,per-CPU 的 thread_info 依旧只被本 CPU 读写的语义不变;扩展性与旧实现一致。
未基准验证:作者明确表示尚未跑 benchmark,这是当前最大的不确定性——优化方向符合代码生成直觉,但缺少实测数据支撑。
严重度:MINOR(无正确性风险,主要是"收益未实测";罕见路径多一次 load 成本未量化)· review 质疑:Jinjie Ruan 已 Reviewed-by,无 NACK/未决反对

🔗 交叉引用

📌 关联工作
[RFC PATCH 00/13] arm64: Preemptible this_cpu_*() operations(cover letter) — 本补丁所属系列;目标是让 this_cpu_*() 不再禁抢占,本补丁是系列地基
Jinjie Ruan Reviewed-by — 唯一公开 review,确认分析并 Reviewed-by
s390/preempt: Optimize __preempt_count_dec_and_test() — Heiko Carstens 在 s390 上的同名优化(2024-12,+1/-1,用 flag 输出约束单指令完成减+测),是系列的参考先例之一
s390/preempt: Optimize __preempt_count_dec_and_test()(2026-01) — Heiko Carstens 后续迭代(改用 alternatives 避免低核基址),同类"减 preempt_count + 测 need_resched"热路径优化

✅ 关键洞察

  • 发现:arm64 旧 __preempt_count_dec_and_test() 的 !pc 特判(罕见捷径)反而污染了常见路径的代码生成——preempt_enable 常见路径因此多一条条件分支、慢路径无法外提;补丁用 __preempt_count_sub(1) + should_resched(0) 统一重读,语义等价但代码生成干净
  • 证据:commit message 的 GCC 15.2.0 代码生成对比(作者自报,未独立验证)——常见路径条件分支从 2(cbz+cbnz)降到 1(cbz),慢路径整体外提;无运行时基准数据,作者明确说还没机会 benchmark
  • 边界:仅 arm64 生效;罕见路径(count 原本为 1 且已请求调度)多一次重读 load;收益形态依赖编译器能否外提慢路径(GCC 15.2.0 已验证,clang 等未验证)
  • 风险 / 建议:优化方向正确但缺实测,建议合入前用 hackbench/lmbench/sysbench 等 preempt 临界区密集负载补基准;本补丁为系列第 1 块基石,可独立先行合入(作者已声明),后续补丁值得持续跟踪其性能验证结果
⚠️ 免责声明

本站内容均由 AI 基于公开知识辅助生成,仅供学习参考,请勿直接引用作为依据。作者不对信息的准确性、完整性及适用性作保证,亦不对因使用本站内容产生的任何损失承担责任。