审校基线:本页引用的内核符号、行号与源码链接以 Linux 7.3-rc4(torvalds/linux master,2026-09-26 实测)为基准;审校当日 master 的确切 commit 未记录,后续版本可能已有漂移。
O2 不是魔法——是几百个"大概率划算"的小赌注,外加一条"别骗我"的契约。
2026-09-26 · Compiler Optimizations

第 19 关 · 编译器优化:O2、内联与 PGO

定位

这一关回答:内核拿 -O2 到底买了什么?又为什么亲手关掉编译器最得意的几个优化?答案比直觉更分裂:-O2 不是"一个优化",而是上百个 pass 的默认组合——每个 pass 都是一次"大概率划算"的小赌注(内联启发式、向量化、死代码删除……);但内核和编译器签的是另一份契约:-fno-strict-aliasing("别拿类型别名来骗我")、-fno-delete-null-pointer-checks("别删我的 NULL 检查")。优化的本质是在 as-if 规则下重写你的代码——内核要的是"快,但不许自作聪明"。

这一关的机制支点:-O2 的 pass 组合与 -O2/-Os 开关(顶层 Makefile 已验证)、内联(__always_inline/noinline)、分支提示(likely/unlikely,以及它的运行期升级版 jump label)、LTO(内核只认 Clang ThinLTO)、PGO/AutoFDO(机制讲透,但 7.3 的 Makefile 里一个 PGO 的影子都没有——已验证),以及 barrier()。M18 讲"编译器会动你的代码"(as-if 与重排),本关讲"让它动得更快,同时划好红线"。

核心机制深讲

19.1 -O2:不是优化,是 pass 流水线的默认配置

-O2 打开的是上百个 pass:内联启发式(-finline-small-functions:小函数默认内联)、向量化(-ftree-vectorize:循环自动 SIMD 化)、死代码删除(-fdce)、公共子表达式删除、循环优化、常量传播……每个 pass 独立下注,赌的是"大多数程序上划算"。这就是 deck 里"小赌注"的含义:没有哪个 pass 敢保证对你的代码一定有利,组合起来期望为正。

唯一的硬契约是 as-if 规则:可观察行为不变,编译器在中间随便重写——M18 的编译器重排正是这条规则的另一面。本关是同一条规则的"进攻形态"。

内核侧的开关在顶层 Makefile 里,白纸黑字(7.3.0-rc4 已验证):CONFIG_CC_OPTIMIZE_FOR_PERFORMANCE → KBUILD_CFLAGS += -O2,CONFIG_CC_OPTIMIZE_FOR_SIZE → KBUILD_CFLAGS += -Os。-Os 不是"更慢的 -O2",是另一套赌注组合——为体积优化(嵌入式、小内存场景),赌的是"icache/内存省下来的比执行慢回去的多"。

19.2 内联:收益最大、代价最显性的优化

__always_inline 的定义只有一行(include/linux/compiler_attributes.h,已验证):#define __always_inline inline __attribute__((__always_inline__))——强制内联,告诉编译器"别算账,直接内"。内核把它用在热路径的小函数上。

内联赚两笔钱:消掉 call/ret 开销;更重要的是跨函数优化——常量传播进函数体、形参变常量后的死代码删除,这些"第二轮"优化只有内联后才看得见。但代价同样显性:代码膨胀 → icache 压力。内核是 7×24 常驻的代码,icache miss 比一次 call 贵得多——这就是为什么不是所有小函数都配 __always_inline,普通 inline 只是"建议",最终由 -O2 的内联启发式按收益模型自动决策。

反方向的旋钮是 noinline(同一文件,已验证):ftrace、kprobe 需要真实的函数边界来下钩——函数被内联了,hook 就无处可挂。优化与可观测性在这里直接打架,内核选了"该留边界的地方留边界"。

19.3 likely/unlikely:给分支预测递纸条

定义(include/linux/compiler.h,已验证):#define likely(x) __builtin_expect(!!(x), 1),unlikely 同理取 0。这只是纸条,不是命令:不改变程序语义,编译器据此排代码布局——热路径直行、冷路径跳到远处,减少分支预测失败的惩罚。注意它不保证分支预测器听话,只影响"代码放哪"。

纸条的运行期升级版是 jump label(CONFIG_JUMP_LABEL,arch/Kconfig 原文:"Optimize very unlikely/likely branches",已验证):把"几乎恒为假"的分支编译成一条 nop,条件翻转的罕见时刻再动态 patch 成 jmp。tracepoint、调度器、网络栈、KVM 都在用——热路径上的判断指令本身消失了,只剩一条 nop。代价是 patch 时的同步复杂性,但"条件翻转"极少发生,代价只付在罕见时刻。

对比着记:likely 是编译期纸条(静态),jump label 是运行期改代码(动态)——后者是"把提示变成代码改写"。

19.4 LTO:打破翻译单元的墙

没有 LTO,内联、常量传播、死代码删除全被 .c 文件边界挡住——跨文件的调用再小也内联不了。LTO 把优化的视野从"一个翻译单元"扩大到"整个程序"。

内核侧的事实(arch/Kconfig,已验证):CONFIG_LTO_CLANG、LTO_CLANG_THIN、LTO_CLANG_FULL——Kconfig 里只有 Clang 一家,没有 GCC LTO。务实的选择是 ThinLTO:用摘要做跨模块内联,内存可承受;Full LTO 要做全程序分析,对 vmlinux 这种体量基本不可用。代价:链接变慢、调试信息复杂化——LTO 从来不是免费午餐。

19.5 PGO/AutoFDO:拿真实 profile 下注

机制本身很漂亮:-fprofile-generate 插桩跑一遍,收集每条边的真实执行计数;-fprofile-use 重编译,分支概率、内联决策、代码布局全部用实测数据下注。AutoFDO 更进一步:用 perf 采样代替插桩,省掉第一遍的插桩开销——"用生产环境的真实采样指导优化"。

然后是内核侧的事实,同样白纸黑字:7.3 顶层 Makefile 全文 grep "PGO",零命中(已验证)——内核构建默认不走 profile 引导。为什么?因为内核的 workload 是"整个世界":桌面、服务器、嵌入式、路由器跑的是完全不同的代码路径,不存在"代表性 profile";而且"插桩→跑→重编"的两遍流程,对内核这种构建也不友好。本节讲透机制,但不虚构内核采用——诚实是本系列的底线。

19.6 "别骗我"契约:内核亲手关掉的优化

-O2 是油门,下面三个是刹车——全在顶层 Makefile,已验证:

一句话总结本节:-O2 是"往快了改",这三个是"不许这么改"——优化是油门,契约是刹车,内核两个都要。

为什么这样设计

为什么默认 -O2 而不是 -O3?

-O3 的激进内联、循环展开让代码膨胀,icache miss 吃掉的那部分经常超过它省下的。内核是 7×24 常驻内存的代码,icache 友好比峰值 IPC 更重要——这是常驻代码和跑一次就退出的用户程序的根本区别。体积敏感场景另有 -Os 兜底,不需要 -O3 来凑。

为什么关掉 strict aliasing,而不是把内核代码改"干净"?

因为 type punning 是全树的惯用法,改"干净"等于重写半个内核;TBAA 带来的那点 load 提升,抵不上引入 subtle miscompile 的风险。务实压倒洁癖——这是内核工程哲学的缩影:正确性面前,优化让路。

likely/unlikely 只是纸条,为什么 jump label 值得动态 patch 代码的复杂性?

因为 tracepoint 这类"几乎恒为假"的分支就在热路径上,likely 只能排布局,省不掉判断指令本身;jump label 把热路径变成一条 nop——判断指令彻底消失。patch 的同步复杂性只付在"条件翻转"那个罕见时刻,一次投入、长期免单。

与已学模块的关联

自测

出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释"为什么这样设计而不是那样"。难度递进,最后两道需要想一会儿。

1.(记忆)顶层 Makefile 里,-O2 和 -Os 各由哪个 config 控制?

CONFIG_CC_OPTIMIZE_FOR_PERFORMANCE → KBUILD_CFLAGS += -O2;CONFIG_CC_OPTIMIZE_FOR_SIZE → KBUILD_CFLAGS += -Os。关键是"受配置控制"——-O2 不是无条件启用的。

2.(记忆)__always_inline 和 noinline 各解决什么问题?

__always_inline(inline __attribute__((__always_inline__)))强制内联,用在热路径小函数,赚调用开销 + 跨函数优化;noinline 反向禁止内联,因为 ftrace/kprobe 需要真实的函数边界来下钩——内联了就无处可挂。

3.(推理)likely(x) 的定义是什么?它会改变程序语义吗?

#define likely(x) __builtin_expect(!!(x), 1)——纯编译器提示,不改变语义,只影响代码布局(热路径直行、冷路径跳走)。它也不保证分支预测器听话:纸条 ≠ 命令。

4.(场景)一个热路径小函数被 __always_inline 内联后,实测性能反而下降。最可能的元凶是什么?

代码膨胀 → icache 压力。函数被多处内联后二进制变大,热代码挤出 L1 icache,一次 icache miss 的代价远超一次 call。内联的赌注输了——这正是启发式内联要算账、而强制内联跳过算账的原因。

5.(权衡)为什么内核用 -fno-strict-aliasing 关掉 TBAA,而不是把 type punning 的代码改"干净"?

type punning 是全树惯用法(网络包解析、驱动寄存器映射),改"干净"等于重写半个内核;TBAA 省下的几个 load 抵不上引入 subtle miscompile 的风险。正确性面前优化让路——关掉整类优化,换"编译器不许拿类型骗我"的确定性。

6.(推理)jump label 相比 likely/unlikely 强在哪里?它付了什么代价?

likely 只排代码布局,判断指令还在;jump label 把"几乎恒为假"的分支编译成 nop,热路径上的判断指令彻底消失。代价是运行期 patch 代码的同步复杂性——但条件翻转极少发生,代价只付在罕见时刻。适用于 tracepoint 这类"几乎不变"的分支,不适用于频繁翻转的条件。

7.(场景)为什么内核 Kconfig 里只有 LTO_CLANG,没有 Full LTO 的实用选项?ThinLTO 折中了什么?

Full LTO 要做全程序分析,vmlinux 的体量下内存/时间不可承受;ThinLTO 用模块摘要做跨模块内联,用"近似全局视野"换"可承受的链接代价"。这是典型的工程折中:拿不到满分,就拿性价比最高的 80 分。

8.(推理)PGO 需要"代表性 workload",为什么内核拿不出来?

内核的 workload 是整个世界:桌面、服务器、嵌入式、路由器跑的代码路径完全不同,用某一种 profile 指导优化,等于拿"昨天的天气"赌"明天的所有天气"。再加上"插桩→跑→重编"两遍流程的工程代价,内核构建干脆不走 PGO——7.3 Makefile 里零命中就是铁证。

9.(设计)barrier() 和 likely() 分别约束编译器的什么?一个管"顺序",一个管"布局",对吗?

对。barrier()(__asm__ __volatile__("" ::: "memory"))是编译器屏障:内存访问不许跨这条线重排(管顺序,M18 的刹车片);likely() 是分支概率提示:只影响代码布局(管放哪),不改变语义、不约束重排。两者正交,一个管"能不能动",一个管"放哪更顺"。

10.(设计·难)一个网卡驱动的中断处理函数:高频、短小、几乎总是"成功收包"路径。你会如何组合 __always_inline / likely / jump label?论证取舍。

参考思路:__always_inline——高频短小,调用开销占比大,内联稳赚(但要确认调用点少,避免多处内联导致膨胀);likely 标"成功收包"分支——编译期纸条,零运行期代价;jump label 留给"几乎恒定"的开关(如调试 tracepoint),不用于"几乎总是成功"这种仍有可观失败率的分支——patch 收益要大于翻转时的同步代价。核心判据:频率 × 稳定性——越热越稳,越值得上重手段。

延伸阅读

-O2 的"赌注组合"(示意)
源代码
  │  -finline-small-functions   小函数内联(启发式)
  ▼  -ftree-vectorize           循环自动向量化
  │  -fdce                     死代码删除
  ▼  -fgcse / 循环优化          公共子表达式、循环变换
  …上百个 pass,每个都是"大概率划算"的小赌注…
  ▼
-O2 二进制(as-if:可观察行为不变)

-O2 不是一个优化,是一整条 pass 流水线的默认配置。内核在这条流水线之外,另签了"别骗我"契约(-fno-strict-aliasing 等)——油门和刹车都要。

likely/unlikely vs jump label
likely(x):编译期纸条
  if (likely(ok)) …   →  热路径直行,冷路径跳走
  代价:判断指令还在,只是放得顺了

jump label:运行期改代码
  热路径:  nop                 ← 条件为假时,这里就是一条 nop
  翻转时:  patch → jmp cold    ← 罕见时刻付代价
  收益:判断指令彻底消失

纸条是静态的,patch 是动态的。tracepoint、调度器用 jump label 的理由:分支"几乎恒定",值得把判断本身也省掉。

在页面里每题写下你的答案(关键词即可),卡住的题点一下"卡住了"标记,
点"生成作答汇总"后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说"下一关"。

作答汇总

点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。