Inside Zig's Incremental Compilation | mlugg.co.uk
作为 Zig 核心团队的一员,我参与过的最具影响力的项目之一就是为 Zig 编译器实现增量编译(incremental compilation)。该功能允许编译器检测自上次构建项目以来哪些单独的函数和声明发生了更改,仅重新编译该部分代码,并直接将生成的字节补丁打入输出二进制文件中,从而使重新构建的速度极快。
Zig 项目为这个功能已经筹备了很长时间。在过去的几个发布周期中,它终于从一个概念验证(proof-of-concept)质量的功能,演变为适用于真实世界项目、且绝大多数 Zig 核心团队成员都在日常使用的实用功能。
今天,借助 Zig 的增量编译,你只需几毫秒就能对真实、复杂的应用程序进行修改。
不过,空口无凭!这里有一个简单的视频(无音频),展示了我如何使用 Zig 快速对像素编辑器应用程序 Fizzy 进行修改和测试。初始构建大约需要 5 秒钟,之后每当我进行更改时,重新构建只需 50 到 70 毫秒即可完成。
为了这个演示,我不得不将 Fizzy 升级到 Zig 的 master 分支。这是因为虽然 Zig 0.16.0 确实支持增量编译,但它缺少一些此后才实现的重要链接器(linker)特性。这意味着如果你倾向于使用 Zig 的带标签版本(tagged releases),在 0.17.0 发布之前,你大概率无法体验到这个功能;抱歉啦!
demo.mp4
(对 Fizzy 的一些随机更改进行快速增量重建)
如果你已经被说服,只想知道如何使用它,那太棒了!直接跳到这篇文章的最后一部分即可了解详情。但也许你理所当然地对这是否适用于大多数项目持怀疑态度,或者像我一样,你只是喜欢了解这些东西是如何运作的。对于所有这些朋友,让我们深入了解细节吧!
处理源文件
Zig 编译器的流水线(pipeline)可以分为几个不同的部分,我们将按顺序来看。第一部分在整个源文件的粒度上工作,基本上由在一个循环中运行以下过程组成:
从磁盘读取源文件
将该文件解析(Parse)为 AST(抽象语法树)
通过名为“AstGen”的遍历(pass),将该 AST 转换为名为“ZIR”的格式
如果你好奇的话,ZIR(Zig 中间表示,Zig Intermediate Representation)是一种无类型的 SSA 形式 IR——但如果你完全不知道这意味着什么,也不用担心,因为在这里这并不重要。我们关心的只是将整个源文件转换为不同的格式。
在 AstGen 运行期间,它会了解源文件中的所有 Zig 导入(@import("foo.zig")),以便我们在所有导入的文件上重复整个过程。因此,通过在一个循环中运行此过程,我们最终将发现编译中的每一个 Zig 源文件,并将它们全部转换为 ZIR。
(Zig 编译器中的文件处理流水线)
流水的这一部分实际上具有几个非常有用的属性:
对每个文件运行的处理是该文件内容的纯函数(pure function),不涉及共享或外部状态。
Parse 和 AstGen 本身都相当快:在我的笔记本电脑上,在 Zig 编译器的整个 src/ 目录下运行这两者(完全没有并行性)大约需要 920 毫秒。
得益于 Zig 对面向数据设计模式(data-oriented design patterns)的使用,ZIR 可以通过一次 writev/readv 系统调用轻松地写入磁盘和从磁盘读取——没有“序列化(serialization)”步骤。
这些属性带来了两个美好的结果。
首先,假设每个源文件对应一个“任务”,整个过程是极其容易并行化的(embarrassingly parallel)。这意味着每当我们从导入中发现一个新的源文件时,只需排队一个任务,就可以轻松地在线程池上运行它——唯一共享的态(我们用互斥锁保护)是一个用于跟踪我们已经见过哪些文件路径的哈希集(hash set)。
其次,可以说更重要的是,这些属性使得为流水线的这一部分实现增量编译变得非常简单。我们要做的全部事情就是将每个源文件生成的 ZIR 缓存到磁盘上,并且只有在我们检测到文件发生更改时才重新构建它。
这两种优化在 Zig 中默认启用多年——它们经过了实战检验,在大多数情况下使流水线的这一部分几乎瞬间完成。如果你正在使用 Zig,你可以通过 stderr 上的进度输出看到它的速度有多快——当它显示“AST Lowering”时,流水线的这一部分正在运行。我猜很多 Zig 用户甚至只有在第一次运行编译器时才会注意到这一点(因为在第一次运行时,编译器需要为整个 Zig 标准库和 compiler_rt 做这项工作)。
好了,我们让这部分变快了!这很棒,但坏消息是这只是简单的一部分——很多编译器已经可以做到这种缓存。从这里开始,事情将变得更加棘手。
语义分析
流水线的下一部分可以说是最重要的:语义分析(semantic analysis)。这包括类型检查(type checking)和 comptime(编译期)求值。
语义分析的工作本质上是“解释”我们之前产生的 ZIR,并在过程中发出编译错误(例如类型错误);并且,对于运行时函数,构建另一个可以发送到流水线后续部分的中间表示(分析中间表示,简称 AIR)。
在我们继续之前,先澄清一下术语。“容器级声明”(container-level declaration)是其他语言所称的“顶级声明”(top-level declaration)在 Zig 中的等价物。这个术语在 Zig 中并不准确,因为容器级声明在语法上不必处于顶层,但概念是相同的。如果我说“容器级声明”,我基本上是指“一个函数、全局常量或全局变量”。
语义分析是编译器中最难增量处理的部分。因此,毫无疑问,语言设计在这里开始变得非常重要:虽然我非常有信心大多数现代语言都可以支持类似于我们这种方式的增量编译,但某些设计决策可能会使这变得困难得多。多年来,Zig 的设计经过了微调(有时会引起争议),专门为了更容易支持快速增量编译。
这里的核心诀窍是将你的编译拆分成一堆可以大部分彼此独立分析的部分,并且至关重要地,这些部分之间确实存在的依赖关系可以很容易地在依赖图中建模。
在 Zig 编译器中,我们将这些部分称为“分析单元”(analysis units),或者有时我简称其为“单元”(unit)。在这里,我将稍微简化一下,告诉你 Zig 编译器有四种不同类型的分析单元:
结构体(struct)或联合体(union)类型的布局(大小、对齐等)。
容器级声明的类型。
容器级 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 不是编译期已知(comptime-known)的,我们对 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 的编译期已知值发生变化,则应重新分析该函数。
对运行时函数体本身的依赖是不可能的(至少在我这里介绍的简化视图中是这样)。这意味着函数体分析单元在依赖图中只能有“出边”(outgoing edges)(即它们可能依赖于其他单元,但其他单元不依赖于它们)。
对 const 声明值的依赖仅由于 Zig 在 comptime 使用这些值的能力而产生。如果没有该语言特性,对声明值的依赖将是不可能的,就像依赖运行时函数体是不可能的一样。
简而言之,对类型布局的依赖源于拥有该类型的植,或者需要了解有关该类型布局的一些信息。我在这里不再进一步讨论这一点,因为它有点复杂并且非常特定于 Zig 的类型系统,但它并没有本质上的不同。
好了,我们已经告诉编译器,当某样东西的重新分析需要同时触发另一样东西的重新分析时该怎么做。然而,这里还有一个拼图——源代码依赖项。单靠这个依赖图是没有用的:当用户请求重新编译(我们称之为“增量更新”)时,我们实际上该做什么?我们甚至不知道要重新分析的第一件事是什么!
为了解决这个问题,我们不仅跟踪分析单元对其他单元的依赖,还跟踪它们对源代码片段的依赖。在我们到目前为止看过的例子中,这些都非常简单:在上面的代码片段中,单元“global_0 的类型”依赖于 global_0 的源代码,单元“global_1 的类型”和“global_1 的值”都依赖于 global_1 的源代码,而单元“foo 的体”依赖于 foo 的源代码。每当给定区域中的任何源代码字节被修改时,依赖的分析单元将被标记为“过时(outdated)”并重新分析。
请注意,在现实中,事情可能比每个单元依赖于一处源代码更复杂。例如,Zig 中的内联函数调用(inline function call)执行语义内联,这意味着它本质上会触发对不同代码片段的语义分析,但在调用者的分析单元中。因此,内联函数调用引入了调用者分析单元对被调用者源代码的依赖。
当然,我们仍然需要能够弄清楚自上次增量更新以来源代码的哪些区域发生了更改。为此,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;。首先,编译器为该文件降级(lowers)新的 ZIR。它根据声明名称将旧 ZIR 中的声明映射到新 ZIR,并比较与每个声明关联的源代码哈希。在这种情况下,它成功映射了每个声明,并且它看到一个源代码哈希发生了变化——与 const lucky_number 关联的那个。
接下来,它查看依赖图以查找依赖于该源代码哈希的所有内容。在这种情况下,它只找到一个直接依赖项:
(更改源代码哈希后失效的依赖项)
因此,编译器重新分析 lucky_number 声明的值。如果我们对该行的更改是个无操作(no-op,例如我们只是添加了一些空白字符),那么它将确定值没有改变,并在此停止。
但在这种情况下,值确实变了!因此,编译器通过接下来考虑依赖于 lucky_number 值的任何分析单元来继续此过程,其中有两个:
(更改 lucky_number 值后失效的依赖项)
编译器分析这两个单元——testLuck 和 getSomething 的函数体。这些单元没有其他依赖项(因为它们是函数体),所以语义分析循环在此停止。然而,这些函数的语义分析确实生成了新的 AIR,这非常整洁地引出了我们的下一个主题:代码生成。
代码生成
代码生成(Code generation),有时简称为“codegen”,是编译器流水线中的一个阶段,在此阶段,来自语义分析的 AIR 被转换为类似于机器指令的东西。Codegen 还没有完全发出机器指令——而是称为 MIR(机器中间表示,Machine Intermediate Representation)的东西——但 MIR 指令与机器指令之间几乎存在 1 对 1 的映射。针对每个目标架构(x86_64、aarch64 等)都有独立的 codegen 实现。
Codegen 的一个好处是,就像早期的全文件处理一样,它是一个极其容易并行化的任务(至少在你不做跨函数优化(如内联)的构建中)。不同函数的代码生成之间没有共享状态,因此我们可以有一个待处理函数的队列,其 AIR 需要转换为 MIR,并跨任意数量的线程处理该队列。
这里只有一个小陷阱,我们需要小心限制该队列的大小,因为如果 codegen 出于任何原因落后于语义分析,排队的 AIR 大小可能会迅速累积!
就增量编译而言,流水线的这个阶段实际上是极其简单的,因为 AIR 和 MIR 都存在于单个函数的粒度上,这与增量编译工作的粒度相同。这意味着编译器根本不需要在磁盘上缓存 AIR 或 MIR!一旦代码生成完成,AIR 就会被丢弃,而 MIR 在被我们的下一站:链接器消费后也会立即被丢弃。
链接
增量链接(Incremental linking)是一个相当困难的问题,我怀疑这也是为什么没有其他主流工具链支持这种增量编译的主要原因。通用增量链接器目前并不真正存在,虽然 Wild 最初被构想为一个增量链接器,但在过去几年里,该项目似乎已将重点坚定地转移到冷链接(cold-link)性能上,并且没有明确的增量链接时间表。
Wild 的创作者 David Lattimore 有一篇博客文章,讨论了增量链接的一些难点。其中之一是对比输入目标文件(diffing input objects)以弄清楚更新时实际更改了什么。然而,当你控制整个编译流水线时,会出现一个更简单的设计,从而巧妙地回避了整个问题:将链接器与编译器紧密集成。
首先,让我们看看链接器在没有增量编译的情况下是如何工作的。因为链接涉及大量共享状态,我们的链接器完全是单线程的(也许我们将来会研究多线程链接,但现在我们保持简单)。
当链接器从 codegen 接收到 MIR 时,它首先需要将该 MIR 转换为实际的机器码。这个逻辑特定于 codegen 后端,但我们直到现在才能运行它,因为它需要与链接器配合。这是因为在发出机器码时,codegen 后端会生成重定位(relocations)——基本上是给链接器的指令,用于用特定的地址或值覆盖代码的某些部分(例如另一个符号的地址)。链接器需要在内部保存所有这些重定位,因此我们为此必须在链接器线程上。
在生成机器码和相关的重定位后,我们为输出节(通常是 .text)中的机器码预留空间。我们将机器码保存在缓冲区中,保存重定位以便稍后应用,并做一些杂项 bookkeeping 工作,例如添加符号表条目。
对于非增量链接器来说,这就是故事的结局。在编译结束时,我们将为每个节分配地址,写出我们为其预留空间的所有内容,并应用所有重定位。
增量链接稍微棘手一些——将机器码写入文件、分配地址和应用重定位,理想情况下都需要在我们知道二进制文件的全部内容之前发生,并且我们需要能够在以后更新这些事情。
这里的许多复杂性实际上只是在移动东西。例如,如果我们想向 .text 节添加一个函数,但空间不够,我们需要扩展该节。但是该节可能被其他节包围,我们不能直接覆盖它们,所以我们需要移动某些东西——要么是 .text 节本身,要么是周围的节之一。在这样做的过程中,我们不仅会改变文件偏移量,还会改变我们移动的所有东西的虚拟地址——这意味着我们需要更新符号表地址、重新应用重定位等。需要跟踪的事情太多了!(对于段(segments)也有类似的问题,高一层。)
为了解决这个问题,Jacob Young 在 Zig 编译器中引入了一个名为 link.MappedFile 的巧妙抽象。它对输出文件进行内存映射(memory-maps),但更重要的是,它跟踪该文件中“节点”(nodes)的树。根节点覆盖整个文件,子节点引用其父节点内的特定区域。
API 用户可以添加节点,或者将节点增长到指定大小——在这两种情况下,如果父节点中没有空间来轻而易举地执行该操作,MappedFile 会处理移动其他节点以为其腾出空间。每当它调整大小或移动节点时,实现就会在该节点上设置一个“脏(dirty)”标志,以便链接器实现在某个时候可以检测到这一点并应用任何必要的修复(fixups),例如重新应用其目标已移动的重定位。
(调整 MappedFile 中节点的大小)
目前,MappedFile 对于节点分配的逻辑相当原始,所以有时会做出次优的决策——但因为我们把它抽象在一个整洁的小 API 后面,我们可以在未来独立改进它。
因此,要实现增量链接,我们只需要稍微改变我前面描述的过程。在我们完成机器码发射后,我们在映射文件中创建一个节点,大到足以容纳该代码——或者如果这个函数已经存在,我们只需调整现有节点的大小——并将机器码复制到其中。在文件中分配这个节点可能(在罕见情况下)需要移动文件中的其他一些东西,在这种情况下,会在这些节点上设置适当的“脏”标志。我们总是为函数自己的节点设置“脏”标志,以便它的重定位将在某个时候被应用。
由于挂起的重定位,我们现在可能没有一个有效的二进制文件——但这没关系!当链接器线程下一次空闲时(即其工作队列为空),或者如果链接器线程一直忙到编译结束,我们将检查所有这些“脏”标志并进行清理。这可能涉及诸如分配新的虚拟地址、更新节头和程序头、更新符号表中的地址以及重新应用重定位等工作。
听起来那些“修复(fixup)”工作可能很昂贵。有时,确实如此——如果你正在创建一个动态可执行文件且 PLT(过程链接表)必须移动,那可能需要一会儿,因为通常有许多重定位针对 PLT。
然而,大多数时候,我们不需要移动任何东西!通过在节点上使用指数增长因子(类似于 ArrayList 等动态数据结构的运作方式),我们分摊了这一成本,使其在现实中变得极其罕见(代价是二进制文件大小略有增加,这在开发过程中通常不是主要关注点)。
这种设计意味着你可能偶尔会看到一次更新运行得比平时稍慢(也许几百毫秒?),但我个人哪怕在过去几个月里几乎每天都在这个链接器上使用增量编译,也没有遇到过哪怕一次。
刷新(Flush)
好了,我们已经到了最后,并且一路保持了所有东西的增量化。文件通过简单的逐文件缓存被降级为 ZIR;声明的语义分析跟踪依赖图以弄清楚可能发生了什么变化;代码生成仅对更新的函数重新运行;我们的链接器将新代码写入文件而没有改变任何其他字节。我们只需再收尾一些松散的末端。
首先,由于 Zig 的“惰性分析”(lazy analysis)特性与增量编译的交互方式,我们需要进行图遍历来弄清楚哪些函数/声明/等实际上被引用了。有可能某个东西在先前的增量更新中被引用了(所以我们编译了它),但此后变得未被引用,这意味着我们需要忽略它发出的任何编译错误、不从中执行符号导出等。你这里可能可以做一些优化,但至少现在,我们只是在每次更新时遍历完整的引用图。即使在大型项目上我们也能蒙混过关,因为计算机真的很快!
一旦我们弄清楚了哪些被引用了,我们就可以告诉链接器从 Zig 代码导出的每个全局符号,以便它可以向符号表添加任何必要的条目。如果有任何编译错误,我们也会报告这些错误,以及诸如此类的一些杂项任务。
最后,我们调用链接器的 flush 函数,其工作只是在关闭文件之前完成任何剩余的链接工作。如果链接器有任何仍然标记为“脏”的 MappedFile 节点,我们需要处理它,但除此之外,我们希望做尽可能少的工作——记住,我们在这里做的任何事情都会在每次更新时发生,所以我们希望将其保持在几乎 $O(1)$ 。因此,ELF 链接器在这里真正做的全部事情就是写出 .dynamic 节,并写入 ELF 头中的 entry 字段。
然后我们关闭文件,编译就完成了!
追踪更新
解释很酷,但我们实际上可以亲眼看到这一切发生。Tracy 是一个实时性能分析器(profiler)——它是为游戏设计的,但你可以将它集成到任何东西中。Zig 编译器具有可选的 Tracy 集成,通过构建标志启用。(如果你眯起眼睛看,我猜增量更新有点类似于电子游戏中的帧?)
这有时对各种编译器性能分析很有用,但我实际上觉得用它来进行增量编译非常酷,因为我们可以清晰地看到编译器流水线的不同部分。让我们来看看对 Fizzy 进行更改的 Tracy 输出,非常类似于前面视频中的更改。这个特定的更新花费了 37ms(比我们在视频中看到的要快一点),但一开始我将放大这 37ms 更新的前 6ms 左右——我稍后会解释原因。
(增量更新的前 6ms,在 Tracy 中可视化)
在开始时,我们可以看到所有线程的一阵活动——那是线程池在做所有逐文件的任务。虽然我们只更改了一个文件,但编译器不会假设这一点,而是会检查每个源文件。这里有一个小优化,即因为我们已经知道哪些源文件在上一次更新时在编译中,我们可以猜测这些文件仍将全部可达,从而检查所有这些文件的更改。这只意味着我们不需要等待第一个文件被处理完以便发现它的导入。
接下来我们看到 computeAliveFiles 中消耗了相当大的一块时间(大约 1ms)。这个函数正在遍历文件导入图,将每个文件分配给一个 Zig “模块”(因为文件有可能在更新之间从一个模块移动到另一个模块)。我们还使用此导入遍历来检查所有源文件是否确实仍在编译中。如果有任何文件不在——因为它们的所有导入都被删除了——那么我们基本上就会在这次更新的剩余时间内忽略这些文件。
然后我们在 updateZirRefs 中花去了另一个毫秒。这个函数负责关联任何更改文件的旧 ZIR 和新 ZIR,并将对 ZIR 指令的所有内部引用更新为引用新 ZIR 中的指令索引,而不是旧 ZIR 中的索引。这是一项相当简单的任务,但当前的实现涉及迭代我们拥有引用的每个 ZIR 指令,并完全重建哈希表的元数据。这可能可以被优化。
现在我们完成了单线程的逐文件工作,我们终于可以进入流水线的重头戏了:语义分析、codegen 和链接。“sema_loop”区域包含了在语义分析中花费的所有时间——大约 1.2ms。然后我们看到该函数的 AIR 在不同的线程上被 codegen 拾取(名为 runCodegenInner 的绿色区域),运行时间约为 240us。产生的 AIR 被链接器线程拾取并发出到二进制文件中——这个链接工作(紫色的 emitFunction 区域和它旁边的小绿色区域)花费大约 170us。
总的来说,这部分流水线——在冷构建中绝大多数占主导地位——在这项更新中大约为 1.6ms。还不错!
这一切完成后,我们就进入了 flush。右下角的小紫色区域是前端在 flush 期间请求的一小部分链接工作:重新生成我们用来实现 Zig 的 @errorName 内置函数的查找表。这大约花费 50us,并将我们带到了我放大的 6ms 区域的尽头。这意味着终于到了缩小并看看剩下的 31ms 是什么的时候了……
(完整的 37ms 增量更新,在 Tracy 中可视化)
基本上所有剩余时间都花在了一个函数上,resolveReferencesInner。还记得刚才我提到在 flush 期间进行图遍历,以确定引用了哪些 Zig 声明吗?嗯,这就是这个函数的工作!它绝不是效率低下的,但那个图有点大,所以当我们试图变快时它开始变得重要,这也许并不令人意外。
一方面,这看起来很傻,以至于我在发布这篇博客文章之前认真考虑尝试改进它(毕竟,7ms 的时间比 37ms 的时间更令人印象深刻)。当你考虑到引用图在这里实际上并没有改变时,这一点被放大了。因此,这次增量更新绝大部分的持续时间都花在了弄清楚一个图没有改变上!
但实际上,我觉得这真的很酷,因为它展示了还有多少效率有待榨取。这片 30ms 的区域在现实中并不是一个大问题,但我们还是可以摆脱它——首先是通过在引用图未更改时避免重新计算这些数据,其次是通过在引用确实更改时仅重新计算我们需要的内容(该问题称为“动态单源最短路径”,是图论中一个相当成熟的研究问题)。
基本上,我们在性能方面远未结束!如果你想跟上我们未来的工作,你可以考虑将 Zig 开发日志(devlog)添加到你的 RSS 阅读器中,或者在发布新版本的 Zig 时查看发行说明。
如何使用
好吧,关于编译器的废话我已经说得够多了;让我实际上向你展示如何使用这个东西。我假设你有一个带有构建脚本的 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" , "Enable incremental compilation" ) 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 项目一样,增量编译尚不稳定。虽然它运行得很好,但现在肯定有漏洞,可能包括一些误报的编译错误甚至是错误编译。如果你在使用增量编译时遇到任何问题,如果可以的话,请在 Zig 仓库中打开一个 Issue——我们被告知的 Bug 越多,我们就越能争取在下一个版本中修复它们!
感谢到目前为止帮助尝试此功能的所有用户,以及在阅读本文后尝试它的任何人——很高兴看到这适用于这么多人。还要感谢所有向 Zig 软件基金会捐款的人:能够把时间花在这样酷的事情上,真是一种莫大的特权。
当然,感谢阅读 :^)
加入我们
Zig 中文社区是一个开放的组织,我们致力于推广 Zig 在中文群体中的使用,有多种方式可以参与进来:
供稿,分享 自己使用 Zig 的心得
改进 ZigCC 组织下的开源项目
加入微信群 、Telegram 群组
作为 Zig 核心团队的一员,我参与过的最具影响力的项目之一就是为 Zig 编译器实现增量编译(incremental compilation)。该功能允许编译器检测自上次构建项目以来哪些单独的函数和声明发生了更改,仅重新编译该部分代码,并直接将生成的字节补丁打入输出二进制文件中,从而使重新构建的速度极快。
Zig 项目为这个功能已经筹备了很长时间。在过去的几个发布周期中,它终于从一个概念验证(proof-of-concept)质量的功能,演变为适用于真实世界项目、且绝大多数 Zig 核心团队成员都在日常使用的实用功能。
今天,借助 Zig 的增量编译,你只需几毫秒就能对真实、复杂的应用程序进行修改。
不过,空口无凭!这里有一个简单的视频(无音频),展示了我如何使用 Zig 快速对像素编辑器应用程序 Fizzy 进行修改和测试。初始构建大约需要 5 秒钟,之后每当我进行更改时,重新构建只需 50 到 70 毫秒即可完成。
为了这个演示,我不得不将 Fizzy 升级到 Zig 的 master 分支。这是因为虽然 Zig 0.16.0 确实支持增量编译,但它缺少一些此后才实现的重要链接器(linker)特性。这意味着如果你倾向于使用 Zig 的带标签版本(tagged releases),在 0.17.0 发布之前,你大概率无法体验到这个功能;抱歉啦!
demo.mp4
(对 Fizzy 的一些随机更改进行快速增量重建)
如果你已经被说服,只想知道如何使用它,那太棒了!直接跳到这篇文章的最后一部分即可了解详情。但也许你理所当然地对这是否适用于大多数项目持怀疑态度,或者像我一样,你只是喜欢了解这些东西是如何运作的。对于所有这些朋友,让我们深入了解细节吧!
处理源文件
Zig 编译器的流水线(pipeline)可以分为几个不同的部分,我们将按顺序来看。第一部分在整个源文件的粒度上工作,基本上由在一个循环中运行以下过程组成:
如果你好奇的话,ZIR(Zig 中间表示,Zig Intermediate Representation)是一种无类型的 SSA 形式 IR——但如果你完全不知道这意味着什么,也不用担心,因为在这里这并不重要。我们关心的只是将整个源文件转换为不同的格式。
在 AstGen 运行期间,它会了解源文件中的所有 Zig 导入(
@import("foo.zig")),以便我们在所有导入的文件上重复整个过程。因此,通过在一个循环中运行此过程,我们最终将发现编译中的每一个 Zig 源文件,并将它们全部转换为 ZIR。(Zig 编译器中的文件处理流水线)
流水的这一部分实际上具有几个非常有用的属性:
src/目录下运行这两者(完全没有并行性)大约需要 920 毫秒。writev/readv系统调用轻松地写入磁盘和从磁盘读取——没有“序列化(serialization)”步骤。这些属性带来了两个美好的结果。
首先,假设每个源文件对应一个“任务”,整个过程是极其容易并行化的(embarrassingly parallel)。这意味着每当我们从导入中发现一个新的源文件时,只需排队一个任务,就可以轻松地在线程池上运行它——唯一共享的态(我们用互斥锁保护)是一个用于跟踪我们已经见过哪些文件路径的哈希集(hash set)。
其次,可以说更重要的是,这些属性使得为流水线的这一部分实现增量编译变得非常简单。我们要做的全部事情就是将每个源文件生成的 ZIR 缓存到磁盘上,并且只有在我们检测到文件发生更改时才重新构建它。
这两种优化在 Zig 中默认启用多年——它们经过了实战检验,在大多数情况下使流水线的这一部分几乎瞬间完成。如果你正在使用 Zig,你可以通过 stderr 上的进度输出看到它的速度有多快——当它显示“AST Lowering”时,流水线的这一部分正在运行。我猜很多 Zig 用户甚至只有在第一次运行编译器时才会注意到这一点(因为在第一次运行时,编译器需要为整个 Zig 标准库和
compiler_rt做这项工作)。好了,我们让这部分变快了!这很棒,但坏消息是这只是简单的一部分——很多编译器已经可以做到这种缓存。从这里开始,事情将变得更加棘手。
语义分析
流水线的下一部分可以说是最重要的:语义分析(semantic analysis)。这包括类型检查(type checking)和 comptime(编译期)求值。
语义分析的工作本质上是“解释”我们之前产生的 ZIR,并在过程中发出编译错误(例如类型错误);并且,对于运行时函数,构建另一个可以发送到流水线后续部分的中间表示(分析中间表示,简称 AIR)。
在我们继续之前,先澄清一下术语。“容器级声明”(container-level declaration)是其他语言所称的“顶级声明”(top-level declaration)在 Zig 中的等价物。这个术语在 Zig 中并不准确,因为容器级声明在语法上不必处于顶层,但概念是相同的。如果我说“容器级声明”,我基本上是指“一个函数、全局常量或全局变量”。
语义分析是编译器中最难增量处理的部分。因此,毫无疑问,语言设计在这里开始变得非常重要:虽然我非常有信心大多数现代语言都可以支持类似于我们这种方式的增量编译,但某些设计决策可能会使这变得困难得多。多年来,Zig 的设计经过了微调(有时会引起争议),专门为了更容易支持快速增量编译。
这里的核心诀窍是将你的编译拆分成一堆可以大部分彼此独立分析的部分,并且至关重要地,这些部分之间确实存在的依赖关系可以很容易地在依赖图中建模。
在 Zig 编译器中,我们将这些部分称为“分析单元”(analysis units),或者有时我简称其为“单元”(unit)。在这里,我将稍微简化一下,告诉你 Zig 编译器有四种不同类型的分析单元:
在对特定单元进行语义分析期间,我们会填充该单元所依赖的其他单元的集合。让我们看一个基本示例:
当我们分析函数
foo的函数体时,会发生以下情况:cond不是编译期已知(comptime-known)的,我们对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的编译期已知值发生变化,则应重新分析该函数。好了,我们已经告诉编译器,当某样东西的重新分析需要同时触发另一样东西的重新分析时该怎么做。然而,这里还有一个拼图——源代码依赖项。单靠这个依赖图是没有用的:当用户请求重新编译(我们称之为“增量更新”)时,我们实际上该做什么?我们甚至不知道要重新分析的第一件事是什么!
为了解决这个问题,我们不仅跟踪分析单元对其他单元的依赖,还跟踪它们对源代码片段的依赖。在我们到目前为止看过的例子中,这些都非常简单:在上面的代码片段中,单元“
global_0的类型”依赖于global_0的源代码,单元“global_1的类型”和“global_1的值”都依赖于global_1的源代码,而单元“foo的体”依赖于foo的源代码。每当给定区域中的任何源代码字节被修改时,依赖的分析单元将被标记为“过时(outdated)”并重新分析。请注意,在现实中,事情可能比每个单元依赖于一处源代码更复杂。例如,Zig 中的内联函数调用(inline function call)执行语义内联,这意味着它本质上会触发对不同代码片段的语义分析,但在调用者的分析单元中。因此,内联函数调用引入了调用者分析单元对被调用者源代码的依赖。
当然,我们仍然需要能够弄清楚自上次增量更新以来源代码的哪些区域发生了更改。为此,ZIR 包含特定“感兴趣的”源代码区域的哈希(例如每个容器级声明的整个源代码),而这些哈希正是你实际依赖的东西。如果源代码发生更改,哈希就会改变,编译器前端很容易检测到这一点。
好吧,解释了这么多——现在让我们看一些漂亮的图片!这里有一些 Zig 源代码:
……以及它的依赖图(为了清晰起见,删除了一些冗余边)。右侧的节点表示已被哈希化的源代码,而其余节点全都是分析单元。
(初始构建期间生成的依赖图)
现在,假设我们将文件的第一行更改为
const lucky_number = 43;。首先,编译器为该文件降级(lowers)新的 ZIR。它根据声明名称将旧 ZIR 中的声明映射到新 ZIR,并比较与每个声明关联的源代码哈希。在这种情况下,它成功映射了每个声明,并且它看到一个源代码哈希发生了变化——与const lucky_number关联的那个。接下来,它查看依赖图以查找依赖于该源代码哈希的所有内容。在这种情况下,它只找到一个直接依赖项:
(更改源代码哈希后失效的依赖项)
因此,编译器重新分析
lucky_number声明的值。如果我们对该行的更改是个无操作(no-op,例如我们只是添加了一些空白字符),那么它将确定值没有改变,并在此停止。但在这种情况下,值确实变了!因此,编译器通过接下来考虑依赖于
lucky_number值的任何分析单元来继续此过程,其中有两个:(更改 lucky_number 值后失效的依赖项)
编译器分析这两个单元——
testLuck和getSomething的函数体。这些单元没有其他依赖项(因为它们是函数体),所以语义分析循环在此停止。然而,这些函数的语义分析确实生成了新的 AIR,这非常整洁地引出了我们的下一个主题:代码生成。代码生成
代码生成(Code generation),有时简称为“codegen”,是编译器流水线中的一个阶段,在此阶段,来自语义分析的 AIR 被转换为类似于机器指令的东西。Codegen 还没有完全发出机器指令——而是称为 MIR(机器中间表示,Machine Intermediate Representation)的东西——但 MIR 指令与机器指令之间几乎存在 1 对 1 的映射。针对每个目标架构(x86_64、aarch64 等)都有独立的 codegen 实现。
Codegen 的一个好处是,就像早期的全文件处理一样,它是一个极其容易并行化的任务(至少在你不做跨函数优化(如内联)的构建中)。不同函数的代码生成之间没有共享状态,因此我们可以有一个待处理函数的队列,其 AIR 需要转换为 MIR,并跨任意数量的线程处理该队列。
这里只有一个小陷阱,我们需要小心限制该队列的大小,因为如果 codegen 出于任何原因落后于语义分析,排队的 AIR 大小可能会迅速累积!
就增量编译而言,流水线的这个阶段实际上是极其简单的,因为 AIR 和 MIR 都存在于单个函数的粒度上,这与增量编译工作的粒度相同。这意味着编译器根本不需要在磁盘上缓存 AIR 或 MIR!一旦代码生成完成,AIR 就会被丢弃,而 MIR 在被我们的下一站:链接器消费后也会立即被丢弃。
链接
增量链接(Incremental linking)是一个相当困难的问题,我怀疑这也是为什么没有其他主流工具链支持这种增量编译的主要原因。通用增量链接器目前并不真正存在,虽然 Wild 最初被构想为一个增量链接器,但在过去几年里,该项目似乎已将重点坚定地转移到冷链接(cold-link)性能上,并且没有明确的增量链接时间表。
Wild 的创作者 David Lattimore 有一篇博客文章,讨论了增量链接的一些难点。其中之一是对比输入目标文件(diffing input objects)以弄清楚更新时实际更改了什么。然而,当你控制整个编译流水线时,会出现一个更简单的设计,从而巧妙地回避了整个问题:将链接器与编译器紧密集成。
首先,让我们看看链接器在没有增量编译的情况下是如何工作的。因为链接涉及大量共享状态,我们的链接器完全是单线程的(也许我们将来会研究多线程链接,但现在我们保持简单)。
当链接器从 codegen 接收到 MIR 时,它首先需要将该 MIR 转换为实际的机器码。这个逻辑特定于 codegen 后端,但我们直到现在才能运行它,因为它需要与链接器配合。这是因为在发出机器码时,codegen 后端会生成重定位(relocations)——基本上是给链接器的指令,用于用特定的地址或值覆盖代码的某些部分(例如另一个符号的地址)。链接器需要在内部保存所有这些重定位,因此我们为此必须在链接器线程上。
在生成机器码和相关的重定位后,我们为输出节(通常是
.text)中的机器码预留空间。我们将机器码保存在缓冲区中,保存重定位以便稍后应用,并做一些杂项 bookkeeping 工作,例如添加符号表条目。对于非增量链接器来说,这就是故事的结局。在编译结束时,我们将为每个节分配地址,写出我们为其预留空间的所有内容,并应用所有重定位。
增量链接稍微棘手一些——将机器码写入文件、分配地址和应用重定位,理想情况下都需要在我们知道二进制文件的全部内容之前发生,并且我们需要能够在以后更新这些事情。
这里的许多复杂性实际上只是在移动东西。例如,如果我们想向
.text节添加一个函数,但空间不够,我们需要扩展该节。但是该节可能被其他节包围,我们不能直接覆盖它们,所以我们需要移动某些东西——要么是.text节本身,要么是周围的节之一。在这样做的过程中,我们不仅会改变文件偏移量,还会改变我们移动的所有东西的虚拟地址——这意味着我们需要更新符号表地址、重新应用重定位等。需要跟踪的事情太多了!(对于段(segments)也有类似的问题,高一层。)为了解决这个问题,Jacob Young 在 Zig 编译器中引入了一个名为
link.MappedFile的巧妙抽象。它对输出文件进行内存映射(memory-maps),但更重要的是,它跟踪该文件中“节点”(nodes)的树。根节点覆盖整个文件,子节点引用其父节点内的特定区域。API 用户可以添加节点,或者将节点增长到指定大小——在这两种情况下,如果父节点中没有空间来轻而易举地执行该操作,
MappedFile会处理移动其他节点以为其腾出空间。每当它调整大小或移动节点时,实现就会在该节点上设置一个“脏(dirty)”标志,以便链接器实现在某个时候可以检测到这一点并应用任何必要的修复(fixups),例如重新应用其目标已移动的重定位。(调整 MappedFile 中节点的大小)
目前,
MappedFile对于节点分配的逻辑相当原始,所以有时会做出次优的决策——但因为我们把它抽象在一个整洁的小 API 后面,我们可以在未来独立改进它。因此,要实现增量链接,我们只需要稍微改变我前面描述的过程。在我们完成机器码发射后,我们在映射文件中创建一个节点,大到足以容纳该代码——或者如果这个函数已经存在,我们只需调整现有节点的大小——并将机器码复制到其中。在文件中分配这个节点可能(在罕见情况下)需要移动文件中的其他一些东西,在这种情况下,会在这些节点上设置适当的“脏”标志。我们总是为函数自己的节点设置“脏”标志,以便它的重定位将在某个时候被应用。
由于挂起的重定位,我们现在可能没有一个有效的二进制文件——但这没关系!当链接器线程下一次空闲时(即其工作队列为空),或者如果链接器线程一直忙到编译结束,我们将检查所有这些“脏”标志并进行清理。这可能涉及诸如分配新的虚拟地址、更新节头和程序头、更新符号表中的地址以及重新应用重定位等工作。
听起来那些“修复(fixup)”工作可能很昂贵。有时,确实如此——如果你正在创建一个动态可执行文件且 PLT(过程链接表)必须移动,那可能需要一会儿,因为通常有许多重定位针对 PLT。
然而,大多数时候,我们不需要移动任何东西!通过在节点上使用指数增长因子(类似于
ArrayList等动态数据结构的运作方式),我们分摊了这一成本,使其在现实中变得极其罕见(代价是二进制文件大小略有增加,这在开发过程中通常不是主要关注点)。这种设计意味着你可能偶尔会看到一次更新运行得比平时稍慢(也许几百毫秒?),但我个人哪怕在过去几个月里几乎每天都在这个链接器上使用增量编译,也没有遇到过哪怕一次。
刷新(Flush)
好了,我们已经到了最后,并且一路保持了所有东西的增量化。文件通过简单的逐文件缓存被降级为 ZIR;声明的语义分析跟踪依赖图以弄清楚可能发生了什么变化;代码生成仅对更新的函数重新运行;我们的链接器将新代码写入文件而没有改变任何其他字节。我们只需再收尾一些松散的末端。
首先,由于 Zig 的“惰性分析”(lazy analysis)特性与增量编译的交互方式,我们需要进行图遍历来弄清楚哪些函数/声明/等实际上被引用了。有可能某个东西在先前的增量更新中被引用了(所以我们编译了它),但此后变得未被引用,这意味着我们需要忽略它发出的任何编译错误、不从中执行符号导出等。你这里可能可以做一些优化,但至少现在,我们只是在每次更新时遍历完整的引用图。即使在大型项目上我们也能蒙混过关,因为计算机真的很快!
一旦我们弄清楚了哪些被引用了,我们就可以告诉链接器从 Zig 代码导出的每个全局符号,以便它可以向符号表添加任何必要的条目。如果有任何编译错误,我们也会报告这些错误,以及诸如此类的一些杂项任务。
最后,我们调用链接器的 flush 函数,其工作只是在关闭文件之前完成任何剩余的链接工作。如果链接器有任何仍然标记为“脏”的$O(1)$ 。因此,ELF 链接器在这里真正做的全部事情就是写出
MappedFile节点,我们需要处理它,但除此之外,我们希望做尽可能少的工作——记住,我们在这里做的任何事情都会在每次更新时发生,所以我们希望将其保持在几乎.dynamic节,并写入 ELF 头中的 entry 字段。然后我们关闭文件,编译就完成了!
追踪更新
解释很酷,但我们实际上可以亲眼看到这一切发生。Tracy 是一个实时性能分析器(profiler)——它是为游戏设计的,但你可以将它集成到任何东西中。Zig 编译器具有可选的 Tracy 集成,通过构建标志启用。(如果你眯起眼睛看,我猜增量更新有点类似于电子游戏中的帧?)
这有时对各种编译器性能分析很有用,但我实际上觉得用它来进行增量编译非常酷,因为我们可以清晰地看到编译器流水线的不同部分。让我们来看看对 Fizzy 进行更改的 Tracy 输出,非常类似于前面视频中的更改。这个特定的更新花费了 37ms(比我们在视频中看到的要快一点),但一开始我将放大这 37ms 更新的前 6ms 左右——我稍后会解释原因。
(增量更新的前 6ms,在 Tracy 中可视化)
在开始时,我们可以看到所有线程的一阵活动——那是线程池在做所有逐文件的任务。虽然我们只更改了一个文件,但编译器不会假设这一点,而是会检查每个源文件。这里有一个小优化,即因为我们已经知道哪些源文件在上一次更新时在编译中,我们可以猜测这些文件仍将全部可达,从而检查所有这些文件的更改。这只意味着我们不需要等待第一个文件被处理完以便发现它的导入。
接下来我们看到
computeAliveFiles中消耗了相当大的一块时间(大约 1ms)。这个函数正在遍历文件导入图,将每个文件分配给一个 Zig “模块”(因为文件有可能在更新之间从一个模块移动到另一个模块)。我们还使用此导入遍历来检查所有源文件是否确实仍在编译中。如果有任何文件不在——因为它们的所有导入都被删除了——那么我们基本上就会在这次更新的剩余时间内忽略这些文件。然后我们在
updateZirRefs中花去了另一个毫秒。这个函数负责关联任何更改文件的旧 ZIR 和新 ZIR,并将对 ZIR 指令的所有内部引用更新为引用新 ZIR 中的指令索引,而不是旧 ZIR 中的索引。这是一项相当简单的任务,但当前的实现涉及迭代我们拥有引用的每个 ZIR 指令,并完全重建哈希表的元数据。这可能可以被优化。现在我们完成了单线程的逐文件工作,我们终于可以进入流水线的重头戏了:语义分析、codegen 和链接。“sema_loop”区域包含了在语义分析中花费的所有时间——大约 1.2ms。然后我们看到该函数的 AIR 在不同的线程上被 codegen 拾取(名为
runCodegenInner的绿色区域),运行时间约为 240us。产生的 AIR 被链接器线程拾取并发出到二进制文件中——这个链接工作(紫色的emitFunction区域和它旁边的小绿色区域)花费大约 170us。总的来说,这部分流水线——在冷构建中绝大多数占主导地位——在这项更新中大约为 1.6ms。还不错!
这一切完成后,我们就进入了 flush。右下角的小紫色区域是前端在 flush 期间请求的一小部分链接工作:重新生成我们用来实现 Zig 的
@errorName内置函数的查找表。这大约花费 50us,并将我们带到了我放大的 6ms 区域的尽头。这意味着终于到了缩小并看看剩下的 31ms 是什么的时候了……(完整的 37ms 增量更新,在 Tracy 中可视化)
基本上所有剩余时间都花在了一个函数上,
resolveReferencesInner。还记得刚才我提到在 flush 期间进行图遍历,以确定引用了哪些 Zig 声明吗?嗯,这就是这个函数的工作!它绝不是效率低下的,但那个图有点大,所以当我们试图变快时它开始变得重要,这也许并不令人意外。一方面,这看起来很傻,以至于我在发布这篇博客文章之前认真考虑尝试改进它(毕竟,7ms 的时间比 37ms 的时间更令人印象深刻)。当你考虑到引用图在这里实际上并没有改变时,这一点被放大了。因此,这次增量更新绝大部分的持续时间都花在了弄清楚一个图没有改变上!
但实际上,我觉得这真的很酷,因为它展示了还有多少效率有待榨取。这片 30ms 的区域在现实中并不是一个大问题,但我们还是可以摆脱它——首先是通过在引用图未更改时避免重新计算这些数据,其次是通过在引用确实更改时仅重新计算我们需要的内容(该问题称为“动态单源最短路径”,是图论中一个相当成熟的研究问题)。
基本上,我们在性能方面远未结束!如果你想跟上我们未来的工作,你可以考虑将 Zig 开发日志(devlog)添加到你的 RSS 阅读器中,或者在发布新版本的 Zig 时查看发行说明。
如何使用
好吧,关于编译器的废话我已经说得够多了;让我实际上向你展示如何使用这个东西。我假设你有一个带有构建脚本的 Zig 项目,并且它可以在 Zig 的最新 master 分支构建上编译(或者,如果你在 Zig 0.17.0 发布后阅读本文,那也可以工作)。
在撰写本文时,这实际上仅在你以
x86_64-linux为目标时才有效,因为我们的其他代码生成和链接器后端还不够成熟。绝大多数 Zig 核心团队成员都在 x86_64 上运行 Linux,因此通过首先关注它,我们加快了我们的工作流程,这意味着对我们来说为其他目标添加支持会更快——现在这是重中之重!坏消息是,目前这并不是零努力的。最终会是——我们将把所有的编译器状态缓存在磁盘上,并在你运行
zig build时自动重新加载最后保存的状态,因此增量编译将自动发生——但我们还没完全走到那一步。不过,好消息是,今天使用它只需非常少的工作!简而言之,你只需要运行以下命令:
--watch参数告诉 Zig 构建系统监视文件系统的源代码文件更改,并在更改发生时触发重新构建。-fincremental参数告诉构建系统,当它进行其中一次重新构建时,它应该使用增量编译。当你运行该命令时,你可能会注意到,即使你有热缓存,项目中的每个可执行文件/库/对象无论如何都会被重新构建。这是预期行为,因为磁盘上的现有缓存与增量编译不兼容。然而,如果这很不方便,那么你可以修改你的
build.zig,要求构建系统仅对特定的编译使用增量编译:然后你可以使用
-Dincremental代替-fincremental,它只会针对该特定步骤进行完整的初始重建。(-fincremental选项基本上是告诉构建系统在每个std.Build.Step.Compile上设置此标志。)无论如何,在初始构建完成后,只需编辑你的一个源文件,保存它,你应该会立即看到重新构建的结果。假设没有编译错误,你将像往常一样在
zig-out/中找到更新的可执行文件。这就是全部内容!如果你将
--watch与运行你的程序的构建步骤结合使用(例如zig build run --watch -fincremental),你的程序将在每次构建完成时运行。对于运行时间短的程序,这可能正是你想要的。然而,对于运行时间长的程序(例如图形应用程序),请注意,目前构建系统只能在你的程序前一个构建关闭后才能触发增量重建。根据你的个人工作流,这可能对你来说没问题(也许你每次想要构建时都会关闭应用程序),但如果不是,你可能更倾向于进行常规构建并从
zig-out/手动运行程序。如果是这样,请不要担心——我们已经在计划对构建系统进行一系列增强,以解锁其他工作流!与整个 Zig 项目一样,增量编译尚不稳定。虽然它运行得很好,但现在肯定有漏洞,可能包括一些误报的编译错误甚至是错误编译。如果你在使用增量编译时遇到任何问题,如果可以的话,请在 Zig 仓库中打开一个 Issue——我们被告知的 Bug 越多,我们就越能争取在下一个版本中修复它们!
感谢到目前为止帮助尝试此功能的所有用户,以及在阅读本文后尝试它的任何人——很高兴看到这适用于这么多人。还要感谢所有向 Zig 软件基金会捐款的人:能够把时间花在这样酷的事情上,真是一种莫大的特权。
当然,感谢阅读 :^)
加入我们
Zig 中文社区是一个开放的组织,我们致力于推广 Zig 在中文群体中的使用,有多种方式可以参与进来: