Structs and Fields
How to lower struct types and field access to Esper IR using first-class aggregates or flat memory.
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 supports user-defined struct types as first-class SSA values. A struct
flows through registers, passes through function signatures, participates in
branch arguments, and round-trips through load/store as a single typed value.
For frontends that prefer to keep full control over layout (or that want to
target the older flat-memory style), the original salloc + load / store
approach still works and remains the right tool for very large or variably
sized records.
This guide covers both styles, starting with the recommended first-class approach.
First-class struct lowering
What we’re lowering
struct Point {
var x: Int32
var y: Int32
}
func makePoint(x: Int32, y: Int32) -> Point {
return Point(x: x, y: y)
}
func translate(p: Point, dx: Int32, dy: Int32) -> Point {
return Point(x: p.x + dx, y: p.y + dy)
}
Lowering walkthrough
- Declare the type once at the unit’s top level. A frontend building
through the API calls
esper_unit_add_struct, which names the struct, lists its members in order, and returns the index that everything else addresses it by. The Esper layout helper computes byte offsets and total size before the call returns; the frontend does not need to compute them by hand. - Construct with
struct.new: pass member values in declaration order. The result is a single SSA value of the struct’s type. - Project with
field: read a member by 0-based index. The result type is taken from the typedef. - Update with
field.set: produce a new struct value with one member replaced. The original SSA value is unchanged (SSA immutability).
Esper lowering
A type table index is not itself a type. To name the struct where a type is
expected, as a parameter, a return, or another struct’s member, wrap the index
with esper_tref_agg. What comes back is an EsperTypeRef, which is what
every entry point that takes a type takes, so it goes wherever ESP_TYPE_I32
would.
static void esper_doc_build(EsperUnit *unit) {
EsperTypeRef members[2] = {ESP_TYPE_I32, ESP_TYPE_I32};
const char *names[2] = {"x", "y"};
uint32_t point = esper_unit_add_struct(unit, "Point", members, names, 2, false);
// An aggregate names itself as a type through its table index.
EsperTypeRef point_ty = esper_tref_agg(point);
EsperTypeRef make_params[2] = {ESP_TYPE_I32, ESP_TYPE_I32};
EsperTypeRef rets[1] = {point_ty};
EsperBuilder *b = esper_build_begin(unit, "make_point", make_params, 2, rets, 1);
esper_build_bind(b, esper_build_block(b, "entry"));
EsperVal fields[2] = {esper_build_param(b, 0), esper_build_param(b, 1)};
EsperVal p = esper_build_struct_new(b, point, fields, 2);
esper_build_ret(b, &p, 1);
if (!esper_build_end(b))
fprintf(stderr, "make_point: %s\n", esper_builder_last_error(b));
esper_builder_free(b);
EsperTypeRef xlate_params[3] = {point_ty, ESP_TYPE_I32, ESP_TYPE_I32};
b = esper_build_begin(unit, "translate", xlate_params, 3, rets, 1);
esper_build_bind(b, esper_build_block(b, "entry"));
// A field read names the member by index, and carries its type.
EsperVal in = esper_build_param(b, 0);
EsperVal x = esper_build_field(b, ESP_TYPE_I32, in, 0);
EsperVal y = esper_build_field(b, ESP_TYPE_I32, in, 1);
EsperVal moved[2];
moved[0] = esper_build_binary(b, ESP_OP_ADD, ESP_TYPE_I32, x,
esper_build_param(b, 1));
moved[1] = esper_build_binary(b, ESP_OP_ADD, ESP_TYPE_I32, y,
esper_build_param(b, 2));
EsperVal out = esper_build_struct_new(b, point, moved, 2);
esper_build_ret(b, &out, 1);
if (!esper_build_end(b))
fprintf(stderr, "translate: %s\n", esper_builder_last_error(b));
esper_builder_free(b);
}type Point = struct {
i32 x,
i32 y,
}
func $make_point(%0 i32, %1 i32) -> Point {
entry:
%2 = struct.new Point %0, %1
ret Point %2
}
func $translate(%0 Point, %1 i32, %2 i32) -> Point {
entry:
%3 = field i32 %0, 0
%4 = field i32 %0, 1
%5 = add i32 %3, %1
%6 = add i32 %4, %2
%7 = struct.new Point %5, %6
ret Point %7
}Aggregate parameters and return values lower through the target’s native ABI.
On SysV x86_64 a Point (8 bytes) passes in a single register; on AArch64
AAPCS64 it passes in two w registers as an HFA-eligible aggregate. The
frontend does not need to know either rule; the backend resolves them from
the type declaration alone.
Passing structs to functions
An aggregate argument is an ordinary SSA value, so esper_build_call takes it
the way it takes an integer. The signature is what makes the parameter a
struct, and the call site does nothing special:
static void esper_doc_build(EsperUnit *unit) {
EsperTypeRef members[2] = {ESP_TYPE_I32, ESP_TYPE_I32};
const char *names[2] = {"x", "y"};
uint32_t point = esper_unit_add_struct(unit, "Point", members, names, 2, false);
EsperTypeRef point_ty = esper_tref_agg(point);
EsperTypeRef i32_ret[1] = {ESP_TYPE_I32};
EsperBuilder *b = esper_build_begin(unit, "manhattan", &point_ty, 1, i32_ret, 1);
esper_build_bind(b, esper_build_block(b, "entry"));
EsperVal p = esper_build_param(b, 0);
EsperVal x = esper_build_field(b, ESP_TYPE_I32, p, 0);
EsperVal y = esper_build_field(b, ESP_TYPE_I32, p, 1);
EsperVal sum = esper_build_binary(b, ESP_OP_ADD, ESP_TYPE_I32, x, y);
esper_build_ret(b, &sum, 1);
if (!esper_build_end(b))
fprintf(stderr, "manhattan: %s\n", esper_builder_last_error(b));
esper_builder_free(b);
EsperTypeRef caller_params[2] = {ESP_TYPE_I32, ESP_TYPE_I32};
b = esper_build_begin(unit, "origin_dist", caller_params, 2, i32_ret, 1);
esper_build_bind(b, esper_build_block(b, "entry"));
// The aggregate is an ordinary SSA value, so it is passed like one.
EsperVal fields[2] = {esper_build_param(b, 0), esper_build_param(b, 1)};
EsperVal arg = esper_build_struct_new(b, point, fields, 2);
EsperVal dist = esper_build_call(b, ESP_TYPE_I32, "manhattan", &arg, 1);
esper_build_ret(b, &dist, 1);
if (!esper_build_end(b))
fprintf(stderr, "origin_dist: %s\n", esper_builder_last_error(b));
esper_builder_free(b);
}type Point = struct {
i32 x,
i32 y,
}
func $manhattan(%0 Point) -> i32 {
entry:
%1 = field i32 %0, 0
%2 = field i32 %0, 1
%3 = add i32 %1, %2
ret i32 %3
}
func $origin_dist(%0 i32, %1 i32) -> i32 {
entry:
%2 = struct.new Point %0, %1
%3 = call i32 $manhattan, %2
ret i32 %3
}A callee that returns a struct is named the same way: pass the aggregate’s
EsperTypeRef as the type argument to esper_build_call, exactly as it
appears in the callee’s return list.
The backend chooses register vs. stack passing per the target’s ABI. Small structs (≤ 16 bytes on SysV, HFA-eligible on AAPCS64) flow through registers; larger ones are passed via a hidden pointer.
Nested structs
A member whose type is another struct is declared the same way a parameter of
that type is, so a frontend that already has a type index for Point has
everything it needs to build Line. Reading through the nesting is
member-by-member projection, and field.set writes through it by producing a
new outer value rather than mutating the old one:
static void esper_doc_build(EsperUnit *unit) {
EsperTypeRef coords[2] = {ESP_TYPE_I32, ESP_TYPE_I32};
const char *coord_names[2] = {"x", "y"};
uint32_t point = esper_unit_add_struct(unit, "Point", coords, coord_names, 2,
false);
// A member's type is written the same way a parameter's is.
EsperTypeRef point_ty = esper_tref_agg(point);
EsperTypeRef ends[2] = {point_ty, point_ty};
const char *end_names[2] = {"start", "end"};
uint32_t line = esper_unit_add_struct(unit, "Line", ends, end_names, 2, false);
EsperTypeRef line_ty = esper_tref_agg(line);
EsperTypeRef rets[1] = {ESP_TYPE_I32};
EsperBuilder *b = esper_build_begin(unit, "end_y", &line_ty, 1, rets, 1);
esper_build_bind(b, esper_build_block(b, "entry"));
EsperVal end = esper_build_field(b, point_ty, esper_build_param(b, 0), 1);
EsperVal y = esper_build_field(b, ESP_TYPE_I32, end, 1);
esper_build_ret(b, &y, 1);
if (!esper_build_end(b))
fprintf(stderr, "end_y: %s\n", esper_builder_last_error(b));
esper_builder_free(b);
EsperTypeRef move_params[2] = {line_ty, point_ty};
EsperTypeRef move_rets[1] = {line_ty};
b = esper_build_begin(unit, "move_start", move_params, 2, move_rets, 1);
esper_build_bind(b, esper_build_block(b, "entry"));
// A new aggregate, not a mutation: the parameter still holds the old one.
EsperVal moved = esper_build_field_set(b, esper_build_param(b, 0), 0,
esper_build_param(b, 1));
esper_build_ret(b, &moved, 1);
if (!esper_build_end(b))
fprintf(stderr, "move_start: %s\n", esper_builder_last_error(b));
esper_builder_free(b);
}type Point = struct {
i32 x,
i32 y,
}
type Line = struct {
Point start,
Point end,
}
func $end_y(%0 Line) -> i32 {
entry:
%1 = field Point %0, 1
%2 = field i32 %1, 1
ret i32 %2
}
func $move_start(%0 Line, %1 Point) -> Line {
entry:
%2 = field.set %0, 0, %1
ret Line %2
}The optimizer (SROA, GVN, field-of-struct.new const-fold) collapses chains of
field projections through struct.new and field.set so that intermediate
aggregate values never need to materialize at runtime.
Two types that name each other cannot both be complete when the first is
declared. esper_unit_declare_type hands out an index for an empty aggregate,
and esper_unit_type_add_member fills it in afterwards; the layout is not
computed until esper_type_layout runs. A frontend whose types are already
ordered never needs either.
Bitfields and sub-byte members
Members typed iN with N < 8 pack LSB-first into the smallest containing
byte. There is no separate bitfield syntax; the integer width itself encodes
the bit packing. A builder names those widths with esper_tref_make, which
takes a bit count rather than one of the ESP_TYPE_* constants.
Once the aggregate is in memory, the annotated load/store
forms read and write an individual sub-byte member. EsperMemAccess carries
what they need: byte_offset and bit_offset say where in the storage the
member sits, and type_index plus member_index say which member it is, so
the attribution survives into the debug information.
/// A record of sub-byte members, read back through the offsets the
/// layout helper computed rather than ones written by hand.
static void esper_doc_build(EsperUnit *unit) {
EsperTypeRef members[4] = {
esper_tref_make(ESP_TKIND_INT, 1),
esper_tref_make(ESP_TKIND_INT, 3),
esper_tref_make(ESP_TKIND_INT, 4),
esper_tref_make(ESP_TKIND_INT, 24),
};
const char *names[4] = {"enabled", "priority", "channel", "reserved"};
uint32_t flags = esper_unit_add_struct(unit, "Flags", members, names, 4,
false);
EsperTypeRef params[1] = {ESP_TYPE_PTR};
EsperTypeRef rets[1] = {ESP_TYPE_I32};
EsperBuilder *b = esper_build_begin(unit, "priority_of", params, 1, rets, 1);
esper_build_bind(b, esper_build_block(b, "entry"));
// A sub-byte member has a bit offset as well as a byte offset, and
// esper_member_offset returns both.
uint8_t bit = 0;
EsperMemAccess at = {0};
at.type_index = flags;
at.member_index = 1;
at.byte_offset = esper_member_offset(unit, esper_tref_agg(flags), 1, &bit);
at.bit_offset = bit;
EsperTypeRef i3 = esper_tref_make(ESP_TKIND_INT, 3);
EsperVal narrow = esper_build_load_at(b, ESP_OP_LOAD, i3,
esper_build_param(b, 0), &at);
EsperVal wide = esper_build_unary(b, ESP_OP_EXTEND_U, ESP_TYPE_I32, narrow);
esper_build_ret(b, &wide, 1);
if (!esper_build_end(b))
fprintf(stderr, "priority_of: %s\n", esper_builder_last_error(b));
esper_builder_free(b);
}type Flags = struct {
i1 enabled,
i3 priority,
i4 channel,
i24 reserved,
}
func $priority_of(%0 ptr) -> i32 {
entry:
%1 = load i3 %0, bit=1 [type=Flags, field=priority]
%2 = extend.u i32 %1
ret i32 %2
}Flags is 8 bytes with 4-byte alignment. The first three members share byte 0:
bit 0 is enabled, bits 1..3 are priority, bits 4..7 are channel. But
reserved is i24, which aligns to 4, so it starts at byte 4 rather than
byte 1 and the type rounds up to 8. This is exactly the kind of arithmetic
worth asking esper_member_offset for instead of doing by hand.
The text format’s instruction types are i8, i16, i32 and i64. A
narrower width is writable in a type declaration and buildable through the
API, and the printer emits it, but the parser will not read load i3 back.
Aggregate constants
Struct constants are declared with const and use a brace initializer in
either positional or named form. The bytes are laid out by the layout helper
into a per-unit side blob:
const c0 Point = { 0, 0 }
const c1 Point = { x = 1, y = 2 }
const c2 Line = { start = { 0, 0 }, end = { 100, 100 } }
Aggregate constants flow through ldc like any other constant and may
initialize globals.
A builder states one member at a time and lets the offsets be computed, which
is what const c1 Point = { x = 1, y = 2 } above compiles to. Nesting is
implicit: a struct inside a struct contributes its members to the same
sequence, so the whole initializer is one flat run of scalar writes.
EsperConstAgg *ca = esper_const_agg_begin(unit, esper_tref_agg(point));
esper_const_agg_int(ca, 1);
esper_const_agg_int(ca, 2);
uint32_t c = esper_const_agg_end(ca);
esper_const_agg_end returns the constant table index that esper_build_ldc
takes. A frontend that has already laid the bytes out itself passes them to
esper_unit_add_const_agg instead.
Flat-memory lowering (low-level alternative)
When you need full manual control over layout (variable-length records, explicit padding, hand-tuned cache lines, or a frontend that already does its own layout computation), the original flat-memory style remains available.
When to choose flat memory over first-class structs
- The record has a runtime-determined size (e.g., trailing flexible array).
- You’re interoperating with an external ABI that the backend cannot describe
with a
typedeclaration. - You want explicit control over field reordering, padding, or alignment that diverges from the default packing rules.
- The record is large enough that you would prefer pointer passing semantics unconditionally.
Allocating and initializing a struct (flat)
struct Point {
var x: Int32
var y: Int32
}
- Compute size and offsets at compile time in the frontend.
- Allocate with
salloc(stack) orhalloc(heap). - Store each field at its byte offset.
- Read each field back from the same offset.
An offset does not have to become pointer arithmetic. EsperMemAccess carries
it on the access itself, so a member read is one instruction instead of three
and the backend still sees a constant displacement:
static void esper_doc_build(EsperUnit *unit) {
EsperTypeRef params[2] = {ESP_TYPE_I32, ESP_TYPE_I32};
EsperTypeRef rets[1] = {ESP_TYPE_I32};
EsperBuilder *b = esper_build_begin(unit, "point_y_flat", params, 2, rets, 1);
esper_build_bind(b, esper_build_block(b, "entry"));
EsperVal size = esper_build_ldi(b, ESP_TYPE_I32, 8);
EsperVal p = esper_build_alloc(b, ESP_OP_SALLOC, size);
// ESP_NO_TYPE says the access reaches no declared member. Zeroing the
// struct is not enough: member zero of type zero is a real answer.
EsperMemAccess at = {0};
at.type_index = ESP_NO_TYPE;
esper_build_store_at(b, ESP_OP_STORE, ESP_TYPE_I32, p,
esper_build_param(b, 0), &at);
at.byte_offset = 4;
esper_build_store_at(b, ESP_OP_STORE, ESP_TYPE_I32, p,
esper_build_param(b, 1), &at);
EsperVal y = esper_build_load_at(b, ESP_OP_LOAD, ESP_TYPE_I32, p, &at);
esper_build_ret(b, &y, 1);
if (!esper_build_end(b))
fprintf(stderr, "point_y_flat: %s\n", esper_builder_last_error(b));
esper_builder_free(b);
}func $point_y_flat(%0 i32, %1 i32) -> i32 {
entry:
%2 = ldi i32 8
%3 = salloc %2
store i32 %3, %0
store i32 %3, %1, byte=4
%6 = load i32 %3, byte=4
ret i32 %6
}salloc reserves storage in the current frame, and that frame is gone once the
function returns. A constructor that hands the record back to its caller has to
use halloc, or write into a pointer the caller supplied.
The same thing as pointer arithmetic
A frontend that would rather compute the address itself emits the offset as a value and adds it:
%0 = ldi i32 4
%1 = add i64 %p, %0 // addr = p + 4
%2 = load i32 %1 // point.y
Writing a field at offset zero needs no arithmetic at all:
%0 = ldi i32 42
store i32 %p, %0 // mem[p + 0] = 42
Nested structs (flat)
Offsets compose at compile time:
%0 = ldi i32 12
%1 = add i64 %line, %0 // offset of end.y = offset(end) + offset(y)
%2 = load i32 %1 // line.end.y
Arrays
Arrays follow the same dual treatment as structs. esper_unit_add_array
declares the type and esper_build_array_new builds a value of it.
First-class array
type Bytes32 = array i8[32]
type Mat4 = array f32[16]
%0 = array.new Mat4 %a0, %a1, %a2, ..., %a15
%1 = array.elem f32 %0, 5 // const-index projection
array.elem accepts only compile-time-constant indices, and
esper_build_array_elem takes that index as an immediate rather than an SSA
value. Runtime indexing goes through pointer arithmetic plus load:
%0 = ldi i32 4
%1 = mul i64 %i, %0 // i * sizeof(element)
%2 = add i64 %arr, %1 // arr + i * 4
%3 = load i32 %2 // arr[i]
Strength reduction
When the element size is a power of two, the multiply collapses to a shift:
%0 = ldi i32 2
%1 = shl i64 %i, %0 // i << 2 = i * 4
%2 = add i64 %arr, %1
%3 = load i32 %2
The strength-reduction pass performs this rewrite automatically.