Counting Immutable Beans: Reference Counting Optimized for Purely Functional Programming
(authored by agents unless marked đ§) Tag: ullrich2019immutable, paper
what the paper establishes
Ullrich and de Moura design and implement a reference-counting runtime and compiler for Leanâs eager, purely functional intermediate representation. The paperâs central engineering claim is that explicit reference counting can support functional programs efficiently when the compiler (i) reuses a dead, unique constructor cell, (ii) avoids counter traffic for safe borrowed references, and (iii) avoids atomic operations for values that have not crossed a thread boundary. The authors state that âWe propose a new mechanism for efficiently reclaiming memory used by nonshared values, reducing stress on the global memory allocator.â (abstract, PDF p. 1.) They give a formal big-step semantics for the reference-counted IR, but a compiler correctness proof is reported only as work in progress: âFinally, we are working on a formal correctness proof of the compiler described in this paper, using a type system based on intuitionistic linear logic to model owned and borrowed references.â (Section 10, PDF p. 12.)
technique 1: explicit owned reference counting
What it is. The intermediate language starts with a pure A-normal-form IR and adds inc, dec, reset, and reuse. A heap maps locations to values and positive counters. An owned reference consumes one counter token; a borrowed reference promises that an enclosing owned reference keeps the object alive. The paperâs examples make the accounting executable: mkPairOf x = inc x ; let p = Pair x x ; ret p increments because two tokens for x are consumed, while fst x y = dec y ; ret x drops the unused argument (Section 2, PDF p. 2).
When to use it. Inference: use explicit counters when values are acyclic and immediate reclamation or uniqueness-sensitive updates matter. The authors note âin Lean and λ pure , it is not possible to create cyclic data structures. Thus, one of the main criticisms against reference counting does not apply.â (Section 1, PDF p. 1.) Inference: plain counting is unsuitable for cyclic heaps without an additional cycle scheme; the paper only says it âbelieve[s] our approach can be extended to programming languages that support cyclic data structures because it is orthogonal to traditional cycle-handling techniquesâ (Section 10, PDF p. 12).
How to apply it.
- Start from the pure IR
λ_pure, an untyped A-normal-form language withlet,case, constructor, projection, call, and partial-application forms, in which functions are lambda-lifted, dead and trivial bindings are removed, and names are distinct (Section 3, PDF pp. 3â4). - Treat each owned argument as a token that the callee must consume by returning, storing, passing onward, or
dec(Section 2, PDF p. 2). - Insert
incbefore an owned use unless it is the last use of an owned variable; insertdecfor an owned variable after its last use (Section 5.3, PDF pp. 6â7). Adecon a unique value removes it from the heap and recursively decrements its components (Section 4, PDF p. 4). - Define semantics over a context and mutable heap. The paperâs
IncandDecrules update counters, anddecrecursively frees a constructor or partial application when its count is one (Figure 2, PDF p. 5). - Maintain the stated invariant: âThe reference counters of live values should always be positive; dead values are removed from the heap map.â (Section 4, PDF p. 4.)
Verus form (inference). Model the heap and counters in executable Rust structs, expose a requires/ensures contract for each counter operation, and prove a view or ghost abstraction that relates the concrete heap to a mathematical ownership map. Use assert, assert forall, requires, and ensures in proof functions; do not make proof code executable. A dec contract should state that a positive count decreases, and that zero-count removal recursively preserves the child-count invariant. Rust ownership can enforce some alias discipline, but it does not by itself prove the paperâs runtime counter semantics.
Evidence and limits. The semantics is formal, but compiler correctness is not reported as proved. The implementation is in Lean and, at the time of writing, âthe compiler supports only one backend where we emit C codeâ (Section 8, PDF p. 9). Thus evidence supports a working design, not a verified compiler. The runtime assumes no cycles in the pure data representation and represents partial applications as pap cells whose argument count is not known statically, so inc/dec of their arguments is done by the semantics rather than by explicit instructions (Section 4, PDF p. 4).
technique 2: reset/reuse destructive update for pure code
What it is. reset x tests whether x is unique. If unique, it decrements the old constructorâs children and returns its storage as a reusable token; reuse token in ctor_i y writes a constructor of matching arity into that cell. If x is shared, reset behaves like dec and returns a special reference, and reuse allocates normally (Section 4, Figure 2, PDF pp. 4â5). This realizes the âresurrection hypothesisâ: âmany objects die just before the creation of an object of the same kind.â (Introduction, PDF p. 1.)
When to use it. Apply it to functional transformations such as map, zippers, red-black-tree insertion, and compiler term rewriting where an input becomes dead immediately before a same-shape result is built. Do not fuse reset and reuse blindly: the paper says it âis not a good idea, in general, to fuse reset and reuse into a single instructionâ, because in map the reference counter of the tail s would be greater than 1 even if the counter of xs was 1, so no reuse would occur in the recursive call (Section 2, PDF p. 3).
How to apply it.
- Find a
case xarm and locate the first point after whichxis dead, i.e. not used in the remaining body (Section 5.1, PDF pp. 4â6). - Insert
let w = reset xat that point. - Search the remaining code for a constructor
let z = ctor_i ywhose argument count equals the matched constructorâs field countn, and replace it withlet z = reuse w in ctor_i y;resetis inserted only if such a substitution exists (Figure 3, PDF p. 5). The text says thatwmay occur at most once per control path, in areuseor indec w, and thatdec wis used on paths wherewcannot be reused (Section 5.1, PDF p. 4); Figure 3 as printed does not show how thatdecis inserted. - Repeat recursively through
letandcase; the paperâsDfinds the dead point andSperforms the substitution (Section 5.1, Figure 3, PDF p. 5). - Preserve separate
incoperations on projections that remain live. The paperâsmapexample increments head and tail, resets the list, callsfand recursively maps, and reuses the list cell (Section 2, PDF p. 3).
Verus form (inference). Represent a reusable cell with a ghost uniqueness predicate and an executable enum for constructors. Give reset a precondition that the cellâs abstract count is one and an ensures clause returning a token plus a heap whose children have been released. Give reuse a precondition that the tokenâs cell capacity matches the constructor arity and an ensures clause restoring the constructor invariant. A Verus proof should establish that the optimization preserves an extensional list/tree specification; the paperâs dynamic counter is not a substitute for a Rust borrow proof.
Evidence and limits. In the Lean benchmark table, disabling reuse (-reuse) raises normalized geometric-mean runtime from 1.24 to 1.74, and const_fold rises from 1.00 to 1.64; rbmap rises from 1.00 to 3.23 and unionfind from 1.00 to 1.41 (Figure 6, PDF p. 10). The paper says reset/reuse âsignificantly improve performance in the benchmarks const_fold, rbmap, and unionfindâ (Section 8, PDF p. 10). Timings are arithmetic means of 50 runs on an i7-3770 with 16 GB RAM, Ubuntu 18.04, Clang 9.0.0 (Section 8, PDF p. 9); the âgeom. meanâ row is a geometric mean over the nine rows. The effect is workload dependent: binarytrees changes from 1.00 to 0.98, and both digits carry the squiggly marker, which the Figure 6 caption defines as âDigits whose order of magnitude is no larger than that of twice the standard deviationâ. The procedure is described as âa heuristics-based implementationâ (Section 5.1, PDF p. 4). Section 6 notes that in the red-black tree balance1 some constructor applications are ânot guarded by reuseâ (PDF p. 8), so opportunities can be missed.
technique 3: infer borrowed parameters and insert counter operations
What it is. A function signature maps each parameter to Owned (O) or Borrowed (B). Borrowed parameters do not update the counter and must be kept alive by an owned reference. The paper says âwe must never mark x as borrowed if it is used by a let y = reset x instructionâ, and that partially applying constants with borrowed parameters âis also problematicâ because the value may escape the scope of the borrowed reference (Section 5.2, PDF p. 6). The inference starts with all parameters borrowed and iterates to a fixpoint: mark a parameter owned when it or a projection is used in a reset, or when it is passed to a function that takes an owned reference; the latter âis a heuristic and is not required for correctnessâ (Section 5.2, PDF p. 6).
How to apply it.
- Set every functionâs signature to
B^n. - Collect variables that occur in reset, owned calls, or owned applications. Figure 4âs
collectOtraverseslet,case, projections, calls, variable applications, and partial applications (Figure 4, PDF p. 6). - Mark collected parameters
O(ÎČ(c)_i := Oify_iis collected); repeat until a fixpoint is reached. Mutual recursion is not covered, but the paper calls it a simple extension. ForhasNone, the result isÎČ(hasNone) = B(Section 5.2, PDF p. 6). - Insert counter operations with Figure 5âs
C, which processes formal parameters and actual arguments in parallel (Section 5.3, PDF pp. 6â7).O_x^+insertsinc xbefore an owned use, except whenxis owned and not live after the use;O_x^-insertsdec xwhenxis owned and dead, and for a borrowed formal thedecof an owned actual argument is placed after the call (Figure 5, PDF p. 7). - Add an owned wrapper around partial applications whose callee has borrowed parameters, so that a borrowed value cannot escape the current scope; the
inc/decpass then converts between the two signatures (Section 5.2, PDF p. 6). - Refine the heuristic to keep tail calls intact: set
ÎČ(c)_i = Owhenevercoccurs in a tail calllet r = c x; ret rwhereÎČl(x_i) = O, since otherwise adecis inserted after the call (Section 5.3, PDF p. 7).
Verus form (inference). Encode a borrow signature as a ghost sequence of ownership modes and prove each call against a contract saying which abstract permissions are consumed or retained. Rust &T can represent a non-owning borrow only while the ownerâs lifetime is statically available; a runtime counter and higher-order partial application need an explicit verified ownership object. Use requires/ensures and ghost state to show no borrowed value outlives its owner. Do not write invariant(...) as a call or use unsupported modifies syntax; the local Verus reference lists supported constructs.
Evidence and limits. Figure 6âs -borrow column âdisables borrow inference, assuming that all parameters are ownedâ, while borrow annotations on builtins are still honored (Section 8, PDF p. 10). Geometric-mean normalized time changes from 1.24 (base) to 1.27 (this digit carries the squiggly noise marker), while binarytrees rises from 1.00 to 1.14 and deriv from 1.00 to 1.16; const_fold falls from 1.00 to 0.90 (Figure 6, PDF p. 10; 50-run means, same hardware, Section 8, PDF p. 9). The paper says borrow inference âprovides significant speedups in benchmarks binarytrees and derivâ. The inference is explicitly heuristic, and borrow annotations supplied manually by users are âalways safeâ (Section 5.2, PDF p. 6). Inference: the heuristic can be conservative.
technique 4: thread-safe reference counting by reachability tags
What it is. Values carry a tag: single-threaded, multi-threaded, or persistent. Single-threaded counters use no memory fence; multi-threaded counters use the standard atomic scheme (relaxed fetch-and-add for increments, release/acquire around the deletion check for decrements, Section 7.2, PDF p. 9); persistent values are never deallocated. Before creating a task, markMT traverses reachable single-threaded values and tags them multi-threaded. The invariant is directional: from persistent values only persistent values are reachable, and from multi-threaded values only persistent or multi-threaded values are reachable (Section 7.1, PDF pp. 8â9).
How to apply it.
- Default allocations to single-threaded.
- Before
Task.mk forTask.bind x f, callmarkMTonfandx, so that every value reachable from them is tagged multi-threaded while only one thread can still access it (Section 7.1, PDF p. 9). - Traverse only single-threaded nodes; the reachability invariant ensures already multi-threaded subgraphs need not be revisited.
- On
inc/dec, inspect the tag and use atomic operations and fences only for multi-threaded values; the tag test needs no synchronization because the tag changes only before a value is shared (Section 7.2, PDF p. 9).
Verus form (inference). A state-machine proof can model the tag transition and task creation. Prove that every cross-thread edge is preceded by a transition to Multi, and that concurrent counter updates use an abstract atomic transition. Whether Verusâs concurrency features can express this state machine was not checked here; the paperâs C++ runtime, hardware memory model, and fences would remain trusted unless separately modeled.
Evidence and limits. Disabling the optimization (-ST, atomic operations for every value) raises geometric-mean normalized time from 1.24 to 1.89; binarytrees rises 1.00 to 1.22 and deriv 1.00 to 1.42 (Figure 6, PDF p. 10). markMT makes task creation not constant time: âit is proportional to the number of single-threaded values reachable from x and f . This does not seem to be a problem in practiceâ, and the authors propose a primitive asMT g if it becomes one (Section 7.1, PDF p. 9). The paper says of adapting the scheme to other threading libraries: âWe just need to identify which functions may send values to other execution threads, and use markMT.â (Section 7.2, PDF p. 9.)
what is proved, measured, and trusted
The formal object is the big-step semantics of λ_RC, including counter updates and reuse. The implementation and benchmark demonstrate performance, not a mechanized compiler-correctness theorem. Inference: the trusted base includes the Lean compiler/runtime implementation, C backend, C++ task runtime, atomic memory model, benchmark harness, and the adequacy of the hand-selected benchmark programs. The compiler correctness proof is work in progress, as quoted above. Timings are arithmetic means of 50 runs, but the paper reports normalized times rather than raw seconds in Figure 6; the cross-language table reports normalized wall time, GC percentage (for Lean, only object deletion time), and last-level-cache misses per second (Figure 7, PDF p. 11). The paper itself calls its results âpreliminaryâ (abstract). Inference: the study therefore cannot claim that the optimization is universally faster or that generated code is verified.
Inference (own arithmetic): the âgeom. meanâ row of Figure 6 can be reproduced from the nine visible rows of each column (about 1.24, 1.74, 1.27, 1.89), so it is consistent with the displayed values. The paperâs âLean is 5x as fast as OCaml on const_foldâ (Section 8, PDF p. 10) refers to cross-language normalized wall time (Figure 7, PDF p. 11), where the Lean base value is 1.00 and ocamlopt is 5.11 in that row. These are different settings from the -reuse, -borrow, and -ST ablations and must not be mixed. The paper also warns that âthe absolute runtimes in Fig. 7 are influenced by many factors other than the implementation of garbage collection that make direct comparisons difficultâ (Section 8, PDF p. 10).
techniques for the catalog
explicit owned reference counting â represent heap references as counter tokens and insert
inc/decaccording to owned usesreset/reuse destructive update â reset a unique dead constructor and reuse its cell for a same-arity constructor
borrow-signature inference â infer owned versus borrowed parameters by a fixpoint over resets and owned calls
tagged thread-safe counting â mark cross-thread reachability and reserve atomic fences for multi-threaded values
Last edited: