本节摘要:函数边界是优化的信息断层:跨过一次调用,编译器通常只知道"有个函数会执行、会返回",不知道它动了哪些内存、参数会被怎么用。过程间优化(Interprocedural Optimization,IPO)在调用图上恢复这些信息——内联把被调函数体搬进调用点,让一切已有优化继续生效;参数特化、全程序死代码删除、跨函数常量传播各自吃掉一类断层。代价同样清晰:内联膨胀代码、IPO 要求全程序视野、与动态链接和间接调用天然冲突。读完你应当能对具体调用点做内联决策,并能说出一个工程为何在"全程序优化"与"分离编译"之间做取舍。
前三档优化都被一堵墙拦着:函数调用。本节负责拆墙——拆墙的次序是先建图(调用图),再顺着图传播事实或搬运代码。
一切 IPO 从调用图开始:结点是函数,边是"可能发生的调用"。静态直接调用画边容易,两类情形会把图打糊:
调用图还喂给一个重要属性:叶子函数(无任何调用)可以放心做寄存器分配的激进假设(第6章),递归环让某些跨函数分析按环截断。图的质量决定 IPO 的天花板——这是"IPO 依赖指针分析"的又一实例。
内联把被调函数的体直接搬进调用点。账面上的收益是省掉调用序列(参数压栈/寄存器传递、返回地址、栈帧建立与拆除,小函数上这部分开销可能与函数体本身同量级),但真正的价值在账面外:
static inline int square(int v) { return v * v; } int dist2(int dx, int dy) { return square(dx) + square(dy); } // 内联后:return dx*dx + dy*dy; // 连锁解锁:dx*dx 与 dy*dy 块内 CSE/折叠 → 常量参数时整函数折叠成常量 // 若调用方再传常量 dist2(3, 4) → 25,全程零运行期计算
被调体进入调用方后,5.1 的局部/全局优化视野突然覆盖了原来隔在墙外的代码:常量参数传播进去、公共子表达式跨墙识别、死分支整段消失。内联不是"优化了一个调用",而是"给全部优化解锁了一片新领土"——这就是发动机的含义。
决策不是无脑搬。工程模型围绕两个量:收益(调用开销 + 后续优化机会)与代价(代码尺寸膨胀、指令缓存压力、编译时间)。实践中常见的闸门:函数体小于阈值内联;调用点只有一处(单点调用)内联;循环体内的小函数倾向内联;递归函数只展开一两层;虚函数未经去虚化不内联。盲目全量内联的历史教训足够多——代码体积翻倍、L1i 未命中率上升,性能不升反降。
搬代码只是手段之一,另一类 IPO 不搬代码、只搬运"事实":
| 优化 | 吃掉的断层 | 做什么 |
|---|---|---|
| 参数特化 | 参数常量性 | 给"总收到同一常量"的参数复制专用版本 |
| 跨函数常量传播 | 返回值未知 | 分析被调体,证明返回值恒为某常量 |
| 全程序 DCE | 符号可见性 | 从入口出发标记可达函数,不可达者整段删除 |
| 去虚化 | 间接调用目标 | 证明虚调用只有一个可能目标,改成直接调用并解锁内联 |
| 尾调用/_leaf 标注 | 调用图结构 | 叶子函数免除保存/恢复调用者寄存器的开销 |
这几个变换互相咬合:特化出的版本更小更好内联,去虚化把间接调用变直接从而画准调用图,全程序 DCE 在链接期才能发挥全力(编译期只见单文件时,导出符号一律"可能被外部用")。它们共同的工程前提是全程序视图——C/C++ 世界由 LTO 提供(把中间码存进目标文件,链接期统一优化),这也是近年"优化越来越依赖构建方式"的原因:同样的源码,开与不开 LTO 的产物性能可差一成以上。
⚠️ 易错点:把 IPO 当成"更大的局部优化"。IPO 的单子每一项都依赖图级事实(调用图、可达性、指向集),而这些事实对增量构建极其敏感——多链一个库、多注册一个回调,图就变,之前的结论全部作废。IPO 的正确心智模型是"链接期的全局推理",不是"编译期的放大镜"。
本节要点回顾:
代码被优化得足够好之后,还差最后一段路:从 IR 落到寄存器与流水线。下一章进后端。
问:内联为什么对虚函数格外谨慎? 虚函数的调用目标要等运行期对象类型揭晓,编译期只能靠类层次分析缩小候选:只有一个实现才敢去虚化、才谈得上内联;候选一多,任何"搬函数体"的动作都可能搬错。面向对象代码的 IPO 性能,一大半押在类层次分析的精度上——这也是"满屏虚函数的代码难优化"的技术出处。
问:递归函数能内联吗? 能,但只能展开有限层(一到两层常见),因为递归的调用图是环,全展开等于无穷。展开一两层的收益依然实在:参数常量传播进去后,浅层递归可能直接坍缩成循环(尾递归场景)或折叠成常量。递归深度相关的 IPO 分析("这个函数最多递归几层")因此成了值得单独立项的学问。
问:库函数的语义信息从哪来? strlen、memcpy 这类函数的"只读""不抛异常""结果只依赖参数"等属性,编译器不可能从源码推出(只有汇编),只能内置一张已知库函数属性表。这张表的价值巨大:一次 strlen 在循环条件里的调用能被外提,前提是表里写着"纯函数"。自研运行时的团队常忘了维护这张表,性能莫名落后一截——排查方向之一就是"编译器不知道我的函数是干净的"。