|
| 1 | +# Reference-count elision: moves, borrows, and the ownership oracle |
| 2 | + |
| 3 | +The plan of record for removing reference-counting work the compiler can prove unnecessary. The |
| 4 | +guiding model: take Rust's ownership and borrow analysis as an internal optimization oracle, never as |
| 5 | +a gate. Where the analysis proves a value's lifetime is clean the reference counting is dropped, and |
| 6 | +where it cannot the reference counting stays, which is always correct. The programmer never sees a |
| 7 | +move, a borrow, or an ownership error, and no program is ever rejected. That is the difference from |
| 8 | +Rust: Rust rejects what it cannot prove, this keeps the refcount. Cycles remain out of scope for RC |
| 9 | +entirely and belong to the future cycle collector (plans/memory/GC.md). |
| 10 | + |
| 11 | +Measurement and the profiling harness live in PROFILING.md. This doc is the design and the edge-case |
| 12 | +ledger. |
| 13 | + |
| 14 | +## Where it stands: move elision (BUILT) |
| 15 | + |
| 16 | +`elidable_moves` in `bytecode_gen.rs` proves a reference-typed variable's last use hands its single |
| 17 | +reference to a consumer (a call argument, a spawn argument, or an aliasing copy), and the lowering |
| 18 | +then skips both the retain at that site and the variable's own drop. Conditions, conservative so it |
| 19 | +can only under-elide: last real use (loop-aware, a loop-carried variable is live across the |
| 20 | +back-edge), exactly one occurrence at the move site (passing a value twice in one call hands over two |
| 21 | +references), and a single drop the move dominates (approximated as no control flow between move and |
| 22 | +drop). The `Drop` instruction itself stays in the IR, only its DecRef emission is skipped, so register |
| 23 | +lifetime bookkeeping is untouched. |
| 24 | + |
| 25 | +Measured on the http hello path: incref 102 to 81 and decref 172 to 151 per request. A soundness |
| 26 | +review traced the refcount balance per consumer kind against the pre-elision build (the residual |
| 27 | +`allocations + incref - decref` must match exactly) and found no imbalance anywhere, including the |
| 28 | +callee-returns-its-param and direct-finalizer cases. The invariant is caller-side pair cancellation: |
| 29 | +a matched retain and drop with the variable provably dead between them cancel regardless of what the |
| 30 | +consumer does. |
| 31 | + |
| 32 | +## The next lever: borrow elision (PLANNED) |
| 33 | + |
| 34 | +Today every reference argument is retained by the caller because the convention is callee-owns: the |
| 35 | +callee's return-path drops release every reference parameter (except a returned one, whose ownership |
| 36 | +moves out through the return value). Move elision only removes the last-use case. |
| 37 | + |
| 38 | +The borrow insight, Rust's shared reference: a parameter the callee only reads needs no ownership |
| 39 | +transfer at all. Give every function a per-parameter mode, Owned or Borrowed. A Borrowed parameter is |
| 40 | +never dropped by the callee and never retained by the caller, who keeps its own drop and thereby |
| 41 | +guarantees the value outlives the synchronous call. Counting the ops for a value passed to k calls: |
| 42 | +the owned convention costs k retains, k callee drops, and one final drop. The borrowed convention |
| 43 | +costs one final drop. Move elision only ever saved one retain and one drop of that total, so borrows |
| 44 | +are the much larger category, and most parameters are read-only in practice (the http parse helpers |
| 45 | +on the hot path are exactly this shape). |
| 46 | + |
| 47 | +### Why this compiler can do it cheaply |
| 48 | + |
| 49 | +- **Whole-program compilation.** The prelude is prepended and imports are AST inclusion, so every |
| 50 | + caller and callee compile together in one run from one summary map. The two-sided contract (both |
| 51 | + sides must agree on each parameter's mode) is structural rather than a linking problem. |
| 52 | +- **Static call graph.** There are no first-class functions, every call resolves to a concrete |
| 53 | + function at compile time, and spawn names its target statically. Escape propagation is an ordinary |
| 54 | + fixpoint over a finite graph. |
| 55 | +- **Monomorphized typed IR.** Generics are resolved before lowering, so summaries are computed on |
| 56 | + concrete functions and no polymorphic mode juggling exists. |
| 57 | +- **One chokepoint.** `lower_program` already holds main plus every function and lowers each body. A |
| 58 | + summary pass runs there before lowering, then `lower_body` consults it on both sides: skip retains |
| 59 | + for arguments in Borrowed positions, skip param drops for the current function's Borrowed |
| 60 | + parameters. |
| 61 | + |
| 62 | +### The analysis |
| 63 | + |
| 64 | +A parameter escapes (and is therefore Owned) when any of these applies to it or to any alias of it |
| 65 | +inside the callee: |
| 66 | + |
| 67 | +- stored into a struct field, array or list element, map entry, enum payload, or global cell |
| 68 | +- returned (ownership moves out to the caller through the return value) |
| 69 | +- passed to another call in a position whose parameter is itself Owned (the transitive case, resolved |
| 70 | + by the fixpoint) |
| 71 | +- passed to a builtin that stores or keeps it, per a curated builtin table (`push`, `set`, map set, |
| 72 | + global set, spawn keep their value argument, `buf_push`, `write_str`, `str_concat2`, comparisons |
| 73 | + and reads only read), with unknown treated as escaping |
| 74 | +- captured by spawn (the task outlives the caller's frame) |
| 75 | +- reassigned inside the callee (the end-of-scope drop releases the current value, so skipping the |
| 76 | + param drop would leak the reassigned value, and param reassignment is verified legal today) |
| 77 | +- the function is a spawn target (the task runtime owns and releases its arguments, and the spawner's |
| 78 | + frame may be gone before the task runs, so no caller-side lifetime guarantee exists) |
| 79 | + |
| 80 | +Everything else is Borrowed. Seed the direct escapes, propagate through the static call graph until |
| 81 | +stable. Escape facts only grow, the graph is finite, so the fixpoint terminates, and recursion and |
| 82 | +mutual recursion need no special case. |
| 83 | + |
| 84 | +### Interaction with move elision |
| 85 | + |
| 86 | +A Borrowed position no longer consumes, so arguments passed there leave the move-candidate set (a |
| 87 | +move into a non-consuming position would leak: nobody would drop the value). Owned positions keep |
| 88 | +today's move logic unchanged. For a last-use temporary the two come out equal (one RC op either way), |
| 89 | +everywhere else the borrow wins, so the composition is strictly better and never double-elides. |
| 90 | + |
| 91 | +## Edge cases and issues (the ledger) |
| 92 | + |
| 93 | +- **Returned params stay Owned.** The callee already skips the drop for a returned parameter and |
| 94 | + ownership transfers to the caller. A Borrowed-and-returned parameter would hand the caller a |
| 95 | + reference it never owned: one count, two eventual drops. |
| 96 | +- **Reassigned params stay Owned.** As above, the reassigned value needs the end-of-scope drop. |
| 97 | +- **Spawn targets get all-Owned params.** Statically detectable since spawn sites name the function. |
| 98 | + This costs some hot-path wins (`http_conn_task` is a spawn target) and is the price of the frame |
| 99 | + lifetime argument. A later refinement can split a task entry wrapper from the borrowable body. |
| 100 | +- **Finalizers are precedent, not a problem.** The runtime already invokes `Delete` without a retain |
| 101 | + and the finalizer does not drop `self`, which is exactly a borrowed-self convention. Keep the |
| 102 | + existing special case untouched. |
| 103 | +- **Green-thread yields are safe.** A callee that parks mid-call suspends the caller's frame with it, |
| 104 | + so the caller's reference survives the yield and the lifetime guarantee holds. Spawn is the only |
| 105 | + way a callee outlives its caller and is excluded above. |
| 106 | +- **Aliases inside the callee.** `let q = p` then `q` escapes means `p` escapes. First cut: any |
| 107 | + aliasing of a parameter marks it Owned, refine to alias-tracking later. |
| 108 | +- **Finalizer timing shifts.** Elision moves the instant a count reaches zero (a borrowed argument |
| 109 | + now dies at the caller's scope end rather than inside the callee). Pin the semantics: a finalizer |
| 110 | + runs no later than the end of the owning scope, and no elision may extend a value's life past what |
| 111 | + the unoptimized program would observe at scope ends. Record this in the language docs when the |
| 112 | + surface stabilizes. |
| 113 | +- **Callee-side skipping is per variable, all paths.** Param drops appear on every return path plus |
| 114 | + the fall-through. Borrowed mode skips all of them, the opposite polarity of the caller-side |
| 115 | + per-site retain rule. Cheap, since the mode is per parameter. |
| 116 | +- **The two-sided contract is the sharpest risk.** Caller and callee must agree on every parameter's |
| 117 | + mode. One summary map drives both sides in one compilation, and `--rc-report` should print each |
| 118 | + function's modes so tests can pin them (the same oracle pattern the move elision used). |
| 119 | +- **Separate compilation later.** Bytecode-level linking (IMPORT.md) must serialize summaries into |
| 120 | + the module format, or boundaries fall back to all-Owned. Whole-program holds today. |
| 121 | +- **Multicore later.** A borrow is valid only within the frame-synchronous world. Cross-core sends |
| 122 | + are moves of `Owned<T>` (DESIGN_NOTES), and a send is an escape, so the models compose. |
| 123 | +- **Closures later.** If first-class functions ever land, a capture is an escape. The static call |
| 124 | + graph assumption is load-bearing and must be revisited then. |
| 125 | +- **Testing oracle.** The decisive equivalence test is residual matching: `allocations + incref - |
| 126 | + decref` on the optimized build must equal the unoptimized build for the same program (residuals are |
| 127 | + compared rather than required to be zero because top-level values are reclaimed by VM teardown |
| 128 | + rather than dropped). Plus the leak suite, byte-identical goldens in both fold modes, and pinned |
| 129 | + per-mode tests: a read-only param is Borrowed, a stored or returned or reassigned param is Owned, a |
| 130 | + spawn target is all-Owned. |
| 131 | + |
| 132 | +## Order of execution |
| 133 | + |
| 134 | +1. **Summaries plus report, no codegen change.** Compute per-function parameter modes with the |
| 135 | + fixpoint, extend `--rc-report` to print them, pin the classification with tests (the measure-first |
| 136 | + pattern that caught the loop-carried bug in move elision). |
| 137 | +2. **Flip codegen on both sides.** Skip caller retains into Borrowed positions and callee drops of |
| 138 | + Borrowed params. Exclude Borrowed positions from move candidates. Prove with residual equivalence, |
| 139 | + the full suites, goldens, and the vm-counters delta on the http path. |
| 140 | +3. **Refine.** Alias-aware escape inside callees, a task-entry wrapper so spawn targets can borrow, |
| 141 | + dominance-based move elision, return-value moves, and the orphan-temp direct free that attacks |
| 142 | + the per-request allocation count. |
0 commit comments