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失效而拖慢编译。
我建议你这样做:
- 先分析项目里哪些头文件被大量包含
- 把这些头文件列出来,放进PCH
- 定期检查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 综合优化策略
这几招组合起来,效果最好。我画了一张图,帮你理清思路:
我建议你按这个顺序来:
- 先做构建分析,找到最慢的环节
- 切换到Ninja,获得立竿见影的效果
- 集成ccache,减少重复编译
- 配置PCH,优化头文件解析
- 再次分析,验证优化效果
最终效果:我自己的一个项目,经过这四步优化,全量编译从40分钟降到8分钟,增量编译从5分钟降到30秒。你想想看,每天省下来的时间能做多少事。
好了,关于CMake性能优化,我就聊这么多。记住一点:优化不是一锤子买卖,项目在变,依赖在变,定期做构建分析,才能保持编译速度在最佳状态。
公众号:蓝海资料掘金营,微信deep3321