Zig 增量编译内部原理
如果你好奇的话,ZIR(Zig 中间表示)是一种无类型的 SSA 形式 IR——但即使你不明白这是什么意思也没关系,因为在这里并不重要。我们只需要知道,我们正在将整个源文件转换为另一种格式。
在 AstGen 运行过程中,它会识别源文件中所有的 Zig 导入语句(@import("foo.zig")),这样我们就可以对所有被导入的文件重复整个流程。通过循环执行这个过程,我们最终会找到编译中所有的 Zig 源文件,并将它们全部转换为 ZIR。
流水线的这一部分实际上有几个有用的特性:
- 每个文件的处理都是该文件内容的纯函数,不涉及任何共享或外部状态
Parse和AstGen各自运行速度都很快:在我的笔记本上,对 Zig 编译器整个src/目录运行这两个步骤(完全不并行)大约需要 920 毫秒- 得益于 Zig 采用的数据导向设计模式,ZIR 可以通过一次 writev/readv 系统调用轻松地写入和读取磁盘——无需“序列化”步骤。
这些特性带来了两个好处。
首先,假设每个源文件对应一个“任务”,那么整个流程是高度可并行化的。这意味着我们可以轻松地在线程池上运行它:每当从导入中发现新的源文件时,就将其任务加入队列——唯一需要保护的共享状态(用互斥锁即可)是一个哈希集合,用于记录我们已经处理过的文件路径。
其次,甚至可以说更重要的是,这些特性让这一环节的增量编译实现变得极其直接。我们只需将每个源文件生成的 ZIR 缓存到磁盘,并在检测到文件变更时重新构建即可。
这两项优化在 Zig 中默认启用已有多年——它们经过实战检验,在大多数情况下能让这一环节近乎瞬间完成。如果你在使用 Zig,可以通过 stderr 上的进度输出来感受其速度——当显示“AST Lowering”时,这一环节正在运行。我猜很多 Zig 用户只有在第一次运行编译器时才会注意到这个现象(因为首次运行时,编译器需要为整个 Zig 标准库和 compiler_rt 完成这项工作)。
好了,我们让这一部分变快了!这很棒,但坏消息是这只是简单部分——很多编译器已经能实现这种缓存。接下来,事情会变得棘手。
语义分析
下一环节可以说是最重要的:语义分析。这包括类型检查和 comptime 求值。
语义分析的工作本质上是“解释”我们之前生成的 ZIR,在此过程中输出编译错误(如类型错误);对于运行时函数,则构建另一种中间表示,供后续环节使用。
在继续之前,先明确一个术语。Zig 中的“容器级声明”相当于其他语言中的“顶层声明”。这个术语在 Zig 中并不准确,因为容器级声明在语法上不必位于顶层,但概念是相同的。我说“容器级声明”时,基本是指“函数、全局常量或全局变量”。
语义分析是编译器中最难实现增量处理的部分。或许并不意外,这正是语言设计开始发挥关键作用的地方:虽然我相当确信大多数现代语言理论上都能支持类似我们这样的增量编译,但某些设计决策会使其变得困难得多。Zig 多年来一直在调整其设计(有时甚至引发争议),目的就是为了更容易支持快速的增量编译。 这里的核心策略是将编译过程拆分成大量可以相对独立分析的片段,并且关键在于,这些片段之间存在的依赖关系能够轻松地在依赖图中建模。 在 Zig 编译器中,我们将这些片段称为“分析单元”,有时也简称为“单元”。这里我稍微简化一下,告诉你 Zig 编译器有四种不同类型的分析单元: - `struct` 或 `union` 类型的**布局**(大小、对齐等)。 - 容器级声明的**类型**。 - 容器级 `const` 声明的**值**。 - 运行时函数的**函数体**。 在分析某个特定单元时,我们会填充该单元所依赖的其他单元集合。来看一个基本示例: ```zig var global_0: u32 = 123; const global_1: u32 = 456; pub fn foo(cond: bool) u32 { if (cond) { return global_0; } else { return global_1; } } ``` 当我们分析函数 `foo` 的函数体时,会发生以下情况: - 由于参数 `cond` 在编译期未知,我们会语义分析 `if` 的两个分支。 - 获取 `global_0` 的指针,为后续加载做准备。 - **添加依赖:** `global_0` 的类型 - 在运行时加载 `global_0`,因为它是 `var`,没有编译期已知的值。 - 获取 `global_1` 的指针,为后续加载做准备。 - **添加依赖:** `global_1` 的类型global_1,因为它的值在编译期已知- 添加依赖:
global_1的值
因此,最终这个函数体依赖于 global_0 和 global_1 的类型,以及 global_1 的值(因为该值在编译期已知)。这告诉编译器:如果 global_0 或 global_1 的类型发生变化,或者 global_1 的编译期已知值发生变化,就需要重新分析该函数。
运行时函数体无法建立依赖关系(至少在我这里简化的视角下如此)。这意味着函数体分析单元在依赖图中只能有“出边”(即它们可以依赖其他单元,但其他单元不能依赖它们)。
对 const 声明值的依赖之所以存在,完全是因为 Zig 允许在编译期使用这些值。如果没有这个语言特性,对声明值的依赖就不可能存在,就像运行时函数体无法被依赖一样。
对类型布局的依赖,简而言之,源于持有该类型的值,或需要了解该类型的布局信息。这里我不再深入讨论,因为涉及 Zig 类型系统的细节且较为复杂,但本质上并无不同。
好了,现在我们已经告诉编译器:当某个东西需要重新分析时,哪些其他东西也需要跟着重新分析。但还有一个关键环节——源代码依赖。仅凭这个依赖图本身毫无用处:当用户请求重新编译(即我们所说的“增量更新”)时,我们到底该怎么做?我们甚至不知道第一个要重新分析的是什么!
为了解决这个问题,我们不仅追踪分析单元对其他单元的依赖,还追踪它们对源代码片段的依赖。在目前看到的例子中,这些依赖都非常简单:在上面的代码片段中,“`global_0` 的类型”和“`global_0` 的值”这两个单元都依赖于 `global_0` 的源代码,“`global_1` 的类型”单元依赖于 `global_1` 的源代码,而“`foo` 的函数体”单元则依赖于 `foo` 的源代码。只要指定区域内的任何字节源代码被修改,依赖它的分析单元就会被标记为“过时”并重新分析。 需要注意的是,实际情况可能比每个单元只依赖一段源代码更复杂。例如,Zig 中的 `inline` 函数调用会执行*语义内联*,这本质上意味着它会在*调用者*的分析单元内触发另一段代码的语义分析。因此,`inline` 函数调用会在调用者的分析单元与被调用者的源代码之间建立依赖关系。 当然,我们仍然需要能够判断自上次增量更新以来哪些源代码区域发生了变化。为此,ZIR 为特定的“感兴趣”源代码区域(例如每个容器级声明的完整源代码)包含了哈希值,而实际依赖的就是这些*哈希值*。如果源代码发生变化,哈希值也会随之改变,编译器前端很容易检测到这一点。 好了,解释得够多了——现在让我们来看一些漂亮的图表!以下是 Zig 源代码: ```zig const lucky_number = 42; const S = struct { x: u32 }; fn getSomething() S { return .{ .x = lucky_number }; } fn testLuck(x: u32) void { if (x == lucky_number) { // 执行某些操作 } } export fn entry() void { const result = getSomething(); testLuck(result.x); } ``` ……这是它的依赖关系图(为了清晰起见,移除了一些冗余的边)。右侧的节点代表已计算哈希值的源代码,而其余节点都是分析单元。
现在,假设我们把文件第一行改成 const lucky_number = 43;。首先,编译器会为这个文件生成新的 ZIR。它会根据声明名称将旧 ZIR 中的声明映射到新 ZIR,并比较每个声明关联的源哈希。在这个例子里,所有声明都成功映射,但编译器发现有一个源哈希发生了变化——就是 const lucky_number 对应的那个。接着,它会查看依赖图,找出所有依赖这个源哈希的内容。这里只发现了一个直接依赖:
于是,编译器重新分析 lucky_number 声明的值。如果我们的改动是空操作(比如只加了几个空格),编译器会判定值没有变化,就此停止。但这次值确实变了!因此,编译器继续推进,接下来考虑所有依赖 lucky_number 值的分析单元,一共有两个:
lucky_number 值变更后失效的依赖
编译器分析这两个单元——testLuck 和 getSomething 的函数体。由于它们是函数体,没有其他依赖,语义分析循环到此结束。不过,对这些函数的语义分析会生成新的 AIR,这正好引出我们的下一个话题:代码生成。
代码生成
代码生成(常简称为 codegen)是编译器流水线中的一个阶段,负责将语义分析产生的 AIR 转换为接近机器指令的形式。Codegen 并不直接生成机器指令,而是生成一种叫 MIR(Machine Intermediate Representation,机器中间表示)的东西——但 MIR 指令与机器指令之间几乎是一一对应的。每个目标平台都有独立的 codegen 实现。
