增量编译
背景
几十行代码经过 #include 展开后往往有数万乃至数十万行:单是 #include <vector> 就有数千行。每次按键后把这些内容全部重新编译一遍要花好几秒。但文件顶部的包含指令很少改动,频繁变化的只是下方的代码:
#include <vector>
#include <string>
#include "project/config.h"
// ── preamble ↑ changes slowly, its compile is cached ──
// ── code ↓ changes constantly, recompiled each time ──
void process(const std::vector<std::string>& data) {
// ...
}因此,C++ 语言服务器会把文件拆成两部分:顶部的指令构成 Preamble,编译一次生成预编译头文件(PCH);其余部分则在每次重编译时基于这份 PCH 编译。clangd 也采用这一做法,但在 PCH 的保存与校验上存在几处不足:它将 Preamble 保存在内存中,长时间运行后内存占用不断增长(clangd#251);它的 PCH 是临时文件,进程崩溃后会残留在磁盘上(clangd#209);头文件时间戳变化而内容未变时也会重建 PCH;而且重启后一切缓存都会丢失。
clice 将 PCH 保存在磁盘上,按内容命名;仅当依赖文件的内容发生变化时才重建 PCH;在请求需要结果时才触发编译,而不是每次编辑都编译;并且绝不会基于尚未输入完的头文件名构建 PCH。
设计
Preamble 的范围
Preamble 从文件开头延伸到第一行非预处理指令之前。在模块单元中,它涵盖整个全局模块片段(global module fragment),直至模块声明之前。遇到只有入栈而没有出栈的 #pragma(如 push_macro、pack(push) 等)时,由于 PCH 不会保存这些栈状态,Preamble 会在该指令前提前结束;遇到正在输入的头文件名(如未闭合的 #include "lib)时同样提前结束:上方已写完的指令保持预编译,未写完的这一行随正文一同解析并就地报告错误。倘若拿输了一半的包含指令构建 PCH,整个文件都会冒出大量误报。在全局模块片段中,若入栈指令之后还有包含指令或片段自身的代码,整个文件不再划分 Preamble;若片段自身含有声明,正在输入的头文件名也会导致同样的结果:否则正文会在模块声明之前解析这些内容。
Preamble 边界仅靠词法扫描即可确定,无需运行预处理器。
PCH 为正文分担的工作
构建 PCH 时还会实例化 Preamble 自身用到的函数模板——例如头文件内联函数调用的 std::vector<int> 成员——这样编译正文时它们已经就绪,而编辑后等待的大半工作往往正在于此。
在模块单元和导入了模块的文件之外,PCH 与 clangd 一样会跳过 Preamble 所包含文件中的函数体,只保留编译器必需的 constexpr、consteval 和 auto 函数,以及转发实参的函数模板(如 make_unique)——各类功能要穿透这类调用读取函数体中的信息,例如内联提示所示的形参名。如此一来,编译正文时几乎不必实例化头文件模板的函数体(在重度使用模板的代码中,编辑后编译的主要开销就在这里),保留的 AST 也小得多。这些函数体原本会产生的诊断则来自会编译所有函数体的后台索引:后台索引编译完该文件后,会补回编辑器编译所跳过的诊断,并在缓冲区内容仍与当时读取的文本一致时显示出来。规则中的 skip_function_body 可以关闭跳过函数体的行为,也可以将其扩展到除文件自身编译外的每次编译,以及该文件所属模块或预编译头文件的构建。
PCH 是否仍然有效?
只要 PCH 读取过的任意文件内容发生改变,这份 PCH 即告失效。仅比较修改时间,每次文件时间戳更新(如构建工具扫描、切换分支、编辑器自动保存)都会触发重建;每次检查都对所有依赖计算哈希,又要读取成百上千个头文件。clice 将两者结合为两层检查,在所有新鲜度检查(PCH 与模块校验、索引失效判定、磁盘轮询)共享的文件表中统一执行:
- 比对文件状态(stat)。 文件表为每个文件记录最近一次读取时看到的状态:文件大小、修改时间(mtime)以及所读字节的哈希值。只要文件状态与记录一致,无需读取文件即可得知其哈希值。
- 内容哈希(Hash)。 状态变化的文件会被重新读取并计算哈希,并更新记录。只有当新哈希值与构建 PCH 时所读字节的哈希值不同时,PCH 才判定为失效。
构建期间所读字节的哈希直接取自编译器自身的缓冲区,即使文件在构建中途发生改动,也不会误把新内容当作当时读取的内容。
按需编译
编辑操作只更新缓冲区并将其编译结果标为过期;真正的编译等到请求需要结果时(如悬停、代码补全、语义 Token)才会运行。一秒内敲十几次键,不会触发十几次编译。磁盘上的文件变动经服务器轮询发现后,同样会将依赖它们的已打开文件标为过期,每次编译前的两层检查则作为兜底保障。
依赖文件变更后(例如保存了头文件,或轮询发现磁盘文件改动),已打开的文件无需等待请求:拉取诊断的客户端若接受重新拉取的请求,就会收到该请求;不拉取诊断的客户端则由 clice 编译过期文件并主动推送诊断。连续多次保存时,一个已打开文件最多编译两次。无人打开的文件,其 PCH 留到文件打开时再重建。
内容寻址存储
PCH 文件名由 Preamble 文本、前端相关编译参数、工作与包含目录以及 clang 版本的哈希共同决定,因此 Preamble 与编译命令一致的文件会共享同一份 PCH 文件——除非 Preamble 定义或检测了宏,此时 PCH 归构建它的文件专有,文件名中也会拼入该文件的路径。PCH 的记录(路径、哈希值、边界位置及读取过的文件)保存在索引数据库中,服务器重启后会逐一校验并仅重建已失效的 PCH。Preamble 改动后会生成新文件;缓存超出容量上限时,优先淘汰最久未用的旧文件。
构建系统声明的预编译头文件(如 CMake 按目标配置的 PCH,或任意由一条编译命令生成并供其他命令读取的 PCH)是 clice 唯一会串联构建的 PCH 链:读取它的文件会在其之上构建自身 Preamble 的 PCH,因此修改文件自己的 Preamble 时无需重新解析该预编译头文件的内容。构建声明的 PCH 只构建一次,编辑器与后台索引都会直接加载它。
常见问题
- 为什么分两层检查,而不是只算哈希? 每次检查都计算所有依赖的哈希需要读取数百个头文件。比对文件状态这一步将哈希计算范围收窄到上次检查后时间戳有变动的文件,通常一个也没有。
- 为什么要按需编译,而不是每次改动都编译? clice 在磁盘上保留所有文件的 PCH,数量远多于 clangd 留在内存中的部分。修改一个头文件可能使大批 PCH 失效,立刻全部重建开销过大,因此只重建当前请求所涉文件的 PCH。
- 磁盘上的 PCH 比内存中的慢吗? Clang 以内存映射加载文件,只读取本次编译用到的节点,两种方式的加载开销相差无几;而保存在磁盘上可以跨重启保留、不占进程内存,还能在不同文件间共享。
已知限制
- 依赖变动会重建整份 PCH。 只有构建系统声明的预编译头文件独立在外。
- 完整性检查仅覆盖头文件名。 其他尚未输入完的指令(例如正在输入值的
#define)会按当前文本直接构建 PCH,输完后再触发重建。 - 含模块导入的全局模块片段不使用 PCH。 若模块单元的全局模块片段导入了模块(通常通过包含的头文件引入),每次编辑都会重新解析整个 Preamble:PCH 不会记录全局模块片段处于开启状态,而其后的模块声明会遮蔽该片段导入的模块。
- Preamble 实例化的模板只能看到 Preamble。 PCH 在 Preamble 末尾实例化头文件用到的模板,而编译器是在文件末尾、文件自身的声明之后才实例化。在 Preamble 末尾失败的实例化会留到文件末尾再处理;在那里成功的实例化在少数情况下会与编译器的结果不同,由此产生的错误改由后台索引显示。
- 头文件函数体内的错误需等待后台索引(模块单元和导入了模块的文件除外,它们会解析所有函数体)。后台索引按已保存的内容编译完文件后才会显示这类错误,一旦编辑文件就会暂时消失,直到下次保存后恢复;未使用声明的警告也是如此。若后台索引未按编辑器的方式编译该文件,这些诊断就不会出现,包括关闭了后台索引、头文件通过包含方的上下文编译、编译数据库未列出该文件,或用户手动切换了编译上下文等情形。需要读取被调函数函数体的 clang-tidy 检查(如
bugprone-exception-escape)在编辑器中检出的问题也会少于clice lint。
