编译器、构建系统和包管理器各管一段,操作系统因此丢掉哪些优化

编译器、构建系统和包管理器各自只掌握一段依赖信息,谁也不交给谁,操作系统因此丢掉一批做得了的优化。编译器看不见别的编译单元。构建系统知道哪些源文件同属一个产物却传不进去。包管理器的知识到包边界就断。这篇写给维护构建流水线的人。

编译单元被切开,优化就停在那一步

C 的构建系统把每个 .c 文件放进独立进程编成 .o,编译器只看得见眼前这个文件。four.c 有个乘 4 的函数,main.c 传的实参是 3,两边看不见,只能生成一次乘法。

全程序优化能补回来,办法是让 .o 装中间表示,链接器收齐后统一优化。构建系统和编译器合成一体,描述里写明两个文件同属一个产物,编译器在链接前就能做。

同一批头文件被反复解析,是最容易放过的重复劳动

多个 .c 文件包含同一组头文件,每个编译进程都要重新解析一遍,重建语法树,劳动量按文件数翻倍。构建系统知道哪些文件共享头文件,交给编译器就能复用。

增量编译的粒度也卡在这里。多数构建工具按时间戳判断要重编的单元,停在文件级。构建图写明哪些源文件组成同一个产物,当成整体看,增量能到函数级。

到了包边界,链接期优化基本失效

C 这边靠系统包管理器提供预编译好的库和头文件。对链接进来的库做全程序优化,静态库必须提供含中间表示的目标文件。不同编译器的中间表示互不兼容,分发的静态库多数只有普通目标文件。

替代方案叫跨包依赖知识。包描述里写清自己由哪些源文件构建、依赖谁,引用写成 @libfour:out:four 的形式,包管理器把跨包构建关系交给构建系统,编译器把最终产物整体优化。

系统管理也能用上这套信息。声明式地列出想要的包,关掉窗口管理器的花哨效果,系统只重编与配置项相关的部分再原子更新。

静态链接省不下的磁盘,文件系统可以补上

动态链接省磁盘,库在磁盘上只存一份。静态链接会把库复制进每个可执行文件,两个程序都链 libfour,磁盘上就有两份。构建系统清楚谁链了 libfour、内容是什么、可执行文件在哪,交给文件系统就能去重。

调度器手里缺的是构建图

一个包里很多编译进程要等某个生成的头文件,构建系统把这一步告诉内核,调度器给这个进程更高的 CPU 优先级,它早点做完,排队的任务一起解锁。把上万个 C++ 目标文件链接成一个可执行文件很吃内存,编译器提前说明,调度器可压住别的进程。

局部做法早就有,为什么还是接不起来

ThinLTO 把全程序优化做得高效,编译器服务器和 ccache 覆盖了编译器侧的部分缓存,Nix 和 Guix 在内容寻址产物和依赖处理上有成熟思路。它们之间不交换信息,Nix 和 Guix 包着别的构建系统用,知识到包边界就断。

普通项目今天能做的几件事

  1. 打开工具链已经支持的全程序优化,ThinLTO 在多数项目上可以直接开,先量构建和运行时间。
  2. 用 ccache 或编译器服务器把重复的头文件解析缓存起来,共享头文件多的项目收益最明显。
  3. 检查依赖链里静态库的分发方式。只能拿到普通目标文件的库,链接期优化就断。

常见问题

这套统一系统现在能用吗

还不能。目前是一套设计推演,作者只把构建系统与包管理器合并做了缩小版。

只做全程序优化,能拿到多少收益

运行速度上的收益来自这里,磁盘占用和调度拿不到。