上周有位做客户端的朋友甩过来一段 CMakeLists,说他们那套模拟项目X已经烂到没法干活了:改一个 .cpp 里的注释,回车下去,Ninja 哗啦啦起 400 多个编译单元,喝完一杯咖啡回来还没链接完。我让他先别急着上各种炫技手段,先把ninja -d explain打开跑一遍,结果一半时间花在和他改动完全无关的模块上——典型的头文件传染加依赖图失控。这件事让我觉得有必要把CMake 预编译头和增量构建这套东西完整捋一遍:它们不是什么黑魔法,而是把编译这桩生意的成本账重新算清楚。这篇内容适合那些项目已经过了“能编过就行”阶段、开始被编译时间反向绑架的人,也适合刚接手一个十几万行老项目、被 configure 和 build 搞得一头雾水的新同学。我会从瓶颈定位讲到 PCH 的正确用法,再到依赖图切割、编译缓存、并行调度,每一步都给可抄的配置和踩过的坑。
1. 先把编译时间账单摊开看清楚
1.1 为什么“感觉慢”这个判断八成是错的
人对时间的感知极不靠谱。你觉得“整个项目编译要十分钟”,实际上这十分钟里 configure 占多久、编译占多久、链接占多久、被 IDE 后台索引吃掉多少,绝大多数人答不上来。我见过最离谱的一次,某实验室的构建流程里 configure 阶段跑了整整两分半,原因是 CMakeLists 里塞了一个execute_process去调用外部脚本探测版本号,每次都重新执行;而真正编译只花了三十秒。他们一直在优化 PCH,方向从一开始就是错的。
构建慢至少可以切成四块独立的账:configure 阶段(跑 CMake 逻辑、探测编译器、生成构建文件)、依赖扫描阶段(编译器或 Ninja 计算头文件依赖图)、编译阶段(预处理、词法、语法、语义、代码生成、优化)、链接阶段(符号解析、重定位、LTO)。这四块的优化手段几乎完全不重叠,你在编译阶段使劲,configure 慢的项目一点感觉都没有。
还有第五块隐形成本:文件系统与杀毒软件。Windows 上开着实时扫描的构建目录,写入几千个 .obj 小文件的开销能占掉 20% 以上。这类问题靠改 CMake 是治不好的,得把构建目录加白名单,或者干脆放到本地 SSD 而不是网络盘上。
所以第一步永远是测量,不是动手改。先把“慢”拆成四五个数字,再决定往哪儿砸时间。
1.2 用数据说话:我给团队固定下来的几把尺子
Clang 系的-ftime-trace是首选。加一个-ftime-trace编译选项,Clang 会为每个编译单元生成一个同名的 .json 文件,里面记录了 ParseClass、InstantiateFunction、Source 等细分事件的耗时。扔进支持火焰图的可视化工具里,哪个头文件、哪个模板实例化在吃时间一目了然。实测下来,一个中等规模项目里<regex>或者某些重型模板库的头文件经常单独占掉单个 TU 的 20% 到 40%,看到这个数字你立刻就明白 PCH 该往里塞什么。
GCC 侧用-ftime-report,它会在编译结束时打印各阶段耗时汇总(预处理、解析、优化、代码生成)。粒度没有-ftime-trace细,但胜在零成本,随时可以开。
Ninja 侧有两个被严重低估的命令:
# 看每个目标的实际耗时统计 ninja -d stats # 解释"为什么这个文件被重新编译"(Ninja 1.11 及以上) ninja -d explain -n # 查某个目标依赖了哪些文件 ninja -t deps path/to/foo.cpp.o-d explain是查增量构建失效的核武器。它会直接告诉你“因为 a.h 的时间戳比 foo.o 新,所以重编”,或者“因为命令行变了”。很多人抱怨“明明没改东西却全量重编”,用这个命令十秒钟就能定位到罪魁祸首。
configure 阶段用 CMake 自带的 profiling:
cmake -S . -B build --profiling-format=google-trace --profiling-output=cfg.json它会生成一份 configure 阶段的追踪数据,哪个find_package、哪个execute_process慢,全在里面。--trace-expand也能用,但输出量巨大,只适合怀疑某个特定变量被反复求值时临时开一下。
提示:把
-ftime-trace加进日常构建要谨慎,它会给每个 TU 多生成一个 json 文件,构建目录体积和 IO 都会涨。我的做法是单独建一个build-profile目录,只在排查问题时用。
2. 预编译头在 CMake 里的正确打开方式
2.1 PCH 到底省了什么:从“重新算一遍”变成“读一次快照”
要理解 PCH 的价值,得先知道编译器处理一个 .cpp 时干了什么。它从第一行开始,遇到#include <vector>就把那个文件完整展开,而<vector>内部又拉进来十几个头,展开后可能是两万到五万行代码。这些代码要经历词法分析(切成 token)、语法分析(建 AST)、语义分析(类型检查、模板推导)。哪怕你只是想在 main 里 push_back 一个 int,这些活儿一样不落。
一个几千行的 .cpp,加上标准库和项目公共头,预处理后的 token 数量通常在十万量级,而真正属于你自己写的代码可能只占 5%。剩下 95% 每次都在重复劳动。
PCH 干的事情就是:把这 95% 的“展开 + 解析 + 类型检查”的结果,以二进制的形式存成一份快照(GCC 是 .gch,Clang 是序列化的 AST 加宏表)。下次编译时,编译器不再重新解析那几万行,而是直接把快照反序列化到内存里。这一步的耗时通常只有重新解析的十分之一到二十分之一。
CMake 从 3.16 开始提供了target_precompile_headers,把不同编译器的差异(GCC 的 .gch、Clang 的 pch、MSVC 的 /Yc 和 /Yu)全部包了起来。你写一行 CMake 命令,它在 GCC 上生成cmake_pch.hxx.gch,在 MSVC 上生成/Yc与/Yu的配套调用,你不用管细节——这是它最大的价值,也是它最大的风险所在,因为细节被藏起来了,出问题的时候不好查。
有一个数字可以作为判断依据:如果你的项目里,<vector>、<string>、<unordered_map>、<algorithm>这几个头在超过 60% 的 TU 里出现,PCH 的收益会非常明显;如果项目本来就很克制,公共头只有一个手写的 200 行接口定义,那 PCH 的收益可能不到 5%,反而增加构建目录体积和首次全量编译时间。
2.2 三种写法与它们的适用边界
最简写法,直接给头文件:
# CMake 3.16+ target_precompile_headers(my_target PRIVATE <vector> <string> <memory> <unordered_map> "project_common.h" )注意尖括号和引号的区别:尖括号表示“系统头”,引号表示“项目内头”。CMake 会把这些内容拼进构建目录里生成的cmake_pch.hxx,然后让所有源的编译命令行都带上-include cmake_pch.hxx(GCC/Clang)或/FI加强制包含(MSVC)。这意味着你的每个 .cpp 都会隐式包含这些头,即使它没写#include。这一点非常好用,但也容易让新人写出依赖隐式包含的代码,我在后面会讲怎么防。
第二种,用PRIVATE和INTERFACE区分“自己用”和“传染给下游”:
add_library(core STATIC ${CORE_SOURCES}) target_precompile_headers(core PRIVATE <vector> <string> "core/internal_types.h" ) # 下游 target 不继承 PCH,但可以自己再声明一份 add_executable(app ${APP_SOURCES}) target_link_libraries(app PRIVATE core) target_precompile_headers(app PRIVATE <vector> <string> <memory> )PRIVATE是关键。PCH 本质上是编译期的一种实现细节,它不应该成为接口的一部分。如果误写成INTERFACE或PUBLIC,所有链接你的下游目标都会被迫使用同一份 PCH,一旦它们的编译选项有差异(比如-std=c++17对上-std=c++20),就会直接编译失败,而且报错信息通常极其晦涩。
第三种,REUSE_FROM跨目标复用,慎用:
add_library(core_pch INTERFACE) target_precompile_headers(core_pch INTERFACE <vector> <string>) add_library(mod_a STATIC ${A_SOURCES}) target_precompile_headers(mod_a REUSE_FROM core_pch)REUSE_FROM的语义是“我不生成自己的 PCH,直接用别人的”。听起来很美,但它要求两个目标的编译选项、宏定义、include 搜索路径顺序完全一致。只要有一个-DENABLE_XXX不同,编译器就会用一份错的快照去编译你的代码,症状是随机的诡异报错或者运行期崩溃。我的建议是:只在 header-only 的第三方依赖或者严格受控的基础库里用REUSE_FROM,业务模块各建各的 PCH。
2.3 PCH 失效的六个典型原因
PCH 最让人抓狂的地方不是它没生效,而是它悄无声息地失效了,构建还能过,只是慢回去。下面这几种我全都踩过:
宏定义不一致。PCH 在生成时记录了当时的宏表。编译某个 TU 时如果多传了一个-DMY_DEBUG=1,编译器会发现宏状态对不上,直接忽略 PCH,退化成普通编译。它不会报错,只是慢。所以但凡项目里用了target_compile_definitions按平台或配置切宏,就要检查这些宏是否影响到了 PCH 里的头。
编译标准不一致。一个库是 C++17,一个可执行文件是 C++20,共享 PCH 必挂。
PCH 里放了会变的内容。常见于把版本号头文件、git describe生成的 build_info.h 塞进 PCH。这类文件每次提交都可能变,导致 PCH 每次重建,重建成本远高于收益,等于负优化。
-fPIC/-fno-exceptions等影响代码生成的选项有差异,同样会导致 PCH 不可用。MSVC 上还有个经典坑:/MP多进程编译与/Yc的关系,某些旧版本组合下会互相打架。
include搜索路径顺序不同。两个目标 include 了同名头文件但来自不同目录,PCH 里的符号就是你不想用的那个。
构建目录搬迁或编译器升级。PCH 文件里记录了编译器版本和内部结构布局,换个编译器版本后旧 PCH 自动失效。CMake 一般能处理,但如果你手动 copy 过构建目录,就可能出现“PCH 存在但内容不匹配”的怪象,清理掉重建最稳妥。
注意:CMake 在检测到 PCH 不可用时会静默回退,不打印警告。想确认 PCH 是否真的生效,最直接的办法是看构建日志里有没有
-include .../cmake_pch.hxx(或 MSVC 的/FI),再对比加 PCH 前后的构建耗时。别相信“我加了这行就快了”的直觉。
还有一个容易被忽略的反向问题:PCH 会让“改一行公共头”的代价变高。因为所有 TU 都依赖这份 PCH,改它就等于全量重编,而且比不加 PCH 时更慢(要先重建 PCH,再编译所有 TU)。所以 PCH 该放的是稳定的、几乎不改的系统头和底层基础头,而不是你天天动刀的业务公共头。业务公共头的正确治理方式是切割依赖,这在下一节讲。
3. 增量构建为什么“增不动”:依赖图的最小化改造
3.1 头文件传染链:这是增量构建的头号杀手
一个典型场景:core/types.h被 600 个 .cpp 直接或间接包含,某天你往这个头里加了一个枚举值。理论上只有用到这个枚举的地方需要重编,但因为编译器的依赖粒度是“整个头文件变了”,600 个 TU 全部重编。这就是头文件传染。
先诊断。Clang 的-H选项会打印出每个头文件的包含链,你能看到某个 .cpp 到底拉进来了哪些东西、谁的层次最深。GCC 也有-H。还有一种做法是打开CMAKE_EXPORT_COMPILE_COMMANDS,拿到编译数据库后做统计分析:哪些头文件出现在最多 TU 的依赖列表里。
# 让 Clang 打印包含链 clang++ -H -c foo.cpp -o /dev/null 2> include_chain.txt再切割。手法按侵入性从低到高排:
第一档,把只用于实现的 include 从 .h 挪到 .cpp。这是收益最高、风险最低的操作。一个常见的反例是在公共头里#include <iostream>,只因为某个函数签名里有std::ostream&。改成#include <iosfwd>就够了——前置声明了basic_ostream,签名照样成立。
第二档,前置声明替代 include。头文件里如果只用到某个类型的指针或引用,写class Foo;就能编译通过,不必包含 Foo 的完整定义。但要注意两处例外:继承和成员对象都要求完整类型;还有std::unique_ptr<Foo>作为成员时,析构函数需要在能看到 Foo 完整定义的地方生成,所以类的析构函数必须声明在头里、定义在 .cpp 里,否则编译不过。
第三档,PIMPL。把实现细节全部塞进一个前向声明为class Impl;的不透明指针里,头文件从几百行压缩到二十行。代价是每次访问成员都要一次间接寻址,且对象不能放在栈上(拷贝语义、移动语义要自己实现)。对性能敏感的核心数据结构我不建议 PIMPL,但对配置类、服务类这类对象非常好用。我做过一个模块,公共头从 340 行降到 41 行,改动它触发的重编范围从 200 多个 TU 降到 30 个。
第四档,使用 include-what-you-use 这类工具做自动化巡检。它基于 Clang 分析每个文件的真实使用情况,建议删掉多余的 include、补上缺少的。接入 CI 之后可以把“头文件纪律”变成可持续的规则。不过它的建议会有误报(尤其是模板和部分特化场景),要人工过一遍。
3.2 target_include_directories 的可见性,决定了传染半径
CMake 的PUBLIC/PRIVATE/INTERFACE三件套,很多人的理解停留在“能不能链接上”,其实它对编译期传播的影响才是关键。
一个库把某个目录写成PUBLIC,意味着所有链接这个库的目标,编译时都会带上这个-I路径。这本身不产生依赖,但会产生名字空间污染:两个库各自有一个config.h,都写成 PUBLIC 且路径靠前,就会出现某模块静默地包含了另一个库的头文件。更隐蔽的后果是:这些下游目标一旦包含到了那个头,就会进入它的依赖链,从此跟着一起重编。
我处理过一个案例,某跨平台系统的platform_utils库把第三方 SDK 的头目录写成了 PUBLIC,导致上层 900 多个 TU 全部依赖 SDK 的版本宏文件。改一次 SDK 配置,全项目重编,构建时间从两分钟飙到九分钟。改成 PRIVATE 之后,只有真正需要用 SDK 的少数几个 TU 受影响,重编范围缩到 5% 以内。
规则很简单:PRIVATE是默认选项,只有当一个目录里放的头文件是你的公开 API 的一部分时才用 PUBLIC。INTERFACE更好用,它把路径传给下游但自己编译时不带,特别适合 header-only 包装层。
顺带说一个细节:target_include_directories里用BUILD_INTERFACE和INSTALL_INTERFACE分开写,能避免构建期和安装期的路径互相污染。这跟增量构建关系不大,但能减少一类莫名其妙的重新 configure。
3.3 生成文件的依赖声明,写漏一条就永远重跑
CMake 的增量能力建立在一张精确的依赖图上。你写漏一个依赖,后果有两种:要么该重跑的没重跑(产物是旧的,比慢更可怕),要么每次构建都重跑(你被告知“反正改了也不对,每次编吧”)。
典型的坑在于add_custom_command:
# 错误:没声明对输入的依赖,改了脚本不会重新生成 add_custom_command( OUTPUT ${CMAKE_BINARY_DIR}/generated/protocol.cpp COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/gen.py -o ${CMAKE_BINARY_DIR}/generated/protocol.cpp ) # 正确:DEPENDS 写清脚本 + 模板,并且标记 GENERATED add_custom_command( OUTPUT ${CMAKE_BINARY_DIR}/generated/protocol.cpp COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/gen.py --input ${CMAKE_SOURCE_DIR}/spec/protocol.json --template ${CMAKE_SOURCE_DIR}/tools/tpl.cpp.in -o ${CMAKE_BINARY_DIR}/generated/protocol.cpp DEPENDS ${CMAKE_SOURCE_DIR}/tools/gen.py ${CMAKE_SOURCE_DIR}/spec/protocol.json ${CMAKE_SOURCE_DIR}/tools/tpl.cpp.in COMMENT "Generating protocol.cpp" VERBATIM ) set_source_files_properties(${CMAKE_BINARY_DIR}/generated/protocol.cpp PROPERTIES GENERATED TRUE)几个容易被忽略的点:COMMAND里如果嵌了 generator expression,记得VERBATIM,否则路径带空格时命令行会被拆坏;生成目录必须在target_include_directories里注册,否则找不到生成的头;如果生成脚本读的是目录下的多个文件,DEPENDS里得逐条列出,或者用file(GLOB)加CONFIGURE_DEPENDS——但CONFIGURE_DEPENDS会让每次构建都做一次 glob 检查,大目录下有明显开销,能不用就不用。
另一类更隐蔽的问题是CMakeLists 本身读了外部文件,比如版本号文件、特性开关表。这类依赖 CMake 自己不知道,得显式声明:
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${CMAKE_SOURCE_DIR}/config/features.yaml")不写这一行,你改了 features.yaml,CMake 认为配置没变,不会重新 configure,编译出来的还是旧配置,排查起来能让人怀疑人生。
还有一个排查利器:set_property(GLOBAL PROPERTY GLOBAL_DEPENDS_DEBUG_MODE 1),它会把 CMake 内部计算的依赖图打印出来,对照着看哪个文件没被正确连上。
4. 缓存与并行:把剩下的时间榨干
4.1 编译缓存命中率,差一个参数就天壤之别
前面两步解决的是“少编译一些东西”,编译缓存(ccache 这类工具)解决的是“编译过的东西不再编”。逻辑很简单:拿整个编译命令的输入(预处理后的源、编译选项、编译器版本)算一个哈希,命中就把上次的 .o 直接拿出来用。
问题在于,默认配置下命中率经常低得可怜。我见过一个项目接入 ccache 后命中率只有 12%,排查下来是三个原因叠加。第一,代码里用了__DATE__和__TIME__做构建时间戳,每次预处理结果都不同,哈希自然对不上。第二,编译命令里带了绝对路径,不同开发者、不同构建目录的哈希完全不一样。第三,所有文件都加了-g,而调试信息里嵌了源文件路径和编译目录。
对症下药:
# 允许对时间宏和 PCH 相关元数据"宽容" export CCACHE_SLOPPINESS=time_macros,pch_defines,include_file_mtime,include_file_ctime # 让不同机器/目录共享同一份缓存 export CCACHE_BASEDIR="$(pwd)" export CCACHE_NOHASHDIR=true # 用内容而非 mtime 判断编译器 export CCACHE_COMPILERCHECK=content # 缓存容量,按构建目录体积的 3 到 5 倍估算 export CCACHE_MAXSIZE=20GCCACHE_SLOPPINESS里的pch_defines和time_macros是 PCH 与缓存共存的关键。Clang 生成的 PCH 里带时间戳,默认情况下每次都不一样,缓存永不命中,还得额外加-fno-pch-timestamp编译选项。
用ccache -s看统计,重点看两个数:直接命中率(cache hit direct)和预处理命中率(preprocessed hit)。前者靠文件内容和命令完全一致,后者靠预处理后的结果一致。前者低后者高,说明命令里带了可变的东西(路径、时间戳);两者都低,说明输入本身在变,得去查是不是有生成文件进了编译链。
有一个现实约束:ccache 对 PCH 的支持在不同编译器上表现不一,微软系编译器的/Yc与/Yu配合 ccache 时,某些版本组合下会命中率骤降。我一般的做法是 PCH 和缓存二选一、哪个收益大用哪个,或者在 CI 上只开缓存(因为 CI 是全量构建,PCH 收益有限),本地开发机两个都开但观察命中率。
4.2 Unity Build:收益很香,边界也很清楚
Unity Build(有些地方叫 Jumbo Build)的思路是:把多个 .cpp 拼成一个大的 .cpp 再编译。好处有两个,一是共享一次头文件解析(相当于天然的大号 PCH),二是减少目标文件数量和链接时的符号处理量。
CMake 从 3.16 起支持:
set_target_properties(my_target PROPERTIES UNITY_BUILD ON UNITY_BUILD_BATCH_SIZE 16 # 每个合并文件里塞多少个源,默认 8 UNITY_BUILD_MODE BATCH # 3.24+,BATCH 按批合并,GROUP 按目录/显式分组 )UNITY_BUILD_BATCH_SIZE的取值需要权衡。调大,头文件解析摊薄得更厉害;但单个 TU 变大后,编译内存线性上涨,而并行度下降——原来 32 个小 TU 可以 8 核并行跑,现在合并成 4 个大 TU,只能 4 核并行。我的经验值:单个合并后 TU 的编译时间控制在 10 到 20 秒之间比较合适,太短说明合并不够,太长则并行度吃不满且单点出错影响面太大。
真正让人放弃 Unity Build 的通常是符号冲突。合并之后,以下东西会撞车:
- 不同 .cpp 里的
static函数或变量重名 - 匿名命名空间里的同名实体(虽然叫“匿名”,但合并后就在同一个 TU 里了)
using namespace xxx;在文件作用域滥用,导致后面合并进来的代码被污染- 宏定义没有
#undef,影响后续文件 - 文件作用域的同名常量(
const int kMaxSize = 10;)在两个文件里都定义了
这些问题的修法都很直接(改名、收敛作用域、加#undef),但一个几万行的老项目里可能有上百处,改起来极其枯燥。所以 Unity Build 更适合新项目从第一天就启用,或者用在边界清晰的子模块上,而不是全项目一把梭。
还有个细节:UNITY_BUILD_MODE GROUP允许你用UNITY_GROUP源文件属性把逻辑上相关的文件分到一组,避免把本来就有符号冲突倾向的两个文件塞进同一批。这比纯 BATCH 灵活得多,值得用 3.24 以上版本的人试试。
4.3 并行度与内存:别让 -j 把机器干趴下
Ninja 默认的并行数不是 CPU 核数,而是核数 + 2(这是为了掩盖 IO 等待)。这个默认值在纯编译场景下经常是过高的,因为编译是内存密集型任务。一个开启了中等优化级别的 C++ TU,峰值 RSS 可以轻松到 1.5 GB 到 3 GB,重型模板代码能冲到 5 GB。8 核机器上跑 10 个并行,就是 20 GB 峰值,机器开始 swap,速度反而暴跌。
算法很简单:
并行数 = floor((物理内存 - 系统余量) / 单 TU 峰值内存) 并行数 = min(并行数, CPU 逻辑核数)系统余量按 4 GB 到 8 GB 留(IDE、浏览器、文件缓存都要吃)。单 TU 峰值内存怎么测?Linux 上用/usr/bin/time -v跑几个有代表性的 TU,看 Maximum resident set size;Windows 上任务管理器盯一下峰值即可。
# 临时指定并行数 cmake --build build --parallel 6 # 或者用环境变量 export CMAKE_BUILD_PARALLEL_LEVEL=6链接阶段要单独限流。链接是最吃内存的环节,尤其是开了 LTO。几个链接任务并行起来,瞬间能把内存打满。CMake 提供了 job pool:
set_property(GLOBAL PROPERTY JOB_POOLS link_pool=2 compile_pool=8) set_property(TARGET my_target PROPERTY JOB_POOL_LINK link_pool)注意JOB_POOLS是全局属性,要在add_subdirectory之前设定;JOB_POOL_LINK是目标属性。这个组合只对 Ninja 生成器有效,Makefile 生成器不认。
另外两个容易被忽略的加速点:链接器本身。较新的链接器在增量链接和多核利用上比传统链接器快好几倍,在支持增量构建这件事上差别明显。切换链接器只需要改-fuse-ld=参数,成本极低,收益往往比折腾半天编译选项还大。LTO 的代价:链接时优化会把优化推迟到链接阶段,全量构建时间可能翻倍,但它对增量构建的伤害更大——因为中间表示要在链接时统一处理,任何一个小改动都要重跑整个 LTO 流程。除非你确实需要那点性能,否则日常开发构建关掉 LTO、只在发布构建里开,是更务实的做法。
5. 一套可直接落地的配置与问题速查
5.1 整合起来的 CMakeLists 骨架
下面这段是我在多个项目里反复调整后沉淀下来的骨架,按需裁剪即可:
cmake_minimum_required(VERSION 3.24) project(ProjectX LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE RelWithDebInfo CACHE STRING "" FORCE) endif() # 让 IDE 和外部工具能拿到编译命令 set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # 调试信息里剥离绝对路径,提高缓存命中率 add_compile_options( $<$<COMPILE_LANGUAGE:CXX>:-fdebug-prefix-map=${CMAKE_SOURCE_DIR}=.> ) # 链接限流 set_property(GLOBAL PROPERTY JOB_POOLS link_pool=2) add_library(core STATIC ${CORE_SOURCES}) target_include_directories(core PUBLIC ${CMAKE_SOURCE_DIR}/include PRIVATE ${CMAKE_SOURCE_DIR}/src ) # PCH 只放稳定的系统头和极底层基础头 target_precompile_headers(core PRIVATE <vector> <string> <memory> <unordered_map> <algorithm> ) add_executable(app ${APP_SOURCES}) target_link_libraries(app PRIVATE core) set_property(TARGET app PROPERTY JOB_POOL_LINK link_pool) # 按需开启 Unity Build option(ENABLE_UNITY_BUILD "Enable unity build for app" OFF) if(ENABLE_UNITY_BUILD) set_target_properties(app PROPERTIES UNITY_BUILD ON UNITY_BUILD_BATCH_SIZE 12 ) endif()配置完之后,用三个数字验证效果:全量构建时间、改一个 .cpp 后的增量构建时间、改一个公共头后的增量构建时间。这三个数字分别对应不同的优化方向,只看第一个是看不出问题的。
5.2 问题排查速查表
| 现象 | 最可能的原因 | 排查手段 | 处理方式 |
|---|---|---|---|
| 改了无关文件却全量重编 | 公共头被大量包含,或 include 路径写成 PUBLIC | ninja -d explain看触发原因 | 切割头文件依赖,改 PUBLIC 为 PRIVATE |
| 什么都没改却每次重跑某条命令 | add_custom_command的 DEPENDS 缺项或输出未标 GENERATED | ninja -d explain看该目标 | 补全 DEPENDS,加 GENERATED 属性 |
| CMake 配置阶段极慢 | execute_process、find_package重复执行 | --profiling-format=google-trace | 缓存探测结果到变量,加if(NOT DEFINED)保护 |
| PCH 加了但速度没变 | 宏、标准、编译选项不一致导致静默回退 | 查构建日志里的-include cmake_pch.hxx | 统一编译选项,或按配置分别建 PCH |
| 编译器爆内存 / OOM | 并行数过高,或单个 TU 过大 | 用/usr/bin/time -v测单 TU 峰值 | 降--parallel,给链接单独开 job pool |
| 缓存命中率低于 30% | 时间宏、绝对路径、-g调试信息 | ccache -s看 direct hit 与 preprocessed hit | 设 SLOPPINESS、BASEDIR,加-fdebug-prefix-map |
| 代码没问题但链接报重复符号 | Unity Build 合并后static或匿名命名空间重名 | 看报错里的两个符号来源文件 | 改名、收敛作用域,或用 UNITY_GROUP 拆开 |
| 增量构建突然从 30 秒变 5 分钟 | 某个生成文件的时间戳每次都在变 | 对比两次构建的ninja -d explain输出 | 检查代码生成脚本是否无条件重写输出文件 |
注意:表格里“生成文件时间戳每次在变”这一条特别隐蔽。很多代码生成脚本不管内容有没有变,都会重新写一遍输出文件,导致下游全部失效。正确做法是先写临时文件、比对内容、只有不同才覆盖,或者让生成器输出内容哈希作为判断依据。
5.3 我踩过的几个坑,说出来给你省点时间
第一个坑:PCH 不是越多越好。我一开始贪心,往 PCH 里塞了二十多个头,包括几个业务公共头。结果改一个业务头,要先重建 PCH(十几秒),再重编所有 TU,比不加 PCH 时还慢。后来我把 PCH 缩到只剩六个系统头,收益反而稳定了。判断标准很朴素:这个头一周内被改过几次?改过三次以上的,别放进去。
第二个坑:-g和缓存的关系。调试信息里默认会带源文件的绝对路径和编译目录,导致两台机器上的 .o 哈希永远不同,缓存形同虚设。加上-fdebug-prefix-map把源码根目录映射成一个固定字符串后,命中率从 20% 出头跳到 70% 以上。这个参数我在好几个项目里都加过,成本几乎为零。
第三个坑:过度依赖 Unity Build 掩盖结构问题。有个项目靠 Unity Build 把构建时间压下去了,但代码里到处是跨文件依赖隐式符号,删掉任何一个 .cpp 都可能编译不过。这属于用工具掩盖设计缺陷,短期有效、长期有害。Unity Build 应该作为“最后的加速手段”,而不是“第一选择”。
第四个坑:构建目录别放在网络盘或同步盘上。我把构建目录放到同步盘里试过一次,增量构建时间涨了三倍——同步客户端在后台不停扫描几千个 .o 文件。构建目录就应该在本地,且加入杀毒软件白名单。这类环境问题排查起来最费时间,因为没有任何构建日志会告诉你“你的同步盘在拖后腿”。
最后分享一个我自己用的小技巧:在 CMakeLists 里加一个自定义目标,一条命令打印出当前的构建健康度:
add_custom_target(build_report COMMAND ${CMAKE_COMMAND} -E echo "Build type: ${CMAKE_BUILD_TYPE}" COMMAND ${CMAKE_COMMAND} -E echo "PCH enabled targets: see compile_commands.json" COMMAND ${CMAKE_COMMAND} -E echo "Parallel level: $ENV{CMAKE_BUILD_PARALLEL_LEVEL}" COMMENT "构建配置速览" VERBATIM )听起来简陋,但它能在你怀疑“配置是不是没生效”的时候,用十秒钟给出确定性答案——这比在群里问一圈、翻半天文档要高效得多。构建优化这件事,最贵的从来不是改配置的功夫,而是在错误方向上反复折腾的时间。