在 C++ 项目里,「改一行代码等十分钟」是常态。根因不是编译器慢,而是同一批头文件被成百上千次重复解析:一个 #include <vector> 在标准库实现里可能展开出两万行代码,乘以翻译单元数量,就是绝大部分的前端时间。优化编译速度的核心思路只有两条——减少每个 TU 需要解析的代码量,以及让解析结果可以被复用。
一、先度量再优化
1.1 GCC 的 -ftime-report
GCC 的 -ftime-report 在编译结束后打印各阶段耗时,是最低成本的入口。
g++ -std=c++20 -O2 -c heavy.cpp -o heavy.o -ftime-report
Time variable usr sys wall
phase setup : 0.00 ( 0%) 0.00 ( 0%) 0.00 ( 0%)
phase parsing : 1.85 ( 68%) 0.32 ( 74%) 2.18 ( 69%)
phase lang. deferred : 0.21 ( 8%) 0.01 ( 2%) 0.22 ( 7%)
phase opt and generate : 0.63 ( 23%) 0.10 ( 23%) 0.72 ( 23%)
template instantiation : 0.91 ( 33%) 0.14 ( 32%) 1.06 ( 34%)
关注三行:phase parsing 高说明头文件解析是瓶颈,template instantiation 高说明模板实例化失控,phase opt and generate 高说明优化器在单个函数上花了太多时间。三者对应的优化手段完全不同。
1.2 Clang 的 -ftime-trace
Clang 的 -ftime-trace 生成 Chrome Tracing 格式的 JSON,能看到「哪个头文件、哪个模板实例消耗了多少毫秒」,粒度远超 -ftime-report。
clang++ -std=c++20 -O2 -c heavy.cpp -o heavy.o -ftime-trace
# 生成 heavy.json,用 chrome://tracing 或 ui.perfetto.dev 打开
火焰图中最有价值的几个事件名:
Frontend:整个前端(预处理 + 解析 + 语义分析)Source/ParseClass/ParseTemplate:具体文件与语法结构InstantiateFunction/InstantiateClass:模板实例化PerformPendingInstantiations:延迟实例化,常是隐藏大头CodeGen Function/Backend:代码生成与后端
-ftime-trace-granularity=500 是默认值(单位微秒),调小会记录更多细粒度事件但增大 JSON 体积。
1.3 其他度量手段
# 打印每个 TU 实际包含的头文件(含嵌套层级)
g++ -std=c++20 -H -c heavy.cpp -o /dev/null 2>&1 | head -40
# 统计预处理后的行数,直接反映 TU 规模
g++ -std=c++20 -E heavy.cpp | wc -l
# 统计某个头文件被多少 TU 直接包含
grep -rl '#include <vector>' src/ | wc -l
预处理后行数是很有用的单一指标:一个健康的 C++ 翻译单元通常在 5 万到 20 万行之间,超过 50 万行基本可以确定存在头文件滥用。
二、头文件依赖治理
2.1 用前置声明替代 include
头文件里如果需要的是引用或指针,通常不需要完整类型定义,前置声明即可。
// 差:为了一个引用把整个头拖进来
#include "engine/renderer.h"
class Scene { Renderer& renderer_; };
// 好:前置声明,不引入任何依赖
class Renderer;
class Scene { Renderer& renderer_; };
前置声明的限制:不能用于继承、不能用于按值成员、不能用于 std::unique_ptr 的析构(析构需要完整类型,这也是 PIMPL 必须把析构函数放到 .cpp 的原因)、不能用于模板实参的实例化。
2.2 iosfwd 与 PIMPL
标准库同样提供了轻量替代头:<iosfwd> 只声明流类型,<cstddef> 只提供 std::size_t。
// 差:头文件里只用到了 ostream& 却引入整个 iostream
#include <iostream>
void log(std::ostream& os, const char* msg);
// 好:只声明,实现文件里再 include <iostream>
#include <iosfwd>
void log(std::ostream& os, const char* msg);
把实现细节藏进 PIMPL,可以让头文件完全不依赖具体类型,是减少传递依赖最彻底的手段。代价是每次访问多一次间接寻址,以及需要处理移动语义与析构函数的位置。
2.3 include-what-you-use
IWYU(include-what-you-use)基于 Clang 分析「每个符号实际来自哪个头文件」,给出精确的增删建议。
# 生成编译数据库
cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
# 批量分析(iwyu_tool.py 随 IWYU 一起发布)
iwyu_tool.py -p build -- -Xiwyu --mapping_file=iwyu.imp > iwyu.out
fix_includes.py < iwyu.out
IWYU 的规则是「每个 TU 只包含自己直接使用的符号所在的头文件,不依赖传递包含」。严格执行后,头文件的传递依赖会显著收敛,配合 Unity Build 效果更好。注意 IWYU 会对标准库给出偏激进的建议(例如建议直接包含 <bits/...> 内部头),需要映射文件约束。
三、预编译头 PCH
3.1 CMake 的 target_precompile_headers
PCH 把一批稳定头文件预编译成二进制形式,后续 TU 直接加载,跳过解析与语义分析。
cmake_minimum_required(VERSION 3.16)
project(speed_demo CXX)
add_executable(myapp src/main.cpp src/scene.cpp src/render.cpp)
# 只对本目标生效;列出的头文件按顺序预编译
target_precompile_headers(myapp PRIVATE
<vector>
<string>
<memory>
<unordered_map>
<algorithm>
"src/common/pch.hpp"
)
target_compile_features(myapp PRIVATE cxx_std_20)
PRIVATE 表示只给本目标用,PUBLIC 会把 PCH 声明导出给依赖者,INTERFACE 则只给依赖者。若多个目标共享同一套 PCH,可以用 REUSE_FROM 避免重复编译:
target_precompile_headers(myapp2 PRIVATE REUSE_FROM myapp)
3.2 PCH 的收益边界与坑
实测经验:一个包含 <vector>、<string>、<unordered_map> 的 PCH,能让每个 TU 的前端时间下降 30% 到 60%;TU 越多、头文件越重,收益越大。
但 PCH 有几条硬约束:
- 必须第一个被包含。若某个
.cpp在包含 PCH 之前先#define了影响头文件语义的宏,GCC 会报-Winvalid-pch并静默放弃 PCH,编译变慢却没有任何提示。 - 编译选项必须完全一致。
-std、-D、-I、优化级别有任何差异都会导致 PCH 失效;CMake 会自动处理,手写 Makefile 时极易出错。 - 头文件一变,所有 TU 全部重编。因此 PCH 里只放几乎不修改的头(标准库、第三方库、平台头)。
- 与 ccache 的交互:PCH 会让 ccache 的命中率下降,因为预编译产物本身很大且随编译选项变化。若 ccache 命中率是主要收益来源,PCH 的净收益需要实测。
# 验证 PCH 是否真的生效
g++ -std=c++20 -include src/common/pch.hpp -c src/scene.cpp -o /dev/null -H 2>&1 | head -3
# 输出第一行应为 "! src/common/pch.hpp.gch"(! 表示使用了预编译头)
四、C++20 Modules
模块从根本上解决「重复解析」问题:模块接口单元(.cppm/.ixx)只被编译一次,生成 BMI(Binary Module Interface),导入方直接加载,不做文本展开。
// math.cppm —— 模块接口单元
export module math;
export int add(int a, int b) { return a + b; }
export template <typename T> T square(T x) { return x * x; }
// main.cpp
import math; // 只加载 BMI,不解析任何文本
#include <iostream> // 标准库仍走传统路径(C++23 起可写 import std;)
int main() { std::cout << add(1, 2) << " " << square(3) << "\n"; }
cmake_minimum_required(VERSION 3.28) # 模块支持需要 3.28+
project(mod_demo CXX)
add_executable(mod_demo)
target_sources(mod_demo
PRIVATE main.cpp
PUBLIC FILE_SET CXX_MODULES FILES math.cppm
)
target_compile_features(mod_demo PRIVATE cxx_std_20)
编译器支持现状:Clang 16+ 较完整,GCC 14+ 可用但仍有边界问题,MSVC 19.34+ 支持良好。对编译速度的影响是双向的:省下了重复解析,但增加了依赖扫描(scanner)与 BMI 序列化/反序列化的开销。在头文件依赖极重的项目里实测常见 20% 到 40% 的总体提升;在 TU 很小、头文件很轻的项目里可能反而变慢。迁移建议从叶子模块开始,不要一次性全量改造。
五、Unity Build
5.1 CMAKE_UNITY_BUILD
Unity Build(也叫 jumbo build)把多个 .cpp 合并成一个大的翻译单元编译,直接消灭重复的头文件解析。
set(CMAKE_UNITY_BUILD ON)
set(CMAKE_UNITY_BUILD_BATCH_SIZE 16) # 每批合并 16 个源文件,默认 8
# 也可以按目标单独开启
set_target_properties(mylib PROPERTIES
UNITY_BUILD ON
UNITY_BUILD_MODE BATCH # 或 GROUP,GROUP 由开发者显式指定分组
UNITY_BUILD_BATCH_SIZE 8
)
实测:一个 500 个 .cpp 的项目开启 Unity Build 后,编译时间常能下降 40% 到 70%,代价是增量编译粒度变粗——改一个 .cpp 要重编整批。
5.2 冲突与 ODR 问题
Unity Build 会暴露代码中原本「靠文件隔离」而侥幸没出问题的写法:
- 匿名命名空间或
static符号同名:两个.cpp各自定义namespace { int helper(); },合并后重定义报错 using namespace std;冲突:合并后产生歧义- 宏泄漏:A.cpp 里
#define MIN(a,b),B.cpp 里用了同名函数 - 文件作用域变量重名:
static int counter;在两个文件里各有一份 #include顺序依赖:某文件依赖前一个文件引入的头
修复方向是「让每个 .cpp 自包含」——这本来就是好习惯。个别无法合并的文件可以排除:
set_source_files_properties(legacy/ugly.cpp PROPERTIES
SKIP_UNITY_BUILD_INCLUSION ON)
六、编译缓存
6.1 ccache
ccache 以「预处理后的源码 + 编译选项 + 编译器版本」为键做哈希,命中则直接复用目标文件。
sudo apt install ccache
ccache --max-size=20G
ccache --set-config=compression=true
# 在 CI 上先清零统计再构建,最后打印命中率
ccache --zero-stats
cmake --build build -j"$(nproc)"
ccache -s
Cacheable calls: 1204 / 1210 (99.50%)
Hits: 987 / 1204 (81.98%)
Misses: 217 / 1204 (18.02%)
Local storage:
Cache size (GB): 3.42 / 20.00 (17.10%)
CMake 集成:
find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM)
set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}")
set(CMAKE_C_COMPILER_LAUNCHER "${CCACHE_PROGRAM}")
endif()
6.2 sccache 与 distcc
sccache 是 ccache 的替代品,最大优势是支持远程共享缓存(S3、GCS、Redis、Azure),让 CI 上不同机器、不同 job 之间共享编译产物。
set(CMAKE_CXX_COMPILER_LAUNCHER sccache)
export SCCACHE_BUCKET=my-ci-cache
export SCCACHE_REGION=us-east-1
export SCCACHE_S3_USE_SSL=true
sccache --show-stats
distcc 把编译分发到多台机器。要求所有机器的编译器版本、目标架构完全一致,否则会静默产生错误产物或直接失败。
export DISTCC_HOSTS='localhost/8 10.0.0.11/16 10.0.0.12/16'
export CCACHE_PREFIX=distcc # ccache 在前,distcc 在后
cmake --build build -j 32
注意 distcc 只加速编译,不加速预处理与链接,且在头文件庞大的项目里网络传输成本可能抵消收益。
6.3 命中率调优
缓存不命中的常见原因与对策:
__DATE__/__TIME__/__TIMESTAMP__:每次预处理结果都不同。用CCACHE_SLOPPINESS=time_macros忽略,或改用构建系统注入的版本号宏。- 绝对路径出现在调试信息或
__FILE__中:加-fdebug-prefix-map=$PWD=.与-ffile-prefix-map=$PWD=.归一化。 -g的随机种子:GCC 加-frandom-seed=<stable>;CI 上还应固定工作目录。- PCH:加
CCACHE_SLOPPINESS=pch_defines,time_macros。
export CCACHE_SLOPPINESS=time_macros,include_file_ctime,include_file_mtime,pch_defines
export CCACHE_COMPILERCHECK=content # 按编译器内容而非 mtime 判断
七、链接器与并行
7.1 lld 与 mold
链接阶段在大型项目里能占掉总构建时间的 30% 以上,换链接器是收益最高的一步。
# GNU ld(默认)→ gold → lld → mold,速度依次提升
clang++ -fuse-ld=lld ... # 或 clang++ -fuse-ld=mold ...
set(CMAKE_LINKER_TYPE MOLD) # CMake 3.29+ 可直接指定
add_link_options(-fuse-ld=mold) # 旧版本用链接选项
实测参考(链接一个 2 GB 的调试版可执行文件):GNU ld 约 42 秒,gold 约 18 秒,lld 约 6 秒,mold 约 2.5 秒。mold 支持增量链接(-Wl,--incremental),二次链接可降到亚秒级。其他值得开启的选项:-pipe 避免中间临时文件、-gsplit-dwarf 拆分调试信息降低链接内存、-fno-var-tracking-assignments 关闭 GCC 在 -g 下的变量跟踪、-flto=thin 用 ThinLTO 换取接近全量 LTO 的收益。
7.2 并行度与内存权衡
cmake --build build -j 32 # 按核数并行
ninja -C build -j 64 -l 8 # 同时限制负载,避免内存吃满换页
/usr/bin/time -v g++ -std=c++20 -O2 -c heavy.cpp -o /dev/null 2>&1 | grep Maximum
模板密集的文件单个编译进程可能占用 4 GB 以上内存。并行度不是越高越好,-j 超过物理内存能容纳的进程数后,交换会拖慢整体。经验公式:-j = min(核数, 可用内存 / 单进程峰值内存)。对于少数几个超重文件,可以用 SKIP_UNITY_BUILD_INCLUSION 之外的另一种手段——把它们单独拆到独立目标,避免拖慢整批。
八、CI 上的增量构建策略
把上述手段组合成一套 CI 策略:
- 缓存 ccache/sccache 目录。GitHub Actions 用
actions/cache缓存~/.cache/ccache,或直接用 sccache 配 S3 后端,让所有 job 共享。 - 固定构建路径与工具链。用容器镜像固定编译器版本,用
-ffile-prefix-map抹平路径差异。 - 分层目标。把第三方库(几乎不变)与项目代码拆成不同 CMake 目标,第三方库单独构建并缓存产物。
- PCH 只放第三方头。项目内部头放进 PCH 会让每次改动都触发全量重编。
- 把 ccache 命中率当作 CI 健康指标:构建前后跑
ccache --zero-stats与ccache -s,低于 60% 就说明配置有问题。 -j与内存监控。CI runner 内存通常小于开发机,把并行度调到内存上限的 70%。
# .github/workflows/build.yml 片段
- name: Configure ccache
run: |
ccache --max-size=5G
ccache --zero-stats
- name: Build
run: cmake --build build -j 8
- name: Cache stats
run: ccache -s
相关阅读
- https://plumephp.com/cpp-cmake-project/ — target 模型、编译选项传播与构建目录组织
- https://plumephp.com/cpp-compilation-linking/ — 预处理、编译、汇编、链接四阶段的分工
- https://plumephp.com/cpp-modules-build-system/ — C++20 模块的语言机制与工程落地
延伸阅读
- https://plumephp.com/cpp-cross-platform-build-matrix/ — CMake Presets 与多平台 CI 矩阵
- https://plumephp.com/cpp-engineering-practices/ — 代码组织与依赖管理规范
- https://plumephp.com/cpp-package-management-vcpkg-conan/ — 第三方依赖如何影响构建时间
文末完整示例
# CMakeLists.txt —— 一套可直接使用的编译加速配置
cmake_minimum_required(VERSION 3.28)
project(speed_demo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# ---- 1. 统一使用 Ninja + 可选链接器 ----
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE RelWithDebInfo)
endif()
add_link_options(-fuse-ld=mold) # 没有 mold 就删掉这一行
# ---- 2. ccache 作为编译器前端 ----
find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM)
set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}")
message(STATUS "ccache: ${CCACHE_PROGRAM}")
endif()
# ---- 3. 全局编译选项 ----
add_compile_options(
-pipe
-fno-var-tracking-assignments # 只在 -g 时生效,显著减少调试信息开销
-fdebug-prefix-map=${CMAKE_SOURCE_DIR}=.
-ffile-prefix-map=${CMAKE_SOURCE_DIR}=.
-frandom-seed=speed-demo # 稳定 ccache 哈希
)
# ---- 4. 主可执行文件 ----
add_executable(myapp src/main.cpp src/scene.cpp src/render.cpp)
# ---- 5. 预编译头:只放稳定的第三方与标准库头 ----
target_precompile_headers(myapp PRIVATE
<vector> <string> <memory> <unordered_map> <algorithm>)
# ---- 6. 对历史遗留目标开启 Unity Build ----
add_library(legacy STATIC legacy/a.cpp legacy/b.cpp legacy/c.cpp)
set_target_properties(legacy PROPERTIES UNITY_BUILD ON UNITY_BUILD_BATCH_SIZE 4)
# 配套的构建与验证命令
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build -j"$(nproc)"
# 验证 ccache 命中率
ccache --zero-stats && cmake --build build -j"$(nproc)" && ccache -s
# 定位单文件瓶颈
clang++ -std=c++20 -ftime-trace -c src/scene.cpp -o /dev/null
配合 build/compile_commands.json,即可运行 iwyu_tool.py -p build 做头文件依赖清理,形成「度量 → 治理 → 缓存 → 并行」的完整闭环。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。