← 返回新闻页面

告别 Zig 的 C++ 实现

2022 年 12 月 7 日

我们如何使用 WebAssembly 彻底清除 80,000 行遗留代码

作者:Andrew Kelley

很有趣——我已经和几位合格且有能力的软件工程师朋友分享过这个故事,每次他们的反应都是困惑,不明白这一切为什么会是必要的,甚至哪怕有一点帮助。*WebAssembly*?!

经过十分钟困惑的表情、紧锁的眉头和热烈的提问,他们才恍然大悟,一切都变得有意义了——但这需要耐心的解释和从基本原理出发的推导才能理解。

Screenshot of GitHub Pull Request depicting many lines deleted

问题陈述

在这次更改之前,Zig 代码库包含两个编译器

新编译器更快,内存使用更少,并且得到了积极的维护和增强。与此同时,没有人愿意碰旧编译器,但它是从源码构建新编译器所必需的。

这意味着**新的 Zig 语言特性必须实现两次**——一次在新代码库中,然后又在旧代码库中。这带来了巨大的痛苦,特别是随着这两个编译器的设计出现分歧。

此外,Zig 的 C++ 实现最初采用了与 D 编译器相同的策略——在进程退出前不释放内存。随着编译时代码执行成为该语言的旗舰特性之一,并且项目规模越来越大,加上 Zig 对所有内容都使用一个编译单元,这项设计决策不再实用

《毁灭战士》(DOOM) 标志,但去掉了‘D’,幽默地表示内存不足 (Out Of Memory)

解决方案空间

这个问题引人入胜,有许多解决方案,每种方案都有其自身的权衡。在我上面提到的所有对话中,一旦我交谈的对象明确了问题,讨论就会转变为关于不同可能性的快速头脑风暴。每个想法都有一系列具体的优点和缺点。

不自举——例如,这是 Lua 所采取的方法。

它完全解决了问题,但缺点显而易见,那就是我们必须使用 C 而不是 Zig。我不想这样做,因为 Zig 语言提供的改进太好了,不容错过。例如,我使用的一些数据导向设计技术在 C/C++ 中因语言陷阱而不切实际。

使用编译器先前的构建版本——这是 Rust 以及许多其他语言所采取的方法。

一个很大的缺点是,它会让你失去从源代码构建任意提交的能力,并且会悄然引入额外的复杂性。例如,假设你正在尝试执行 git bisect。在某个时候,git 会检出旧的提交,但脚本会因为用于构建编译器的二进制文件版本不正确而无法从源代码构建。当然,这可以解决,但这会引入贡献者宁愿不处理的不必要的复杂性。

此外,构建编译器受限于可用的先前二进制文件所支持的目标。例如,如果不存在 riscv64 架构的编译器构建版本,那么你就无法在 riscv64 硬件上从源代码构建。

这里的核心问题是,它无法充分支持在任何系统上构建任意提交的用例。

将编译器编译成 C 代码——例如,这是 Nim 所采取的方法。

与之前的策略相比,优点是将生成的 C 源代码提交到版本控制中感觉不如提交机器代码那么糟糕,但如果 C 代码是特定于目标的,那么效果上是相同的。

例如,我不确定 Nim 生成的 C 代码在多大程度上是目标无关的,但我看到描述中写着“支持比旧的 csources 仓库更多的 CPU/操作系统组合”,这意味着尽管它提供了许多方便的组合,但它并非真正的可移植 C 代码。它也位于与主编译器不同的单独仓库中,因此它也存在与上一个策略相同的问题。

在 Zig 的案例中,我探讨了这种可能性,发现生成的 C 代码不仅是特定于目标的,而且非常庞大。对我们来说,它是一个 80 MiB 的 C 文件,虽然可以通过 C 后端增强来改进,但这比我将二进制文件提交到 Git 仓库时所能接受的要大几个数量级。

只将编译器编译成 C 代码一次,然后直接清理并维护它——这是我计划多年要做的事情,直到我真正开始调查实施这种方法。这有一个明显的缺点,那就是清理大量自动生成的 C 源代码是一项来自计算机科学地狱的任务,而且从那时起我们仍然拥有两个编译器实现,这使得贡献者不那么有动力提供帮助,因为他们必须做两次所有工作,一次用 Zig,一次用 C。

将编译器编译成一个简单的虚拟机——我偶尔会和 Drew DeVault 聊工作,因为他正在开发 Hare。我们开始聊起编译器自举,他随口提到了 OCaml 的策略。

<andrewrk> do you care about the "stage 0" bootstrapping process?
<ddevault> marginally
<andrewrk> idea is to start with literally only a text editor and get up and running
<ddevault> text editor? pfft! back in my day we used a magnetized needle and a steady hand
<andrewrk> classic xkcd
<andrewrk> did I say text editor? I meant hex editor
<ddevault> anyway, there's a bootstrapping path taken by, uh, ocaml I think
<ddevault> which is kind of interesting
<ddevault> they have a backend for a tiny VM target
<ddevault> you implement that VM on your desired platform and you can then compile ocaml proper
<andrewrk> ooooooh

这激发了我的创造力。一个专门用于自举的定制后端是一个巧妙的想法,但我认为 Zig 的最佳方案与 OCaml 不同。

Zig 只有一个虚拟机目标既是操作系统无关的,又能受益于 LLVM 最先进的优化遍数,那就是 WebAssembly,它使用 WASI 作为操作系统抽象层。

探索这个想法

这里的想法是使用一个最小的 wasm 二进制文件作为第一阶段内核,它被提交到版本控制中,因此可以用于从源代码构建任意提交。我们提供一个从 C 源码构建的最小 WASI 解释器实现,然后用它将 Zig 自举编译器的源代码翻译成 C 代码。接着,C 代码再次由系统 C 编译器编译和链接,生成第二阶段二进制文件。从那时起,第二阶段二进制文件就可以与 zig build 重复使用,以从源代码构建。

wasm 二进制文件是通过 zig build update-zig1 生成的,它使用 LLVM 后端生成一个目标为 wasm32-wasi、CPU 为 generic+bulk_memoryReleaseSmall 二进制文件。此二进制文件中除 C 后端外,所有后端均已禁用。这会生成一个 2.6 MiB 的文件。然后使用 wasm-opt -Oz --enable-bulk-memory 进一步优化,将总大小降至 2.4 MiB。最后,使用 zstd 进行压缩,将总大小降至 637 KB。这被 C 语言 zstd 解码器实现的大小所抵消,然而这是值得的,因为 zstd 实现极少发生变化,甚至永不变化,每次 wasm 二进制文件更新时总共节省 1.8 MiB。

因此,现在不是 80,000 行 C++ 代码,而是 4,000 行可移植的 C 代码。这段代码只使用标准 libc 函数,不依赖于任何 POSIX 头文件或 windows.h。操作系统互操作层已完全抽象为少量 WASI 函数,这些函数将在 WASI 解释器中实现。

(import "wasi_snapshot_preview1" "args_sizes_get" (func (;0;) (type 3)))
(import "wasi_snapshot_preview1" "args_get" (func (;1;) (type 3)))
(import "wasi_snapshot_preview1" "fd_prestat_get" (func (;2;) (type 3)))
(import "wasi_snapshot_preview1" "fd_prestat_dir_name" (func (;3;) (type 6)))
(import "wasi_snapshot_preview1" "proc_exit" (func (;4;) (type 11)))
(import "wasi_snapshot_preview1" "fd_close" (func (;5;) (type 8)))
(import "wasi_snapshot_preview1" "path_create_directory" (func (;6;) (type 6)))
(import "wasi_snapshot_preview1" "fd_read" (func (;7;) (type 5)))
(import "wasi_snapshot_preview1" "fd_filestat_get" (func (;8;) (type 3)))
(import "wasi_snapshot_preview1" "path_rename" (func (;9;) (type 9)))
(import "wasi_snapshot_preview1" "fd_filestat_set_size" (func (;10;) (type 36)))
(import "wasi_snapshot_preview1" "fd_pwrite" (func (;11;) (type 28)))
(import "wasi_snapshot_preview1" "random_get" (func (;12;) (type 3)))
(import "wasi_snapshot_preview1" "fd_filestat_set_times" (func (;13;) (type 51)))
(import "wasi_snapshot_preview1" "path_filestat_get" (func (;14;) (type 12)))
(import "wasi_snapshot_preview1" "fd_fdstat_get" (func (;15;) (type 3)))
(import "wasi_snapshot_preview1" "fd_readdir" (func (;16;) (type 28)))
(import "wasi_snapshot_preview1" "fd_write" (func (;17;) (type 5)))
(import "wasi_snapshot_preview1" "path_open" (func (;18;) (type 52)))
(import "wasi_snapshot_preview1" "clock_time_get" (func (;19;) (type 53)))
(import "wasi_snapshot_preview1" "path_remove_directory" (func (;20;) (type 6)))
(import "wasi_snapshot_preview1" "path_unlink_file" (func (;21;) (type 6)))
(import "wasi_snapshot_preview1" "fd_pread" (func (;22;) (type 28)))

这就是全部的集合。为了让 Zig 编译器能够将自身编译成 C,只需要这些系统调用。

Jacob Young 和我在 andrewrk/zig-wasi 上合作开发了这个 WebAssembly/WASI 解释器。我用 Zig 创建了第一个版本,利用 Zig 丰富的标准库和安全机制,迅速探索了这个想法。这个解释器没有预先解码 wasm 模块;相反,它直接将文件偏移量用作程序计数器。它工作正常,但速度太慢,解释一次编译器运行需要好几个小时,而在原生机器码中,这大约只需要 5 秒。

Jacob 改进了该项目,引入了更适合解释执行的不同指令集,以及无数其他技术,将性能提升到可接受的范围内。与此同时,我开始着手将代码库从 Zig 转换为纯 C。

我们在这个项目上并肩工作了两周,使用 IRC 进行沟通,相互挑选对方分支的提交,分享挫折,庆祝胜利,总的来说,一起编程非常愉快。Jacob 在这个项目上付出了多少努力,我怎么称赞都不为过,特别是考虑到他负责改进 Zig 的 C 后端,使其足以使这成为可能。

一旦这个概念被证明可行,Jacob 意识到通过将 WebAssembly 代码转换为 C 代码而不是直接解释它,可以使其更快。这实际上是即时编译 (JIT compilation),但利用了我们的基本自举工具是系统 C 编译器这一事实。

虽然已经有一个通用的 wasm2c 工具作为 WebAssembly 二进制工具包项目的一部分存在,但 Jacob 没有移植或分叉它,而是从头开始创建了一个 wasm2c 实现。这个替代实现故意缺乏通用性;它只包含编译器在自构建时实际调用的 WASI 函数的实现!

除此之外,还有其他好处,这使得我们这个小巧而令人满意的 wasm2c 实现只有 4,000 行而不是 60,000 行,不依赖 C++,并且采取了简化捷径,例如不实现任何沙盒或安全措施。

新的构建过程

以下是我们最终确定的方案

Building CXX object CMakeFiles/zigcpp.dir/src/zig_llvm.cpp.o
Building CXX object CMakeFiles/zigcpp.dir/src/zig_llvm-ar.cpp.o
Building CXX object CMakeFiles/zigcpp.dir/src/zig_clang.cpp.o
Building CXX object CMakeFiles/zigcpp.dir/src/zig_clang_driver.cpp.o
Building CXX object CMakeFiles/zigcpp.dir/src/zig_clang_cc1_main.cpp.o
Building CXX object CMakeFiles/zigcpp.dir/src/zig_clang_cc1as_main.cpp.o
Building CXX object CMakeFiles/zigcpp.dir/src/windows_sdk.cpp.o
Linking CXX static library zigcpp/libzigcpp.a
Built target zigcpp
Building C object CMakeFiles/zig-wasm2c.dir/stage1/wasm2c.c.o
Building C object CMakeFiles/zig-wasm2c.dir/stage1/zstd/lib/decompress/huf_decompress.c.o
Building C object CMakeFiles/zig-wasm2c.dir/stage1/zstd/lib/decompress/zstd_ddict.c.o
Building C object CMakeFiles/zig-wasm2c.dir/stage1/zstd/lib/decompress/zstd_decompress.c.o
Building C object CMakeFiles/zig-wasm2c.dir/stage1/zstd/lib/decompress/zstd_decompress_block.c.o
Building C object CMakeFiles/zig-wasm2c.dir/stage1/zstd/lib/common/entropy_common.c.o
Building C object CMakeFiles/zig-wasm2c.dir/stage1/zstd/lib/common/error_private.c.o
Building C object CMakeFiles/zig-wasm2c.dir/stage1/zstd/lib/common/fse_decompress.c.o
Building C object CMakeFiles/zig-wasm2c.dir/stage1/zstd/lib/common/pool.c.o
Building C object CMakeFiles/zig-wasm2c.dir/stage1/zstd/lib/common/xxhash.c.o
Building C object CMakeFiles/zig-wasm2c.dir/stage1/zstd/lib/common/zstd_common.c.o
Linking C executable zig-wasm2c
Built target zig-wasm2c
Converting ../stage1/zig1.wasm.zst to zig1.c
Building C object CMakeFiles/zig1.dir/zig1.c.o
Building C object CMakeFiles/zig1.dir/stage1/wasi.c.o
Linking C executable zig1
Built target zig1
Running zig1.wasm to produce zig2.c
Running zig1.wasm to produce compiler_rt.c
Building C object CMakeFiles/zig2.dir/zig2.c.o
Building C object CMakeFiles/zig2.dir/compiler_rt.c.o
Linking CXX executable zig2
Built target zig2
Building stage3

总结一下

  1. 使用系统 C 编译器编译 zig-wasm2.c
  2. 使用 zig-wasm2.c 将 zig1.wasm.zst 转换为 zig1.c
  3. 使用系统 C 编译器编译 zig1.c。
    • 注意 zig1 只启用了 C 后端。
  4. 使用 zig1 将 Zig 编译器构建成 zig2.c
  5. 使用系统 C 编译器编译 zig2.c
    • 这个文件拥有正确的最终逻辑,但其机器码是由系统 C 编译器而非其自身优化。我们继续执行第 6 步,以获得一个具有自举性能特性的二进制文件。
  6. zig2 build (使用旧的 Zig 构建版本来构建 Zig 的标准构建过程)

如果你使用这最后一步的输出再次构建 Zig,它会逐字节产生相同的结果。换句话说,zig3zig4 是相同的,因此我们完成了,并将这个最终的二进制文件命名为 zig,不带任何后缀。

只有当破坏性更改或新功能在编译器自构建时影响到它时,才需要更新 wasm 二进制文件。例如,一个编译器在自构建时不会触发的错误修复可以被忽略。然而,如果该错误修复是 Zig 自构建所必需的,那么就需要更新 wasm 二进制文件。同样,当语言发生变化并且编译器希望利用这些变化进行自构建时,也需要更新二进制文件。

更新 stage1/zig1.wasm.zst 如下所示

zig build update-zig1

性能

我收集了两项测量数据

测量 #1,使用 make -j8 install 从源代码编译,配置为 -DCMAKE_BUILD_TYPE=Debug

old: 8m12s with 11.3 GiB peak RSS
new: 9m59s with  3.8 GiB peak RSS

测量 #2,使用 ninja install 从源代码编译,配置为 -DCMAKE_BUILD_TYPE=Release

old: 13m20s with 10.3 GiB peak RSS
new: 10m53s with  3.2 GiB peak RSS

这些测量是我在相互竞争的情况下进行的,其中一次还在 Twitch 上直播,所以时间有点不稳定,但你大概明白了。这里需要注意的重要一点是构建所需的内存量。这关系到拥有 4-8 GiB 内存的人能否为 Zig 做出贡献。这关系到能否使用 GitHub 提供的 Actions 运行器

展望未来

我想明确一点,尽管这项更改落地 Zig 是一个净收益,但它确实代表了特定用例的倒退:即在固定步骤数内仅从源代码自举 Zig 的能力。直到现在,从源代码构建的过程除了系统 C/C++ 编译器外,不涉及任何二进制大文件。现在,存在这个 WebAssembly 二进制文件,它不是源代码,而是一个构建产物。有些人理所当然地非常重视这些事情——例如参见 Debian 自由软件指南

我公开承认正在付出这一代价,但我坚信最终是值得的。我猜想,结合官方语言规范和 Zig 日益增长的人气,我们将看到一个第三方项目启动 C 语言的替代 Zig 实现,类似于 Rust 的 mrustc (尽管 Rust 尚未有规范)。这将填补必要的角色,再次解决 O(1) 源码自举问题。

愿意在不解决此用例的情况下发布 1.0 版本,并且 Zig 软件基金会目前没有计划承担这个替代编译器实现项目。当然,情况可能会改变,但就目前而言,这就是计划。

此外,这项更改意味着移除了 -fstage1 标志,该标志允许 Zig 用户选择不使用新编译器而使用旧编译器。这是利用异步函数的唯一方法,异步函数是新编译器中尚未实现的实验性语言特性。

我建议 -fstage1 的用户继续使用 0.10.0 版本,然后在 0.10.1 发布时升级到该版本,最后升级到 0.11.0 版本,该版本将支持异步函数。请注意,Zig 遵循语义化版本控制,因此本博客文章中描述的所有内容都将不会包含在 0.10.1 版本中,该版本将**只包含从 master 分支挑选出来的错误修复**。

语言演进

从更积极的方面来看,这项更改意味着所有那些我们一直搁置的计划中语言更改将取得迅速进展。没有了遗留编译器代码库的阻碍,你可以期待在 Zig 0.11.0 发布周期中,Zig 语言的完成将取得重大进展。

这使我们能够立即在编译器和标准库中使用语言更改,进行内部测试(dogfood)。在我被指控将语言设计得过度拟合于编写编译器之前,我很高兴地报告,我已开始将每周五用于使用 Zig 而非开发 Zig。也就是说,对我来说,周五是用于个人项目的时间,我扮演的是该语言的普通用户角色,而不是编译器开发人员。

当这项更改合入 master 分支时,有 650 个带有“stage1”标签的未解决问题,这表明一个现在已删除的代码库存在问题!因此理论上,我们可以立即关闭所有这些问题,这会非常令人满意。然而,我制定了一项政策,即要关闭其中一个问题,必须为其添加测试覆盖,或者识别已经覆盖它的测试。或许这会付出多于回报,但目前我们正在这样做。

最后,我想用我最喜欢的一条推文来结束,这条推文自从 Leonard Ritter 迁移到 Mastodon 后已被删除,所以我将在此为你重现它。

使用 C++ 自举一门新的编程语言

鸣谢