Zig 0.15 编译提速实战:自研 x86_64 后端如何把 Ghostty 的构建时间砍掉四分之一

Zig 社区有句话被反复引用:"编译器太慢了,所以我们才有 bug。"这句话背后的逻辑很朴素——编译慢,开发者就会下意识地少编译、少测试,把验证推迟到 CI 甚至推迟到线上。所以 Zig 多年来把"更快的编译"当作一等目标,干了几件在别人看来很疯的事:抛弃 LLVM、自己写代码生成后端、自己写链接器、一步步逼近增量编译。

这些投入在 Zig 0.15.1 终于开始显形。Mitchell Hashimoto 用他的 Ghostty 终端模拟器做了一组真实场景的对比,数据相当能说明问题。

四组真实构建数据

所有测量都在同一台 x86_64 Linux 机器上完成,对比 Zig 0.14 与 0.15。

构建脚本自身的编译(zig build --help 触发的 build.zig 编译):

场景 Zig 0.14 Zig 0.15
build.zig 编译 7.167s 1.702s

一个写得好的构建脚本很少需要重建自己,但每个未缓存的源码构建都要先付这笔成本。对"从源码编译一次"的用户来说,这直接决定了拿到可用二进制的等待时间。4 倍多的差距。

完整未缓存构建 Ghostty 可执行文件:

场景 Zig 0.14 Zig 0.15
全量未缓存 41s 32s

注意这里包含了构建脚本自身的编译时间。扣掉那 5 秒后,其余部分约快 2 秒。关键是,这次构建仍然大部分走 LLVM。Ghostty 目前还无法完全用自研 x86_64 后端完成构建和链接,因为后端本身还有 bug。也就是说,这里的提升纯粹来自 Zig 编译器自身的通用优化,LLVM 还在链路里。等 Ghostty 能完全切到自研后端,Mitchell 预计这个数字会掉到 25 秒以下,不到 0.14 的一半。

增量构建 Ghostty 可执行文件(改一行终端模拟核心代码,比如在转义序列解析器里加一次日志调用):

场景 Zig 0.14 Zig 0.15
增量(改一行) 19s 16s

构建脚本和依赖图已完全缓存,只重建必要的部分。但增量编译在 Zig 里还没完全可用,所以仍然重编了相当多的代码,而且依然在用 LLVM。仅把 LLVM 从链路里拿掉,预计能降到 12 秒左右。

增量构建 libghostty-vt(只重建这个库):

场景 Zig 0.14 Zig 0.15
改一行的增量 2.884s 0.975s

这组最值得看,因为 libghostty-vt 已经能完全跑在自研 x86_64 后端上,LLVM 完全不在链路里。近 3 倍的提速。同样,由于增量编译尚未完全可用,它仍在重建整个库模块。Mitchell 预计增量编译落地后,这个数字会掉到毫秒级。

对一个非平凡的库来说,亚秒级构建体验是质变。Mitchell 的原话是:升级到 0.15.1 才几天,工作流就有天壤之别。以前构建/测试之间会切出去看眼邮件,现在快到他可以一直待在终端里保持心流。

为什么能快:自研后端 + 线程化代码生成

要理解这些数字,得看 Zig 在 0.15 里做了什么。

自研 x86_64 后端在 Debug 模式下默认启用。根据官方发布说明,大多数情况下编译时间相比 LLVM 有约 5 倍的下降。而且这不只是快——自研 x86 后端已经通过了比 LLVM 后端更大的行为测试子集(1984/2008 vs 1977/2008),换句话说,它提供了更完整、更正确的语言实现。官方还在追踪 60 多个上游 LLVM bug,自研后端让编译器不再受这些问题波及。

当然自研后端也有自己的短板:它生成的机器码目前比 LLVM 慢。如果受影响,可以在 Debug 构建里传 -fllvm,或在 std.Build.Step.Compile 上设 .use_llvm = true 回退。Zig 核心团队自己编译 Zig 编译器时已经几乎只用自研 x86 后端——编译时间从 1-2 分钟降到几秒。

线程化代码生成(Threaded Codegen) 是另一个关键。相比 0.14.0,它在使用自研后端时带来又一波性能提升,墙钟时间改善从"不明显"到"超过 50%"不等,取决于具体编译的代码。一个真实数据点:用自研 x86_64 后端构建 Zig 编译器本身,在某个系统上因这项改动快了 27%。

链接器这轮只做了 bug 修复和维护,没有大改动。但官方路线图写得很清楚:增强链接器实现,消除对 LLD 的依赖,并支持增量编译。

自研后端和 LLVM 到底差在哪

传统上,Zig 把 AST 降级成 LLVM IR,交给 LLVM 做优化和机器码生成。这条链路成熟、优化强,但代价也明显:每一层都带着自己的中间表示和构建开销,而且 Zig 的语义得先"翻译"成 LLVM 能理解的样子,翻译过程本身就是信息损失和额外交互。

自研后端直接把 Zig 的中间表示降到机器码,跳过 LLVM 那一整套 IR 构造、优化 pass 和它对上下文的假设。这解释了为什么它能快 5 倍,也解释了为什么它生成的机器码暂时还比 LLVM 慢,毕竟 LLVM 十几年积累的优化器不是白给的。Zig 团队的选择是:先把开发循环这条最频繁的路径做快,机器码质量的问题用更多工程迭代去追,反正 Debug 构建本来就不追求极致优化。

这套设计从第一天就是为增量编译准备的。线程化代码生成把不同的编译单元并行处理,墙钟时间最多再降一半;而增量编译要做到"只重编改动的部分",前提就是后端能对单个函数/模块独立生成代码、独立链接。这正是自研后端相对 LLVM 的架构优势,LLVM 的模块级假设更难支持这种细粒度重建。

这一点从 Zig 的长期取舍里能看出来:它宁愿用一个暂时慢一点、但结构可控的自家后端,也不愿长期被上游 IR 的抽象层次绑住。

路线图:LLVM 降级为可选依赖

把以上线索串起来,就是 Zig 的长期计划——把 LLVM 变成可选依赖,与编译器实现解耦。

这个目标如果达成,意味着:编译时间大幅下降、Debug 构建获得良好的增量编译支持,甚至能探索 LLVM 无法有效 lowering 的语言特性。这不是一次版本更新的小优化,而是编译器架构层面的路线切换。

下一个焦点是 aarch64。自研 x86 后端稳定到能默认启用后,Jacob 把目光投向了 aarch64——现代 Apple 硬件都基于这个架构,需求很实际。这个进行中的后端从 x86 的经验里学到了不少,采用了一些新设计方向。官方说法是:预期它既比自研 x86_64 后端更快,生成的机器码质量也更好,终极目标是代码生成能力能与 LLVM 竞争。

增量编译:目前的状态与预期

Zig 0.15 在增量编译上继续推进:它只重建发生变化的代码。这轮修了不少 bug,尤其是文件导入变化相关的问题。用法上:

zig build --watch -fincremental

官方特别提到,如果你以前因为 macOS 上的某个 bug 而回避 --watch,现在可以放心用了。下一个发布周期会继续朝"默认启用增量编译"推进。

需要泼盆冷水:增量编译目前还是实验性的,需要显式加 -fincremental。Debug 构建的完整增量支持还不完善——这正是上面几组数据里,即便库已经跑在自研后端上、改一行代码仍然要重建整个模块的原因。等它真正落地,本文里这些数字会显得"慢得可笑"。

一个开发者的视角

这组数据最打动我的地方,不是某个绝对值,而是方向的一致性。Zig 没有走"把 LLVM 换个姿势调用"的捷径,而是花了好几年重写后端、链接器,一步步把上游依赖变成可选项。短期看这很亏——自研后端生成的机器码还比 LLVM 慢,还会有自己的 bug。但长期看,编译速度、语言特性的实现自由度、对上游 bug 的免疫力,都握在自己手里了。

如果你在做系统级项目,几个可落地的做法:

  1. 先测自己项目的构建瓶颈在哪。用 zig build --help 单独测构建脚本的编译时间。如果它占了几秒,说明你的 build.zig 或者依赖图有优化空间。
  2. Debug 构建试试自研后端的实际收益。默认已经启用了,如果你的项目恰好踩到后端 bug,-fllvm 能一键回退,不用担心被卡死。
  3. 保持对 -fincremental 的关注。现在别用在生产构建上,但在本地开发循环里值得试,尤其改动集中在单个库时,亚秒级重建的心流价值是实打实的。

第 2 点如果要在 build.zig 里显式控制,大致是这样:

const exe = b.addExecutable(.{
    .name = "myapp",
    .root_source_file = b.path("src/main.zig"),
    .target = target,
    .optimize = optimize,
});
// 想强制走 LLVM(比如后端有 bug 时回退):
// exe.use_llvm = true;
// 想手动开增量编译(目前仍实验性):
// exe.use_incremental = true;
b.installArtifact(exe);

命令行侧对应的开关是 -fllvm 和 -fincremental,配合 --watch 使用。

编译器把构建时间压到毫秒级,改变的不只是数字,是开发者"敢不敢多编译一次"的直觉。而那个直觉,最终决定了 bug 是在本地被抓到,还是在线上被用户遇到。

参考来源: