This page describes the current SSA builder and backend implementation. Its unversioned esp examples are compatibility snapshots, still accepted by the reader. Start with the new text walkthrough for esper 1 syntax and the roadmap for the library-first migration boundary.

Note: Esper BE is a work in progress and all instructions are subject to change.

Instruction encoding

Every instruction is stored as an EsperInst struct with:

Most instructions fit in the 3 inline slots with zero heap allocation. CALL with many operands overflow to a heap-allocated array. The SSA result id of each instruction is implicit: param_count + inst_index.

SSA value model

Esper BE is a native SSA IR. Every instruction defines exactly one value, identified by a numeric SSA id (%0, %1, …). Instructions that produce no useful result (stores, jumps, ret) still consume an id slot typed void, maintaining the invariant value_count == param_count + inst_count.

Values are never mutated after definition. Control-flow merge points are written as block parameters, which the branch taking the edge supplies.

Loading values

ldi (load immediate)

%2 = ldi i32 42   // %2 = 42
%3 = ldi i32 -1   // %3 = -1
%4 = ldi i64 7    // the immediate is sign-extended to the named width

Produces a value from a signed 32-bit literal held directly in the instruction’s operand slot. The type operand is required and names the result type; a wider type sign-extends the immediate. Anything that does not fit in 32 bits goes in the constant pool and is loaded with ldc.

ldc (load constant)

%2 = ldc f64 c0
%3 = ldc i64 c1
%4 = ldc Bytes32 c2

Produces a typed value from the constant pool, naming the entry rather than repeating the literal. Used for float values, integers too wide for ldi’s 32-bit slot, vectors, and aggregate constants.

Integer arithmetic

All integer arithmetic instructions take a type suffix and two operands. Results wrap modulo 2^N where N is the bit width.

add / sub / mul

%4 = add i32 %0, %1    // %4 = %0 + %1
%5 = sub i32 %0, %1    // %5 = %0 - %1
%6 = mul i32 %0, %1    // %6 = %0 * %1

div.s / div.u / rem.s / rem.u

%4 = div.s i32 %0, %1  // signed division, truncates toward zero
%5 = div.u i32 %0, %1  // unsigned division
%6 = rem.s i32 %0, %1  // signed remainder (sign follows dividend)
%7 = rem.u i32 %0, %1  // unsigned remainder

neg

%3 = neg i32 %0        // two's complement negation

Float arithmetic

Float instructions are prefixed with f and operate on f32 or f64 values following IEEE 754 semantics.

fadd / fsub / fmul / fdiv

%4 = fadd f64 %0, %1
%5 = fsub f64 %0, %1
%6 = fmul f64 %0, %1
%7 = fdiv f64 %0, %1

fneg / fabs / fsqrt / fceil / ffloor

%3 = fneg f64 %0       // flip sign bit
%4 = fabs f64 %0       // clear sign bit
%5 = fsqrt f64 %0
%6 = fceil f64 %0
%7 = ffloor f64 %0

fmin / fmax

%4 = fmin f64 %0, %1   // IEEE 754 minimum
%5 = fmax f64 %0, %1   // IEEE 754 maximum

Bitwise operations

and / or / xor / not

%4 = and i32 %0, %1
%5 = or  i32 %0, %1
%6 = xor i32 %0, %1
%7 = not i32 %0

shl / shr.s / shr.u

%4 = shl   i32 %0, %1  // logical left shift
%5 = shr.s i32 %0, %1  // arithmetic right shift (sign-extending)
%6 = shr.u i32 %0, %1  // logical right shift (zero-extending)

Shift amounts are masked to the type width minus one (e.g., b & 31 for i32).

rotl / rotr

%4 = rotl i32 %0, %1   // rotate left
%5 = rotr i32 %0, %1   // rotate right

clz / ctz / popcnt

%3 = clz    i32 %0     // count leading zeros
%4 = ctz    i32 %0     // count trailing zeros
%5 = popcnt i32 %0     // population count

For clz and ctz, input zero yields the bit width (e.g., 32 for i32).

bswap / bitrev

%3 = bswap  i32 %0     // reverse the byte order
%4 = bitrev i32 %0     // reverse the bit order

Both reverse within the value’s own width, which is the part that is easy to get wrong: an i8 bswap is the identity and an i16 swaps two bytes, not eight.

bswap  i32 0x00000001  ->  0x01000000
bitrev i32 0x00000001  ->  0x80000000

Reading a value written by a machine of the other endianness is what bswap is for; without it a frontend emits a shift-and-or sequence the backends then have to recognize. LLVM names both operations directly. The C backend uses __builtin_bswap for the three widths the host names and emits a small static helper otherwise, rather than calling into Esper’s runtime: the generated C is handed to a compiler, not to a linker that also has libesper.

Integer comparison

All comparison instructions produce an i32 result (0 or 1). eq, ne, and eqz are sign-agnostic. Ordered comparisons (lt, gt, le, ge) come in .s (signed) and .u (unsigned) variants.

%4 = eq    i32 %0, %1  // %4 = (%0 == %1)
%5 = ne    i32 %0, %1  // %5 = (%0 != %1)
%6 = eqz   i32 %0      // %6 = (%0 == 0)
%7 = lt.s  i32 %0, %1  // signed less-than
%8 = lt.u  i32 %0, %1  // unsigned less-than
%9 = gt.s  i32 %0, %1  // signed greater-than
%10 = ge.u i32 %0, %1  // unsigned greater-or-equal

Float comparison

Float comparisons follow IEEE 754 semantics and produce i32 results. Comparisons involving NaN always return 0 (false), except fne which returns 1 (true) when either operand is NaN.

%4 = feq f64 %0, %1
%5 = fne f64 %0, %1
%6 = flt f64 %0, %1
%7 = fgt f64 %0, %1
%8 = fle f64 %0, %1
%9 = fge f64 %0, %1

Type conversions

Conversion instructions use the type annotation for the destination type. The source type is implicit from the operand.

extend.s / extend.u (integer widening)

%3 = extend.s i32 %0   // sign-extend to i32
%4 = extend.u i32 %0   // zero-extend to i32

trunc (integer narrowing)

%3 = trunc i8 %0       // truncate to i8 (high bits discarded)

f2i.s / f2i.u (float to integer)

%3 = f2i.s i32 %0      // float to signed integer, truncate toward zero
%4 = f2i.u i32 %0      // float to unsigned integer

i2f.s / i2f.u (integer to float)

%3 = i2f.s f64 %0      // signed integer to float
%4 = i2f.u f64 %0      // unsigned integer to float

fconv (float width conversion)

%3 = fconv f64 %0      // f32 -> f64 (widen, lossless)
%4 = fconv f32 %0      // f64 -> f32 (narrow, may lose precision)

bitcast (bit reinterpretation)

%3 = bitcast f32 %0    // reinterpret i32 bits as f32 (zero-cost)
%4 = bitcast i64 %0    // reinterpret f64 bits as i64

Source and destination must have the same byte size.

Control flow

jmp (unconditional jump)

  jmp loop_body
  jmp loop_body(%3, %4)

Transfers control to the named block, carrying the arguments that block’s parameters take. A target with no parameters takes no list. Up to two arguments are stored inside the instruction; beyond that the encoding spills to the heap, which the text form does not distinguish.

jmp.if (conditional branch)

  jmp.if %cond then_block else_block
  jmp.if %cond then_block(%3) else_block(%4, %5)

If %cond is nonzero, jumps to then_block. Otherwise jumps to else_block. Each arm carries its own argument list, and either may be empty. Arguments on a conditional branch always use the spilled encoding, since the inline slots are taken by the condition and the two targets.

jmp.table (branch table)

  jmp.table %idx [case0, case1, case2] default_block

Jumps to case[%idx] if in range, otherwise jumps to default_block. A branch table carries no arguments, so none of its targets may declare block parameters.

ret (return)

  ret i32 %result            // single return value
  ret i32 %a, i32 %b         // multiple return values
  ret                        // void return

Returns from the current function. Each return value is annotated with its type.

trap (stop the program)

  trap

Ends the block by aborting the program. This is what a bounds check, an overflow check or an exhausted match arm branches to when the language says execution must not continue. Without it a frontend can only call abort through an extern, which the optimizer cannot reason about and which costs a call at every check.

trap takes no operands and produces no value. It is a terminator, so nothing falls through it.

The C backend emits __builtin_trap(), LLVM emits llvm.trap followed by unreachable, and the machine-code backend calls esp_trap, which aborts: it has no trapping instruction of its own to emit, and stopping is what the interpreter does, so the differential sweeps still compare like for like.

unreachable (assert control never arrives)

  unreachable

Ends the block by asserting that control never gets there. Unlike trap it emits nothing, and reaching it is undefined, which is exactly what lets a pass prune the path leading to it and a backend drop the guard that protected it. See unreachable-arm folding for the transform that pays for it existing.

Also a nullary terminator. The C backend emits __builtin_unreachable() and LLVM a bare unreachable, both of which tell the compiler below to drop the branch that guarded the path. The machine-code backend has nothing to assert with, so it calls esp_trap as it does for a trap: an unreachable is free to do anything, and stopping is what the interpreter does, which keeps the differential sweeps comparing like for like. The interpreter cannot have undefined behaviour, so an unreachable faults there rather than doing nothing.

Use trap when the language says the program must stop, and unreachable when the frontend has already proved the path impossible. Spelling an impossible-but-checked path trap costs a branch that unreachable does not.

Select

%5 = select i32 %cond, %a, %b   // %5 = %cond ? %a : %b

Branchless conditional select. Always evaluates both sides.

Block parameters

loop(%6 i32, %7 i32):
  ...
  jmp.if %10 loop(%7, %6) done(%6)

A block parameter is a value the branches into that block supply. It is how a merge is written: on the edge, rather than in a list of predecessors held by the block.

Parameters are declared on the label line and are instructions in their own right, so each takes an SSA id by position, exactly like every other instruction. They occupy the leading run of the block. A parameter has no operands: it names no predecessor and stores no incoming values.

The printed form gives them no line of their own, since the label line already says the id and the type. A param <type> line does appear when a parameter is found below the leading run, so that a malformed unit is visible rather than silently short an instruction. The parser does not accept that spelling; a parameter is only ever written on a label.

What this buys. The alternative, a node in the merge block naming each predecessor it takes a value from, duplicates part of the control-flow graph inside an instruction, and the copy can go stale: rerouting an edge means finding and fixing every such node in the target, and a pass that forgets leaves one naming a predecessor that no longer branches there. An argument on a branch cannot go stale, because moving the branch moves it. The verifier is correspondingly short: it checks that each edge passes as many arguments as its target declares, that the types agree, and that parameters sit at the top of their block.

Two edges carry nothing, so neither may enter a block that takes parameters: a fallthrough, which has no branch to put arguments on, and a jmp.table arm, whose targets are a shared list with no room for a value per arm.

Function calls

call (direct call)

%5 = call i32 $add, %0, %1          // single-return call
%6 = call $divmod, %0, %1           // multi-return (tuple result)
%7 = extract i32 %6, 0              // project first return value
%8 = extract i32 %6, 1              // project second return value

The callee reference is followed by a comma, and then the arguments, also comma separated. For single-return calls, the return type appears between call and the function name. For multi-return calls (where the callee returns 2+ values), the type may be omitted; the result is typed tuple, and the printer writes that type back out as call tuple $divmod, %0, %1. Use extract to project individual components.

call.ind (indirect call)

sig (i32, i32) -> i32             // at top level, entry 0

%5 = call.ind i32 %target, 0, %arg1, %arg2   // in a function body

Calls through a runtime-determined function index. The first operand is the target, the second is an index into the unit’s signature table, and the arguments follow. There is no callee to read an arity or a parameter type off, so the shape of the call is declared by a top-level sig form and named by index; declaration order is the numbering.

Extract

%5 = extract i32 %tuple_val, 0
%6 = extract f64 %tuple_val, 1

Projects a component from a tuple-typed value. The component index is a literal integer. The result type must match the callee’s corresponding return type.

Memory access

load (typed memory load)

%3 = load i32 %addr

Loads a value from memory at the given pointer address.

store (typed memory store)

  store i32 %addr, %val

Stores a value to memory. No result produced.

Narrow loads and stores

%3 = load8.s  i32 %addr    // load 8 bits, sign-extend to i32
%4 = load16.u i32 %addr    // load 16 bits, zero-extend to i32
  store8  i32 %addr, %val  // truncate to 8 bits and store
  store16 i32 %addr, %val  // truncate to 16 bits and store

The width sits on the mnemonic and the extension follows it: load8.s, load8.u, load16.s, load16.u. A store discards the high bits rather than extending, so store8 and store16 carry no sign.

Volatile loads and stores

%3 = load i32 %addr, volatile
  store i32 %addr, %val, volatile
%4 = load8.u i32 %addr, volatile
%5 = load i32 %addr align=4, volatile
  store i32 %addr, %val, volatile, byte=4

volatile is a flag on the existing access, not a separate opcode, and it applies to load, store and every narrow form. It says the access itself is the observable effect: performing it is the point, and its result is not what makes it live. Without it a frontend targeting memory-mapped hardware cannot use Esper safely, because the optimizer would delete a load whose result goes unused, merge two loads of one address, hoist one out of a loop, and promote the slot into a register.

No pass may remove a volatile access, merge two of them, or move one across a loop boundary, and a stack slot touched by one is never promoted to an SSA value. Optimization Passes states this per pass: DCE keeps an unused volatile load, CSE and GVN give one a congruence class of its own, LICM leaves it in the loop, and mem2reg declines any slot one touches.

Every backend lowers it. The C backend emits *(int32_t volatile *)p, with the qualifier between pointee and star because ptr spells itself void * and the naive prefix would qualify the wrong level. LLVM emits load volatile and store volatile. The machine-code backend needs no marking of its own: it emits one machine access per IR access in program order, and the register allocator below it neither forwards a load, caches a loaded value, nor reorders memory. Nor is there a later compiler to constrain, the way the two text backends must qualify what they hand on.

The flag sits in the same comma-separated suffix as byte= and bit=, after any align=, and may be written in any order among them. The printer emits it first in that list, so a unit reprints as itself.

Atomic operations

%3 = atomic.load i32 %addr, seq_cst
  atomic.store i32 %addr, %val, release
%4 = atomic.rmw i32 add %addr, %val, acq_rel        // returns old value
%5 = atomic.cmpxchg i32 %addr, %expected, %new, seq_cst
  fence seq_cst

The ordering is the last operand and is separated by a comma like any other. atomic.rmw names its operation between the type and the address: add, sub, and, or, xor, or xchg.

Memory orderings: relaxed, acquire, release, acq_rel, seq_cst.

Memory management

halloc / salloc / alloc / free

%3 = halloc %size          // heap allocation (calls runtime esp_halloc)
%4 = salloc %size          // stack allocation (freed on function return)
%5 = alloc %size           // automatic: lowered to salloc or halloc by escape analysis
  free %ptr                // heap deallocation

alloc is the “smart” allocator. An escape analysis pass classifies each alloc result: non-escaping pointers are lowered to salloc, escaping pointers to halloc with compiler-inserted free at the last use (when the lifetime is bounded).

Bulk memory

  memcpy  %dst, %src, %len
  memmove %dst, %src, %len
  memset  %dst, %byte, %len

Copying a struct and clearing a buffer are the two things a frontend would otherwise emit a byte loop for, and a loop is the one shape the backends cannot improve on. Given the operation by name, the C backend hands it to libc and the host optimizer, LLVM gets the intrinsic it knows how to turn into wide stores, and the machine-code backend calls the same libc entry point the C backend does.

All three take three SSA operands and no type, since they count bytes whatever their operands were declared as. memset writes the low byte of its second operand. None produces a value, so a use count says nothing about whether it matters; all three are pinned in DCE’s side-effect list.

memcpy is undefined when the regions overlap, exactly as in C; use memmove when they may. The interpreter faults on a negative length rather than handing it to libc as a huge unsigned one, and returns early on zero, where libc still requires valid pointers.

SIMD / Vector operations

SIMD instructions operate on v128 values. The type operand names the result type, as everywhere else, and the lane descriptor that says how to read the 128 bits comes after the value operands.

%4 = vadd  v128 %0, %1, i32x4   // per-lane integer add
%5 = vfmul v128 %0, %1, f32x4   // per-lane float multiply
%6 = vand  v128 %0, %1          // full 128-bit bitwise AND, no lanes to name
%7 = vneg  v128 %0, i32x4       // per-lane negate
%8 = vsplat v128 %0, i32x4      // broadcast a scalar to all lanes
%9 = vext  i32 %4, i32x4, 2     // read lane 2 out as a scalar
%10 = vldm v128 %addr           // load 128 bits from memory
  vstm void %addr, %val         // store 128 bits to memory

vand, vor, vxor and vnot work on the whole register and take no lane descriptor. vext is the one instruction whose type operand is the scalar it produces rather than v128, and it takes the lane index as a literal after the descriptor. vstm is void, so it names void where the others name their result type.

See the Types page for lane descriptor details.

System calls

%3 = syscall0 i32 %num
%4 = syscall2 i32 %num, %a1, %a2

Invoke a host system call. The type operand names the result type; the syscall number is the first value operand, followed by up to 6 arguments. Variants syscall0 through syscall6 encode the argument count in the opcode.

Aggregate value operations

Aggregate SSA values (structs, unions, arrays) are first-class. They are constructed, projected, and updated with the following instructions. Aggregate values may flow through load, store, branch arguments, and function call/return boundaries like any primitive.

struct.new (aggregate construction)

%3 = struct.new Point %x, %y          // construct a Point from two f32s
%4 = struct.new Pair %a, %b           // construct a Pair from two i32s

Produces an aggregate SSA value from its member values, supplied in declaration order. The type operand is the aggregate type name (declared via type). The operand count must match the member count of the type. For large aggregates (4+ members), operands overflow to heap storage.

field (member extraction)

%4 = field f32 %3, 0                  // extract member 0 (type from typedef)
%5 = field i32 %pair, 1               // extract member 1

Extracts a single member from an aggregate SSA value by constant index (0-based, in declaration order). The result type is the member’s declared type from the aggregate typedef. On a union, field returns the indexed member’s declared type; the backend emits an internal bitcast if the runtime representation differs.

field.set (member replacement)

%5 = field.set %3, 0, %new_x          // produce updated copy with member 0 replaced

Produces a new aggregate value identical to the input except that member at the given constant index is replaced with the new value. The result type matches the input aggregate type. The original aggregate is not mutated (SSA semantics).

array.new (array construction)

%6 = array.new Bytes4 %b0, %b1, %b2, %b3   // construct from elements

Produces an array-typed aggregate from element values, supplied in index order. The type operand is the array type name. The operand count must equal the array’s element count.

array.elem (element extraction)

%7 = array.elem i8 %6, 2              // extract element at constant index 2

Extracts a single element from an array-typed aggregate by compile-time-constant index. Runtime indexing is not supported; use pointer arithmetic plus load for dynamic access (base + i * stride followed by a typed load).

Annotated load and store

load and store accept two optional decorations for sub-byte access and debug annotation. These extend the base load / store syntax described under Memory access.

Byte and bit offset immediates

The byte=N, bit=N immediates specify an access offset within the loaded or stored memory region: byte= is a displacement in bytes, bit= a further one in bits. The access width comes from the instruction’s type operand. When both immediates are zero, they are omitted from the printed form. Sign-extension after a sub-byte load is performed at the use site via the existing extend.s instruction.

%v = load i8  %ptr, bit=4             // read the field four bits up in the byte
store i8  %ptr, %r, bit=4             // write it back without disturbing its neighbours
%w = load i32 %ptr, byte=4            // read four bytes at offset 4

The type operand is one of the machine widths, not an arbitrary iN: iN is a member declaration spelling, and a packed field is reached by naming the storage unit it sits in and the offset within it. A byte= displacement is something the addressing mode already takes; a bit= one is not, and a backend lowers it as a byte read, a shift and a mask for a load, and a read, mask, shift and write back for a store.

Type and field annotations

The [type=Name, field=Name] annotation records which aggregate member is being accessed. The annotation is debug-info only and has no codegen effect; it preserves the original field intent through optimizations and debug output.

%v = load i32 %ptr [type=Point, field=x]
%v = load i8  %ptr, byte=0, bit=1 [type=Flags, field=priority]
store i32 %ptr, %r, byte=1 [type=Flags, field=reserved]

Special instructions

nop

  nop

No operation. Optimization passes may remove nop freely.

select

See Select above.