序言
出于对成为编译器工程师的向往,我开始深入挖掘各项编译技术的细节。作为一名前端工程师,我决定首先从 WebAssembly 技术开始学习。在阅读完 WebAssembly 规范后,我准备着手深入了解如何实现一个 WebAssembly 运行时。
考虑到我只熟悉 Rust,我选择从 Wasmtime 开始,这是一个由 Rust 编写的 WebAssembly 运行时。它严格遵循 WebAssembly 规范并采用 JIT 技术以提高运行速度,是个相当优秀的实现。
之前的两篇文章,我们一直在深入探讨 Cranelift,它是 Wasmtime 项目的编译器后端。而同样作为编译器后端,本文我们将探讨 LLVM,这个被广泛使用的现代编译器工具链的主要构成部分,对比它们之间的相似性和差异。
相似之处
LLVM 是一组以 C++ 库的形式实现的编译器组件。它可以用来构建 JIT 编译器和静态编译器,如 Clang,并且因此而非常受欢迎。Chris Lattner 在《开源应用程序的架构》一书中关于 LLVM 的章节,对 LLVM 的架构和设计有很好的概述。
Cranelift 和 LLVM 在表面上是相似的项目,因此值得强调它们的一些差异和相似之处。这两个项目都:
- 使用大部分与 ISA 无关的输入语言,主要是为了抽象化目标指令集架构之间的差异。
- 大量依赖 SSA 形式。
- 它们的主要中间表示形式都有文本和内存形式。(LLVM 还有一个二进制 bitcode 格式;Cranelift 没有。)
- 可以针对多个 ISA。
- 默认可以进行跨编译,而无需重建代码生成器。
然而,也存在一些主要的差异,这些差异在以下部分中进行了描述。
中间表示
LLVM 在将程序转换为二进制机器代码的过程中使用了多种中间表示:
LLVM IR:这是主要的中间表示,它有文本、二进制和内存形式。它主要有两个用途:
diff
- 作为一个与 ISA 无关、相对稳定的输入语言,前端可以轻松生成。
- 用于常见的中级优化的中间表示。大量的代码分析和转换通道在 LLVM IR 上操作。
SelectionDAG:这是由指令选择器使用的基于图的代码表示形式,它表示一个单独的基本块。它具有与 ISA 无关和特定于 ISA 的操作码。以下主要的通道运行在 SelectionDAG 表示上:
diff
- 类型合法化消除了所有在目标 ISA 寄存器中没有表示的值类型。
- 操作合法化消除了所有不能映射到目标 ISA 指令的操作码。
- DAG-combine 在合法化通道后清理冗余代码。
- 指令选择将与 ISA 无关的表达式转换为特定于 ISA 的指令。
SelectionDAG 表示自动消除了常见的子表达式和死代码。
MC 作为输出抽象层,是 LLVM 集成汇编器的基础,用于:
diff
- 分支松弛。
- 发出汇编或二进制对象代码。
- 汇编器。
- 反汇编器。
有一个正在进行的"全局指令选择"项目,其目的是用 MachineInstr 表示上的与 ISA 无关的操作码来替换 SelectionDAG 表示。某些目标 ISA 具有快速的指令选择器,可以将简单的代码直接翻译为 MachineInstrs,尽可能绕过 SelectionDAG。
Cranelift IR 使用单一的中间表示来覆盖这些抽象级别。这部分可以归因于 Cranelift 较小的领域范围。
- Cranelift 并未提供汇编器和反汇编器,因此没有必要能够表示 ISA 中的每个奇特的指令。只有代码生成器发出的指令才有表示。
- Cranelift 的操作码是与 ISA 无关的,但在合法化 / 指令选择之后,每个指令都会用一个与 ISA 特定的编码进行注释,该编码代表了一个本地指令。
- 整个过程都保留了 SSA 形式。在寄存器分配之后,每个 SSA 值都会用分配的 ISA 寄存器或栈槽进行注释。
Cranelift 的中间表示与 LLVM IR 相似,但抽象级别稍低一些,以允许它在整个代码生成过程中使用。
这种设计权衡意味着 Cranelift IR 对于中级优化不太友好。尽管 Cranelift 目前并未进行中级优化,但如果它不断发展到这成为重要的地步,该愿景是 Cranelift 会添加一个单独的 IR 层,或可能是一个单独的 IR,来支持这一点。Cranelift 将让前端生成代码生成 IR,该 IR 可以被翻译为优化器 IR 并反向翻译,而不是前端生成优化器 IR 然后翻译为代码生成 IR。
这种偏向于在不需要中级优化(例如在发出未优化的代码或低级优化就足够时)时快速编译的整体系统。
并且,它删除了中级优化 IR 设计空间中的一些约束,使考虑使用基于 VSDG 的 IR 这样的想法变得更加可行。
程序结构
在 LLVM IR 中,可表示的最大单元是模块,它或多或少对应于 C 语言的转换单元。它是函数和全局变量的集合,也可能包含对外部符号的引用。
在由 cranelift-codegen crate 使用的 Cranelift 的 IR 中,函数是自包含的,允许它们独立编译。在这个级别,没有包含函数的显式模块。
Cranelift 中的模块功能是作为一个可选的库层提供的,在 cranelift-module crate 中。它提供了处理模块的设施,模块可以包含多个函数以及数据对象,并将它们链接在一起。
LLVM 和 Cranelift 都使用基本块的图作为其函数的 IR。然而,LLVM 在其 SSA 表示中使用 phi 指令,而 Cranelift 则传递参数给 BB。这两种表示是等价的,但 BB 参数更适合处理可能包含多个分支到具有不同参数的同一目标块的 BB。传递参数给 BB 很像传递参数给函数调用,寄存器分配器非常类似地处理它们。参数被分配给寄存器或堆栈位置。
值类型
Cranelift 的类型系统主要是 LLVM 类型系统的子集。它的抽象较少,更接近常见 ISA 寄存器可以容纳的类型。
- 整数类型限于从
i8
到i64
的二次幂。LLVM 可以表示任意位宽的整数类型。 - 浮点类型限于
f32
和f64
,这是 WebAssembly 提供的。可能在未来会添加 16 位和 128 位类型。 - 地址被表示为整数------Cranelift 没有指针类型。LLVM 目前有包含被指对象类型的丰富指针类型。它可能在未来转向更简单的'地址'类型。Cranelift 也可能增加一个单一的地址类型。
- SIMD 向量类型限于 256 以下的二次幂向量通道数。LLVM 允许任意数量的 SIMD 通道。
- Cranelift 没有聚合类型。LLVM 有命名和匿名的结构类型以及数组类型。
Cranelift 有多种布尔类型,而 LLVM 仅使用 i1
。Cranelift 的有大小的布尔类型用于表示 SIMD 向量掩码,如 b32x4
,其中每个通道要么全部为 0,要么全部为 1 位。
Cranelift 指令和函数调用可以返回多个结果值。LLVM 则通过返回一个聚合类型的单一值来模拟这一点。
指令集
LLVM 有一个小型的定义良好的基本指令集和大量的内置函数,其中一些是特定于 ISA 的。Cranelift 有一个更大的指令集和没有内置函数。一些 Cranelift 指令是特定于 ISA 的。
由于 Cranelift 指令被直接用于生成二进制机器代码,因此每个可以生成的本地指令都有操作码。不同 ISA 之间有很多重叠,所以例如 iadd_imm
指令被每个可以将立即整数加到寄存器的 ISA 使用。一个简单的 RISC ISA,如 RISC-V,可以只用共享指令定义,而 x86 需要一些特定的指令来模拟寻址模式。