Memory Model — How FBIP Works
March’s headline promise is functional code that runs in place: you write pure transformations over immutable data — the style where you never mutate anything, you just build new values from old ones — and the compiler turns that into ordinary in-place mutation wherever it can prove no one will notice the difference. There is no tracing garbage collector, no stop-the-world pause, and — in the common “transform and return” case — no allocation at all. This page explains the mechanism end to end, from first principles, so you don’t need a compilers background to follow it.
The two ingredients are Perceus reference counting (deterministic, compiled in — the technique comes from the 2021 paper “Perceus: Garbage Free Reference Counting with Reuse” by Reinking, Xie, de Moura, and Leijen, if you want the formal treatment) and FBIP — Functional But In-Place (the reuse optimization that rides on top of it). The same uniqueness property that makes FBIP work also makes parallel FBIP lock-free — no thread coordination needed.
Perceus: deterministic reference counting
Every heap value carries a small reference count (RC) — a running tally of how many places in the program currently hold a pointer to it. The classic problem with reference counting is throughput: naively, every time a value is passed, returned, or bound, you pay an increment, and every time a reference dies you pay a decrement — and those add up fast.
Perceus eliminates almost all of that cost. Instead of inserting an inc/dec
around every single use, the compiler traces, ahead of time, exactly where each
value is created and where it’s used for the last time — then it inserts inc/dec
operations only at the points where ownership of a value actually changes, and
simplifies away as much of that as it can:
- A function that receives a value and immediately returns a transformed version often needs no increment at all — the caller’s reference is transferred.
- An
incfollowed by adecon the same path provably cancels, and both are removed. - What survives is only the genuinely uncertain residue.
The result is deterministic memory management. Because the dec that frees a
value is emitted by the compiler at that value’s last use, deallocation
happens at a known program point:
| Tracing GC (e.g. OCaml’s minor/major) | Perceus RC | |
|---|---|---|
| When memory is freed | Later, when a collection runs | At the value’s last use, in line |
| Pause | Stop-the-world (or concurrent barriers) | None — no scan ever happens |
| Write barriers | On every pointer store | None (immutable-by-default has no stores) |
| Predictability | Depends on heap pressure | Compile-time, per-value |
No scan. No pause. No write barriers. Freeing is just a dec the compiler
already wrote into the code.
Uniqueness = RC 1
The key fact Perceus exploits at runtime is simple: when a value’s last use sees RC == 1, that value is uniquely owned. No other reference exists, so nothing else can ever observe it again. Its memory is about to become garbage.
If, at that same point, the program is allocating a new value of the same shape (same constructor arity / size class), the runtime skips the allocator entirely and reuses the dying value’s memory for the new one. One uniqueness check replaces an allocator round-trip — and the new value lands at the same address, so it stays hot in cache.
This is the whole trick. FBIP is just this rule applied to constructor rewrites.
FBIP: a worked before/after
Here is the canonical example — incrementing every leaf of a binary tree:
mod TreeDemo do
ptype Tree = Leaf(Int) | Node(Tree, Tree)
pfn inc_leaves(t : Tree) : Tree do
match t do
Leaf(n) -> Leaf(n + 1)
Node(l, r) -> Node(inc_leaves(l), inc_leaves(r))
end
end
pfn sum_leaves(t : Tree) : Int do
match t do
Leaf(n) -> n
Node(l, r) -> sum_leaves(l) + sum_leaves(r)
end
end
-- A runnable entry point: build a small tree, increment every leaf, and
-- fold it back to a number so the demo cell below can print a result.
fn demo() : Int do
let t = Node(Leaf(1), Node(Leaf(2), Leaf(3)))
sum_leaves(inc_leaves(t))
end
end
Read functionally, this allocates a brand-new tree: every Leaf(n + 1) and
every Node(...) is a fresh constructor. A naive implementation would allocate
a full second tree and then free the first.
What the compiler actually emits. When inc_leaves owns t uniquely (the
caller transferred its only reference), Perceus knows that the moment the match
scrutinizes a Node(l, r), that Node cell is dying — its RC is 1 and its last
use is right here. The rebuild Node(inc_leaves(l), inc_leaves(r)) has the
same shape as the cell being matched, so instead of allocating, the compiler
reuses the matched cell in place:
before (functional reading): after (what runs):
match Node(l, r): match cell @0x40 = Node(l, r):
allocate a NEW Node reuse cell @0x40
fill it with (l', r') overwrite its two fields with (l', r')
free the old Node (no alloc, no free — same address)
Conceptually the optimized form is reuse t as Node(l', r'): the Node(l, r)
you matched and the Node(...) you build are the same heap cell, with its
fields rewritten. Across the whole tree, a transform that looks like it allocates
N nodes allocates zero after the first pass — it walks the existing tree and
overwrites it.
Why this is safe. Immutability is what makes the rewrite invisible. Because
no other reference to the old Node can exist (RC == 1) and March values are
immutable (no aliased reference could have stashed a pointer into the old cell to
observe later), there is no observer to fool. The language semantics say “old
value gone, new value fresh”; the runtime says “same bytes, rewritten.” Both
agree because no one else is looking.
This is why
tree_transform(the FBIP benchmark) runs roughly 15× faster than the equivalent C that allocates and frees a fresh tree each pass, and several times faster than OCaml’s tracing GC: it does no allocator work at all in steady state.
Try it. The TreeDemo module above is runnable — this cell builds the sample
tree Node(Leaf(1), Node(Leaf(2), Leaf(3))), runs inc_leaves, and sums the
result. The leaves 1, 2, 3 become 2, 3, 4, so the total is 9:
println("sum after inc_leaves: " ++ int_to_string(TreeDemo.demo()))
When reuse fires vs. falls back
Reuse fires when, at a constructor expression, the compiler can pair it with a uniquely-owned dying value of matching shape. It falls back to a normal allocation when it can’t:
| Situation | Outcome |
|---|---|
| Matched value is uniquely owned (RC == 1) and the rebuild matches its shape | Reuse in place — no allocation |
| Matched value is shared (RC > 1 — someone else still holds it) | Allocate fresh, leave the shared value untouched |
| No same-shape value is dying at this allocation point | Allocate fresh |
The crucial property: the fallback is automatic and always correct. If a value is shared, mutating it in place would be observable — so Perceus simply doesn’t. It allocates a new value and decrements the shared one’s count. You never get a wrong answer from a missed reuse; you only get an allocation. A fallback is a performance characteristic, never a bug.
This is also why you can reason about reuse locally: sharing a value (keeping the old binding around, storing it in two places) is exactly what disables reuse at that site, and the compiler quietly does the safe thing.
Writing allocation-free code
You don’t write reuse directives — the compiler decides. But you can see its decisions and steer them. The LSP reports, as inlay hints and per-function code lenses:
♻ in-place— FBIP fired: this value was reused without allocating.⧉ copied— a value had to be copied (it was shared, so reuse couldn’t fire).⚡ stack-allocated— the value never reached the heap at all.
The practical loop:
- Turn on performance annotations (
march.inlayHints.performanceAnnotations). - Scan a hot function.
♻and⚡are good. A⧉ copiedinside a hot loop is a refactor target — that’s an allocation you can probably remove. - Rewrite so the value you transform is consumed, not aliased.
The rules that keep reuse firing:
- Consume the value you transform. Destructure it and rebuild from the pieces; don’t read the original again afterward.
- Don’t reuse the old binding after rebuilding. A second use of the old value pushes its RC above 1 at the rebuild point — reuse can’t fire.
- Keep the constructor shape and arity matched. Reuse needs the dying cell and the new cell to be the same size class.
Here is a ⧉-triggering anti-pattern next to its ♻ fix:
ptype Box = Box(Int, Int)
-- ⧉ copied: `b` is read AGAIN after the new Box is built, so the old Box is
-- still live (RC > 1) at the rebuild point. The compiler must allocate a copy.
pfn bump_copied(b : Box) : (Box, Int) do
match b do
Box(x, y) ->
let updated = Box(x + 1, y)
let old_x = match b do Box(ox, _) -> ox end -- second use of `b`
(updated, old_x)
end
end
-- ♻ in-place: `b` is consumed exactly once. Nothing else references the old
-- Box, so its cell is rewritten in place — zero allocation.
pfn bump_reused(b : Box) : Box do
match b do
Box(x, y) -> Box(x + 1, y)
end
end
If bump_copied genuinely needs the old field, read it before you rebuild
(bind x in the same match, then return it) rather than matching b a second
time — that collapses the two uses into one and reuse fires again.
Parallel FBIP needs no locks
Normally, running code on two threads at once means you need locks or atomic operations to stop them from corrupting the same memory at the same time — even something as simple as two threads incrementing the same reference count needs synchronization, or you can lose an update. The uniqueness that powers FBIP sidesteps this entirely and makes it lock-free across cores.
Consider summing or transforming the two children of a tree in parallel:
Node(l, r) -> Node(inc_leaves(l), inc_leaves(r))
l and r are disjoint subtrees. When the parent Node is uniquely owned,
each child is uniquely owned within its own branch — RC == 1 in its own context.
Hand l to one core and r to another and each rewrites its subtree in place
with nothing to synchronize: there is no shared cell two cores could both
touch, so there’s no lock, no atomic RC traffic, no contention. Uniqueness is
the disjointness proof.
This is exactly the property the parallel benchmark relies on: sibling subtrees
have independent RC chains, so in-place reuse stays correct on both sides with no
locking. The same idea scales to actor message passing — see the
parallelism guide, and
linear types for the ownership-transfer
(“zero-copy send”) case where a linear value is guaranteed RC == 1.
Where this sits in the bigger picture
FBIP doesn’t work alone — it’s one layer of several that reinforce each other. You don’t need to understand every layer below to use March, but here’s how they fit together:
linear/affinevalues have statically known lifetimes — the compiler already knows, from the type alone, exactly where each one is used for the last time, so it can insertfreethere directly, with zero RC bookkeeping at all. Alinearvalue is the strongest FBIP case: RC == 1 by construction, guaranteed by the type system rather than inferred by the compiler.- Immutable-by-default means pointer fields are never written after construction, which is what eliminates write barriers — and what makes in-place reuse unobservable.
- Whole-program monomorphization and defunctionalization — two compile-time passes
that turn generic, closure-using code into plain, concrete code (a separate copy of a
generic function per concrete type it’s used with; closures turned into ordinary data
instead of heap-allocated function values) — remove a lot of what would otherwise force
heap allocation. That, in turn, lets the compiler prove that many values never
outlive the function call that created them, so it can put them on the stack instead
of the heap entirely (
⚡ stack-allocated).
The net effect: most values never touch the heap, the ones that do are usually
reused rather than reallocated, and the residual frees are deterministic dec
operations the compiler already wrote — not a collector you have to wait for.
Next Steps
- Linear Types — ownership that guarantees RC == 1, and zero-copy actor sends.
- Safety by Construction — how the safety layers stack on one function.
- Parallelism — the scheduler that runs disjoint, uniquely-owned subtrees across cores.
- LSP & Editors — turning on the
♻ / ⧉ / ⚡performance hints.