Skip to content

Commit 19dd6ae

Browse files
committed
Borrow analysis step 1: per-function parameter modes, report, and tests
analyze_param_modes classifies every function's reference parameters as Borrowed (the callee only reads them, so a caller could skip the retain and keep its own drop) or Owned (escaping). Escape seeds: returned, stored into a field or aggregate or collection or global, reassigned, aliased (the conservative first cut), captured by spawn, or the function being a spawn target (resolved through the function id the spawn lowering assigns, degrading to all-Owned when unresolvable). A parameter forwarded to another function's parameter position becomes a dependency edge, propagated to fixpoint over the static call graph, so recursion needs no special case. The builtin escape table is exhaustive with no wildcard, so adding a builtin forces a decision here rather than silently classifying it read-only. Report and analysis only, no codegen consults it yet. --rc-report now prints each function's modes plus the aggregate. Eight tests pin the classification per category. First measurement on the http hello server: 61 of 83 reference parameters are borrowable, including the whole parse path, and unwrap_or's fallback correctly stays owned since it can be returned.
1 parent 1750f25 commit 19dd6ae

3 files changed

Lines changed: 373 additions & 7 deletions

File tree

plans/RC_ELISION.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,12 +164,20 @@ assumption, and the melding is three layers, from safest to most optimized:
164164

165165
## Order of execution
166166

167-
1. **Summaries plus report, no codegen change.** Compute per-function parameter modes with the
168-
fixpoint, extend `--rc-report` to print them, pin the classification with tests (the measure-first
169-
pattern that caught the loop-carried bug in move elision).
167+
1. **Summaries plus report, no codegen change. DONE.** `analyze_param_modes` computes per-function
168+
parameter modes with the escape fixpoint (seeds from returns, stores, reassignment, aliasing,
169+
spawn capture, and an exhaustive per-builtin escape table with no wildcard, so a new builtin
170+
forces a decision rather than silently reading as read-only). `--rc-report` prints every
171+
function's modes, and eight tests pin the classification per category (read-only borrowed,
172+
returned owned, field-stored owned, reassigned owned, list-pushed owned beside buf-pushed
173+
borrowed, transitive escape and transitive borrow, recursion staying borrowed, spawn target
174+
owned). First measurement on the http hello server: 61 of 83 reference parameters are borrowable,
175+
including the whole parse path (`self` receivers, `Connection.write` data, `Request.header`
176+
name), while `unwrap_or`'s fallback correctly stays owned since it can be returned.
170177
2. **Flip codegen on both sides.** Skip caller retains into Borrowed positions and callee drops of
171178
Borrowed params. Exclude Borrowed positions from move candidates. Prove with residual equivalence,
172-
the full suites, goldens, and the vm-counters delta on the http path.
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.
173181
3. **Refine.** Alias-aware escape inside callees, a task-entry wrapper so spawn targets can borrow,
174182
dominance-based move elision, return-value moves, and the orphan-temp direct free that attacks
175183
the per-request allocation count.

solid-snake-compiler/src/bytecode_gen.rs

Lines changed: 319 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,234 @@ pub fn analyze_rc_elision(ir: &[TypedIRInstruction]) -> RcElisionReport {
777777
r
778778
}
779779

780+
/// A function parameter's ownership mode under the borrow analysis (plans/RC_ELISION.md). Borrowed
781+
/// means the callee only reads the parameter, so a caller could pass it without a retain and keep
782+
/// its own drop. Owned means the parameter escapes (stored, returned, reassigned, spawned, aliased,
783+
/// or handed to an Owned position elsewhere) and keeps the callee-owns convention. Non-reference
784+
/// parameters carry no count and report as Borrowed trivially. This is analysis and report only, no
785+
/// codegen consults it yet.
786+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
787+
pub enum ParamMode {
788+
Borrowed,
789+
Owned,
790+
}
791+
792+
/// The argument positions a builtin stores or keeps, making the value escape. Exhaustive over the
793+
/// `Builtin` enum with no wildcard, so adding a builtin forces a decision here rather than silently
794+
/// classifying it as read-only. `CtxNew` is handled at its call site since every argument past the
795+
/// function id escapes into the task.
796+
fn builtin_escaping_positions(builtin: &Builtin) -> &'static [usize] {
797+
match builtin {
798+
Builtin::Push => &[1],
799+
Builtin::Set => &[2],
800+
Builtin::MapSet => &[1, 2],
801+
Builtin::GlobalSet | Builtin::GlobalSetList => &[1],
802+
Builtin::CtxNew => &[],
803+
Builtin::Print
804+
| Builtin::ReadLine
805+
| Builtin::ReadInt
806+
| Builtin::Len
807+
| Builtin::ArrayGet
808+
| Builtin::ListGet
809+
| Builtin::Pop
810+
| Builtin::IntToStr
811+
| Builtin::FloatToStr
812+
| Builtin::BoolToStr
813+
| Builtin::MapNew
814+
| Builtin::MapGet
815+
| Builtin::MapHas
816+
| Builtin::MapLen
817+
| Builtin::WriteStr
818+
| Builtin::WriteByte
819+
| Builtin::ByteAt
820+
| Builtin::BufNew
821+
| Builtin::BufPush
822+
| Builtin::BufPushByte
823+
| Builtin::BufToStr
824+
| Builtin::BufLen
825+
| Builtin::ArrayClone
826+
| Builtin::StrConcat2
827+
| Builtin::Substring
828+
| Builtin::ParseInt
829+
| Builtin::Status
830+
| Builtin::FileOpen
831+
| Builtin::FileRead
832+
| Builtin::FileWrite
833+
| Builtin::FileClose
834+
| Builtin::FileExists
835+
| Builtin::Poll
836+
| Builtin::LastOp
837+
| Builtin::OpResultStr
838+
| Builtin::OpResultInt
839+
| Builtin::TcpListen
840+
| Builtin::TcpAccept
841+
| Builtin::TcpRecv
842+
| Builtin::TcpSend
843+
| Builtin::TcpClose
844+
| Builtin::CtxSwitch
845+
| Builtin::CtxLive
846+
| Builtin::GlobalGet
847+
| Builtin::GlobalGetList => &[],
848+
}
849+
}
850+
851+
/// Classify every function's parameters as Borrowed or Owned (plans/RC_ELISION.md). Seeds each
852+
/// parameter's escape from its own body (returned, stored into an aggregate or collection or global,
853+
/// reassigned, aliased under the conservative first cut, spawned), records a dependency edge when a
854+
/// parameter is passed on to another function's parameter position, marks spawn-target functions
855+
/// all-Owned (the task outlives the spawner's frame, so no caller lifetime guarantee exists), and
856+
/// propagates escapes over the static call graph to a fixpoint. Escape facts only grow and the graph
857+
/// is finite, so it terminates, and recursion needs no special case. Unknown callees and unresolvable
858+
/// spawn targets degrade conservatively to Owned.
859+
pub fn analyze_param_modes(ir_functions: &[TypedIrFunction]) -> HashMap<String, Vec<ParamMode>> {
860+
// Escape flags per function, indexed like its params. Only reference-typed params matter, the
861+
// rest stay false and report Borrowed trivially.
862+
let mut escapes: HashMap<String, Vec<bool>> = ir_functions
863+
.iter()
864+
.map(|f| (f.name.clone(), vec![false; f.params.len()]))
865+
.collect();
866+
// (dependent function, param index) escapes when (callee, callee param index) does.
867+
let mut deps: Vec<((String, usize), (String, usize))> = Vec::new();
868+
let by_raw_id: HashMap<u64, &TypedIrFunction> = ir_functions
869+
.iter()
870+
.map(|f| (f.id.raw() as u64, f))
871+
.collect();
872+
873+
for f in ir_functions {
874+
let param_index: HashMap<usize, usize> = f
875+
.params
876+
.iter()
877+
.enumerate()
878+
.map(|(i, p)| (p.id, i))
879+
.collect();
880+
let mut escape = |escapes: &mut HashMap<String, Vec<bool>>, var: usize| {
881+
if let Some(&i) = param_index.get(&var) {
882+
escapes.get_mut(&f.name).expect("seeded above")[i] = true;
883+
}
884+
};
885+
for (idx, instr) in f.body.iter().enumerate() {
886+
match &instr.kind {
887+
TypedIRStmt::Assign { target, value } => {
888+
// Reassigning a param means its end-of-scope drop releases the new value, and
889+
// aliasing a param is the conservative first cut for alias-tracked escape.
890+
escape(&mut escapes, target.id());
891+
if let TypedIRExpr::Var(src) = value {
892+
escape(&mut escapes, src.id());
893+
}
894+
}
895+
TypedIRStmt::Return { value: Some(v) } => escape(&mut escapes, v.id()),
896+
TypedIRStmt::FieldSet { value, .. } => escape(&mut escapes, value.id()),
897+
TypedIRStmt::StructNew { args, .. } => {
898+
for a in args {
899+
escape(&mut escapes, a.id());
900+
}
901+
}
902+
TypedIRStmt::ArrayNew { elements, .. } => {
903+
for e in elements {
904+
escape(&mut escapes, e.id());
905+
}
906+
}
907+
TypedIRStmt::Call { function_name, args, .. } => {
908+
for (j, a) in args.iter().enumerate() {
909+
if let Some(&i) = param_index.get(&a.id()) {
910+
if escapes.contains_key(function_name) {
911+
deps.push(((f.name.clone(), i), (function_name.clone(), j)));
912+
} else {
913+
escapes.get_mut(&f.name).expect("seeded above")[i] = true;
914+
}
915+
}
916+
}
917+
}
918+
TypedIRStmt::CallBuiltin { builtin, args, .. } => {
919+
if *builtin == Builtin::CtxNew {
920+
// Every argument past the function id escapes into the task, and the target
921+
// function itself must keep owned params (the task runtime drops them and
922+
// the spawner's frame may be gone before the task runs).
923+
for a in args.iter().skip(1) {
924+
escape(&mut escapes, a.id());
925+
}
926+
let target_fid = args.first().and_then(|a| {
927+
f.body[..idx].iter().rev().find_map(|prev| match &prev.kind {
928+
TypedIRStmt::Assign {
929+
target,
930+
value: TypedIRExpr::Int(k),
931+
} if target.id() == a.id() => Some(*k as u64),
932+
_ => None,
933+
})
934+
});
935+
match target_fid.and_then(|fid| by_raw_id.get(&fid)) {
936+
Some(task) => {
937+
if let Some(flags) = escapes.get_mut(&task.name) {
938+
flags.iter_mut().for_each(|e| *e = true);
939+
}
940+
}
941+
None => {
942+
// Unresolvable spawn target: every function might be the task, so
943+
// everything degrades to Owned rather than guessing.
944+
for flags in escapes.values_mut() {
945+
flags.iter_mut().for_each(|e| *e = true);
946+
}
947+
}
948+
}
949+
} else {
950+
for &pos in builtin_escaping_positions(builtin) {
951+
if let Some(a) = args.get(pos) {
952+
escape(&mut escapes, a.id());
953+
}
954+
}
955+
}
956+
}
957+
TypedIRStmt::Drop { .. }
958+
| TypedIRStmt::Jump { .. }
959+
| TypedIRStmt::JumpIf { .. }
960+
| TypedIRStmt::JumpIfNot { .. }
961+
| TypedIRStmt::Label(_)
962+
| TypedIRStmt::FieldGet { .. }
963+
| TypedIRStmt::Return { value: None } => {}
964+
}
965+
}
966+
}
967+
968+
// Propagate escapes through the call graph until stable.
969+
let mut changed = true;
970+
while changed {
971+
changed = false;
972+
for ((fname, i), (gname, j)) in &deps {
973+
let callee_escapes = escapes
974+
.get(gname)
975+
.and_then(|flags| flags.get(*j).copied())
976+
.unwrap_or(true);
977+
if callee_escapes {
978+
let flags = escapes.get_mut(fname).expect("seeded above");
979+
if !flags[*i] {
980+
flags[*i] = true;
981+
changed = true;
982+
}
983+
}
984+
}
985+
}
986+
987+
ir_functions
988+
.iter()
989+
.map(|f| {
990+
let flags = &escapes[&f.name];
991+
let modes = f
992+
.params
993+
.iter()
994+
.zip(flags)
995+
.map(|(p, &esc)| {
996+
if is_rc(&p.typ) && esc {
997+
ParamMode::Owned
998+
} else {
999+
ParamMode::Borrowed
1000+
}
1001+
})
1002+
.collect();
1003+
(f.name.clone(), modes)
1004+
})
1005+
.collect()
1006+
}
1007+
7801008
/// The variable a statement defines (writes a fresh value into a variable), if any. Used to emit a
7811009
/// store-back when that variable is memory-resident. Statements that only read (drops, jumps,
7821010
/// returns) or write into an object field rather than a variable define nothing.
@@ -1029,7 +1257,7 @@ fn lower_expr_to_bytecode_stage_one(
10291257
/// Whether a value of this type is a reference-counted heap object: nominal structs, strings, and
10301258
/// arrays/lists. All carry the shared object header and are managed by IncRef/DecRef, and a
10311259
/// container's descriptor traces its elements on free.
1032-
fn is_rc(typ: &ProcessedType) -> bool {
1260+
pub fn is_rc(typ: &ProcessedType) -> bool {
10331261
matches!(
10341262
typ,
10351263
ProcessedType::Custom { .. }
@@ -1964,6 +2192,96 @@ mod codegen_tests {
19642192

19652193
const RC_SINK: &str = "def sink(s: String) -> Int:\n return len(s)\n\n";
19662194

2195+
/// The borrow analysis's parameter modes for one function (mangled names match by prefix).
2196+
fn param_modes_for(src: &str, fn_name: &str) -> Vec<super::ParamMode> {
2197+
let ctx = analyze_source(src);
2198+
assert!(!ctx.errors().is_fatal(), "compile errors: {:?}", ctx.errors());
2199+
let mut ir_functions = vec![ctx.main_function()];
2200+
ir_functions.extend(ctx.functions().to_vec());
2201+
let modes = super::analyze_param_modes(&ir_functions);
2202+
let mangled = format!("{fn_name}$");
2203+
let name = ir_functions
2204+
.iter()
2205+
.map(|f| f.name.clone())
2206+
.find(|n| n == fn_name || n.starts_with(&mangled))
2207+
.unwrap_or_else(|| panic!("no function named {fn_name}"));
2208+
modes[&name].clone()
2209+
}
2210+
2211+
use super::ParamMode::{Borrowed, Owned};
2212+
2213+
#[test]
2214+
fn borrow_read_only_param_is_borrowed() {
2215+
let m = param_modes_for("def f(s: String) -> Int:\n return len(s)\n", "f");
2216+
assert_eq!(m, vec![Borrowed]);
2217+
}
2218+
2219+
#[test]
2220+
fn borrow_returned_param_is_owned() {
2221+
// Ownership rides out on the return, so the caller cannot keep the only reference.
2222+
let m = param_modes_for("def f(s: String) -> String:\n return s\n", "f");
2223+
assert_eq!(m, vec![Owned]);
2224+
}
2225+
2226+
#[test]
2227+
fn borrow_field_stored_param_is_owned() {
2228+
let m = param_modes_for(
2229+
"type Box:\n v: String\n\ndef f(b: Box, s: String):\n b.v = s\n",
2230+
"f",
2231+
);
2232+
assert_eq!(m, vec![Borrowed, Owned], "the box is read, the stored value escapes");
2233+
}
2234+
2235+
#[test]
2236+
fn borrow_reassigned_param_is_owned() {
2237+
// The end-of-scope drop releases the reassigned value, so the param cannot be borrowed.
2238+
let m = param_modes_for(
2239+
"def f(s: String) -> Int:\n s = \"other\"\n return len(s)\n",
2240+
"f",
2241+
);
2242+
assert_eq!(m, vec![Owned]);
2243+
}
2244+
2245+
#[test]
2246+
fn borrow_list_pushed_param_is_owned_buf_pushed_is_borrowed() {
2247+
// The list stores its element (escape), the buffer only copies bytes out (read).
2248+
let m = param_modes_for(
2249+
"def f(xs: List[String], s: String, b: StrBuf, t: String):\n push(xs, s)\n buf_push(b, t)\n",
2250+
"f",
2251+
);
2252+
assert_eq!(m, vec![Borrowed, Owned, Borrowed, Borrowed]);
2253+
}
2254+
2255+
#[test]
2256+
fn borrow_escape_propagates_transitively() {
2257+
// g stores its param, f only forwards to g, so f's param is owned through the fixpoint,
2258+
// while a forward to a read-only callee stays borrowed.
2259+
let src = "type Box:\n v: String\n\ndef store(b: Box, s: String):\n b.v = s\n\ndef f(b: Box, s: String):\n store(b, s)\n\ndef peek(s: String) -> Int:\n return len(s)\n\ndef g(s: String) -> Int:\n return peek(s)\n";
2260+
assert_eq!(param_modes_for(src, "f"), vec![Borrowed, Owned]);
2261+
assert_eq!(param_modes_for(src, "g"), vec![Borrowed]);
2262+
}
2263+
2264+
#[test]
2265+
fn borrow_recursive_read_only_param_stays_borrowed() {
2266+
// The fixpoint terminates on the self-edge with no escape seed, so recursion alone does not
2267+
// pessimize.
2268+
let m = param_modes_for(
2269+
"def f(s: String, n: Int) -> Int:\n if n <= 0:\n return len(s)\n return f(s, n - 1)\n",
2270+
"f",
2271+
);
2272+
assert_eq!(m, vec![Borrowed, Borrowed], "the string param and the trivially non-ref Int");
2273+
}
2274+
2275+
#[test]
2276+
fn borrow_spawn_target_params_are_owned() {
2277+
// The task outlives the spawner's frame, so no caller lifetime guarantee exists.
2278+
let m = param_modes_for(
2279+
"def worker(s: String):\n print(s)\n\nlet x = \"hi\"\nspawn worker(x)\nscheduler_run()\n",
2280+
"worker",
2281+
);
2282+
assert_eq!(m, vec![Owned]);
2283+
}
2284+
19672285
#[test]
19682286
fn rc_call_arg_last_use_is_movable() {
19692287
// `x`'s only use is the call, so passing it is a move: the retain is elidable.

0 commit comments

Comments
 (0)