28、CMake性能优化:并行构建、ccache集成、预编译头文件、构建分析

说实话,项目大了以后,最让人头疼的就是编译速度。

我记得有一次接手一个遗留项目,改一行代码,喝杯咖啡回来还没编译完。那感觉,真叫一个绝望。后来我花了整整一周时间,把CMake的构建流程从头到尾梳理了一遍,才把编译时间从40分钟压到了8分钟。

今天我就把这几招压箱底的东西掏出来,跟你聊聊。

28.1 并行构建:别让CPU闲着

并行构建,说白了就是让CPU多核干活。你想想看,现在随便一台开发机都是8核16线程,如果只用一个核编译,那不是在浪费资源吗?

CMake本身不直接控制并行度,它把这事交给了底层的构建工具。比如Make用-j,Ninja默认就是并行的。

核心思路:让CMake生成Ninja的构建文件,而不是Makefile。Ninja的并行调度比Make高效得多。

# 用Ninja替代Make
cmake -B build -G "Ninja"

# 或者指定并行任务数
cmake --build build --parallel 8

# 更聪明的做法:自动检测CPU核心数
cmake --build build --parallel $(nproc)

我个人习惯在CMakeLists.txt里加一个全局设置:

# 设置并行编译的默认行为
if(CMAKE_GENERATOR MATCHES "Makefile")
    set(CMAKE_MAKE_PROGRAM "make" CACHE STRING "Make program")
    # 自动获取CPU核心数
    include(ProcessorCount)
    ProcessorCount(N)
    if(N GREATER 0)
        set(CMAKE_BUILD_PARALLEL_LEVEL ${N})
    endif()
endif()

嗯,这里要注意:CMAKE_BUILD_PARALLEL_LEVEL这个变量是CMake 3.12才引入的。如果你还在用老版本,记得升级一下。

我的经验:Ninja比Make平均快30%左右,尤其是在增量编译场景下。我建议你直接切换到Ninja,省心。

28.2 ccache集成:缓存编译结果

ccache是个好东西。它会把编译结果缓存起来,下次同样的源文件、同样的编译选项,直接命中缓存,跳过真正的编译。

我在项目中遇到过最典型的场景:频繁切换分支,每次切回来都要重新编译。有了ccache,切回来只要几秒钟。

集成方式很简单:

# 方法一:通过CMake变量启用
set(CMAKE_C_COMPILER_LAUNCHER ccache)
set(CMAKE_CXX_COMPILER_LAUNCHER ccache)

# 方法二:更灵活的方式,支持条件启用
find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM)
    set(CMAKE_C_COMPILER_LAUNCHER "${CCACHE_PROGRAM}")
    set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}")
    message(STATUS "ccache found: ${CCACHE_PROGRAM}")
endif()

但有个坑,我曾经踩过:如果你用了CMAKE_CXX_FLAGS里带了绝对路径,ccache的命中率会直线下降。因为ccache的缓存key包含了编译选项,绝对路径一换,缓存就废了。

避坑指南:我曾经因为-I/path/to/somewhere这种绝对路径,导致ccache命中率只有20%。后来全部改成相对路径,命中率回到了90%以上。

检查ccache是否生效:

# 查看缓存统计
ccache -s

# 清空缓存(如果遇到奇怪的问题)
ccache -C

我个人习惯在CI脚本里把ccache的缓存目录挂载到持久化存储上,这样不同构建之间也能共享缓存。

28.3 预编译头文件:减少重复解析

预编译头文件(PCH)的原理很简单:把那些不怎么变的标准库头文件(比如<iostream><vector>)提前编译成二进制格式,后续编译直接加载,省去了解析时间。

CMake从3.16开始原生支持PCH,用法如下:

# 指定预编译头文件
target_precompile_headers(my_target PRIVATE
    
    
    
    "my_common.h"
)

# 也可以对多个目标共享
target_precompile_headers(my_target INTERFACE
    
    
)

这里有个细节:PRIVATE表示只对当前目标生效,INTERFACE表示所有链接这个目标的目标都会继承。

核心原则:只把那些几乎不变的头文件放进PCH。频繁修改的头文件放进去,反而会因为PCH失效而拖慢编译。

我建议你这样做:

  1. 先分析项目里哪些头文件被大量包含
  2. 把这些头文件列出来,放进PCH
  3. 定期检查PCH的命中率

举个例子,一个典型的游戏引擎项目:

target_precompile_headers(engine PRIVATE
    
    
    
    "core/types.h"
    "core/math.h"
)

嗯,这里要注意:PCH不是越多越好。我见过有人把整个项目的头文件都塞进PCH,结果编译时间反而变长了。因为PCH本身也要编译,太大反而得不偿失。

28.4 构建分析:找到瓶颈

优化之前,先得知道瓶颈在哪。CMake提供了几种分析手段。

28.4.1 使用--trace--profiling-output

CMake 3.20+支持生成构建分析报告:

# 生成构建分析数据
cmake --build build --profiling-output build_profile.json

# 然后用Chrome的tracing工具打开这个JSON文件
# 或者用在线工具分析

这个JSON文件里记录了每个编译任务的耗时、依赖关系。你可以直观地看到哪些文件编译最慢。

28.4.2 使用ninja -t graph

如果你用Ninja,可以生成依赖图:

# 生成依赖图(dot格式)
ninja -t graph build/my_target > graph.dot

# 转换成图片
dot -Tpng graph.dot -o graph.png

我个人习惯用ninja -t targets先看看有哪些目标,然后再针对性地分析。

28.4.3 手动计时

有时候,简单粗暴的方法最有效:

# 对单个目标计时
time cmake --build build --target my_slow_target

# 对比优化前后的差异
# 优化前:real 2m30s
# 优化后:real 0m45s

我的经验:构建分析不要只看总时间,要看每个文件的编译时间分布。通常20%的文件占了80%的编译时间。找到这些"热点文件",优先优化它们。

28.5 综合优化策略

这几招组合起来,效果最好。我画了一张图,帮你理清思路:

CMake性能优化策略全景图 CMake构建优化 并行构建 ccache缓存 预编译头文件 构建分析 Ninja替代Make 自动检测CPU核心 增量编译优化 缓存编译结果 避免绝对路径 CI共享缓存 减少头文件解析 只放稳定头文件 PRIVATE/INTERFACE --profiling-output ninja -t graph 热点文件分析 组合使用效果最佳 先分析 → 找到瓶颈 → 针对性优化 → 验证效果

我建议你按这个顺序来:

  1. 先做构建分析,找到最慢的环节
  2. 切换到Ninja,获得立竿见影的效果
  3. 集成ccache,减少重复编译
  4. 配置PCH,优化头文件解析
  5. 再次分析,验证优化效果

最终效果:我自己的一个项目,经过这四步优化,全量编译从40分钟降到8分钟,增量编译从5分钟降到30秒。你想想看,每天省下来的时间能做多少事。

好了,关于CMake性能优化,我就聊这么多。记住一点:优化不是一锤子买卖,项目在变,依赖在变,定期做构建分析,才能保持编译速度在最佳状态。


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