@@ -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 \n def consume(r: Res) -> Int:\n return r.id\n \n let r = Res(5)\n let a = peek(r)\n let b = consume(r)\n print(\" ab\" , a, b)\n "
23452414 ) ) ;
2346- assert_eq ! ( out, "D 5 \n ab 5 5\n " ) ;
2415+ assert_eq ! ( out, "ab 5 5 \n D 5\n " ) ;
23472416 }
23482417
23492418 #[ test]
0 commit comments