极简后端 / B3 中间表示
B3 IR 是过程的类 C SSA 表示。过程有一个根块,它在被调用时从该根块开始执行。过程不一定终止,但如果终止,则可能是由于 Return (优雅地返回某个值),或者是由于指定指令处的侧边退出。B3 为客户端提供了极大的灵活性来实现多种不同的侧边退出。
B3 旨在表示过程,以便对其进行转换。了解哪些转换是合法的需要知道过程的作用。如果转换不改变过程的可观察行为,则它是有效的。本文档通过解释 B3 IR 中每个构造的作用来告诉您 B3 过程的作用。
过程
B3 中所有内容的父对象是 Procedure(过程)。每次要编译某些内容时,都从创建 Procedure 开始。Procedure 的生命周期通常是:
- 创建 Procedure。
- 用代码填充 Procedure。
- 使用高级编译 API 或低级生成 API。
编译 Procedure 的操作会对其进行原地更改,使其不适合再次编译。每次要编译某些内容时,请始终创建一个新的 Procedure。
类型
B3 具有一个简单的类型系统,仅包含五种类型:
- Void
- 用于表示指令不返回值。
- Int32
- 32位整数。整数没有符号,但对它们的操作有符号。
- Int64
- 64位整数。
- Float
- 32位二进制浮点数。
- Double
- 64位二进制浮点数。
B3 没有指针类型。相反,B3::pointerType()
函数将根据当前平台上哪种整数可以表示指针来返回 Int32 或 Int64。B3 的目标不是支持需要指针和整数分开的硬件目标。B3 的目标也不是将 GC(垃圾回收)根作为单独的类型来支持,因为 JSC 使用 Bartlett 风格的保守根扫描。这不排除任何主流垃圾回收算法,包括复制式、分代式或并发式收集器,并允许编译器执行更多优化。
值
变量和定义它们的指令都使用 Value 对象表示。Value 对象具有返回类型、种类和零个或多个子节点。子节点是对其他 Value 的引用。这些值用作计算此值的指令的输入。
值种类是操作码和可选标志的组合。标志允许单个操作码具有多种变体。例如,Div 和 Mod 可以设置 Chill 标志以指示它们不应在特殊情况下陷入。或者,Load/Store 操作码可以设置 Traps 标志以指示它们应确定性地陷入。
值还具有一个用作名称的唯一 32 位索引。
示例
Int32 @3 = Add(@1, @2)
这表示一个 Value 实例。其索引为 3。它是 Int32 类型。操作码是 Add,其子节点是 @1 和 @2。
值还可以有额外的元数据。对于需要元数据的值,我们使用 B3::Value 类的特殊子类。例如,Load 值需要一个 32 位偏移量用于加载。我们为内存访问值使用 MemoryValue 类,所有这些值都具有这样的偏移量。
栈槽
B3 暴露了栈分配数据的概念,并为客户端提供了很多控制。默认情况下,栈槽由 B3 选择分配位置。它会尽可能地进行打包。编译完成后,您可以以帧指针偏移量的形式检索每个栈槽的位置。
尽管这非常危险,但您可以强制栈槽位于帧指针的特定偏移量处。例如,B3 假定它可以将最靠近帧指针的槽用于被调用者保存寄存器,目前当您将某些内容强制到特定帧指针偏移量时,没有机制来注意到该空间也用于被调用者保存寄存器。因此,我们建议不要使用帧指针偏移量强制功能,除非您对 ABI 非常了解并且别无选择。
变量
有时,SSA 不方便。例如,很难在 SSA 上进行路径专业化。B3 具有变量的概念作为备用方案。后端知道如何处理它们并将它们合并和进行拷贝传播。在 B3 优化器内部,有一个经典的 SSA 构建器,它消除变量并在其位置构建 SSA。
您可以通过使用 Procedure::addVariable() 创建变量,然后可以使用 Get 和 Set 操作码访问它们。
fixSSA() 阶段将变量转换为 SSA。如果您在 B3 的输入中使用了大量变量,那么在运行编译器之前手动运行 fixSSA() 是一个好主意。默认优化器只在优化中期运行 fixSSA()。将非 SSA 代码作为输入传递给优化器可能会使早期阶段无效。幸运的是,B3 阶段非常容易运行。以下代码对名为 "proc" 的 Procedure 运行 SSA 修复:
fixSSA(proc);
控制流
B3 使用基本块表示控制流。每个基本块可以有零个或多个前驱。每个基本块可以有零个或多个后继。后继的含义由基本块的最后一个 Value 决定,该 Value 必须是终结符。如果一个值是终结符,则:
value->effects().terminal
某些操作码是明确的终结符,如 Jump、Branch、Oops、Return 和 Switch。但带有 Patchpoint 操作码的值可能不是终结符。通常需要检查 terminal
位,如上所示。
每个基本块都包含一个 Vector<Value*> 作为块的内容。块内的控制流是根据向量中 Value 的顺序隐式确定的。
内存模型
B3 使用一个辅助地址空间——抽象堆来推理别名和并发。这个地址空间是 32 位的,我们使用 HeapRange 类型指向它。如果您从不向内存操作(默认)提供 HeapRanges,它们将具有 [0, UINT_MAX] 作为其范围,表示该操作可能会保守地写入所有 232 个抽象堆。如果两个内存操作访问相同的抽象堆,则称它们是别名。使用 HeapRanges 的一个简单示例是加载和存储,但您也可以将 HeapRanges 作为调用和补丁点效果的边界。
内存屏障被建模为我们称之为屏障范围的幻影效果。例如,加载屏障可以表示为写入 [0, UINT_MAX] 的补丁点。B3 使用屏障范围建模加载屏障、存储屏障、存储-加载屏障、获得屏障和释放屏障。没有屏障范围意味着没有屏障。
获得屏障被建模为对内存的幻影写入,而释放屏障被建模为从内存的幻影读取。仅凭这一点,B3 无法执行获得/释放允许的所有重排序。因此,B3 允许更精确地处理带有屏障范围的加载/存储。带有屏障的加载的幻影写入效果不能写入在屏障之后读取或写入的任何内容。带有屏障的存储的幻影读取效果不能读取在屏障之后写入的任何内容。
将屏障范围应用于加载或存储只会使该访问成为半屏障:带屏障的加载是获得屏障,它只阻止后续操作被提升;带屏障的存储是释放屏障,它只阻止早期操作被下沉。表示完全屏障加载(在两个方向上都有屏障)的规范方法是使用带有非空屏障范围的 AtomicXchgAdd(0, ptr)。表示完全屏障存储的规范方法是使用带有非空屏障范围的 AtomicXchg(value, ptr)。
操作码
本节以以下格式描述操作码:
- Int32 Foo(Int64, Double)
- 这描述了一个名为 Foo 的操作码,它使用 Int32 作为其返回类型,并接受两个子节点——一个 Int64 类型,另一个 Double 类型。
我们有时使用通配符类型 T 来表示多态操作,例如“T Add(T, T)”。这意味着该值必须接受两个相同类型的子节点,并返回该类型的值。我们使用 IntPtr 类型来表示 Int32 或 Int64,具体取决于平台。
某些操作码可以设置一些标志。标志的描述在操作码描述之后。
操作码描述
- Void Nop()
- 空值。大多数优化不是从基本块中移除 Value,而是将它们转换为 Nop。各种阶段会运行修复,一次性移除所有 Nop。在优化过程中,B3 IR 的中间版本中常见 Nop。Nop 永远不会导致生成任何代码,也不会阻碍优化,因此它们通常是无害的。您可以通过调用 convertToNop() 将 Value 转换为 Nop。
- T Identity(T)
- 返回传递的值。可用于除 Void 以外的任何类型。大多数优化不是用不同的 Value 替换 Value 的所有用途,而是将它们转换为 Identity。各种阶段会运行修复,其中 Identity 的所有用途都被 Identity 的子节点替换(传递地,因此 Identity(Identity(Identity(@x))) 会被更改为 @x)。甚至指令选择器也会“看穿”Identity。您可以通过调用 Value::performSubstitution() 移除任何值中对 Identity 的所有引用。您可以通过调用 convertToIdentity(otherValue) 将 Value 转换为 Identity。如果该值是 Void,则 convertToIdentity() 会将其转换为 Nop。
- T Opaque(T)
- 返回传递的值。可用于除 Void 以外的任何类型。此操作码是用于避免客户端认为无利可图的 B3 优化的一个技巧。B3 仅在指令选择期间将 Opaque 视为恒等。所有先前的优化(包括所有 B3 IR 优化)都不知道 Opaque 的作用,除了 Opaque(x) == Opaque(x) 和 Opaque(Opaque(x)) == Opaque(x)。
- Int32 Const32(constant)
- 32位整数常量。必须使用 Const32Value 类,该类有空间容纳 int32_t 常量。
- Int64 Const64(constant)
- 64位整数常量。必须使用 Const64Value 类,该类有空间容纳 int64_t 常量。
- Float ConstFloat(constant)
- 浮点常量。必须使用 ConstFloatValue 类,该类有空间容纳 float 常量。
- Double ConstDouble(constant)
- 双精度浮点常量。必须使用 ConstDoubleValue 类,该类有空间容纳 double 常量。
- Void Set(value, variable)
- 将给定值赋值给给定 Variable。必须使用 VariableValue 类。
- T Get(variable)
- 返回给定 Variable 的当前值。其返回类型取决于变量。必须使用 VariableValue 类。
- IntPtr SlotBase(stackSlot)
- 返回指向给定栈槽基址的指针。必须使用 SlotBaseValue 类。
- IntPtr|Double ArgumentReg(%register)
- 返回该寄存器在过程序言处的值。对于通用寄存器 (GPRs) 返回 IntPtr,对于浮点寄存器 (FPRs) 返回 Double。必须使用 ArgumentRegValue 类。
- IntPtr FramePointer()
- 返回帧指针寄存器的值。B3 过程总是使用帧指针 ABI,并且保证帧指针在过程内的任何位置都具有此值。
- T Add(T, T)
- 适用于除 Void 以外的任何类型。对于整数类型,这表示带环绕语义的加法。对于浮点类型,这表示根据 IEEE 854 规范的加法。B3 没有“快速数学”的概念。如果新代码生成完全相同的值(位对位),则对浮点代码的转换是有效的。
- T Sub(T, T)
- 适用于除 Void 以外的任何类型。对于整数类型,这表示带环绕语义的减法。对于浮点类型,这表示根据 IEEE 854 规范的减法。
- T Mul(T, T)
- 适用于除 Void 以外的任何类型。对于整数类型,这表示带环绕语义的乘法。对于浮点类型,这表示根据 IEEE 854 规范的乘法。
- T Div(T, T)
- 适用于除 Void 以外的任何类型。对于整数类型,这表示带符号除法,向零取整。默认情况下,对于 x/0 或 -231/-1,其行为未定义。对于浮点类型,这表示根据 IEEE 854 规范的除法。整数 Div 可以设置 Chill 标志。
- T Mod(T, T)
- 适用于除 Void 以外的任何类型。对于整数类型,这表示带符号模数运算。默认情况下,对于 x%0 或 -231%-1,其行为未定义。对于浮点类型,这表示根据“fmod()”的模数运算。整数 Mod 可以设置 Chill 标志。
- T Neg(T)
- 适用于除 Void 以外的任何类型。对于整数类型,这表示二进制补码取反。对于浮点类型,这表示根据 IEEE 规范的取反。
- T BitAnd(T, T)
- 按位与。适用于除 Void 以外的任何类型。
- T BitOr(T, T)
- 按位或。适用于除 Void 以外的任何类型。
- T BitXor(T, T)
- 按位异或。适用于除 Void 以外的任何类型。
- T Shl(T, Int32)
- 左移。适用于 Int32 和 Int64。移位量始终为 Int32。对于 Int32,仅使用移位量的低 31 位。对于 Int64,仅使用移位量的低 63 位。
- T SShr(T, Int32)
- 带符号扩展的右移。适用于 Int32 和 Int64。移位量始终为 Int32。对于 Int32,仅使用移位量的低 31 位。对于 Int64,仅使用移位量的低 63 位。
- T ZShr(T, Int32)
- 带零扩展的右移。适用于 Int32 和 Int64。移位量始终为 Int32。对于 Int32,仅使用移位量的低 31 位。对于 Int64,仅使用移位量的低 63 位。
- T RotR(T, Int32)
- 右旋转。适用于 Int32 和 Int64。移位量始终为 Int32。对于 Int32,仅使用移位量的低 31 位。对于 Int64,仅使用移位量的低 63 位。
- T RotL(T, Int32)
- 左旋转。适用于 Int32 和 Int64。移位量始终为 Int32。对于 Int32,仅使用移位量的低 31 位。对于 Int64,仅使用移位量的低 63 位。
- T Clz(T)
- 计数前导零。适用于 Int32 和 Int64。
- T Abs(T)
- 绝对值。适用于 Float 和 Double。
- T Ceil(T)
- 向上取整。适用于 Float 和 Double。
- T Floor(T)
- 向下取整。适用于 Float 和 Double。
- T Sqrt(T)
- 平方根。适用于 Float 和 Double。
- U BitwiseCast(T)
- 如果 T 是 Int32 或 Int64,则分别返回位对应的 Float 或 Double。如果 T 是 Float 或 Double,则分别返回位对应的 Int32 或 Int64。
- Int32 SExt8(Int32)
- 用低字节的符号扩展填充整数的高 24 位。
- Int32 SExt16(Int32)
- 用低字(16位)的符号扩展填充整数的高 16 位。
- Int64 SExt32(Int32)
- 返回一个 64 位整数,其中低 32 位是给定的 Int32 值,高 32 位是其符号扩展。
- Int64 ZExt32(Int32)
- 返回一个 64 位整数,其中低 32 位是给定的 Int32 值,高 32 位是零。
- U Trunc(T)
- 返回 64 位值的低 32 位。如果 T 是 Int64,则 U 是 Int32。如果 T 是 Double,则 U 是 Float。
- Double IToD(T)
- 将给定整数转换为双精度浮点数。适用于 Int32 或 Int64 输入。
- Double FloatToDouble(Float)
- 将给定浮点数转换为双精度浮点数。
- Float DoubleToFloat(Double)
- 将给定双精度浮点数转换为浮点数。
- Int32 Equal(T, T)
- 比较两个值。如果它们相等,返回 1;否则返回 0。适用于除 Void 以外的所有类型。整数比较仅比较所有位。浮点比较主要比较位,但有一些特殊情况:正零和负零被认为是相等的,并且当任一值为 NaN 时,它们返回 false。
- Int32 NotEqual(T, T)
- Equal() 的反面。NotEqual(@x, @y) 产生的结果与 BitXor(Equal(@x, @y), 1) 相同。
- Int32 LessThan(T, T)
- 如果左值小于右值,返回 1;否则返回 0。对整数执行有符号比较。对于浮点比较,这有关于负零和 NaN 的常见注意事项。
- Int32 GreaterThan(T, T)
- 如果左值大于右值,返回 1;否则返回 0。对整数执行有符号比较。对于浮点比较,这有关于负零和 NaN 的常见注意事项。
- Int32 LessEqual(T, T)
- 如果左值小于或等于右值,返回 1;否则返回 0。对整数执行有符号比较。对于浮点比较,这有关于负零和 NaN 的常见注意事项。
- Int32 GreaterEqual(T, T)
- 如果左值大于或等于右值,返回 1;否则返回 0。对整数执行有符号比较。对于浮点比较,这有关于负零和 NaN 的常见注意事项。
- Int32 Above(T, T)
- 无符号整数比较,仅适用于 Int32 和 Int64。如果左值无符号大于右值,返回 1;否则返回 0。
- Int32 Below(T, T)
- 无符号整数比较,仅适用于 Int32 和 Int64。如果左值无符号小于右值,返回 1;否则返回 0。
- Int32 AboveEqual(T, T)
- 无符号整数比较,仅适用于 Int32 和 Int64。如果左值无符号大于或等于右值,返回 1;否则返回 0。
- Int32 BelowEqual(T, T)
- 无符号整数比较,仅适用于 Int32 和 Int64。如果左值无符号小于或等于右值,返回 1;否则返回 0。
- Int32 EqualOrUnordered(T, T)
- 浮点比较,仅适用于 Float 和 Double。如果左值等于右值或任一值为 NaN,返回 1。否则返回 0。
- T Select(U, T, T)
- 返回第二个子节点或第三个子节点。T 可以是除 Void 以外的任何类型。U 可以是 Int32 或 Int64。如果第一个子节点非零,则返回第二个子节点。否则返回第三个子节点。
- Int32 Load8Z(IntPtr, offset)
- 从地址加载一个字节,该地址通过将编译时 32 位带符号整数偏移量添加到子值来计算。零扩展加载的字节,因此高 24 位都为零。必须使用 MemoryValue 类。可以设置 Traps 标志。可以设置屏障范围以将其转换为加载获得屏障。
- Int32 Load8S(IntPtr, offset)
- 从地址加载一个字节,该地址通过将编译时 32 位带符号整数偏移量添加到子值来计算。符号扩展加载的字节。必须使用 MemoryValue 类。可以设置 Traps 标志。可以设置屏障范围以将其转换为加载获得屏障。
- Int32 Load16Z(IntPtr, offset)
- 从地址加载一个 16 位整数,该地址通过将编译时 32 位带符号整数偏移量添加到子值来计算。零扩展加载的 16 位整数,因此高 16 位都为零。未对齐的加载不被惩罚。必须使用 MemoryValue 类。可以设置 Traps 标志。可以设置屏障范围以将其转换为加载获得屏障。
- Int32 Load16S(IntPtr, offset)
- 从地址加载一个 16 位整数,该地址通过将编译时 32 位带符号整数偏移量添加到子值来计算。符号扩展加载的 16 位整数。未对齐的加载不被惩罚。必须使用 MemoryValue 类。可以设置 Traps 标志。可以设置屏障范围以将其转换为加载获得屏障。
- T Load(IntPtr, offset)
- 适用于除 Void 以外的任何类型。从地址加载该类型的值,该地址通过将编译时 32 位带符号整数偏移量添加到子值来计算。未对齐的加载不被惩罚。必须使用 MemoryValue 类。可以设置 Traps 标志。可以设置屏障范围以将其转换为加载获得屏障。
- Void Store8(Int32, IntPtr, offset)
- 将第一个子节点的低字节存储到通过将编译时 32 位带符号整数偏移量添加到第二个子节点来计算的地址中。必须使用 MemoryValue 类。可以设置 Traps 标志。可以设置屏障范围以将其转换为存储释放屏障。
- Void Store16(Int32, IntPtr, offset)
- 将第一个子节点的低 16 位存储到通过将编译时 32 位带符号整数偏移量添加到第二个子节点来计算的地址中。未对齐的存储不被惩罚。必须使用 MemoryValue 类。可以设置 Traps 标志。可以设置屏障范围以将其转换为存储释放屏障。
- Void Store(T, IntPtr, offset)
- 将第一个子节点中的值存储到通过将编译时 32 位带符号整数偏移量添加到第二个子节点来计算的地址中。未对齐的存储不被惩罚。必须使用 MemoryValue 类。可以设置 Traps 标志。可以设置屏障范围以将其转换为存储释放屏障。
- Int32 AtomicWeakCAS(T expectedValue, T newValue, IntPtr ptr)
- 执行弱 CAS (比较并交换)。返回一个布尔值 (0 或 1) 来指示结果 (失败或成功)。可能会虚假失败,在这种情况下它将不执行任何操作并返回 0。您可以提供一个屏障范围来指示 CAS 是带屏障的。带屏障的 CAS 具有获得和释放屏障,并且除了 CAS 本身提供的主范围外,还声称读写完整的屏障范围。AtomicWeakCAS 仅在其成功时具有屏障行为。原子操作接受一个宽度参数,允许它们对 8 位、16 位、32 位或 64 位整数内存位置进行操作。
- T AtomicStrongCAS(T expectedValue, T newValue, IntPtr ptr)
- 执行强 CAS (比较并交换)。返回 CAS 之前的旧值。指令选择器在 Equal(AtomicStrongCAS(expected, ...), expected) 模式方面很智能,因此此操作码也是执行返回布尔值的强 CAS 的最佳方式——只需使用 Equal 将结果与 expected 进行比较。您可以提供一个屏障范围来指示 CAS 是带屏障的。带屏障的 CAS 具有获得和释放屏障,并且除了 CAS 本身提供的主范围外,还声称读写完整的屏障范围。AtomicStrongCAS 在失败和成功时具有相同的屏障。原子操作接受一个宽度参数,允许它们对 8 位、16 位、32 位或 64 位整数内存位置进行操作。对于 8 位或 16 位宽度,返回符号扩展结果。
- T AtomicXchgAdd(T, IntPtr)
- 原子地将一个值添加到内存位置并返回旧值。允许有屏障范围,这会导致它具有获得/释放屏障。原子操作接受一个宽度参数,允许它们对 8 位、16 位、32 位或 64 位整数内存位置进行操作。对于 8 位或 16 位宽度,返回符号扩展结果。
- T AtomicXchgAnd(T, IntPtr)
- 原子地将一个值按位与到内存位置并返回旧值。允许有屏障范围,这会导致它具有获得/释放屏障。原子操作接受一个宽度参数,允许它们对 8 位、16 位、32 位或 64 位整数内存位置进行操作。对于 8 位或 16 位宽度,返回符号扩展结果。
- T AtomicXchgOr(T, IntPtr)
- 原子地将一个值按位或到内存位置并返回旧值。允许有屏障范围,这会导致它具有获得/释放屏障。原子操作接受一个宽度参数,允许它们对 8 位、16 位、32 位或 64 位整数内存位置进行操作。对于 8 位或 16 位宽度,返回符号扩展结果。
- T AtomicXchgSub(T, IntPtr)
- 原子地从内存位置减去一个值并返回旧值。允许有屏障范围,这会导致它具有获得/释放屏障。原子操作接受一个宽度参数,允许它们对 8 位、16 位、32 位或 64 位整数内存位置进行操作。对于 8 位或 16 位宽度,返回符号扩展结果。
- T AtomicXchgXor(T, IntPtr)
- 原子地将一个值按位异或到内存位置并返回旧值。允许有屏障范围,这会导致它具有获得/释放屏障。原子操作接受一个宽度参数,允许它们对 8 位、16 位、32 位或 64 位整数内存位置进行操作。对于 8 位或 16 位宽度,返回符号扩展结果。
- T AtomicXchg(T, IntPtr)
- 原子地将一个值存储到内存位置并返回旧值。允许有屏障范围,这会导致它具有获得/释放屏障。原子操作接受一个宽度参数,允许它们对 8 位、16 位、32 位或 64 位整数内存位置进行操作。对于 8 位或 16 位宽度,返回符号扩展结果。
- T Depend(T)
- 仅对整数类型有效。这保证通过将输入与自身进行异或操作来返回零,并且 B3 编译器保证不会利用此知识进行任何优化。在 x86 上,这被降级为 read=Bottom,write=Top 的 Fence(即加载-加载屏障),然后被常量折叠为零。在 ARM 上,这通过编译器管道一直保留到 MacroAssembler,MacroAssembler 会将其转换为
eor
。无论平台如何,使用 Depend 是创建加载-加载依赖项的最有效方式。它允许 B3 的 CSE 适用于周围的加载,甚至支持 CSE 依赖项本身——因此两个具有相同加载链且没有交错效应的可以合并为一个。在 ARM 上,它还避免发出昂贵的 dmb ish
加载-加载屏障。由于 CSE 的好处,即使您只针对 x86,也建议将 Depend 用于加载-加载依赖项。
- IntPtr WasmAddress(IntPtr, pinnedGPR)
- 这用于计算从固定基础通用寄存器加载 wasm 内存的地址。必须使用 WasmAddressValue 类。
- Void Fence()
- 抽象 x86 和 ARM 上的独立数据屏障。必须使用 FenceValue 类,该类具有两个附加成员来配置屏障的精确含义:
HeapRange FenceValue::read
和 HeapRange FenceValue::write
。如果 write
范围为空,则这被视为存储-存储屏障,在 x86 上不会生成代码,在 ARM 上会生成较弱的 dmb ishst
屏障。如果写入范围非空,则在 x86 上生成 mfence
,在 ARM 上生成 dmb ish
。在 B3 IR 内部,Fence 还在其效果中报告读/写。这允许您为了 B3 的加载消除目的来限定屏障的范围。例如,您可以使用 Fence 来保护存储不被下沉到特定加载之下。在这种情况下,您可以声称只读取该存储的范围并写入该加载的范围。
- T1 CCall(IntPtr, [T2, [T3, ...]])
- 对第一个子节点指向的函数执行 C 函数调用。函数接受的类型和返回的类型由子节点的类型和 CCallValue 的类型决定。只有第一个子节点是强制性的。必须使用 CCallValue 类。
- T1 Patchpoint([T2, [T3, ...]])
-
Patchpoint 是一个可定制的值。Patchpoint 接受零个或多个任何类型的值,并返回任何类型。Patchpoint 的行为由生成器对象决定。生成器是一个 C++ lambda,在代码生成期间被调用。它被传递一个汇编器实例(特别是 CCallHelpers&)和一个描述在哪里找到所有输入值以及在哪里放置结果的对象。这是一个例子:
PatchpointValue* patchpoint = block->appendNew<PatchpointValue>(proc, Int32, Origin());
patchpoint->append(ConstrainedValue(arg1, ValueRep::SomeRegister));
patchpoint->append(ConstrainedValue(arg2, ValueRep::SomeRegister));
patchpoint->setGenerator(
[&] (CCallHelpers& jit, const StackmapGenerationParams& params) {
jit.add32(params[1].gpr(), params[2].gpr(), params[0].gpr());
});
这会创建一个只将两个数字相加的补丁点。补丁点设置为返回 Int32。两个子值 arg1 和 arg2 以 SomeRegister 约束传递给补丁点,这只是要求它们被放入适当的寄存器中(整数值使用 GPR,浮点值使用 FPR)。生成器使用 params 对象来确定输入在哪个寄存器中(params[1] 和 params[2])以及结果放在哪个寄存器中(params[0])。许多复杂的约束都是可能的。您可以要求将子节点放置在特定寄存器中。您可以列出被破坏的其他寄存器——要么在补丁点顶部(即早期),以便被破坏的寄存器干扰输入,要么在补丁点底部(即后期),以便被破坏的寄存器干扰输出。补丁点约束还允许您将值强制到栈的调用参数区域。补丁点功能强大,足以实现自定义调用约定、内联缓存和侧边退出。
补丁点允许“侧边退出”,即突然从过程中退出。如果它想通过返回来这样做,它可以使用 Procedure 的 API 来获取被调用者保存寄存器布局,解开被调用者保存寄存器,然后返回。更可能的是,补丁点会将一些退出状态作为其参数的一部分,并且它将原地操作调用帧,使其看起来像另一个执行引擎的调用帧。这被称为 OSR,JavaScriptCore 经常这样做。
补丁点可以用作终结符。只需设置 terminal
位:
patchpoint->effects.terminal = true;
生成器可以通过使用 StackmapGenerationParams 获取所有后继的标签集来确定分支到哪里。您保证补丁点基本块的后继数量与您创建时相同。然而,像任何值一样,补丁点可能会被克隆。这意味着,例如,如果您使用它来实现表跳转,那么跳转表必须在生成器内部创建,以便每个克隆都获得一个跳转表。
Patchpoint 操作码必须使用 PatchpointValue 类。
- T CheckAdd(T, T, [T2, [T3, ...]])
- 检查整数加法。适用于 T = Int32 或 T = Int64。前两个子节点是强制性的。附加子节点是可选的。所有 Check 指令都像 Patchpoint 一样接受生成器和值约束。在 CheckAdd 的情况下,生成器在整数加法溢出的路径上运行。B3 假定 CheckAdd 在溢出时会侧边退出,因此生成器必须进行某种终止。通常,这用于在整数溢出时实现 OSR 退出,CheckAdd 的可选参数将是 OSR 退出状态。必须使用 CheckValue 类。
- T CheckSub(T, T, [T2, [T3, ...]])
- 检查整数减法。适用于 T = Int32 或 T = Int64。前两个子节点是强制性的。附加子节点是可选的。所有 Check 指令都像 Patchpoint 一样接受生成器和值约束。在 CheckSub 的情况下,生成器在整数减法溢出的路径上运行。B3 假定 CheckSub 在溢出时会侧边退出,因此生成器必须进行某种终止。通常,这用于在整数溢出时实现 OSR 退出,CheckSub 的可选参数将是 OSR 退出状态。您可以使用 CheckSub 通过将第一个子节点设为零来实现检查负数。当您这样做时,B3 将选择原生取反指令。必须使用 CheckValue 类。
- T CheckMul(T, T, [T2, [T3, ...]])
- 检查整数乘法。适用于 T = Int32 或 T = Int64。前两个子节点是强制性的。附加子节点是可选的。所有 Check 指令都像 Patchpoint 一样接受生成器和值约束。在 CheckMul 的情况下,生成器在整数乘法溢出的路径上运行。B3 假定 CheckMul 在溢出时会侧边退出,因此生成器必须进行某种终止。通常,这用于在整数溢出时实现 OSR 退出,CheckMul 的可选参数将是 OSR 退出状态。必须使用 CheckValue 类。
- Void Check(T, [T2, [T3, ...]])
- 退出检查。适用于 T = Int32 或 T = Int64。这根据第一个子节点进行分支。如果第一个子节点为零,它会直接通过。如果非零,它会转到由传递的生成器生成的退出路径。只有第一个子节点是强制性的。B3 假定当第一个子节点非零时 Check 会侧边退出,因此生成器必须进行某种终止。通常,这用于实现 OSR 退出检查,Check 的可选参数将是 OSR 退出状态。Check 支持高效的比较/分支融合,因此您可以检查相当复杂的谓词。例如,Check(Equal(LessThan(@a, @b), 0)),其中 @a 和 @b 是双精度浮点数,将生成一条指令,如果 @a >= @b 或 @a 或 @b 是 NaN,则分支到退出路径。必须使用 CheckValue 类。
- Void WasmBoundsCheck(Int32, pinnedGPR, offset)
- 特殊的 Wasm 操作码。这根据第一个子节点进行分支。如果第一个子节点加上偏移量产生一个小于 pinnedGPR 的 Int64 值,则继续执行。否则,它分支到由传递的生成器生成的退出路径。与 Patch/Check 系列不同,WasmBoundsCheck 使用的生成器应在 Procedure 本身设置。传递给 pinnedGPR 的 GPRReg 也必须通过调用 Procedure 的 pinning API 标记为 pinned,或者它必须是 InvalidGPR,在这种情况下,越界限制是 4GiB。在后一种情况下,可以提供一个最大参数,以进一步限制越界限制并帮助生成更小的立即数。B3 假定 WasmBoundsCheck 在分支时会侧边退出,因此生成器必须进行某种终止。在 Wasm 中,这用于捕获并回溯到 JS。必须使用 WasmBoundsCheckValue 类。
- Void Upsilon(T, ^phi)
- B3 使用 SSA。SSA 要求每个变量在过程中的任何地方只被赋值一次。但这不适用于您有一个变量,它应该是沿着控制流合并两个值的结果。B3 使用 Phi 值来表示值合并,就像 SSA 编译器通常所做的那样。但是,其他 SSA 编译器通过列出 Phi 获取其值的控制流边来表示 Phi 的输入,而 B3 使用 Upsilon 值。每个 Phi 的行为都像它有一个与之关联的内存位置。执行 Phi 的行为类似于从该内存位置加载。Upsilon(@value, ^phi) 的行为类似于将 @value 存储到与 @phi 关联的内存位置。当我们表示要写入与 Phi 关联的内存位置时,我们说“^phi”。必须使用 UpsilonValue 类。
- T Phi()
- 适用于除 Void 以外的任何类型。表示一个足够大以容纳 T 的局部内存位置。从该内存位置加载。存储到该位置的唯一方法是使用 Upsilon。
- Void Jump(takenBlock)
- 跳转到 takenBlock。这必须出现在基本块的末尾。基本块必须只有一个后继。
- Void Branch(T, takenBlock, notTakenBlock)
- 适用于 T = Int32 或 T = Int64。根据子节点进行分支。如果子节点非零,则分支到 takenBlock。否则分支到 notTakenBlock。必须出现在基本块的末尾。该块必须有两个后继。
- Void Switch(T, cases...)
- 适用于 T = Int32 或 T = Int64。根据子节点进行开关。包含一个开关情况列表。每个开关情况都有一个整数常量和一个目标块。开关值还包含一个贯穿目标,以防子节点的值未在情况列表中提及。必须使用 SwitchValue 类。必须出现在基本块的末尾。该块必须为每个情况有一个后继,再加上一个用于贯穿(默认)情况的后继。您可以使用 SwitchValue 中的 API(如 SwitchValue::appendCase() 和 SwitchValue::setFallThrough())管理包含 Switch 的块的后继。
- Void EntrySwitch()
-
B3 支持多个过程入口点。创建多个入口点的方法是在根块的末尾放置一个 EntrySwitch。然后根块必须为每个入口点有一个后继。此外,您必须告诉 Procedure 您想要多少个入口点。例如:
Procedure proc;
proc.setNumEntrypoints(3);
BasicBlock* root = proc.addBlock();
BasicBlock* firstEntrypoint = proc.addBlock();
BasicBlock* secondEntrypoint = proc.addBlock();
BasicBlock* thirdEntrypoint = proc.addBlock();
root->appendNew<Value>(proc, EntrySwitch, Origin());
root->appendSuccessor(firstEntrypoint);
root->appendSuccessor(secondEntrypoint);
root->appendSuccessor(thirdEntrypoint);
这是使用 EntrySwitch 的规范方式,然而其语义足够灵活,允许在控制流图中的任何地方使用它。您可以拥有任意数量的 EntrySwitch。这种灵活性确保即使 B3 执行将代码移动到 EntrySwitch 上方、复制 EntrySwitch 本身或执行其他任何意外操作的转换时,EntrySwitch 也能正常工作。
EntrySwitch 的行为就好像每个 Procedure 都有一个名为 Entry 的变量。每个物理入口点将 Entry 设置为该入口点的索引(因此如上所示为 0、1 或 2)并跳转到根块。EntrySwitch 只是 Entry 变量上的一个开关。对使用 EntrySwitch 的代码进行的转换是有效的,只要它们不改变在此语义下过程的行为。
EntrySwitch 的实现不涉及任何实际变量或切换。相反,从根块到某个 EntrySwitch 的路径上的所有代码都会为每个入口点克隆。这种降级在 Air 中作为一个非常晚的阶段完成,因此编译器的大部分内容不需要了解任何关于入口点的信息。编译器的大部分内容将 EntrySwitch 视为不透明的控制流构造。EntrySwitch 降级允许克隆任意数量的代码。然而,EntrySwitch 的正常用法会将其放置在空根块的末尾,并且 B3 只会将少量内容提升到 EntrySwitch 上方。这导致实践中只有少量克隆代码。
- Void Return(T (可选))
-
将控制流返回给调用者并终止过程。必须出现在基本块的末尾。该块必须有零个后继。
如果节点有子节点,则返回其值。子节点的类型可以是除 Void 以外的任何类型。
- Void Oops()
- 表示不可达代码。这可以实现为陷阱或裸贯穿,因为 B3 允许假定永远不会到达此处。必须出现在基本块的末尾。该块必须有零个后继。请注意,在某些 B3 转换中,我们也将 Oops 操作码表示为“无此操作码”。
标志
本节描述标志。这些可以在 Kind
中设置。
- Chill
- 适用于:Div, Mod。您可以通过调用
chill(Div)
来创建松散的 Div/Mod。这会创建一个设置了 Chill 标志的 Kind。这只能与整数类型一起使用。如果操作在其非松散形式会具有未定义行为时返回一个合理的值,则称其为松散。松散 Div 将 x/0 转换为 0,将 -231/-1 转换为 -231。我们在 IR 中识别这一点,因为它与 ARM64 上的除法语义完全相同,也与 JavaScript 对 "(x/y)|0" 所期望的语义完全相同。松散 Mod 将 x%0 转换为 0,将 -231%-1 转换为 0。这与 JavaScript "(x%y)|0" 的语义匹配。
- Traps
- 适用于:Load8Z, Load8S, Load16Z, Load16S, Load, Store8, Store16, Store。您可以通过调用
trapping(opcode)
从操作码创建捕获 Kind。例如,trapping(Load)
。如果操作在用于无效指针时会导致页错误并且此页错误将被观察到,则称其为捕获。这意味着编译器不能模糊页错误发生的时间。这在逻辑上等同于 B3 所谓的 Effects::exitsSideways
,但进一步暗示,如果用于融合 Air 指令的任何 B3 值是捕获的,那么 Air 指令必须设置其 Air::Kind::traps
标志。编译器不会帮助您识别陷阱发生的位置。即使您使用编译器的源跟踪机制来追踪陷阱位置,您也可能获得合并到导致陷阱的融合指令中的任何 B3 值的源。例如,“Add(Load<Traps>(ptr), $1)”在 x86 上可能声称在 Add 处发生陷阱而不是 Load,因为此模式是加载-加法融合的完美候选。尽管如此,您仍保证会发生陷阱,并且将在您预期的时间点观察到该陷阱。例如,编译器不会将捕获加载提升到任何效果之上,即使是其读取范围之外的效果,因为陷阱被假定为读取最高地址。编译器不会尝试对捕获加载进行死代码消除 (DCE)。编译器不会尝试下沉或消除任何捕获存储,即使它们由于后续保证的相同地址存储而成为死代码,因为我们保守地假定存储是为了陷阱效果而进行的。此功能旨在支持 WebAssembly 中的高吞吐量内存安全检查。