25、性能优化:并行构建控制、Unity构建、预编译头文件、链接时优化

说实话,项目规模一大,编译速度就成了团队的痛点。

我记得刚接手一个百万行级别的C++项目时,一次完整构建要跑40分钟。开发人员每天等编译的时间,加起来够看一部电影了。这谁受得了?

好在CMake给了我们几把利器。今天我就把这些压箱底的东西掏出来,跟你好好聊聊。

25.1 并行构建控制:让CPU火力全开

现在的机器,哪个不是8核16线程起步?但默认情况下,CMake只用一个核在干活。你想想看,这不是暴殄天物吗?

控制并行构建的核心,就是告诉构建系统:你可以用多少个任务同时干活。

25.1.1 通过CMake命令行控制

最直接的方式,就是在构建时指定并行数:

# 使用4个并行任务
cmake --build build_dir -j4

# 让CMake自动检测CPU核心数
cmake --build build_dir -j

# 或者用Ninja构建系统(默认就是并行的)
cmake -G Ninja -B build_dir
cmake --build build_dir

我个人习惯用 -j 不带参数,让CMake自己判断。但要注意,有些老项目在极端并行下会出问题,这时候就得手动限制一下。

⚠️ 我曾经踩过的坑: 有一次在CI服务器上用了 -j,结果服务器有64个核,直接拉满了所有资源。构建是快了,但其他服务全挂了。后来我学乖了,CI环境里一般限制为 -j$(nproc --ignore=2),留两个核给系统。

25.1.2 在CMakeLists.txt中设置默认并行数

你也可以在项目层面设置默认值,但注意这只是个建议值:

# 设置Makefile生成器的默认并行数
set(CMAKE_MAKE_PROGRAM "make -j4" CACHE STRING "Make program with parallel jobs")

# 或者更优雅的方式:通过全局属性
set_property(GLOBAL PROPERTY JOB_POOLS "compile_pool=4" "link_pool=1")
set_property(TARGET myapp PROPERTY JOB_POOL_COMPILE compile_pool)
set_property(TARGET myapp PROPERTY JOB_POOL_LINK link_pool)

这里有个小技巧:链接通常比编译更吃内存,所以我习惯把链接池设小一点,比如只允许1个链接任务同时进行。

25.2 Unity构建:把多个源文件合并编译

Unity构建,说白了就是把多个.cpp文件合并成一个大的.cpp文件来编译。这样做的好处是:头文件只需要解析一次,编译速度能提升30%-50%。

为什么会这样?因为C++编译最耗时的部分其实是头文件的解析和展开。你把10个.cpp文件合并,头文件就只解析一次,而不是10次。

25.2.1 手动Unity构建

最原始的方式,就是自己写一个合并文件:

// unity_1.cpp
#include "src/module_a.cpp"
#include "src/module_b.cpp"
#include "src/module_c.cpp"

// unity_2.cpp
#include "src/module_d.cpp"
#include "src/module_e.cpp"

然后只编译这两个unity文件。但这种方式太原始了,维护起来很痛苦。

25.2.2 CMake内置的Unity构建支持

CMake 3.16开始,官方支持了Unity构建。用法很简单:

# 开启Unity构建
set(CMAKE_UNITY_BUILD ON)

# 或者针对特定目标开启
set_property(TARGET myapp PROPERTY UNITY_BUILD ON)

# 控制每个Unity文件包含多少个源文件(默认是8)
set_property(TARGET myapp PROPERTY UNITY_BUILD_BATCH_SIZE 16)
💡 我的经验: Unity构建不是万能的。如果两个源文件定义了同名的全局变量或函数,合并后就会冲突。所以Unity构建更适合那些模块化做得好的项目。我一般在开发阶段开启Unity构建加速编译,发布版本时关掉,避免潜在的符号冲突。

25.2.3 条件式Unity构建

实际项目中,我更喜欢这样用:

# 只在Debug模式下开启Unity构建
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
    set(CMAKE_UNITY_BUILD ON)
    message(STATUS "Unity构建已开启,加速Debug编译")
endif()

# 排除某些文件不参与Unity构建
set_property(SOURCE src/hot_reload.cpp PROPERTY UNITY_GROUP "")
set_property(SOURCE src/config.cpp PROPERTY UNITY_GROUP "")

嗯,这里要注意:频繁修改的文件最好排除在Unity构建之外。否则改一个文件,整个Unity单元都得重新编译,反而更慢。

25.3 预编译头文件:把稳定的头文件缓存起来

预编译头文件(PCH)的原理很简单:把那些几乎不变的头文件(比如STL、第三方库)预先编译成二进制格式,后续编译直接加载缓存,不用再解析一遍。

效果有多明显?我见过一个项目,加上PCH后编译时间从15分钟降到了4分钟。

25.3.1 CMake中的PCH配置

CMake 3.16开始,PCH的支持也变得很优雅了:

# 创建一个预编译头文件
target_precompile_headers(myapp PRIVATE
    <vector>
    <string>
    <memory>
    <algorithm>
    "myapp/common.h"
    "myapp/config.h"
)

# 也可以让所有目标共享同一个PCH
target_precompile_headers(myapp INTERFACE
    <vector>
    <string>
)
🔑 关键点:
  • PRIVATE:只对当前目标生效
  • INTERFACE:当前目标和所有链接的目标都生效
  • 尖括号 <> 表示系统头文件,引号 "" 表示项目头文件

25.3.2 PCH的最佳实践

我总结了几条经验:

  • 只放稳定的头文件:频繁修改的头文件放进去,每次改完都得重新生成PCH,得不偿失
  • 控制PCH的大小:太大的PCH反而会拖慢编译,我一般控制在50-100个头文件
  • 不同模块用不同的PCH:GUI模块和核心算法模块的头文件依赖不同,分开更高效
⚠️ 我曾经踩过的坑: 有一次我把一个经常改的配置文件放进了PCH,结果每次改配置,整个项目都要重新编译。同事们都以为电脑坏了。后来我专门写了个脚本,定期分析头文件的修改频率,自动生成PCH列表。

25.4 链接时优化:让编译器在链接阶段再做一次优化

链接时优化(LTO)是个好东西。它让编译器在链接阶段能看到所有目标文件的代码,从而做更激进的优化。

说白了,就是让编译器有全局视野。没有LTO时,每个.cpp文件是独立编译的,编译器不知道别的文件里有什么。有了LTO,编译器就能做跨模块的内联、死代码消除等优化。

25.4.1 开启LTO

CMake中开启LTO非常简单:

# 全局开启LTO
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON)

# 或者针对特定目标开启
set_property(TARGET myapp PROPERTY INTERPROCEDURAL_OPTIMIZATION ON)

# 检查编译器是否支持LTO
include(CheckIPOSupported)
check_ipo_supported()
if(IPO_SUPPORTED)
    set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON)
    message(STATUS "LTO已开启")
else()
    message(WARNING "编译器不支持LTO,跳过")
endif()

25.4.2 LTO的代价

LTO不是免费的午餐。它有两个主要代价:

代价 说明 我的建议
链接时间变长 链接阶段要做优化,时间可能增加2-5倍 只在Release版本开启,Debug版本关掉
内存消耗大 链接器需要加载所有目标文件的中间表示 确保有足够内存,否则会触发交换
调试困难 优化后的代码和源码对应关系变差 Debug版本不要开LTO

25.4.3 实际项目中的LTO配置

我一般这样配置:

# 只在Release和RelWithDebInfo模式下开启LTO
if(CMAKE_BUILD_TYPE MATCHES "Release|RelWithDebInfo")
    include(CheckIPOSupported)
    check_ipo_supported(RESULT result)
    if(result)
        set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON)
        # 对于大项目,可以只对核心库开启
        set_property(TARGET core_lib PROPERTY INTERPROCEDURAL_OPTIMIZATION ON)
        set_property(TARGET myapp PROPERTY INTERPROCEDURAL_OPTIMIZATION ON)
    endif()
endif()
💡 小技巧: 如果你用GCC,可以试试 -flto=auto 参数,它会自动选择合适的并行数。Clang的话,-flto=thin 是更轻量的LTO,链接速度更快,优化效果接近全量LTO。

25.5 知识体系总览

说了这么多,我把这四种优化手段的关系整理成了一张图,方便你理解:

CMake性能优化四大利器 构建性能优化 并行构建控制 Unity构建 预编译头文件 链接时优化 -j参数 / Ninja / 作业池 UNITY_BUILD / 批处理大小 target_precompile_headers INTERPROCEDURAL_OPTIMIZATION 最佳实践组合 Debug模式:并行构建 + Unity构建 + 预编译头 Release模式:并行构建 + 预编译头 + LTO CI环境:限制并行数 + 预编译头(避免资源耗尽)

你看,这四种优化手段各有侧重,但组合起来效果最好。我个人习惯在开发阶段用「并行构建 + Unity构建 + 预编译头」的组合,发布版本再加上LTO。

最后说一句:优化编译速度不是一锤子买卖。项目在变,依赖在变,编译器的版本也在变。定期用 cmake --build . --target clean 做一次完整构建,记录时间,才能知道优化效果是否还在。


公众号:蓝海资料掘金营,微信deep3321