- Larva
- Cómo funciona Larva
- La IL y la máquina virtual
Esta página aún no está traducida al español. Leer la versión en inglés
The IL and the VM
Larva IL - its opcodes, nibble encoding and disassembler - how the IR is lowered to it, and the interpreter that runs it.
En esta página
- Why a bytecode
- The IL at a glance
- Opcodes
- Operands
- The nibble encoding
- The module file.lvm
- Reading IL: larva dis
- Safepoints with roots
- Exception handlers
- Lowering IR to IL
- Per function
- Statements and rvalues
- Ownership and drop flags
- GC cooperation
- Exceptions and cleanup pads
- The peephole pass
- The interpreter
- The loop
- The VM's garbage collector
- Threads
- Intrinsics
- Calling C from the VM
- Reflection: RuntimeModule, TypeInfo, MethodInfo
- When the VM runs
- Speed
Larva has a bytecode of its own, Larva IL, and an interpreter for it, the VM (also written LVM). The IL is the last form of a program inside Larva that is still independent of any machine: the native back ends (x64, LLVM, WebAssembly) start from the IR instead, but the VM runs the IL directly.
IR module (CFG, typed places)
│ Monomorphizer (shared generics by default)
▼
IlLowering ── per function: liveness, move analyses, drop flags,
│ GC cooperation, safepoint root maps, handler table
▼
Peephole.RemoveStoreLoadPairs
▼
IlModule ──► ModuleFile (.lvm) ──► larva dis (text)
│ └──► larva run (Interpreter)
└──► Interpreter (in larva's own process: binding profiles, hosts)
The pieces live in three places:
| Project / folder | What it holds |
|---|---|
Larva.IL |
OpCode, OpCodes (metadata), SubOps, OperandKind, Operation, OperationBlock; Encoding/ (nibble writer, reader, encoder, decoder); Optimization/Peephole; Text/Disassembler. Knows nothing about types. |
Larva.IR/Lowering |
IlLowering (IR to IL), IlModule / IlFunction (the lowered module with its tables), ModuleFile (the .lvm format). |
Larva.Runtime |
Vm/ (the Interpreter partial class, Value, Heap, NativeCalls), and the reflection view: RuntimeModule, TypeInfo, MethodInfo, RuntimeObject. |
Why a bytecode
The design keeps one executable form that needs no toolchain at all: no LLVM, no linker, no C compiler. The VM is the reference implementation every other back end is compared with. Each feature of the language arrives "in LVM, x64 and LLVM" with the same expected output, and the test programs (wide integers, floats of every width, records, destructors, GC domains) run on all three. The VM is the simplest of the three to get right, and its checks (typed memory, use after free, null handles) make it a good oracle.
It is not the fast path. The design document says it plainly after the first benchmarks: the interpreter is very slow with objects and calls, and for speed the target is a native back end. Every value carries its type, every integer is an arbitrary-precision number cut to its width, and every local lives in a separate heap allocation.
The IL at a glance
The IL is a stack machine with typed slots but untyped operations. An operation such as add does not say whether it adds i32 or i64: the operands know. Locals and arguments are numbered slots whose types come from the function's header (locals: [type.0, …]). Everything a function refers to goes through tokens into the module's tables:
| Token | Printed | Table in IlModule |
|---|---|---|
| type | type.N |
Types (IR types; in shared generic code they may be open: T0, T1…) |
| field | fld.N |
Fields (FieldRef(owner struct, field index)) |
| function | fn.N |
Functions |
| constant | const.N |
Constants (integers and floats wider than 64 bits) |
| local / argument | loc.N / arg.N |
the function's slots |
| label | L0, L1… |
an operation index inside the same function |
IR arguments _1 … _N become arg.0 … arg.N-1; the IR return place _0 becomes loc.0, and the other IR locals follow in order. After them come the hidden locals lowering adds: one bool drop flag per owner whose release depends on the path, then one pin slot per borrow taken inside a moving gc object.
Opcodes
The opcode space has two sizes. The 15 most frequent operations take 4 bits; the nibble 0xF is a prefix, and the next nibble selects one of 16 extended opcodes (8 bits). Most extended opcodes are families: their first operand is a one-nibble sub-operation, so a family has at most 16 members. OpCodes.Table is the single source of truth (mnemonic, operand layout, sub-operation table); the encoder, decoder, disassembler and interpreter all read it.
Short opcodes (one nibble):
| Code | Mnemonic | Operands | Stack effect |
|---|---|---|---|
0 |
ldloc |
local | → value |
1 |
stloc |
local | value → |
2 |
ldarg |
argument | → value |
3 |
ldc |
signed immediate | → integer (untyped until stored or converted) |
4 |
add |
a, b → a + b | |
5 |
sub |
a, b → a − b | |
6 |
mul |
a, b → a × b | |
7 |
call |
function | witnesses, arguments → result |
8 |
ret |
result → (the caller) | |
9 |
jmp |
label | |
A |
brtrue |
label | condition → |
B |
brfalse |
label | condition → |
C |
ldfld |
field | struct value → field value |
D |
stfld |
field | address, value → |
E |
dup |
a → a, a |
Extended opcodes (F + one nibble). The second column is the sub-operation nibble; operands after the sub-operation are listed per member:
| Code | Family | Members (nibble: name operands) |
|---|---|---|
F0 |
alu |
0 div, 1 rem, 2 neg, 3 inc, 4 dec, 5 and, 6 or, 7 xor, 8 not, 9 shl, A shr, B ushr, C rol, D ror |
F1 |
fpu |
0 add, 1 sub, 2 mul, 3 div, 4 rem, 5 neg, 6 sqrt, 7 abs, 8 min, 9 max, A floor, B ceil, C round, D trunc |
F2 |
cmp |
0 eq, 1 ne, 2 lt, 3 le, 4 gt, 5 ge, 6 ord (three-way: −1, 0 or 1) |
F3 |
br |
0 eq … 5 ge, each with a label (compare and branch) |
F4 |
conv |
0 to type, 1 cast type, 2 bitcast type, 3 box type, 4 unbox type, 5 sext bits, 6 zext bits, 7 trunc bits |
F5 |
obj |
0 new type, 1 newval type, 2 init type, 3 copy type, 4 isinst type, 5 castclass type, 6 ldtype type, 7 sizeof type, 8 typeof, 9 ldvtbl, A ldfldinfo field, B ldflda field |
F6 |
arr |
0 new type, 1 len, 2 ld, 3 st, 4 ldelem type, 5 stelem type, 6 copy, 7 ldelema, 8 ldfixa type |
F7 |
ldk |
0 null, 1 true, 2 false, 3 wide constant, 4 data N, 5 zero type, 6 global N, 7 setglobal N |
F8 |
stk |
0 pop, 1 swap, 2 ldmem, 3 stmem, 4 starg argument, 5 mov local, local, 6 ldloca local, 7 ldarga argument, 8 chknull, 9 isnull |
F9 |
callx |
0 virt function, 1 ind, 2 native function, 3 tail function, 4 ldfn function |
FA |
switch |
a label table (count, then labels) |
FB |
eh |
0 throw, 1 rethrow, 2 leave label, 3 try, 4 endtry, 5 catch type, 6 finally, 7 endfinally |
FC |
atom |
0 ld, 1 st, 2 xchg, 3 add, 4 cmpxchg, 5 fence, 6 lock, 7 unlock |
FD |
sys |
0 nop, 1 break, 2 trap code, 3 debug code, 4 safepoint, 5 poll, 6 intrinsic id |
FE |
gen |
0 wit type, 1 ldtarg N, 2 sizeof type, 3 drop type |
FF |
gc |
0 new type, 1 alloc type, 2 free type, 3 wb type, 4 pin, 5 unpin, 6 retain, 7 release, 8 renter N, 9 rexit N, A newarr type, B newfin type, C newtrail type, D alloctrail type |
The opcode space was designed ahead of the compiler, and not every member is produced today. IlLowering emits: the 15 short opcodes; alu div/rem/neg/and/or/xor/not/shl/shr/ushr; fpu add/sub/mul/div/rem/neg; cmp eq…ge; conv.to and conv.bitcast; obj.ldflda; arr new/len/ld/st/ldelema/ldfixa; every ldk member except null; stk pop/ldmem/stmem/starg/ldloca/ldarga/chknull/isnull; callx.ind and callx.ldfn; switch; eh.throw and eh.rethrow; sys.trap, sys.poll and sys.intrinsic; every gen member; and every gc member. Virtual calls are not an opcode: the lowering turns them into a load from the vtable and callx.ind. Exceptions are not structured opcodes either (eh.try, eh.leave, eh.finally…): they are a handler table beside the code, described below. Reserved and not produced: ldk.null, obj (except ldflda), callx.virt/native/tail, eh.leave…endfinally, atom.* (atomics are intrinsics), br.*, cmp.ord, conv.box/unbox, the alu inc/dec/rol/ror and the rounding fpu members. The interpreter rejects the obj members other than ldflda and sizeof, callx.virt/native/tail and the structured eh members with "not supported by the interpreter".
Operands
OperandKind says what an operand means and how it is written:
| Kind | Printed | Wire form |
|---|---|---|
Immediate |
42 |
signed (zigzag) varint |
Unsigned |
8 |
unsigned varint (bit widths, trap codes, intrinsic ids, global and data indexes) |
Local, Argument |
loc.1, arg.0 |
unsigned varint |
Field, Function, Type, Constant |
fld.3, fn.2, type.4, const.0 |
unsigned varint |
Label |
L0 |
in memory the absolute operation index; on the wire a zigzag offset from the next operation |
LabelTable |
[L0, L1] |
last operand only: an unsigned count, then each label as above |
An Operation is a small value: OpCode, SubOp (0 for plain opcodes) and long[] Operands. Its constructors check the layout against OpCodes: the operand count, and that only Immediate and Label operands are negative.
The nibble encoding
The IL is written as a stream of 4-bit units, high nibble first (Encoding/NibbleWriter, NibbleReader). Integers use a nibble varint: each nibble carries 3 data bits, least significant group first, and its top bit (0b1000) says another nibble follows.
| Value | Nibbles | Bits |
|---|---|---|
| 0–7 | 1 | 4 |
| 8–63 | 2 | 8 |
| 64–511 | 3 | 12 |
| 512–4095 | 4 | 16 |
Signed values are zigzag encoded first (0, -1, 1, -2, … become 0, 1, 2, 3, …), so small negative numbers stay small. Values wider than 64 bits (the constant pool, ReadUnsignedBig) use the same nibbles, without the 64-bit limit.
A block is [count:uvarint] [operation]*, and an operation is:
opcode short: xxxx (0x0 … 0xE)
extended: 1111 xxxx (0xF0 … 0xFF)
sub-op xxxx (families only)
operands varint* (layout from OpCodes)
Labels are relative to the next operation, so a jump over a few operations costs one or two nibbles. Some encodings from the listing below:
| Text | Nibbles | Why |
|---|---|---|
ldc 3 |
3 6 |
opcode 3; zigzag(3) = 6 |
ldc -4 |
3 7 |
zigzag(−4) = 7 |
ldc 1000 |
3 8 A F 3 |
zigzag(1000) = 2000 = groups 0, 2, 7, 3 with continuation bits |
stk.ldloca loc.1 |
F 8 6 1 |
prefix, stk, sub-op 6, local 1 |
brfalse L0 |
B 8 1 |
target 4 operations after the next: zigzag(4) = 8 = 8 1 |
jmp L0 |
9 9 5 |
a jump 21 operations back: zigzag(−21) = 41 = 9 5 |
ret |
8 |
a whole operation in half a byte |
The reasons are size and simplicity: the most common operations (ldloc, stloc, ldarg, ldc, add, call, ret) with small operands take one byte, a whole function body is often a few dozen bytes, and decoding needs no tables beyond OpCodes. An odd trailing nibble is padded with zero. IlDecoder is the exact inverse of IlEncoder and validates every opcode and sub-operation as it reads.
The .lvm module file
ModuleFile writes an IlModule to disk: the magic LRVA, a version byte (1), then one nibble stream:
file = "LRVA" version:byte body
body = gc entry:sv types fields constants witnesses functions metadata data globals
gc = mode:4 capabilities:uv plugin:name
types = n:uv type* fields = n:uv {owner:type field:uv}*
constants = n:uv {type bits:sv}* witnesses = n:uv {type:uv layout}*
layout = size:uv align:uv flags:4 gc:uv offset:uv*
function = name args:uv witnesses:uv argTypes localTypes roots safepoints handlers body:bytes
roots = n:uv {isArg:4 slot:uv hasOffsets:4 [offsets]}*
safepoint = op:uv roots handler = start:uv end:uv hasType:4 [type:uv] target:uv
metadata = bytes (see MetadataEncoding) bytes = n:uv byte*
It holds everything a runtime needs and nothing from the compiler: the GC mode the code was generated for, the entry function, the type, field and constant tables, the witness tables of shared generics, every function with its GC root maps and handler table, the reflection metadata, the read-only static arrays (ldk.data) and the globals (ldk.global).
A .lvm is a linked program. The step before it is the .lvo object (one module, with imports and exports; see IR objects and linking). larva link turns objects into a .lvm:
larva link -o app.lvm --entry app::main geom.lvo app.lvo
linked 2 object(s) into app.lvm (8 functions)
larva run app.lvm
7
Not implemented yet: no larva command writes a .lvo from source. larva build always goes to native code or WebAssembly, and a .queen project's backend: vm is accepted but builds with LLVM (a project's backend is only honored when it says llvm or x64). .lvo files come from the compiler library (Compiler.CompileObject, then ObjectFile.Save), which a host program can call; a project can link such files with a larvaobj: dependency. The two objects above (geom exports a Point struct, make, norm1, a generic max and identity; app imports them, and main returns 7) are built that way.
Reading IL: larva dis
larva dis <module.lvm> prints the whole module (IlModule.ToString): the header tables, then each function with its root maps, safepoints and handlers, and its disassembly (Disassembler). Each operation line shows its offset in nibbles, its encoding (one hex digit per nibble) and its text. Labels are numbered in order of their targets.
larva dis app.lvm
.gc Default (Tracing, Moving, Barriers)
.entry fn.0 (app::main)
.type type.0 = i32
.type type.1 = struct#0
.type type.2 = &struct#0
.type type.3 = i64
.type type.4 = bool
.type type.5 = T0
.field fld.0 = struct#0.0
.field fld.1 = struct#0.1
.witness type.0 = { size 4, align 4, copy|unmanaged|fixedsize, gc [] }
.reflect struct#0 geom::Point (layout) { x @0: i32, y @4: i32 } [size 8, align 4, copy|unmanaged|fixedsize, gc []]
.function app::main (args: 0, locals: [type.0, type.1, type.2, type.0, type.0, type.3, type.0, type.0, type.0])
.safepoint #2: [] ; call fn.1
.safepoint #5: [] ; call fn.2
.safepoint #7: [] ; call fn.3
.safepoint #11: [] ; call fn.4
.safepoint #14: [] ; call fn.5
.safepoint #18: [] ; call fn.6
app::main: ; 28 ops · 67 nibbles · 34 bytes
0002 36 ldc 3
0004 37 ldc -4
0006 71 call fn.1
0008 11 stloc loc.1
000A F861 stk.ldloca loc.1
000E 72 call fn.2
0010 3A1 ldc 5
0013 73 call fn.3
0015 14 stloc loc.4
0017 3C2 ldc 10
001A 385 ldc 20
001D 74 call fn.4
001F 15 stloc loc.5
0021 32 ldc 1
0023 75 call fn.5
0025 16 stloc loc.6
0027 FE00 gen.wit type.0
002B 04 ldloc loc.4
002D 76 call fn.6
002F 17 stloc loc.7
0031 06 ldloc loc.6
0033 38AF3 ldc 1000
0038 5 sub
0039 181 stloc loc.8
003C 07 ldloc loc.7
003E 081 ldloc loc.8
0041 4 add
0042 8 ret
…
.function geom::max<i32> (args: 2, locals: [type.0, type.4])
geom::max<i32>: ; 12 ops · 26 nibbles · 13 bytes
0002 20 ldarg arg.0
0004 21 ldarg arg.1
0006 F24 cmp.gt
0009 B81 brfalse L0
000C 20 ldarg arg.0
000E 10 stloc loc.0
0010 00 ldloc loc.0
0012 8 ret
L0:
0013 21 ldarg arg.1
0015 10 stloc loc.0
0017 00 ldloc loc.0
0019 8 ret
…
.function geom::identity (witnesses: 1, args: 1, locals: [type.5])
.gcroots loc.0 [witness], arg.0 [witness]
geom::identity: ; 2 ops · 4 nibbles · 2 bytes
0001 20 ldarg arg.0
0003 8 ret
Things to notice:
- Shared and specialized generics side by side. The compiler's default (
GenericsMode.Shared) compiles a generic function once and passes a witness per type parameter, except where the body needs the concrete type.max<T: numeric>compares its arguments, so it is specialized (geom::max<i32>,geom::max<i64>);identity<T>only moves a value, so one shared body serves everyT. Its slots have the open typeT0(type.5), and the call site pushesgen.wit type.0before the argument. The.witnessline is the static table the VM reads fori32: size, alignment, flags and the GC pointer map. - Calls are safepoints. The module's GC mode is tracing (
Default), so every call records the slots a collector must scan if it stops there. Here none holdgchandles, so the lists are empty. - The 8-byte struct
Pointis a local.stk.ldloca loc.1takes its address for&p. stloc loc.0; ldloc loc.0; retstays. The peephole pass (below) only removes a pair when the load is the local's only read;loc.0is read on both paths.
Safepoints with roots
A program that keeps a gc object alive across a call shows a non-empty root map. In this module, keep makes an object, calls churn (which allocates in a loop), and reads the object afterwards:
larva link -o trace.lvm --entry trace::keep trace.lvo
linked 1 object(s) into trace.lvm (2 functions)
larva dis trace.lvm
…
.function trace::keep (args: 1, locals: [type.1, type.0, type.2, type.3])
.gcroots loc.2 [0]
.safepoint #10: [loc.2 [0]] ; call fn.1
trace::keep: ; 16 ops · 42 nibbles · 21 bytes
0002 F861 stk.ldloca loc.1
0006 3E1 ldc 7
0009 D0 stfld fld.0
000B F861 stk.ldloca loc.1
000F 382 ldc 8
0012 D1 stfld fld.1
0014 01 ldloc loc.1
0016 FF00 gc.new type.0
001A 12 stloc loc.2
001C 20 ldarg arg.0
001E 71 call fn.1
0020 13 stloc loc.3
0022 02 ldloc loc.2
0024 F82 stk.ldmem
0027 C1 ldfld fld.1
0029 8 ret
.function trace::churn (args: 1, locals: [type.3, type.3, type.0, type.2, type.4])
.gcroots loc.3 [0]
.safepoint #2: [] ; sys.poll
trace::churn: ; 25 ops · 64 nibbles · 32 bytes
0002 30 ldc 0
0004 11 stloc loc.1
L0:
0006 FD5 sys.poll
0009 01 ldloc loc.1
000B 20 ldarg arg.0
000D F22 cmp.lt
0010 B84 brfalse L1
…
002B 02 ldloc loc.2
002D FF00 gc.new type.0
0031 13 stloc loc.3
…
003A 995 jmp L0
L1:
003D 20 ldarg arg.0
003F 8 ret
.gcroots is the conservative frame map (every slot that may ever hold a handle, with the byte offsets of the handles inside it); .safepoint #10: [loc.2 [0]] is the precise map at the call, from liveness: loc.2 is read after the call, so it is a root. In churn, the loop header polls (sys.poll), and its map is empty: the object in loc.3 is never read again, so the collector may reclaim it.
Exception handlers
Exceptions do not appear as opcodes around protected code. Each function has a table of ExceptionHandler(Start, End, CatchType, Handler): operations Start..End are protected, CatchType is a type token (or none for catch-all), and Handler is where execution continues with the operand stack cleared and the exception pushed.
larva link -o exc.lvm --entry exc::caller exc.lvo
linked 1 object(s) into exc.lvm (2 functions)
larva dis exc.lvm
…
.function exc::caller (args: 1, locals: [type.0, type.0])
.safepoint #1: [] ; call fn.1
.handler [#1, #2) catch type.0 -> #5
exc::caller: ; 9 ops · 19 nibbles · 10 bytes
0002 20 ldarg arg.0
0004 71 call fn.1
0006 10 stloc loc.0
0008 00 ldloc loc.0
000A 8 ret
000B F02 alu.neg
000E 10 stloc loc.0
0010 00 ldloc loc.0
0012 8 ret
.function exc::thrower (args: 1, locals: [type.0, type.1])
exc::thrower: ; 10 ops · 22 nibbles · 11 bytes
0002 20 ldarg arg.0
0004 30 ldc 0
0006 F22 cmp.lt
0009 B4 brfalse L0
000B 20 ldarg arg.0
000D FB0 eh.throw
L0:
0010 20 ldarg arg.0
0012 34 ldc 2
0014 6 mul
0015 8 ret
larva run exc.lvm -3
3
thrower throws its argument (an i32) when it is negative; the handler at #5 in caller catches an i32 and negates it. The handler block has no label in the listing: it is reached only through the table.
Lowering IR to IL
IlLowering.Lower(module, optimize, gc, source, reflection) (Larva.IR/Lowering/IlLowering) takes a module the Monomorphizer has already processed and lowers each function on its own. It interns types, fields, constants and witnesses into a TokenTable as it goes. With LARVA_TIMINGS=1 it prints how long liveness, the move analyses, dataflow and emission took, and its eight slowest functions.
Per function
- Analyses.
Liveness.Compute, then twoMoveAnalysisinstances run forward over the CFG (Dataflow.Forward): may be uninitialized (union over paths) and must be uninitialized (intersection). - Layout. Blocks are laid out in index order, so a jump to the next block disappears (
JumpUnlessNext); anifwhose else-block is next becomes a singlebrtrue. - Two passes. The body is emitted twice. The first ("planning") pass finds the owners whose release depends on the path; they get a drop flag. The second pass emits the real code with those flags allocated.
- Tables. Exception handlers, safepoints with their root maps, the conservative frame roots, the argument types, the GC domain, and
Nativefor functions that stand for a C call.
Statements and rvalues
| IR | IL |
|---|---|
copy / move of a place |
ldloc / ldarg, or address + stk.ldmem; ldfld for fields of a loaded struct |
| assignment | stloc / stk.starg, or address + value + stfld / stk.stmem (gc.wb when the GC needs barriers) |
| binary op | add/sub/mul for integers, fpu.* for floats, alu.* for division, bit operations and shifts (ushr for unsigned >>), cmp.* for comparisons |
| unary op | alu.neg / fpu.neg, alu.not, or ldk.false; cmp.eq for !bool |
&place |
stk.ldloca, stk.ldarga, obj.ldflda, arr.ldelema, arr.ldfixa (and gc.pin inside a moving gc object) |
| cast | conv.to type (nothing when the types already match) |
gc / box allocation |
gc.new / gc.alloc; with a finalizer gc.newfin; arrays gc.newarr / arr.new; in-place trailing arrays gc.newtrail / gc.alloctrail |
sizeof(T) |
ldc size, or gen.sizeof for an open T |
| intrinsic | arguments, then sys.intrinsic id |
| global | ldk.global N / ldk.setglobal N (plus gc.retain under reference counting) |
? unwrap / null test |
stk.chknull / stk.isnull |
| function reference | callx.ldfn |
| call | witnesses (gen.wit / gen.ldtarg), arguments, call fn.N or callee + callx.ind |
unreachable |
sys.trap 0 |
A shift whose left operand is a constant gets a conv.to first: ldc is untyped, and the VM would otherwise shift at 64 bits.
Ownership and drop flags
Values that own resources are released when their owner goes away: overwritten, StorageDead, drop, or still initialized at return. A box T is freed (gc.free) in every GC mode; a gc T handle is released (gc.release) only under reference counting; a shared function's T is dropped through its witness (gen.drop). The two move analyses decide each release:
| At this point the owner is | Lowering emits |
|---|---|
| initialized on every path | the release |
| uninitialized on every path | nothing |
| initialized on some paths | the release behind its drop flag (a hidden bool local set on initialization and cleared on move) |
Under reference counting, a read of a handle that is the local's last use transfers the reference instead of taking a new one (Transfers: a local nobody borrowed, read once at that point and dead afterwards). The native back ends follow the same rule, so a counted object dies at the same moment everywhere.
GC cooperation
The lowering only emits what the function's GC needs (GcCapabilities, from module.CodeGcOf(domain, gc); see garbage collection and GC domains):
| Capability | IL |
|---|---|
Tracing |
sys.poll at loop headers; a precise root map for every safepoint (polls and calls) |
Moving |
gc.pin when borrowing inside a gc object, gc.unpin at return and in cleanup pads |
Barriers |
gc.wb for stores of handle-carrying values behind a pointer |
RefCounting |
gc.retain when a handle gains an owner, gc.release when an owner dies |
Regions |
gc.renter / gc.rexit |
Exceptions and cleanup pads
Every call, throw and resume can raise. For each one, the lowering adds a handler entry per catch clause of the IR terminator. When there is no catch-all, it also queues a cleanup pad: code placed after the body that pops the exception, releases what the frame owns at that point (from the move states on the exception edge), unpins, and eh.rethrows. A pad with nothing to release is dropped, and the exception passes straight through.
The peephole pass
Larva.IL/Optimization/Peephole holds one rewrite, run when optimize is on (CompilationOptions.Optimize):
| Rewrite | Condition | Effect |
|---|---|---|
RemoveStoreLoadPairs: delete stloc X; ldloc X |
the ldloc is the only read of X in the function, X's address is never taken (stk.ldloca), and nothing jumps to the ldloc (no label, no handler start) |
the value simply stays on the stack |
That pattern is what a CFG of temporaries produces everywhere (_5 = a + b; _0 = _5). The pass returns a newIndex map from old to new operation indexes, and IlLowering.Optimize uses it to remap labels, safepoint indexes and the handler table (a removed target maps to the next kept operation). Binding profiles that run on the VM compile with Optimize = false.
The interpreter
Larva.Runtime.Vm.Interpreter (a partial class over Interpreter, .Ops, .Gc, .Intrinsics, .Invoke, .Threads) executes an IlModule. It is built for correctness, not speed:
- Typed values.
Valueis a record with aValueKind(Unit,Bool,Int,Float,Pointer,Struct,Deferred,Witness,Function) and theIrTypeit came from. Integers are arbitrary-precision numbers wrapped to their type's width after each operation (Numbers.Wrap), soi128,u256and every narrower width share one code path. Floats are doubles rounded tof32orf16when the type says so;f128andf256keep their exact IEEE bits and go throughSoftFloat(Restoration 13 in the design document). Anldcliteral has no type until it is stored or converted. - Addressable memory.
HeapholdsAllocations (id, kind, type, bytes). AnAddressis an allocation id and a byte offset, packed into 64 bits asid << 32 | offset; id 0 is null, and function references stored in memory set bit 63. Every local and argument of a running call is its ownFrameallocation, soldloca, field addresses and pointers in memory all work the same way. Reads and writes are typed (Heap.Read,Heap.Write,DecodeBytes), and touching freed memory is an error. - Frames. A
Frameholds the function, its type arguments (shared generics resolvetype.Nagainst them:Frame.Resolve), argument and local addresses, the operand stack (List<Value>), the program counter, the exception being handled, and how many arena regions were open when it started.
AllocationKind |
What |
|---|---|
Frame |
a local or argument of a running call (freed when it returns) |
Gc |
gc.new and friends: reclaimed by the GC mode |
Heap |
gc.alloc: box T or *mut T, freed with gc.free |
Buffer |
argument and result buffers of a reflective invoke |
Static |
a read-only static array (ldk.data), and globals |
Native |
memory outside the interpreter: mem.alloc, or memory a C function returned |
The loop
Execute(frame) is a switch over the opcode with a step counter. The short opcodes are handled inline; families go to ExecuteFamily and its helpers (Alu, Fpu, ArrayOperation, StackOperation, GcOperation, GenericOperation, RunIntrinsic). A Larva exception is a host exception carrying the value (LarvaException): Handle searches the current function's table for an entry covering the program counter whose type matches, clears the stack, pushes the exception and jumps. If none matches, the exception leaves the frame; Call closes the arena regions the frame left open, frees the frame and rethrows to the caller. One that reaches the host becomes UnhandledException; sys.trap becomes VmTrapException.
InterpreterOptions controls a run:
| Option | Default | Meaning |
|---|---|---|
GcStress |
off | collect at every safepoint (larva run --gc-stress) |
CollectEvery |
64 | tracing GCs collect at the next safepoint after this many allocations |
StepLimit |
50,000,000 | operations before giving up ("step limit reached in …") |
Output, Input |
the console | where print and read intrinsics go |
Arguments |
none | what args.* reads |
larva run <module.lvm> uses the defaults, so a long computation hits the step limit; binding profiles set StepLimit to the maximum.
The VM's garbage collector
Interpreter.Gc honors the GC mode the module was compiled for:
- Tracing (
Default): at a safepoint (a call orsys.poll),Collectruns when stress is on, when the program asked (gc.request), or afterCollectEveryallocations. It is a mark and sweep: the roots of each frame are the precise map recorded for the operation it is stopped at (the conservative.gcrootsmap if none), plus handles on operand stacks, the exception being handled, pinned objects, and the contents of live boxes, raw allocations and globals. It does not move objects, even when the mode saysMoving: pins are counted and checked, which is what flushes out a missing pin. Unreachable objects with a finalizer survive one collection; their finalizers run after the sweep, and a later collection frees them. - Reference counting:
gc.retainandgc.releasecount in the allocation; at zero the object is freed, or its finalizer runs first with the last reference. - Arena regions:
gc.renter Npushes a region,gc.newtags objects with the innermost one, andgc.rexit Nruns their finalizers and frees them all. - Domains: each object records the domain of the function that made it, and whether that domain counts references (
IlModule.Counts), so a mixed program counts only its counted objects.
--gc-stress is the main use of this collector: it collects at every safepoint, so a root missing from a map shows up at once as a use of freed memory.
LARVA_STATS=1 larva run trace.lvm 1000
8
steps 21025, calls 2, safepoints 1002, collections 15 (reclaimed 959), retain/release 0/0, barriers 0, pins 0
LARVA_STATS=1 larva run trace.lvm --gc-stress 1000
8
steps 21025, calls 2, safepoints 1002, collections 1002 (reclaimed 1000), retain/release 0/0, barriers 0, pins 0
The object keep holds across the call survives every collection (the result is still 8), and each of the 1000 objects churn makes is reclaimed.
Threads
Each thread (thread.start) is an interpreter of its own (its call stack, its regions) on a host thread, sharing the module, heap, globals and open streams. They take turns: one interprets at a time, with a ticket lock (Threading.Take / Give). A thread gives its turn up at a safepoint when another one is waiting, and while it waits in the runtime (sleeping, joining, for a lock or a condition). No collection runs while more than one thread is alive, because the collector only knows the call stack of the interpreter it runs on. Native executables run threads truly in parallel; the VM keeps the semantics and gives up the parallelism.
Intrinsics
sys.intrinsic id calls a runtime service. The table is Larva.IR/Intrinsics; each entry has an id, a name, a fixed signature and the C symbol native code calls (larva_print_i64…). The VM, the in-process native runtime and larva_rt.c implement the same table, so a service behaves the same everywhere. Input never allocates in the runtime: read.line reads into the runtime's buffer and returns its length, and read.unit(i) reads one unit of it; command-line arguments, directory listings, environment values and formatted floats work the same way.
| Ids | Services |
|---|---|
| 0–5, 59 | print.i64, print.u64, print.f64, print.bool, print.str, print.line; print.utf8 |
| 6–7 | read.line, read.unit |
| 8–10 | args.count, args.length, args.unit |
| 11–12 | format.f64, format.unit |
| 13–15, 33, 90 | gc.request, gc.collections, gc.objects; gc.custom; gc.domain |
| 16–18 | mem.alloc, mem.free, native.symbol |
| 19–24 | file.read, file.take, file.write, file.kind, file.delete, file.time |
| 25–28 | dir.create, dir.list, dir.length, dir.unit |
| 29–30 | env.get, env.unit |
| 31 | process.run |
| 32 | parse.f64 |
| 34–35, 60–62 | time.now, time.sleep; time.utc, time.offset, time.precise |
| 36–42 | stream.open, stream.close, stream.read, stream.write, stream.seek, stream.length, stream.flush |
| 43–58 | math.sqrt, sin, cos, tan, asin, acos, atan, exp, log, log10, floor, ceil, atan2, pow; f64.bits, f64.from_bits |
| 63–67 | thread.start, thread.join, thread.id, thread.yield, cpu.count |
| 68–77 | mutex.new, lock, trylock, unlock, free; cond.new, wait, signal, broadcast, free |
| 78–87 | atomic.load, store, add, xchg, cas for i64 (78–82) and i32 (83–87) |
| 88–89 | mem.copy, mem.cmp |
| 91–94 | net.connect, net.listen, net.accept, net.port |
| 95–96 | proc.start, proc.wait |
The runtime page describes what each service does in native code.
Calling C from the VM
A function lowered from an [Import] declaration (attribute Larva.Import, library and symbol) or a func* call helper (Larva.NativeCall, the address is the first argument) carries IlFunction.Native. When the VM calls such a function, it does not interpret a body: NativeCalls (Vm/NativeCalls) makes the C call itself.
- Symbols. The library is loaded once and the symbol looked up once (
Symbol); a missing export is aVmException. - Calls out. For each signature, an invoker is generated once at run time in larva's own process and cached by its parameter and result types; it calls the address with the platform's C convention. Arguments are numbers, pointers and
func*. A pointer into the interpreter's memory is pinned for the duration of the call (Heap.ToNative), so C sees real bytes. - Calls in. An interpreter function passed to C as a
func*becomes a native entry point, one per function and signature, that runs the function in the interpreter when C calls it. - Memory C returns becomes a
Nativeallocation of unknown size (Heap.FromNative);mem.allocmakes zeroed native memory that C can use and the program frees.
Only scalars, pointers and function pointers cross: a struct by value is "cannot cross to C in the interpreter (numbers, pointers and func* can)". Native back ends pass structs by value; see bindings internals.
Reflection: RuntimeModule, TypeInfo, MethodInfo
The reflection view (Larva.Runtime) is built from the module's metadata alone (ModuleMetadata, the .reflect lines in larva dis), as a native image would ship it:
| Type | What it gives |
|---|---|
RuntimeModule |
From(metadata) / Load(bytes); GetType(name), GetMethod(name), TypeOf(IrType) for any concrete type; WitnessFor(type) creates a witness table on first use (what gen.wit would push); New(type) allocates a zeroed value |
TypeInfo |
name, Layout (size, alignment, flags, GC pointer map), Fields (name, index, offset, type) as far as the type's reflection level allows; MakeGeneric(arguments) instantiates a generic struct at run time: it checks the constraints and computes the layout, which needs no code |
MethodInfo |
parameters, return type, the IL function and its invoke stub; MakeGeneric (shared methods accept any type that satisfies the constraints, specialized ones only the compiled instantiations); PrepareInvoke(arguments) type-checks and marshals into an InvokePlan |
RuntimeObject |
a value in memory accessed through dynamic borrows: many shared or one exclusive at a time, the borrow checker's rule enforced at run time because reflection is invisible to it |
Interpreter.Invoke(method, plan) runs a plan: it materializes the 8-byte slots and buffers in memory and calls the stub with the target function, as call stub(fn, slots, result).
When the VM runs
| Where | How |
|---|---|
larva run <module.lvm> [--gc-stress] [args…] |
Loads the module, runs its entry with the arguments (integers, floats, true/false), prints the result; LARVA_STATS=1 adds the VM statistics. A .lvm argument is what tells larva run to use the VM rather than build the current project. |
larva dis <module.lvm> |
No execution: the text form. |
Binding profiles (larva bind --profile) |
The profile program is compiled and run inside larva. On Windows x64 it runs as x64 machine code in memory (see the x86-64 back end); elsewhere it runs on the VM with no step limit. |
| Hosts and tests | new Interpreter(il).Run("name", arguments): the reference results every back end is compared with. |
Not implemented yet: a .queen backend: vm that builds and runs a project on the VM (it falls back to LLVM today).
Speed
The VM is orders of magnitude slower than native code. Two causes stand out in the code: every value is a boxed record with an arbitrary-precision integer, and the heap keeps every allocation it ever made in its table (freed ones are only marked), while a tracing collection filters that whole table several times to clear marks, scan and sweep. A collection therefore costs time in proportion to everything allocated so far, which shows on long allocating loops:
LARVA_STATS=1 larva run trace.lvm 20000
8
steps 420025, calls 2, safepoints 20002, collections 312 (reclaimed 19967), retain/release 0/0, barriers 0, pins 0
That run takes under 2 seconds; with 100,000 iterations (2.1 million steps) it takes about 16. For speed, build natively: the x64 back end compiles fast, and LLVM makes fast code.
Editar esta página en GitHub Reportar un problema