Instruction Set
Complete reference for the Esper BE instruction set with SSA operand semantics, encoding details, and backend mapping notes.
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:
- 8-bit opcode identifying the operation
- 4-bit result type (VOID for instructions that produce no value)
- 16-bit operand count
- 3 inline u32 operand slots (covers binary, unary, and ternary ops)
- Heap overflow pointer for CALL and spilled branches with 4+ operands
- 32-bit line and column for source mapping
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.