diff --git a/changelog.d/8540-number-local-root-slots.md b/changelog.d/8540-number-local-root-slots.md new file mode 100644 index 0000000000..0ae160b65d --- /dev/null +++ b/changelog.d/8540-number-local-root-slots.md @@ -0,0 +1,30 @@ +### Performance + +- Stop binding and maintaining shadow-root slots for locals proved Number- + by-construction. The shadow-root map is deliberately conservative because + it is collected before specialization facts exist; a guarded typed-array + clone could therefore keep root stores and write barriers in its integer + hot loop even after the later fact graph proved the local non-pointer. + + The omission is safe at every collection point, not merely at reads. The + proof considers only function-local `let` bindings, excludes boxed/captured + locals and module globals, never trusts TypeScript annotations, and checks + the initializer plus every later write. Its ordinary arm admits only Number + values. Its specialized undefined-seed arm admits only Number or Perry's + non-pointer `undefined` sentinel and permanently rejects the local after any + other write, even when that value is overwritten before the next read. + Parameters and conditional expression writes stay fail-closed. Generic + fallback bodies therefore retain their conservative slots when the guarded + input proof is unavailable. + + On `main` at measurement time (8d837df22) with the 20-row #8496 corpus + (`PERRY_NO_AUTO_OPTIMIZE=1`, release compiler and matching runtime archives, + `/usr/bin/time -l`, interleaved medians of 5), `typed_array` retires 56.013 B + instructions instead of 56.669 B (-1.16%). Peak RSS is 5.489 MB instead of + 5.472 MB (+16 KiB, +0.30%). Heavy host contention makes the wall medians + non-actionable (25.56 s instead of 13.30 s, with overlapping run ranges). + Five shorter rows cross 1% in instruction medians amid run-to-run/GC noise: + `asyncpipe` +2.11%, `cycles` +1.67%, `retain1` -2.11%, `retain_wide1` + -1.22%, and `shapes` -2.12%; `churn`, `tree`, and `retain` move -0.25%, + -0.18%, and +0.21%. All 20 outputs remain byte-exact, including + `typed_array`'s `-821955270` checksum. diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index fee7fb5978..73747e9184 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -549,7 +549,7 @@ pub(super) fn compile_function( // populate the frame with live values; today the slots stay // zero (the tracer doesn't consume them yet — Phase A ship // criterion is "shadow stack is built but not yet consumed"). - let shadow_slot_map = if precise_root_analysis_enabled() { + let mut shadow_slot_map = if precise_root_analysis_enabled() { let flat_const_ids: std::collections::HashSet = cross_module.flat_const_arrays.keys().copied().collect(); let m = @@ -559,9 +559,6 @@ pub(super) fn compile_function( } else { std::collections::HashMap::new() }; - let shadow_slot_clears_after_stmt = - crate::collectors::collect_shadow_slot_clear_points(&f.body, &shadow_slot_map); - // Small leaf functions (≤ 8 statements) get alwaysinline so LLVM // exposes their operations to the caller's optimizer context — critical // for vectorizing clamp helpers and similar patterns. Excluded: @@ -864,6 +861,21 @@ pub(super) fn compile_function( &spec_numeric_params, ); + // A Number-by-construction local cannot ever hold a GC pointer, so it + // must not pay the root-slot protocol on every assignment. The shadow + // map is initially conservative because it is built before the + // specialization-aware fact graph: an `any` local initialized from a + // guarded numeric array (and then updated only by numeric expressions) + // therefore receives a slot even though the later whole-write proof has + // established that every one of its values is either a Number or the + // non-pointer `undefined` seed. Drop those redundant entries before + // statement lowering. `enable_shadow_frame` deliberately retains the + // original upper-bound size, so the remaining preassigned slot indices + // stay valid even when filtering leaves holes. + shadow_slot_map.retain(|id, _| !native_facts.number_by_construction_locals().contains(id)); + let shadow_slot_clears_after_stmt = + crate::collectors::collect_shadow_slot_clear_points(&f.body, &shadow_slot_map); + if let Some(plan) = spec_entry { // `--opt-report` (#6952): the spec-ABI win, recorded at the same site // as the PERRY_REPSEL_DEBUG line so the two cannot diverge. diff --git a/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs b/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs index a0c7f686b2..330aafbf4d 100644 --- a/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs +++ b/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs @@ -27,6 +27,10 @@ fn function_ir<'a>(ir: &'a str, marker: &str) -> &'a str { &ir[start..end] } +fn root_slots(ir: &str) -> usize { + ir.matches("alloca ptr addrspace(1)").count() + ir.matches("@js_shadow_slot_bind(").count() +} + #[test] fn public_guard_routes_to_proof_clone_and_conservative_fallback() { let payload = Type::Object(ObjectType { @@ -242,6 +246,94 @@ fn mixed_ta_clone_guards_numeric_array_shape_at_the_direct_call() { assert!(generic.contains("js_dynamic_bitxor")); } +#[test] +fn numeric_by_construction_local_drops_specialized_clone_root() { + let encipher = Function { + id: 1, + name: "encipher".to_string(), + type_params: Vec::new(), + params: vec![Param { + id: 10, + name: "table".to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }], + return_type: Type::Void, + body: vec![ + Stmt::Let { + id: 11, + name: "n".to_string(), + ty: Type::Number, + mutable: true, + init: Some(Expr::Undefined), + }, + Stmt::While { + condition: Expr::Bool(false), + body: vec![Stmt::Expr(Expr::LocalSet( + 11, + Box::new(Expr::IndexGet { + object: Box::new(Expr::LocalGet(10)), + index: Box::new(Expr::Integer(0)), + }), + ))], + }, + Stmt::Return(None), + ], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }; + let mut module = Module::new("numeric_local_root.ts"); + module.functions.push(encipher); + module.init.extend([ + Stmt::Let { + id: 20, + name: "table".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::TypedArrayNew { + kind: perry_hir::TYPED_ARRAY_KIND_INT32, + arg: Some(Box::new(Expr::Integer(4))), + }), + }, + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: vec![Expr::LocalGet(20)], + type_args: Vec::new(), + byte_offset: 0, + }), + ]); + + let opts = CompileOptions { + emit_ir_only: true, + output_type: "executable".to_string(), + ..Default::default() + }; + let ir = String::from_utf8(compile_module(&module, opts).expect("module compiles")) + .expect("LLVM IR is UTF-8"); + let specialized = function_ir(&ir, "encipher$spec_ta4x4("); + let generic = function_ir(&ir, "@perry_fn_numeric_local_root_ts__encipher("); + + assert_eq!( + root_slots(specialized), + 0, + "the specialized typed-array proof makes every value of n non-pointer:\n{specialized}" + ); + assert_eq!( + root_slots(generic), + 2, + "the annotation-agnostic fallback must retain roots for both the receiver and n:\n{generic}" + ); +} + #[test] fn nonsuspending_async_function_needs_no_direct_call_site_for_its_guarded_clone() { // An async body with no `await` runs to completion synchronously, so the