# Memory Management Deft's heap has a **high-water shape**: memory grows to meet the largest burst of allocation an app ever performs, dead values are recycled into new ones, but the pools backing that recycling are never returned to the OS while the runtime lives. This entry explains where values live, how allocation and GC actually work, what survives loading and unloading modules, and how to observe all of it — so a footprint gap between an "empty" app and a "loaded then unloaded" app reads as documented behaviour rather than a leak. This is a design-level doc for Deft users and embedders. For the function-level story see [Runtimes](runtimes); for the embedding and FFI angle see [Embedding](embedding) and [FFI](ffi). ## One Heap Per Runtime A runtime owns exactly one heap (see [Runtimes](runtimes)). Values never cross heaps by reference — they serialise through `deft/stringify` / `deft/parse` — so each heap's allocation behaviour can be reasoned about in isolation. In the TUI hosts every panel app runs its own runtime on its own OS thread with its own heap; the prelude is deep-copied onto each. Two consequences follow: - **Closing a panel frees everything.** The app runtime is joined and torn down, which destroys the whole heap — slabs, interned keywords, namespaces, the lot. (The host follows up with `malloc_trim` because glibc retains freed arena chunks at the RSS level; musl builds return memory via `munmap` directly.) - **Minimising or switching workspaces does not.** Docked and keep-alive apps stay loaded with their heaps intact by design — check `[tui/app-threads]` before concluding anything from RSS. ## Where Values Live Scalar values — numbers, booleans, nil — are NaN-boxed and live inline in stack slots and object fields; they never allocate (see [Embedding](embedding) for the representation). Everything else is a heap object with a header and a kind: strings, keywords, vectors, maps, closures, and so on. Allocation is routed by kind through layered caches: | What | Layout | Allocation source | |---|---|---| | Small strings (≤64 bytes) | one block: header + bytes | 64 KB bump slab, then exact-length free-list buckets | | Small keywords (≤64 bytes) | one block: header + bytes | same: slab carve, then exact-length buckets | | Large strings / keywords | one block | system allocator directly | | CHAMP map nodes | fixed nodes, FAM-packed | 64 KB slabs + power-of-2 bucket free lists | | Map shells | fixed-size structs | a dedicated shell pool | | Everything else | fixed-size structs | per-kind free lists, then system allocator | Two properties of this design matter for footprint: - **Strings and keywords are single-block.** Header and bytes are allocated together — one allocation per value, not two. Keywords and strings each have their own slabs and bucket sets (the layouts are similar but the header sizes differ, so they don't share blocks). - **The GC never moves objects.** Blocks are recycled only after their owner is swept, and only into new values of the same bucket. This is what makes it safe to read a rooted value from another heap or to hold it pinned across FFI calls (see [FFI](ffi)). ## Garbage Collection The collector is mark-sweep and non-moving. Roots include the VM stacks and frames, namespaces, module export constants, protocol method bodies, temporary roots pushed around native calls, and the keyword structures described below. A cycle runs when allocated bytes exceed `max(2 × live, 4 MiB)` — so garbage from small bursts lingers until the threshold is crossed. `[gc/collect]` forces a cycle immediately (the editor already does this when closing a buffer, to release lines at once rather than at the next threshold). After a sweep, dead blocks are not freed to the OS: small strings and keywords are pushed onto their length bucket, CHAMP nodes onto their size bucket, object shells onto their kind list. The next allocation of a matching size pops one instead of carving fresh slab memory. This is why allocation-heavy loops (string building, map building) are cheap after warm-up — and why footprint does not come back down after a burst. ## Keywords and Interning Keywords have three creation paths, and which one a key took determines whether it can ever go away: 1. **Compile-time literals.** Every distinct `:name` literal in compiled source is interned into the heap's keyword table: one canonical object per name, shared by all uses. Interned keywords are permanent GC roots and the table never shrinks — interning a name keeps it alive for the heap's lifetime. This is deliberate (identity and pointer-stable slices), and bounded by the distinct literals ever compiled on that heap. 2. **Dynamic string keys.** `assoc` / `get` / `dissoc` with a runtime-computed string key auto-convert it to a keyword *without* interning — otherwise every generated key would live forever. These plain keywords are cached in a small direct-mapped memo (128 entries, content-keyed), so repeated conversions reuse one object; the memo is bounded and rooted, costing a few KB at most. 3. **Copies.** Metadata-carrying copies are fresh allocations and follow normal GC rules. All three are interchangeable as map keys: keyword equality is content-based (hash + length + byte compare), so an interned `:alpha`, a dynamic `"alpha"` conversion, and a fresh copy of either all hit the same map entry. ## What Survives Unload There is no module unload API. `[reload "path"]` re-evaluates a file into the same namespace of the same heap (existing references keep the old definitions — see [Modules and Imports](modules-and-imports)), and imports are cached per heap, so re-importing doesn't duplicate anything. After a load-then-unload cycle, the gap between "empty" and "back to empty" decomposes into four retention layers: | Layer | Shape | Reclaimed | |---|---|---| | Slab high-water | Cache-shaped: dead blocks recycle, but slab memory and over-flowed buckets persist | Only at heap teardown (panel close) | | Keyword intern table | Live-by-design: every distinct `:literal` ever compiled on this heap | Only at heap teardown | | Namespace + constant roots | All namespaces ever created are GC roots, including replaced ones; export values and protocol method bodies stay rooted | Never, in place | | Lazy GC | Garbage below the `max(2 × live, 4 MiB)` threshold hasn't been swept yet | On the next collection | None of these grow without bound: the intern table is capped by the distinct literals in compiled source, the slabs plateau once buckets can serve incoming allocations, and roots track what was loaded. The one mild inefficiency is bucket fragmentation — string and keyword buckets are keyed by exact payload length, so churn across many distinct lengths leaves each bucket holding dead blocks it can't hand to other sizes. For calibration: CPython's `pymalloc` also arenas small objects, but it returns completely-empty arenas to the OS. Deft's slabs have no "all-dead slab" release path — a documented trade-off in favour of simplicity. Empty-slab reclamation and weak interning (evicting dead intern table entries at sweep, making the table track live usage) are the two candidate levers if long-lived hosts ever need them; neither is implemented today. ## Observing the Heap | Tool | What it tells you | |---|---| | `[gc/stats]` | cycles, live vs allocated bytes, peak bytes, next threshold | | `[gc/pools]` | retention pools: free lists, slab bytes, keyword count — built for leak triage | | `[gc/census]` | live object counts per kind, plus the largest live strings | | `[runtime/mem-stats]` | process RSS and allocator arena figures | | `[tui/app-threads]` | per-app heap / live / peak KB, read cross-thread | | sysmon (TUI) | live charts of the above — see [Sheet as a live model — sysmon](sheet-sysmon) | A load/unload audit takes three steps. In the app's own console (TUI consoles evaluate in the app's runtime) or around the relevant code:
 # 1. Baseline: force a collection, then record the pools
[gc/collect]
def before [gc/pools]

# 2. Load and unload the thing under test
[reload "big-file.dft"]

[gc/collect]
def after [gc/pools]

# 3. Attribute the gap
echo "keywords:"      ($after~keywords - $before~keywords)
echo "str slabs:"     ($after~str_slab_bytes - $before~str_slab_bytes)
echo "kw slabs:"      ($after~kw_slab_bytes - $before~kw_slab_bytes)
echo "champ slabs:"   ($after~champ_slab_bytes - $before~champ_slab_bytes)
echo "live bytes:"    ([gc/stats]~live_bytes) 
Reading the result: - **`keywords` up, `live_bytes` flat** → intern table growth from the file's literals. Bounded by source; permanent for the heap. - **`*_slab_bytes` up** → slab high-water from the load burst. Recycled on future loads; returned at teardown. - **`live_bytes` up** → genuinely more rooted data: namespaces, export constants, or state the app itself is holding. - **RSS up but all of the above flat** → allocator-level retention (glibc arenas); `[gc/collect]` already trims this, musl doesn't need to. ## Design Notes - **Non-moving GC** buys simple, fast cross-heap reads of rooted values and cheap FFI pinning, at the cost of compaction never being available to fight fragmentation. - **Slab allocation** exists because per-object system malloc dominated string- and map-heavy workloads once allocator fast paths churn (the CHAMP slabs alone were worth ~25% on map benchmarks under musl). - **Permanent interning** exists so keyword identity, table keys, and the `~field` access fast paths can rely on stable, canonical objects. The dynamic-key path deliberately avoids it to keep generated keys collectable. - **Bounded caches everywhere else**: the dynamic-key memo is a fixed 128-entry array; free-list buckets hold recycled blocks but can only be filled by prior sweeps of that size. ## Quick Reference | Question | Answer | |---|---| | Does closing a panel free its memory? | Yes — the whole heap is destroyed; RSS residue is allocator-level | | Does minimising? | No — docked apps keep their runtime and heap alive | | Why didn't memory drop after `[reload]`? | Intern table, namespace roots, and slab high-water persist by design | | Why didn't memory drop after GC? | `live-bytes` drops; slab bytes and buckets persist (recycled, not freed) | | Do dynamic map keys leak? | No — uninterned, content-equal, cached in a bounded 128-entry memo | | Do `:literals` leak? | They're interned permanently, bounded by distinct literals compiled | | How do I measure? | `[gc/collect]` then `[gc/pools]` / `[gc/stats]` before and after | | Where's the per-app view? | `[tui/app-threads]`, or the sysmon app |