Skip to content

Commit e338bee

Browse files
committed
Borrow elision step 2: flip codegen on both sides of the contract
A caller no longer retains an argument passed to a Borrowed parameter position and keeps its own drop, which guarantees the value outlives the synchronous call. A callee no longer drops its Borrowed parameters. One summary map computed in lower_program drives both decisions, so the two-sided contract cannot disagree. Borrowed positions leave the move-candidate set (a move into a non-consuming position would leak), and the current function's own Borrowed parameters are excluded from move candidacy defensively, though the escape fixpoint already forces anything they alias, spawn, or hand to an Owned position to be Owned itself. Measured per http request: incref falls from 81 to 43 (102 before any elision) and decref from 151 to 113, with the per-request residual identical between builds at 23.70, preserving the reference balance. All suites and goldens green in both fold modes. One pinned finalizer test changed as intended: a value passed to two read-only callees now finalizes at the owner's scope end rather than at the consuming call, the documented borrow timing.
1 parent 19dd6ae commit e338bee

3 files changed

Lines changed: 106 additions & 29 deletions

File tree

plans/RC_ELISION.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,17 @@ assumption, and the melding is three layers, from safest to most optimized:
174174
owned). First measurement on the http hello server: 61 of 83 reference parameters are borrowable,
175175
including the whole parse path (`self` receivers, `Connection.write` data, `Request.header`
176176
name), while `unwrap_or`'s fallback correctly stays owned since it can be returned.
177-
2. **Flip codegen on both sides.** Skip caller retains into Borrowed positions and callee drops of
178-
Borrowed params. Exclude Borrowed positions from move candidates. Prove with residual equivalence,
179-
the full suites, goldens, and the vm-counters delta on the http path. This is the
180-
soundness-critical step and gets the same two-angle review the move elision got.
177+
2. **Flip codegen on both sides. DONE.** One summary map computed in `lower_program` drives both
178+
sides, so the two-sided contract is agreed by construction: the caller skips the retain into a
179+
Borrowed position and keeps its own drop, the callee skips the drop of a Borrowed parameter, and
180+
Borrowed positions leave the move-candidate set (with the current function's own Borrowed
181+
parameters excluded from candidacy defensively, though the fixpoint already forces anything they
182+
alias, spawn, or hand to an Owned position to be Owned). Measured per http request: incref 102 to
183+
81 to 43 across no-elision, move-elision, and borrow-elision builds, decref 172 to 151 to 113,
184+
and the per-request residual `allocations + incref - decref` is identical between the move and
185+
borrow builds at 23.70, so the balance is preserved. All suites and goldens green in both fold
186+
modes. One pinned test changed as intended: a value passed to two read-only callees now finalizes
187+
at the owner's scope end rather than at the consuming call, the documented borrow timing.
181188
3. **Refine.** Alias-aware escape inside callees, a task-entry wrapper so spawn targets can borrow,
182189
dominance-based move elision, return-value moves, and the orphan-temp direct free that attacks
183190
the per-request allocation count.

solid-snake-compiler/src/bytecode_gen.rs

Lines changed: 93 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -583,8 +583,16 @@ fn for_each_stmt_read(stmt: &TypedIRStmt, f: &mut impl FnMut(usize)) {
583583
/// is elided (it was consumed). Conservative and sound: the move must dominate the drop, approximated
584584
/// by a single drop with no control flow between the move and it (a straight-line consumed value), and
585585
/// it is loop-aware (a variable carried into a loop is live across the back-edge, never a move). When
586-
/// it cannot prove a move it keeps the reference counting. See plans/PROFILING.md.
587-
fn elidable_moves(ir: &[TypedIRInstruction]) -> HashMap<usize, usize> {
586+
/// it cannot prove a move it keeps the reference counting. Borrowed call positions are not move
587+
/// candidates (the callee no longer consumes there, so a move would leak), and the current
588+
/// function's own Borrowed parameters are excluded defensively (a function cannot give away a
589+
/// reference it only borrows, and the escape fixpoint already forces anything it aliases, spawns,
590+
/// or hands to an Owned position to be Owned itself). See plans/PROFILING.md and RC_ELISION.md.
591+
fn elidable_moves(
592+
ir: &[TypedIRInstruction],
593+
modes: &HashMap<String, Vec<ParamMode>>,
594+
own_borrowed: &std::collections::HashSet<usize>,
595+
) -> HashMap<usize, usize> {
588596
let last = last_real_use(ir);
589597
let loops = loop_ranges(ir);
590598
let mut defs: HashMap<usize, Vec<usize>> = HashMap::new();
@@ -638,9 +646,11 @@ fn elidable_moves(ir: &[TypedIRInstruction]) -> HashMap<usize, usize> {
638646
value: TypedIRExpr::Var(src),
639647
..
640648
} => candidates.push((src.id(), is_rc(src.typ()), i)),
641-
TypedIRStmt::Call { args, .. } => {
642-
for a in args {
643-
candidates.push((a.id(), is_rc(a.typ()), i));
649+
TypedIRStmt::Call { function_name, args, .. } => {
650+
for (j, a) in args.iter().enumerate() {
651+
if param_mode(modes, function_name, j) == ParamMode::Owned {
652+
candidates.push((a.id(), is_rc(a.typ()), i));
653+
}
644654
}
645655
}
646656
TypedIRStmt::CallBuiltin {
@@ -668,7 +678,11 @@ fn elidable_moves(ir: &[TypedIRInstruction]) -> HashMap<usize, usize> {
668678

669679
let mut elidable: HashMap<usize, usize> = HashMap::new();
670680
for (v, is_rc_v, i) in candidates {
671-
if !is_rc_v || occurrences.get(&(v, i)) != Some(&1) || !is_movable(v, i) {
681+
if !is_rc_v
682+
|| own_borrowed.contains(&v)
683+
|| occurrences.get(&(v, i)) != Some(&1)
684+
|| !is_movable(v, i)
685+
{
672686
continue;
673687
}
674688
if let Some(ds) = drops.get(&v) {
@@ -789,6 +803,20 @@ pub enum ParamMode {
789803
Owned,
790804
}
791805

806+
/// A callee parameter's mode at a call position, defaulting to Owned for an unknown callee so
807+
/// everything unknown keeps the retain-and-callee-drops convention.
808+
fn param_mode(
809+
modes: &HashMap<String, Vec<ParamMode>>,
810+
function_name: &str,
811+
position: usize,
812+
) -> ParamMode {
813+
modes
814+
.get(function_name)
815+
.and_then(|m| m.get(position))
816+
.copied()
817+
.unwrap_or(ParamMode::Owned)
818+
}
819+
792820
/// The argument positions a builtin stores or keeps, making the value escape. Exhaustive over the
793821
/// `Builtin` enum with no wildcard, so adding a builtin forces a decision here rather than silently
794822
/// classifying it as read-only. `CtxNew` is handled at its call site since every argument past the
@@ -856,7 +884,7 @@ fn builtin_escaping_positions(builtin: &Builtin) -> &'static [usize] {
856884
/// propagates escapes over the static call graph to a fixpoint. Escape facts only grow and the graph
857885
/// is finite, so it terminates, and recursion needs no special case. Unknown callees and unresolvable
858886
/// spawn targets degrade conservatively to Owned.
859-
pub fn analyze_param_modes(ir_functions: &[TypedIrFunction]) -> HashMap<String, Vec<ParamMode>> {
887+
pub fn analyze_param_modes(ir_functions: &[&TypedIrFunction]) -> HashMap<String, Vec<ParamMode>> {
860888
// Escape flags per function, indexed like its params. Only reference-typed params matter, the
861889
// rest stay false and report Borrowed trivially.
862890
let mut escapes: HashMap<String, Vec<bool>> = ir_functions
@@ -867,10 +895,10 @@ pub fn analyze_param_modes(ir_functions: &[TypedIrFunction]) -> HashMap<String,
867895
let mut deps: Vec<((String, usize), (String, usize))> = Vec::new();
868896
let by_raw_id: HashMap<u64, &TypedIrFunction> = ir_functions
869897
.iter()
870-
.map(|f| (f.id.raw() as u64, f))
898+
.map(|f| (f.id.raw() as u64, *f))
871899
.collect();
872900

873-
for f in ir_functions {
901+
for &f in ir_functions {
874902
let param_index: HashMap<usize, usize> = f
875903
.params
876904
.iter()
@@ -1298,6 +1326,8 @@ fn lower_body(
12981326
constants: &mut Vec<Vec<u8>>,
12991327
func_ids: &HashMap<String, u64>,
13001328
has_spill_table: bool,
1329+
modes: &HashMap<String, Vec<ParamMode>>,
1330+
borrowed_params: &std::collections::HashSet<usize>,
13011331
) -> Result<Vec<UnprocessedInstruction>, CompileError> {
13021332
let mut instructions = Vec::with_capacity(ir.len());
13031333

@@ -1315,7 +1345,7 @@ fn lower_body(
13151345
}
13161346

13171347
// Variables whose reference is moved into a consumer, so their retain and drop are both elided.
1318-
let elidable = elidable_moves(ir);
1348+
let elidable = elidable_moves(ir, modes, borrowed_params);
13191349

13201350
for (idx, ir_instr) in ir.iter().enumerate() {
13211351
match &ir_instr.kind {
@@ -1336,8 +1366,12 @@ fn lower_body(
13361366
TypedIRStmt::Drop { target, .. } => {
13371367
// Releasing a reference at end of life, freeing the object at zero. A moved variable
13381368
// was consumed at its last use, so its reference is already gone and its drop is elided
1339-
// (the register is still freed below).
1340-
if is_rc(target.typ()) && !elidable.contains_key(&target.id()) {
1369+
// (the register is still freed below). A Borrowed parameter of this function is never
1370+
// dropped by it: the caller kept ownership and its own drop.
1371+
if is_rc(target.typ())
1372+
&& !elidable.contains_key(&target.id())
1373+
&& !borrowed_params.contains(&target.id())
1374+
{
13411375
let (reg, instrs) = get_var_register(target.id(), reg_alloc)?;
13421376
instructions.extend_from_slice(&instrs);
13431377
emit_release(target.typ(), reg, &mut instructions);
@@ -1390,11 +1424,16 @@ fn lower_body(
13901424
let arg_reg = (i as u8) + 1;
13911425
instructions
13921426
.push(UnprocessedInstruction::MoveU64((arg_reg.into(), reg.into())));
1393-
// The callee owns its parameters and drops them, so an argument gets an extra
1394-
// reference here to balance that drop, unless the argument is moved into the call
1395-
// (its last use), in which case the caller hands over its reference and skips both
1396-
// this retain and the argument's drop.
1397-
if elidable.get(&arg.id()) != Some(&idx) {
1427+
// An Owned parameter keeps the callee-owns convention: the callee drops it, so
1428+
// the argument gets an extra reference here to balance that drop, unless it is
1429+
// moved into the call (its last use), where the caller hands over its reference
1430+
// and skips both this retain and the argument's drop. A Borrowed parameter is
1431+
// only read by the callee and never dropped there, so the caller passes it with
1432+
// no retain and keeps its own drop, which guarantees the value outlives the
1433+
// synchronous call.
1434+
if param_mode(modes, function_name, i) == ParamMode::Owned
1435+
&& elidable.get(&arg.id()) != Some(&idx)
1436+
{
13981437
emit_retain(arg.typ(), reg, &mut instructions);
13991438
}
14001439
}
@@ -1795,13 +1834,38 @@ pub fn lower_program(
17951834
func_ids.insert(f.name.clone(), f.id.raw() as u64);
17961835
}
17971836

1837+
// The borrow analysis over the whole program: one summary map drives the caller side (skip the
1838+
// retain into a Borrowed position) and the callee side (skip the drop of a Borrowed parameter),
1839+
// so the two-sided contract is agreed by construction. See plans/RC_ELISION.md.
1840+
let mut all_funcs: Vec<&TypedIrFunction> = Vec::with_capacity(functions.len() + 1);
1841+
all_funcs.push(main);
1842+
all_funcs.extend(functions.iter());
1843+
let modes = analyze_param_modes(&all_funcs);
1844+
let borrowed_of = |f: &TypedIrFunction| -> std::collections::HashSet<usize> {
1845+
f.params
1846+
.iter()
1847+
.zip(modes.get(&f.name).map(Vec::as_slice).unwrap_or(&[]))
1848+
.filter(|(_, m)| **m == ParamMode::Borrowed)
1849+
.map(|(p, _)| p.id)
1850+
.collect()
1851+
};
1852+
17981853
// Main, at byte offset 0. Main runs once and ends in Halt, so its spill table (if any) is
17991854
// reclaimed at VM teardown rather than per call, and needs no explicit free.
18001855
{
18011856
let mut reg_alloc = RegisterAllocator::new(MAX_REGISTERS);
18021857
reg_alloc.plan(&main.body, &[]);
18031858
program.extend(maybe_spillage_table_init(&mut reg_alloc)?);
1804-
program.extend(lower_body(&main.body, &mut reg_alloc, &mut constants, &func_ids, false)?);
1859+
let no_params = std::collections::HashSet::new();
1860+
program.extend(lower_body(
1861+
&main.body,
1862+
&mut reg_alloc,
1863+
&mut constants,
1864+
&func_ids,
1865+
false,
1866+
&modes,
1867+
&no_params,
1868+
)?);
18051869
program.push(UnprocessedInstruction::Halt((0,)));
18061870
}
18071871

@@ -1826,12 +1890,15 @@ pub fn lower_program(
18261890
reg_alloc.end_statement();
18271891
}
18281892

1893+
let borrowed_params = borrowed_of(f);
18291894
program.extend(lower_body(
18301895
&f.body,
18311896
&mut reg_alloc,
18321897
&mut constants,
18331898
&func_ids,
18341899
has_spill_table,
1900+
&modes,
1901+
&borrowed_params,
18351902
)?);
18361903
// Fallthrough terminator for functions whose control reaches the end without a return,
18371904
// freeing the spill table on that path too.
@@ -2198,7 +2265,9 @@ mod codegen_tests {
21982265
assert!(!ctx.errors().is_fatal(), "compile errors: {:?}", ctx.errors());
21992266
let mut ir_functions = vec![ctx.main_function()];
22002267
ir_functions.extend(ctx.functions().to_vec());
2201-
let modes = super::analyze_param_modes(&ir_functions);
2268+
let func_refs: Vec<&crate::intermediate_pass1::TypedIrFunction> =
2269+
ir_functions.iter().collect();
2270+
let modes = super::analyze_param_modes(&func_refs);
22022271
let mangled = format!("{fn_name}$");
22032272
let name = ir_functions
22042273
.iter()
@@ -2336,14 +2405,14 @@ mod codegen_tests {
23362405
}
23372406

23382407
#[test]
2339-
fn rc_two_calls_retain_stays_at_the_first() {
2340-
// Passed to two calls: the retain must stay at the first (the value is still live), and only
2341-
// the second is a move. The finalizer fires once, at the consuming call, before the print
2342-
// that follows it.
2408+
fn rc_two_calls_frees_exactly_once_at_scope_end() {
2409+
// Both callees only read their parameter, so the borrow analysis marks it Borrowed: neither
2410+
// call retains or consumes, the caller keeps the one reference, and the finalizer fires at
2411+
// the owner's scope end after both calls. Exactly one free either way is the invariant.
23432412
let (_, out) = run_src(&format!(
23442413
"{RES_TYPE}def peek(r: Res) -> Int:\n return r.id\n\ndef consume(r: Res) -> Int:\n return r.id\n\nlet r = Res(5)\nlet a = peek(r)\nlet b = consume(r)\nprint(\"ab\", a, b)\n"
23452414
));
2346-
assert_eq!(out, "D 5\nab 5 5\n");
2415+
assert_eq!(out, "ab 5 5\nD 5\n");
23472416
}
23482417

23492418
#[test]

solid-snake-compiler/src/runner.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,8 @@ pub fn rc_report(ir_functions: &[TypedIrFunction]) -> String {
267267

268268
// The borrow analysis: per-function parameter modes, listed for every function with at least one
269269
// reference parameter, sorted for a stable report. See plans/RC_ELISION.md.
270-
let modes = analyze_param_modes(ir_functions);
270+
let func_refs: Vec<&TypedIrFunction> = ir_functions.iter().collect();
271+
let modes = analyze_param_modes(&func_refs);
271272
let mut ref_params = 0usize;
272273
let mut borrowed = 0usize;
273274
let mut lines: Vec<String> = Vec::new();

0 commit comments

Comments
 (0)