C/C++ 现代语言与底层 · 第 七 篇

链在一起:LTO 与 PGO 的收益从哪来

2026-10-1316,623 字36 张配图

本文由@溯光 出品 站点:https://yyy3d.online/blog/ | 本系列:https://yyy3d.online/blog/#topic-cpp-language-foundations 两条边界:LTO 拆翻译单元、PGO 拆类型与分支 题图 两条边界:LTO 拆翻译单元、PGO 拆类型与分支 @ 本系列绘制


本系列已发表篇目(全系列共 12 篇,分四波连载):


背景与边界

LTO(Link-Time Optimization,链接时优化)与 PGO(Profile-Guided Optimization,配置文件引导优化)总是成对出现。它们常被并列成"两个高级优化开关",一起写进构建脚本,一起被当成"性能调优的第二步"。但它们动的东西完全不一样,代价也不一样,失败的方式更不一样。

这一篇不讲"怎么开这两个开关"。本篇要算的是一笔账:这两个开关各自把什么边界拆掉了,收益从哪个循环里长出来,代价落在体积、构建时间还是数据的保鲜期上。

0.1 一句话结论先放这里

  • LTO 拆的是翻译单元(translation unit)边界。它让编译器在链接期看见全部 TU,于是"跨文件的小函数"终于能被内联。收益出现在函数调用上。
  • PGO 拆的是类型与分支边界。它让编译器知道"这个虚调用在训练里永远走同一个实现""这个分支几乎不会走",于是能把间接调用消掉、把冷代码挪出热路径。收益出现在间接调用与分支上。

两句话各管一段,谁也替代不了谁。这不是修辞——本篇会给出一个把两者分开量到各自循环上的实验。

0.2 实测环境(本篇所有数字都出自这里)

项 值
MSVC cl / link / dumpbin 19.44.35228(Visual Studio 2022 Community,工具集 14.44.35207)
GCC MSYS2 ucrt64 g++ 16.2.0(Rev3, Built by MSYS2 project),配 objdump / ar / gcc-ar
计时 被测进程内 std::chrono::steady_clock,每档跑 5–7 次取中位数
平台 Windows x64,PE/COFF

0.3 本篇不涉及什么(边界声明)

  • 不做编译器入门教学:不解释什么是 IR、什么是基本块、什么是支配树。需要这些前置的朋友,先看本系列 ep04(编译期账本)与 ep05(优化器的边界)。
  • 不做 Clang/LLVM 的本机实测:本机无 Clang、无 WSL。ThinLTO、BOLT、AutoFDO、CSIR 这类只属于 LLVM 生态的东西,本篇一律只引官方原文,绝不冒充实测。这是硬边界,不是为了省事。
  • 不做 ELF 侧的实测:MSYS2 产出的是 PE/COFF 而不是 ELF,所以链接器插件、--plugin、ELF 段布局这些只在 Linux 上成立的东西,同样只引原文。
  • 不给"最佳实践清单":本篇只给判据和读数,不给"你应该开 LTO"这种没有上下文就没有意义的建议。

0.4 实验用例:刻意做成两个靶子

好了,为了让两种收益量在各自的循环上,用例被刻意设计成四文件、两个独立靶子:

  • 靶子一(LTO 的靶子):step(int) 定义在 a.cpp,被 b.cpp 里的 run() 在 4096 次循环中逐元素调用。它很小(x*3+1 过一遍饱和到 0..255),小到"如果看得见就一定会被内联"——所以它是否被内联,只取决于编译器能不能跨过 TU 边界。
  • 靶子二(PGO 的靶子):drive() 里一次虚调用 op->apply(s + p[i])。抽象基类 Op 有三个实现(AddOp / MulOp / XorOp),但训练和实测都只喂 AddOp——这正是 PGO 的虚拟调用推测(virtual call speculation)想要的场景。
用例四文件:a.cpp 的 step 与 b.cpp 的 run 构成跨 TU 靶子,c.cpp 的 drive 与三个 Op 子类构成间接调用靶子
题图1 用例四文件:a.cpp 的 step 与 b.cpp 的 run 构成跨 TU 靶子,c.cpp 的 drive 与三个 Op 子类构成间接调用靶子@ 本系列绘制

两个循环各跑 20000 轮 × 4096 次迭代 ≈ 8,192 万次,各报一个毫秒读数。两个读数互不干扰:谁的收益长在哪个循环上,一眼可辨。

实测方法与计时口径:编译链路、取样方式与三类取数
图 7-12 实测方法与计时口径:编译链路、取样方式与三类取数@ 本系列绘制

一、一句话点题:两条边界

1.1 一条边界的名字叫"翻译单元"

先来看看C++ 的编译模型是按翻译单元切开的:每个 .cpp 加它引到的头文件,编成一个目标文件(object file,.obj / .o)。编译器在编 b.cpp 的时候,看不见 a.cpp 的内容。它只看见一个声明:

int step(int x);              /* 声明可见,定义不可见 */

于是它只能生成一条真正的调用指令——把参数放好、call 过去、拿返回值。至于被调函数体是不是只有两条指令,编译器无从判断。

LTO 做的事非常直白:把"看不见"变成"看得见"。编译期不再直接生成最终机器码,而是先把中间表示(IR / GIMPLE)留在目标文件里;链接期把所有目标文件的 IR 合到一起,再做一遍全程序优化,此时 step 的函数体和 run 的循环在同一张图里,内联就顺理成章了。

1.2 另一条边界的名字叫"类型"

说白了,虚调用卡在另一处。op->apply(x) 这行代码里,静态类型是 Op&,编译器不知道它指向哪个子类,于是生成"取虚表指针 → 取槽位 → 间接调用"三步。这条间接调用还有第二个受害者:CPU 的分支预测器——间接跳转的目标只有在运行时才知道。

PGO 做的事也很直白:跑一遍程序,把"实际发生了什么"记下来。训练数据显示这个虚调用 100% 落在 AddOp::apply 上,编译器就可以生成一条带守卫的快路径:先比较虚表槽位是不是 AddOp::apply,是就直接算(甚至可以内联),不是才跳到冷路径去走通用间接调用。

1.3 行业刻度:这笔账在真实产品上有多大

言归正传,在动手之前,先给出一个外部参照——这两个开关在成熟产品上的收益量级。这些数字全部来自官方文档或官方工程博客,与本篇的玩具用例无关,只用来校准预期:

案例 官方给的数字
Chrome on Windows(PGO) 新标签页加载快 14.8%、首次绘制快 5.9%、启动快 16.8%
Chrome 整体 启动最高快 25%、页面加载最高快 7%
Chrome on Android(PGO + orderfile + 更激进内联) 这些构建优化"贡献了 Speedometer 分数提升的一半以上"
Firefox "PGO 默认关闭,可提升运行时最高约 20%,但构建耗时很长"
MSVC 采样 PGO(SPGO) 典型加速 5–15%(取决于 profile 质量)
CSSPGO(伪插桩 + 采样) 在 AutoFDO 基础上再提升 1–5%

三句读法:

  • 个位数到十几个百分点是常态。凡是宣传"开 LTO/PGO 提速数倍"的说法,其基准多半是"关掉全部优化"或者恰好命中了某个可被完全内联的极热函数——本篇 §3 的 7.2 倍就是后一种情况,它成立,但不可外推。
  • 数字越大的案例,构建代价也越大。Firefox 那句"提升最高约 20% 但构建耗时很长"是同一个句子里说完了收益和代价的——这才是这两个开关的常态描述方式。
  • PGO 的收益通常比 LTO 大(Chrome 那组数字主要来自 PGO)。原因是 PGO 拿到的信息是"运行时事实",而 LTO 拿到的只是"更大的可见范围"。

1.4 分开讲的理由

因为它们在构建系统里的位置不同、失败方式不同、保鲜期也不同:

维度 LTO PGO
输入 全部目标文件的 IR 一次真实运行的采样/计数
发生在 链接期(编译期产出 IR) 编译 + 链接(两遍链接)
主要收益位 函数调用(内联) 间接调用、分支、代码布局
主要代价 构建时间、体积 训练流程 + 数据的保鲜期
失效方式 静默失效(链接器一声不吭) 静默失效 + 静默过期(两套编译器都不报)
是否依赖运行场景 不依赖 强依赖,训练数据不代表真实负载就会反向优化

最后两行是这一篇的分量所在。LTO 与 PGO 最危险的共性不是"复杂",而是它们失败的时候页面一片安静:没有报错、没有警告,甚至还有"100% 命中"的漂亮统计报告。本篇会用实测把这些安静的位置逐个指出来。


二、两条边界分别卡住什么

2.1 LTO 卡住的那一类调用

先来看看被 TU 边界卡住的是小函数。这一点值得说清:大函数本来就不该被内联(内联的代价是代码复制),所以"看不见"对它们影响不大。真正吃亏的是这样一类函数:

/* a.cpp */
static int clamp8(int v) { return v < 0 ? 0 : (v > 255 ? 255 : v); }
int step(int x) { return clamp8(x * 3 + 1); }

step 展开后不过三四个算术指令。不内联意味着每次调用要付出:

  1. 参数搬运与栈帧建立;
  2. 一条 call 与一条 ret;
  3. 最要命的:编译器看不见函数体,就无法做常量传播、无法把 clamp8 提前化简、无法把结果直接接到下一次运算上——指令级并行的机会全丢了。

在一个逐元素调用的循环里,这三项会被乘上"循环次数"。本篇的用例是 8,192 万次,所以 7 倍的差距并不奇怪。

2.2 PGO 卡住的那一类调用

说白了,被类型卡住的是间接调用。这里有两个层级的代价,很多人只算第一层:

  • 第一层(执行开销):取虚表指针、取槽位、间接跳转,比直接调用多几条指令;
  • 第二层(预测开销):间接跳转的目标要在运行时才知道,分支预测器只能用历史去猜。猜错的代价是流水线冲刷——这一层常常比第一层大一个量级。

PGO 的价值正在第二层:它把"猜"变成"验"。带守卫的快路径让分支从"间接跳转"退化成"比较 + 条件跳转",两者对预测器的友好度完全不同。

2.3 两个靶子在汇编上长什么样

本篇后面会反复引用两段汇编,这里先把"看什么"立好,后面就不必每次解释:

  • 跨 TU 小函数被内联 ⇔ 反汇编里再也找不到对被调函数的 call 指令;
  • 间接调用被消掉 ⇔ 热路径里出现了"比较虚表槽位与某个已知实现地址"的 cmp/jne 对,且命中分支上没有任何 call。
两条机械可验的判据:目标函数的 call 归零、热路径出现守卫且命中无 call
图 7-23 两条机械可验的判据:目标函数的 call 归零、热路径出现守卫且命中无 call@ 本系列绘制

这两条判据是纯机械的、可脚本化的。它们不依赖任何"编译器应该说"的文档承诺——这是本篇全部结论的证据形态:不是"编译器应该内联",而是"反汇编里那条 call 没了"。

2.4 一个必须提前说清的坑:注释的编码

下面进入在动手之前先记一个真实的坑,它拖掉了本篇第一轮实测。

本组源文件最初带中文注释,用 UTF-8 保存。MSVC 在简体中文 Windows(代码页 936)下会把 UTF-8 字节按 GBK 解码,于是报出 warning C4819: 该文件包含不能在当前代码页(936)中表示的字符。这个警告本身不致命,致命的是它的副作用:GBK 的双字节字符里,尾字节可能是 0x5C——也就是反斜杠。当它落在一行注释的末尾时,编译器会把这一行的换行符"吃掉",于是下一行的代码被并进注释,接着是一串莫名其妙的语法错误:

iface.h(6): error C2270: "apply": 非成员函数上不允许修饰符
iface.h(7): error C2588: "::~Op": 非法的全局 析构函数

第一条错误指向 virtual int apply(int x) const = 0; ——明明完全合法。看到"合法代码被判非法、且错误行号成片漂移",先怀疑编码,而不是先怀疑语法。 本篇的做法是把实测源文件的注释全部改成 ASCII;工程上的正规修法是给编译命令加 /utf-8,或把源文件存成带 BOM 的 UTF-8。

这条坑也解释了为什么它值得写进正文:它和本篇主题是同一类问题——工具链在你不看的地方做了一个决定(这里是"按系统代码页解码"),代价却出现在很远的地方。

2.5 LTO 还会顺手改掉一件不太被提起的事

除了函数调用,LTO 还会顺手改掉一件和可观测性有关的事:它会把一批内部符号变成不可见的。

原理不复杂:当 step 被内联进 run 之后,step 本身不再需要作为独立函数存在;同理,a.cpp 里那些只在本 TU 用到的静态函数、模板实例、常量表,在全程序视角下如果确认无人引用,就会被直接消掉,或者退化成局部符号。

这件事在体积上是好事,在两个场景里却是麻烦:

  • 符号级性能工具(采样剖析器、符号化回溯)看到的函数会变少。你按函数名去看火焰图,发现某个函数"消失"了——它没消失,它是被内联进调用者了。
  • 动态追踪/打桩(依赖 PLT/IAT 或符号名挂钩)会失去挂点。一个被内联掉的函数,你没法再"钩"它。

这类代价在本篇用例里是看得见的:MSVC 基线档里 ?run@@ 与 ?drive@@ 都是独立符号,LTO 档里两个符号都不存在了(§8.2)。"函数不见了" 是 LTO 最容易被误读的现象——它不是优化失败,恰恰是优化成功。


三、LTO 的收益:跨 TU 内联(MSVC 实测)

3.1 原理:把 IR 留到链接期

说白了,MSVC 的 LTO 由两个开关分工:

  • /GL(Whole Program Optimization,全程序优化):编译期不生成最终机器码,而是在目标文件里留下中间语言(IL)。官方文档的原话是,/GL 还额外"让 PGO 可用"MSVC /GL 文档。
  • /LTCG(Link-Time Code Generation,链接时代码生成):链接期把各模块的 IL 读出、合并、做跨模块优化,再生成最终代码MSVC /LTCG 文档。

GCC 侧的名字更简单:-flto。官方手册对它的描述一句话就到点子上——链接时把所有函数体读出来,"as if they had been part of the same translation unit"(就像它们本来就在同一个翻译单元里)GCC 优化选项手册。这句"as if"就是 LTO 的全部定义。

3.2 参数逐项释义(本组实测用到的)

开关 作用 本篇实测取值
/O2 优化级别,作为所有对照组的公共基线 全部档位
/GL 编译期产出 IL,不产出最终代码 基线档不写;LTO/PGO 档写
/LTCG 链接期做跨模块代码生成 见 §5.3 的开关语义实测
/MD 动态 CRT,保证各档的运行时一致 全部档位
/OPT:REF /OPT:ICF 链接器默认开:未被引用的 COMDAT 消除 + 相同 COMDAT 折叠MSVC /OPT 文档 全部档位显式写出

最后一行的口径必须说清:/OPT:REF 与 /OPT:ICF 在命令行下本来就是默认开启的,所以下表里"基线 13,312 B"这个数字已经享受过它们的好处。两组之间只差 LTO 本身,不存在"基线没开去重"这种不公平。

3.3 代码:四文件用例的关键部分

/* a.cpp —— 靶子一:跨 TU 小函数 */
#include "iface.h"
static int clamp8(int v) { return v < 0 ? 0 : (v > 255 ? 255 : v); }   /* 【1】 */
int step(int x) { return clamp8(x * 3 + 1); }

/* b.cpp —— 调用方,在另一个 TU 里 */
#include "iface.h"
int run(const int *p, int n) {
    int s = 0;
    for (int i = 0; i < n; ++i) s += step(p[i] ^ (i & 7));             /* 【2】 */
    return s;
}

【1】clamp8 是 static:它已经是 TU 局部的,所以基线档里它就能被内联进 step——这一项不受 TU 边界影响,把它写进来是为了让"没被内联"的成本集中在 step 本身。

【2】这一行是本篇所有跨 TU 数字的来源:step 的定义在 a.cpp,调用点在 b.cpp。基线档里编译器只看得见一条声明,只能生成 call;LTO 档里两边同图,clamp8 与 step 会被一起展开进这个循环。

3.4 效果:两列读数

MSVC 四档读数:跨 TU 调用 153.65 → 21.39 ms(7.2×),体积反涨 512 B
图 7-34 MSVC 四档读数:跨 TU 调用 153.65 → 21.39 ms(7.2×),体积反涨 512 B@ 本机实测(MSVC 19.44.35228)
配置                             exe(B)   代码段(B)   cross_tu 中位   indirect 中位
/O2 基线(无 /GL)               13,312     5,120      153.65 ms      152.92 ms
/GL + /LTCG                     13,824     5,632       21.39 ms      109.69 ms
/GL(不写 /LTCG)                13,824     5,632       22.17 ms      120.52 ms
/GL + /LTCG:OFF                 13,312        —       149.67 ms      150.77 ms

好了,读这张表要抓住三件事:

  1. 收益是真的,而且很大:跨 TU 循环 153.65 → 21.39 ms,7.2 倍。
  2. 体积反而变大了:13,312 → 13,824 B,代码段 5,120 → 5,632 B,各涨 512 字节。这不是异常,是内联的必然结果——step 与 clamp8 的函数体被复制进了调用点。
  3. 基线档的间接调用也很慢(152.92 ms),因为它和 LTO 档差了同一条内联(下面 §8 会解释为什么 LTO 之后间接调用还剩 109.69 ms)。

3.5 静态证据:那条 call 去哪了

读数只是"更快了",它回答不了"为什么更快"。要回答后一个问题,必须看汇编——只有汇编能告诉你那条调用指令到底还在不在。这里有一个实操前提:默认编出来的 EXE 不带符号表,dumpbin /disasm 只会打印一串裸地址,看不出哪条 call 指向谁。必须加 /Zi 编译、并用 /DEBUG 链接,把 COFF 符号表留在映像里,反汇编才会把调用目标解析成 ?step@@YAHH@Z 这样的名字。

有了能解析名字的反汇编,判据就变得非常机械:在 dumpbin /disasm 的输出里搜 call ?step@@YAHH@Z 这个串,出现几次就是几条。不需要看计时、不需要跑程序、不需要任何统计工具。

MSVC 反汇编对照:基线档对 ?step@@YAHH@Z 有 1 条 call,LTO 档归零
图 7-45 MSVC 反汇编对照:基线档对 ?step@@YAHH@Z 有 1 条 call,LTO 档归零@ 本机实测(dumpbin /disasm)
构建 反汇编里对 ?step@@YAHH@Z 的 call 指令
/O2 + /DEBUG 1 条
/GL + /LTCG + /DEBUG 0 条

这就是"收益从哪来"的第一层答案:那条调用指令消失了。计数是机械的——dumpbin /disasm 的输出里搜 call ?step@@YAHH@Z 这个串,出现几次就是几条。

3.6 用符号存活做交叉验证

读数会受机器状态影响,符号存活不会。所以每做完一组对比,本篇都做一次符号存活交叉验证——它把"更快了"这个感受,钉在"编译器具体做了什么"上:

构建 ?step@@YAHH@Z ?run@@ ?drive@@ 结论
/O2 基线 存在(1 条 call 指向它) 存在 存在 无跨 TU 内联
/GL + /LTCG 不存在 不存在(已内联) 不存在(已内联) 跨 TU 内联发生
/GL + /LTCG + PGO 不存在 存在 存在 见 §8.6 的反直觉

第三行值得先埋一个伏笔:PGO 档里那两个符号又回来了,而它更快。

这个验证为什么单独成节:本节的三行表格全部可以由一条命令生成(dumpbin /symbols 或 dumpbin /disasm 后搜符号名),不需要重跑程序、不需要计时的稳定性。当计时读数出现互相矛盾时(本篇 §6.4 那组 1% 的差异就是这种情形),符号与汇编层面的证据是最后的裁判。

3.7 完整实现外链

本篇用例的五个源文件(iface.h / a.cpp / b.cpp / c.cpp / main.cpp)与全部原始日志随篇留档,位于本篇素材目录的 evidence/ 下;两套工具链的完整编译/链接/运行记录见 evidence/logs_msvc/ 与 evidence/logs_gcc/。同一份源码在 GCC 侧的结果见 §六。


四、收益来源的判定实验

4.1 一个问题:LTO 的收益会不会来自别处

到这里我们可以说"LTO 让跨 TU 调用快了 7.2 倍,且那条 call 没了"。但严格地说,这还只证明了相关,没有排除别的可能:

  • 会不会是 /GL 改变了编译期的某些默认(比如打开了某个别的优化)?
  • 会不会是链接器顺带做了别的调整?

要排除这些,需要一个"只有 TU 边界被拿掉、其他一切不变"的对照组。

4.2 做法:把函数搬进同一个 TU,不开任何 LTO

先来看看最干净的做法是手工消除那条边界:把 step 的定义从 a.cpp 搬进 b.cpp(就在 run 上面),然后完全不开 LTO,只用 /O2 编一遍。

这时编译器在编 b.cpp 时能看见 step 的全部函数体——它拥有了 LTO 提供的唯一那一项好处,而 /GL、/LTCG、链接期代码生成这些一个都没开。

这组对照的分量在这里:它把"TU 边界是否消失"这一个变量单独拿了出来,同时把 LTO 引入的其它一切(链接期代码生成、跨模块的寄存器分配、链接器的额外遍历)全部排除在外。如果同 TU 无 LTO 的读数与 LTO 档落在同一量级,那么 LTO 的收益就只能是"边界被拆掉"这一件事带来的;反之,如果同 TU 无 LTO 明显更慢,就说明 LTO 里还有别的收益来源——那样本篇的结论就得改写。

这组对照的成本极低:代价只是把函数挪个文件,不改任何编译选项。

收益来源判定:同 TU 无 LTO 的 19.73 ms 与 LTO 档同量级
图 7-56 收益来源判定:同 TU 无 LTO 的 19.73 ms 与 LTO 档同量级@ 本机实测(MSVC 19.44)
配置 跨 TU 循环(中位)
/O2 基线(step 在另一个 TU) 153.65 ms
/GL + /LTCG(step 在另一个 TU) 21.39 ms
同 TU(step 搬进 b.cpp)、无任何 LTO 19.73 ms
同 TU + /GL + /LTCG 20.01 ms
收益来源判定:三条横条对比,后两者落在同一位置
图 7-67 收益来源判定:三条横条对比,后两者落在同一位置@ 本系列绘制

4.3 结论

同 TU 无 LTO 的 19.73 ms,落进了 LTO 档 21.39 ms 的同一量级(差异 8%,在计时抖动范围内)。

这组对照把话说死了:

  • LTO 在这段代码上的收益全部来自跨 TU 内联,没有第二个来源;
  • 而且内联之后的收益基本到顶了——同 TU 组再加 /GL + /LTCG(20.01 ms)没有任何进一步提升,因为已经没有边界可拆。

方法论备注:这条"手工消除边界"的对照,是本篇最有用的一件工具。它把"某个开关带来了多少收益"这个模糊问题,转成"把边界手工拿掉,能达到多少"这个可测问题。凡是怀疑某条优化"是不是真在起作用",都可以照这个方法做一遍——代价只是把函数挪个文件。

4.4 顺便排除一个误解

有人会说:"那我直接把小函数写进头文件里 inline 不就行了,何必 LTO?"

这句在代码量小时是对的,在代码量大时反而是错的,而且错得很具体:

  • 把函数放进头文件,意味着每个包含它的 TU 都要编译一遍。函数一多,编译期账本(这正是 ep04 的主题)会立刻恶化;
  • 头文件里的函数体一旦改动,所有包含者全部重编,增量构建直接失效;
  • 而 LTO 把这项成本挪到了链接期,且只在最终链接时付出一次。

inline 把代价前移到每个 TU,LTO 把代价后移到一次链接。 两者都在拆同一条边界,但付账的位置不同——这是选型时真正该比较的东西。


五、LTO 的代价与开关语义

5.1 代价清单:三笔账

拆边界不是免费的。值得逐笔算的三笔是:

代价 机制 本篇是否实测
体积 内联 = 函数体复制 实测(§5.2)
构建时间 链接期要重跑一遍全程序优化 实测(§5.2,含规模解释)
可调试性 优化跨过 TU 边界后,行号与栈帧的对应关系变复杂 未实测,只作说明(本机无源码级调试器接入场景)

5.2 体积与构建时间:两套工具链给出相反的体积方向

LTO 的两笔代价:体积方向相反、构建时间在玩具项目上几乎看不见
图 7-78 LTO 的两笔代价:体积方向相反、构建时间在玩具项目上几乎看不见@ 本机实测(MSVC 19.44 / g++ 16.2)
体积(exe)
  MSVC:13,312 B  → 13,824 B   (涨 512 B,+3.8%)
  GCC :239,133 B → 237,124 B  (降 2,009 B,−0.8%)

构建+链接一次(中位,3 次)
  MSVC:4.88 s → 4.92 s        (持平)
  GCC :1.85 s → 2.30 s        (+0.45 s,+24%)

言归正传,这三行值得逐个说:

  1. 体积方向相反:MSVC 涨、GCC 降。这不是谁对谁错——GCC 侧的体积下降主要来自 LTO 之后跨模块的死代码消除(?run@@ 与 ?drive@@ 都被内联,独立的函数体随之被消除,/OPT:REF 式的收益被放大),而 MSVC 侧的内联复制量压过了消除量。同一件事在两套工具链上可以有相反的体积后果——所以"开 LTO 会变小还是变大"这个问题,脱离具体工具链和具体代码是没法回答的。
  2. 构建时间在玩具项目上几乎看不见:MSVC 持平,GCC 涨 24%(绝对值 0.45 秒)。原因很直白:只有 4 个文件时,"全程序优化"无非是把 4 个文件重编一遍,而且 LTO 的优化质量恰恰让一部分代码变少、编得更快。
  3. 所以"LTO 构建时间暴涨"的报告都来自大项目。公开资料里的量级是:单块 LTO 让链接时间 +2 小时以上(LLVM 开发者会议 2022 的链路级报告)LLVM 开发者会议 2022(链接期代价)、移动端项目里单块 LTO 因"只能单线程处理合并后的模块"而给构建管线带来最多约 45 分钟开销移动端链接期优化论文。GCC 与 LLVM 都为此做了并行化:GCC 的 WHOPR 把调用图切成分区并行编译(§6.3),LLVM 的 ThinLTO 更进一步(§6.4)。

口径声明:本节的构建时间是在同一台机器、同一时段、连续三次取中位得到的,只能横向比较本节内的两组数字,不能与其他机器上的绝对值相比。更要紧的是:4 个文件的规模不足以暴露 LTO 的构建时间代价,所以本节把公开资料里的量级一并列出,避免读者拿玩具数字去推一个真实项目。

5.3 /LTCG 到底是不是开关:三个方向都有意外

好了,这是本篇第一个反直觉发现。官方文档说 /LTCG 由 /GL 隐含——"implied"。但"隐含"这个词掩盖了两件事,实测把三个方向都试了一遍:

/LTCG 开关语义的三个方向:不写也生效、:OFF 生效但静默
图 7-89 /LTCG 开关语义的三个方向:不写也生效、:OFF 生效但静默@ 本系列绘制
写法 实测跨 TU 读数 链接器输出
/GL + 不写 /LTCG 22.17 ms(LTO 生效) 无警告、无提示
/GL + /LTCG(显式) 21.39 ms(LTO 生效) 正在生成代码 / 已完成代码的生成
/GL + /LTCG:OFF 149.67 ms(退回基线) rc=0,零输出

三个结论:

  • /LTCG 不是"开"的必要条件。不写它,链接器自己发现目标文件里有 IL,LTO 照常发生(22.17 ms 与显式的 21.39 ms 同量级)。官方文档说未显式指定时链接器会"自动检测并重启链接"MSVC /LTCG 文档——那个重启是你看得见的构建时间,看不见的才是它已经替你做完了 LTO。
  • /LTCG:OFF 是有效的"关"。加上它,读数退回基线(149.67 ms),体积也退回 13,312 B。"我以为我关了 LTO"这件事是可以做到的——但你必须显式写 :OFF。
  • 关闭是静默的。那一次链接 rc=0、终端零输出——没有警告、没有提示、没有任何一行告诉你"IL 被当作普通目标文件处理了"。而官方文档对 /LTCG:OFF 的语义说得很清楚:"把用 /GL 编译的模块当作没用该选项编译"— 只是它不打算提醒你。

这就是本篇想说的第一类"安静的失效":一个决定被做掉了,页面上什么都没有。如果构建系统里有人为了排查某个问题临时加了 /LTCG:OFF,而后来忘了删,你会得到一个看起来完全正常、只是慢了 7 倍的二进制。

5.4 -flto 侧对应的口径

GCC 这边没有 /LTCG:OFF 这种"半开"状态。-flto 给在编译与链接两侧,LTO 就发生;不给就不发生。但 GCC 有一个方向相反的隐式开关值得记住:-ffat-lto-objects 会让目标文件同时保存 GIMPLE 与机器码GCC Internals · LTO Overview。官方文档指出它的副作用很危险:"工具链里任何一处失误都会导致 LTO 信息不被使用,而用户不会被告知优化已被禁用"。fat 模式提高兼容性的代价,正是又一种静默失效。

5.5 一个容易被忽略的口径:/O2 与 /GL 的叠加关系

上面所有对照都写着 /O2 /GL,这里说清两者的关系,否则容易被误读成"LTO 取代了 /O2":

  • /O2 管的是单个 TU 内部的优化强度(内联预算、循环变换、向量化、寄存器分配);
  • /GL 管的是可见性边界(把 IR 留到链接期,让跨 TU 的优化有可能发生);
  • 链接期的 LTCG 不是从零开始优化,它是在各模块已有的优化结果之上,再做一轮跨模块的决策。

所以两者是叠加关系,不是替代关系。官方 /LTCG 页也明确把跨模块内联、过程间寄存器分配(仅 64 位)这些能力挂在"用 /GL 并按 /Og /O1 /O2 /Ox 编译"这个前提上——没有优化级别,/GL 本身不产生优化。

这也解释了一个常见困惑:"我加了 /GL /LTCG,为什么没变快?"——如果代码里本来就没有跨 TU 的小函数调用,LTO 就没有可拆的边界,收益自然接近 0。收益的前提是"被边界挡住的东西确实存在",这一点和 §8.5 里"PGO 对跨 TU 调用差 0.07%"是同一枚硬币的两面。

5.6 官方口径备查

官方口径:/GL 使 PGO 可用、/LTCG 由 /GL 隐含、/GENPROFILE 须配 /LTCG
图 7-910 官方口径:/GL 使 PGO 可用、/LTCG 由 /GL 隐含、/GENPROFILE 须配 /LTCG@ Microsoft Learn 原文摘录

三条需要在文中写准的官方表述(原文摘录):

  • /GL 页:"/GL also makes profile guided optimization available."(/GL 同时使 PGO 可用)MSVC /GL 文档
  • /LTCG 页:"/LTCG is implied with /GL.",且说明链接器一旦收到 /GL(或 MSIL)模块即启用 LTCG;未显式写时会自动检测并重启链接,显式指定只为拿到最快的构建性能MSVC /LTCG 文档。
  • /GENPROFILE 页:"Always specify /LTCG when you specify /GENPROFILE or /FASTGENPROFILE."MSVC /GENPROFILE 文档

六、GCC 侧:-flto、WHOPR 与两处实测

6.1 同一份源码,GCC 给出的四档读数

GCC 四档读数:两列数字各自归属一个开关
图 7-1011 GCC 四档读数:两列数字各自归属一个开关@ 本机实测(g++ 16.2.0 · MSYS2 ucrt64)
配置              exe(B)    cross_tu 中位   indirect 中位
-O2 基线          239,133    112.52 ms       43.66 ms
-O2 -flto         237,124     23.80 ms       10.76 ms
-O2 PGO           239,253    112.44 ms       10.45 ms
-O2 PGO + LTO     237,165     21.83 ms       10.74 ms

下面进入同一份四文件用例,GCC 这边一次把四档都编了出来。这张表比 MSVC 那张更有用,因为它把四个组合放在一行里对比——只看 MSVC 的数据,你很难判断"跨 TU 变快"和"间接调用变快"究竟是不是同一个开关带来的;而 GCC 这张表里,两列数字自己就把答案写出来了。

这张表是本篇的结构骨干,它把两件事分得干干净净:

  • -flto 把跨 TU 调用从 112.52 压到 23.80 ms(4.7 倍),但间接调用只从 43.66 降到 10.76 ms——降是降了,但那是顺带的;
  • PGO 把间接调用从 43.66 压到 10.45 ms(4.2 倍),但跨 TU 调用几乎一动不动(112.52 → 112.44 ms,差 0.08 ms);
  • 两者都开,两列才都拿下(21.83 / 10.74 ms)。

这里有一个容易被跳过的细节:-flto 那一档的间接调用也确实从 43.66 降到了 10.76 ms,看起来"LTO 也优化了间接调用"。但这不是 LTO 在直接优化间接调用,而是跨 TU 内联的连带结果——drive 被内联进 main 之后,对象的具体类型(main 里那个栈上的 AddOp)在调用点变得可见,编译器于是顺手做了去虚拟化。收益的来源仍然是"可见性",不是"LTO 这个开关本身"。 这也是为什么下面 §8.3 里要看汇编:只有看汇编,才能把"顺带"和"直接"分开。

6.2 内联决策的原文证据

GCC 有一个 MSVC 没有的好东西:-fopt-info 系列可以直接把优化决策打成文本。要看跨 TU 内联,用 -fopt-info-inline-optimized。

这里有一个实操要点:LTO 阶段的决策发生在链接期,所以这个选项必须给在链接那一步。只给在 -c 上,你只会看到 TU 内部的内联(比如 clamp8 进 step),看不到跨模块的那两条。

GCC 内联决策原文:两条带 (cross module) 标记的内联记录
图 7-1112 GCC 内联决策原文:两条带 (cross module) 标记的内联记录@ 本机实测(-fopt-info-inline-optimized)
a.cpp:3:32: optimized:  Inlining clamp8(int)/1 into step(int)/2.
optimized:  Inlined step/1 into run.constprop/56 which now has time 109.181819
            and size 16, net change of -6 (cross module).
optimized:  Inlined run.constprop/56 into main/35 which now has time 20205.004364
            and size 78, net change of -7 (cross module).

三条记录,两个层次:

  1. 第一条是 TU 内联(clamp8 进 step)——不带 (cross module),基线档也会有;
  2. 后两条才是 LTO 的功劳,GCC 自己打了 (cross module) 标记。注意它连内联的估值都写出来了:把 step 内联进 run 后 -6(净减 6 个单位),把 run 内联进 main 后 -7。收益为正(为负值表示代码变小),所以编译器认为值得做。
  3. 顺带一提:run.constprop/56 这个名字里的 constprop 是"常量传播"(constant propagation)的缩写——说明 run 在被内联前已经被复制成了一份针对特定调用点的特化版本。跨 TU 内联往往不是简单搬运,而是"先特化、再内联"。

6.3 WHOPR:大项目为什么还能用 LTO

GCC 的 LTO 有一个和 MSVC 完全不同的设计:它不把全部 IR 合成一个巨型模块,而是分三段(GCC Internals 的 LTO Overview):

  1. LGEN(Local GENeration,本地生成):每个 TU 编译时产出 GIMPLE 字节码,放进目标文件的特殊节里;
  2. WPA(Whole Program Analysis,全程序分析):串行。生成全局调用图,做全局分析决策,并把调用图切成分区——切分目标是"最大化并行、最小化跨分区引用";
  3. LTRANS(Local TRANSformations,本地变换):并行。每个分区独立优化并生成最终汇编。

三段的分工出自 GCC Internals 的 LTO OverviewGCC Internals · LTO Overview 与 WHOPR 页(该页同时解释了链接器插件与 collect2 的分工:带 LTO 节的目标文件会先交给 lto1 处理,再进链接器)GCC Internals · WHOPR。

WHOPR 三阶段:LGEN 本地生成 → WPA 全程序分析(串行)→ LTRANS 本地变换(并行)
图 7-1213 WHOPR 三阶段:LGEN 本地生成 → WPA 全程序分析(串行)→ LTRANS 本地变换(并行)@ 本系列绘制

为什么这个设计重要:它把"全程序优化"里唯一无法并行的部分(全局分析)压到最薄,剩下的重活全部下推到可并行的 LTRANS。GCC 4.6 起 WHOPR 稳定下来并成为 LTO 的默认模式,支持 -flto=n 控制并行编译数GCC 4.6 发行说明;GCC 10 起 LTO 的并行阶段还能自动识别 make 的 jobserver,或回落到可用核心数(即 -flto=auto / -flto=jobserver 的来源)GCC 10 发行说明。

对照一下 LLVM 的两种模式(只引官方原文,本机无 Clang,不做实测):

模式 做法 官方给的性质
Full LTO(-flto=full) 全部 bitcode 合并成单一 Module,做串行的 IPA/IPO 时间与内存不可扩展
ThinLTO(-flto=thin) 编译期产出模块摘要;链接期只合并摘要为 combined summary index,在其上做快速全程序分析;函数导入与优化下推到各后端并行Clang ThinLTO 文档 构建时间非常接近非 LTO 构建,且可增量、可缓存LLVM 项目博客(ThinLTO)
Full LTO 与 ThinLTO 的结构对照(只引官方原文)
图 7-1314 Full LTO 与 ThinLTO 的结构对照(只引官方原文)@ 本系列绘制

6.4 三套工具链的 profile 数据结构对照

把三家的 profile 产物放在一起看,能看出设计取向的差别——这也解释了为什么"坑"的形状各家不同:

MSVC GCC Clang/LLVM
单次运行计数 app!N.pgc <out>-<src>.gcda *.profraw
中间/数据库层 app.pgd(显式) 无(累加进 .gcda) .profdata(显式,须 merge)
多次训练怎么合 pgomgr /merge[:权重] 再次运行即累加 llvm-profdata merge
命名绑定的是什么 输出映像名(app) 输出名 + 源文件名 生成时的输出名
常见"静默失效" /PGD 指错文件 → LNK1266(这个会报错) 改了 -o → 只给警告、rc=0 忘记 merge → 直接没有可用数据

从这张表能读出一条设计规律:MSVC 与 LLVM 都有显式的中间层(.pgd / .profdata),而 GCC 把累加直接写进 .gcda。中间层带来两个后果——多一次"合并"的操作步骤(所以 Clang 那边"忘记 merge"是高频坑),换来了一个可以"加权"的地方(所以只有 MSVC 有 pgomgr /merge:6 这种写法)。

而 GCC 没有中间层、命名又绑了源文件名与输出名,于是"改个输出名"就能让整份数据静默失配(§7.4)——这是同一张表上唯一一个连警告都很轻的失效路径。

6.5 一处"流传很广但实测不成立"的规则

说白了,GCC 生态里有一条几乎人人都会背的规则:

用 LTO 编静态库时,必须用 gcc-ar / gcc-ranlib 打包,用普通 ar 会让 LTO 静默失效。

这条的本意有官方依据:gcc-ar 的 man 页写得很明确——它是 ar 的包装,自动加上 --plugin,好让 ar/nm/ranlib 看得懂 LTO 目标文件的符号表gcc-ranlib man 页;GCC 邮件列表里也有相应的讨论,结论是 LTO 静态库要把 lto_plugin.so 交给 ar/nm/ranlibGCC 邮件列表(链接器插件)。

但实测没有复现:

静态库打包方式对照:普通 ar 与 gcc-ar 体积相同、时间差 1%
图 7-1415 静态库打包方式对照:普通 ar 与 gcc-ar 体积相同、时间差 1%@ 本机实测(g++ 16.2 + binutils)
LTO 目标文件 + 普通 ar   :exe 237,124 B | cross_tu 27.91 ms | indirect 11.56 ms
LTO 目标文件 + gcc-ar    :exe 237,124 B | cross_tu 28.20 ms | indirect 10.65 ms

两者体积完全相同,时间差异 1%,在抖动范围内;且都与直接 -flto 链接的 23.80 ms 同量级——说明 LTO 在这两种打包方式下都生效了。

为什么会这样:--plugin 的作用是让归档工具"看得懂"插件,而现代 binutils 的链接器可以自己找到并加载 liblto_plugin。那条规则的适用条件是"链接器找不到插件"的老环境,它被当成普适规则流传下来,但至少在本机这套 binutils 上不成立。

这条要如实写成"实测不成立",而不是照抄网上的结论。 本篇把一个"大家都这么说"的规则单独拿出来测一遍,正是为了说明:构建工具的规则会随版本失效,而失效的方式往往是"你还照着老规矩做,它也没坏"——这比报错更难发现。


七、PGO 的工程链路与四个坑

7.1 三步:插桩 → 训练 → 使用

先来看看PGO 不是编译期的一个开关,而是一条三阶段的流水线,中间夹着"跑一次真实程序":

步 MSVC GCC 产物
① 插桩构建 /GL + /GENPROFILE 编译,/LTCG /GENPROFILE 链接MSVC /GENPROFILE 文档 -fprofile-generateGCC 插桩选项手册 MSVC:插桩 exe + .pgd;GCC:插桩 exe
② 训练 运行插桩 exe 运行插桩 exe MSVC:app!N.pgc;GCC:*.gcda(每 TU 一个)
③ 使用 /GL + /USEPROFILE 编译,/LTCG /USEPROFILE 链接 -fprofile-use 优化后的二进制
PGO 三步流水线:插桩构建 → 训练 → 带 profile 重建
图 7-1516 PGO 三步流水线:插桩构建 → 训练 → 带 profile 重建@ 本系列绘制

这张图里最该被注意的是中间那一步。前面所有篇章(ep01–ep06)讲的都是"同一份源码 → 不同开关 → 不同二进制"的单向流程;PGO 第一次在流程中间插入了一个必须在部署环境里跑起来的程序。这一步带来了本篇其余所有风险。

7.2 坑一:插桩二进制在裸环境里根本跑不起来

先来看看第一个坑在第一步就出现,而且它伪装成"程序没反应"。

用 /GENPROFILE 生成的插桩 exe,如果在一个没有 Visual Studio 工具目录的 PATH 的环境里直接运行,它会不打印任何输出、直接退出。退出码是 -1073741515,换成十六进制是 0xC0000135,也就是 Windows 的 STATUS_DLL_NOT_FOUND。

坑一:插桩 exe 裸跑退出码 0xC0000135(STATUS_DLL_NOT_FOUND)
图 7-1617 坑一:插桩 exe 裸跑退出码 0xC0000135(STATUS_DLL_NOT_FOUND)@ 本机实测(MSVC 19.44)

原因:插桩构建多了一条运行时依赖——PGO 插桩运行时 pgort140.dll(在 ...\VC\Tools\MSVC\<版本>\bin\Hostx64\x64\ 下)。这个依赖不是编译期就会提醒你的东西:编译成功、链接成功、产物存在,只有运行时才会炸,而且炸得毫无提示。

修法很简单:训练必须在开发者命令提示符(vcvars64.bat 初始化过 PATH)里跑。但要记住这个坑的形态——"程序没有任何输出" 和 "程序没有跑起来" 在终端上长得一模一样,所以排查时第一件事是看退出码。

7.3 坑二:/PGD 要指 .pgd,不是 .pgc

下面开始看这一步的产物。第二步训练完之后,目录里会出现两个文件,体积差了将近 200 倍:

坑二:.pgd 与 .pgc 体积差 196 倍,/PGD 要指 .pgd
图 7-1718 坑二:.pgd 与 .pgc 体积差 196 倍,/PGD 要指 .pgd@ 本机实测(MSVC 19.44)
msvc_pgogen!1.pgc     5,648 B      ← 执行计数
msvc_pgogen.pgd   1,110,016 B      ← profile 数据库
  • .pgc 是这一次运行的计数(名字里的 !1 是运行序号,可以有多份);
  • .pgd 是数据库,把多份 .pgc 合并在一起。

第三步链接时,如果按直觉写 /PGD:msvc_pgogen.pgc,会直接报错:

LINK : fatal error LNK1266: 读取检测文件"msvc_pgogen.pgc"时出错;未找到文件
LNK1266 报错原文:/PGD 指向 .pgc 而非 .pgd
图 7-1819 LNK1266 报错原文:/PGD 指向 .pgc 而非 .pgd@ 本机实测(MSVC link 14.44)

/PGD: 指的是那个数据库(.pgd),不是计数文件(.pgc)。 这条在官方文档里说得不算清楚(/PGD 页面已被标注为自 VS2015 起废弃、建议改用 /GENPROFILE),实操上就是一次 LNK1266。

多份计数怎么合并:如果一次训练覆盖不到所有场景(比如"启动路径"和"高负载路径"要分两次跑),MSVC 用 pgomgr /merge[:权重],官方示例是 pgomgr /merge:6 a.pgc b.pgd——可以给每份计数加权。GCC 侧对应的分工是:-fprofile-generate 跑多次(默认是累加到同一批 .gcda 上),Clang 侧则必须显式 llvm-profdata merge 把 .profraw 合成 .profdata。

顺带一句诚实的边界:GCC 的多次训练是累加语义,所以"权重"要靠多跑几次来调;Clang 的 merge 是显式语义。MSVC 是三者里唯一提供"加权合并"开关的——因为它有 .pgd 这个显式的数据库层。

7.4 坑三:GCC 的 .gcda 名字跟着 -o 走

第三个坑最阴,因为它不会让任何一步失败。

说白了,GCC 的 .gcda 文件命名规则是 <输出名>-<源文件>.gcda。所以:

  • 训练时用 -o g_pgogen.exe 构建插桩版 → 生成 g_pgogen-a.gcda、g_pgogen-b.gcda…
  • 使用 profile 时如果习惯性写成 -o g_pgo.exe → GCC 去找 g_pgo-*.gcda,找不到。

找不到会怎样?它给一条警告:

a.cpp:3:45: warning: '...\g_pgo-a.gcda' profile count data file not found [-Wmissing-profile]

...然后rc=0、链接照常成功。

坑三:GCC 的 .gcda 名字跟着 -o 走,改输出名即整份失配
图 7-1920 坑三:GCC 的 .gcda 名字跟着 -o 走,改输出名即整份失配@ 本机实测(g++ 16.2)

这就是本篇想说的第二类"安静的失效",而且比第一类更隐蔽:/LTCG:OFF 至少需要你写一个显式选项,而这个坑只需要你改了输出文件名——一个在构建脚本里再平常不过的动作。

这个坑真实地污染过本篇的第一轮数据。第一轮的 "PGO 组"就是这样一个"名义 PGO、实际无 profile"的构建,它的间接调用读数是 115.34 ms(详见 §9.3)——比基线还慢 2.5 倍。数据看起来"有变化、有差异、像是 PGO 起了作用",只是作用方向是反的。发现它靠的不是读数,而是回头核对 .gcda 的实际文件名。

可操作的经验:PGO 的构建脚本里,训练产物名与使用阶段名必须由同一个变量生成,不允许手写两遍。这不是洁癖——本节的 196 倍体积差、0xC0000135、LNK1266 三个坑都只是"麻烦",只有这一个会悄悄给你一个错的二进制。

7.5 坑四:训练数据必须代表真实负载

好了,第四个坑是语义层面的,没有工具能替你检查。

PGO 的全部结论都建立在"训练时跑的就是真实负载"这个假设上。如果训练跑的是 AddOp、生产里跑的是 XorOp,那么编译器为 AddOp 精心准备的守卫快路径每次都会失败,每次都跳到冷路径——比不做优化还差(多了一次比较和一次跳转)。

Linux 内核的 AutoFDO 文档措辞很直白:训练负载必须"具有代表性",否则会朝错误的目标优化。微软的采样 PGO 文档给了一条可操作的监控指标:"被 profile 优化的函数比例若显著下降(低于 90%),就该重新采集"——注意它监控的是"用了多少 profile",不是"profile 对不对"。

这一点会在 §九 展开:本篇实测到,两套编译器都不会告诉你 profile 已经过期。所以"训练数据保鲜"这件事,最终只能靠构建流程里的人来管。


八、PGO 的收益:把间接调用消掉

8.1 官方定义:虚拟调用推测

先来看看先看官方怎么定义这件事。MSVC 的 PGO 总览页写得很清楚:

Virtual Call Speculation:"If a virtual call, or other call through a function pointer, frequently targets a certain function, a profile-guided optimization can insert a conditionally executed direct call to the frequently targeted function, and the direct call can be inlined."

翻译过来就是三步:① 如果某个虚调用/函数指针调用频繁指向同一个函数 → ② 插入一条"条件执行的直接调用" → ③ 这条直接调用还可以被内联。

"条件执行 + 可内联"这七个字就是本节的判据,而且它是可以直接在反汇编里核对的:如果编译器真的做了这件事,那么热路径上应当出现一对"比较 + 条件跳转"的守卫,且命中分支上没有任何 call。§2.3 立的两条判据正好对上这一形态。

反过来说,凡是"命中分支上仍有一条 call"的形态,都不算虚拟调用推测真正落地——哪怕它前面已经加了一串比较。这一点很重要,因为这两种形态在源码层面完全看不出区别,计时读数也可能都"变快了";只有数命中路径上的 call,才能把两者分开。下一节的两段汇编就是这两种形态的实例。

官方定义:Virtual Call Speculation 与 -fprofile-values 的原文
图 7-2021 官方定义:Virtual Call Speculation 与 -fprofile-values 的原文@ Microsoft Learn + GCC 10 发行说明

GCC 侧对应的数据来源是间接调用值剖析。GCC 10 发行说明原文:"Using -fprofile-values, an instrumented binary can track multiple values (up to 4) for e.g. indirect calls and provide more precise profile information."——最多记 4 个目标值。这句解释了 GCC 为什么能做到下面 §8.3 那个"只比一个目标"的形态。

8.2 MSVC:两段汇编的对照

好了,LTO 版(有 /GL、无 profile):main 有 241 行,?run@@ 与 ?drive@@ 都已被内联进去(两个符号在反汇编里都不存在了)。而那个虚调用原封不动——它是一条真正的间接调用:

; MSVC /GL + /LTCG,main 里被内联进来的 drive 循环
mov  rax,qword ptr [rax]      ; 取 vptr
call qword ptr [rax]          ; ★ 间接调用:目标要到运行时才知道

于是中间接循环停在 109.69 ms。

MSVC LTO 版反汇编:drive 已被内联,虚拟调用仍是一条间接 call
图 7-2122 MSVC LTO 版反汇编:drive 已被内联,虚拟调用仍是一条间接 call@ 本机实测(dumpbin /disasm)

PGO 版(/GENPROFILE → 训练 → /USEPROFILE):main 缩到 123 行,?run@@ 与 ?drive@@ 重新成为独立函数。但 ?drive@@ 的循环体里,call 归零:

; MSVC LTO + PGO,?drive@@ 的循环体
lea  rbp,[?apply@AddOp@@UEBAHH@Z]     ; 【1】训练期唯一目标的地址
loop:
  mov  rax,qword ptr [rdi]            ; 取 vptr
  add  edx,dword ptr [rsi+rbx*4]      ; s += p[i]
  mov  r8,qword ptr [rax]             ; 取 vtable[0] = apply 的实际地址
  cmp  r8,rbp                         ; 【2】与训练期唯一目标比较
  jne  00000001400023FC               ; 【3】不等 → 冷路径(被外提到函数尾)
  inc  edx                            ; 【4】命中 → 直接算 AddOp::apply 的 x+1

同一处改成 53.47 ms(2.9 倍)。

逐条读:

  • 【1】 编译器在循环外就把 AddOp::apply 的地址装进了一个寄存器——这是"训练期唯一目标"这个信息的落点;
  • 【2】【3】 守卫:把 vtable 槽位与那个已知地址比较,不等就跳走。注意 jne 的目标 0x1400023FC 远在函数尾——通用间接调用被移出了热循环,这就是"冷路径外提";
  • 【4】 命中路径上只有一条 inc(x+1)。没有 call——AddOp::apply 被内联了。
MSVC PGO 版反汇编:drive 循环体里 call 归零(守卫 + 内联)
图 7-2223 MSVC PGO 版反汇编:drive 循环体里 call 归零(守卫 + 内联)@ 本机实测(dumpbin /disasm)

8.3 GCC:基线本来就会去虚拟化,PGO 把命中路径的 call 也去掉

先来看看GCC 这边有一个额外发现,而且它推翻了"只有 PGO 才能去虚拟化"这个印象。

基线 -O2 版的 drive() 就已经在做推测性去虚拟化了——而且做得更"贪":它把三个已知子类的 apply 地址全部预载,循环里逐个比较:

; GCC -O2 基线,drive() 循环体
lea  -0x35(%rip),%rbp        # XorOp::apply(int) const    ┐
lea  -0x52(%rip),%r13        # MulOp::apply(int) const    ├ 【1】三个目标全枚举
lea  -0x69(%rip),%rdi        # AddOp::apply(int) const    ┘
loop:
  mov  (%rsi),%rdx           ; 取 vptr
  add  (%rbx),%eax
  mov  (%rdx),%r8            ; 取 vtable[0]
  cmp  %rbp,%r8              ; 【2】逐个比较
  je   <直接调用点>
  cmp  %r13,%r8
  je   <直接调用点>
  ...
  call *%r8                  ; 【3】命中路径上仍是一条间接 call

这一版的间接循环是 43.66 ms。

为什么基线也敢这么干:因为 Op 的子类在程序里是封闭的——c.cpp 里只有三个实现,编译器在全程序视角下知道"不可能有第四个"。这不是 profile 信息,是类型可见性信息。 所以这一层去虚拟化是 -O2 就能拿到的。

但它只做到"变窄",没做到"消掉":命中路径上【3】那条 call *%r8 还在——它只是从"随便哪个 apply"变成了"三个之一",仍然是间接调用。

PGO 版换成"只比一个目标 + 命中即内联":

; GCC -O2 + PGO,drive() 循环体
lea  r12,[AddOp::apply(int) const]   ; 【1】只预载训练期唯一目标
loop:
  mov  (%rbp),%rax           ; 取 vptr
  add  -0x4(%rdi,%rsi,4),%ecx
  mov  (%rax),%rax           ; 取 vtable[0]
  cmp  %r12,%rax             ; 【2】只比一个
  jne  140007f3e             ; 【3】不等 → 冷路径
  add  $0x1,%ecx             ; 【4】命中 → 内联 AddOp::apply 的 x+1,命中路径上再无 call

这一版落到 10.45 ms(4.2 倍)。

GCC PGO 版反汇编:只比一个目标、命中即内联
图 7-2324 GCC PGO 版反汇编:只比一个目标、命中即内联@ 本机实测(objdump -d)

8.4 判据图:什么叫"变窄",什么叫"消掉"

间接调用的两个层级:变窄(仍是 call)与消掉(无 call)
图 7-2425 间接调用的两个层级:变窄(仍是 call)与消掉(无 call)@ 本系列绘制

上面两段汇编放在一起,能看出一个此前没有被点破的层次差别:GCC 的基线档与 PGO 档都做了去虚拟化,都有守卫比较,但它们的收益差了 4.2 倍。这说明"有没有去虚拟化"这个二分法太粗——真正决定性能的是命中路径上还剩什么。把这件事拆成两个层级来看,就得到了下面这张判据表。

形态 守卫 命中路径 谁是它的来源
无优化 无 间接 call —
变窄(枚举已知类型) 逐个 cmp 三个目标 仍是间接 call 类型可见性(-O2 就有)
消掉(单目标 + 内联) 一个 cmp 无 call,直接算 profile 数据(PGO)

这张表就是本篇的核心结论之一:-O2 能把间接调用"变窄",只有 profile 能让它"消失"。两者的时间差距(43.66 → 10.45 ms)就摆在这里。

把这张表当成一条通用判据来用:任何时候你想确认"某条去虚拟化优化到底做没做到位",就去反汇编里找热路径,数一数命中分支上的 call 是 0 还是 1。是 0,说明目标函数已经被内联进来;是 1,说明它只是被收窄了范围、仍然要付出一次间接跳转的代价——包括分支预测那一层的代价。

8.5 两套工具链的 PGO 对照

两套工具链的 PGO 对照:GCC 的 PGO 对跨 TU 调用差 0.07%
图 7-2526 两套工具链的 PGO 对照:GCC 的 PGO 对跨 TU 调用差 0.07%@ 本机实测(MSVC 19.44 / g++ 16.2)
指标 MSVC GCC
间接调用:基线 → PGO 152.92 → 53.47 ms(2.9×) 43.66 → 10.45 ms(4.2×)
跨 TU 调用:基线 → PGO 153.65 → 21.11 ms(顺带,因为 PGO 档同时开了 /GL) 112.52 → 112.44 ms(几乎不变)
命中路径上还有 call 吗 没有 没有
守卫比较几个目标 1 个 1 个(基线是 3 个)

第二行是这张表里最该盯着的一行:GCC 的 PGO 对跨 TU 调用毫无帮助(差 0.08 ms,即 0.07%)。这一条把 §1.3 的论点从"讲道理"变成了实测:PGO 拆的是类型与分支边界,它对 TU 边界无能为力。 想同时拿到两边,就得 PGO + LTO 一起开(§6.1 的第四档:21.83 / 10.74 ms)。

8.6 一个反直觉:PGO 会"少内联"

再看一眼 MSVC 那组数字:main 从 241 行缩到 123 行,?run@@ 与 ?drive@@ 两个符号从"消失"变回"存在"。

开了 PGO 之后,LTO 反而没有把这两个函数内联进 main。而 PGO 版更快(间接调用 53.47 vs 109.69 ms)。

这件事初看很怪,但理由很干净:

  • LTO 版为什么内联?因为没有别的办法——它只能靠"把 drive 搬进 main、让对象的具体类型在调用点可见"来提速。这是一条粗放但有效的路。
  • PGO 版为什么可以不内联?因为它有更精准的武器:它直接把 drive 内部的间接调用消掉了。这个收益比"把 drive 内联进 main"更大,而且不必付出代码复制的代价——main 因此反而更小。

结论:内联数量不是优化质量的度量。这两个构建给出了同一个提醒——看内联次数(或看 main 有多少行)来判断优化做得好不好,是会看错的。要看的是热路径上还有没有该消失的指令。

8.7 为什么"变窄"和"消掉"能差 4 倍

GCC 那两组数字(43.66 → 10.45 ms)差 4.2 倍,而两者都做了去虚拟化、都有守卫比较。差距从哪来?把两段循环体的指令数摆在一起就清楚了:

基线(变窄)循环一次迭代要做:
  取 vptr → 取槽位 → cmp(目标1)→ [不中] cmp(目标2)→ [不中] cmp(目标3)
  → call *%r8              ← 命中后是一次间接调用

PGO(消掉)循环一次迭代要做:
  取 vptr → 取槽位 → cmp(唯一目标)→ [中] add $0x1
                          ↑ 命中路径上没有 call

三个来源,按贡献排序:

  1. 命中路径少一条 call(最主要)。间接调用不只是几条指令的问题——它影响分支预测与返回地址栈。用守卫把它换成"比较 + 条件跳转"后,预测器面对的是一个规则到不能再规则的形状。
  2. 守卫从三个比较变成一个。基线要为每个已知子类各准备一条比较与跳转;PGO 只准备一条。这在每次迭代都要付的循环里,是实打实的指令数减少。
  3. 冷路径被外提。通用间接调用(以及为它准备的寄存器保存/恢复)被移出了热循环,只在守卫失败时才进入。

这里有一个可迁移的判据:当两种优化都能"去掉间接调用"的时候,看命中路径上还剩不剩 call——剩着,就是"变窄";不剩,才是"消掉"。这条判据不依赖任何文档,只需要一次反汇编。

8.8 小结:收益从哪来

把这一章收成一句话:

  • LTO 把间接调用变窄(因为类型可见了),也把跨 TU 调用变快(因为函数体可见了);
  • PGO 把间接调用消掉(因为知道谁是主目标),并把冷路径外提出热循环。

两者的收益位不同、证据形态相同(都是反汇编里少了一条 call),所以可以分别量、可以叠加——这就是 §6.1 那张四档表存在的意义。


九、PGO 的风险:过期 profile 没人告诉你

9.1 一个问题:profile 过期了,编译器知道吗

言归正传,PGO 的 profile 文件是一份和源码绑定的产物——它记的是"某个函数的某个分支被走了多少次"。一旦源码改动(哪怕只是改了热路径里一个运算符),旧 profile 描述的就不再是这份代码了。

常识会让人以为:编译器手里同时有源码和 profile,一旦对不上,它应该报错。

实测结果:两套编译器都不报。

9.2 MSVC:不但不报过期,还告诉你"100% 命中"

实验做法:/GENPROFILE 插桩 → 训练 → 修改 drive() 的热路径(把 s + p[i] 改成 s ^ p[i],热行为完全变了)→ 不重新训练,直接 /USEPROFILE 重链。

链接器的输出是:

正在生成代码
86 of 86 (100.00%) profiled functions will be compiled for speed
86 of 86 functions (100.0%) were optimized using profile data
2566369444 of 2566369444 instructions (100.0%) were optimized using profile data
已完成代码的生成
MSVC 用过时 profile 链接:仍宣称 86/86、100.0% 命中
图 7-2627 MSVC 用过时 profile 链接:仍宣称 86/86、100.0% 命中@ 本机实测(MSVC link 14.44)

"100% 命中" 与 "profile 已经过期" 同时成立。 顺带一提,这几行统计本来就是 PGO 流程里唯一会主动开口的地方;它开口说的却是覆盖率。

这句要反复看:86 of 86 与 100.0% 度量的是"你给的数据有多少被用上了",而不是"你给的数据还对不对"。这两件事在统计表上长得一模一样。 一个团队如果只看这几行输出,会得到"PGO 工作正常、覆盖率 100%"的结论——而底层代码已经和 profile 脱节了。

9.3 GCC:没有数据时,它反而更慢

GCC 这边的问题形态不同,但同样会给你一个看起来像"PGO 生效了"的结果。

GCC 的 .gcda 缺失(§7.4 那个命名坑)时,-O2 -fprofile-use 的表现是:

rc = 0
warning: '...\m_nodata-a.gcda' profile count data file not found [-Wmissing-profile]   (4 条,每 TU 一条)

体积:239,133 B   ← 与纯 -O2 完全相同
indirect 中位:44.05 ms  →  111.53 ms   ← 慢 2.5 倍
GCC 无数据 -fprofile-use:体积不变,间接调用慢 2.5 倍
图 7-2728 GCC 无数据 -fprofile-use:体积不变,间接调用慢 2.5 倍@ 本机实测(g++ 16.2)

这一组数字很值得琢磨:

  • 体积一模一样:说明二进制层面看不出任何异常;
  • 跨 TU 时间也正常:说明它不是在"什么都没做";
  • 只有间接调用这一处慢了 2.5 倍。

原因在 -fprofile-use 的语义里:GCC 手册对它的描述是"启用 profile 反馈驱动的优化,以及下列只有在有 profile 反馈时才划算的优化",后面跟着一长串——-fbranch-probabilities、-fvpt、-funroll-loops、-fpeel-loops、-ftracer、-ftree-loop-distribute-patternsGCC 优化选项手册、以及一组 -fipa-* 的过程间优化(含 -fipa-cp-clone)GCC 警告选项手册(14.x)。

关键点:这一串是被 -fprofile-use 这个选项打开的,不是被 profile 数据打开的。 数据没了,选项还在,于是那些"只有在有数据时才划算"的优化在没数据的情况下也照做——它们依据的是编译器的静态估算,而静态估算在间接调用这里恰恰最不准。

这就是第三种"安静的失效",也是最难受的一种:它慢,但慢得不明显(只有一处循环,2.5 倍);它报了警,但只是 4 条 -Wmissing-profile,混在成百上千行构建日志里;它的体积和大部分读数都完全正常,只有逐项对比读数才看得出来。

9.4 手册与实测的一处出入(如实记录)

这里必须交代一处不一致。

GCC 手册对 -Wcoverage-mismatch 的原文是:"By default, this warning is enabled and is treated as an error"——默认开启,且默认当成 error;语义是"profile 存在但与源码匹配不上"GCC 警告选项手册。按这条,§9.3 之外的"真·过期"场景应当报错。

但实测不是这样:同名 -o 重编、源码已改(改函数体、甚至改头文件加新声明),GCC 16.2 rc=0、完全静默,一条警告都没有。

可能的解释是:GCC 在发现某个函数的校验和对不上时,会静默丢弃该函数的 profile 数据(只保留能对上的部分),于是"部分过期"退化成了 §9.3 那类"部分缺失",而不是"整体不匹配"。但这个解释本篇没有验证到源码级,所以只能作为推测记录在此。

本篇的处理原则:正文与结论一律按实测写,并显式标注"与手册描述存在出入"。不去替编译器圆场,也不把手册当实测的替代品——一个"默认 error"的开关在实测里静默通过,这件事本身比它报不报错更重要,因为它正是构建流水线敢把 PGO 交给自动化去跑的心理依据。

9.5 风险链与三条对策

过期 profile 的风险链与三条对策
图 7-2829 过期 profile 的风险链与三条对策@ 本系列绘制

好了,把它连成一条链:

源码改动(哪怕只有一个运算符)
  → profile 描述的热路径不再存在
  → 编译器不报警,统计仍显示 100% 命中
  → 守卫快路径每次落空、冷路径被反复走
  → 性能反向(比不做 PGO 更差)

三条对策,按可操作性排序:

  1. profile 与源码同版本:把"训练"纳入 CI,每次源码变更后重跑训练,而不是把一份 .pgd / .gcda 当长期资产提交进仓库缓存。profile 是有保鲜期的构建中间物,不是配置。
  2. 构建脚本单一变量:训练产物名与使用阶段名由同一个变量生成(§7.4 的坑三)。这一条是"防止静默失效"里性价比最高的一条,成本几乎为零。
  3. 监控"被优化函数比例":这是微软采样 PGO 文档给的指标——低于 90% 就重采MSVC 采样 PGO 文档。要提醒的是,它拦截的是"数据用不上",拦不住"数据用错了"。所以它只能当兜底告警,不能当保鲜机制。

9.6 官方口径备查

官方口径:两条警告语义与 -fprofile-use 的隐式选项
图 7-2930 官方口径:两条警告语义与 -fprofile-use 的隐式选项@ GCC 手册 + 内核文档 + MSVC SPGO 文档
  • MSVC 采样 PGO 文档:监控 LINK 的 profile 完整度统计,被 profile 优化的函数比例显著下降(低于 90%)时应重新采集,因为代码累积改动会使 profile 变陈旧MSVC 采样 PGO 文档。
  • GCC 手册:-Wcoverage-mismatch 默认开启且默认 treated as an error("profile 存在但不匹配")GCC 警告选项手册;-Wmissing-profile 默认开启,性质是 warning("profile 缺失",语义正是"generate 与 use 之间新增了函数或文件却没重新生成 profile")GCC 警告选项手册(14.x)。两者的语义要分清——本篇实测到的都是后者。
  • Linux 内核 AutoFDO 文档:训练负载必须"具有代表性",否则会朝错误的目标优化;文档同时指出采样型 PGO 需要 perf 的硬件支持(x86 LBR、arm64 SPE/ETM)内核 AutoFDO 文档。

十、收束与下一篇钩子

10.1 全篇读数汇总

把全篇实测读数收在一张表里,方便对照与复核。每一行的原始输出都随篇留档(见 §10.3 的目录说明),所以这张表里的任何一行都可以逐字核对——本篇不给"约"、"大约"这样的措辞,所有数字都是某一份日志里的原文。

表里有两行值得最后再看一遍:GCC 那一栏"PGO 对跨 TU 调用"只差 0.08 ms(说明两种手段互不替代),以及 MSVC 那一行"main 行数 241 → 123"(说明内联变少了、程序反而更快了)。这两行是全篇两个反直觉发现的落点。

全篇 17 项读数汇总
图 7-3031 全篇 17 项读数汇总@ 本机实测(两套工具链)
cross-module 内联在两侧的证据形态对照
图 7-3132 cross-module 内联在两侧的证据形态对照@ 本机实测(dumpbin /disasm + -fopt-info)
读数 值
MSVC 跨 TU 调用:/O2 → /GL+/LTCG 153.65 → 21.39 ms(7.2×)
MSVC 同 TU 对照(无 LTO) 19.73 ms(与 LTO 档同量级)
MSVC 体积 / 代码段:基线 → LTO 13,312→13,824 B / 5,120→5,632 B(涨)
MSVC /GL + /LTCG:OFF 149.67 ms,链接器 rc=0 且零输出
MSVC 反汇编里 ?step@@YAHH@Z 的 call 基线 1 条 → LTO 0 条
MSVC 间接调用:LTO → PGO 109.69 → 53.47 ms(2.9×)
MSVC main 行数:LTO → PGO 241 → 123 行(**内联变少)
MSVC PGO profile 体积 .pgc 5,648 B / .pgd 1,110,016 B
MSVC 插桩 exe 裸跑退出码 0xC0000135(STATUS_DLL_NOT_FOUND)
GCC 跨 TU 调用:-O2 → -flto 112.52 → 23.80 ms(4.7×)
GCC 体积:-O2 → -flto 239,133 → 237,124 B(降 2,009 B)
GCC 间接调用:基线 → PGO 43.66 → 10.45 ms(4.2×)
GCC 跨 TU 调用:基线 → PGO 112.52 → 112.44 ms(几乎不变)
GCC 无数据 -fprofile-use 体积不变,间接 44.05 → 111.53 ms
构建+链接:MSVC 基线 → LTO 4.88 → 4.92 s(持平)
构建+链接:GCC 基线 → -flto 1.85 → 2.30 s(+24%)
普通 ar vs gcc-ar(LTO 静态库) 27.91 vs 28.20 ms(无差别)

10.2 LTO / PGO 选型判据

LTO / PGO 选型判据表
图 7-3233 LTO / PGO 选型判据表@ 本系列绘制
维度 LTO PGO
收益位 函数调用(跨 TU 内联) 间接调用、分支、代码布局
依赖运行场景 不依赖 强依赖(训练必须代表真实负载)
主要代价 构建时间(大项目才显著)、体积可能变大 训练流程、profile 保鲜期
失效方式 选项写错时静默(/LTCG:OFF) 数据缺失/过期时静默(两种编译器都不报过期)
取证手段 反汇编里目标函数的 call 归零 反汇编里热路径出现 cmp+jne 守卫且命中路径无 call
合起来用 colspan:两者收益位不同,可以叠加(GCC 第四档 21.83 / 10.74 ms)

好了,三条可以直接用的判据:

  1. 先问"热在哪里":热在小函数调用 → LTO;热在虚调用/函数指针 → PGO。开错开关的收益是 0(§8.5 第二行:PGO 对跨 TU 调用差 0.07%)。
  2. 再问"能不能跑真实负载":跑不了(比如部署在客户现场、或训练成本高到无法进 CI),PGO 就不要上——一份过期的 profile 比没有 profile 更危险(§9.3 实测慢 2.5 倍)。
  3. 到最后才轮到"能不能接受构建变慢":这一条在大项目上才成为约束(单块 LTO 链接时间可 +2 小时)。小项目上它根本不是问题(本篇 4 文件:MSVC 持平、GCC +0.45 秒)。

10.3 复现流程

复现流程:两套工具链六档构建与三类取数
图 7-3334 复现流程:两套工具链六档构建与三类取数@ 本系列绘制

说白了,复现只需要三样东西:五个源文件(iface.h / a.cpp / b.cpp / c.cpp / main.cpp)、两套工具链(MSVC 19.44 与 g++ 16.2)、一组开关组合。全部原始日志、反汇编转储、计时结果随篇留档在本篇 evidence/ 目录下(logs_msvc/、logs_gcc/、bins_*/、*.json),可以直接逐行核对本文表格里的每一个数字。

10.4 自检清单

LTO / PGO 上线前自检清单
图 7-3435 LTO / PGO 上线前自检清单@ 本系列绘制
  • [ ] 训练产物名与使用阶段名由同一个变量生成
  • [ ] profile 与源码同版本,且训练已纳入 CI
  • [ ] 构建脚本里没有遗留 /LTCG:OFF(它会静默把 LTO 关掉)
  • [ ] 链接/编译日志里核对实际文件名(.gcda / .pgd),不是核对"命令看起来对"
  • [ ] 反汇编核对:目标函数的 call 是否真的归零
  • [ ] 体积与构建时间都记了基线(不记基线就没法发现"反向优化")
  • [ ] 训练负载代表真实负载(至少覆盖主路径)
  • [ ] 监控"被优化函数比例"(低于 90% 重采)
  • [ ] 有条件时双工具链交叉验证(本篇就出现了体积方向相反的结果)
  • [ ] 全部原始输出留档(否则上面九条都只是"相信")

10.5 资源清单

10.6 一条可以搬走的通用方法

把本篇的做法抽象出来,是三步,它可以搬到任何"某个开关到底有没有起作用"的问题上:

  1. 立判据:先说清"起作用的话,机器输出里应该少掉/多出什么"。本篇的两条判据是"目标函数的 call 归零"与"热路径出现守卫且命中无 call"。判据必须是机械可数的,不能是"感觉快多了"。
  2. 做对照:至少两组,只差一个变量。本篇的关键对照组是"同 TU 无 LTO"——它把"边界是否消失"这一个变量单独拿了出来(§4.2)。
  3. 交叉验证:用不同性质的证据互相印证——计时(易抖动)、汇编(确定)、符号存活(确定)、体积(确定)。当计时读数出现互相矛盾的差异时,以确定性的那几种为准(§3.6、§6.4 的 1% 差异就是这样处理的)。

这套方法的价值在于它不依赖对编译器的信任。本篇全部结论都建立在"机器实际产出了什么"上——不是"编译器应该会内联",而是"那条 call 没了"。

10.7 本篇与系列其他篇目的关系

三个交叉点,方便按需跳读:

  • 与 ep04《编译期账本》:ep04 算的是"编译时间花在哪",本篇算的是"链接期还能再赚回多少"。两者合起来才是完整的"编译期—链接期"账本。若你的项目因为编译太慢而犹豫要不要开 LTO,ep04 的读数方法(模板实例化计数、Unity Build、PCH)可以先试,它们不改运行期行为、不引入 profile 保鲜问题。
  • 与 ep05《优化器的边界》:ep05 立的是"优化器在单 TU 内的自由度边界"(UB、严格别名、__restrict、向量化报告)。本篇是它的续集——单 TU 内能做的都做完了,剩下的收益只能靠拆边界(LTO)或靠运行时事实(PGO)去拿。
  • 与 ep06《名字与符号》:ep06 讲的是"链接器只认符号名"。LTO 会把一批符号消掉或降为局部(§2.5),这正是 ep06 那套符号表知识的一个直接后果——两篇连着看,能把"符号表里少了几行"这件事解释到底。

10.8 下一篇:分发的代价

这一篇算的是"构建期"这笔账。下一篇要算的是运行期的那一笔:

ep08《分发的代价:虚函数、模板、variant 与类型擦除》 本篇已经证明"类型可见性"能换来性能(§8.3 那个三目标枚举)。下一篇反过来问:把类型藏起来——虚函数表、模板实例、std::variant、类型擦除(std::function、any)——各自要付多少运行时代价?哪些可以白拿,哪些必须付现金? 下一篇会继续用反汇编作判据,把"多一次间接跳转"和"多一次堆分配"两种代价分开量。


本篇实测环境:MSVC 19.44.35228(VS 2022 Community,工具集 14.44.35207)|MSYS2 ucrt64 g++ 16.2.0(Rev3)|Windows x64 / PE-COFF。本机无 Clang、无 WSL,故 LLVM 侧(ThinLTO / BOLT / AutoFDO / CSIR)与 ELF 侧(链接器插件、PLT/GOT)只引官方原文,不做本机实测;计时为进程内 steady_clock 中位数,非跨机器可比。

本文由@溯光 出品 | 站点:https://yyy3d.online/blog/ | 本系列共 12 篇,本篇为第 7 篇

这一篇把「两种开关各自动了哪条边界」从头量到了汇编一级。如果它能帮你少一次「开了优化却没变快」的困惑,希望能对大家有所帮助。