Reed's News
← 返回精选

Zig 增量编译内部原理

Tech 78 garyhtou 2026/7/28 3960 字 原文 ↗

作为Zig核心团队成员,我参与过最具影响力的项目之一,就是为Zig编译器实现增量编译功能。这项功能能让编译器检测出自上次构建后,项目中哪些函数和声明发生了变化,仅重新编译改动的代码,并直接将生成的字节补丁注入输出二进制文件,大幅缩短重建耗时。

Zig项目为实现这一功能已筹备多年。经过数个版本迭代,它终于从概念验证阶段,发展为可支撑实际项目的成熟功能,如今Zig核心团队成员大多已日常使用。

如今,借助Zig的增量编译,你只需毫秒级时间就能完成复杂应用的修改与重建。

光听我说可不够!下面这段无声视频展示了我用Zig快速修改并测试像素编辑器Fizzy的过程:首次构建耗时约5秒,而后续每次修改后的重建仅需50-70毫秒。

演示前我先将Fizzy升级到了Zig的master分支。这是因为尽管Zig 0.16.0已支持增量编译,但缺少一些后续才实现的关键链接器特性。也就是说,如果你偏好使用正式版本,可能要等到Zig 0.17.0发布才能体验该功能,在此先致歉!

如果你已心动,只想了解使用方法,直接跳至本文最后一个章节即可。但如果你对该功能的普适性存疑,或是像我一样好奇背后的原理,那就一起深入探究细节吧!

Zig编译器的流程可分为几个环节,我们逐一拆解。第一个环节以整个源文件为处理单位,核心是循环执行以下步骤:

  • 从磁盘读取源文件
  • 将文件解析为抽象语法树(AST)
  • 通过名为“AstGen”的处理流程,将AST转换为一种名为“ZIR”的格式

如果你感兴趣,补充说明一下:ZIR(Zig中间表示)是一种无类型的静态单赋值(SSA)形式中间表示——不过不懂也没关系,这不影响后续理解。我们只需知道,整个源文件被转换成了另一种格式。

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,过程中会输出编译错误(如类型错误);对于运行时函数,则会生成另一种中间表示(分析后中间表示,简称AIR),供后续环节处理。

在继续之前,先澄清一个术语:“容器级声明”是Zir对其他语言中“顶层声明”的等效表述。之所以不用“顶层”,是因为Zig的容器级声明不一定在语法上处于顶层,但概念是一致的。简单来说,“容器级声明”指的是“函数、全局常量或全局变量”。

语义分析是编译器中最难实现增量处理的环节。毫不意外,语言设计在这里至关重要:虽然我相信多数现代语言理论上都能实现类似的增量编译,但某些设计决策会大幅提升实现难度。Zig多年来不断调整设计(有时甚至引发争议https://github.com/ziglang/zig/issues/20663),正是为了更易实现高速增量编译。

这里的核心思路是,将编译过程拆分为多个可独立分析的单元,关键是这些单元之间的依赖关系能轻松建模为依赖图。

在Zig编译器中,这些单元被称为“分析单元”,有时也简称为“单元”。我在此稍作简化,介绍Zig编译器的四类分析单元:

  • structunion类型的布局(大小、对齐方式等)
  • 容器级声明的类型
  • 容器级const声明的
  • 运行时函数的函数体

分析某个单元时,我们会记录该单元依赖的其他单元。举个简单例子:

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_0global_1的类型,以及global_1的编译期值。这意味着,如果global_0global_1的类型发生变化,或者global_1的编译期值改变,该函数就需要重新分析。

运行时函数体的分析单元不会被其他单元依赖(至少在我当前简化的模型中是这样)。也就是说,函数体分析单元在依赖图中只有“向外”的边(即它们可能依赖其他单元,但其他单元不会依赖它们)。

const声明的值的依赖,仅源于Zig允许在comptime中使用这些值。如果没有这个语言特性,对声明值的依赖就不可能存在,就像无法依赖运行时函数体一样。

对类型布局的依赖,简言之源于使用该类型的值,或需要了解该类型的布局信息。这里不再展开,因为它涉及Zig类型系统的复杂细节,但本质上和其他依赖类型并无不同。

好了,我们已经让编译器知道,修改某一单元时需要触发哪些其他单元的重新分析。但还有一个关键问题:源代码依赖。仅靠依赖图还不够——当用户请求重新编译(我们称之为“增量更新”)时,我们不知道首先该重新分析什么!

为解决这一问题,我们不仅跟踪分析单元之间的依赖,还跟踪它们与源代码片段的依赖关系。在之前的例子中,这种关系很简单:单元“global_0的类型”依赖global_0的源代码,单元“global_1的类型”和“global_1的值”都依赖global_1的源代码,单元“foo的函数体”依赖foo的源代码。只要对应源代码区域的任何字节被修改,相关的分析单元就会被标记为“过期”并重新分析。

实际上,情况可能比“一个单元依赖一段源代码”更复杂。例如,Zig中的inline函数调用会执行语义内联,这意味着它会在调用者的分析单元中触发对被调用者代码的语义分析。因此,inline函数调用会让调用者的分析单元依赖被调用者的源代码。

当然,我们还需要能检测自上次增量更新以来,哪些源代码区域发生了变化。为此,ZIR中包含特定“关键”区域的哈希值(例如每个容器级声明的完整源代码),分析单元实际依赖的就是这些哈希值。源代码变更会导致哈希值改变,编译器前端能轻松检测到这一点。

说了这么多理论,来看些直观的图示吧!以下是一段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) {
// do something
}
}
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的值

编译器会分析这两个单元——testLuckgetSomething的函数体。由于没有其他单元依赖它们(它们是函数体),语义分析循环到此结束。不过,对这些函数的语义分析会生成新的AIR,这自然引出我们的下一个话题:代码生成。

代码生成(常简称为codegen)是编译器流程中将语义分析生成的AIR转换为近似机器指令的阶段。它不会直接输出机器指令,而是生成一种名为MIR(机器中间表示)的格式,但MIR指令与机器指令几乎是一一对应的。针对不同目标架构(x86_64、aarch64等),有各自独立的代码生成实现。

代码生成的一大优势是,和之前的全文件处理环节一样,它也是易并行化任务(至少在不进行函数内联等跨函数优化的构建中是如此)。不同函数的代码生成之间没有共享状态,因此我们可以维护一个待处理队列,将需要转换为MIR的AIR加入队列,再通过任意数量的线程并行处理。只有一个小问题:需要限制队列的大小,因为如果代码生成的速度跟不上语义分析,待处理的AIR会迅速累积!

在增量编译方面,这一环节其实是最简单的,因为AIR和MIR都以单个函数为粒度,与增量编译的工作粒度完全一致。这意味着编译器根本不需要缓存AIR或MIR!AIR在代码生成完成后就会被丢弃,MIR则会在被链接器处理后立即销毁。

好了,我们走完了整个流程,全程保持增量处理:文件通过简单的单文件缓存转换为ZIR;声明的语义分析通过依赖图追踪变更;代码生成仅针对更新后的函数;链接器将新代码写入文件,不改动其他字节。最后还有一些收尾工作需要完成。

首先,由于Zig的“延迟分析”特性与增量编译的交互,我们需要遍历引用图,确定哪些函数/声明等实际被引用。有可能某个单元在之前的增量更新中被引用(因此已编译),但后来不再被引用,这就需要忽略它产生的编译错误、不导出其符号等。这一步或许可以优化,但目前我们在每次更新时都会完整遍历引用图。即便在大型项目中,这也完全可行,因为计算机的速度足够快!

确定被引用的单元后,我们会告知链接器Zig代码导出的所有全局符号,以便链接器在符号表中添加必要的条目。我们还会报告所有编译错误,以及完成其他一些杂项任务。最后,调用链接器的flush函数,它的职责是在文件关闭前完成剩余的链接工作。如果链接器仍有标记为“脏”的MappedFile节点,我们需要进行处理,否则应尽可能减少操作——记住,这里的操作会在每次更新时执行,因此要尽量保持O(1)复杂度。ELF链接器在此阶段实际上只需要写入.dynamic段和ELF头中的entry字段。

随后关闭文件,编译完成!

光有解释还不够,我们可以直观看到整个过程。Tracy是一款实时分析器,原本为游戏设计,但可集成到任何程序中。Zig编译器支持可选的Tracy集成,通过构建标志启用。(仔细想想,增量更新和视频游戏的帧渲染确实有点像?)

它偶尔可用于编译器性能分析,但我发现用它观察增量编译特别有意思,因为能清晰看到编译器各环节的运行情况。来看一段修改Fizzy时的Tracy输出,和之前视频中的操作类似。这次更新耗时37毫秒(比视频中的略快),我先放大前6毫秒左右的过程,稍后解释原因。

一开始,所有线程都有密集的活动——这是线程池在处理全文件任务。尽管我们只修改了一个文件,但编译器不会做此假设,而是检查所有源文件。这里有个小优化:由于我们知道上次更新时所有参与编译的源文件,可以假设这些文件仍可被访问,因此直接检查所有文件的变更,无需等待第一个文件处理完成后再发现其导入的文件。

接下来是computeAliveFiles函数,耗时约1毫秒。该函数遍历文件导入图,将每个文件分配到对应的Zig“模块”(因为文件可能在两次更新之间从一个模块转移到另一个模块)。我们还通过这次导入遍历,确认所有源文件是否仍在编译范围内。如果某个文件的所有导入都被移除,那么在本次更新的剩余过程中,我们会忽略该文件。

然后是updateZirRefs函数,耗时约1毫秒。它负责关联变更文件的新旧ZIR,并将所有指向ZIR指令的内部引用,从旧ZIR中的指令索引更新为新ZIR中的索引。这是个相对简单的任务,但当前实现需要遍历所有被引用的ZIR指令,并完全重建哈希表的元数据,这部分还有优化空间。

至此,单线程的全文件处理工作完成,我们终于进入流程的核心:语义分析、代码生成和链接。“sema_loop”区域包含所有语义分析耗时,约1.2毫秒。随后,该函数的AIR被另一个线程的代码生成模块处理(绿色的runCodegenInner区域),耗时约240微秒。生成的MIR被链接器线程接收并写入二进制文件——这部分链接工作(紫色的emitFunction区域及其旁边的小绿色区域)耗时约170微秒。总体而言,这个在冷构建中占主导地位的环节,本次更新仅耗时约1.6毫秒,表现不错!

完成这些后,进入flush阶段。右下角的小紫色区域是前端在flush时请求的少量链接工作:重建一个用于实现Zig内置函数@errorName的查找表,耗时约50微秒,至此我放大的6毫秒区域结束。现在终于可以放大全貌,看看剩下的31毫秒都在做什么……

剩下的时间几乎都花在resolveReferencesInner函数上。还记得之前提到的,在flush阶段需要遍历引用图以确定哪些Zig声明被引用吗?这就是该函数的工作!它本身并非低效,但引用图规模较大,因此在追求极致速度时,它的耗时就变得显著了。

一方面,这看起来有点荒谬,以至于我曾认真考虑在发布这篇博客前优化它(毕竟7毫秒比37毫秒更令人印象深刻)。尤其需要注意的是,这次更新中引用图其实并未改变——也就是说,增量更新的大部分时间都花在了确认“引用图没有变化”上!

但另一方面,我觉得这其实很酷,因为它说明还有很大的性能优化空间。这30毫秒的耗时实际上不算大问题,但我们完全可以消除它:首先,当引用图未变化时避免重复计算;其次,当引用确实变化时,仅重新计算必要的部分(这个问题在图论中称为“动态单源最短路径”,已有不少研究成果)。

简言之,在性能优化方面,我们还有很长的路要走!如果你想关注后续进展,可以考虑将Zig开发日志添加到你的RSS阅读器,或在Zig新版本发布时查看发行说明

好了,关于编译器的话题已经聊得够多了,现在来看看如何实际使用增量编译。我假设你已有一个带构建脚本的Zig项目,且能在最新的master分支版本(或Zig 0.17.0发布后的版本)上编译。

撰写本文时,该功能仅在目标平台为x86_64-linux时能正常工作,因为其他代码生成和链接器后端尚未成熟。Zig核心团队大部分成员使用x86_64架构的Linux系统,因此先聚焦这一平台,能加速我们自身的工作流程,进而更快地为其他平台添加支持——这也是当前的首要任务!

目前使用该功能还无法做到“零成本”。最终我们会将所有编译器状态缓存到磁盘,运行zig build时自动加载上次保存的状态,让增量编译自动生效——但现在还没到那一步。不过好消息是,现在使用它只需很少的操作!

简单来说,只需运行以下命令:

$ zig build --watch -fincremental

--watch参数告诉Zig构建系统监听文件系统的源代码变更,并在变更发生时触发重建。-fincremental参数则告诉构建系统,在重建时使用增量编译。

运行该命令时你可能会发现,即便已有热缓存,项目中的所有可执行文件/库/目标文件仍会被重新构建。这是预期行为,因为现有的磁盘缓存与增量编译不兼容。如果这对你造成不便,可以修改build.zig,让构建系统仅对特定编译步骤启用增量编译:

// 暴露 '-Dincremental' 选项
const incremental = b.option(bool, "incremental", "启用增量编译") orelse false;
// 如果启用,为 'exe' 开启增量编译
if (incremental) exe.incremental = true;

之后你就可以用-Dincremental代替-fincremental,这样只有指定的步骤会进行完整的初始重建(-fincremental本质上是告诉构建系统,对所有std.Build.Step.Compile步骤都启用增量编译)。

无论如何,完成初始构建后,只需编辑并保存任意源文件,就能立即看到重建结果。如果没有编译错误,更新后的可执行文件会像往常一样出现在zig-out/目录中。就是这么简单!

如果将--watch与运行程序的构建步骤结合使用(例如zig build run --watch -fincremental),程序会在每次构建完成后自动运行。对于短运行时的程序,这可能正是你需要的。但对于长运行时的程序(如图形应用),需要注意的是,当前构建系统只有在之前的程序实例关闭后,才能触发增量重建。根据你的工作流程,这可能没问题(比如每次构建前手动关闭应用),但如果不符合你的需求,你可以先进行常规构建,再从zig-out/目录手动运行程序。如果你属于这种情况,不必担心——我们已在规划对构建系统进行一系列增强,以支持更多工作流程!

与Zig项目的所有功能一样,增量编译尚未稳定。 虽然它的表现已经不错,但目前仍存在一些bug,可能包括误报编译错误甚至编译结果异常。如果你在使用增量编译时遇到任何问题,请尽可能在Zig代码库提交issue——我们收到的bug反馈越多,就越能在下次版本中修复更多问题!

感谢所有提前试用该功能的用户,以及阅读本文后尝试使用它的人——很高兴看到它能为这么多人带来帮助。也感谢所有向Zig软件基金会捐款的朋友:能全身心投入这类有趣的工作,对我而言是莫大的荣幸。

最后,感谢你的阅读 :^)