Memory Usage Guide

This guide puts a number on what each Turmeric feature costs in heap memory, says what frees that memory, and lists strategies for using less. Use it when a program is bigger than you expect, or when you are choosing a data structure and want to know what it costs.

It covers compiled programs (tur build, tur run). The interpreter and the two dynamic dialects work differently and get their own short section at the end. For why memory is managed the way it is, read gc-guide.md. For who should own a value, read ownership-guide.md.


The model in one paragraph

Turmeric has no tracing collector in the default build. A value is either free (it lives in registers, on the C stack, or inline in its parent), or it is a heap block with exactly one of these ways to be reclaimed:

  1. Scope exit. The compiler emits a drop when the owner goes out of scope. This covers rc<T>, capturing closures, a by-value struct with an owning field, and a by-value recursive defdata whose spine it can prove is owned.
  2. An explicit free that you write: vec-free, mutmap-free, map-free, string/release, and the free of a cstr that a stdlib function returned.
  3. A region. with-region (or bt-scope) rewinds every :heap node allocated inside it in one step, if the compiler can prove nothing escapes.
  4. Never, by design. A :heap node allocated outside a region and a :copy recursive value live until the process exits.

Most memory problems come from a value you assumed was in group 1 being in group 2 or 4.


How the numbers were measured

Every number below was measured, not estimated. The method:

Traffic tells you how hard the allocator is working. Peak tells you how big the process gets. Small, freed-at-once allocations show up in traffic but not in peak.

The harness is checked in: benchmarks/memory/measure.sh, with one probe per row of the table below under benchmarks/memory/probes/ (each with __N__ where the element count goes, and the first line of output it must print, so a probe that does less fails instead of looking cheap). To regenerate the table, or to measure a shape of your own:

TUR=./build-release/tur bash benchmarks/memory/measure.sh            # every probe
TUR=./build-release/tur bash benchmarks/memory/measure.sh my-probe.tur

It refuses a sanitized tur (ASan would be the thing measured). See benchmarks/memory/README.md for the columns and the last run's raw output.

The table was regenerated with it on 2026-10-14 (performance-audit WP3, main @ 83f7857ae, gcc 13.3, glibc 2.39). The numbers move when the runtime or the codegen changes; re-run the harness before you lean on one.


The cost table

"Per element" means per value, cell, entry or call, depending on the row.

Feature Heap per element Allocations Reclaimed by
:int, :float, :bool, :cstr literal 0 0 -- (stack or static)
By-value defstruct ((defstruct P [x : int y : int z : int])), passed and returned 0 0 -- (copied by value)
Option/Result of a scalar, constructed and matched 0 0 -- (by value)
:heap defstruct / defdata node (3 ints) 24 B (40 B peak with malloc overhead) 1 a region, otherwise never
By-value recursive defdata link ((ICons [hd : int tl : IL])) 24 B 1 scope exit, in the shapes gc-guide.md lists; a region
stdlib (Cons int) cell (tcons-of) 16 B (24 B peak) 1 a region, otherwise never
Vec int slot 8 B, doubling from 4 (~19 B peak, ~26 B traffic incl. growth copies) amortised 0 vec-free
Vec P slot, P wider than 8 B 8 B slot + a boxed copy of P (~53 B peak for a 24 B P) 1 vec-free (frees the boxes too)
MutableMap int int entry 32 B slot at load factor <= 0.75 (~78 B peak, ~105 B traffic) amortised 0 mutmap-free
Map int int entry (persistent HAMT) ~96 B live peak (~66 B requested); ~716 B and ~7 allocations of traffic per insert ~7 per insert map-free of each version you drop
String 16 B header + length + 1 1 string/release (refcounted)
rc<T> 72 B control block + a separate block for the value 2 last drop
first rc<T> in a program one-time 544 KiB (512 KiB free queue + 32 KiB GC registry) 2 process exit
capturing closure 8 B drop-glue pointer + 8 B code pointer + 8 B per capture 1 scope exit (closure drop glue)
named defn used as a value 0 (its fat box is a static) 0 --
a call through a function value, from direct-style code ~300 B (a tail call) to ~600 B (non-tail) of continuation frames 2 to 5 as they run
... inside a CPS-colored loop ~176 B a turn 2 as they run
perform + a tail resume under handle ~168 B: the perform's frame and env 2 as they run
perform + a non-tail resume, one handle per perform ~790 B 7 as they run
... N performs under one handle ~168 B held per perform 3 when the handle returns
... a k resumed more than once ~795 B held per turn 17 when the handle's outermost direct-style call returns
async + await of a body that does not suspend ~256 B 4 at the await (no fiber stack: the body runs inline)
a raw fiber (fiber-new) ~1 MiB stack + ~2.3 KB block 2 when it is freed
region generation 64 KiB slabs; nodes inside cost their size and no malloc each 1 per 64 KiB rewind at bracket exit, if nothing escapes

A few rows need more than a cell.

By-value data is free

A defstruct without :heap, an Option, a Result and a non-recursive defdata are C structs. They are passed, returned and matched by value, and they never touch the heap:

(defstruct P [x : int y : int z : int])

(defn mk [i : int] : P (P i i i))
(defn sum [p : P] : int (+ (.x p) (+ (.y p) (.z p))))

(defn half [i : int] : (Option int)
  (if (= i (* 2 (/ i 2))) (Some (/ i 2)) (None)))

(defn main [] : int
  (println (sum (mk 3)))                         ; 9, no heap
  (println (match (half 8) (Some h) h (None) 0)) ; 4, no heap
  0)

Measured: 0 allocations per call for both shapes. A wide struct still costs a copy every time it is passed, which matters for speed (see performance-guide.md) but not for memory.

:heap nodes live until a region ends or the process does

:heap on a defstruct or defdata makes each value a malloc'd node shared by pointer. Nothing frees such a node one at a time: a persistent structure's nodes have no single owner, so neither a refcount nor a scope-exit drop can know when the last reference goes. The stdlib's (Cons A) list is :heap.

Outside a region, every node you allocate stays allocated until exit (measured: 24 B per 3-int node, all of it reported lost). Inside a with-region that rewinds, the same nodes cost no individual mallocs and are reclaimed together. That is what regions are for -- see strategy 4.

Containers

Per entry, from smallest to largest:

Container Peak per entry Mutation Frees
Vec int ~19 B in place vec-free
MutableMap int int ~78 B in place mutmap-free
Map int int ~96 B a new version per insert map-free per version

Vec holds a 24-byte header and a buffer of 8-byte slots that doubles when full, so a Vec can be up to twice as large as its contents. An element wider than 8 bytes -- a by-value struct of two or more fields, a wide defdata -- does not fit in a slot, so each vec-push! mallocs a box for it. A Vec of 24-byte structs costs about 53 bytes per element, not 24.

MutableMap is open-addressed: 32-byte slots, resized to double when the load factor reaches 0.75.

Map (and Set, which is built on the same HAMT) is persistent: every map-assoc returns a new map that shares structure with the old one. That makes old versions cheap to keep, but each version is a separate object you must map-free. A loop that rebinds a map without freeing the previous version keeps every version: measured ~790 B per insert, ~715 B of it leaked. When you free each old version, a live entry costs about 96 bytes -- 66 requested (a 24-byte leaf node, a 32-byte entry, and its share of the bitmap nodes above it) plus ~30 of malloc's own overhead, which a HAMT of small blocks pays more of than a flat table does. (Before 2026-10-07 every node was allocated at the size of a full 32-slot node, and an entry cost ~360 bytes -- hamt-nodes-allocated-at-full-array-size.) What a persistent map still costs is traffic: each insert copies the path from the root, about 7 allocations and 720 bytes.

None of the three containers is freed at scope exit. A compiled Vec, MutableMap or Map local that you do not free lives until the process exits.

Strings

Type What it is Heap
cstr literal a pointer to static bytes 0
cstr returned by a stdlib function a malloc'd buffer yours to free
str a pointer and length into a buffer you already have 0
String one block: 16-byte header, the bytes, a NUL 1 malloc; refcounted

string/concat copies both inputs into a new String. Building a string by concatenating in a loop therefore copies everything built so far, every time: 10 000 one-character appends cost about 5 KB of traffic per character. A StringBuilder grows its buffer by doubling, so the same build costs about 4 bytes of traffic per character. See strategy 6.

rc<T>

(rc/of v) makes two allocations: a 72-byte control block (counts, a drop function, a cycle-collector walker, GC bookkeeping) and a separate block for the value. Measured: 2 allocations per rc/of, of an int or of a struct, freed at the last drop.

The first rc/of in a program also allocates the deferred-free queue (512 KiB) and the cycle collector's block registry (32 KiB). Those live until exit. In a small program that uses one rc, they are most of the heap.

Closures

A capturing lambda is one malloc of 8 + 8 + 8 * captures bytes: a pointer to its drop glue, the code pointer, and the captured values. A closure bound in a let is freed when the let ends. A named defn passed as a value costs nothing: its fat box is allocated once, statically.

In a loop written as recursion, a closure let-bound in the body and only ever called is freed at the jump back to the top of the loop. One that the body passes on as an argument -- to the loop's own next call, or to a function that only calls it -- is kept until the outermost call into the loop returns, then freed. (Until 2026-10-07 a closure returned by a call, like (mk i) below, was never freed in that position: closure-let-in-self-tail-loop-leaks.)

Calls through function values, and effects

A function that calls a function-typed value -- a parameter f, a closure in a let, a field holding a function -- is emitted in continuation-passing style, and so is one that performs an effect or enters a handle. (Not always: a self-tail-recursive loop that calls its own function parameter can stay direct-style -- benchmarks/memory/probes/21b-fn-param-loop.tur.) Each such call allocates continuation frames. Entered from direct-style code, a call through a function value costs about 2 allocations and 300 bytes when it is a tail call, 5 and 600 when it is not (a heap join node, and the reap list's array, which every direct-style entry allocates afresh); inside an already-colored loop, about 2 and 176 a turn. A perform handled by handle with a tail resume costs 2 allocations and ~168 bytes.

Most of them are handed back as soon as they have run. The rest are freed in batches, when the outermost direct-style call returns. What that means in practice depends on what the loop around them does:

Each iteration... Peak memory
calls through a function value constant
performs an effect whose handler case ends in a tail (resume k v) and uses k for nothing else constant
enters a handle (and performs inside it, as above) constant
enters a handle whose case resumes non-tail ((+ 1 (resume k v))) once constant (~790 B of traffic a turn)
performs, under one long-lived handle, an effect whose case resumes non-tail grows ~168 B a turn until the handle returns
performs an effect whose case resumes k more than once (^multishot) grows ~795 B a turn until the outermost call returns

The last two rows are what is left of fn-value-call-cps-frames-held-until-outer-entry: a non-tail resume runs a copy of the continuation, and the copies are kept (effect-nontail-resume-copies-held-until-outer-entry). Writing such a loop as a while loop, which calls the function directly once per iteration, keeps it constant. An #fx{} annotation on the function type does not change any of this.


What frees what

You allocated It is freed when If you do nothing
rc<T> the last strong handle drops freed (unless it is in a cycle and the collector is off)
a capturing closure in a let the let ends freed
a capturing closure as the payload of a by-value Option/Result local the let ends, when every use is a match that only calls it, a some?-style tag predicate, or a call to a function that does the same otherwise leaked (sum-closure-payload-never-dropped)
a by-value struct with an rc or ref field the owning local ends freed
a by-value recursive defdata local the local ends, when the compiler proves it owns the spine freed in the proven shapes; otherwise leaked
Vec, MutableMap, Map, Set you call vec-free / mutmap-free / map-free leaked
String its count reaches 0 via string/release leaked
a cstr returned by a stdlib function you free it (or adopt it into a String) leaked
a :heap node its region rewinds leaked
a :copy recursive defdata value its region rewinds leaked
anything a region retires process exit --

"Leaked" here means "kept until the process exits". For a short-lived program that is often fine. For a server, a game loop or anything that runs a loop many times, it is the bug.


Strategies

1. Prefer by-value data

The cheapest heap block is the one you never allocate. A defstruct without :heap, an Option, a Result and a non-recursive defdata are free. Reach for :heap only when you need sharing by pointer or a recursive shape.

2. Pick the container by its per-entry cost

If you need an index or a stack, use a Vec (~19 B per int). If you need lookups and mutate in place, use a MutableMap (~78 B per entry). Use a persistent Map (~96 B per live entry) when you need its persistence: several versions alive at once, or a value you pass around and must not see change. A Map used as a mutable table is ~20% larger than a MutableMap, every insert allocates a new path (~7 allocations, ~716 B), and each old version needs its own map-free.

For a Vec of wide structs, two cheaper layouts avoid the per-element box:

For many small flags, stdlib/sized-bits.tur's SizedBitVec stores one bit per flag instead of 8 bytes per slot.

3. Free every container, and every version of a persistent one

Pair every vec-new, mutmap-new and map-new/#map{} with its free. For a persistent map you rebind in a loop, free the version you are dropping:

(load "stdlib/map.tur")

(defn fill [m : (Map int int) i : int n : int] : (Map int int)
  (if (= i n)
    m
    (let [m2 (map-assoc m i (* i i))]
      (map-free m)              ; drop the old version, keep m2
      (fill m2 (+ i 1) n))))

(defn main [] : int
  (let [m (fill (:: #map{} (Map int int)) 0 1000)]
    (println (map-count m))     ; 1000
    (map-free m)
    0))

map-free is safe under structural sharing: it decrements each node's count and frees only the nodes no other version uses. Without the (map-free m) line, this loop keeps every intermediate version. A #map{...} literal already frees its own intermediate versions.

For a bulk build of a raw HAMT, a transient avoids the intermediate versions altogether (hamt/transient, hamt/transient-set!, hamt/persistent!; see hamt-guide.md).

4. Bracket allocation-heavy phases in with-region

A region is the only way to reclaim :heap nodes, and the cheapest way to reclaim any batch of them: the nodes cost no malloc each, and the whole generation is reclaimed in one step. Put work that builds a temporary structure and returns a small answer inside with-region:

(defdata Tree :heap (Leaf) (Node [l : Tree v : int r : Tree]))

(defn build [lo : int hi : int] : Tree
  (if (> lo hi)
    (Leaf)
    (let [mid (/ (+ lo hi) 2)]
      (Node (build lo (- mid 1)) mid (build (+ mid 1) hi)))))

(defn total [t : Tree] : int
  (match t
    (Leaf) 0
    (Node l v r) (+ v (+ (total l) (total r)))))

(defn rounds [k : int acc : int] : int
  (if (= k 0)
    acc
    (rounds (- k 1)
            (+ acc (with-region (fn [] : int (total (build 1 10000))))))))

(defn main [] : int
  (println (rounds 50 0))   ; 2500250000, in the memory of one round
  0)

The region rewinds at the end of each round, and the next round reuses its slabs, so 50 rounds peak at the memory of one.

A region rewinds only if the compiler can prove that nothing allocated inside is still reachable after the bracket. When it cannot, it retires the generation instead: the memory is kept until process exit, exactly as if there were no region. Retiring is always safe; it just saves nothing. A generation retires when, inside the bracket:

Walking a stdlib (Cons A) with tnil?, thead, ttail and tlength inside a region rewinds. Building one does too with tcons-of, but not with tcons: its tail parameter is the carrier-level :int, so passing a typed list to it erases the node and the generation retires. Inside regions, build with tcons-of.

Check that your regions rewind. Run the program with TUR_REGION_STATS=1; it prints one line at exit:

region-stats: pushes=50 rewinds=50 retires=0

Every retire is a region that saved nothing. If you see retires you did not expect, look for one of the four escapes above.

5. Use rc<T> for sharing, not by habit

rc<T> costs two allocations and about 100 bytes per value, and the first one in a program costs 544 KiB. If the value has one owner, pass it by value or ^borrow it instead (ownership-guide.md). When you do share, put one rc around a struct rather than an rc around each of its fields.

An rc (or any value with drop glue) that is still live at a self tail call also stops that call from becoming a loop: its drop has to run after the call, so each iteration keeps a stack frame and its rc until the recursion unwinds. Measured: about 144 bytes of peak memory per iteration. Write ^tailcall on the call to be told when this happens; it reports TUR-E0716 and names the live local. Then move the rc into a helper that returns a plain value, so it is dead before the call.

6. Build strings with a builder

Concatenation in a loop is quadratic. A builder is linear:

(load "stdlib/string.tur")

(defn add-xs [b : StringBuilder i : int n : int] : int
  (if (= i n)
    0
    (do (builder/push-cstr! b "x")
        (add-xs b (+ i 1) n))))

(defn main [] : int
  (let [b (builder/new)]
    (add-xs b 0 10000)
    (let [s (builder/finish b)]
      (println (string/len s))   ; 10000
      (string/release s)))
  0)

Also:

7. Write long-running loops as while loops

A while loop and a self-tail-recursive function compile to the same jump, but they are not the same for memory today:

(defn mk [k : int] : (fn [int] int) (fn [x : int] : int (+ x k)))

(defn main [] : int
  (let [^mut i   0
        ^mut acc 0]
    (while (< i 100000)
      (let [f (mk i)]               ; freed at the end of each iteration
        (set! acc (+ acc (f 1))))
      (set! i (+ i 1)))
    (println acc))
  0)

For a loop that runs a handful of times, either form is fine. For an event loop, a server's accept loop or a simulation's main loop, use while.

8. Turn the collector on only for cycles

The cycle collector is off by default and only matters if you build cycles out of rc<T> values. Prefer to avoid the cycle with weak<T> for the back edge. If you need the collector, (gc-auto!) collects at allocation checkpoints; (gc-live-blocks) and (gc-candidate-high-water) tell you whether it is keeping up. A cycle that runs through a plain Vec or Map slot is invisible to it; use RcVec or RcChain for those. See gc-guide.md.


Finding where the memory went

  1. Measure the whole program. valgrind --tool=massif ./prog and ms_print massif.out.<pid> show the peak and the call stacks that allocated it. A large block from rc_free_queue_init is the one-time rc cost, not a leak.
  2. Find leaks. valgrind --leak-check=full ./prog names the allocation site of every lost block. In Turmeric's emitted C, the site names the feature: ctor_* is a :heap constructor, rc_cb_alloc* is an rc, a __env_N block is a closure, dk_frame* is a continuation frame, tur_hamt* is a map.
  3. Check regions. TUR_REGION_STATS=1 ./prog prints pushes, rewinds and retires.
  4. Check the collector. TUR_GC_TRACE=1 ./prog prints one line per collection.
  5. Read the C. tur emit-c prog.tur and search the function you care about for malloc, tur_region_alloc_or_malloc, rc_cb_alloc and __cps. A __cps twin of a function means its calls through function values allocate frames.
  6. Leak-check a fixture. In this repository, a fixture with a requires.leak-check marker is run under LeakSanitizer by tests/run-leak-check.sh; bash tests/run.sh does not check emitted programs for leaks. See test-suite-portability-guide.md.

The interpreter and the dynamic dialects

tur --interpret and the REPL use a different allocator. The tree-walking interpreter allocates frames, bindings and closures from a pool and never frees them one at a time; a trampolined loop retains about 4 KiB per step, so a 1e6-step loop peaks around 3.5 GiB under --interpret and uses nothing extra compiled. The REPL copies each turn's surviving values out and rewinds the rest. If interpreted memory is the problem, compile the program. See gc-guide.md.

#lang r7rs and #lang saffron programs replace the whole allocator with a conservative mark-sweep collector, so nothing above about explicit frees applies to them. TUR_GC_STATS=1 prints what that collector did. See gc-guide.md.


Checklist


Known issues

These are open findings that change the numbers above. Each report has a repro and the measurements.