# 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 |