- Larva
- How Larva works
- Lowering HIR to IR
Lowering HIR to IR
How HirLowerer turns a linked HIR assembly into Larva IR - object layouts, vtables and itables, generics, boxing, checked arithmetic and static constructors.
On this page
- Where it runs
- Seeing what lowering makes
- The driver
- The passes
- Options
- Types
- Object layout
- Value types
- Arrays kept in the object
- Methods and objects
- Virtual dispatch
- Slots
- The vtable
- Calls that skip the vtable
- Interfaces and itables
- Finding the itable: fixed entries
- Type tests
- Boxing
- Generics
- Generic in IR, resolved after linking
- Requirements
- Shared or specialized
- checked arithmetic
- Static constructors
- Linkage
A frontend gives Larva a HIR assembly: types, members and statement trees, in the vocabulary of a typed object language. The back ends want something else: Larva IR, a control-flow graph of basic blocks over structs, handles, raw pointers and plain functions, where there are no classes, no virtual methods and no interfaces. Lowering is the step between them, and it is written once, in Larva, for every language.
That is the reason it exists as its own project. Before the HIR rearchitecture, each piece of this (class layout, dispatch, closures, iterators, reflection, interop, the program entry) lived in the Antril compiler's binder, around 12,000 lines. Moving it under HIR means a second language gets objects, interfaces, exceptions and generics that behave the same way, and a library compiled from one language can be called from another without a translation layer.
This page covers the driver, types and layouts, generics, dispatch, boxing, checked arithmetic and static constructors. Lowering language features continues with closures, iterators, async, exceptions, reflection, interop, regions and destructors.
Where it runs
larva build runs the frontend, links the HIR of the program with System.Core and any libraries (HirLinker), and lowers the result as one assembly:
sources ──frontend──► .lva (HIR) ──HirLinker──► one HIR assembly
│
HirLowerer.Lower(assembly, LoweringOptions)
│
▼
IrModule (generic, unchecked)
│ borrow check (program functions)
│ Compiler.CompileObject: type check, region check
▼
LarvaObject ──Linker──► program
│
Monomorphizer.Run (Coroutines.Lower first)
▼
back end: LLVM, x64, Wasm, IL for the VM
--timings shows the share of each step. Lowering the whole program, System.Core included, is the lowering entry:
larva build --check shapes.ant
no errors (1 file; System.Core (compiled library) 52 ms, frontend 1291 ms, HIR link 65 ms, lowering 643 ms; total 2057 ms)
The output of lowering is still generic and still unchecked. The IR type checker and region checker run on it next (The IR, Borrow checker), and generics are resolved only after the IR objects are linked (IR objects and linking). Lowering therefore never needs to see every instantiation of a generic type: it writes each generic body once.
Seeing what lowering makes
larva show --lang hir prints the declarations of a type as HIR:
larva show --lang hir Shapes.Square shapes.ant
public class Shapes.Square : Shapes.Shape, Shapes.IArea
{
field side: f64
constructor Square(side: f64)
method Sides() -> i32
method Area() -> f64
}
The bodies and the IR are not printed by any larva command. The listings on this page were printed with Larva's own printers (Larva.Hir.Text.HirPrinter for HIR, Larva.IR.Text.IrPrinter for IR) on the lowered program; each one is shown as the printer wrote it. For the final machine-level form, LARVA_DUMP=folder makes larva build (LLVM back end) write the optimized LLVM IR and assembly of the whole program as NAME.ll and NAME.s in that folder.
The driver
Larva.Hir.Lowering has one public entry, HirLowerer.Lower(HirAssembly, LoweringOptions), which returns a LoweringResult: the IrModule, the errors, and the IR function of each HIR method (by its token).
| File | What it holds |
|---|---|
HirLowerer |
The driver: the root structs, type declarations, layout, members, vtable slots, linkage. |
BodyLowerer |
Method bodies: statements and expressions to basic blocks; calls, try, patterns, new, delete, region. |
Interfaces |
Itables, $interfaces, the fixed entries, $find, thunks and value boxes. |
InterfaceValues |
Calls through interface values, boxing and unboxing, type tests against interfaces and values. |
Structural |
Equality, hash and text for any type (records, tuples): the last resorts for types that implement nothing. |
Checked |
checked arithmetic and conversions. |
StaticInit |
Static constructor order and $StaticInit. |
Closures, Iterators, Interop, Reflection |
See Lowering language features. |
The passes
HirLowerer.Run does its work in a fixed order. Each step only needs what the steps before it decided:
| Step | What it decides |
|---|---|
PrepareReflection |
When some body uses typeof or GetType(): writes describer classes as HIR (see reflection). |
DeclareRoot |
The structs every program has: Larva.Interface, Larva.Object$vt, Larva.Object, Larva.Nullable<T>, Larva.Func0..8, Larva.Array2..8. |
DeclareTypes |
A TypeInfo per HIR type, each class's base, an empty IR struct per class and value type (so fields can name types laid out later). |
LayOutTypes |
Field lists: bases before derived classes, value types before the types that hold them. |
DeclareMembers |
A MethodPlan per method (IR signature, this type, type parameters with their requirements), vtable slots, vtable structs. |
OrderStaticConstructors |
The order static constructors run in, and $StaticInit. |
DeclareImplementations |
The $impl wrappers that meet interface requirements of constrained type parameters. |
RegisterReflection, RegisterStructural |
Implementations of $typeof, $box, $unbox, and the equality/hash/text last resorts. |
DeclareItables, BuildItables |
Itable structs, $find functions and the thunks of each implementing type. |
LowerBodies |
Every method body (BodyLowerer), then every class's vtable maker. |
ApplyLinkage |
Exports and imports across GC domains. |
LowerBodies runs the method bodies in parallel when LoweringOptions.Parallel is on (the default). Each body only reads the shared tables built before it, and writes its function into its own slot, so the module is the same as a sequential run; Larva's tests compare the two printed modules.
Options
LoweringOptions |
Meaning |
|---|---|
Gc |
The GC mode objects are managed by. Decides whether a handle is gc T (a managed mode) or *mut T (manual), and whether region { } opens a region. |
ClosedWorld (on) |
The assembly is the whole program, so a virtual call whose slot no class below the receiver overrides is a direct call. |
StaticConstructorsFromEntry |
Only the static constructors of types reachable from the entry and exports run. larva build sets it. |
ExternalAssemblies |
Input assemblies built as objects of another GC domain: their callable statics become imports (assembly::name). |
ExportAll |
Export everything internal: a library built as a domain of its own. |
Parallel (on) |
Lower bodies on several threads. |
Types
HirLowerer.Lower(HirType, …) maps every HIR type to an IR type:
| HIR | IR |
|---|---|
bool, i8…i256, u8…u256, f16…f256 |
The primitive of the same name. |
char |
u32. |
void |
() (unit). |
enum, bitflag enum |
Its underlying primitive (i32 when it has none). |
A primitive's provider type (Int32) |
The primitive it describes. |
class C<A> |
A handle to its struct: gc C<A>, or *mut C<A> under gc: manual. |
value type V<A> |
The struct itself, held by value. |
interface I |
The root handle gc Larva.Object (an interface value is an object). |
T? of a class |
?gc C: an optional handle, null is its zero. |
T? of a value |
Larva.Nullable<T> { $has: bool, $value: T }, a value whose zero is null. |
T[] |
gc [T]: a GC array of T (or *mut [T] under manual). |
T[,] … rank 8 |
gc Larva.Array2<T> … Larva.Array8<T>: the elements in one row-order array, plus each dimension's length. |
fixed T[N] field |
[T; N], kept inline. |
func<A…> : R |
gc Larva.Func{n}<A…, R>: a function object (at most 8 parameters). |
func*<A…> : R |
fn(A…) -> R: a raw function address. |
&T, &mut T |
&T, &mut T; &T[] borrows the elements themselves, &[T]. |
T* |
*T / *mut T. |
| type parameter | T0…: the type's parameters first, then the method's. |
Every IR type parameter is copy & unborrowed: any Larva value can be copied, and a type argument is never a &T borrow (the borrow checker relies on that). A parameter with a class constraint is object & unborrowed, so it can be cast to a class or thrown. A parameter constrained to a list of primitives (T : [u8, u16, u32]) is that list exactly, which is what lets a generic body do arithmetic on T.
Object layout
Every object starts with the root header, Larva.Object:
struct Larva.Object { $class: i32, $vt: *mut Larva.Object$vt }
$classis the class id. Ids number the classes of the whole program in preorder over their bases, starting at 1, so a class and every class derived from it are one range[classid C, classid.end C).x is Cis two comparisons, whatever module each class came from. The ids are fixed when generics are resolved, after linking, because only the whole program knows every class.$vtpoints to the class's vtable. Vtables are made once and never freed, so they live in raw memory outside the collector, and the global that holds one is plain data: reading it is a load, not a call into the runtime.
A class's struct is its base's fields, then its own, in declaration order, and its Base names the base struct, so a handle to a derived class is a handle to its base. With these classes:
import System;
into namespace Shapes
{
public interface IArea
{
Area() : f64;
}
public base class Shape
{
public:
str Name;
new(str name) { Name = name; }
virtual Sides() : i32 => 0;
}
public final class Square : Shape, IArea
{
f64 side;
public:
new(f64 side) : base("square") { this.side = side; }
override Sides() : i32 => 4;
Area() : f64 => side * side;
}
Main()
{
Shape s = new Square(3.0);
Console.WriteLine(s.Sides());
IArea a = new Square(2.0);
Console.WriteLine(a.Area());
}
}
the structs are:
struct Shapes.Shape { $class: i32, $vt: *mut Larva.Object$vt, Name: gc System.String<u8> } // base System.Any
struct Shapes.Square { $class: i32, $vt: *mut Larva.Object$vt, Name: gc System.String<u8>, side: f64 } // base Shapes.Shape
Larva.IR.Lowering.Layouts places the fields the way C does: in order, each aligned to its size (primitives up to 16 bytes), pointers and handles 8 bytes, the size rounded up to the alignment. The collector does not see the IR struct alone: the runtime puts its own 16-byte header (GcHeader in larva_rt.c: type descriptor, reference count, mark, flags, domain and age) before the payload. A handle points at the payload, so $class is at offset 0.
┌──────────────────────────────┐
handle-16 │ type → LarvaType descriptor │ GcHeader (runtime, 16 bytes)
handle-8 │ refcount │ mark flags domain age │
╞══════════════════════════════╡
handle+0 │ $class i32 (+4 padding) │ Larva.Object
handle+8 │ $vt *mut Square$vt ─────┼──► vtable (raw memory)
├──────────────────────────────┤
handle+16 │ Name gc String<u8> ──────┼──► another object
├──────────────────────────────┤
handle+24 │ side f64 │ Square's own field
└──────────────────────────────┘
Layouts.Of also records GcOffsets, the offsets that hold handles. That is the pointer map the back ends write into each type's descriptor, so the tracing collector knows exactly which words to follow (Garbage collection).
Value types
A value is an IR struct with no header: it lives inline in locals, fields and arrays. Its methods take this as &mut V, so a method can change the value in place:
struct Values.Point { X: i32, Y: i32 }
export fn Values.Point.Move(_1: &mut Values.Point, mut _2: i32) -> () {
let mut _0: (); // return
bb0: {
(*_1).0 = Add(copy (*_1).0, copy _2);
return;
}
}
A primitive's members (those of Int32 for i32) take this as the value itself.
A value holding objects is never copied bit for bit: the body lowerer copies it field by field, so that under reference counting each handle gains its owner. A value with [ValueLayout] (C structs, unions, packed structs, from bindings) is laid out by its attribute instead: each field gets an offset (sequential, aligned to min(alignment, Pack), or explicit from [FieldOffset]), and the IR struct becomes that many storage words ($bytes0, $bytes1, …) of the value's alignment. Its fields are read and written through pointers at their offsets. Such a value may not hold objects.
Arrays kept in the object
The last field of a class can be an array kept in place: fixed T[] name; in Antril, FieldFlags.InPlace in HIR. Lowering lays it out last, as an IR field of type [T]:
import System;
into namespace Values
{
public value Point
{
public:
i32 X;
i32 Y;
new(i32 x, i32 y) { X = x; Y = y; }
Move(i32 dx) { X = X + dx; }
}
public final class Label
{
Point at;
i32? width;
fixed u8[] text;
public:
new(i32 length) : fixed(length) { width = null; }
Size() : i32 => text.Length;
}
Main()
{
Point p = new Point(1, 2);
p.Move(3);
Console.WriteLine(p.X);
Console.WriteLine(new Label(5).Size());
}
}
struct Values.Label { $class: i32, $vt: *mut Larva.Object$vt, at: Values.Point, width: Larva.Nullable<i32>, text: [u8] } // base System.Any
In the object, [T] takes one 8-byte word, its length, and the elements follow the struct with the same shape as a plain array:
+0 $class i32
+8 $vt *mut Label$vt
+16 at Point { X, Y } 8 bytes, align 4
+24 width Nullable<i32> { $has, $value }
+32 text length (u64) Layouts.Trailing: LengthOffset 32
+40 text[0] text[1] … Header 40, Stride 1
Layouts.Trailing gives (LengthOffset, Header, Stride, Element); Header is where the elements start and the size the collector sees, and the runtime descriptor carries the flag TYPE_TRAILING. The elements may hold no references and align to at most 8 bytes, so the collector only ever reads the fields before them.
Each constructor n of such a class gets a generated static method $fixed{n} that computes the length from the constructor's arguments (the : fixed(length) clause). new Label(5) evaluates the arguments once, asks $fixed0, allocates the object with that many elements, sets $class and $vt, then runs the constructor:
bb3: {
_11 = const 5_i32;
_12 = Values.Label.$fixed0(copy _11) -> bb4;
}
bb4: {
_13 = gc Values.Label [u8; copy _12];
(*_13).0 = classid Values.Label;
_14 = Values.Label.$vtable() -> bb5;
}
bb5: {
(*_13).1 = copy _14 as *mut Larva.Object$vt;
_15 = Values.Label..new(copy _13, copy _11) -> bb6;
}
This exists for strings. Before it, every str was two objects, the String<T> and its T[] of units, and a profile of Larva's stress test found most of the string section's time in the minor collections that promoted both halves. System.String<T> is now one object:
struct System.String<T> { $class: i32, $vt: *mut Larva.Object$vt, hash: u64, units: [T] } // base System.Any
and sorting 200,000 strings went from 257 ms to 155 ms (LLVM, O3).
Methods and objects
Every method becomes an IR function named Type.Method, with this as its first argument (a handle for a class, &mut V for a value). Constructors are Type..new and static constructors Type..static; overloads get a $n suffix (System.Console.WriteLine$39). Methods of generic types take the type's parameters first, then their own.
new C(args) is spelled out in the IR: a struct filled with zeroes (filler), the class id, the vtable from C.$vtable(), the move into the heap (gc move), then the constructor call. From Shapes.$Module.Main:
bb0: {
_4 = filler Shapes.Square;
_4.0 = classid Shapes.Square;
_5 = Shapes.Square.$vtable() -> bb1;
}
bb1: {
_4.1 = copy _5 as *mut Larva.Object$vt;
_6 = gc move _4;
_7 = Shapes.Square..new(copy _6, const 3_f64) -> bb2;
}
C.$vtable() makes the class's vtable the first time it is asked for and keeps it in the global C$vtable: vtables are built lazily, once per class (once per instance of a generic class), so a program pays nothing for classes it never instantiates.
Virtual dispatch
Slots
AssignSlots walks each class after its base. A class starts with its base's slots; each virtual or abstract method it introduces appends a slot; an override (same name and parameters as a virtual method of a base, compared after substituting the base's type arguments) takes the slot of the method it overrides. Overriding a final method, or overriding nothing, is a lowering error.
Every implementation in a slot takes this as the class that introduced the method, so the slot has one function type for the whole hierarchy. An override that needs its own class casts back:
export unsafe fn Shapes.Square.Sides(_1: gc Shapes.Shape) -> i32 {
let mut _0: i32; // return
let _2: gc Shapes.Square; // self
bb0: {
_2 = copy _1 as gc Shapes.Square;
_0 = const 4_i32;
return;
}
}
The vtable
A vtable is a struct too. Every vtable starts with the same prefix (VtablePrefix): the $interfaces list and eight fixed interface entries. Method slots start at field SlotBase = 9:
struct Larva.Interface { id: i32, table: u64 }
struct Larva.Object$vt { $interfaces: *mut [Larva.Interface], $fixed0: Larva.Interface, $fixed1: Larva.Interface, $fixed2: Larva.Interface, $fixed3: Larva.Interface, $fixed4: Larva.Interface, $fixed5: Larva.Interface, $fixed6: Larva.Interface, $fixed7: Larva.Interface }
struct Shapes.Square$vt { $interfaces: *mut [Larva.Interface], $fixed0: Larva.Interface, $fixed1: Larva.Interface, $fixed2: Larva.Interface, $fixed3: Larva.Interface, $fixed4: Larva.Interface, $fixed5: Larva.Interface, $fixed6: Larva.Interface, $fixed7: Larva.Interface, RuntimeType: fn(gc System.Any) -> ?gc System.Type, ToString: fn(gc System.Any) -> gc System.String<u8>, Hash: fn(gc System.Any) -> u64, destroy: fn(gc System.Any) -> (), Sides: fn(gc Shapes.Shape) -> i32 } // base Shapes.Shape$vt
The first four slots come from System.Any (RuntimeType, ToString, Hash, destroy). Laid out in memory:
Shapes.Square$vt (raw memory, made once)
┌──────┬──────────────────────────────────────┐
│ +0 │ $interfaces ──► [ {id IArea, table} ]│ every interface, searched by $find
│ +8 │ $fixed0 { id -1, table 0 } │
│ +24 │ $fixed1 { id -1 } │
│ +40 │ $fixed2 { id -1 } │
│ +56 │ $fixed3 { id typeid IArea$itable, │──► Shapes.IArea$itable
│ │ table ────────────────── │ ┌──────────────────────────────┐
│ +72 │ $fixed4 { id -1 } │ │ Area: fn(gc Larva.Object)->f64│
│ +88 │ $fixed5 { id -1 } │ └──────────┬───────────────────┘
│ +104 │ $fixed6 { id -1 } │ ▼
│ +120 │ $fixed7 { id -1 } │ Square.$itable.Shapes.IArea.Area<>
├──────┼──────────────────────────────────────┤ (thunk: cast, call $impl)
│ +136 │ RuntimeType (slot 9 = SlotBase) │
│ +144 │ ToString │
│ +152 │ Hash │
│ +160 │ destroy │
│ +168 │ Sides ──► Shapes.Square.Sides │
└──────┴──────────────────────────────────────┘
Each Larva.Interface entry is 16 bytes (an i32 id, padding, a u64 table address), so the fixed entries take bytes 8 to 136. The offsets in the diagram are what the LLVM back end really reads. For a program with a Shape hierarchy where two classes override Sides, the optimized LLVM IR of SidesOf(Shape s) : i32 => s.Sides(); loads $vt at offset 8 and the slot at offset 168:
polled: ; preds = %poll, %entry
%1 = getelementptr inbounds nuw i8, ptr %0, i64 8
%2 = load ptr, ptr %1, align 8
%3 = getelementptr inbounds nuw i8, ptr %2, i64 168
%4 = load ptr, ptr %3, align 8
%5 = tail call i32 %4(ptr %0)
ret i32 %5
(polled follows the GC poll at the function's entry.) The same call in IR, from Main of the Shapes program, reads field 13 of the vtable:
bb2: {
_3 = copy _6;
_1 = copy _3 as gc Shapes.Shape;
_10 = copy _1;
_11 = copy (*_10).1;
_12 = copy _11 as *mut Shapes.Shape$vt;
_13 = copy (*_12).13;
_9 = call copy _13(copy _10) -> bb3;
}
The vtable maker fills every field. For Square, it allocates the $interfaces list, makes the IArea itable, writes it both into the list and into its fixed entry, marks the free fixed entries with id -1, and stores the slots:
bb1: {
_3 = filler Shapes.Square$vt;
_4 = const 1_u64;
_5 = alloc [Larva.Interface; copy _4];
_7 = filler Shapes.IArea$itable;
_7.0 = fn Shapes.Square.$itable.Shapes.IArea.Area<>;
_8 = alloc move _7;
_9 = copy _8 as u64;
_6 = const 0_u64;
(*_5)[_6].0 = typeid Shapes.IArea$itable;
(*_5)[_6].1 = copy _9;
_3.4.0 = typeid Shapes.IArea$itable;
_3.4.1 = copy _9;
_3.1.0 = const -1_i32;
_3.2.0 = const -1_i32;
_3.3.0 = const -1_i32;
_3.5.0 = const -1_i32;
_3.6.0 = const -1_i32;
_3.7.0 = const -1_i32;
_3.8.0 = const -1_i32;
_3.0 = copy _5;
_3.9 = fn System.Any.RuntimeType;
_3.10 = fn System.Any.ToString;
_3.11 = fn System.Any.Hash;
_3.12 = fn System.Any.destroy;
_3.13 = fn Shapes.Square.Sides;
_10 = alloc move _3;
_11 = copy _10 as u64;
Shapes.Square$vtable = copy _11;
_0 = copy _10;
return;
}
Calls that skip the vtable
BodyLowerer.Devirtualized turns a virtual call into a direct call when the target is known without the vtable:
- the receiver's static type is a
finalclass, or the method isfinalon that class; or - with
ClosedWorld, no class below the receiver's type puts another function in that slot (TypeInfo.SlotVaries). Lowering sees the whole program, so a virtual method nobody overrides costs a plain call. The root class is excluded, since lowering itself makes classes below it (the boxes of values).
In the Shapes program, s is typed Shape, and Square overrides Sides, so the call stays virtual.
Interfaces and itables
An interface value is a handle to an object, the same as any: converting between them costs nothing. A call through it needs the object's itable for that interface: a struct with one function per method of the interface (and of those it extends).
struct Shapes.IArea$itable { Area: fn(gc Larva.Object) -> f64 }
The functions in an itable are thunks: they take the object as the root handle, cast it to the class, and call the type's $impl wrapper, which calls the method itself (virtually when it is virtual):
unsafe fn Shapes.Square.$itable.Shapes.IArea.Area<>(_1: gc Larva.Object) -> f64 {
let mut _0: f64; // return
let mut _2: gc Shapes.Square;// receiver
bb0: {
_2 = copy _1 as gc Shapes.Square;
_0 = Shapes.Square.$impl.Shapes.IArea.Area(move _2) -> bb1;
}
bb1: {
return;
}
}
The $impl wrappers are named with the interface's arguments (Both.$impl.IPut.Put<i32>): two instances of one generic interface on one class used to share a name, and the x64 back end called the wrong one.
Finding the itable: fixed entries
Each entry of $interfaces is { id, table }, where the id is the typeid of the itable struct with its arguments, so IComparable<str> and IComparable<i32> are different entries. I.$find(obj) is a short loop over that list.
Originally every interface call went object, vtable, $interfaces, loop, itable, function. A profile of the stress test measured, for 20 million calls, 27 ms direct (the floor of the benchmark's f64 chain), 60 ms virtual and 79 ms through an interface, with the extra cost in that chain of loads. Fixed entries remove the loop in the common case:
- Color. Each interface has an entry number,
FNV-1a(name of its definition) % 8(HirLowerer.Color). It depends on the name alone, so code lowered separately (another GC domain, a cached library) agrees on it without coordination.Shapes.IArealands in$fixed3; the same interface in a namespace calledCallslands in$fixed6. - Filling (
FillInterfaces). The first interface of each color, in the order$findsearches (a class before its bases), takes the entry; free entries have id-1. The$interfaceslist stays complete. - Lookup (
BodyLowerer.FindItable, used by calls,isandas). If the vtable is not null andvt.$fixedC.idis the itable'stypeid, usevt.$fixedC.table; otherwise callI.$find. Because the id is checked, a color collision, two instances of one generic interface, or a disagreement between modules stay correct; they are only slower.
The call a.Area() in Main:
bb6: {
_14 = copy _17;
_2 = copy _14 as gc Larva.Object;
_21 = copy _2;
_23 = copy (*_21).1;
_24 = copy _23 as u64;
_25 = Eq(copy _24, const 0_u64);
if copy _25 -> [true: bb9, false: bb7];
}
bb7: {
_26 = copy (*_23).4.0;
_27 = typeid Shapes.IArea$itable;
_25 = Eq(copy _26, copy _27);
if copy _25 -> [true: bb8, false: bb9];
}
bb8: {
_28 = copy (*_23).4.1;
_29 = copy _28 as *mut Shapes.IArea$itable;
_22 = wrap copy _29;
goto -> bb10;
}
bb9: {
_22 = Shapes.IArea.$find(copy _21) -> bb10;
}
bb10: {
_30 = unwrap copy _22;
_31 = copy (*_30).0;
_20 = call copy _31(copy _21) -> bb11;
}
The chain is now object, vtable, itable, function: three loads, against two for a virtual call. The stress test's interface section went from 90–105 ms to 61–62 ms, and the call microbenchmark from 82–89 ms to 65–75 ms.
$find itself, for the slow path:
unsafe fn Shapes.IArea.$find(_1: gc Larva.Object) -> ?*mut Shapes.IArea$itable {
let mut _0: ?*mut Shapes.IArea$itable;// return
let mut _2: *mut Larva.Object$vt;// vt
…
bb3: {
_9 = copy (*_5)[_7].0;
_10 = Eq(copy _9, copy _8);
if copy _10 -> [true: bb5, false: bb4];
}
…
bb5: {
_11 = copy (*_5)[_7].1;
_12 = copy _11 as *mut Shapes.IArea$itable;
_0 = wrap copy _12;
return;
}
bb6: {
_0 = zeroed ?*mut Shapes.IArea$itable;
return;
}
}
Type tests
x is C for a class compares $class with the class's range: classid C <= $class < classid.end C. x is I for an interface asks for its itable (fixed entry, then $find) and tests for null. x is V for a value type tests the class of its box. The same tests decide as, catch (T e) and type patterns.
Boxing
A value (a primitive, a value type or an enum) that becomes any or an interface is copied into a box: a class of its own, V$box { $class, $vt, value }, made the first time a program boxes a value of that type. Its vtable answers any's methods with the value's (ToString, Hash), and its itables have thunks that read the value out of the box:
struct System.Int32$box { $class: i32, $vt: *mut Larva.Object$vt, value: i32 } // base System.Any
unsafe fn System.Int32$box.ToString(_1: gc System.Any) -> gc System.String<u8> {
let mut _0: gc System.String<u8>;// return
let mut _2: gc System.Int32$box;// box
let mut _3: i32; // value
bb0: {
_2 = copy _1 as gc System.Int32$box;
_3 = copy (*_2).2;
_0 = System.Int32.ToString$6(copy _3) -> bb1;
}
bb1: {
return;
}
}
any boxed = 42; is HIR box<[System.Core]System.Any>(42i32), and lowers to an ordinary object creation:
bb18: {
_34 = filler System.Int32$box;
_34.0 = classid System.Int32$box;
_35 = System.Int32$box.$vtable() -> bb19;
}
bb19: {
_34.1 = copy _35 as *mut Larva.Object$vt;
_34.2 = const 42_i32;
_36 = gc move _34;
_33 = copy _36 as gc System.Any;
_2 = copy _33;
_37 = System.Console.WriteLine$55(copy _2) -> bb20;
}
Unboxing tests the box's class and copies the value out. Every box is an allocation; unlike some runtimes, Larva does not remove the box in a box followed by a type test, which is why a T passed as any measured 918 ms for 200 million against 167 ms there.
Generics
Generic in IR, resolved after linking
Lowering does not instantiate anything. A generic class becomes a generic struct, a generic method a generic function, with the type's parameters first and the method's after. The program from this section:
import System;
into namespace Gen
{
public class Box<T>
{
T item;
public:
new(T item) { this.item = item; }
Get() : T => item;
}
public static class Counter
{
public:
static i32 Start;
static new() { Start = 40; }
}
First<T>(T[] items) : T => items[0];
Twice<T : INumber<T>>(T x) : T => x + x;
AddChecked(i32 a, i32 b) : i32 => checked(a + b);
Main()
{
Box<str> b = new Box<str>("hi");
Console.WriteLine(b.Get());
Console.WriteLine(First<i32>([1, 2, 3]));
Console.WriteLine(Twice<i32>(21));
Console.WriteLine(AddChecked(Counter.Start, 2));
any boxed = 42;
Console.WriteLine(boxed);
}
}
lowers to:
struct Gen.Box<T> { $class: i32, $vt: *mut Larva.Object$vt, item: T } // base System.Any
export fn Gen.Box.Get<T: copy & unborrowed>(_1: gc Gen.Box<T>) -> T {
let mut _0: T; // return
bb0: {
_0 = copy (*_1).2;
return;
}
}
fn Gen.$Module.First<T: copy & unborrowed>(mut _1: gc [T]) -> T {
let mut _0: T; // return
let mut _2: i32;
bb0: {
_2 = const 0_i32;
_0 = copy (*_1)[_2];
return;
}
}
and a call names its type arguments: Gen.$Module.First::<i32>(copy _25), Gen.Box.Get::<gc System.String<u8>>(copy _22).
Requirements
A constraint becomes requirements on the IR type parameter: one per method of each interface that constrains it, and one per operator. T : INumber<T> asks for INumber's statics and every operator it declares:
fn Gen.$Module.Twice<T: copy & unborrowed + System.INumber.get_Zero() -> T + System.INumber.get_One() -> T + System.INumber.FromF64(f64) -> T + System.INumber.ToF64(T) -> f64 + op+(T, T) -> T + op-(T, T) -> T + op*(T, T) -> T + op/(T, T) -> T + op%(T, T) -> T + op==(T, T) -> bool + op<(T, T) -> bool>(mut _1: T) -> T {
let mut _0: T; // return
bb0: {
_0 = require T.op+(copy _1, copy _1) -> bb1;
}
bb1: {
return;
}
}
require T.op+ is a call through the requirement. For each type, DeclareImplementations registers what meets it: the primitive's own operation, a type's declared operator, or an $impl wrapper that calls the type's method. Every function of a generic type declares all the requirements of the type's parameters, so whatever it calls with them finds them met.
Shared or specialized
Monomorphizer.Run resolves generics, starting from the roots (the entry, exports, reflected functions) and following calls. Each reachable instantiation becomes one of two things:
| Mode | What it makes |
|---|---|
| specialize | A copy per instantiation with every parameter substituted: Gen.$Module.First<i32>, Gen.Box.Get<gc System.String<u8>>. Fastest code, larger output. |
| shared | The generic body kept once, called with one witness per type argument. Only for functions that do not need the concrete type (Monomorphizer.Shareable); the others are still specialized. Allows instantiating with types known only at run time. |
A function cannot be shared when its body picks an instruction by type (arithmetic, comparisons or casts on a T), calls through a requirement, takes a function address, a typeid or an implementation, reads a generic global, or passes its parameters to a function that cannot be shared. In shared mode the same program keeps First and Get generic and specializes Twice:
fn Gen.$Module.First<T: copy & unborrowed>(mut _1: gc [T]) -> T {
…
}
fn Gen.$Module.Twice<i32>(mut _1: i32) -> i32 {
let mut _0: i32; // return
bb0: {
_0 = Add(copy _1, copy _1);
return;
}
}
In specialize mode both become copies (fn Gen.$Module.First<i32>(mut _1: gc [i32]) -> i32). Closed instantiations of generic structs become structs of their own (struct Gen.Box<gc System.String<u8>>); open ones stay generic inside shared code.
The .queen key generics: shared|specialize sets CompilationOptions.Generics (default shared). It applies to the IL that the VM runs, where shared functions take their witnesses with gen.wit / gen.ldtarg. The native back ends (LLVM, x64, Wasm) always specialize: their builders call Monomorphizer.Run with GenericsMode.Specialize, and each refuses shared code with "is shared generic code; compile with GenericsMode.Specialize".
A generic type's static field is one global per instance (GlobalDef.GenericCount), so Cache<i32>.Count and Cache<str>.Count are different cells.
checked arithmetic
In a checked context, integer + - *, negation, ++/--, compound assignments and numeric conversions that may lose the value test the result and call System.Fail.Overflow(), which throws OverflowError. The tests are plain IR, so back ends need nothing new (Checked):
| Operation | Test |
|---|---|
a + b, a - b |
By signs: for a sum, ((a ^ r) & (b ^ r)) < 0; unsigned, r < a. |
a * b |
Through a wider type, or for 64 bits and more by dividing back. |
-a |
The smallest signed value fails; for unsigned, anything but 0. |
| conversion | Converting back and comparing, unless every value of the source fits the target. |
checked(a + b) on i32:
fn Gen.$Module.AddChecked(mut _1: i32, mut _2: i32) -> i32 {
…
bb0: {
_3 = copy _1;
_4 = copy _2;
_5 = Add(copy _3, copy _4);
_7 = Xor(copy _3, copy _5);
_8 = Xor(copy _4, copy _5);
_9 = And(copy _7, copy _8);
_6 = Lt(copy _9, const 0_i32);
if copy _6 -> [true: bb1, false: bb2];
}
bb1: {
_10 = System.Fail.Overflow() -> bb3;
}
bb2: {
_0 = copy _5;
return;
}
bb3: {
unreachable;
}
}
Outside checked, integer arithmetic wraps.
Static constructors
A static constructor (static new() { … }) and static field initializers run once, before Main. The design choice is that reading a static field then costs nothing but the read: no "is this type ready" check on every access, as lazily initialized statics would need. Static fields are IR globals (global Gen.Counter.Start: i32), and globals that hold objects are read directly by LLVM, calling the runtime only the first time; a loop reading a static field went from 804 to 54 ms when that changed.
OrderStaticConstructors decides the order: each type's constructor after those of the types it uses, following the methods it calls (UsedTypes); a cycle runs in type order. With StaticConstructorsFromEntry (what larva build sets), only the types reachable from the entry and exports are included (ReachableTypes follows calls, fields, properties, objects made, function values, and every instance method of a type in use, since virtual and interface calls can reach those).
The result is one exported function, $StaticInit, guarded by a flag:
export fn $StaticInit() -> () {
let mut _0: (); // return
let mut _1: bool;
let mut _2: ();
bb0: {
_1 = $StaticInit.done;
if copy _1 -> [true: bb2, false: bb1];
}
bb1: {
$StaticInit.done = const true;
_2 = Gen.Counter..static() -> bb3;
}
bb2: {
return;
}
bb3: {
goto -> bb2;
}
}
The entry calls it first (_3 = $StaticInit() -> bb1; at the top of Gen.$Module.Main). It is exported because a host that calls functions of the program without going through Main (a test, a library's user) must run it itself. In a DLL built with larva build --dll, each exported function runs the static constructors the first time it is called.
Linkage
Public types are exported (their structs appear in exported signatures); everything else stays internal to the module. Root structs such as Larva.Object and Larva.Func1 are linkonce: every module may define them and the linker keeps one. When a dependency is built for another GC, its callable static functions become imports named assembly::name, and everything that carries objects is compiled in this domain instead; see GC domains.
Edit this page on GitHub Report an issue