- Larva
- How Larva works
- GC domains
GC domains
How one program mixes garbage collectors - each project keeps its own gc, objects carry their domain in the header, the linker groups modules into domains, and what may cross between them.
On this page
Every project chooses its memory management with gc: in its .queen file. A GC domain is what lets a program use libraries that chose differently: a tracing program can call a reference-counted library, and each side keeps its own rules. This page explains how. The collectors themselves are described in Garbage collection and the object header in The native runtime.
The goal
The design notes state it plainly: each project picks its GC, projects with the same GC share it, and projects with different GCs still call each other and use each other's code. The per-mode work (tracing, counting, arenas, manual memory, destructors in each) was the precondition; mixing is the point.
Until the domains work, the IR linker enforced one GC per program. Every object file records the GcConfig it was checked for, and a module checked for another mode was rejected with "module … was checked for GC mode …, but the program uses …". That error still exists (Linker.CheckModules), for links that do not ask for mixing (LinkOptions.MixedDomains).
Vocabulary
| Term | Meaning |
|---|---|
| Domain | One GcConfig (mode, capabilities, plugin name) inside a linked program. All modules with the same GcConfig form one domain and share its collector and heap. |
| Domain 0 | The root project's GC (CompilationOptions.Gc). Other domains are numbered in the order the linker meets them. |
IrModule.Domains |
The linked program's GcConfig[], indexed by domain. Empty before linking and in programs with one GC. |
IrFunction.Domain, StructDef.Domain |
The domain each function and struct came from. The monomorphizer copies it to every instance. |
| Mixed program | One whose Domains has more than one entry (IrModule.MixedDomains). |
The central decision: the domain is in the object
There were two ways to know which collector owns an object.
- In the type (
gc<D> T): every signature that receives another domain's objects would change, System's generic code would be multiplied per domain, and aList<T>could not hold objects of two domains. Rejected. - In the object: one byte of the header says which domain the object belongs to. The handle type stays
gc Teverywhere; what changes is the operation at the boundary, which reads the byte. This is the same scheme as the generational write barrier, which already reads theagebyte on every handle store. Chosen.
The byte is the header's byte 14, domain (it used to be custom, which distinguished memory from a custom collector; that moved to FLAG_CUSTOM in the flags byte):
handle - 16 handle - 2 handle - 1
+---------------+----------+------+-------+--------+-----+--------------
| type | refcount | mark | flags | domain | age | payload ...
+---------------+----------+------+-------+--------+-----+--------------
domain byte: bit 7 bit 6 bits 5..0
+-------+-----------+------------------+
| 0 | UNCOUNTED | domain (0 to 63) |
+-------+-----------+------------------+
0xFF: SLOT_FREE (a free chunk slot, not an object)
IrModule.DomainByte(domain, program) computes what code of a domain writes there: the domain number, plus UncountedDomain (0x40) when that domain does not count references. In a program with one GC it is always 0, so every single-GC path is unchanged.
The byte reaches each new object by the route that object takes:
| Allocation | How the domain gets there |
|---|---|
Typed object or array (larva_gc_alloc, larva_gc_array) |
Bits 8–15 of the type descriptor's flags; the runtime copies type->flags >> 8 into the header. |
| Inline nursery allocation (LLVM) | The constant 1 | domain << 48 stored as the header's second word (count 1, domain in byte 14). |
| Untyped object | larva_gc_new_in(size, align, domain). |
| Untyped array | Bits 8–15 of larva_array_new's flags argument. |
Intrinsic 90, gc.domain(*mut u8) : i64 (larva_gc_domain), returns the low six bits. It reads the header blindly: on a pointer that is not a gc object it reads whatever is there.
How a mixed program is built
larva build of a project makes a mixed program when a project in its DEPS (proj:) says a different gc: than the root. Dependencies with the same GC are compiled into the program as before; the others become external dependencies (SourceBuild, "a dependency built for another GC is not compiled into the program"):
App.queen (gc: default) ──proj──> Counter.queen (gc: refcount)
Counter (external) App (the program, domain 0)
───────────────── ───────────────────────────
sources + System.Core sources, compiled against
│ compile (language pack) [System.Core, Counter.lva]
▼ │
Counter.lva ──────── reference ─────────────>│
│ HIR link with the core │ HIR link [core, Counter, App]
▼ ▼
lower with gc RefCount, ExportAll lower with gc Default,
│ ExternalAssemblies = {Counter}
▼ │ (crossable calls become imports)
IR object (its own System) ──┐ ▼
└──> Linker.Link([App, Counter], MixedDomains)
│
▼ one IR program, Domains = [Default, RefCount]
back end + runtime
DomainLibrary builds each external: it compiles the dependency's sources (and those of its own dependencies) against System.Core, links them with the core at the HIR level, lowers the result for the dependency's GC with ExportAll (every member is exported, so the program's declarations resolve), checks it for that GC, and returns the IR object and the .lva the program's frontend compiles against. --timings shows the step under the dependency's name and mode:
larva build --timings -o app.exe
app.exe (llvm back end, O3, entry app::App.$Module.Main)
timings: System.Core (compiled library) 81 ms, Counter (RefCount) 3683 ms, frontend 66 ms, HIR link 7 ms, lowering 69 ms, IR checks 136 ms, IR link 367 ms, native (llvm) 4511 ms; total 8935 ms
The program is lowered with LoweringOptions.ExternalAssemblies. HirAssembly.Origins, filled by HirLinker while it numbers types, tells the lowering which assembly each type came from. After lowering the bodies, HirLowerer.ApplyLinkage turns the methods of external types that may cross (below) into declarations: Linkage.Import, the symbol Counter::name, no body. The IR linker binds them to the external object's exports. Everything else of an external type that the program's code mentions would be compiled locally, in the program's domain; the frontend makes sure the program cannot use such members (see The crossing diagnostic).
The linker
With MixedDomains, CheckModules starts domain 0 with the program's GC and gives each object the domain whose mode, capabilities and plugin match, opening a new one when none does. DefineSymbols tags every struct and function with its object's domain.
System per domain. Each domain has its own copy of System.Core: a str made by the library is the library's String<u8>, allocated by its collector. The copies do not clash because every symbol carries its module's name. In the example below, the linked program defines both of these:
larva.Counter::System.Exchange.Text
larva.Counter::System.List..new$1<gc Counter::System.String<u8>>
and the program has its own larva.app::System… functions. The design notes weighed a single shared core instead (simpler, but a manual-memory library would then allocate its strings in the root's collector) and chose one per domain; the price is that each domain pays for the parts of System it uses.
Link-once items (the runtime helpers Larva.Object, Larva.Object$vt, Larva.Interface, Larva.Nullable, Larva.FuncN) are shared or duplicated by OnceKey(name, domain, handleFree):
| Key | When |
|---|---|
*::name (shared) |
Domain 0; or an item free of handles (no gc, box, pointers, borrows or structs in its fields or locals, no allocation or region in its body); or a domain whose objects have the same shape as domain 0's (both manage objects, so both use gc handles). The shared copy is the program's, and what it allocates belongs to domain 0. |
*@N::name (one per domain) |
A domain whose objects have another shape: a manual domain, whose objects are raw pointers, in a program whose domain 0 manages objects (or the reverse). |
So tracing and reference counting share *::Larva.Object, while a manual library gets *@2::Larva.Object.
Code generation per domain
The back ends (LLVM's FunctionEmitter, x64's FunctionCompiler, the WebAssembly emitter and IlLowering) compile each function with its domain's GC instead of the program's. Three questions on IrModule drive them:
| Method | Answer |
|---|---|
GcOf(domain, program) |
The domain's own GcConfig. Used for what objects the code makes (inline allocation, regions). |
CodeGcOf(domain, program) |
The GC the code is compiled with: its own, plus RefCounting when the program mixes domains and any domain counts. So every function retains and releases handles, and a counted object's count stays right whoever stores or drops it. |
Any(capability, own), Traces(program) |
Whether a capability is in play anywhere in the program. When any domain traces, every function records stack roots and call safepoints (the collector walks every frame, including a counting library's); when any domain moves objects, every function pins its borrows. |
In the example, the tracing App.Main calls larva_rc_dead, its type descriptors carry domain bytes, and its code has no inline nursery allocation (InlineHeapFor is false for code that counts):
@larva.type.0 = private constant { i32, i32, i32, i16, i16, [0 x i32] } { i32 32, i32 1, i32 16396, i16 0, i16 0, [0 x i32] zeroinitializer }, align 8
@larva.type.3 = private constant { i32, i32, i32, i16, i16, [0 x i32] } { i32 32, i32 1, i32 268, i16 0, i16 0, [0 x i32] zeroinitializer }, align 8
Both are String<u8> (TRAILING | LEAF = 12). type.0 has flags 16396 = 0x400C: domain byte 0x40, domain 0, uncounted (the program's strings). type.3 has 268 = 0x10C: domain byte 0x01, domain 1, counted (the library's strings).
The rules at the boundary
Calls
A call between domains is an ordinary direct call: a handle is the payload's address in every domain, so nothing is converted. Stacks belong to the whole program; every collector scans all of them.
What may cross
Between domains cross only static, non-generic functions whose parameters and result are numbers, bools, or pointers to them (HirLowerer.Crosses, NotCrossable):
| Crosses | Does not cross |
|---|---|
i32, u64, f64, bool, … |
Objects (gc handles), str, arrays |
u8*, i32* (pointers to numbers) |
value structs, borrows (&T), functions |
| Static methods of a type of the other domain | Instance methods, generic methods, methods of generic types |
Reading or writing a field of the other domain's types; new of its classes |
The reason is in the vocabulary: a library's str is the library's System.String<u8>, a different struct from the program's, with another vtable. Importing the library's structs, vtables and globals was tried first and failed for exactly that reason (imported struct Lib$vt does not match Lib::Lib$vt).
Strings and bytes: System.Exchange
What does not cross by value crosses by copy. System.Exchange (Library/System.Core/Exchange.ant) packs text or bytes into a packet: native memory from mem.alloc, outside every collector, with an 8-byte length and then the bytes.
packet (u8*)
+---------------------------+----+----+----+----+-----
| length (8 bytes, little | b0 | b1 | b2 | b3 | ...
| endian) | | | | |
+---------------------------+----+----+----+----+-----
| Function | What it does |
|---|---|
Exchange.Pack(str) : u8* |
A packet with the text's UTF-8 bytes. |
Exchange.PackBytes(u8[]) : u8* |
A packet with a copy of the bytes. |
Exchange.Length(u8*) : i32 |
How many bytes a packet carries. |
Exchange.Text(u8*) : str |
The packet's bytes as a string of this domain; frees the packet. |
Exchange.Bytes(u8*) : u8[] |
The packet's bytes as an array of this domain; frees the packet. |
Exchange.Free(u8*) |
Frees a packet nobody takes. |
Each domain has its own copy of Exchange in its own System; they agree through the packet's primitive format. A packet is taken once, by the side that receives it, and packets are never collected: one that nobody takes leaks.
The crossing diagnostic
The Antril frontend knows which referenced assembly belongs to another domain (TypeSymbol.Domain) and reports AN1079 where a member that cannot cross is used. The lowering has the same check as a second line (CrossingError). Calling a library method that returns a str gives:
…\App.ant(12,27): error AN1079: `Words.Name` belongs to another GC domain (`Counter`, a library built for another gc): its result is a `str`, an object of that domain that cannot cross. Between domains cross only static functions over numbers, bools and `u8*` packets: pack strings and bytes with System.Exchange
The pairs
Each combination of modes needed its own answer. The design first planned a table of cross-domain references (slot → handle, registered by a domain barrier); in the end no pair needed it.
| Pair | What makes it work |
|---|---|
| Tracing + manual | Raw memory is already a conservative root in every runtime (larva_raw_alloc, KIND_PAYLOAD): a gc object kept only inside a manual struct survives collections without any barrier or registration. A manual module may receive, hold and pass gc T handles; it still may not allocate them (alloc gc without a collector is GcNotAvailable). |
| Tracing + reference counting | The object's own count is the cross reference. All code of the program retains and releases (CodeGcOf), and the runtime skips objects whose domain byte has DOMAIN_UNCOUNTED: retain and release return at once. A table keyed by slot address would also have broken when a tracing collector moves the container; a count does not depend on addresses. If a tracing container dies young without visiting its slots, a counted child keeps an extra count; it is unreachable, and the tracing sweep, which ignores counts, frees it anyway. |
| Reference-counting program + tracing library | The tracing collector must run although the program counts: Traces(program) turns on stack roots and safepoints in every function, and the entry info's bit 17 stays clear, so the runtime keeps the nursery. Polls are still emitted only by tracing functions, so a long loop in counting code delays the library's collections. |
| Arena + any | A region's object must not outlive its region through code the region checker cannot see. RegionChecker.CheckMixed runs on the linked program: any region-tagged argument of a direct call to a function of another domain is a RegionEscape ("… belongs to another GC domain (Default): a value from region r0 passed to it may be stored there and outlive its region"), and what such a call returns carries no region tag. |
| Manual + reference counting | free of a raw struct releases the counted handles it holds: HandleSlots.Of lists the gc slots of a value (nested structs, up to depth 8, no generics) and the back ends release each before larva_free. This also fixed free in pure reference-counting programs. |
Moves. For counts to be exact the native back ends had to transfer a reference on a move, as IlLowering always did: a local read once and dead afterwards hands its reference to the destination instead of being retained and released later (MoveAnalysis, Transfers). Before that, a counted object handed to a manual struct died only when the function returned.
An example
Two projects in sibling folders. The library counts references:
queen 1.0;
chunk PROJ:
name: Counter,
type: library
chunk BUILD:
gc: refcount
import System;
into namespace Counter
{
public static class Words
{
public:
/// Counts the words of the text in a packet (the packet is taken and freed).
static Count(u8* packet) : i32
{
str text = Exchange.Text(packet);
List<str> words = new List<str>();
loop (str word in text.Split(' '))
if (word.Length > 0)
words.Add(word);
return words.Count;
}
/// The text in capitals, as a new packet.
static Shout(u8* packet) : u8*
{
str text = Exchange.Text(packet);
return Exchange.Pack(text.ToUpper() + "!");
}
/// A string: an object of this library's domain.
static Name() : str => "Counter";
}
}
The program uses the default tracing collector and depends on the library:
queen 1.0;
chunk PROJ:
name: App,
type: exe
chunk DEPS:
proj: ../Counter
import System;
import Counter;
into namespace App
{
Main()
{
i32 count = Words.Count(Exchange.Pack("the quick brown fox"));
Console.WriteLine($"words: {count}");
str loud = Exchange.Text(Words.Shout(Exchange.Pack("hello")));
Console.WriteLine(loud);
}
}
The library's List<str>, its strings and its ToUpper result are all allocated and freed by reference counting, in the library's own System; the program's strings live in the tracing heap. Built with either back end:
larva build -o app.exe
app.exe (llvm back end, O3, entry app::App.$Module.Main)
./app.exe
words: 4
HELLO!
larva build --backend x64 -o app.exe
app.exe (x64 back end, entry app::App.$Module.Main)
./app.exe
words: 4
HELLO!
Adding Console.WriteLine(Words.Name()); to Main stops the build with the AN1079 error shown above: Name returns the library's str.
Limits
- Only static functions over numbers, bools and packets cross. There is no packet format yet for other types (wide numbers, lists): those would be sugar over bytes.
- Packets are not freed for you. A packet nobody takes leaks.
- One level. An external dependency's own dependencies are compiled inside it, in its domain.
- No VM.
larva buildhas novmback end, so it makes mixed programs only as native code (the VM itself supports domains when a host links IR objects withMixedDomains). - Not implemented yet: domains in Larva's own WebAssembly back end and in
larva_rt.js(the header there has no domain byte, andgc.domainreturns 0); a custom collector per domain (gc: customis not usable at all yet, see Custom collectors). - Known issue: in a mixed program, tracing code is compiled as counting code (
CodeGcOf), and the LLVM and x64 back ends then emit no generational write barrier for it, while the runtime still allocates its objects in the nursery. A young object stored into an old object of the tracing domain can then be missed by a minor collection. A tracing program that fills an oldListwith new objects across minor collections printed a wrong sum (786 instead of 1000) with LLVM and crashed with the x64 back end once it depended on a reference-counted library; without the dependency it is correct, andLARVA_GC_VERIFY=1reports the old-to-young pointer.
Edit this page on GitHub Report an issue