Serem: the target-independent SSA IR

Serem is Sere's own SSA intermediate representation. It sits between the typed AST and LLVM IR, it is fully inspectable as text, and it is defined by Sere's own model — not by an LLVM version. --emit-serem shows it, --backend=serem compiles through it, and the optimizer can rewrite it before either backend runs.

.sere source
    │
    ▼
 TypeChecker (typed AST)
    │
    ├─ IRGenerator ─────────────────► llvm::Module ─┐
    │                                              │
    └─ SeremGenerator ──► Serem IR ─┬─ SeremTransform (opt passes)
                    (--emit-serem)  │
                                    └─ SeremLLVMBackend ──► llvm::Module ─┤
                                                                         ▼
                                                opt pipeline ──► .ll / .s / exe

Everything below the typed AST goes through the same optimization pipeline, so --emit-serem, --emit-llvm, --emit-asm, and a linked executable all agree about which switches are on.

Why a second IR

ReasonConsequence
Every rewrite pass reads and writes a Sere-owned data structureA pass cannot break on an LLVM upgrade, and value types carry Sere meaning (str, Any, boxed records) instead of opaque pointers
The module is printable text--emit-serem is a debugging tool that needs no LLVM knowledge, and the printed text is what the passes produced
The optimizer runs before any target decision--no-runtime-checks, --cse, and --strength-reduce change the visible IR rather than a hidden internal state
The backend is replaceableSeremLLVMBackend is one implementation of an interface, not the IR itself

Text format

The printed form is stable and line oriented. A module is a header, then its type definitions, globals, and functions, in that order.

text
module @test
type @Exception = %Exception [class, field=message:1]
global @str.0 = str "Hello world!"
func @main() -> i32 {
entry:
  runtime.print @str.0
  return 0
}
ElementSyntaxNotes
Module headermodule @<name>The name is the source file stem
Type definitiontype @<name> = <type> [<attr>, …]Attributes like class and field=message:1 mark records and their field slots
Globalglobal @<name> = <type> <value>String literals become str "..." globals so a rewrite can see them
Function[extern ][async ][generator ]func @<name>(%arg0: <type>, …) -> <type> [<attr> = <value>, …]An extern function prints on one line and has no body
Block<label>: followed by two-space-indented operationsThe entry block is always labelled entry
Operation[%<result> = ]<opcode>[ <type>][ <operand>, …][ {<attr> = <value>, …}]A void result prints no type; attributes are space separated inside braces

Value references:

ValuePrints as
Integer constant42 (wrapped to the result width when folded)
Float constantThe value at 17 significant digits
String constant"...", or @global when the literal has a named global
Argument%arg0
Function reference@name
Operation result%name

Type model

IRType is a value type — cheap to copy and compare — with these kinds:

KindPrints asMeaning
VoidvoidNo value
Booli1A boolean
I8, I16, I32, I64i8, i16, i32, i64Signed integers
U8, U16, U32, U64u8, u16, u32, u64Unsigned integers; the strength-reduction pass uses the distinction
F32, F64f32, f64Floating point
PtrPtr[T]Pointer to T; the pointer value is the address
Array[N x T]Fixed-length aggregate
Function(T, U) -> RA callable signature
Struct%NameA named record
LabellabelA branch target
StringstrA Sere string (pointer plus length at the LLVM level)

Two helper contracts live next to the types because both the generator and the backend must agree on them without exchanging data out of band:

  • ListElementKind — the numeric tag stored with a list so the backend knows its element layout and the runtime formatter knows how to print an element.
  • recordTypeId(name) — FNV-1a hash of a record name, stored in the first word of every class value so a union can answer x is Mayor for a member it does not list.

Instruction set

Serem opcodes are strings, so a new operation does not need a new enum value. IRBuilder covers the common shapes; the generator may also emit a dialect-specific opcode directly through operation().

Arithmetic, logic, and values

OpcodeResultNotes
add, sub, mul, div, remIntegerdiv/rem are signed unless the type is unsigned
fadd, fsub, fmul, fdivFloat
and, or, xorIntegerBitwise
shl, shrIntegershr is arithmetic at the LLVM level, which is why the strength-reduction pass only rewrites unsigned division into a shift
negInteger/floatUnary minus
not, invertIntegerLogical/binary negation
cmp.<pred>i1<pred> names the comparison (eq, ne, lt, slt, ult, …)
cast.<kind>Any<kind> names the conversion
selectAnyselect <cond>, <a>, <b>
phiAnyphi <type> <incoming>, …; incoming pairs carry their predecessor in attributes
alloca, load, storePtr/VoidStack slot, load, store
get_element, extract, insertAnyAggregate access
deref, address.of, store.indirectPtr/VoidPointer vocabulary
pointer.null, pointer.is_nullPtr/i1Null literal and null test

Aggregates, strings, and runtime calls

OpcodeNotes
aggregate.list, aggregate.array, aggregate.rangeBuild a list, an array, or a range; the element kind travels in an attribute
string.concatConcatenate two Sere strings
containsMembership test
runtime.printWrite a value to stdout
runtime.inputRead a line
runtime.lenLength of a collection or string
value.reprRender a value the way repr() prints it; the element kind travels in an attribute
builtin.methodA lowered built-in call such as list.append; the lowered name is an attribute
construct, member.get, member.setRecord construction and field access
static.get, static.setTyped static field load/store; symbol identifies the declaring class's field. A get has no operands; a set takes the new value.
enum.tag, enum.payloadEnum tag and payload access
union.pack, union.extract, union.is, object.isaUnion and dynamic-type tests
iter.begin, iter.has_next, iter.nextIterator protocol
assertCompiler-generated assertion
sere.expression, sere.statementEscape hatches that carry a source expression or statement the Serem lowering does not model
binary.dynamic, unary.dynamic, assign.dynamic, decorated.call, destroy, throw, index.address, index.set, shared.newDynamic dispatch, decorators, destruction, exceptions, indexing, and shared boxes

Control flow

OpcodeNotes
branchUnconditional; the target travels in the target attribute
cond_branchConditional; true and false attributes name the two successors
returnreturn or return <value>
unreachableCannot be reached; usually the arm of a check that was eliminated

Exceptions

OpcodeNotes
error.enterSave the pending error so a finally body starts clean
error.leaveRestore or release the pending error; restore is an attribute
error.isaTest the pending error against a type named in the type attribute
error.bindBind the caught value out of the pending error; the field slot is an attribute
throwRaise

--no-runtime-checks removes error.enter, error.leave, and error.bind, and rewrites every error.isa to false. The handler blocks then become unreachable and the unreachable-block pass deletes them, so a release build carries no exception machinery at all.

Coroutines and async

OpcodeNotes
coro.beginStart a generator or async function; the element type is the result
coro.suspend, coro.end, yieldSuspend, finish, and produce a value
awaitSuspend until a task completes
async.create, async.resume, async.destroyTask creation and driving
coro.resume, coro.done, coro.promise, coro.destroyCoroutine handle operations

Optimization passes

SeremTransform.h defines TransformPass: a named rewrite that reports whether it changed the module. The driver iterates the selected passes to a fixed point (bounded by eight rounds, since every pass is monotone).

PassRuns whenWhat it does
constant-foldAlwaysFolds arithmetic, comparisons, casts, select, and branches whose operands are all constants; a constant cond_branch becomes a branch and the dead edge disappears
dead-codeAlwaysKeeps only what the entry point can reach, following call operands and attribute references, because a callee can be named in an attribute
unreachable-blocksAlwaysDrops blocks no branch can reach
unused-globalsAlwaysDrops string literals nothing references any more
strength-reduce--peephole or --strength-reducex+0, x*1, `x
cse--cseReplaces a repeated pure computation in the same block with the first result; arithmetic, compares, casts, select, and aggregate reads are candidates
runtime-checks--no-runtime-checksRemoves the pending-error bookkeeping and turns the handler dispatch into false

Every pass is independent: none of them knows about the others, and adding one means writing a class and putting it in transformPasses() (or in defaultTransformPasses() if it should always run).

Backend

SeremLLVMBackend lowers a Serem module to llvm::Module:

SeremLLVM
add, sub, mul, and, or, xor, shlThe matching IRBuilder instruction
div, remSigned or unsigned division depending on the integer type
shrashr
cmp.<pred>icmp/fcmp
neg, not, invertCreateNeg, CreateNot
alloca, load, store, derefalloca, load, store
branch, cond_branch, return, unreachableThe matching terminator
call, invokecall; the callee is a FunctionRef
coro.*, await, yieldThe LLVM coroutine intrinsics, split by coro-early,coro-split,coro-cleanup
everything elseA runtime call or an inline expansion, exactly as the LLVM generator would do

The lowered module then enters the same optimization pipeline as a module produced by IRGenerator, so --backend=serem and the default backend share every optimization switch.

Using it

GoalCommand
Print the Serem IRsere --emit-serem app.sere -o app.serem
Print the stable bytecode textsere --emit-serem-bytecode app.sere
Compile through Serem to an executablesere --backend=serem app.sere -o app.exe
See the IR after the release rewritessere --emit-serem -O3 --release app.sere
Skip the passes entirelysere --emit-serem --no-transformers app.sere

--no-transformers prints what the generator produced, before any rewrite, which is the right starting point when a pass looks wrong.

Safety notes

  • Values are shared pointers. Replacing an operation means rewriting every operand that points at it (that is what applyReplacements does) and then removing it from its block; editing a block in place without doing both leaves the printer and the backend disagreeing.
  • A block is a straight-line list of operations plus a terminator flag. Passes that change control flow must keep isTerminated() accurate.
  • removeFunction and removeBlock must not run while a walk over the module is in progress; the dead-code pass collects names first and removes second.