Bare Bones Backend / B3 Intermediate Representation

B3 IR is a C-like SSA representation of a procedure. A procedure has a root block at which it starts execution when it is invoked. A procedure does not have to terminate, but if it does, then it can be either due to a Return, which gracefully returns some value, or by a side-exit at designated instructions. B3 gives the client a lot of flexibility to implement many different kinds of side-exits.

B3 is designed to represent procedures for the purpose of transforming them. Knowing what transformations are legal requires knowing what a procedure does. A transformation is valid if it does not change the observable behavior of a procedure. This document tells you what B3 procedures do by telling you what each construct in B3 IR does.

Procedure

The parent object of all things in B3 is the Procedure. Every time you want to compile something, you start by creating a Procedure. The lifecycle of a Procedure is usually:

  1. Create the Procedure.
  2. Populate the Procedure with code.
  3. Use either the high-level Compilation API or the low-level generation API.

The act of compiling the Procedure changes it in-place, making it unsuitable for compiling again. Always create a new Procedure every time you want to compile something.

Types

B3 has a trivial type system with only five types:

Void
Used to say that an instruction does not return a value.
Int32
32-bit integer. Integers don't have sign, but operations on them do.
Int64
64-bit integer.
Float
32-bit binary floating point number.
Double
64-bit binary floating point number.

Values

Variables, and the instructions that define them, are represented using the Value object. The Value object has a return type, an opcode, and zero or more children. Children are references to other Values. Those values are used as input to the instruction that computes this value.

Values also have a unique 32-bit index that is used as the name.

Example:

Int32 @3 = Add(@1, @2)

This represents a single Value instance. Its index is 3. It is an Int32. The opcode is Add, and its children are @1 and @2.

Values may also have additional meta-data. We use special subclasses of the B3::Value class for values that need meta-data. For example, the Load value needs a 32-bit offset for the load. We use the MemoryValue class for memory-accessing values, which all have such an offset.

Stack Slot

B3 exposes the concept of stack-allocated data and gives the client a lot of control. By default, stack slots get allocated wherever B3 chooses. It will try to pack them as much as possible. After compilation is done, you can retrieve each stack slot's location in the form of an offset from the frame pointer.

You can force stack slots to end up at a particular offset from the frame pointer, though this is very dangerous. For example, B3 assumes that it can use the slots closest to the frame pointer for callee-saves, and currently when you force something to a particular frame pointer offset, there is no mechanism to notice that this space is also being used for callee-saves. Therefore, we recommend not using the frame pointer offset forcing feature unless you know a lot about the ABI and you have no other choice.

Stack slots are also used for creating non-SSA variables with the intention of having B3 convert them into SSA. There are two kinds of stack slots.

Anonymous
Anonymous stack slots are used to represent local variables that aren't in SSA form. B3 is allowed to assume that nobody will store to an anonymous stack slot except through Store instructions in the B3 procedure. B3 is allowed to assume that a Store that does not write to the entire anonymous stack slot leaves the unwritten part in an undefined state. Usually, anonymous stack slots are allocated to have the same size as the type of variable they are being used to represent.
Locked
These stack slots are assumed to operate "as if" they were in the heap, in the sense that the may get read or written using operations not visible in B3 IR.

The fixSSA() phase will convert anonymous stack slots to SSA.

Control flow

B3 represents control flow using basic blocks. Each basic block may have zero or more predecessors. Each basic block may have zero or more successors. The successors are controlled by the basic block's last Value, which must be a ControlValue instance.

Each basic block contains a Vector<Value*> as the contents of the block. Control flow inside the block is implicit based on the order of Values in the vector.

Opcodes

This section describes opcodes in the following format:

Int32 Foo(Int64, Double)
This describes an opcode named Foo that uses Int32 as its return type and takes two children - one of type Int64 and another of type Double.

We sometimes use the wildcard type T to represent polymorphic operations, like "T Add(T, T)". This means that the value must take two children of the same type and returns a value of that type. We use the type IntPtr to mean either Int32, or Int64, depending on the platform.

Opcode descriptions

Void Nop()
The empty value. Instead of removing Values from basic blocks, most optimizations convert them to Nops. Various phases run fix-up where all Nops are removed in one pass. It's common to see Nops in intermediate versions of B3 IR during optimizations. Nops never lead to any code being generated and they do not impede optimizations, so they are usually harmless. You can convert a Value to a Nop by doing convertToNop().
T Identity(T)
Returns the passed value. May be used for any type except Void. Instead of replacing all uses of a Value with a different Value, most optimizations convert them to Identity. Various phases run fix-up where all uses of Identity are replaced with the Identity's child (transitively, so Identity(Identity(Identity(@x))) is changed to just @x). Even the instruction selector "sees through" Identity. You can remove all references to Identity in any value by calling Value::performSubstitution(). You can convert a Value to an Identity by doing convertToIdentity(otherValue). If the value is Void, convertToIdentity() converts it to a Nop instead.
Int32 Const32(constant)
32-bit integer constant. Must use the Const32Value class, which has space for the int32_t constant.
Int64 Const64(constant)
64-bit integer constant. Must use the Const64Value class, which has space for the int64_t constant.
Float ConstFloat(constant)
Float constant. Must use the ConstFloatValue class, which has space for the float constant.
Double ConstDouble(constant)
Double constant. Must use the ConstDoubleValue class, which has space for the double constant.
IntPtr SlotBase(stackSlot)
Returns a pointer to the base of the given stack slot. Must use the SlotBaseValue class.
IntPtr|Double ArgumentReg(%register)
Returns the value that the given register had at the prologue of the procedure. It returns IntPtr for general-purpose registers and Double for FPRs. Must use the ArgumentRegValue class.
IntPtr FramePointer()
Returns the value of the frame pointer register. B3 procedures alway use a frame pointer ABI, and the frame pointer is always guaranteed to have this value anywhere inside the procedure.
T Add(T, T)
Works with any type except Void. For integer types, this represents addition with wrap-around semantics. For floating point types, this represents addition according to the IEEE 854 spec. B3 does not have any notion of "fast math". A transformation over floating point code is valid if the new code produces exactly the same value, bit for bit.
T Sub(T, T)
Works with any type except Void. For integer types, this represents subtraction with wrap-around semantics. For floating point types, this represents subtraction according to the IEEE 854 spec.
T Mul(T, T)
Works with any type except Void. For integer types, this represents multiplication with wrap-around semantics. For floating point types, this represents multiplication according to the IEEE 854 spec.
T Div(T, T)
Works with any type except Void. For integer types, this represents signed division with round-to-zero. Its behavior is undefined for x/0 or -231/-1. For floating point types, this represents division according to the IEEE 854 spec.
T Mod(T, T)
Works with any type except Void. For integer types, this represents signed modulo. Its behavior is undefined for x%0 or -231%-1. For floating point types, this represents modulo according to "fmod()".
T Neg(T)
Works with any type except Void. For integer types, this represents twos-complement negation. For floating point types, this represents negation according to the IEEE spec.
T ChillDiv(T, T)
Chill division. Valid for Int32 and Int64. An operation is said to be chill if it returns a sensible value whenever its non-chill form would have had undefined behavior. ChillDiv turns x/0 into 0 and -231/-1 into -231. This is a separate opcode because it's exactly the semantics of division on ARM64, and it's also exactly the semantics that JavaScript wants for "(x/y)|0".
T ChillMod(T, T)
Chill modulo. Valid for Int32 and Int64. ChllMod turns x%0 into 0 and -231%-1 into 0.
T BitAnd(T, T)
Bitwise and. Valid for Int32 and Int64.
T BitOr(T, T)
Bitwise or. Valid for Int32 and Int64.
T BitXor(T, T)
Bitwise xor. Valid for Int32 and Int64.
T Shl(T, Int32)
Shift left. Valid for Int32 and Int64. The shift amount is always Int32. Only the low 31 bits of the shift amount are used for Int32. Only the low 63 bits of the shift amount are used for Int64.
T SShr(T, Int32)
Shift right with sign extension. Valid for Int32 and Int64. The shift amount is always Int32. Only the low 31 bits of the shift amount are used for Int32. Only the low 63 bits of the shift amount are used for Int64.
T ZShr(T, Int32)
Shift right with zero extension. Valid for Int32 and Int64. The shift amount is always Int32. Only the low 31 bits of the shift amount are used for Int32. Only the low 63 bits of the shift amount are used for Int64.
T Clz(T)
Count leading zeroes. Valid for Int32 and Int64.
T Abs(T)
Absolute value. Valid for Float and Double.
T Ceil(T)
Ceiling. Valid for Float and Double.
T Sqrt(T)
Square root. Valid for Float and Double.
U BitwiseCast(T)
If T is Int32 or Int64, it returns the bitwise-corresponding Float or Double, respectively. If T is Float or Double, it returns the bitwise-corresponding Int32 or Int64, respectively.
Int32 SExt8(Int32)
Fills the top 24 bits of the integer with the low byte's sign extension.
Int32 SExt16(Int32)
Fills the top 16 bits of the integer with the low short's sign extension.
Int64 SExt32(Int32)
Returns a 64-bit integer such that the low 32 bits are the given Int32 value and the high 32 bits are its sign extension.
Int64 ZExt32(Int32)
Returns a 64-bit integer such that the low 32 bits are the given Int32 value and the high 32 bits are zero.
Int32 Trunc(Int64)
Returns the low 32 bits of the 64-bit value.
Double IToD(T)
Converts the given integer into a double. Value for Int32 or Int64 inputs.
Double FloatToDouble(Float)
Converts the given float into a double.
Float DoubleToFloat(Double)
Converts the given double into a float.
Int32 Equal(T, T)
Compares the two values. If they are equal, return 1; else return 0. Valid for all types except Void. Integer comparisons simply compare all bits. Floating point comparisons mostly compare bits, but have some corner cases: positive zero and negative zero are considered equal, and they return false when either value is NaN.
Int32 NotEqual(T, T)
The opposite of Equal(). NotEqual(@x, @y) yields the same result as BitXor(Equal(@x, @y), 1).
Int32 LessThan(T, T)
Returns 1 if the left value is less than the right one, 0 otherwise. Does a signed comparison for integers. For floating point comparisons, this has the usual caveats with respect to negative zero and NaN.
Int32 GreaterThan(T, T)
Returns 1 if the left value is greater than the right one, 0 otherwise. Does a signed comparison for integers. For floating point comparisons, this has the usual caveats with respect to negative zero and NaN.
Int32 LessEqual(T, T)
Returns 1 if the left value is less than or equal to the right one, 0 otherwise. Does a signed comparison for integers. For floating point comparisons, this has the usual caveats with respect to negative zero and NaN.
Int32 GreaterEqual(T, T)
Returns 1 if the left value is greater than or equal to the right one, 0 otherwise. Does a signed comparison for integers. For floating point comparisons, this has the usual caveats with respect to negative zero and NaN.
Int32 Above(T, T)
Unsigned integer comparison, valid for Int32 and Int64 only. Returns 1 if the left value is unsigned-greater-than the right one, 0 otherwise.
Int32 Below(T, T)
Unsigned integer comparison, valid for Int32 and Int64 only. Returns 1 if the left value is unsigned-less-than the right one, 0 otherwise.
Int32 AboveEqual(T, T)
Unsigned integer comparison, valid for Int32 and Int64 only. Returns 1 if the left value is unsigned-greater-than-or-equal the right one, 0 otherwise.
Int32 BelowEqual(T, T)
Unsigned integer comparison, valid for Int32 and Int64 only. Returns 1 if the left value is unsigned-less-than-or-equal the right one, 0 otherwise.
Int32 EqualOrUnordered(T, T)
Floating point comparison, valid for Float and Double only. Returns 1 if the left value is equal to the right one or if either value is NaN. Returns 0 otherwise.
T Select(U, T, T)
Returns either the second child or the third child. T can be any type except Void. U can be either Int32 or Int64. If the first child is non-zero, returns the second child. Otherwise returns the third child.
Int32 Load8Z(IntPtr, offset)
Loads a byte from the address, which is computed by adding the compile-time 32-bit signed integer offset to the child value. Zero extends the loaded byte, so the high 24 bits are all zero. Must use the MemoryValue class.
Int32 Load8S(IntPtr, offset)
Loads a byte from the address, which is computed by adding the compile-time 32-bit signed integer offset to the child value. Sign extends the loaded byte. Must use the MemoryValue class.
Int32 Load16Z(IntPtr, offset)
Loads a 16-bit integer from the address, which is computed by adding the compile-time 32-bit signed integer offset to the child value. Zero extends the loaded 16-bit integer, so the high 16 bits are all zero. Misaligned loads are not penalized. Must use the MemoryValue class.
Int32 Load16S(IntPtr, offset)
Loads a 16-bit integer from the address, which is computed by adding the compile-time 32-bit signed integer offset to the child value. Sign extends the loaded 16-bit integer. Misaligned loads are not penalized. Must use the MemoryValue class.
T Load(IntPtr, offset)
Valid for any type except Void. Loads a value of that type from the address, which is computed by adding the compile-time 32-bit signed integer offset to the child value. Misaligned loads are not penalized. Must use the MemoryValue class.
Void Store8(Int32, IntPtr, offset)
Stores a the low byte of the first child into the address computed by adding the compile-time 32-bit signed integer offset to the second child. Must use the MemoryValue class.
Void Store16(Int32, IntPtr, offset)
Stores a the low 16 bits of the first child into the address computed by adding the compile-time 32-bit signed integer offset to the second child. Misaligned stores are not penalized. Must use the MemoryValue class.
Void Store(T, IntPtr, offset)
Stores the value in the first child into the address computed by adding the compile-time 32-bit signed integer offset to the second child. Misaligned stores are not penalized. Must use the MemoryValue class.
T1 CCall(IntPtr, [T2, [T3, ...]])
Performs a C function call to the function pointed to by the first child. The types that the function takes and the type that it returns are determined by the types of the children and the type of the CCallValue. Only the first child is mandatory. Must use the CCallValue class.
T1 Patchpoint([T2, [T3, ...]])
A Patchpoint is a customizable value. Patchpoints take zero or more values of any type and return any type. A Patchpoint's behavior is determined by the generator object. The generator is a C++ lambda that gets called during code generation. It gets passed an assembler instance (specifically, CCallHelpers&) and an object describing where to find all of the input values and where to put the result. Here's an example:
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());
    });

This creates a patchpoint that just adds two numbers. The patchpoint is set to return Int32. The two child values, arg1 and arg2, are passed to the patchpoint with the SomeRegister constraint, which just requests that they get put in appropriate registers (GPR for integer values, FPR for floating point values). The generator uses the params object to figure out which registers the inputs are in (params[1] and params[2]) and which register to put the result in (params[0]). Many sophisticated constraints are possible. You can request that a child gets placed in a specific register. You can list additional registers that are clobbered - either at the top of the patchpoint (i.e. early) so that the clobbered registers interfere with the inputs, or at the bottom of the patchpoint (i.e. late) so that the clobbered registers interfere with the output. Patchpoint constraints also allow you to force values onto the call argument area of the stack. Patchpoints are powerful enough to implement custom calling conventions, inline caches, and side-exits.

A patchpoint is allowed to "side exit", i.e. abruptly exit from the procedure. If it wants to do so by returning, it can use Procedure's API for getting the callee-save register layout, unwinding the callee-saves, and then returning. More likely, the patchpoint will take some exit state as part of its arguments, and it will manipulate the call frame in place to make it look like another execution engine's call frame. This is called OSR, and JavaScriptCore does it a lot.

Must use the PatchpointValue class with the Patchpoint opcode.

T CheckAdd(T, T, [T2, [T3, ...]])
Checked integer addition. Works for T = Int32 or T = Int64. First first two children are mandatory. Additional children are optional. All of the Check instructions take a generator and value constraints like a Patchpoint. In the case of a CheckAdd, the generator runs on the path where the integer addition overflowed. B3 assumes that CheckAdd will side-exit upon overflow, so the generator must do some kind of termination. Usually, this is used to implement OSR exits on integer overflow and the optional arguments to CheckAdd will be the OSR exit state. Must use the CheckValue class.
T CheckSub(T, T, [T2, [T3, ...]])
Checked integer subtraction. Works for T = Int32 or T = Int64. First first two children are mandatory. Additional children are optional. All of the Check instructions take a generator and value constraints like a Patchpoint. In the case of a CheckSub, the generator runs on the path where the integer subtraction overflowed. B3 assumes that CheckSub will side-exit upon overflow, so the generator must do some kind of termination. Usually, this is used to implement OSR exits on integer overflow and the optional arguments to CheckSub will be the OSR exit state. You can use CheckSub for checked negation by using zero for the first child. B3 will select the native negate instruction when you do this. Must use the CheckValue class.
T CheckMul(T, T, [T2, [T3, ...]])
Checked integer multiplication. Works for T = Int32 or T = Int64. First first two children are mandatory. Additional children are optional. All of the Check instructions take a generator and value constraints like a Patchpoint. In the case of a CheckMul, the generator runs on the path where the integer multiplication overflowed. B3 assumes that CheckMul will side-exit upon overflow, so the generator must do some kind of termination. Usually, this is used to implement OSR exits on integer overflow and the optional arguments to CheckMul will be the OSR exit state. Must use the CheckValue class.
Void Check(T, [T2, [T3, ...]])
Exit check. Works for T = Int32 or T = Int64. This branches on the first child. If the first child is zero, this just falls through. If it's non-zero, it goes to the exit path generated by the passed generator. Only the first child is mandatory. B3 assumes that Check will side-exit when the first child is non-zero, so the generator must do some kind of termination. Usually, this is used to implement OSR exit checks and the optional arguments to Check will be the OSR exit state. Check supports efficient compare/branch fusion, so you can Check for fairly sophisticated predicates. For example, Check(Equal(LessThan(@a, @b), 0)) where @a and @b are doubles will be generated to an instruction that branches to the exit if @a >= @b or if either @a or @b are NaN. Must use the CheckValue class.
Void Upsilon(T, ^phi)
B3 uses SSA. SSA requires that each variable gets assigned to only once anywhere in the procedure. But that doesn't work if you have a variable that is supposed to be the result of merging two values along control flow. B3 uses Phi values to represent value merges, just like SSA compilers usually do. But while other SSA compilers represent the inputs to the Phi by listing the control flow edges from which the Phi gets its values, B3 uses the Upsilon value. Each Phi behaves as if it has a memory location associated with it. Executing the Phi behaves like a load from that memory location. Upsilon(@value, ^phi) behaves like a store of @value into the memory location associated with @phi. We say "^phi" when we mean that we are writing to the memory location associated with the Phi. Must use the UpsilonValue class.
T Phi()
Works for any type except Void. Represents a local memory location large enough to hold T. Loads from that memory location. The only way to store to that location is with Upsilon.
Void Jump(takenBlock)
Jumps to takenBlock. This is a ControlValue, so it must appear at the end of the basic block.
Void Branch(T, takenBlock, notTakenBlock)
Works for T = Int32 or T = Int64. Branches on the child. If the child is non-zero, it branches to the takenBlock. Otherwise it branches to the notTakenBlock. Must use the ControlValue class. Must appear at the end of the basic block.
Void Switch(T, cases...)
Works for T = Int32 or T = Int64. Switches on the child. Contains a list of switch cases. Each switch case has an integer constant and a target block. The switch value also contains a fall-through target in case the child has a value that wasn't mentioned in the cases list. Must use the SwitchValue class. Must appear at the end of the basic block.
Void Return(T)
Works for any type except Void. Returns the given value and terminates the procedure. This is a ControlValue, but it must have an empty successors list. Must appear at the end of the basic block.
Void Oops()
Indicates unreachable code. This may be implemented as either a trap or as a bare fall-through, since B3 is allowed to assume that this will never be reached. This is a ControlValue, but it must have an empty successors list. Must appear at the end of the basic block. Note that we also use the Oops opcode to mean "no such opcode" in some B3 transformations.