Memory
This document owns the memory model of Gizlang: what each part of memory management does, the one owner of each part, and the rules between them. RUNTIME owns the C files and their ABI. COMPILER owns code generation.
The model in one paragraph
Values are owned and counted. A string, bytes, Vec or function value is one buffer with a count.
The compiler emits the counting (dup, drop, write-in-place when the count is 1). A buffer that a
second owner gets is copied only when one of the owners writes (copy on write). There is no
collector for values. The only collected memory is the heap of Ref<T> objects: a program makes
one with Memory.create, and only then. Each World has one heap, on one thread. It is precise
and does not move objects. Its roots are the compiler's root frames, the parked frames of
suspended tasks, and holds. Every byte, an object's own block and every buffer, counts against
one budget: the World's GIZLANG_MEMORY_LIMIT. The same limit bounds all Worlds together:
the allocator measures the process's resident memory, and a request that would pass the limit
fails with fault 11 on the thread that asks (the table below).
Concerns and owners
| Concern | Owner | Rule |
|---|---|---|
| Allocation of blocks | runtime/memory.c (gizlang_block_alloc); wasm.c on Wasm | Size classes in thread-local pages. Blocks past the largest class go to malloc. Heap objects and buffers both come from here. A thread keeps at most 4 MiB of empty pages resident (KEPT), each for 1 s; a page that empties past that is given back at once. string(b) shares the buffer of b unless its capacity passes 2 × the length + 64 (append room); then it copies the bytes into an exact buffer (gizlang_rc_fit). |
| The process bound | runtime/memory.c (charge, gizlang_memory_resident) | The anonymous resident memory of the process, measured from the kernel each time the allocator takes a step of fresh memory, never passes GIZLANG_MEMORY_LIMIT for all threads together. Past it the allocator gives back what it keeps (kept large blocks, empty pages, malloc_trim), asks the Task.parallel threads and the event loops that wait to give back the empty pages they keep (io.c reclaim_waiting_loops, at most 50 ms), and measures again; a request still past it fails as a budget does (fault 11). A thread that is not essential (Memory.essential) leaves 1 / ESSENTIAL_SHARE of the limit to those that are (a server's engine loop). Shared buffers settle into a budget with no check of their own: this bound is the check. The host's room bounds it too (host_room): the room left in the process's cgroups and on the machine, so a request fails with fault 11 before the kernel OOM-kills the process; a request takes at most 1 / ROOM_SHARE of the room. |
| The budget | Gizlang_Context.memory_remaining (word 4), refilled by gizlang_memory_more | One budget for each World call. A buffer, a heap object and the buffers it holds are charged when made and credited when freed. No other quota exists. Refusal is fault 11 (R011). |
| Counting of owned values | compiler/ownership.giz (generated dup, drop, mark, unique) | The count is at data-16 and the capacity at data-8. n > 0 is owned by one thread, -1 - n is shared between threads, -1 is immortal. runtime/value_layout.c (gizlang_value_retain, gizlang_value_drop) does the same by layout, for the runtime. |
| Lifetime of expression temporaries and their root slots | compiler/managed.giz rootedExpression, compiler/emit.giz dropTemporaries | A root slot holds only a value its frame owns (a temporary), never a borrow of a parameter, a local or a place of one: their binding roots them. A temporary's buffers and its root slot end together, where the temporary ends. |
| Roots | runtime/heap.c | Root frames (compiler, LIFO), parked frames of suspended tasks, holds (values outside every frame) and shared borrows. Nothing else is a root: no stack scan, no pins. |
| Heap objects and their handles | runtime/heap.c | The heap owns each object's slot, generation, block and value. A Ref is (generation << 32) | (slot + 1). Objects are not resources. |
| Collection | runtime/heap.c collect | Two generations. A minor collection marks from the roots and the remembered set and sweeps the young objects; a major one marks and sweeps all. An edge to no live object fails the collection with -40207 and sweeps nothing. |
| Lifetimes | Memory.region (runtime/heap.c regions) | A region's end frees what it made and did not keep. Web, DB and JS wrap their units (a request, a statement, a COPY batch, an exec) in it. |
| A type's walks | Gizlang_HeapType retain, drop, trace (compiler, may be null) | The heap calls the type's own walk when it is set, else it walks the layout. |
| Ownership across tasks | runtime/task.c | Tasks of one World share its heap. A suspended task's frames are parked, and still roots. |
| Ownership across threads | gizlang_value_transfer, crossing channels, Task.cell, Task.thread | A value crosses only with no Ref in it. Its buffers are marked shared. Each thread has its own World and heap. |
| Live swap | runtime/state.c | A named state binding is a hold. A new type migrates field by field, matched by key. |
| External resources | runtime/resources.c | One handle table of files, sockets and processes. A handle has a generation and an owner World. Release is explicit. A World with open resources refuses teardown. Heap objects have no finalizers. |
| Objects of a guest language (JS, Python) | The VM's own generational collector (vm/heap.giz) | VM. |
Rules between the owners
- A buffer has one count. A heap object holds its value's buffers as one more owner. A load gives
the caller one more owner (
gizlang_services_ref_load: one copy of the fixed bytes, no buffer copy). A store gives the object the new value and drops the old one. A write to a loaded value copies only what it changes. - A Ref lives only in the heap that made it. No boundary admits a Ref: thread closures and
results, crossing channels and cells refuse one (
gizlang_value_transfer). A Ref from another heap names nothing, because each heap starts its generations at its own seed. - Collection happens only at a Ref allocation or store, or at
Memory.collect. At these points the compiler roots every Ref it still holds. A buffer allocation never collects, because generated code holds Refs in registers between root slots. - A Ref allocation collects when the heap has twice the objects or object bytes that the last collection left (at least 4,096 objects or 4 MiB), or when the budget is below a quarter of the limit and has fallen by a sixteenth of the limit since the last collection. The budget shows dead buffers that a heap object held: the heap does not count them itself.
- When a call ends, the World's heap is collected into the call's budget before the leak check
(
runtime/process.c,wasm.c). A byte that is still taken is a leak (exit 70). - A task that faults, also with fault 11 (over the budget), fails alone: its joiner gets the
fault as a value, and the join collects, so the Refs the task held go back to the budget. A
server that runs each request or statement in a task turns that value into its own limit error
(a DB statement gets a DbError); a fault outside every task ends the World.
tests/sdk/memory/task-limit. - A heap object whose layout can reach no Ref (bytes, strings, vectors of scalars, records of them) is a leaf: a collection marks it and never walks its value.
- A stored value's edges are checked by a walk only under GC stress. In production every Ref in a value that generated code holds is rooted, so it is valid by construction; the walk cost a whole value per store (7 µs per Resources reserve in a COPY).
- Generations. An object is young until it survives a collection, then old. The barrier is
in the two stores (
gizlang_heap_replaceandgizlang_heap_swap, the put-back ofMemory.updateincluded): a store into an old object that can hold a Ref puts it in the remembered set, once. A new object names only older ones, so a store is the one way an old object can come to name a young one. A minor collection traces the roots and the remembered objects, never an old object's edges, and sweeps the young list. A minor collection runs when the young objects reach 4,096 (or twice the young survivors) or 4 MiB; a major one when all objects double, or on budget pressure, andMemory.collectis major. Under GC stress three minor collections run to each major one. Memory.region(f)runsfas one lifetime. While a region runs, a collection keeps its young survivors young (they are not promoted), so the minor collection at the region's end frees all thatfmade and did not keep: in its result, a root or an older object (the barrier finds the stores). The survivors become old after the outermost region. It is not O(1): each freed object's buffers are dropped. A region that faults ends without the collection. Regions nest, and tasks may interleave them: the rule above is correct for any order, only less exact. The runtime entry isgizlang_memory_budgetedwith bytes -1 (no grant of its own);Memory.budgetedtreats a negative grant as 0.- Validation of stored values.
gizlang_heap_allocate,_put,_replaceand_swapcheck a value's edges only under GC stress. Every Ref in a value from generated code was valid when loaded, and is rooted since, so it names a live object of this heap: a Ref of another World never reaches a store (a value crosses Worlds only with no Ref in it,gizlang_value_transfer), and a stale one would mean a missing root, which stress finds. A wrong edge found at a collection fails it with-40207before any sweep, in every build. - A type's own walks.
Gizlang_HeapType(64 bytes) ends withretain(value),drop(context, value)andtrace(value, visit, state), each emitted by the compiler or null.tracevisits each Ref, and a boxed word's short form(1 << 63) | payloadfor each tag in its mask.gizlang_services_ref_put,_copyand_swapare create, load and store for a compiler that dups and drops by type: put takes a dup'd value as is (the caller drops it on an error), copy gives the bits, swap stores a dup'd value and gives the old bits to drop. Memory.update(r, f)is the one in-place write of a Ref's value. The value moves out (no count),fgets it asmutand is its one owner, so it writes without a copy; the value moves back whenfreturns. Meanwhile the object is busy: a load, store or update of it faults, and a collection marks it without tracing it (the caller's frame roots the value). Ifffaults, the object stays busy: valid to trace and to sweep, never readable again.Memory.budgeted(bytes, f)runsfon the calling task with at mostbytesof the running budget. The scope takes its grant out of the running budget (a nested scope out of its parent's) and stops refills, so scopes never add up. Over it,fends with fault 11 and unwinds (its frames drop what they own), andbudgetedreturnsMemoryLimit; any other fault goes on. A scope bounds its own task only: a task switch parks it (runtime/task.c). The caller's state is asfleft it; a DB rolls back through its transaction.Memory.weak(r)does not keep its object alive;Memory.upgrade(w)gives the Ref while the object lives. A Weak is always the full 64-bit Ref (31-bit generation), never a boxed word's short form, so a dead Weak never names a later object in its slot: a slot whose generation would come back to its first is retired.Memory.collections()counts collections, so a table of Weak values (an intern table) drops its dead entries when it changed.Memory.census(top)says where the memory is, as text: the World's Ref objects by type (count and bytes, a type by the names in its key), the allocator's blocks of every thread by size class with the pages that hold them, the empty pages kept and given back, and the blocks past 64 KiB. It walks every object and page, so it is for finding where memory went, not for a hot path.Memory.pin(r)gives a u64 word that keeps its object alive untilMemory.unpin(word), for a table outside the program's values (a VM's cache of compiled code per JS object, a native's handle). The word is the Ref's own bits;Memory.pinned<T>(word)gives the Ref back, or undefined for another type. A pinned object is a collection root, as an open borrow is. Each pin needs its own unpin; an unpin of a word with no pin faults. A pin still held when the entry returns ends there, as the program's module state does, so it is no leak (runtime/heap.cgizlang_heap_unpin_all).Memory.Boxed<MASK>is a NaN-boxed word (JS values; Python later): boxed when its top 13 bits are set, with its tag in bits 48 to 50. The tags in MASK (JS:0x78, tags 3 to 6: string, symbol, bigint, object) hold a reference: a heap slot in the low 32 bits and the low 16 bits of its generation above. A collection traces those (the 16 bits are a guard: a traced object is alive); a double and any other tag (JS's 7, an array hole, payload 0) are scalars.Memory.box<T, MASK>(r, tag)andMemory.unbox<T, MASK>(word)convert; unboxing checks the tag, the slot and the type, as a load does.- A Task.thread whose work faulted ends its OS thread. Only a thread whose work returned is reused. The kernel releases what a thread issued (its io_uring commands) only when the thread exits.
The guest heap rule
A guest language (JavaScript, Python) runs on the VM and keeps its values in the VM heap (vm/heap.giz): u64 values,
a non-moving generational collector, and a conservative scan of the native stack. Gizlang heap structures that the VM
holds are invisible to it, so the VM roots them by hand. The VM's memory counts under the one budget.
Measurements
bench/memory/main.giz, best of 3, GIZLANG_GC_STRESS=0:
| Load | Time |
|---|---|
| graph: 20 rounds of a 20,000-node ring | 35 ms, 12 MiB peak |
| directory: 200,000 sets in a ManagedPageDirectory of 4,096 | 382 ms |
| record: 1,000,000 load and store of one 12-field record | 192 ms |
Limits
- Tasks. A task costs 4.4 KB at 100,000 tasks (
bench/tasks), against 2.5 KB for a goroutine. Each task has its own guarded stack, and a stack costs at least one 4 KiB page. - Escape analysis. The compiler borrows const bindings and reuses unique buffers. A Ref whose object does not escape a function is still a heap object.
- Allocator. One allocator API (
gizlang_block_alloc) has every block. A replacement goes behind that API only, and is measured withbench/memoryand the bench suites. - Bulk loads. Leaf objects (a Ref to bytes, a string or a column vector of scalars) are never walked, and old objects
are not marked again (generations, above). A batch can run in
Memory.region. In a database COPY, marking is about 3% of the CPU; the rest is Ref loads and stores that copy whole values. - Weak references and finalizers do not exist. Cycles of Refs are collected. Cycles of counted buffers cannot form: a buffer cannot point to itself without a Ref.
- Sanitizers.
giz testhas no ASan, UBSan or TSan build. Stress collection is the default ofgiz test, and a bad edge fails with-40207.