Zig 增量编译:毫秒级重建复杂应用,开发效率大提升!
Zig 增量编译内幕揭秘
2026 年 7 月 28 日,作为 Zig 核心团队一员,参与过的最具影响力项目之一,便是在 Zig 编译器实现 _增量编译_ 功能。该功能可让编译器检测项目上次构建后函数和声明变化,仅重编更改代码,将生成字节修补到输出二进制文件,使重建极快。
Zig 项目为实现此功能努力已久。过去几个发布周期,该功能从概念验证发展到可用于实际项目,且 Zig 核心团队多数成员日常使用。如今,借助该功能可在毫秒级修改真实复杂应用程序。这里有个无音频视频,展示使用 Zig 快速修改和测试 Fizzy(像素编辑应用程序)过程,初始构建约 5 秒,后续修改重建仅需 50 - 70 毫秒。
演示中需将 Fizzy 升级到 Zig 的 `master` 分支,因 Zig 0.16.0 虽支持增量编译,但缺重要链接器功能,这些功能后续才实现。若想用 Zig 标记版本,可能要等 0.17.0 版本发布后尝试此功能。
Fizzy 随机修改的快速增量重建
若被说服想知道如何使用该功能,可直接看本文最后部分。若怀疑其是否适用于多数项目,或想了解工作原理,下面深入探讨细节。
处理源文件
Zig 编译器流程分几部分,先以整个源文件为粒度,在循环中执行:从磁盘读取源文件;将文件解析为抽象语法树(AST);用 “AstGen” 过程将 AST 转换为 “ZIR” 格式。ZIR 是无类型的静态单赋值(SSA)形式的中间表示。
`AstGen` 运行时会识别源文件中 Zig 导入,对导入文件重复此过程,最终找到编译中每个 Zig 源文件并转换为 ZIR。此流程有特性:对每个文件处理是文件内容纯函数,无共享或外部状态;`Parse` 和 `AstGen` 快,在笔记本上对 Zig 编译器 `src/` 目录运行这两步约 920 毫秒;因 Zig 用面向数据设计模式,ZIR 可通过 `writev`/`readv` 系统调用读写磁盘,无需 “序列化” 步骤。
这些特性带来好处:整个过程高度并行,发现新源文件可将任务加入线程池队列多线程运行,唯一共享状态用互斥锁保护;实现增量编译简单,将生成 ZIR 缓存到磁盘,文件更改时才重建。这两个优化在 Zig 中默认启用多年,多数情况能让此流程瞬间完成,通过标准错误输出进度信息可看到速度,显示 “AST 降级” 时此流程运行,很多 Zig 用户可能首次运行编译器时才注意到。不过这只是简单部分,后续更棘手。
语义分析
流程的语义分析部分很重要,包括类型检查和 `comptime` 求值。主要任务是 “解释” 之前生成的 ZIR,发出编译错误,为运行时函数构建另一种中间表示。“容器级声明” 在 Zig 中类似其他语言 “顶级声明”,指 “函数、全局常量或全局变量”。
语义分析是编译器最难增量处理部分,因语言设计在此阶段重要。虽多数现代语言可支持增量编译,但某些设计决策会增加实现难度,Zig 多年来调整设计以支持快速增量编译。关键是将编译过程拆分成可独立分析部分,用依赖图建模部分间依赖关系。
Zig 编译器有四种分析单元:`struct` 或 `union` 类型布局;容器级声明类型;容器级 `const` 声明值;运行时函数主体。分析单元时会确定其依赖的其他单元集合,如分析函数 `foo` 主体时,会确定其依赖 `global_0` 和 `global_1` 类型及 `global_1` 值,若这些发生变化,需重新分析该函数。
运行时函数主体分析单元在依赖图中只有 “出边”;对 `const` 声明值的依赖源于 Zig 在 `comptime` 使用这些值的能力;对类型布局的依赖源于拥有该类型的值或需了解布局信息。为解决源代码依赖问题,不仅跟踪分析单元间依赖,还跟踪其与源代码片段依赖,ZIR 包含特定源代码区域哈希值,源代码变化哈希值改变,编译器前端易检测。
通过示例代码和依赖图展示,若修改源代码,编译器会生成新 ZIR,比较声明哈希值,找到依赖该哈希值的单元,重新分析相关单元,最终引出代码生成阶段。
代码生成
代码生成(“codegen”)是编译器流程阶段,将语义分析生成的 AIR 转换为类似机器指令的 MIR 形式,MIR 指令和机器指令几乎一一对应,每个目标架构有单独实现。
代码生成是高度并行任务(不进行内联等函数间优化的构建中),不同函数代码生成无共享状态,可在多线程处理待处理函数队列,但要控制队列大小。增量编译时,此阶段简单,因 AIR 和 MIR 以单个函数为粒度,编译器无需缓存,代码生成完成后 AIR 丢弃,MIR 传递给链接器后丢弃。
链接
增量链接是难题,可能是其他主要工具链未支持增量编译的重要原因。通用增量链接器不常见,wild 最初设计为增量链接器,但近年重点转向冷链接性能,且无明确增量链接时间表。
wild 创建者讨论过增量链接困难,如比较输入对象确定变化。控制整个编译流程时有更简单方案:将链接器与编译器紧密集成。无增量编译时,链接器单线程工作,接收 MIR 后转换为机器代码,生成重定位信息,在输出段预留空间,保存相关信息。
增量链接更复杂,写入机器代码、分配地址和应用重定位信息需在知道二进制文件完整内容前完成,并能后续更新。为解决问题,Jacob Young 在 Zig 编译器引入 `link.MappedFile` 抽象,它将输出文件内存映射,跟踪文件中 “节点” 树,API 用户可添加或扩展节点,空间不足时处理移动其他节点,调整节点时设置 “脏” 标志,链接器检测并应用必要修复。
目前 `MappedFile` 节点分配逻辑简单,可在未来改进。实现增量链接时,完成机器代码生成后在映射文件创建或调整节点,设置 “脏” 标志,链接器空闲或编译结束时检查并清理,虽有时修复工作耗时,但多数情况无需移动内容,通过指数增长因子分摊成本,实际中更新慢情况罕见。
刷新
完成整个流程的增量处理后,需进行收尾工作。因 Zig “懒分析” 功能与增量编译交互,需图遍历确定哪些函数、声明等被引用,忽略未引用内容的编译错误,不进行符号导出等。确定引用内容后,告诉链接器导出全局符号,报告编译错误,执行杂项任务,调用链接器 `flush` 函数完成剩余工作,最后关闭文件,编译完成。
跟踪更新
Tracy 是实时性能分析工具,可集成到 Zig 编译器,通过构建标志启用。对 Fizzy 更改时的 Tracy 输出显示,增量更新耗时 37 毫秒,前 6 毫秒线程池进行按文件处理工作,检查每个源文件,`computeAliveFiles` 函数遍历文件导入图分配文件到模块并检查是否参与编译,`updateZirRefs` 函数关联旧 ZIR 和新 ZIR 并更新内部引用。
完成按文件处理后进入流程核心部分,语义分析约 1.2 毫秒,代码生成约 240 微秒,链接工作约 170 微秒,此部分共约 1.6 毫秒。刷新阶段前端请求部分链接工作约 50 微秒。剩余 31 毫秒花在 `resolveReferencesInner` 函数上,用于确定 Zig 声明引用情况,虽此函数本身不低效,但图大影响速度,可通过避免重新计算未改变的引用图和只重新计算变化部分来提高效率。
使用增量编译
假设你有 Zig 项目和构建脚本,且能在最近 `master` 分支版本的 Zig 上编译(Zig 0.17.0 发布后也可)。目前该功能在以 `x86_64-linux` 为目标时才能发挥作用,因其他代码生成和链接器后端不成熟,Zig 核心团队先专注此平台,后续会为其他目标添加支持。
目前使用该功能非零成本,未来会将编译器状态缓存到磁盘自动加载,但现在只需运行 `zig build --watch -fincremental` 命令,`--watch` 参数监视源文件更改触发重建,`-fincremental` 参数使用增量编译。运行此命令时,即使有热缓存,项目中可执行文件、库或对象会重新构建,若不便可修改 `build.zig` 文件,用 `-Dincremental` 代替 `-fincremental` 对特定步骤进行初始重建。
初始构建完成后,编辑源文件保存即可看到重建结果,无编译错误时在 `zig-out/` 目录找到更新后的可执行文件。若将 `--watch` 与运行程序构建步骤结合,短时间运行程序适用,但长时间运行程序要注意构建系统需在程序关闭后触发增量重建,可正常构建后手动运行程序,后续会对构建系统改进支持其他工作流程。
和 Zig 项目其他部分一样,增量编译目前不稳定,存在 bug,使用时遇到问题可在 Zig 仓库提交 issue。感谢帮助测试和尝试使用该功能的用户,以及向 Zig 软件基金会捐款的人。
