增量编译
背景
C++ 的 #include 是文本替换——预处理器将被包含的头文件内容原样插入到源文件中。一个只有几十行用户代码的源文件,经过 #include 展开后,可能膨胀到数万行甚至更多。例如,仅 #include <vector> 一条指令就会引入数千行标准库代码,如果再加上项目自身的头文件,展开后的代码量可以轻松超过十万行。
语言服务器需要在每次用户编辑后重新编译文件,以提供最新的诊断、补全和语义信息。如果每次都完整编译这十万行代码,延迟会达到数秒,显然不可接受。
但观察用户的实际编辑行为,会发现一个关键特征:文件头部的 #include 区域很少变化,真正频繁变化的只是底部的用户代码。在一次典型的编辑会话中,用户可能修改了上百次代码,但 #include 区域一次也没动过。
#include <vector>
#include <string>
#include <map>
#include "project/config.h"
#include "project/logging.h"
// ── preamble ↑ 变化慢,编译结果可以缓存 ──
// ── 用户代码 ↓ 变化快,每次需要重新编译 ──
void process(const std::vector<std::string>& data) {
// ...
}基于这个观察,C++ 语言服务器普遍采用 preamble 分离策略:将文件分为头部的 preamble(预处理指令区域)和剩余的用户代码。Preamble 编译为预编译头文件(PCH)并缓存,后续编译直接加载 PCH,只重新处理用户代码。这样每次编辑后的重编译只涉及几十行到几百行代码,延迟通常可以控制在一秒以内。
clangd 也采用了这一策略,但在失效检测和生命周期管理方面存在一些设计层面的不足:
内存占用高。clangd 将 preamble 的编译产物保留在进程内存中。对于大型项目,多个打开文件的 preamble AST 会消耗大量内存,长时间运行后内存占用持续增长(clangd #251、#115)。
崩溃后 PCH 文件泄漏。clangd 使用临时文件存储磁盘上的 PCH。崩溃时 RAII 清理无法执行,临时文件残留在
/tmp中。在多用户服务器上,累积的泄漏文件会耗尽/tmp空间(clangd #209、#255)。失效检测不够精确。当头文件被 touch 但内容未变时(常见于构建工具的依赖扫描、版本控制的分支切换),仅靠修改时间(mtime)判断会导致不必要的 PCH 重建。在大型项目中,这种误报造成的重复构建会明显影响编辑体验。
重启后需要冷启动。PCH 不跨会话持久化,服务器重启后需要为所有打开的文件重新构建 PCH。
clice 针对这些问题重新设计了增量编译机制:磁盘持久化的内容寻址 PCH 存储、两层失效检测、拉取式编译模型和 preamble 完整性检测。
设计
增量编译围绕四个核心概念组织:preamble 分离定义了"缓存什么",两层失效检测定义了"何时重建",拉取式编译定义了"何时触发",内容寻址存储定义了"怎么存"。
Preamble 分离
Preamble 是源文件头部由预处理指令(#include、#define、#pragma 等)和模块声明(module;)组成的区域。Preamble 的结束位置由一个字节偏移量(bound)标识——第一行非预处理内容之前的字节位置。
Preamble 编译为 PCH 文件后缓存在磁盘上。后续编译加载 PCH 后,只需要处理 bound 之后的用户代码。如果 preamble 为空(bound 为零),则不需要 PCH,直接跳过整个 PCH 流程。
PCHState 是 PCH 在缓存中的条目,包含:
- PCH 文件在磁盘上的路径
- preamble 内容的哈希值
- preamble 的字节边界(bound)
- 依赖快照(
DepsSnapshot,见下文) - 与 PCH 配对存储、共享生命周期的 preamble 状态 blob 的句柄:构建时提取的 preamble 符号索引和功能状态(document links、非活跃区域、未闭合条件栈),以内存映射 FlatBuffer 形式打开、按需懒查询(见符号索引)
两层失效检测
PCH 缓存了 preamble 中所有被 #include 的头文件的预处理结果。当任何一个依赖头文件的内容发生变化时,PCH 就已过时,需要重建。问题在于如何精确判断"内容是否真的变了"。
最直接的方案是检查文件修改时间(mtime):如果所有依赖文件的 mtime 都不晚于 PCH 的构建时间,说明没有文件被修改过。这种检查只需 stat 系统调用,非常快。但 mtime 检查会产生误报:构建工具的依赖扫描、版本控制的分支切换、编辑器的自动保存等操作都会更新 mtime 而不改变文件内容。
另一个方案是直接比较文件内容的哈希值:每次检查时重新计算所有依赖文件的哈希,与构建时记录的哈希比较。这种方案完全精确,但需要读取和计算所有依赖文件的内容。一个典型的 C++ 文件可能依赖数百个头文件,每次检查都全量哈希的 I/O 开销不可忽视。
clice 将两者结合为两层检测策略:
- 第一层(mtime 快速筛选):遍历所有依赖文件,比较每个文件的 mtime 与 PCH 的构建时间戳。如果所有 mtime 都不晚于构建时间戳,PCH 有效,直接复用。
- 第二层(内容哈希精确验证):对第一层标记为"可能已修改"的文件(mtime 晚于构建时间戳),重新计算其 xxh3 内容哈希,与构建时记录的哈希比较。只有哈希不一致时才触发重建。
第一层过滤掉了绝大多数未变化的文件(常态路径),第二层消除了 mtime 误报(构建工具 touch、VCS checkout 等场景)。两层结合的效果是:只有依赖文件的内容真正发生变化时才重建 PCH。
DepsSnapshot 是两层检测的基础数据结构,在 PCH 构建完成时捕获。它记录所有依赖文件的路径标识、内容哈希和构建时间戳。
拉取式编译
clice 采用拉取式(pull-based)编译模型:编译不在文件变更时立即触发,而是在功能请求(hover、补全、语义高亮等)需要最新 AST 时按需触发。
当用户编辑文件时(didChange),主进程只更新内存中的文件内容并标记 AST 为脏(ast_dirty),不启动任何编译。当功能请求到达时,编译服务检查 AST 是否脏或是否因外部文件变化而过时,如果需要重编译,则先确保 PCH 和模块依赖就绪,再将编译任务发送到工作进程。
这种模型的好处是避免了用户快速连续输入时的无效编译。用户每秒可能触发十几次 didChange,但只有当鼠标悬停、请求补全等实际需要编译结果时,才执行一次编译。
注意"外部文件变化"和"用户编辑"是两条独立的脏标记路径。用户编辑通过
didChange标记ast_dirty;外部文件变化(如依赖的头文件被修改)由 stat 轮询的文件追踪器主动发现,其事件经失效引擎折叠后把受影响文件标脏。编译前的两层失效检测仍然保留,作为轮询尚未覆盖到的变化的兜底。
内容寻址 PCH 存储
PCH 文件在磁盘上以 preamble 内容连同影响前端的编译标志、目录与 clang 版本共同计算的哈希值命名(如 a3f7e8c1d2b4f6e9.pch),实现内容寻址。这带来两个好处:
- 磁盘共享:preamble 内容与编译配置一致的不同文件自然共享同一个 PCH 磁盘文件,无需额外的去重逻辑。
- 跨会话持久化:PCH 缓存的元数据(路径、哈希、边界、依赖快照)序列化到磁盘上的
cache.json文件。服务器重启时加载这些元数据,通过两层失效检测验证 PCH 是否仍然有效,避免冷启动时重建所有 PCH。
当 preamble 内容变化时,新的 PCH 使用不同的哈希命名,旧文件成为孤立文件。清理机制定期回收超过一定期限未使用的孤立 PCH 文件。
实现
Preamble 边界计算
Preamble 的边界通过词法扫描确定:使用项目的 Lexer 从文件开头逐行扫描,识别以 # 开头的预处理指令和 module; 全局模块片段声明。遇到第一行非预处理内容时停止,返回此时的字节偏移作为边界。
这种基于词法的检测不需要启动完整的预处理器,速度非常快。
Preamble 完整性检查
在触发 PCH 重建之前,需要检查 preamble 是否在语法上完整。两种典型的不完整状态:
#include "lib // 引号未闭合,用户正在输入文件名
import std.core // 缺少分号,用户正在输入模块声明如果在不完整的 preamble 上构建 PCH,会产生一个包含错误预处理状态的 PCH 文件。后续加载这个错误 PCH 的编译会看到大量虚假错误。因此,当检测到 preamble 不完整时,延迟重建,继续使用旧的 PCH(如果存在)。
PCH 构建流水线
当编译请求需要 PCH 时,按以下流程处理:
计算 preamble 边界和哈希
│
▼
┌─ 缓存命中?──── 是 → 复用缓存的 PCH
│ │
│ 否
│ │
│ ▼
│ preamble 完整?── 否 → 延迟重建,沿用旧 PCH
│ │
│ 是
│ │
│ ▼
│ 有其他协程正在构建?── 是 → 等待构建完成,使用结果
│ │
│ 否
│ │
│ ▼
│ 发送到无状态工作进程构建 PCH
│ │
│ ▼
└─ 更新缓存,捕获依赖快照缓存命中的条件有两个:preamble 哈希与缓存一致(preamble 内容没变),且两层失效检测通过(依赖文件内容没变)。两个条件必须同时满足。
PCH 构建由无状态工作进程执行(详见多进程架构)。工作进程使用 Clang 的 Preamble 编译模式,只处理 bound 之前的 preamble 部分。构建完成后返回 PCH 文件路径和依赖文件列表。
并发构建序列化
多个功能请求可能同时触发同一文件的 PCH 构建。PCHState 中包含一个共享事件(building):第一个发起构建的协程设置这个事件,后续协程发现事件存在时等待其完成,然后使用构建结果。这确保同一文件的 PCH 只构建一次。
依赖快照的时序保证
DepsSnapshot 的构建时间戳(build_at)在计算文件哈希之前获取。这个顺序确保了不存在遗漏修改的时间窗口:
如果一个文件在哈希计算过程中被修改,它的 mtime 会晚于 build_at。下次两层检测时,第一层会将这个文件标记为"可能已修改",第二层会重新计算哈希并发现变化。
如果反过来先计算哈希再获取时间戳,就可能出现这样的窗口:文件在哈希计算和时间戳获取之间被修改,但 mtime 不晚于 build_at,导致修改被遗漏。
整体编译流程
当功能请求到达时,编译流水线按以下顺序执行:
- 检查 AST 是否已缓存且未过时——是则直接复用
- 如果使用了 C++20 模块,先确保模块依赖就绪(详见模块编译)
- 确保 PCH 就绪
- 将编译任务发送到有状态工作进程,附带 PCH 路径和模块文件路径
- 工作进程加载 PCH 后只编译 preamble 之后的用户代码
AST 的依赖文件(preamble 之后的 #include)同样通过 DepsSnapshot 跟踪,使用相同的两层失效检测。即使用户没有编辑当前文件,如果其依赖的头文件在磁盘上被修改,下次功能请求时也会触发重编译。
与编译上下文的交互
对于非自包含头文件,编译上下文系统会合成一个前缀文件,通过 -include 注入到编译参数中(详见编译上下文)。这个注入的前缀文件会被 Clang 在 preamble 编译阶段处理,因此自然地被 PCH 缓存覆盖。PCH 的构建流水线对源文件和头文件的处理是统一的。
缓存持久化
PCH 和 PCM 的缓存元数据通过 cache.json 文件持久化到磁盘。每次成功构建后更新,服务器启动时加载。写入采用先写临时文件再原子重命名的模式,避免写入过程中崩溃导致文件损坏。
启动时加载缓存后,所有 PCH 条目通过两层失效检测验证有效性。过时的条目会在下次编译时自动重建,无需特殊的缓存一致性恢复逻辑。
FAQ
为什么用两层检测,而不是只用内容哈希? 内容哈希虽然精确,但需要读取所有依赖文件的内容。一个典型的 C++ 文件可能依赖数百个头文件,每次检查都全量哈希的 I/O 开销不可忽视。mtime 快速筛选将需要哈希的文件数量缩减到"自上次构建以来被 touch 过的文件",在常态路径下通常为零。
为什么每次都完全重建 PCH?能否增量更新? Clang 支持链式 PCH(chained PCH):将 preamble 中的每条
#include构建为独立的 PCH 链节,每个链节依赖前一个链节的编译产物。当用户在 preamble 末尾新增一条#include时,只需要在已有链的末尾追加一个链节,而不是重建整个 preamble。基准测试表明(PR #405),对于 70 个 C++ 标准库头文件的 preamble,增量追加一条#include只需约 36ms,而整体重建需要约 1230ms(35 倍加速)。链式 PCH 的 AST 加载延迟几乎不受影响(+2% ~ +6%)。clice 计划引入链式 PCH 来优化增量重建性能,目前尚在实验阶段。为什么采用拉取式编译而不是推送式? 关键原因在于 clice 将所有文件的 PCH 持久化到磁盘上,缓存的文件数量远多于 clangd 的内存模型(clangd 通过 LRU 策略只保留少量活跃文件的 preamble)。当一个头文件被修改时,可能有大量文件的 PCH 受到影响。如果采用推送式编译,就需要在头文件修改时立即重建所有受影响的 PCH,这个数量级是不可接受的。拉取式编译将重建延迟到功能请求到达时,只重建用户当前需要的那个文件的 PCH。由于 PCH 加载本身很快(见下一条),这种按需重建引入的延迟很小。
磁盘 PCH 比内存 PCH 慢吗? 实际影响很小。Clang 的 PCH 加载使用 mmap 将文件映射到内存,避免了完整的读取拷贝。更重要的是,Clang 对 PCH 中的 AST 节点采用惰性反序列化——只有实际被引用的节点才会被反序列化,大部分 PCH 内容在编译过程中不会被访问。因此 PCH 的加载性能主要取决于二进制文件的映射方式,磁盘文件和内存缓冲区之间没有本质差异。而磁盘 PCH 带来的好处——跨重启持久化、不占用进程常驻内存、内容寻址共享——使得这个 trade-off 是值得的。
已知局限
完全重建。任何一个依赖文件的内容变化都触发 PCH 的完全重建,无法做到只重建受影响的部分。改进方向是引入链式 PCH(见 FAQ),将重建范围限制在变化点之后的链节。
Preamble 完整性检查不完整。当前的完整性检查只覆盖了
#include/import指令中的未闭合引号和缺失分号。其他类型的不完整编辑(如正在输入#define的值)不会被检测到。如果这类不完整的 preamble 被构建为 PCH,对后续编译的影响尚未充分测试。需要进一步研究 Clang 在处理不完整预处理指令时的行为,以确定是否需要扩展完整性检查的范围。头文件保存后不主动重编译。保存头文件(或文件追踪器发现的磁盘变化)会主动把依赖它的已打开文件标脏,但重编译仍是拉取触发的:诊断要等到对该文件的下一次请求(如 hover、编辑)才刷新,而不是立即更新。改进方向是为受影响的已打开 session 主动触发一次编译(混合推/拉模型)。