Internals
luvm - Qoqi Virtual Machine Specification
Goals
luvm is designed to be:
- Fast: Register-based execution with minimal overhead
- Compact: NaN-boxed values reduce memory footprint
- Simple: Clear execution model, easy to debug and optimize
- Safe: Automatic memory management via garbage collection
Value Representation
luvm uses 64-bit NaN-boxing to represent all values uniformly.
NaN-Boxing Layout
A 64-bit IEEE 754 double has this structure:
[sign:1][exponent:11][mantissa:52]
When all exponent bits are set (0x7FF), the value is NaN. We exploit this:
- If the value is a normal double (not NaN), it represents a Number
- If it's a NaN, we use the mantissa bits to encode type and payload:
[0][11111111111][tttt pppppppppppppppppppppppppppppppppppppppppppppp]
^ ^ ^ ^
sign exp=0x7FF tag (4 bits) payload (48 bits)
Tag Encoding
| Tag | Type | Payload |
|---|---|---|
| 0x0 | (Number) | infinity and NaN: floats that have this exponent themselves |
| 0x1 | Bool | 0 = false, 1 = true |
| 0x2 | Int | 48-bit signed integer |
| 0x3 | HeapPtr | 48-bit pointer to heap object |
| 0x4 | Null | (unused) |
Tag 0 is deliberately not given to a boxed kind. +Inf is the bit pattern
0x7FF0000000000000, exponent all ones and everything else zero; if null had
tag 0 it would be the same pattern, and a float overflowing to infinity would
turn into null. NaN is stored as one canonical pattern with tag 0.
Bit Masks
expMask = 0x7FF0000000000000 // exponent bits
mantMask = 0x000FFFFFFFFFFFFF // mantissa bits
tagMask = 0x000F000000000000 // tag nibble (high 4 bits of mantissa)
payloadMask = 0x0000FFFFFFFFFFFF // payload (low 48 bits)
The tag occupies bits 48-51 and the payload bits 0-47, so they never overlap. A full 48-bit payload matters for heap pointers: the Go runtime may place its heap anywhere in the 48-bit address space, and a narrower payload would truncate the pointer.
VM Execution Model
Registers and Stack
luvm is a register machine: an instruction names the registers it reads and writes, and a function declares how many it needs.
The registers of every call in progress are on one stack, a call after the other. A call takes its registers from the top (the arguments first, the rest set to null) and gives them back when it returns, so calling a function allocates nothing. Each task has a stack of its own.
- Registers: the window on the stack that belongs to the function running
- Base: where that window starts
- IP (Instruction Pointer): the instruction being executed
- Frames: for each call in progress, where its caller was: function, instruction, base and number of registers
The stack grows by moving to a larger array, so nothing keeps the address of a register. A variable a closure captured is named by its place on the stack while the call that owns it is in progress, and moves into the closure when that call returns (see closure-spec.md).
RET puts the result in the caller's r0.
Execution Loop
- Fetch instruction at IP
- Decode opcode and operands (A, B, C)
- Execute operation
- Advance IP
- Repeat
The loop reads the code, the registers and the instruction pointer from variables of its own, and takes them from the VM again after what can change them: a call, a return, a host function (which may call back into the program or let another task have the turn).
Checked before it runs
Exec verifies a chunk before running it (vm.Verify), and refuses one that
could make the VM read outside its tables:
- a function's code lies inside the chunk, it has between 1 and 65536 registers and no more arguments than registers;
- every operand that names a register names one the function has, and a call does not reach past them with its arguments;
- constants, strings, functions, host functions and captured variables named by an instruction exist;
- a jump lands on an instruction of the same function, not in the middle of a long jump;
- a function ends with a return or a jump, so that it cannot run into the next.
Bytecode from the compiler passes by construction. Bytecode damaged on the way, in a store or a file, is refused with a message that names the function and the instruction:
invalid bytecode: function 'fib': instruction 0: register r60000 of a function that has 7
Reading serialized bytecode checks every length it gives against what is left of the file before making anything to hold it, and refuses a constant that is a pointer. What depends on the values a program computes is checked when it runs: that a register holds a function when it is called, an array when it is indexed.
Chunk Structure
A Chunk represents a compiled function:
type Chunk struct {
Constants []Value // constant pool
Code []Instr // bytecode instructions
RegisterCount int // number of registers needed
ArgCount int // number of arguments
}
Opcode Families
Data Movement
MOV r_dest r_src- Copy value between registersLOADK r_dest const_idx- Load constant to registerLOADNULL r_dest- Load nullLOADBOOL r_dest value- Load boolean
Arithmetic
ADD r_dest r_a r_b- Addition, or concatenation of two stringsSUB r_dest r_a r_b- SubtractionMUL r_dest r_a r_b- MultiplicationDIV r_dest r_a r_b- DivisionMOD r_dest r_a r_b- RemainderNEG r_dest r_a- NegationSWITCH r_value count elsefollowed bycountwordsCASE name target- Go on at the target of the case that names the type of the value, or atelsefor a value of none of themONCE r_dest function- The value of a constant that is made (const RED = Color { ... };): what the function of the chunk with that number returns. The function takes nothing and captures nothing (verified), is run the first time the instruction is reached in a run, and its result is kept for the others and for the collector. The result is frozen with everything it holds:SETFIELD,SETELEM,SETINDEX,ARRPUSH,DELFIELDand the native functions that change an array or a field refuse a frozen value.ISTYPE r_dest r_value name,JNTYPE r_value name target- Whether a value is of a struct or a variant, by the name it carries; the second goes on attargetunless it isADDI r_dest r_a k,SUBI r_dest r_a k- Addition and subtraction of a numberk(0 to 65535) written in the instructionADDK r_dest r_a k,SUBK,MULK,DIVK,MODK- The same operations with the constantkof the chunk, an Int or a Float (verified): whatLOADKand the operation do, without the registerJNLTK r_a k target,JNLEK,JNGTK,JNGEK,JNEQK,JNNEK- Go on attargetunlessr_ais less than (and so on) the constantk, an Int or a FloatNOT r_dest r_a- Boolean negation
Both operands must be of the same kind (two Ints or two Numbers); there is no
implicit conversion. Int division and remainder by zero raise a runtime error,
and so does an Int result outside the 48-bit range (integer overflow): Ints
never wrap around. Number arithmetic follows IEEE 754, including infinity and
NaN.
Comparison
EQ r_dest r_a r_b- EqualNE r_dest r_a r_b- Not equalLT r_dest r_a r_b- Less thanLE r_dest r_a r_b- Less than or equalGT r_dest r_a r_b- Greater thanGE r_dest r_a r_b- Greater than or equal
EQ/NE compare strings, arrays and objects by content: arrays item by item, objects of one type field by field. An object whose type has the method __equal, which the compiler gives every type that implements Equal, is compared by calling it. A value that holds itself is compared without going round for ever: a pair met again while it is being compared counts as equal. Functions are compared by identity.
The ordering instructions accept two Ints, two Numbers or two Strings.
Heap Objects
NEWSTR r_dest const_idx- Create a string from a string constantNEWARRAY r_dest capacity- Create an empty arrayNEWOBJ r_dest [const_idx 1]- Create an empty object (used for struct values). The compiler passes the struct's name, which is recorded on the objectGETINDEX r_dest r_arr r_idx/SETINDEX r_arr r_idx r_val- Element accessSETELEM r_arr r_idx r_val- Replace an existing element; never grows the array. What the compiler emits forxs[i] = vARRPUSH r_arr r_val- Append to an arrayARRLEN r_dest r_val- Length of an array or stringGETFIELD r_dest r_obj const_idx/SETFIELD r_obj const_idx r_val- Field accessHASFIELD/DELFIELD- Field presence and removal
These instructions check the type of their operand and report a runtime error
instead of reinterpreting memory: indexing a number, reading a field of an
array, or going past the end of an array all stop the program with a message.
qoqi dump prints a program's bytecode in the notation used here.
Control Flow
JMP offset- Unconditional jumpJMPIF r_cond offset- Jump if trueJMPIFNOT r_cond offset- Jump if falseLONG+JMP32/JMPIF32/JMPIFNOT32+ offset word - The same jumps with a 32-bit relative offset, for a function that lies beyond the first 65536 instructions of its chunk. The compiler uses the short form wherever the address fits.JNLT r_a r_b target,JNLE,JNEQ,JNNE- A test and a jump in one: go on attargetunlessr_a < r_b(<=,==,!=). No Bool is made.JNLTI r_a k target,JNLEI,JNGTI,JNGEI,JNEQI,JNNEI- The same against a numberkwritten in the instruction.
Function Calls
Function Calls & Closures
NEWFUNC r_dest func_name- Create a function object (or closure) from a prototype.NEWEXFUNC r_dest extern_name- Create an external function object.CALL r_func nargs- Call a function object inr_funcwithnargsarguments.CALLK r_window nargs function- Call a function of the chunk by its number, with the arguments in the registers afterr_window. The function must capture nothing; that, and the number of arguments, are checked when the chunk is verified, not at each call.CALLX r_window nargs host_function- The same for a host function.CALLM r_window nargs name- Call the methodnameof the first argument, found by the type that value carries;GETMETHOD r_dest r_value namegives the method as a value.RET r_val- Return from function with value.CAPLOCAL r_func upval_idx local_reg- Capture a local variable into a closure's upvalue slot.CAPUPVAL r_func upval_idx parent_upval_idx- Capture an existing upvalue into a closure's upvalue slot.CLOSEUP r- Detach the closures that captured registerr: they keep its current value and stop following it.LDCAP r_dest upval_idx- Load value from an upvalue.STCAP upval_idx r_src- Store value to an upvalue.
Host Functions
A chunk lists the host functions it uses by name (ExternNames). NEWEXFUNC
turns a name into a callable object, and CALL invokes it like any other
function. The VM registers these by default:
| Name | Arguments | Result | Description |
|---|---|---|---|
print |
any number | null | Writes the values separated by spaces |
println |
any number | null | Same, followed by a newline |
toString |
1 | String | The text print would write for the value |
push |
array, value | null | Appends the value to the array |
__is |
value, descriptor | Bool | Implements value is Type: whether the value matches the descriptor |
__cast |
value, descriptor | the value | Implements value as Type: fails unless the value matches the descriptor (int, [int], ?string, `{Circle |
Values of an enum
A value of an enum is an object, like a value of a struct, created by NEWOBJ
with a third operand of 2 (vm.ObjectIsVariant; 1 is a struct). The object
carries the name Enum.Variant and holds the values of the variant as the
fields "0", "1", ...
The mark changes two things:
- Equality.
EQandNEcompare two such objects by content: the same name, and each value equal in turn, recursively, as structs and arrays are. - Printing.
FormatValuewrites them as they are made, without the enum's name:Rect(2, 3),Empty.
Testing for a variant is __is with the descriptor {Enum.Variant}, the
same check of the object's name as for a struct. A test or cast to the enum
type lists all its variants: {Shape.Circle|Shape.Rect|Shape.Empty}.
Runtime errors and protected calls
A failure is a *vm.RuntimeError: a kind (ErrorKind), a message and the
call stack, taken from the frames when it is raised. Exec returns it as
its error; Error() renders it as a diagnostic (see
errors.md).
The instruction loop is VM.run(base): it runs until the function that was
current when it started returns. Exec calls it once for main. A native
function can call it again through VM.callFunction(fn, args), which sets
up a frame as CALL does and waits for the result; that is how a native
runs a function of the program.
VM.protect(fn) is callFunction with a way back: it saves the frame
count, registers, function and instruction, and if the call fails, unwind
drops the frames above, closes the upvalues that point into their
registers, and restores what was saved. The failure is kept for the caller
instead of ending the program. It is what std.result.attempt is built on.
Native functions of the standard library
The part of the standard library that is not written in Qoqi is
a set of host functions named after the library function they implement:
std.math.sqrt, std.strings.split, std.io.readFile, and so on
(internal/vm/native_*.go; vm.NativeNames() lists them). Every VM has them.
A Qoqi module reaches one by declaring it with extern fn; the compiler
emits NEWEXFUNC with the function's full name.
They differ from the functions above in three ways:
- Arguments are checked: count and types, although the type checker has already done so for calls from Qoqi. Bytecode can be written by hand.
- Errors name the function as the program does:
std.strings.substring: range 2..9 is outside a text of 3 characters. - They may allocate more than once, which the others never do. A second
allocation can collect garbage, and what the function has built so far is
held only in Go variables, where the collector cannot see it. Such a
function pins its intermediate values (
vm.pin): pinned values are roots until the function returns.newStringArraydoes this for the common case of an array of strings.
Per-VM state: the input std.io.readLine reads from (VM.SetInput, default
standard input) and the generator behind std.random, so that seeding in one
VM does not affect another.
Values are written the way a program shows them: 42, 2.5, true, null,
the text of a string, [1, "a"] for arrays and {x: 1, y: 2} for objects
(fields sorted by name, function-valued fields omitted). This is
vm.FormatValue; Value.String() remains the debug form (Int(42)).
Output goes to standard output unless redirected with VM.SetOutput, which is
how tests capture it. More functions can be added with VM.RegisterExtern, or
with VM.RegisterTypedExtern to give the function a type
("fn(any)->string"); only a typed function can be cast to a function type.
Function signatures
Each function prototype carries a Signature: the function's type as a cast
descriptor, e.g. fn(int,string)->bool. The compiler fills it in. It is the
only type information kept at
runtime, and exists so that value as fn(Int) -> Int and
value is fn(Int) -> Int can be answered.
Bytecode file version
Serialized bytecode starts with a format version, currently 6 (1 predates the
48-bit value payload, 2 the tag of null, 3 had one-byte operands, 4 wrote
debug information once per instruction;
5 did not list the priv fields of each struct, which std.reflect leaves
out).
The same chunk is always written as the same bytes. A file of
another version
is refused with unsupported bytecode version rather than misread; rebuild it
from source.
Operand Limits
Every operand is 16 bits (vm.MaxOperand = 65535), which bounds what a single
function or chunk can address:
| What | Limit | Reported by the compiler as |
|---|---|---|
| Registers per function | 65536 | E6001 |
| Distinct constants per chunk | 65536 | E6002 |
| Functions per chunk | 65536 | E6003 |
| Arguments per call | bounded by the register window | E6007 |
Strings have no length limit of their own: a string constant stores an index into the string pool, and bytecode files encode string lengths in 32 bits. What counts against the constant limit is the number of distinct strings, field names, floats and integers outside 0-65535.
Operands were one byte until format version 4, and each of these limits was 256: a program with a few dozen structs ran out of constants.
Instruction Encoding
Every instruction has an opcode and three operands:
[opcode:8][A:16][B:16][C:16]
In memory this is the Instr struct. In a .lc file an instruction takes 7
bytes, operands in little-endian order.
The offset word of a long jump (LONG, JMP32, offset) is not an
instruction: it holds a signed 32-bit offset, the high half in A and the low
half in B (EncodeOffset32 / DecodeOffset32).
- opcode: Operation code
- A: First operand (usually destination register)
- B: Second operand
- C: Third operand
Memory Management
Garbage Collection
luvm uses mark-sweep garbage collection:
- Mark Phase: Starting from roots (the registers of every call in progress, of every task), mark all reachable objects. Objects found are put on a list to be looked into, rather than followed by a call each: a list of a million nodes is marked without a million calls inside each other
- Sweep Phase: Iterate through heap, free unmarked objects
When it runs. The first collection happens once 1 MB has been allocated. After each collection the threshold is set to twice what survived (never below that first megabyte), so collections spread out as the live data grows and the total work stays proportional to what the program allocates. Everything that takes memory is counted, including an array's growth as it is appended to and the fields added to an object.
The limit. A program may hold 256 MB at once by default
(NewVMWithHeapSize, or --heap-mb=N on the command line). An allocation that
would exceed it first triggers a collection; if that does not make room, the
program stops with out of memory.
Function objects are reused. NEWFUNC for a function without captured
variables and NEWEXFUNC for a host function return the same object every
time, so calling a function by its name allocates nothing. A closure
(UpvalueCount > 0) gets a new object each time, since it carries its own
captured variables.
Call depth
Calls may nest 10000 deep by default (VM.SetMaxCallDepth, --max-depth=N);
beyond that the program stops with stack overflow. The frame stack grows as
needed, so the limit is a guard against runaway recursion, not a reserved
size. The registers of all the calls in progress are bounded as well, at 256
per call the depth limit allows.
Fields
A struct value holds its fields in slots, and shares with every other value made the same way a shape: which field is in which slot. Adding a field moves an object to the shape of those that have it; values of one struct all end up with the same one.
The shape also holds what the values of a type have in common besides their
fields: the name of the struct, or Enum.Variant for a value of an enum. A
value is then its shape and its slots, 48 bytes before the fields, and
NEWOBJ says how many fields it is about to be given, so that the value and
the room for them are one allocation (up to 16 fields; more are a second).
A value of an enum that holds nothing, Light.Red or Option.None, is
made once per place that makes it and handed out each time: nothing can
tell two of them apart, and nothing can change one.
Asking what a value is, as every case of a match on an enum does, is
JNTYPE value name target (or ISTYPE dest value name): it compares the
name the value's shape carries. The instruction remembers the shapes it
answered yes and no for, so the names themselves are compared once each.
A match with three cases or more is one SWITCH: the cases are gone
through the first time a type is met, and where its values go on is kept
with the type, so that the twelfth variant costs what the first does.
A call through a trait finds its method the same way (CALLM window nargs name, or GETMETHOD dest value name): the method area of a value of
Square is the function Square.area of the program, and of a value of
the variant Shape.Circle the function Shape.area. The functions found
are kept with the type, and each instruction remembers the type it met
last. No value holds its methods.
An instruction that reads or writes a field remembers the shape of the last object it met and where the field was. While it keeps meeting values of the same struct, which is nearly always, the field is found by comparing one pointer, without looking the name up. A struct value takes the room of its slots; the names are kept once per shape.
Texts
Positions in a text are characters; its bytes are UTF-8. A text of plain
ASCII is indexed directly. For any other, where every 32nd character starts is
worked out the first time a position is asked for and kept, so text[i],
the length and substring do not depend on how long the text is. The
texts of one ASCII character, which is what indexing mostly gives, are made
once each.
A text written in the program is one object, made the first time the
program reaches it: a text never changes, so a loop that compares each
character with "." does not make a "." at every turn. Two texts joined
(a + b) that come to 64 bytes or less are one allocation, the object and
its bytes together. A text is 48 bytes before its characters.
When the VM itself goes wrong
A Go panic in the VM or in a native function does not take the process down:
Exec turns it into an error of kind internal (R0010), with the place the
program was at, and the VM can run the next program. Nothing in the program
catches it.
Stopping a program from outside
VM.Interrupt() may be called from any thread. The program ends with an error
of kind interrupted (R0011) that says where it was: at its next call or
jump backwards if it is computing, at once if it sleeps. attempt does not
catch it and no defer runs. The qoqi command does this on Ctrl-C, and
exits with status 130.
Programs that are not trusted
A program written by someone else, or made by a machine, gets more limits
than the two on memory and depth, set by its host before Exec:
SetStepLimit(n): the program ends with an error of kindbudget(R0012) afternsteps, a step being a call or a jump backwards. Steps are counted, not time, so the same program ends at the same place on any machine;PermitNatives(allow): which native functions it may use, by name. Reaching for another is a failure of kindlibrary;ExecContext(ctx, chunk):Exec, stopped when the context is done.
internal/sandbox puts these together: a small heap, a bound on steps, depth,
output and time, and only the natives that compute (no files, network, other
programs, clock or exit). It is what the fuzzers run programs in.
Threads
A VM runs one program at a time, and a program one task at a time: the heap, the collector and the registers are only touched by whoever has the turn, which is what lets them go without locks. From any thread, at any time:
Exec: a second program waits for the first to end;Interrupt;- anything on another VM. VMs share nothing that changes, so several run side by side, one per thread, each on a processor of its own.
Everything else configures a VM (SetOutput, RegisterExtern, the limits)
and is done before Exec. make test-race runs the tests under Go's race
detector.
Tasks of one program take turns on one processor. Running them on several would take a collector that stops every thread at a safe point, and allocation and objects safe to use from two threads at once.
What the process holds
The heap limit is on the values of the program. qoqi also watches what
the whole process holds, and ends it with out of memory when that passes
four times the limit plus 1 GB: a guard for what the count does not cover,
which would otherwise only stop when the machine has no memory left
(internal/memguard). The test packages that run programs use the same guard,
at 4 GB.
Heap Object Header
Every heap-allocated object begins with:
type ObjHeader struct {
Type ObjType // object type (string, array, closure, etc.)
Marked bool // GC mark bit
Next *Obj // linked list for GC
}
Object types:
StringObj- Immutable stringArrayObj- Dynamic arrayObjectObj- Slots and a shape (struct and enum values)FunctionObj- Function closureExternFunctionObj- Host functionUpvalueObj- Captured variable
Example Execution
Given this bytecode:
LOADK r0 0 ; load constant 0 (value: 10) into r0
LOADK r1 1 ; load constant 1 (value: 20) into r1
ADD r2 r0 r1 ; r2 = r0 + r1
RET r2 ; return r2
Execution:
- r0 ← 10
- r1 ← 20
- r2 ← 10 + 20 = 30
- Return 30
Design Decisions
- Register-based: Reduces instruction count vs stack-based VMs
- NaN-boxing: Efficient value representation without heap allocation
- Simple ISA: Easy to implement, optimize, and debug
- Fixed-width instructions: Fast decode, predictable performance