Types
This page describes the type system used by Esper BE for its intermediate representation.
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.
Esper BE has a deliberately small but expressive type system: a handful of primitive types plus user-defined aggregates (structs, unions, arrays, and aliases). The frontend remains responsible for type correctness; the IR carries just enough type information to drive code generation, ABI lowering, and target-specific optimizations.
| Type | Size | Description |
|---|---|---|
iN | ⌈N/8⌉ | Arbitrary-width integer, N in 1..65535, in declarations only |
i8 / i16 / i32 / i64 | 1/2/4/8 | Canonical integer widths (aliases for iN) |
f32 | 4 bytes | 32-bit IEEE 754 float |
f64 | 8 bytes | 64-bit IEEE 754 float |
ptr | target | 4 bytes (32-bit) or 8 bytes (64-bit) |
v128 | 16 bytes | 128-bit SIMD vector |
tuple | n/a | Multi-value bundle from multi-return calls |
void | 0 | No value (void instructions) |
| user types | computed | struct, union, array, alias (see below) |
Integers
Integers in Esper BE are spelled iN for any N in the range 1..65535. The
type reference reserves sixteen bits for the width, and i8, i16, i32 and
i64 are the canonical names for the four widths a machine has registers for.
Where a width off that list may be written is narrower than the representation
allows. A declaration takes any N: a struct or union member, an array’s
element type, an alias, a global’s type, and a function’s parameter and return
types.
type Flags = struct {
i1 enabled,
i3 priority,
i4 channel,
i24 reserved,
}
type Words = array i17[4]
type Handle = alias i33
const c0 i32 = 0
global @packed i17 = c0
An instruction’s type operand does not. It takes i8, i16, i32, i64,
f32, f64, ptr, v128, void, tuple, or the name of a user-defined
aggregate, so add i17 %a, %b and ldi i3 5 are parse errors. A const is the
same: there is no const c0 i17 = 5. A packed field is therefore reached by
naming the machine width of the storage unit it sits in and the offset within
it, which is what the byte= and bit= suffixes on load and store are for.
Signedness is encoded into the instruction (via mnemonic suffix like .s /
.u, or a dedicated opcode), not into the type. A single iN value can be
fed to either signed or unsigned instructions; the bit pattern is the same.
Arithmetic semantics
Planned. Arbitrary-width arithmetic is not lowered yet, since no instruction can name a width off the canonical four. What follows is the intended lowering, not what a backend emits today.
Arithmetic on iN is performed modulo 2^N. Backends round up to the nearest
native representation as needed and emit explicit masks on stores and on
ABI-visible boundaries:
| Backend | Lowering for iN |
|---|---|
| VM | Store in i32 or i64; mask after each arithmetic op |
| C | Round up to uint8 / uint16 / uint32 / uint64; explicit mask |
| LLVM | Native iN |
| WASM | Round up to i32 / i64; explicit mask |
| x86_64 | Native register; mask on memory-store and ABI boundaries |
| ARM64 | Native register; bfi / ubfx / sbfx on bitfield access |
| SPIR-V | Round up to i8 / i16 / i32 / i64 per Vulkan capabilities |
Sub-byte members and bitfields
iN with N < 8 participates in struct layout as a packed sub-byte field.
Adjacent sub-byte members share a containing byte (LSB-first packing); access
goes through the annotated load / store forms described in
IL & text format. The access names the
machine width of the byte it reads and a bit= offset within it, so a backend
lowers a load as a byte read, a shift and a mask, and a store as a read, mask,
shift and write back.
Floating Point
f32 and f64 follow IEEE 754. The f16 type is not currently supported; it
is planned as part of the GPU backend effort.
Pointers
The ptr type is target-sized and opaque. It is produced by allocations
(alloc, salloc, halloc), by gget of pointer-typed globals, and by
explicit bitcast from integers.
Pointer arithmetic is ordinary integer arithmetic; there is no dedicated
address opcode and no element-size scaling. add ptr %base, %offset is the
spelling to prefer, since it stays correct whatever the target’s pointer width
is. A machine-width integer type is accepted in the same position, so a
frontend that already knows the target may write add i64 on a 64-bit one, but
it then has to change with the target.
Vectors
The v128 type represents a 128-bit SIMD vector. The interpretation of the
lanes is determined by the instruction, not by the type itself. A v128 value
has no inherent lane layout; you can operate on the same value as i32x4 in
one instruction and f32x4 in the next.
SIMD instructions use lane descriptors to specify how the 128 bits are
partitioned. These are not types in the Esper BE type system; they are
instruction operands, written after the value operands rather than in the type
position, which stays v128: vadd v128 %0, %1, i32x4.
| Descriptor | Lanes | Lane width |
|---|---|---|
i8x16 | 16 | 8-bit integer |
i16x8 | 8 | 16-bit integer |
i32x4 | 4 | 32-bit integer |
i64x2 | 2 | 64-bit integer |
f32x4 | 4 | 32-bit float |
f64x2 | 2 | 64-bit float |
See the Instruction Set page for the full list of SIMD instructions.
Tuples
The tuple type marks an SSA value that bundles several values produced
together. It is produced by multi-return call instructions when the callee
has 2+ return values. Individual components are projected using extract:
%4 = call $divmod %0, %1 // tuple result
%5 = extract i32 %4, 0 // first component
%6 = extract i32 %4, 1 // second component
Tuples are not first-class values; they cannot be stored to memory, passed as
arguments, or used as operands to arithmetic instructions. Their sole purpose
is to carry multi-return results from a call to the extract projections
that decompose them.
Void
The void type is used in function signatures to indicate that a function
does not return a value, and internally to mark SSA id slots consumed by
instructions that produce no result (stores, jumps, etc.). Void-typed SSA
slots must never be referenced as operands.
User-defined Aggregates
Aggregate types are declared at the unit’s top level with the type keyword.
The four aggregate kinds are:
struct— sequentially laid out members, each with its own type and offsetunion— overlapping members all sharing offset 0array—Nrepeats of a single element typealias— a named handle for another type
Aggregate types use bare names (no sigil), distinguishing them from function
references ($name) and global references (@name).
Structs
type Point = struct {
f32 x,
f32 y,
}
Members are listed in declaration order. The layout helper packs members at
their natural alignment; adjacent sub-byte members (iN with N < 8) share
a containing byte, LSB-first:
type Flags = struct {
i1 enabled,
i3 priority,
i4 channel,
i24 reserved,
}
Flags occupies exactly 4 bytes. enabled is bit 0, priority is bits 1..3,
channel is bits 4..7, and reserved fills bytes 1..3.
Unions
type Variant = union {
i32 as_int,
f32 as_float,
ptr as_ptr,
}
All union members start at offset 0. Reading one member’s type from a value
that was written through a different member’s type performs a bit-level
reinterpretation (the IR-level equivalent of a bitcast).
Arrays
type Matrix4 = array Point[16]
type Bytes32 = array i8[32]
The element type may be any other type (primitive or aggregate). The element
count is a compile-time u32 literal.
Aliases
type Handle = alias i64
type PointAlias = alias Point
alias declarations give a new name to an existing type. Aliases are
transparent: they participate in type checking by their underlying type.
Debug locations
Type declarations and individual members may carry @ "file":line:column
annotations that round-trip through the parser and printer:
type Point @ "geom.f":12:1 = struct {
f32 x @ "geom.f":13:3,
f32 y @ "geom.f":14:3,
}
Forward references
Aggregate members may reference types declared later in the same unit. The parser’s fixup queue resolves these once the full unit is parsed.
First-class SSA values
Aggregates flow through SSA the same as primitives: they can be the result of
construction opcodes (struct.new, array.new), they can be projected with
field / array.elem, they can be updated with field.set (producing a new
aggregate value), they can travel as a branch argument, and they can be loaded
from or stored to memory as a whole.
%3 = struct.new Point %x, %y
%4 = field f32 %3, 0 // %3.x by index
%5 = field.set %3, 0, %new_x // updated copy
%6 = array.new Bytes32 %b0, %b1, ... // const-count construction
%7 = array.elem i8 %6, 5 // const-index projection
%a = load Point %ptr
store Point %ptr, %a
The full IL surface for aggregates (including aggregate constants and the
annotated load / store forms used for sub-byte and field-aware access)
is described in IL & text format.