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.

Última actualización: 31 min de lectura Editar en GitHub

En esta página

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:

Terminal
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.

Terminal
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 every T. Its slots have the open type T0 (type.5), and the call site pushes gen.wit type.0 before the argument. The .witness line is the static table the VM reads for i32: 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 hold gc handles, so the lists are empty.
  • The 8-byte struct Point is a local. stk.ldloca loc.1 takes its address for &p.
  • stloc loc.0; ldloc loc.0; ret stays. The peephole pass (below) only removes a pair when the load is the local's only read; loc.0 is 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:

Terminal
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.

Terminal
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

  1. Analyses. Liveness.Compute, then two MoveAnalysis instances run forward over the CFG (Dataflow.Forward): may be uninitialized (union over paths) and must be uninitialized (intersection).
  2. Layout. Blocks are laid out in index order, so a jump to the next block disappears (JumpUnlessNext); an if whose else-block is next becomes a single brtrue.
  3. 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.
  4. Tables. Exception handlers, safepoints with their root maps, the conservative frame roots, the argument types, the GC domain, and Native for 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. Value is a record with a ValueKind (Unit, Bool, Int, Float, Pointer, Struct, Deferred, Witness, Function) and the IrType it came from. Integers are arbitrary-precision numbers wrapped to their type's width after each operation (Numbers.Wrap), so i128, u256 and every narrower width share one code path. Floats are doubles rounded to f32 or f16 when the type says so; f128 and f256 keep their exact IEEE bits and go through SoftFloat (Restoration 13 in the design document). An ldc literal has no type until it is stored or converted.
  • Addressable memory. Heap holds Allocations (id, kind, type, bytes). An Address is an allocation id and a byte offset, packed into 64 bits as id << 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 own Frame allocation, so ldloca, 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 Frame holds the function, its type arguments (shared generics resolve type.N against 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 or sys.poll), Collect runs when stress is on, when the program asked (gc.request), or after CollectEvery allocations. 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 .gcroots map 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 says Moving: 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.retain and gc.release count in the allocation; at zero the object is freed, or its finalizer runs first with the last reference.
  • Arena regions: gc.renter N pushes a region, gc.new tags objects with the innermost one, and gc.rexit N runs 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.

Terminal
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 a VmException.
  • 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 Native allocation of unknown size (Heap.FromNative); mem.alloc makes 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:

Terminal
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.

¿Te resultó útil esta página?

No se guarda ninguna dirección; tu nombre de usuario sí, si iniciaste sesión.

Esc