C++ 编译速度优化:PCH、Unity Build、ccache 与 IWYU

大型 C++ 项目的编译时间常以小时计,大部分浪费在重复解析头文件上。本文从 -ftime-report 与 -ftime-trace 度量入手,讲解 IWYU 头文件治理、PCH、C++20 Modules 与 Unity Build,再谈 ccache 与 lld/mold。

在 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 做头文件依赖清理,形成「度量 → 治理 → 缓存 → 并行」的完整闭环。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cpp」更多文章

  1. C++ Unicode 与文本处理:编码转换与高性能字符串
  2. C++ 数值计算与线性代数:Eigen 与表达式模板
  3. C++ 静态分析与代码质量工具链:clang-tidy 与 Clang Static Analyzer