Skip to content

Commit 0bcd2fd

Browse files
committed
Plan borrow elision: the ownership-oracle design and edge-case ledger
RC_ELISION.md is the plan of record for the elision program. It records the model (Rust's ownership and borrow analysis as an internal oracle, never a gate, RC as the always-correct fallback), the built move elision with its soundness review verdict, and the borrow-elision design: per-parameter Owned or Borrowed modes, an escape analysis seeded by stores, returns, reassignment, spawn capture, and the builtin table, propagated to fixpoint over the static call graph. The edge-case ledger covers what the assessment surfaced: returned and reassigned params stay Owned, spawn targets stay all-Owned (the task outlives the frame), finalizers already run borrowed-self, yields are safe since the caller's frame survives them, the two-sided caller-callee contract is structural under whole-program compilation but must be pinned by report tests, finalizer timing needs pinned semantics, and separate compilation later needs serialized summaries. Verified against the code: param reassignment is legal, the callee drops every ref param except a returned one, and the call graph is static.
1 parent bbaa063 commit 0bcd2fd

2 files changed

Lines changed: 144 additions & 1 deletion

File tree

plans/INDEX.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ Mostly current and self-describing. Grouped by theme.
2323
- **Modules and stdlib**: MODULES (compile-time AST imports, BUILT v1), HTTP (std.http, BUILT),
2424
WEB_SERVER_TARGET, ROADMAP_GENERICS_STDLIB, TEST_HARNESS, ASSESSMENTS (the futures ledger).
2525
- **Performance**: PROFILING (HTTP server profiling method and findings, single-core throughput and
26-
memory, the harness in `tools/profiling/`).
26+
memory, the harness in `tools/profiling/`), RC_ELISION (the ownership-oracle model, move elision
27+
BUILT, borrow elision PLANNED with the edge-case ledger).
2728

2829
## Runtime foundation (VM side), with build status
2930

plans/RC_ELISION.md

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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

Comments
 (0)