Menu
Guides
- Install
- CLI reference
- The superset
- Differences from PHP
- Async
- Modules
- FFI
- Attributes
- Generics
- Memory model
- Typed arrays (Ds)
- HTTP server
- WebSocket
- PDO
- curl
Reference
Internals
Memory ABI contract — the stone tablet
Status: shipped; normative. The layout and refcount contract between the compiler and its runtime.
The single source of truth for string / object / array layout, refcount encoding, ownership transitions, destructor order and the child-iteration ABI. Anything that breaks this contract breaks the compiler; anything that extends it updates this doc in the same patch.
Every number here is mirrored by a constant in src/Compile/MemoryAbi.php — that file
is the machine-readable version and wins any disagreement. Cite it, do not re-derive it.
Current MemoryAbi::VERSION is 24 (v24: a class tree with a typed property php starts UNINITIALIZED carries a per-object INIT BITMAP, §3.3; v23: a risky DENORMAL float (bits in (0xFFFF, 2^48)) is boxed or'ed with FLOAT_DENORM_TAG (0x7FF0<<48), so no cell can look like a heap pointer; every cell->double read decodes it; v22: a PACKED array's buckets@48 word is its head gap, §4.1; v21: a closure DROP fn takes a mode; v20: Manticore\Ds hash table block and the cell object-tag constants; v19: descriptor grew visit_fn@64 — the json encoders walk public props without a temporary array; v18: v17 AND the native-buffer lineage — the BUF_* block, §7c, the descriptor's json_fn@56, buffer kind 12 (U64); v17: a Generator frame OWNS every cell of its header and __mir_str_reclaim destroys a frame it frees — v17 and v18 each merged two lineages that both called themselves v17, so neither links with the other, see the comments at the top of MemoryAbi; v16: zero-cost exception object, §7b, AND tagged bucket words + int bucket hash, §4.1 — two separate v15 lineages merged, so neither v15 links with v16; v11: element repr ARRAY_REPR_CLO = 10 — raw closure-slot words the buffer counted, §4.1; v10: descriptor grew cmp_view_fn@40 and cmp_group@48 — php's object ==/<=>; v9: a reference box carries [REF_TAG_MAGIC@-8, value@0, rc@+8]; v8: descriptor grew dyn_methods@24; props_fn@32
followed without a bump — it is appended, older .os never read it).
Supersedes the former
docs/bootstrap/12-memory-abi-contract.mdand the unified-array design notedocs/bootstrap/16, which source files used to cite. Sections on the deleted AST backend (exprProducesAssoc,assocOwnedLocals,emitObjReleaseHelper,forEachRefcountedChild) are gone — none of those symbols exist; ownership is decided by MIR passes, described in §5.
1. The rc self-routing tag#
Every heap value that participates in refcounting is reachable through a data pointer, and
ptr-8 (RC_TAG_OFFSET) always holds something the rc helpers can dispatch on:
Word at ptr-8 | Kind | rc lives at |
|---|---|---|
0x7E66000000000000 (RC_TAG_MAGIC) | object / vec | ptr+8 |
0x7E66000000000001 (ASSOC_TAG_MAGIC) | assoc | ptr+24 |
0x7E66000000000002 (ARRAY_TAG_MAGIC) | unified array, heap | ptr+24 |
0x7E66000000000003 (ARRAY_TAG_ARENA) | unified array, arena | never — the arena reclaims it |
| anything else | string | ptr-8 is the rc |
This is why __mir_rc_retain / __mir_rc_release self-route regardless of the static
type the call site guessed. The magic is chosen far above any real refcount (< 2^56, see
RC_MASK) and distinct from the immortal -1, so a string's rc word can never collide
with it. Tagged allocations hand out malloc_base + 8 as the data pointer.
An arena array is never rc-bumped and never free()d — the retain/release helpers bail on
ARRAY_TAG_ARENA immediately; grow / promote / index paths route to the arena allocator.
2. String#
STRING_HEADER_SIZE = 32, all offsets relative to the data pointer:
data - 32 : i64 hash -- cached FNV-1a; 0 = not computed. Literals bake it in.
data - 24 : i64 cap -- byte capacity of the data region (content + NUL)
data - 16 : i64 len -- content length, binary-safe
data - 8 : i64 rc -- small count, or -1 = immortal
data + 0 : bytes...cap is what makes amortized .= possible: the append happens in place when rc == 1 and
len + addlen < cap. Immortal strings (literals, arena) carry rc = -1 and still carry a
full header, so len reads stay valid.
Two small-string free-list size classes recycle freed buffers (STRING_POOL0_ALLOC = 64,
STRING_POOL1_ALLOC = 128); a class's data capacity is alloc - 32, and a freed buffer is
recognised by that cap.
3. Object#
OBJECT_HEADER_SIZE = 16. Non-#[Struct] instances reserve it before the first property:
offset 0 : ptr class descriptor -- NOT the raw class id
offset 8 : i64 rc_word -- packed rc | color | buffered
offset 16 : ... propertiesThe descriptor (@__mir_cd_<id>, { i64, ptr, ptr, ptr, ptr, ptr, i64, ptr, ptr }, 72 bytes) is a static global, linkonce_odr
so each class has exactly one across every separately-linked object:
descriptor + 0 : i64 class_id -- never 0
descriptor + 8 : ptr drop_fn -- or null
descriptor + 16 : ptr rmeta -- reflection metadata, or null
descriptor + 24 : ptr dyn_methods -- compiler-owned dynamic-method table, or null
descriptor + 32 : ptr props_fn -- @__mir_props_<id>: declared props + bag as a FRESH
assoc; what json_encode / (array) / get_object_vars
read from inside stdlib.o. Null for a class with
neither properties nor a bag
descriptor + 40 : ptr cmp_view_fn -- @__mir_cmpview_<id>: the COMPARE view as a FRESH assoc —
every declared prop (any visibility) then the bag, or
only the #[CompareKey] props. Null: nothing to compare
descriptor + 48 : i64 cmp_group -- objects compare through their views only within one
group: the class id, CMP_GROUP_KEYED (-1) for every
#[CompareKey] class, 0 (identity only) for an enum and
an #[Uncomparable] class. #[CompareNone]: a null
cmp_view_fn under the class-id group (any two equal)
descriptor + 56 : ptr json_fn -- `i64 (i64 cell)`: the jsonSerialize() result (a backed
enum: its value; Resource: raises UNSUPPORTED_TYPE),
or null (v18)
descriptor + 64 : ptr visit_fn -- @__mir_pvisit_<id>(obj, ctx, cb): props_fn's public view
WALKED — cb(ctx, name, cell) per declared public prop
(cell borrowed for the call), then cb(ctx, null, bag).
Null exactly where props_fn is (v19)instanceof, method dispatch and exception catch read class_id at descriptor offset 0;
object release calls drop_fn indirectly. Offsets 0 and 8 are ABI — new fields append.
The struct is spelled in exactly one place: Compile\Mir\RuntimeLibrary::descriptorType;
every offset is a MemoryAbi::DESCRIPTOR_*_OFFSET constant.
rmeta stays null unless reflection actually reaches the class, so a binary that never
reflects pays 8 rodata bytes per class and nothing else. Its layout (RMETA_*,
RMETA_SIZE = 104, plus method / param / attribute row shapes) lives in MemoryAbi.php;
it is metadata, not a memory-management contract, and is not duplicated here.
3.1 rc_word encoding#
One i64 at offset 8:
| Bits | Width | Purpose |
|---|---|---|
| 63 | 1 | buffered — member of the cc candidate list (BUFFERED_MASK) |
| 62..56 | 7 | color — 0 BLACK, 1 PURPLE, 2 GRAY, 3 WHITE (COLOR_MASK, COLOR_SHIFT = 56) |
| 55..0 | 56 | rc — signed; trial deletion drives it negative (RC_MASK) |
Read by masking and sign-extending from bit 55; compare with signed predicates:
%rc_only = and i64 %word, 0x00FFFFFFFFFFFFFF
%shl8 = shl i64 %rc_only, 8
%rc_s = ashr i64 %shl8, 8Write only the rc field — colour and buffered are mutated by their own helpers:
new_word = (word & ~RC_MASK) | ((rc + delta) & RC_MASK)A plain add %word, 1 is forbidden: the carry overflows into the colour field. It used
to look like it worked because the corruption landed in bytes nobody read.
3.2 #[Struct] opt-out#
Classes tagged #[Struct] skip the header entirely — fields start at offset 0, dispatch is
fully static, and no retain / release / cycle-collector hook touches them
(ClassDef::headerSize() returns 0). A 3-field struct is 24 bytes against 40 for the
refcounted form.
There is no #[NoRefcount] attribute. AllocationKind::NoRefcount exists but is an
escape-analysis verdict produced by InferAllocKind, not a user-facing marker — do not
confuse the two.
3.3 Init bitmap (v24)#
php starts a typed property with no default (not promoted, not hooked) UNINITIALIZED: a read
throws Error: Typed property C::$p must not be accessed before initialization, and
isset / json_encode / get_object_vars / var_dump skip it. An int / float / bool slot
has no spare value to mark that, so a class tree that declares such a property carries
ClassDef::$initMapWords i64 words at ClassDef::$initMapOffset:
bit b = (slot_offset - OBJECT_HEADER_SIZE) / INIT_MAP_SLOT_BYTES -- word b / 64, bit b % 64- One offset per class tree (topmost ancestor down), past every member's properties and
bag, chosen like the bag's (
EmitLlvm::grantInitMaps), so a base-typed pointer finds it in every instance. The bag of a tree that also gets one is placed after it. - Every member the module owns carries it; a store to any property in the tree's set
(
ClassDef::$initMapProps) sets its bit through any class. A read tests it only where the static class says the property may be uninitialized (ClassDef::initChecked). - 0 at allocation (the constant instance template already carries 0), copied by
clone, cleared byunset(). - Never in a library module, an extern or prelude class, a
#[Struct], or a narrow (#[TypeDef]) slot: those layouts are published facts, so their tracked set is empty and they read as before.
4. Arrays#
4.1 Unified array (the live representation)#
One header for every PHP array, packed or hashed — ARRAY_HEADER_SIZE = 56:
offset 0 : i64 length -- live element count
offset 8 : i64 capacity -- slots (i64 if packed, entry slots if hashed)
offset 16 : i64 next_int_key
offset 24 : i64 rc -- SINGLE FIXED OFFSET for every array
offset 32 : i64 flags -- mode + element repr + tombstones + internal pointer
offset 40 : i64 n_buckets -- 0 = index not built
offset 48 : ptr buckets -- side allocation; null until built
-- PACKED: i64 head gap (v22), see below
offset 56 : ... dataHead gap (v22). A PACKED buffer has no bucket index (n_buckets stays 0), so its
buckets word is the HEAD GAP: how many 8-byte slots of the malloc block lie in front of
the tag. array_shift on a uniquely owned heap packed buffer writes tag + header one slot
further on instead of moving the data (array pointer +8, capacity -1, gap +1), and the
caller stores the moved pointer back; array_unshift spends the gap the other way and
regrows with front slack when there is none. The block start is ptr - 8 - gap*8: release
and promote free that, and __mir_realloc_tagged first moves a gapped header back to the
block start. Copies and clones are fresh blocks and zero the word.
The fixed rc offset is the structural fix: a value can never have its rc read at the wrong offset regardless of mode. Mode lives in the flags word instead.
PACKED (flags & ARRAY_FLAG_HASHED == 0): contiguous i64 values at data + i*8,
implicit int keys. HASHED: 24-byte entries.
entry + 0 : i64 kind -- 0 STRING, 1 INT, -1 DELETED
entry + 8 : ptr key
entry + 16 : i64 valueFlags-word bitfields:
| Bits | Field | Notes |
|---|---|---|
| 0 | ARRAY_FLAG_HASHED | cleared ⇒ PACKED |
| 1..3 | element repr (ARRAY_REPR_*, shift 1) | 0 RAW, 2 STR, 4 OBJ, 6 ARR, 8 CELL, 10 CLO |
| 8..35 | tombstone counter (ARRAY_TOMB_SHIFT, 28 bits) | every read must mask |
| 36..63 | internal pointer (ARRAY_PTR_SHIFT, 28 bits) | current()/next() cursor |
The element-repr nibble is the runtime-truthful record of what retain / release / COW must do to each element. It is stamped as elements are stored, so it travels with the array through erased aliases — unlike the compile-time flavour guess.
CLO (v11) is the one repr a CONCRETE array stamps: a Closure / callable element has no
hint code, and its static type cannot be the owner because a callable slot also holds
words that are not closure envs (a function-name string, an [obj, 'm'] array) and builders
copy closure words without counting them. So a closure-element store stamps CLO only when
the value it wrote is the buffer's sole element, or the buffer already says CLO; a closure
literal stamps it outright. Its element op is __mir_closure_retain / __mir_closure_release,
which act only on a word carrying CLOSURE_TAG_MAGIC — anything else is left alone. The
overwrite / unset drop of a closure slot is the runtime-gated __mir_array_clo_drop, so a
buffer that never said CLO never gives back a count it did not take. Read only while the
element hint is 0.
⚠ The tombstone counter used to run to bit 63. It is now bounded so the internal pointer can
live above it: an unmasked flags >> 8 reads the pointer as tombstones, and
__mir_array_live_len compacts whenever that is non-zero — so a moved cursor would compact
the array on every foreach and every count. Compaction resets both fields, which is why
and flags, 255 (ARRAY_FLAGS_LOW_MASK) is still the right reset.
Bucket index (v16). buckets holds n_buckets i64 words (a power of two, linear
probing, 0 = empty slot). A used word is (h32 << 32) | (entry_index + 1): h32 is the low
32 bits of the key hash, so a probe rejects a colliding slot and a delete's backward shift
finds a slot's home without loading the entry or re-hashing its key. The int key hash is
(k ^ (k >> 12)) * golden; the string hash is __mir_array_hash_str. The index is trusted,
not validated, so a library built with another hash or word format misses keys in the
arrays it built. A value copy (__mir_array_copy_bare) holds the same entries at the same
positions and duplicates the source's index verbatim instead of resetting it.
IMMORTAL_ARRAY_RC = 1 << 62 is baked into the empty-array singleton. It is deliberately
not -1: COW's sle rc, 1 and release's sle rc, 0 must both stay false, and the
string immortal encoding would make COW mutate the shared singleton and release free it
(__mir_array_cow never reads the tag).
4.2 Arena arrays#
Non-escaping arrays can be bump-allocated and bulk-freed at scope exit
(__mir_array_alloc_arena, __mir_arena_realloc), tagged ARRAY_TAG_ARENA. Eligibility is
decided by InferAllocKind::isArenaEligibleType() and is deliberately narrow today: flat
int / float / bool arrays with int keys. Gated by Debug::$arenaArrays
(MANTICORE_ARENA_ARRAYS, on by default).
4.3 Legacy vec / assoc headers#
VEC_* and ASSOC_* constants still exist and are still referenced by paths that predate
the unified array. VEC_HEADER_SIZE = 16 (length@0, rc@8, 8-byte elements).
ASSOC_HEADER_SIZE = 48 (length@0, capacity@8, next_int@16, rc@24, n_buckets@32,
buckets@40, 24-byte entries at 48); an empty [] stub is ASSOC_STUB_SIZE = 16 with
capacity == 0 and no rc slot, so every assoc helper must guard cap == 0 before
touching the rc word. The hash index is built at ASSOC_INDEX_THRESHOLD = 8 live entries —
below that a linear scan beats hashing plus the bucket allocation.
Assoc rc is a plain count, not packed with colour/buffered, so cycles routed purely through assoc values are not collected.
5. Ownership transitions#
Ownership is not decided by an expression predicate any more. It is a MIR pass chain,
run in this order (src/Manticore/Main.php):
InferEffects → InferAllocKind → ApplyMemoryMode → SpillFreshBases → InsertMemoryOps → OwnershipFlow → VerifyInferEffects— what each function does to its arguments and globals.InferAllocKind— per-allocation verdict: arena, heap-rc, orNoRefcount.ApplyMemoryMode— applies the--memory/MANTICORE_MEMORYstrategy (hybriddefault,rc,arena); resolved byCompile\Mir\MemoryMode::resolve().InsertMemoryOps— the frame's ARENA scope (arena_enter/arena_leave) only; aNoRefcountverdict plants nothing — its value is an rc value the flow owns like any other.OwnershipFlow— the rc LOCALS, per program point. A forward dataflow overEmpty | Own(k) | Borrow(k)(plusScalarandMixDead),ka release class: the flavor plus the slot type a drop releases by. A store takes the stateOwnership::classifyStoredanswers for its value against the SLOT's representation. WhereOwn(k)meetsBorrow(k)at a join the borrowed edge takes anown_retain; where no edge has a statement position the name is FORCED (every borrowed source takes its +1 on the spot). Where two representations meet, the name is dead past the join (a read there is aVerifyfailure) and each owned side is dropped on its edge. The plan is written as ops the emitter executes, never re-decides:dropof the old value on a store (StoreLocal::$ownOld, after the new value is computed), before anunset, on every return (Return_::$ownDrops) and on the fall-through end; a returned local the flow owns MOVES (Return_::$ownMove); a foreach binding's co-ownership and the drop of what its slot still holds (Foreach_::$ownCoOwn/$ownDropValue/$ownDropKey); oneown_localregistration per managed name.
A name is managed or not as a whole — never "one flavor per name, else blocked and leaked":
the state is per point, so a name bound to an owned value on one path and a borrowed one on
another is exact on both. Names a reference can reach (&, static, a by-ref capture or
foreach) are left to their storage. A local the flow owns and hands to a container that takes
NO count of it (Ownership::containerStoreRetains refuses) MOVES there — holds a borrow of the
container's reference from then on — only when nothing reads the name afterwards (no other
read, not in a loop, no goto, no symbol-table reader). Otherwise the container takes its
own +1 (own_share, before the statement) and the local stays owned: php's
$a[] = $x; unset($a); echo $x->p; reads a live value. An array local handed to an object-producing call
whose callee may keep it with its element refs (Ownership::elementSharedArgs) releases its
buffer only (vecbuf / assocbuf).
Conditional expressions are a shared contract rather than per-consumer guesswork: ?:,
??, ternary and match all route through Compile\Mir\CondOwn so the arms and their
consumer agree on who owns the result. An emitter-only fix leaks; a pass-only fix double-frees.
Returning a value hands the caller +1. An owned returned local moves; any other value takes
the borrowed-return retain. A conditional the return takes no +1 on (an erased arm the
retain cannot name) hands back one arm's reference as it stands: the pass lists those arm
locals (Ownership::returnArmLocals → Return_::$ownArms) and the emitter drops each only
when its slot word is not the returned word. Which +1 a return takes is ONE decision
(Ownership::returnRetain) the emitter executes and the pass reads. Every function, method,
static method or closure that DECLARES a bare array / ?array return
(Ownership::erasedArrayReturn, decided on the declaration and carried across .sig as
array) returns +1 on EVERY path — a borrowed value is retained by its tag, every arm of a
conditional is normalized — so a caller owns what such a call hands back: a local stores it
at the erasedarr class (raw buffer or tagged cell, split by tag at every retain and drop),
an argument temp or a discarded result is released after the call. A generator FRAME owns what it holds
(ABI v17): its params from creation (the creator takes their +1), its locals, and the four
header cells — current@16, key@24, sent@40, retval@48, each a tagged cell. Every reader
(current(), key(), getReturn(), a foreach value / key var) takes its own +1; a yield
expression MOVES the sent value out of sent. The frame is freed through the string release,
and __mir_str_reclaim calls __mir_gen_destroy on it first: a frame that is not finished
(state ≥ 0) re-enters its resume function at -2 - state, where each yield carries the exit
of a generator abandoned there — the finally of every enclosing try, then the drops of the
locals OwnershipFlow has Own at that point (Yield_::$ownLive, or the outermost finally's
ownFinally) — and a frame nobody started drops its params; then the header cells are
dropped. The resume entry stores state -1, so an exception that leaves the body leaves the
generator finished, and its unwind drops the locals through the same cleanup pads as any
other function. By-reference binding forwards the slot
to a shared cell and bypasses rc ops at the binding site; the underlying buffer stays owned
by whichever local holds it.
Array elements belong to the buffer#
A holder's count on an array is a count on the BUFFER (Debug::$rcBufferOnly, on by
default; MANTICORE_RC_BUF_ONLY=0 restores the old model for bisecting). Every retain
variant is rc + 1, every release variant is rc - 1, and the keys and elements are
dropped once, when rc reaches 0 — whatever variant name (_obj, _str, _ownel_*) a site
picked. The element walk at 0 is keyed by the buffer's ELEMENT HINT, and by the repr bits
only for an unstamped buffer; every walk — release, adopt, cow — uses that same key.
What that asks of everything else: a buffer must OWN the elements it holds.
- A value copy adopts them:
__mir_array_copytakes a count on each (by hint), so a caller never adopts after it. - Anything that copies element words into another buffer takes a count on each word it
copies — spread (
__mir_array_spread_into), union,array_unshiftof a pack. - An argument literal whose ARRAY elements are released by the call site's argument-temp
list is released as a bare buffer (
vecbuf/assocbuf), never as element-walking.
The model it replaced let every retain co-own the elements and a proven _ownel_ release
give one back. That is sound only when every count on a buffer follows it, and a fresh +1
carries no element refs of its own, so on a buffer with mixed holders the element count
depended on which holder let go last. The extra element refs also MASKED each shallow copy
above; each one surfaced as a premature free when they were gone.
6. Destructor order#
__mir_rc_release brings the rc to 0 (signed comparison) and calls the descriptor's
drop_fn indirectly. The drop body for class C:
- Run the user
__destruct()if declared, resolved to the most-derived one (EmitLlvmRuntime.php). PHP calls it before properties are released, and so do we. - Release each refcounted property in declaration order — string, object, array, or cell by its element repr. Structs and raw FFI pointers are skipped.
free(self).
Children are released after the user destructor so destructor code still sees its state, and
before free so their own destructors see a valid parent pointer — there are no weak refs.
Iteration order is declaration order and that stability is load-bearing: the cycle collector's walkers must visit the same set in the same order, or trial deletion and restoration get out of sync.
Release recurses directly, so a long ownership chain (a linked list) can still exhaust the stack. Iterative release via a worklist is open work.
Shutdown sweep. A class that declares __destruct registers each instance in
@__mir_dtor_ents (creation order) at new/clone; the drop body unregisters it as
its destructor starts. An atexit hook (@__mir_dtor_sweep, after the
register_shutdown_function queue, before the output-buffer drain) runs the destructors still
owed (statics, cycles, anything alive at exit()) through the class's drop function in
one-shot destructor-only mode (@__mir_dtor_only): members and storage stay intact.
7. Cycle collector#
Synchronous Bacon–Rajan, in tree, emitted as LLVM IR by
src/Compile/Mir/Passes/EmitLlvmRuntime.php and reached through the gc_collect_cycles()
builtin (EmitLlvmBuiltins.php). Regression test: tests/aot/cases/gc_cycles.php.
Emitted symbols:
@__manticore_cc_add_root @__manticore_cc_scan
@__manticore_cc_collect_cycles @__manticore_cc_scan_black
@__manticore_cc_mark_gray @__manticore_cc_collect_white
@__manticore_cc_child_apply @__manticore_cc_drop_stringsGlobal state: @__manticore_cc_roots, @__manticore_cc_count, @__manticore_cc_cap,
@__manticore_cc_children, @__manticore_cc_freed.
Triggering is manual only. gc_collect_cycles() is the sole entry point; there is no
threshold heartbeat and no safe-point trigger, and the collector does not scan static or
global roots. Both are open work — see docs/ROADMAP.md.
7a. Small-object pool#
Objects, unified-array buffers and hash bucket side-arrays are served by a
size-classed pool in front of malloc, emitted by
EmitLlvmRuntime::poolRuntime() and gated on Compile\Debug::$pool
(MANTICORE_POOL=0 disables). Strings keep their own older two-class free list;
__mir_alloc (closure envs, generator frames, boxed args) is still plain
malloc.
Shape (MemoryAbi): one mmap region of POOL_REGION_BYTES (1 GiB of ADDRESS
SPACE — untouched pages are never committed), carved into POOL_SPAN_SIZE
(64 KiB) span-aligned spans. A span's first word holds its size class, so
__mir_pool_free derives the class by masking the pointer to its span and needs
no size from its caller — that is what keeps the pool out of the ABI: no block
header, no descriptor field, no offset moves, nothing a stale .o can disagree
about. Classes are exact multiples of POOL_GRAIN (16 B) up to
POOL_MAX_SMALL (512 B); anything larger, or an exhausted region, falls through
to malloc. A failed mmap leaves base/top at 0, which reads as "not mine" for
every pointer, so the whole thing degrades to plain malloc/free.
Invariants worth stating out loud:
- A pooled block is mmap memory, not a malloc chunk — libc
free()on one aborts. Every free site of a pooled kind routes through__mir_pool_free(two of them live in the cycle collector), and__mir_realloc_taggedasks__mir_pool_sizebefore growing. MANTICORE_POOL=0must hold for the WHOLE build. These bodies arelinkonce_odr, so a stdlib.obuilt with the pool linked against a user.obuilt without it keeps ONE body of each: an honest A/B is two full builds with the pool on and off, not twocompileinvocations with different env.- Freed blocks are never returned to the OS.
__mir_pool_freepushes onto a per-class free list; there is nomunmap, nomadvise, no span reclaim and no cross-class reuse. Peak RSS is a per-size-class high-water mark by design — a burst commits those pages for the life of the process, and a workload that peaks on one class and then shifts to another holds both peaks resident. This is the reasonbench/run.sh'sLEAK=1mode confirms a suspected leak with a third measurement: a high-water grows once and plateaus, a leak keeps paying. - The pool is not thread-safe (plain loads/stores on the class heads) — fine for the single-threaded fiber runtime; a fork-per-worker model COW-duplicates touched pool pages per child.
MANTICORE_DEBUG_VERIFY=1poisons word +8 of a freed block (cleared on alloc) and aborts by name on a double free — libc used to catch those for us, and a pooled double free would otherwise just cycle a free list, silently.
7b. Exception object (ABI v15, carried into v16)#
A PHP throw is a zero-cost Itanium unwind (src/Compile/Runtime/UnwindRuntime.php).
@__mc_throw(obj) mallocs one exception object per raise and hands it to
_Unwind_RaiseException:
| Offset | Size | Field |
|---|---|---|
| 0 | 8 | exception_class = MemoryAbi::EXC_CLASS ("MNTCPHP\0") |
| 8 | 8 | exception_cleanup = null (the landing pad frees the object) |
| 16 | 8 | private_1 (unwinder) |
| 24 | 8 | private_2 (unwinder) |
| 32 | 8 | payload: the Throwable's address, owning one reference |
EXC_HEADER_SIZE = 32 (the _Unwind_Exception header on every 64-bit target),
EXC_PAYLOAD_OFFSET = 32, EXC_SIZE = 48.
- Personality
@__mc_personality(own, so no binary links a C++ runtime) reads only the LSDA call-site table: action ≠ 0 is a PHP catch pad (catch ptr @__mc_typeinfo) and takes onlyEXC_CLASS; action 0 is a cleanup pad and runs for any exception. - Every catch pad starts with
@__mc_eh_catch(ex): the payload moves into@__mir_thrown(which then owns the +1, as before) and the exception object is freed. A rethrow — no catch matched, or a finally re-raising — is a fresh@__mc_throw. - When the search phase finds no catch pad,
_Unwind_RaiseExceptionreturns having unwound nothing, and@__mc_throwcalls the uncaught fatal through@__mc_uncaught_fn(set by@main). - Link: Darwin's libSystem carries the unwinder; Linux links
-static-libgcc(libgcc_eh.a), so nolibgcc_s.sodependency is added. - Fiber context (64 B,
EmitLlvmFiber): the five arena globals at 0..39, bytes 40..55 unused (they held the setjmp try-slot stack before v15),@__mir_thrownat 56. The context OWNS the arena it saved:__mir_fiber_ctx_freewalks the chunk chain (head at 0, each chunk's next at its offset 0), frees the mark stack (16), then the block.
7c. BIGINT box and native buffers (ABI v14 / v18)#
BIGINT (cell tag 5, CELL_TAG_BIGINT). An int past the signed-48 inline cell form lives in
a heap box [rc@0 | value@8] (BIGINT_BOX_SIZE = 16). The cell's payload points at the VALUE,
so every reader is a plain load; the box count is at BIGINT_BOX_RC_OFFSET = -8 relative to
the payload pointer, the value at BIGINT_BOX_VALUE_OFFSET = 8 relative to the malloc base.
The box is COUNTED like any payload a cell carries (__mir_cell_retain / __mir_cell_drop);
it was immortal once, which leaked one box per boxing of a pointer-sized int through a
mixed slot.
Native fixed-width buffer (v18; SplFixedArray, Manticore\Ds\*). A malloc'd block reached
through an int handle (its address):
offset 0 : i64 len -- BUF_LEN_OFFSET
offset 8 : i64 cap -- BUF_CAP_OFFSET
offset 16 : i32 kind -- BUF_KIND_OFFSET
offset 20 : i32 flags -- BUF_FLAGS_OFFSET
offset 24 : ... data -- BUF_DATA_OFFSETThe element type is kind; codes (BUF_KIND_*): I8 1, I16 2, I32 3, I64 4, U8 5, U16 6, U32 7,
F32 8, F64 9, BIT 10 (packed 64 per i64 word), CELL 11 (one 8-byte cell word the slot OWNS),
U64 12 (raw 64 bits read as unsigned, stored like I64). Slots in [len, cap) are zero / null
cells. The bootstrap twins of the __mc_nbuf_* codegen builtins are src/Runtime/Stdlib/Buf.php.
8. Debug and verification#
src/Compile/Debug.php reads these environment variables, once, at startup:
| Env var | Default | Effect |
|---|---|---|
MANTICORE_MEMORY | hybrid | allocation strategy; also --memory=<rc|arena|hybrid> |
MANTICORE_ARENA_ARRAYS | on | arena-allocate non-escaping eligible arrays |
MANTICORE_EMPTY_SINGLETON | on | share one immortal empty-array buffer |
MANTICORE_POOL | on | size-classed small-object pool (§7a); 0 opts out |
MANTICORE_DEBUG_VERIFY | off | slow-path invariant checks at memory ops |
MANTICORE_PROFILE | off | thread-local rc / alloc counters |
MANTICORE_REFLECT_REPORT | off | report what reflection kept alive |
MANTICORE_TYPECHECK=1 gates the TypeCheck pass and is read in the driver, not here.
There is no MANTICORE_DEBUG_RC_TRACE.
9. Versioning#
Bump MemoryAbi::VERSION in the same patch as any layout or encoding change, and in that
patch also: rewrite the drop-body / walker emission against the new shape, update every
direct offset GEP, and bump the affected *_HEADER_SIZE.
No command prints the constant — manticore version prints the release version only. A
library's .sig header carries it as "abi", and the compiler refuses a .sig whose abi
differs from its own MemoryAbi::VERSION. A memory-ABI bump needs no bootstrap-pin raise
(AGENTS.md, "Bootstrap: the pinned release").