Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions changelog.d/8540-number-local-root-slots.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 16 additions & 4 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32> =
cross_module.flat_const_arrays.keys().copied().collect();
let m =
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
92 changes: 92 additions & 0 deletions crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading