LIR — Low-level IR

<!-- audited: 2026-09-23 -->

LIR is an SSA-form intermediate representation with virtual registers, basic blocks, and explicit control flow.

Key types

(arity, locals, captures, capture-cell masks, signal, region table)

Terminator

branch, emit, unreachable). Note: tail calls are LirInstr variants (TailCall/TailCallArrayMut), not terminators.

list, a bool, an int, a float, an interned symbol or keyword — all tag+payload, no heap. Its String variant reaches no bytecode Const: a string literal is a MaterializeConst. Const/ValueConst are pure pool loads with no region field.

(a string, or quoted compound data: list / array / nested structure) from a recursive immutable ConstTemplate (template.rs) into its own solver-assigned region. It carries a mandatory region: StaticRegion and is an ordinary allocation site (see Heap literals are allocations below). The whole aggregate shares the one region (built bottom-up, so every internal reference is a self-edge taking no cross-region RC).

From HIR to LIR

The lowerer (src/lir/lower/) transforms HIR trees into LIR:

1. Flatten — nested expressions → linear instruction sequences 2. Register allocation — each intermediate value gets a virtual register 3. Block construction — control flow (if, loops, match) creates basic blocks connected by terminators 4. Region assignment — every allocation is routed to a region (see regions); the lowerer emits each region's release after the HirId the solver names as its decref_point, and IncrefRegion at cross-region edges

The operand proof

A %-intrinsic in call position compiles only when the front end discharges its operand contract (intrinsics.md). BinOp, Compare and UnaryOp carry that proof across the LIR boundary, so no backend re-derives at run time what the compiler already decided. OperandProof::Int says every operand of that instruction is an integer on every path reaching it. OperandProof::Unproven claims nothing. The lowerer reads each operand node's inferred type out of TypeInfo — the same map the contract check discharged against — and marks the instruction Int when every one of them is exactly int. Nothing downstream may set the proof: a backend that guessed would be asserting what only the front end can know.

Build an instruction with LirInstr::binop, compare or unary to claim nothing, and with int_binop, int_compare or int_unary to carry the proof.

What each backend spends it on:

BackendUnprovenInt
bytecodeAdd Sub Mul DivAddInt SubInt MulInt DivInt
JITtag check, then the integer path or a helper callthe integer instruction alone
WASMtag check, then the i64 path or the f64 paththe i64 instruction alone
MLIR, SPIR-Voperand types from local inferencethe integer operation

The bytecode set specializes four operations, so a proven %rem, %bit-and or comparison still emits the polymorphic opcode. Those handlers already read their operands as integers and do no better with the proof.

A division keeps its zero test on every tier. The contract does prove the divisor nonzero wherever both operands are proven integers, but OperandProof names the operand type and says nothing about a value, and a trapping sdiv is the wrong place to spend a reading it does not carry.

An unproven instruction is correct everywhere and only slower, so a lowering that cannot decide says Unproven and each backend does what it did before.

Heap literals are allocations

A heap literal is an ordinary allocation, not a pre-baked Value. The constant pool stores only the literal's immutable template — a recursive ConstTemplate (string bytes; or a quoted list/array/nested structure; plus the closure template) as compile-time data, encoded inline in the (reclaimable) bytecode and held as a Box<ConstTemplate> by JitCode. MaterializeConst reads that template and allocates a fresh heap value every time it runs into its own region — the solver gives each literal its own region: StaticRegion (alloc_here) and decref_point exactly like List/ MakeArrayMut/MakeClosure, resolved per activation to a fresh physical region and allocated into that explicit region (alloc_in_region). A quoted aggregate's whole structure shares that one region. Normal escape RC keeps any escaped copy alive past its decref_point. Only immediates (numbers, bools, nil, interned keyword/symbol) remain as plain pool constants loaded by Const/ValueConst.

A template carries symbols by name, not by interned id. The id would in fact survive a sys/spawn boundary now that it is the name's hash (symbol.md), but the name is what makes the template readable and self-describing; materialize interns it, which records the spelling for display and returns that same id.

region/model.md says why a code-object-lifetime "constant-pool region" is forbidden.

Self-reference: LoadSelf

A closure that references itself in value position — passed to a higher-order call, returned, or stored, then invoked later — lowers that reference to LoadSelf { dst }. The op takes no operand and pushes the currently-executing closure: the runtime holds the executing closure in a per-activation register (current_closure, fiber.rs), and the JIT receives that same closure value as a compiled-body parameter, so LoadSelf reads it directly rather than naming a capture slot. The value it yields is the closure itself, so an invocation of that value recurses correctly (selfrec.rs, recur-as-value.lisp, recur-after-tail-call.lisp).

A self-reference in call position ((loop args)) lowers its callee to LoadSelf too, so the call re-enters the same code and environment with new arguments.

Files

PathContents
src/lir/types/LirFunction (func.rs), LirInstr (instr.rs), BasicBlock, Reg, Terminator, LirConst (mod.rs)
src/lir/display.rsDebug printing of LIR
src/lir/lower/Lowering from HIR
src/lir/emit/Bytecode emission from LIR

src/lir/AGENTS.md describes the types and the emitter.


See also