Diese Seite gibt es noch nicht auf Deutsch, deshalb steht sie hier auf Englisch. Zur englischen Seite
The Bytecode VM#
Status: implemented — decided on 2026-09-23 as the start of milestone 7 (docs/BACKEND.md section 5 and
rows 7.1-7.2); every slice is in (section 9): the bytecode and torb ir --bytecode (compiler/src/backend/bytecode/),
the kernel of std/machine with runtime/machine.c and the generated table of thunks, and the interpreter
(compiler/src/vm/). tools/conformance.sh --vm runs every program of the suite, binary-only/ included, and the
programs of vm-only/, and compares them byte for byte with their native run - a program with a .workers file on
that many workers and on one. test/group, keys compared by a program's own equals and tasks run through the
call back of section 10, tasks run on every worker of the pool (section 4), Array<Item, Size> is inline words, the
leak gate holds the VM to "live blocks at exit: 0" as it holds a native binary, torb test runs a test suite with
the native binary's report, and fibonacci(30) interprets in about 0.15 s (section 8). torb run and torb test
run in the VM by default and --native builds a binary instead; torb build is always native. The compiler's own
test suite runs natively in the gates (torb test --native compiler/tests): it checks and lowers whole programs
thousands of times, which the VM interprets many times slower than the binary does, and everything else runs in both. A program the VM runs
loads a receiver script from a path only known while it runs, which the running torb checks and lowers into it as a
continuation (docs/design/SCRIPTS.md slice 7), and torb build --embed-vm builds a native binary that embeds the VM
and runs the program's bytecode (section 11).
TorbScript has two back ends that read one IR. The C back end turns it into a native binary; the VM turns it into
bytecode and runs that inside torb, for torb run and torb test, the sandbox (7.4), project.trb as a script
(7.5) and the REPL (7.6). Design principle 5 is the whole requirement: nothing observable may differ between the two. Output,
exit codes, panic messages and sites, the order in which close() runs and whether a block leaks are observable, so
all of them are decided once, in the IR, and executed by both back ends as they were decided.
CheckedProgram ─► lowering ─► devirtualize ─► ownership ─┬─► C source ─► cc ─► native binary
(the final IR) └─► bytecode ─► VM (TorbScript, inside torb)
│
the same runtime/ ◄─────────┘ natives, text, lists, maps,
(linked into torb) counts, panics, the leak counter- 1. What the VM interprets — the final IR, nothing earlier
- 2. Values: typed words in the runtime's own representation
- 3. The bytecode — chunks, instructions, places, the constant pool
- 4. Frames and calls — one word stack per task, references as words
- 5. Generics, witness tables, closures
- 6. How the VM reaches the runtime — the kernel of natives and the thunk table
- 7. Releases and destructors — identical to the C back end, by construction
- 8. The gate: C and the VM agree
- 9. Slices
- 10. Open
- 11. A native binary that embeds the VM —
torb build --embed-vm
1. What the VM interprets#
Decision: bytecode is emitted from the final IR - the IrProgram that emitProgram of the C back end reads:
after lowerWorkspace (finishProgram, devirtualizeProgram, rewriteElementPlaces, dropUncountedMakeUnique,
dropProvableChecks) and after insertOwnership. The emitter takes the same set of functions the C back end emits
(emittedFunctionsOf: the kept functions plus the destructors their releases need).
- Because every decision is then shared. Monomorphization, layouts and representation classes, which calls are
direct after the devirtualization, which overflow checks the range analysis dropped, and every
Retain,Release,MoveandMakeUnique: the VM re-derives none of it. A leak or a copy is the same in both back ends because it is the same instruction. - Because the IR was built for it (
docs/BACKEND.md1.1): blocks over numbered slots are a register machine already, so the bytecode is a near 1:1 encoding and not a second lowering. - What the C back end refuses, the VM refuses. A construct the C back end reports as unsupported (a call that
passes a witness table, a
Contentsstep) is reported by the bytecode emitter too, with the same kind of finding, and the program does not run. The VM never becomes the more permissive back end. The one exception is a receiver script, which is not a program but the one construct defined to be interpreted: its limits and its recoverable panic are properties of an interpreter (docs/design/SCRIPTS.md section 1).
The bytecode lives in memory; a file format (.torbc, a version and a hash of its inputs) is milestone 8, together
with the IR cache.
2. Values: typed words in the runtime's own representation#
Decision: a register is a 64-bit word, and a value is the C runtime's own bytes spread over as many words as it
needs. There are no tags: the IR gives every slot a type, so the emitter chooses a typed opcode (add.i64,
move and a kernel retain with the shape of the value) and the interpreter never inspects a value to learn what it is.
| IR type | Words | What the words hold |
|---|---|---|
Integer, Boolean, Character, VoidType | 1 | the value, sign- or zero-extended to 64 bits |
Floating | 1 | the bits of the double |
Text | 2 | the torb_text struct: the storage pointer, then offset and length |
Runtime(ListStorage) | 2 | the torb_list struct |
Runtime(MapStorage), Runtime(SetStorage) | 1 | the torb_map / torb_set struct (one storage pointer) |
Runtime(TaskObject/ChannelObject/FileHandle) | 1 | the pointer to the runtime's counted block |
Record/Tuple/Variant, Boxed | 1 | the pointer to a counted block (below) |
Record/Tuple, Inline | sum of the fields | the fields, flattened in declaration order |
FixedArray (Array<Item, Size>) | Size times the item's | the items, one after the other; an item step checks the index against Size |
Variant, Inline | common fields + 1 + largest group | the common fields, the tag word, then the group of the variant |
Variant, Niche | the payload's words | exactly the payload; None is a zero first word, as in C |
Closure | 2 | the function (its index plus one, so zero is the niche) and the environment pointer |
Object(bounds) | 1 + bounds | the payload block, then one witness table (index plus one) per bound |
SharedObject | 1 | the pointer to the object's counted block |
- Because a value must cross between the VM and the runtime without marshalling. A
Stringthe VM holds is atorb_text, a list is atorb_list:String.trim,ArrayList.appendandprintare the functions ofruntime/called on the very words in the register file (section 6). There is one list implementation and one text implementation in the process, and both back ends use it. - Because the counts are then the counts. A retain is
torb_retainon the same pointer the C back end retains, a release of a string istorb_text_release, and the live-block counter ofruntime/memory.ccounts the VM's blocks exactly as it counts a native binary's. - Not tagged values. A tagged
Valueof the host language would put a second representation of every type next to the runtime's, a conversion at every native call, and counts that the host language manages instead of the IR - the moment of a release, and with it the moment of aclose(), would no longer be the IR's. - Inline layouts are the VM's own. Field offsets and slot sizes are the back end's business (BACKEND section 0):
there is no
sizeof, no offset and no reflection in the language, so a record of twoInt32fields is two words in the VM and eight bytes in C, and nobody can tell. The representation class - inline, boxed or niche - is the IR's, because it decides where a count is. - Sub-word values are widened to a word everywhere the VM stores them itself - a register, a field of a block. A
value the runtime stores keeps the runtime's size (
torb_element_textis sixteen bytes, two words). - An element of a container is stored in the C back end's width - decided, since the network: an integer narrower
than a word, a
Booland aCharare a byte, two or four in a list, a map, a set, a task's value or a channel. A function of the runtime reads the bytes of aList<UInt8>(torb_network_sendrefuses any other element size), so a list the VM makes has to be the list the runtime expects. The element descriptor is a narrow one of the kernel's (ElementKind.Narrow, equal by its bytes, hashed as the language hashes the number,torb_hash_u64of the widened word -element-hashesprints the hashes of such containers in both back ends); the conversions are where bytes move between a register and the storage: a place that ends in such an element loads and stores through the kernel (NarrowLoad,NarrowStore) in the element's width, sign- or zero-extending; a value handed to the runtime by address is its word, whose low bytes are the narrow value; an element the runtime writes through avoid *lands in a zeroed word and a signed one is widened (Widen); a task's value goes into its slot in its own width; and avarargument that is such an element is read into a word for the call and written back after it, which exclusive access makes the same as a reference into the storage - a reference always points at words. AFloat32element stays a word: no function of the runtime reads one out of a list, and itsequalsis the program's.
A counted block of the VM is torb_allocate(8 + 8 + 8 * words, kind): the torb_header of runtime/torb.h, one
word naming the block's shape (which of its words are counted, and how), then the words of the value. The shape
word is what lets code that did not create the block release it: an element of a list the runtime frees, the payload
of a trait-typed value, a closure's environment. C keeps the same knowledge in a drop function pointer (in the
environment, in the witness table); the VM keeps it in the block because it has no C function per layout.
3. The bytecode#
Register based, one Chunk per function, one List<Int64> of code per chunk. Each IR instruction becomes one
bytecode instruction (a few become two: a read through a var parameter that is also an operand, section 4), and the
blocks disappear into code offsets.
instruction = head word, then its tail words
head: bits 0-7 opcode
bits 8-23 operand A a register offset, a count, or a small index
bits 24-39 operand B
bits 40-55 operand C
tail: one full word per operand that does not fit a head field: a function index, a jump target, an
argument register, a place index, a location index- Operands are word offsets into the frame, not slot numbers: the emitter lays the frame out once (every slot at
an offset, with its width) and the interpreter adds the frame base. A frame of more than 65535 words, and anything
else that does not fit sixteen bits, is a finding of the emitter; a
wideform of the head is the answer when the first real program needs it, and nothing has yet. - Jump targets are absolute code offsets in a tail word, because a chunk of more than 65535 words is plausible (the compiler's largest functions) and a jump is rare next to the arithmetic around it.
- A place is compiled once, into the chunk's place table: the base register, whether the base is a
varparameter (whose word is a reference, section 4), and one step perPathStep-offset kfor a field, a tuple field or a variant field (a field of aBoxedvalue is a dereference first, exactly as->is in C), andelement index missing atfor an element of a contiguous list.Read,Write,MakeUniqueand every argument of avarparameter name a place by its index. - The constant pool is per program: every static the code names, as the words it places into a register - an
integer, a float's bits, an immortal
torb_text, the flattened words of an inline aggregate. A constant that is text or a boxed aggregate is immortal, as the C back end's static data is, so a retain or a release of it is a no-op in both back ends. - Witness sources, switch tables, argument lists and captures are tail words or entries of per-chunk tables, never values built at run time.
- Locations are an index into the program's location table, which the kernel turns into
torb_locationvalues once when the program is loaded, so a panic printsat path:line:columnfrom the same table the C back end's#linedirectives come from.
torb ir --bytecode <path> prints the disassembly: per chunk its frame (slot, offset, width, type), its places, and one
line per instruction at its code offset. The format is deterministic, which is what the snapshot tests compare.
4. Frames and calls#
Decision: one word stack (a List<Int64> of the interpreter, reserved at its largest size before the first
instruction, so it never moves), a frame is a window of it, and a call's return record lies in the stack too. A call
pushes a frame of the callee's word count on top of the caller's, copies each argument's words into the callee's
parameter words, and writes the return record (code offset, base, chunk, result register) - the result's register
counted in the caller's frame, -1 for none - into the four words right below the callee's frame - the upper half of
the caller's scratch words, which nothing uses while the callee runs; return copies the result words into the
caller's result register and goes on where the record says. A loop knows how many calls it made, so its bottom frame's
return ends it.
- A
varparameter receives one reference word: an address. The IR passes a place (Argument.place), and the VM passes where that place is - a register of the word stack, whose address stays valid because the stack never moves, or storage inside a counted block (an interior pointer, whichMakeUniquemade safe to write through, exactly as in C). A place through avarparameter continues from the address it holds. (The kernel still reads an odd word as the registerindex * 2 + 1, which is what the interpreter passed before its registers stopped moving.) - The loop reads everything by address. The chunks are laid out once as an image (
Imageofcompiler/src/vm/interpret.trb): every chunk's code in one list with its jump targets, resume points and stop paths relocated, a header of ten words per chunk (where its code starts, its frame, whether it takes an environment, its parameters, places and witness entries, its location, its task register, its resume points), four words per parameter, and the witness tables as words. The kernel answers the address of each list once (ListAddress,RegisterAddress), and from then on the loop reads a head word, a register or a header with oneMachine.loadand writes with oneMachine.store- bothstatic inlineinruntime/include/torb.h, so neither is a call. - A slot that is a
varparameter is read and written through its reference. C writes*pwherever the IR names such a slot; the emitter writes an explicitload/storethrough the parameter's place around an instruction that uses one as a plain operand, so every other opcode reads and writes plain frame words. - Results by pointer do not exist in the VM.
ResultMode.ByPointeris a property of the C calling convention; a VM result is words copied into the caller's register, whatever their number. - Tasks fit because frames are data - decided: the runtime's own scheduler, and a frame saved into the task block.
The IR's state machine (
FunctionKind.TaskResume,Suspend(state, awaited, observes),Terminator.Stop) decides everything observable, and the VM encodes it as the C back end does: a task's body keeps its task in one more register after its slots;cancelledis the check at the entry and at every loop back-edge,suspendrecords the state and waits -torb_task_awaitfor anawait(), which takes over a cancellation of the awaited task,torb_task_observefor aresult(), which only observes it (docs/design/CONCURRENCY.md sections 8 and 16) -resume- where the body goes on, at once or later - is the check and the outcome read once,finishmoves the value into the task block, and every stop path releases whatir/suspension.trbsays is live and ends instop.TaskNewmakes the task over one resume function ofruntime/machine.cwith room for the chunk's number and the frame's words, moves the arguments into the frame and starts it pinned to the running worker. When the scheduler resumes it, the kernel calls the interpreter back (section 10): the frame is copied out of the task block above everything in use and the body goes on at the offset of its state; where it suspends again, the frame goes back into the block and the body answersTORB_POLL_SUSPENDED. Only the bottom frame of a body suspends, because the IR makes everyawaitone of the body itself. The entry file of a program that waits is the main task (RunMain), whosetorb_task_end_mainmakes the run end withTORB_EXIT_CANCELLEDwhere it ended cancelled, one that starts tasks and does not wait hands the scheduler the rest once it is done, andtorb_scheduler_finishcancels what still runs -main's order in the C back end. The entry cells of a top-levelconst(entry.cell, a gap of the constant pool per cell and per flag) are released after all of it by one chunk, which skips a cell its flag says was never set; a program that ends inProcess.exitnever gets there, so the run hands the kernel that chunk first (OnExit) andtorb_process_on_exitruns it through a call back, as the nativemainhands itentry_cells_release. - Tasks run on every worker of the pool - decided: registers and an interpreter per thread, the program shared by
address. Every thread that may resume a task - the workers of
torb's own pool after the first, and the threads of its blocking pool - getsRegistersof its own before the first instruction (workersForofcompiler/src/vm/interpret.trb): its own words for its frames, a stack of a million words, and an image that has the program's addresses and none of its lists, installed as that thread's interpreter (InstallFor, thenMachine.install). What the threads share - the constant pool with the cells in its gaps, the code, the headers, the places and witness entries, which are words of the image for this reason - they only read by address, so no count of the interpreter's is ever touched by two threads, which is the memory model's one rule (docs/design/ CONCURRENCY.md section 2). The kernel keeps one interpreter and one queue of destructors per thread. A task is started portable where the C back end's test says its frame may cross:TaskNewcarries the runtime's tests of the frame's texts, lists, maps and closures, written from the IR types ascrossing.trbwrites them, and a type that never crosses pins it. The copy at the crossing is the C back end's too: whereprivateOfsays every value of the frame may be copied soundly,TaskNewcarries each counted value's register and shape, and while the pool has more than one worker the kernel makes them private by walking the shape - a text bytorb_text_privatize, a list, a map or a set by the runtime's with what makes one element private (nothing for a plain one, the runtime's for a text, the walk of the slot's shape for a record of the program), a task or a channel of plain values and a shared environment as they are - and starts the task portable. A closure's environment is shared (Share,torb_share) where every capture may cross without a transfer, as the C back end shares it, so a closure in a task's frame no longer pins it; the kernel releases a shared block with the runtime's atomic count. A module constant is built under the runtime's lock of constants and published with a release, as the C accessor does (ConstantLock,ConstantUnlock). A thread of the pool that runs a task of the program counts what its scheduler frees as the program's, so the leak gate stays exact whichever worker ran what. A program that runs scripts keeps its tasks on one thread, because the sandbox is the whole process's. - The recursion limit is the VM's own (a word stack of bounded size), and exceeding it is the same
panic: stack overflowwith the site of the function entry that C reports. How deep a program may recurse before it panics differs between the two back ends - as it already differs between two machines of the C back end, whose limit is the native stack - and nothing else about it does.
5. Generics, witness tables, closures#
- Monomorphic by construction. The IR holds one function per instance, so a chunk is a function instance and a call names a chunk by the function's index. Mangled names appear only in the disassembly.
- A witness table is a list of chunk indices plus the ids of its nested tables.
CallWitnessresolves its source at run time - a static table, or the table word of a trait-typed value, then onenestedstep per supertrait - and calls the member. It does what the C back end's witness thunk does: the receiver is the payload out of the value's block (its words for aselfby value, its address for avar self), and a parameter the member takes owned is retained first, because the erased convention borrows every operand. - A closure value is the function index and the environment pointer. A function whose first parameter is an
Environmentrecord receives the environment there; a named function used as a value has none and ignores it - the job the C back end'sF_thunks do. The VM always allocates the environment; the C back end's frame environment of a closure that does not escape is an optimization whose only trace is the live-block counter's peak, never its end. - Element descriptors are made at load time from the IR's
ElementDescriptor: a trivial element gets a descriptor with no callbacks and a size of words, aStringelement istorb_element_text. A counted element of the program's own types needs callbacks the runtime can call into the VM with (section 7).
6. How the VM reaches the runtime#
The VM is TorbScript compiled into torb by the C back end, so it is ordinary machine code linked against runtime/.
What it needs beyond TorbScript is a way to put the words of a register in front of a C function.
Decision: a kernel of ten natives, Machine of std/machine, and one table of thunks generated from the manifest.
operate(var words: ArrayList<Int64>, base: Int64, code: ArrayList<Int64>, at: Int64): Int64
code[at] names an operation of the kernel's table, code[at + 1 ...] are its operands (register offsets from
base, location indices, sizes). The operation reads and writes the words directly and answers one word.
load(address: Int64): Int64, store(address: Int64, value: Int64)
one word of a counted block: a field the interpreter reads or writes through a place
loadAt(base: Int64, index: Int64): Int64, storeAt(base: Int64, index: Int64, value: Int64)
the word index words after base - a register of a frame, a word of the code - its address base + 8 * index
computed by the machine, wrapping, without the overflow checks of TorbScript's arithmetic (section 10)
countUp(block: Int64): Bool, countDown(block: Int64): Bool
one count more, or one count less that is not the last, of a block of this thread: the retain and the release
of a value of one pointer without a kernel call, or false where they are the kernel's after all (section 10)
placeText(var words: ArrayList<Int64>, at: Int64, text: String)
an immortal copy of the text, as two words at the register at: how the constant pool gets its strings
placeFloat(var words: ArrayList<Int64>, at: Int64, value: Float64)
the bits of the float at the register at: how the constant pool gets its floats
install(interpreter: (Int64) => Int64)
the closure the kernel calls back into the interpreter with, where the runtime has to run code of the program:
a test body behind its recovery point, an equals or a hash a map asks for, a task the scheduler resumes- The table (
runtime/machine_natives.c, written bytorb natives --headernext totorb_natives.h) has one thunk per.Ready.Runtimerow of the manifest, in the manifest's sorted order, generated from the row's C prototype: each argument is read from the register the operand names (a struct bymemcpy, an integer by a cast, a pointer parameter from a reference word or as the address of the register), the runtime function is called, and the result is written back. The word an out parameter (void *out) points at is zeroed first, because an element the runtime describes itself may be narrower than a word - the flag a deadline task answers.testandgrouptake a closure the runtime calls, and a closure of the program is a function of the bytecode: their thunks call substitutes ofruntime/machine.cinstead, which handruntime/test.ca C closure that calls back into the interpreter (section 10), so a failing test body lands on the runtime's own recovery point. A function no thunk can be written for -Machine.installitself - gets one that panics with an internal error, so the numbering stays the manifest's.runtime/machine.cadds the operations only a VM needs: allocation of a counted block with its shape word, the shapes and the counts of section 7, float arithmetic and conversions, checked arithmetic of every width with the runtime's own panic functions, the tables of locations and element descriptors, the program's arguments (Process.arguments()answers them, nottorb's), the immortal region a module constant is built in, and the sandbox of a receiver script: opening and closing it, the stop it recorded, and a text copied out as code points (docs/design/SCRIPTS.md sections 4 to 6). - One native, many operations, is deliberate. A new native is two commits and a refreshed seed (CONTRIBUTING, "Two commits for a breaking change"). A new operation is a new row of a C table and a new constant of the emitter, in one commit, because the manifest does not change. The kernel natives are the only natives the VM will ever need, apart from the inline words of the loop (section 10).
- Why the generated table and not a VM-side adapter per native. "Every
nativedeclaration has to be rebuilt by every back end" (CONTRIBUTING) is exactly what a table generated from the manifest's prototypes avoids: a native added toruntime/reaches the VM when the header is regenerated, andcompiler/testsfails when the file on disk is not what the manifest generates. - What stays TorbScript. The dispatch loop, the frames,
Int64arithmetic with its overflow checks (a check that fails calls the kernel for the panic, so the message and the site are the runtime's), comparisons, jumps and the moving of words. A kernel call costs one C call; a word the loop reads or writes costs a load or a store, becauseMachine.loadandMachine.store, andMachine.loadAtandMachine.storeAtbeside them, are the natives the runtime definesstatic inlineintorb.h: the prototypetorb_natives.hwrites after each takes its internal linkage (C11 6.2.2), so a read or a write of a word is no call. The first pair needed no seed to learn anything; the second is a new pair of the manifest, which takes the two commits of a new native (section 10), and so isMachine.countUpandMachine.countDown,static inlineas well.
std/machine is not for programs. Its operations trust their operands the way machine code does - a wrong register
offset is a wrong write - and a sandboxed script cannot import it, because a sandbox's capabilities are its import list
(BACKEND 5.4).
7. Releases and destructors#
Decision: the kernel retains and releases, in C, over a mirror of the shapes; the interpreter runs the destructors. Revised from the first plan (a walk in TorbScript), because the runtime itself has to release the VM's values too - an element of a list, a map value it replaces - and two walkers would be two orders.
- The shapes are defined once, when a program is loaded (
DefineShape), andretain/releaseare one kernel call each: the counted words of the value in the order the C back end's helpers visit them - the common fields, then the group of the variant the tag names, or all of it reversed whereemitDropHelperreverses it. A counted block of the VM carries its shape word, so a block is released the same way whoever releases it. - A counted element of the program's own types gets a descriptor whose callbacks walk its shape. The runtime hands a
callback the element and nothing else, so the callbacks come from a pool of 64 slots in
runtime/machine.c, one per counted element type; a trivial key of several words is compared and hashed by its words in the same way, which is exact for integers,Bools andChars (the bytecode says which keys qualify,comparesByWords). A key the program compares with anequalsof its own - aStringfield, a float - gets a slot as well, whoseequalsandhashcall back into the interpreter (section 10) with the element's address: the chunks of the program'sequalsandhashrun on the words of the element, borrowed, exactly as the C back end's element helpers call them. close()runs where C runs it. A release that drops the last count of a block whose shape has a destructor queues the block instead of freeing it. After every kernel call that answered a queued count, the interpreter takes the batch out, and for each block lends it a count (torb_closing_begin), runs itsclose()as one moreexecuteon top of the words in use, takes the count back (torb_closing_end), and releases its fields - and what that release queues runs before the next block of the batch. That is the depth-first order of nested C drop functions, and nothing but a destructor can tell that it ran after the kernel call instead of inside it, because the runtime writes no output of its own. A release outside every operation of the loop closes at once: the scheduler of a worker that frees the value of a task nobody waits for, or the scheduler an operation runs (RunMain,SchedulerRun, the wait of a test), releases where no loop will look at the queue before the program goes on, so the kernel counts per thread how many operations it is inside and, at zero, runs the queue there through a call back (TORB_REQUEST_CLOSE) - where the C back end's drop function would run (destructor-unawaited-result).- A value of one counted pointer is counted by the loop. A
kernel retainorkernel releasewhose shape is one counted word that holds its block - a record or a boxed variant, a closure's environment, aString, a list - says so in its head (Bthe register,C1 or 2), and the loop counts the block withMachine.countUporMachine.countDownwithout the kernel call where the block is this thread's and keeps a count. A shared block, the last count, a count of zero and a count inside a sandbox answerfalseand take the kernel call above, so the destructor queue, the shared atomics and the live-block counter see what they saw before (section 10). - A release nests at most 256 drops deep. The kernel hands a block whose last count it drops to the runtime's
torb_release(ortorb_free_counted, for a shared one) with the walk of its shape as the drop function, as the C back end hands it aD_function, so the runtime's bound on nested drops holds in both back ends and a chain of a million nodes is released in bounded stack (docs/design/DESTRUCTORS.mdsection 2a). - A panic runs nothing on the way out, in the VM as in C (gap 9): the kernel calls the runtime's panic function, which prints and leaves with 101.
8. The gate: C and the VM agree#
Decision: tools/conformance.sh --vm runs every program of the suite with torb run --vm and compares standard
output, standard error (folded as for the native run) and the exit code with the same .expected/.stderr/.exit
files the native run is compared with, byte for byte. 7.2's gate - C and the VM agree on every program - is the whole
suite run twice, and the list of what the VM runs that grew with every slice is gone.
The two that answered differently, and how they agree.
CurrentProcess.executable()ofstd/osinside the VM answers the path of the entry file, which is what executes, as an interpreter answers the path of its script (SetExecutable, the substitutetorb_machine_current_process_executable);current-processholds both answers to the same checks.TORB_MEMORY_LIMITis the program's: a binary that hosts the VM (TORB_HOSTS_MACHINE, which the driver defines for every program that calls the kernel -torb, the compiler's tests) counts the blocks the interpreted program allocates inside the kernel against the variable, or against the dev default where it is not set, becausetorb runmeans the dev profile, and ends the program with the native binary's message and exit code 102; the host itself keeps the default of its own profile and compiles unlimited by the variable (memory-limit). Every program of the network is in, since the elements of a list are stored narrow (section 2), and so aretls-loopbackandhttps-exchange: the driver links the TLS part of the runtime (runtime/tls/, mbedTLS) into every program that calls the VM's kernel -torbitself - because the table of thunks reaches every function of the runtime.narrow-elementsholds every narrow width to it, the negative ones and avarargument that is an element too.Workers. A program with a
.workersfile runs on that many workers oftorb's pool (TORB_WORKERS) and again on one, and has to print the same bytes both times, as its native binary does; every other program runs on one.task-workers-callbacksholds the per-thread parts to it: a map whose key is compared by the program'sequalsand objects with a destructor, in tasks that idle workers take. Eight tasks of a million steps each take 7.9 s of interpretation on one worker, 2.4 s on four and 1.6 s on eight.target-branchis in: the table of thunks has a thunk for every native ofruntime/os/, which calls it where its file is compiled and panics elsewhere, and the lowering keeps only the branch of the machine it runs on. So isdestructor-slice-concrete: a slice of a list whose items may hold aCloseobject copies them, through the kernel'sListSliceCopied, where the C back end callstorb_list_slice_copied.stack-overflowis in: its recursion never ends, so the VM's limit (section 4) is reached as surely as the native stack's, at the same site.The leak gate for the VM - decided: the kernel counts the program's blocks apart. The VM runs inside
torb, so the heap's own "live blocks at exit" counts the compiler's blocks as well, and a count after the program against one before it would count the interpreter's own allocations of the run too. Every block of the program is made and freed inside a call of the kernel, though, and nothing of the interpreter is:torb_machine_operatesets the thread's heap counting (torb_count_in_machine), a call back clears it for as long as the interpreter runs, and the heap counts what it allocates and frees while it is set once more, apart. Once a VM counted a program,torb_report_leaksreports those two counts - at the end oftorb run --vmand at aProcess.exitinside the program alike - soTORB_REPORT_LEAKS=1says of a run of the VM what it says of the native binary, andtools/conformance.sh --vmholds every program to "live blocks at exit: 0" with the same exemptions (.stderr,.leaks,binary-only/). The registers themselves (Reserve) are the interpreter's and are counted astorb's.What it costs, measured.
fibonacci(30)(about 2.7 million calls), the wall time oftorb run --vmminus that of a program that prints one line (the front end, the same for both) in atorbbuilt with thereleaseprofile, on one machine, the best of five runs:fibonacci(30)fibonacci(32)native binary 0.03 s - VM, registers through Index.at, aListof return records, aChunkcopied per call1.18 s 3.31 s VM, registers and code by address, return records in the stack, the image (section 4) 0.19 s 0.46 s The first number was 2.0 s in a
devbuild. Six times faster is what the reads alone buy; the rest - about seventy nanoseconds a call - was the loop's own checked arithmetic on addresses, the header words a call reads, and one C call per count, which section 10 listed and the steps below took one at a time.The steps since, measured in the forge's CI on the night of 2026-10-23 to 2026-10-24, the toolchains the nightly published (
linux-x64, a download and no build, so thereleaseprofile) before and after each step, on one runner (the forge'subuntu-latest, an AMD EPYC Genoa with 16 processors that other jobs shared: the load average was 26 to 37). One job timed all the toolchains round-robin, five rounds, the best of five of each, minus the best of five of a program that prints one line (about 0.23 s of start). The same machine as the table above it is not, so its numbers are not compared with that table's:Step Toolchain fibonacci(30)fibonacci(32)What else is in the delta before the steps nightly-202609290.21 s 0.54 s one record per call, read as one (#166, 068e52da)nightly-202610080.20 s 0.48 s 70633ece(a runtime wait on the first of two tasks, and the thunk table regenerated for it)Machine.loadAtandMachine.storeAtin the loop (#163: the natives in91c009ff, the loop in248380a8and9889e8f9)nightly-20261009(the natives, no loop change) tonightly-202610100.20 s to 0.16 s 0.49 s to 0.40 s the natives of #164 part 1 ( d43017cd, unused by the loop), debugger and coverage code invm/debug.trba one-pointer retain or release counted in the loop (#164, d1710631)nightly-202610110.14 s 0.40 s 6ffd6cc5: a loop's record written only where a call back begins, breakpoints tested by a bit; no nightly lies between the two commitsnow nightly-202610230.16 s 0.43 s 18 commits that touch compiler/src/vm,runtime/machine*.corstd/machine: the debugger's task threads, the Decimal, Float32 and text natives,8e7ee41a(deep chains released iteratively); none is a step of this listWhat the numbers say, and what they do not. The loop took about 1.45 times less time on
fibonacci(30)over the four steps (0.21 s to 0.14 s, about seventy-eight nanoseconds a call to about fifty-three) and 1.37 times less onfibonacci(32).loadAtandstoreAtare the step that shows: 1.26 times onfibonacci(30)and 1.21 onfibonacci(32), the largest of the rows. The per-chunk record (#166) and the count in the loop (#164) are not told from the noise of this runner: the runs of one toolchain spread by 10 to 20% (the five ofnightly-20261008onfibonacci(30)lie between 0.42 s and 0.50 s before the start is taken off), and the drift check -nightly-20261008again at the end of the job - came out 9% off its first number onfibonacci(30)and 3% off onfibonacci(32). The per-chunk record shows 2% onfibonacci(30)and 11% onfibonacci(32), which do not agree. A program that retains nothing does not use the count -fibonacci(30)has no count to take - so that row is recorded as measured, and what the step buys is the 15% of docs/PERFORMANCE.md 9.12, on a walk down a chain of variant cells. The last row is a later nightly, a little slower than the best of the two before it, within the same noise. A difference below 10% in this table is not read as a gain or a loss until a second run on a quiet runner says the same.The start, measured (
torb run --timings). Once the VM was the default, the front end was the whole wait for a short program: 1.7 s for one that prints one line, of which 0.7 s parsed every file of the workspace, 0.7 s checked every body of every package of the program, and 0.6 s parsed everything a second time for a script the program might load. Three changes took it to about 0.25 s (0.23 s until the first instruction, 0.26 s wall time):- Only what the program reaches is read and parsed.
readSourceTreelists the files and reads a file the first time its text is asked for (SourceTree.onDisk), and the graph of a run holds the packages of the program alone - the files below the entry and of its package, of every package one of them imports, depends on or names as its prelude (reachedFilesOfofsemantics/graph.trb), which is the closed worldprogramPackagesOfnames anyway. - Bodies are checked on demand. The checker checks the bodies of the entry; the lowering checks the bodies of a
module the first time it enters it (
enterModule,checkBodiesOf, [Checker.checksOnDemand]). A program that prints one line checks the bodies of one module ofstd. Where the lowering refuses the program, every body of the program is checked and it is lowered again, so the refusal is the one a native build gives. - The loader of scripts parses the program's files at the first script it loads, and not before the run.
What is left is parsing the packages the prelude reaches (about 0.15 s) and starting the process.
torb checkkeeps reading, parsing and checking the whole program as before.torb buildchecks on demand as well since the round of docs/PERFORMANCE.md section 9, with the same fallback, and still parses its whole workspace.- Only what the program reaches is read and parsed.
9. Slices#
| # | Scope | State |
|---|---|---|
| 1 | The bytecode format, the frame layout, the emitter from the final IR, the disassembler, torb ir --bytecode, snapshots | Done: compiler/src/backend/bytecode/, compiler/tests/bytecode.test.trb |
| 2 | The kernel: std/machine (five natives, a sixth for the call back of section 10), the manifest rows, runtime/machine.c, the thunk table generated by torb natives --header | Done, as the first of two commits: the seed is refreshed from it before the interpreter can call the natives |
| 3 | The interpreter loop: calls, closures, witness calls, records, variants, text, lists, maps and sets, module constants, destructors; torb run --vm; tools/conformance.sh --vm | Done: compiler/src/vm/, 120 programs of the suite then |
| 4 | Keys compared by a program's own equals: a callback from the runtime into the interpreter | Done: the slots of the element pool call the program's equals and hash back; capsule, paths |
| 5 | The leak gate of the VM: the program's blocks counted apart from torb's | Done: section 8; tools/conformance.sh --vm checks every program |
| 6 | test and group: the runtime's recovery point around a closure of the interpreter | Done: Machine.install and the substitutes torb_machine_test_case/_group; tests, test-failure, assert-values |
| 7 | Tasks: TaskNew, Suspend, Stop, channels, the FIFO order of runtime/task.c (docs/BACKEND.md 7.3's VM half) | Done, on every worker of the pool: section 4; the entry cells of a top-level const too; every program with tasks |
| 8 | The gate of 7.2: every conformance program in both back ends; vm.list deleted | Done: every program, binary-only/ and vm-only/ too (section 8); vm.list is deleted |
| 11 | torb test in the VM: every test file an entry of one program, the report of runtime/test.c | Done: TestFile, TestFinish; the default of torb test, and tier A runs the test packages of std/ and examples/ both ways |
| 9 | Array<Item, Size>, added to the language after the interpreter: inline items, a checked item step | Done: ArrayNew, the static Items, PlaceStep.Item, the counted words of every item; the eleven programs with an array |
| 10 | Speed: register reads without Index.at, return records in the word stack, one record per call, unchecked loadAt/storeAt, the count of a one-pointer retain in the loop (section 10) | Done: the image, addresses and inline load/store (section 4); six times faster, then #166, #163 and #164 about 1.45 times on top of that on fibonacci(30) (section 8) |
| 12 | A native binary that embeds the VM: torb build --embed-vm (section 11) | Done: cli/embed.trb, vm/embed.trb; tier B runs tests/language/ in such binaries |
10. Open#
- A call from the runtime into the interpreter - decided: one closure,
Machine.install. Slices 4, 6 and 7 need the same thing: a C function the runtime calls (an element'sequals, a test body, a task's resume) that runs a chunk. The interpreter hands the kernel one closure when a run starts,(Int64) => Int64, and the kernel calls it with the address of a request (torb_machine_requestofruntime/machine.c: a kind, a chunk, two operands, a width) and gets the request's one word back. The interpreter lays the frame of a request out above everything the interrupted loop uses - the loop writes that height intoRegisters.topin front of every kernel call - and runs it as one moreexecute. Two rules make this safe: the registers are reserved at their largest size before the first instruction (Reserve), so a call back never moves the words a kernel call holds a pointer into, and a call back uses a kernel scratch of its own. A queue the interpreter drains would have done for tasks, but anequalsneeds its answer at once, and one mechanism serves all three. A call back runs with an unlimited budget of its own, and a sandboxed script that stops inside one (a panic in anequals) is passed on: the interpreter says so (StopInCallBack), andtorb_machine_call_backpanics again with the stop the inner operation recorded, which the recovery point of the operation that made the call takes as it is - so the loop that made that call unwinds the script as for any other stop. A stop inside a task the scheduler resumed, or inside a test body, jumps through nothing - a jump would leave the scheduler in the middle of a run: the task answersTORB_POLL_STOPPED, the test body returns, and the stop waits until the operation that ran the scheduler returns, which then stops the script with it. A script's tasks belong to it: each opening of a sandbox is a generation, a task remembers the one it was started in, and it runs only while that sandbox is open - one the script left behind, or one of a script that stopped, stops where it is without running again, so a script's code never runs outside its grant, in the program or in another script's test (vm-only/sandbox-tasks.trb). What such a frame holds is not given back, as nothing is when a script stops. A host that loads a program once (LoadedProgram: a session oftorb repl, the scripts of a manifest) keeps its registers and its image in the oneRegistersthe call back reads, and lays the image out again when a continuation adds chunks. - Where the tasks' scheduler runs - decided: in the runtime. The runtime's own FIFO scheduler drives a VM task
through one resume function of
runtime/machine.cthat calls the interpreter back (section 4), so the process has one scheduler and the order ofruntime/task.cis the VM's by construction, on every worker of the pool (section 4). A task of the program that the scheduler resumes while a script's sandbox is open - the script's test waits for its own tasks and runs whatever is queued - runs with the sandbox set aside and no recovery point of the script's, so it reads what the program may and a panic of it is the program's (vm-only/sandbox-tasks.trb). - Calling compiled code from bytecode. BACKEND 5.2 promised that a compiled function can be called from bytecode
and back. With the VM's own inline layouts that is a marshalling step at the boundary for any record of the program.
docs/design/SCRIPTS.md section 5 decided it for the sandbox: only text crosses between
torband the VM (the kernel operationTextOut), and a native binary that loads scripts will cross withstd/encoding. - Speed, the next steps. All of them are in, and section 8 has what each bought. The three first steps (section 4):
registers and code read by address through an inline
Machine.load, return records in the word stack, and the image instead of aChunkcopied per call - no new native was needed, because a native the runtime definesstatic inlineis inlined wherevertorb.his included. The fourth:frame + 8 * offsetadds to a raw address no per-function range analysis can bound, so the loop reads and writes registers, code, the pool and the headers throughMachine.loadAtandMachine.storeAt(base + 8 * index, section 6), which compute the address unchecked. A register number is masked to the 16 bits of an operand, which changes nothing because the emitter keeps every frame insidelargestOperand, and so boundsa + offsetfor the range analysis, and an offset into the pool to 32 bits for the same reason; the return record holds the result's register in the caller's frame. The fifth: a call reads the chunk's header as one record (recordOfandparametersOfofcompiler/src/vm/interpret.trb), where it read five header words. The places and the witness entries are words of the image as well (section 4), not aPlacerecord ofList<PlaceStep>read perload. What stays checked is the arithmetic on the code offset (pc + 1) and the address of a frame, once per call and return. A step that is left is one the next profile offibonacci(30)shows, and it is added here when it is measured. - A retain or a release of one pointer counts in the loop. It was a C call of the kernel even for a value whose
shape says it is one counted pointer, and that call is a seventh of the time of a walk down a chain of variant cells
(docs/PERFORMANCE.md 9.12: 15% faster without it, nothing measurable for a program that counts nothing).
Machine.countUpandMachine.countDown(section 6) do the count where it is a block of this thread and not the last count, and answerfalsefor everything that stays the kernel's: a shared block, the last count with its destructor, a count of zero, and every count while a sandbox is open, so a script that runs out of memory stops where it stopped before. The instruction stayskernel retainandkernel release, with the tail it has; the emitter marks the ones of a one-pointer shape in the head's unused operands -Bthe register of the pointer,C1 for a retain and 2 for a release - and the loop tries the native first and calls the kernel where it answersfalse(section 7). Program text of an older emitter hasC0 and calls the kernel as before, so the format staystorb-bytecode-1.
11. A native binary that embeds the VM#
Decision: torb build --embed-vm builds the interpreter natively with the program's bytecode inside it. The
program is checked, lowered for the VM and encoded as bytecode exactly as torb run does; the bytecode is written as
text (vm/embed.trb, programText) into a module that exists only in memory, beside the interpreter in the
toolchain's own sources (compiler/src/vm/embedded-program.trb), and that module - whose top-level code decodes the
program and runs it (runEmbedded) - is what torb build compiles through C with the interpreter and the runtime.
- What it is for. A program that loads receiver scripts ships as one executable: the scripts it was compiled with
run in their sandbox, with the steps, the time, the memory and the recoverable panic of section 1 of
docs/design/SCRIPTS.md, and the binary needs neithertorbnor a C compiler where it runs. Every other program runs as it runs undertorb run- the same output, exit code, panics, leak report and memory limit (TORB_MEMORY_LIMITlimits the program's own blocks, as intorb) - which tier B checks with the programs oftests/language/. - What it is not. The whole program is interpreted: this is no native code that calls into a VM for its scripts,
and nothing crosses between a native and an interpreted value. That host - compiled code that loads a script, the
value encoded across with
std/encoding- isdocs/design/SCRIPTS.md's slice 8, built beside this: a programtorb buildcompiles links the script host instead. The front end is not in an--embed-vmbinary, so a script is loaded there by a path the program was compiled with; any other path is theSandboxErrorofSandbox.load. - Why text, and why a module of TorbScript. The bytecode is plain data, and a string literal is the one piece of
data the C back end already places in a binary without a native of its own; the text is one token per word, and its
first token is a version the binary checks. The derived
EncodeandDecodereach every field of aChunksince anArrayListdecodes; the hand-written reader of a few hundred lines stays, because it reads the tokens straight into the chunks, where a deriveddecodewould build every word of the code as a value first. Building the interpreter from the toolchain's own sources, as the compiler itself is built, needs no library of the VM kept beside the runtime; it costs a C compile of the interpreter per binary, about half a minute. - The sources.
--embed-vmfindscompiler/the way the toolchain findsstd/andruntime/(above a path of the command line, the working directory ortorbitself), or whereTORB_COMPILERsays.