Zig 的增量编译原理

Zig 的增量编译原理

Zig 增量编译概述

Zig 的增量编译通过缓存 ZIR、使用依赖图以及增量链接,在毫秒级时间内重建已更改的代码。 编译器首先将每个源文件转换为 ZIR,缓存结果,并仅在文件哈希发生变化时进行重建。 在语义分析期间,它会跟踪分析单元(layout, type, value, body)与源代码区域之间的依赖关系,因此一个更改只会使依赖于该更改哈希的单元失效。 代码生成以函数为单位进行,生成 MIR 并立即交给链接器。 链接器对输出二进制文件进行内存映射,并使用节点树 (MappedFile) 来插入或调整代码段的大小,仅在节点被标记为 dirty 时应用 fix-ups。 代码生成后,编译器会遍历引用图以仅保留可达的符号,并调用链接器的 flush 来完成二进制文件的构建。 Tracy profiling 显示大部分时间花在引用图遍历上,这表明存在进一步优化的机会。 若要立即尝试增量编译,请在针对 x86_64-linux 的近期 master 构建上运行 zig build --watch -fincremental(或使用逐步选项)。 该功能尚未稳定;它在 debug 构建中表现最佳,且需要手动清理缓存,未来的工作重点是稳定化、支持其他目标以及避免全量引用图重计算。

文件级缓存与并行化

编译器将每个源文件的 ZIR 缓存到磁盘上,并并行处理文件,因为解析和 AstGen 是纯函数。 读取文件、将其解析为 AST 并将该 AST 转换为 ZIR 不涉及任何共享或外部状态。 这些步骤非常快——在单线程上对整个 Zig 编译器 src/ 目录进行解析和 AstGen 大约需要 920 ms。 由于 ZIR 可以通过单个 writev/readv 系统调用写入和从磁盘读取,因此缓存的实现非常简单。 纯函数的特性使得这项工作具有极高的并行性:线程池可以独立处理每个新发现的源文件,只需使用 mutex 保护已见路径的哈希集即可。 缓存 ZIR 并仅在文件源哈希发生变化时重建已默认启用多年,这使得该流水线的大部分环节在大多数情况下几乎是瞬时完成的。

通过依赖图进行语义分析

增量重新编译依赖于细粒度的分析单元(layout, type, value, body)图,该图通过跟踪源代码哈希来检测更改。 在语义分析期间,编译器会为每个单元填充一组它所依赖的其他单元。 例如,分析一个加载全局变量的函数体会增加对该变量类型的依赖;如果该变量是 comptime 已知的,它还会增加对其值的依赖。 单元还依赖于定义它们的源代码区域;编译器在 ZIR 中存储每个区域的哈希,因此源代码的更改会改变哈希并标记依赖的单元为过期。 当编辑源文件时,编译器通过名称将旧的 ZIR 映射到新的 ZIR,比较哈希,并仅重新分析哈希发生变化的单元,然后传播到任何依赖这些值的单元。 由于不存在对运行时函数体的依赖,函数体单元在图中只有出边。 这种设计刻意限制了依赖的类型,以使图变得易于处理且可实现增量化。

代码生成与 MIR

代码生成以函数为单位进行,因此不需要对 AIR 或 MIR 进行缓存;输出会直接传递给链接器。 该阶段将 AIR 从语义分析转换为 MIR,后者与机器指令紧密对应。 由于 AIR 和 MIR 存在于单个函数的粒度上——这与增量编译使用的粒度相同——因此缓存它们没有好处;它们在处理后会被丢弃。 代码生成是极度可并行的:每个函数的 AIR 都可以排队并由任意数量的线程处理,只需设置一个队列大小限制以防止内存无限制增长。

使用 MappedFile 进行增量链接

链接器对输出二进制文件进行内存映射,并使用节点树 (MappedFile) 来插入或调整代码段的大小,仅在节点被标记为 dirty 时应用 fix-ups。 如果没有增量链接,链接器只有在所有代码已知后才会分配地址并应用重定位。 通过 MappedFile,在 codegen 为函数生成机器码后,链接器会在映射文件中创建一个或调整一个足够大的节点来容纳该代码,将代码复制进去,并在该节点上设置 dirty 标志。 如果父节点空间不足,MappedFile 会移动其他节点以腾出空间,并根据需要传播 dirty 标志。 当链接器线程空闲或编译结束时,它会处理所有 dirty 节点:分配虚拟地址、更新 section/program headers、更新符号表条目并重新应用重定位。 由于节点呈指数级增长(类似于 ArrayList),在实践中移动操作很少见,从而保持了较低的摊销成本。 这种方法避免了对整个对象文件进行 diff 的需求;编译器与链接器的集成让链接器确切知道哪些部分发生了变化。

Flush 与引用解析

代码生成后,编译器会遍历引用图以仅保留可达的符号,并调用链接器的 flush 来完成二进制文件的构建。 flush 步骤决定哪些 Zig 声明是实际被引用的,忽略自上次构建以来变得不可达的任何声明。 然后它会将每个导出的全局符号告知链接器,以便链接器可以添加适当的符号表条目。 最后,链接器的 flush 函数执行任何剩余工作——写入 .dynamic section 和 ELF header entry field——同时力求实现每次更新的 O(1) 工作量。 任何仍被标记为 dirty 的 MappedFile 节点都将在此步骤中处理。

一次更新的 Tracy Profiling

Tracy 显示大部分时间花在引用图遍历上,这表明存在进一步优化的机会。 在一个耗时 37 ms 的采样增量更新中,前 6 ms 用于每个文件的 ZIR 更新、语义分析、代码生成和链接。 剩余的 ~31 ms 被 resolveReferencesInner 消耗,它遍历完整的引用图以确定可达的声明。 尽管图本身没有变化,但编译器在每次更新时仍会重新计算它。 作者指出这提出了一个明确的优化目标:在图未改变时避免重新计算,或者仅更新受影响的部分(这是一个动态单源最短路径问题)。

如今如何使用增量编译

若要尝试增量编译,请在针对 x86_64-linux 的近期 master 构建上运行 zig build --watch -fincremental(或使用逐步选项)。 --watch 标志使构建系统在文件系统发生变化时重新构建;-fincremental 告诉它在重建时使用增量编译。 由于现有的磁盘缓存与增量编译不兼容,启用该标志后的第一次运行将重建所有内容。 此后,编辑并保存源文件会触发一个在几十毫秒内完成的重建。 对于逐步方法,可以在 build.zig 中暴露 -Dincremental 选项,并在选项为 true 时设置 exe.incremental = true,然后使用 -Dincremental 代替 -fincremental。 作者指出,该工作流在运行时间较短的程序中效果最好;对于运行时间较长的图形化应用,构建系统目前会等待前一个进程退出后才触发重建。

当前局限性与未来工作

增量编译尚未稳定,在 debug 构建中表现最佳,且需要手动清理缓存;未来的工作包括稳定该功能、支持其他目标以及避免全量引用图重计算。 作者明确指出,增量编译尚未稳定,可能包含错误的编译错误或错误的编译结果。 虽然本文展示了在 debug 构建下像素编辑器 (Fizzy) 的快速重建,但并未解决该功能是否适用于 release 构建的问题。 一位评论者询问,鉴于 Zig 编译器可以编译 C,该功能是否也适用于 C 代码;本文并未回答这个问题。 另一位评论者质疑为什么链接器在 debug 构建中产生单个巨大的二进制文件,而不是使用许多小的共享库;本文并未讨论替代设计。 作者提到,计划对构建系统进行未来增强,以支持其他工作流,例如在不等待长时间运行的程序退出时触发重建。 正在进行的工作还旨在通过缓存结果或增量更新结果来消除昂贵的引用图遍历。

"这在 release 构建中有效,还是目前只在 debug 构建中有效?" – @remywang

"Zig 编译器可以编译 C,那么它在 C 上也会有效吗?" – @hoppp

"我对这个设计有一些不太理解的地方:为什么他们坚持在 debug 构建中构建一个包含所有代码的巨大二进制文件?" – @thefaux

Sources