Skip to content

增量编译

背景

C++ 的 #include 是文本替换——预处理器将被包含的头文件内容原样插入到源文件中。一个只有几十行用户代码的源文件,经过 #include 展开后,可能膨胀到数万行甚至更多。例如,仅 #include <vector> 一条指令就会引入数千行标准库代码,如果再加上项目自身的头文件,预处理后的代码量可以轻松超过十万行。

语言服务器需要在每次用户编辑后重新编译文件,以提供最新的诊断、代码补全和语义信息。如果每次都完整编译这十万行代码,延迟会达到数秒,显然不可接受。

但观察用户的实际编辑行为,会发现一个关键特征:文件头部的 #include 区域很少变化,而底部的用户代码不断变化。在一次典型的编辑会话中,用户可能修改代码数百次,却一次也没有改动 #include 区域。

cpp
#include <vector>
#include <string>
#include <map>
#include "project/config.h"
#include "project/logging.h"
// ── preamble ↑ changes slowly, compilation result can be cached ──
// ── user code ↓ changes rapidly, must be recompiled each time    ──

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)。

  • 失效检测不够精确。当头文件的时间戳发生变化但内容未变时(常见于构建工具的依赖扫描和版本控制系统的分支切换),仅依赖修改时间(mtime)会触发不必要的 PCH 重建。在大型项目中,这类误报会明显影响编辑体验。

  • 重启后冷启动。PCH 不会跨会话持久化,服务器重启后必须为所有打开的文件重新构建 PCH。

clice 针对这些问题重新设计了增量编译机制:持久化到磁盘的内容寻址式 PCH 存储、两层失效检测、拉取式编译模型和 Preamble 完整性检查。

设计

增量编译围绕四个核心概念组织:Preamble 分离定义“缓存什么”,两层失效检测定义“何时重建”,拉取式编译定义“何时触发”,内容寻址存储定义“如何存储”。

Preamble 分离

Preamble 是源文件头部由预处理指令(#include#define#pragma 等)和模块声明(module;)组成的区域。其结束位置由字节偏移量(bound)标识,即第一行非预处理器内容之前的字节位置。

Preamble 会被编译为 PCH 文件并缓存在磁盘上。后续编译会加载 PCH,只需处理 bound 之后的用户代码。如果 Preamble 为空(bound 为零),则不需要 PCH,并会跳过整个 PCH 流程。

PCHState 是一个 PCH 缓存条目,包含:

  • PCH 文件在磁盘上的路径
  • Preamble 内容的哈希值
  • Preamble 的字节边界(bound)
  • 依赖快照(DepsSnapshot,见下文)
  • 与 PCH 配对的 Preamble 状态数据块句柄;该数据块与 PCH 相邻存储并共享生命周期,包含构建时提取的 Preamble 符号索引和功能状态(文档链接、非活跃区域、未闭合的条件栈),以内存映射 FlatBuffer 的形式打开并按需查询(见符号索引

两层失效检测

PCH 缓存 Preamble 中包含的所有头文件的预处理结果。只要任何一个依赖头文件的内容发生变化,PCH 就会过期,需要重建。难点在于精确判断“内容是否真的发生了变化”。

最直接的方案是检查文件的 stat 数据:如果依赖文件的大小和修改时间仍与上次观察其内容时记录的值相同,该文件就没有变化。这种检查只需调用 stat 系统调用,非常快。然而,状态标记会产生误报:构建工具的依赖扫描、VCS 分支切换、编辑器自动保存等操作会重写文件,却不改变其内容。

另一种方案是直接比较内容哈希:每次检查时,重新计算每个依赖文件的哈希,并与构建时记录的哈希比较。这种方法判断完全准确,但必须读取所有依赖文件的内容并计算哈希。典型的 C++ 文件可能依赖数百个头文件,因此每次检查都进行全量哈希计算所产生的 I/O 开销不可忽视。

clice 将两者结合成两层检测策略,在 master 的文件表中统一实现,供所有需要判断文件是否为最新状态的使用方共享(PCH 校验、索引过期判断、磁盘轮询):

  • 第一层(stat 快速路径):将每个依赖文件的 (size, mtime) 状态标记,与 PCH 构建所依据的该文件版本的记录值进行比较。状态标记相同即表示文件未变化。
  • 第二层(内容哈希校验):对于状态标记不同的文件,重新计算 xxh3 内容哈希并与记录值比较。如果内容实际上相同,就原地修正记录的状态标记,使下次检查再次采用快速路径;只有哈希不匹配才会使 PCH 失效。

第一层会过滤掉绝大多数未变化的文件(这是常见情况的处理路径)。第二层会消除状态标记造成的误报(构建工具更新时间戳、VCS 检出等)。二者结合后,只有依赖文件的内容实际发生变化,才会重建 PCH。

DepsSnapshot 是用于两层检测、按产物保存的记录,在 PCH 构建完成时生成。它保存每个依赖文件的文件标识和当时观察到的版本(以及构建时缺失文件的标记),并将实际比较委托给共享文件表。

拉取式编译

clice 采用拉取式(pull-based)编译模型:编译不会在文件发生变化时立即触发,而是在功能请求(悬停、代码补全、语义高亮等)需要最新 AST 时按需触发。

当用户编辑文件(didChange)时,master 进程只更新内存中的文件内容,并将 AST 标记为脏(ast_dirty),不会启动任何编译。功能请求到达时,编译服务会检查 AST 是否为脏,或是否因外部文件变化而过期。如果需要重新编译,它会先确保 PCH 和模块依赖就绪,再将编译任务派发给 worker 进程。

这种模型的好处是避免快速连续输入期间的无效编译。用户每秒可能触发十几次 didChange 事件,但只有确实需要结果时——例如收到悬停或代码补全请求——才会执行编译。

注意,“外部文件变化”和“用户编辑”是两条独立的脏标记路径。用户编辑通过 didChange 标记 ast_dirty。外部文件变化(例如磁盘上的依赖头文件被修改)会被主动发现:基于 stat 轮询的文件跟踪器将变更事件传递给失效引擎,由失效引擎将受影响的文件标记为脏。编译前的两层检测仍作为防御性兜底,用于发现轮询尚未察觉的任何变化。

内容寻址式 PCH 存储

磁盘上的 PCH 文件以 Preamble 内容、与前端相关的编译选项、目录和 clang 版本共同计算出的哈希命名(例如 a3f7e8c1d2b4f6e9.pch),从而实现内容寻址存储。这带来两个好处:

  • 磁盘共享:preamble 内容和编译配置一致的不同文件会自然共享磁盘上的同一 PCH 文件,无需额外的去重逻辑。
  • 跨会话持久化:PCH 缓存元数据(路径、哈希、边界、依赖快照)与符号索引一起持久化到索引数据库中。服务器重启时加载这些元数据,并通过两层失效检测验证每个 PCH 的有效性,避免冷启动时重建所有 PCH。

当 preamble 内容变化时,新 PCH 的文件名会使用不同的哈希值,旧文件则成为孤立文件。孤立文件由产物存储的淘汰机制回收:PCH 和 PCM 缓存位于有容量预算的命名空间中,一旦超过预算,最冷的条目会优先被淘汰。

实现

Preamble 边界计算

Preamble 边界通过词法扫描确定:项目的 Lexer 从文件开头逐行扫描,识别以 # 开头的预处理指令和 module; 全局模块片段声明。扫描在第一行非预处理内容处停止,并将该位置的字节偏移量作为边界返回。

这种基于词法的检测不需要启动完整的预处理器,速度非常快。

Preamble 完整性检查

在触发 PCH 重建之前,需要检查 preamble 在语法上是否完整。典型的不完整状态有两种:

cpp
#include "lib          // unclosed quote, user is typing a filename
import std.core        // missing semicolon, user is typing a module declaration

用语法不完整的 preamble 构建 PCH,会使 PCH 包含错误的预处理器状态。后续编译加载这个错误的 PCH 后,会产生大量伪错误。因此,检测到 preamble 不完整时,会推迟重建,并继续使用旧 PCH(若存在)。

PCH 构建流水线

当编译请求需要 PCH 时,将执行以下流水线:

Compute preamble boundary and hash


   ┌─ Cache hit? ──── yes → Reuse cached PCH
   │     │
   │    no
   │     │
   │     ▼
   │  Preamble complete? ── no → Defer rebuild, keep old PCH
   │     │
   │   yes
   │     │
   │     ▼
   │  Another coroutine building? ── yes → Wait for completion, use result
   │     │
   │    no
   │     │
   │     ▼
   │  Dispatch to stateless worker to build PCH
   │     │
   │     ▼
   └─ Update cache, capture dependency snapshot

缓存命中需要同时满足两个条件:preamble 哈希与缓存值一致(preamble 内容未变),且通过两层失效检测(依赖文件内容未变)。

PCH 构建由无状态工作进程执行(见多进程架构)。工作进程使用 Clang 的 Preamble 编译模式,只处理边界之前的 preamble 部分。构建完成后,它会返回 PCH 文件路径和依赖文件列表。

并发构建序列化

多个功能请求可能同时触发相同内容的 PCH 构建。PCH 构建作为编译任务图中的节点运行,并以 PCH 的内容键为键:首个获取该节点的请求会启动一轮构建,后续请求则加入同一节点并等待该轮构建的结果,而不会各自启动构建。这确保给定的 PCH 同一时间只会构建一次,共享 preamble 的文件也会共享这次构建。

依赖快照的时序保证

DepsSnapshot 的构建时间戳(build_at)在计算文件哈希之前获取。这个顺序确保不存在遗漏修改的时间窗口:

如果文件在哈希计算过程中被修改,其 mtime 会晚于 build_at。在下一次两层检测中,第一层会将该文件标记为“可能已修改”,第二层会重新计算其哈希并发现变化。

如果顺序反过来——先计算哈希,再获取时间戳——就可能出现一个时间窗口:文件在哈希计算与时间戳获取之间被修改时,其 mtime 不晚于 build_at,从而导致该修改被遗漏。

整体编译流程

当功能请求到达时,编译流水线按以下顺序执行:

  1. 检查 AST 是否已缓存且未过时——若是,则直接复用
  2. 如果使用了 C++20 模块,先确保模块依赖就绪(见模块编译
  3. 确保 PCH 就绪
  4. 将编译任务连同 PCH 路径和模块文件路径一起分派给有状态工作进程
  5. 工作进程加载 PCH,并且只编译 preamble 之后的用户代码

AST 的依赖文件(preamble 之后通过 #include 包含的头文件)也通过 DepsSnapshot 跟踪,并使用相同的两层失效检测。即使用户没有编辑当前文件,只要磁盘上的依赖头文件发生修改,下次功能请求也会触发重编译。

与编译上下文的交互

对于非自包含头文件,编译上下文系统会合成一个前缀文件,并通过 -include 将其注入编译参数(详见编译上下文)。Clang 会在 Preamble 编译阶段处理这个注入的前缀文件,因此它自然会纳入 PCH 缓存范围。PCH 构建流水线以相同方式处理源文件和头文件。

缓存持久化

PCH 和 PCM 缓存元数据以元数据 blob 的形式持久化在索引数据库中。该 blob 会在每次成功构建后不久刷盘,并在服务器启动时加载;blob 写入是原子的,每条记录还会绑定其所描述的确切产物文件(大小、mtime、文件系统标识和内容哈希)。因此,即使在发布产物文件和刷写其元数据之间发生崩溃,系统也能检测到这种情况,并重新验证产物文件,而不会直接信任它。

启动时加载缓存后,所有 PCH 条目都会经过两层失效检测。过时的条目会在下次编译时自动重建,无需特殊的缓存一致性恢复逻辑。

FAQ

  • 为什么用两层检测,而不是只用内容哈希? 内容哈希虽然精确,但需要读取所有依赖文件的内容。一个典型的 C++ 文件可能依赖数百个头文件,每次检查都进行完整哈希的 I/O 开销不容忽视。mtime 快速筛选层将需要哈希的文件限定为“自上次构建以来发生过变动的文件”;通常情况下,这类文件一个也没有。

  • 为什么每次都完整重建 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 使用 mmap 将 PCH 文件映射到内存,避免完整读取和复制文件。更重要的是,Clang 会惰性反序列化 PCH 中的 AST 节点——只有编译期间实际引用的节点才会被反序列化,绝大部分 PCH 内容根本不会被访问。因此,PCH 加载性能主要取决于二进制文件的映射方式,磁盘文件与内存缓冲区之间并无本质差异。磁盘 PCH 具有跨重启持久化、不占用进程常驻内存以及基于内容寻址进行共享等优势,值得做出这种取舍。

已知局限

  • 完整重建。任何依赖文件发生内容变化都会触发 PCH 的完整重建,无法只重建受影响的部分。改进方向是采用链式 PCH(见 FAQ),将重建范围限制在变化点之后的链节。

  • Preamble 完整性检查不完善。当前的完整性检查只涵盖 #include/import 指令中未闭合的引号和缺失的分号,无法检测其他类型的未完成编辑(例如正在输入 #define 的值)。基于这种不完整的 Preamble 构建 PCH 会对后续编译产生何种影响,尚未得到充分测试。需要进一步研究 Clang 处理不完整预处理指令时的行为,以确定是否应扩大完整性检查的范围。

  • 保存头文件后不主动重编译。保存头文件(或文件跟踪器发现变更)会主动将依赖它的已打开文件标记为脏,但重编译仍由拉取触发:这些文件的诊断要等到下一次针对它们的请求(如悬停、编辑)才会刷新,而不会立即更新。改进方向是为受影响的已打开会话主动触发编译(推拉混合模型)。