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
6 changes: 3 additions & 3 deletions benchmarks/repsel_census/baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@
"role": "corpus",
"source": "benchmarks/app-patterns/kernels/batch.ts",
"floors": {
"ptr-shape": 0,
"ptr-shape": 2,
"ptr-numarray": 0,
"canonical-i32": 3,
"canonical-u32": 0,
Expand All @@ -126,7 +126,7 @@
"spec-abi-taptr-slot": 0
},
"candidates": {
"ptr-shape": 4,
"ptr-shape": 5,
"ptr-numarray": 0,
"canonical-slot": 3,
"int-valued-ta": 0,
Expand Down Expand Up @@ -508,5 +508,5 @@
}
}
],
"generated_at": "2026-07-31T01:39:37.595749Z"
"generated_at": "2026-07-31T02:23:42.048018Z"
}
24 changes: 24 additions & 0 deletions changelog.d/7107-repsel-return-shape-facts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
**Representation selection — `Ptr<Shape>` survives the return escape (#7034 §4, phase P2).**

`Ptr<Shape>` promoted **zero** locals on `benchmarks/app-patterns/kernels/batch.ts`, the object/property-heavy workload the representation exists for. `collectors/ptr_shape.rs` rule 2 (containment) listed `return` as an outright disqualifier, and every real record escapes its producing scope, so the proof died at the first escape. `PERRY_PTR_SHAPE_LOCALS=0` versus default produced an identical `__text` — there was nothing to switch off.

Two halves, both gated by the existing `PERRY_PTR_SHAPE_LOCALS` (no new env knob, so no new unexercised off-state):

- **Producer side** — `return <the local>` no longer disqualifies. A `return` is a terminator: every use of the local in that body either precedes it on that path or is unreachable from it, the sole exception being a `finally` block, which still runs before the caller resumes and whose uses the same walk checks anyway. The caller cannot have reshaped the object at any access this pass licenses. Only the **bare** form is exempt — `return [o]`, `return {a: o}`, `return f(o)` still escape — and a `return` inside a nested closure body is **not** exempt, because that value escapes at an unbounded later time (`UseWalk::in_closure`).
- **Caller side** (new `collectors/ptr_shape_returns.rs`) — a module function whose every return path hands back a *freshly allocated, unaliased* object of one class carries a **return-shape fact**, and a direct call to it is rule-1 provenance of exactly `new C(...)` strength. Freshness is discharged by re-running the full Phase 3b proof over the producer's body rather than by a second, weaker approximation of it: a local that proof promotes has, by rule 2, no alias anywhere. `return CACHE`, a fall-through-to-`undefined` path, a bare `return;`, disagreeing return classes, an async/generator producer, and an indirect callee all yield no fact.

No ABI change, no function cloning, no cross-call-site agreement — that is why returns went first among the three escape positions (#7034 §5).

**Measured.** `batch.ts`: **0 → 2** promoted locals (`acc` in `totalsRow`, producer side; `totals` at module scope, caller side). `benchmarks/app-patterns/kernels/`: 2 → 4. `benchmarks/suite/`: unchanged at 4 — the micro-benchmarks do not use the record-producing idiom. The promotion census (#7104) reports the same delta independently and its `batch` `ptr-shape` floor is ratcheted 0 → 2 here, so the gain is now gated: with `PERRY_PTR_SHAPE_LOCALS=0` the census goes red on `batch 0 (floor 2)`.

The A/B that motivated the work stops being vacuous: `PERRY_PTR_SHAPE_LOCALS=0` vs default now moves `__text` by **1,532 bytes** on `batch.ts` (9,674,204 → 9,672,672), where before it moved nothing; in the emitted IR, guard-gate volatile loads drop 26 → 22 and by-name field fallbacks 53 → 51. That is a **size** number: no speed claim is made, because the box was under load 40–135 throughout and nothing was timed.

**Known limitation, stated rather than papered over:** module-init contexts set `repsel_context_allows_canonical_i32: false` (a pre-existing Phase 1 decision in `codegen/entry.rs`), and `FnCtx::ptr_shape_receiver_fact` gates on that flag. So `totals` — a module-scope binding — is proven and reported as a win but its access sites keep the guarded lowering. One of the two `batch.ts` promotions is therefore currently unconsumed; the 1,532-byte `__text` delta is attributable to `acc` alone. Making module-init consume `Ptr<Shape>` is a separate, independently-measurable change.

**GC contract.** No new site holds an object pointer. The caller's binding is an ordinary NaN-boxed local slot, shadow-bound by `collect_pointer_typed_locals` / `js_shadow_slot_bind` exactly as before; verified in the emitted IR that the returned register is stored to that slot and bound with no intervening allocation or call, and that every access re-derives the raw pointer from the slot inside one region. `TaPtr`'s callee-side no-bind shortcut is explicitly not copied — it is sound only for non-movable typed-array storage, and `GC_TYPE_OBJECT` moves (#6990, #7019). One new guard closes a hazard the original sketch did not have: a producer annotated with a definitely-non-pointer return type would cost the caller's binding its shadow slot (`collect_pointer_typed_locals` drops it), leaving a promoted `Ptr<Shape>` local in an unrooted alloca; since Perry does not check annotations, such a producer carries no fact.

A call-seeded candidate never claims `numeric_fields`: the producer's stores are outside the caller's region, so no exhaustive-reachable-store proof is available. Same stand-down, same reason, as `collectors/proven_this.rs`.

**Verification.** 16 new unit tests in `ptr_shape_returns_tests.rs`, each guard sabotage-verified — removing it makes exactly the test that names it fail. New corpus member `test-files/test_gap_repsel_return_shape.ts`, registered in `test-parity/gc_repsel_corpus.txt`, byte-exact against the pinned Node 26.5.1 oracle on the default, `PERRY_PTR_SHAPE_LOCALS=0`, `PERRY_GC_HEAP_LIMIT=8`, `PERRY_GC_FORCE_EVACUATE=1` and conservative-scan-off arms. Its `survivesGc` case is **GC-live by measurement, not by hope**: a first draft using non-escaping churn drove zero collections (the #6942/#6946 inert-arm failure mode, caught before shipping); the committed version drives 6–8 copying minors, ~1M objects copied, and 12–13 shadow-stack slots rewritten by the collector while the output stays oracle-exact. A base-vs-new behavioural A/B over the gap corpus showed no output change attributable to this work.

Review follow-up: `is_definitely_non_pointer_type` is hoisted to module scope in `collectors/pointer_locals.rs` and called from here rather than restated — a copy drifting by one `Type` variant would mean a value the slot-assigning pass leaves unrooted while this one treats it as a live, movable pointer.
1 change: 1 addition & 0 deletions crates/perry-codegen/src/collectors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ mod proven_this;
mod ptr_numarray;
mod ptr_shape;
mod ptr_shape_report;
mod ptr_shape_returns;
mod refs;
mod scalar_method_dispatch;
mod scalar_methods;
Expand Down
38 changes: 25 additions & 13 deletions crates/perry-codegen/src/collectors/pointer_locals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,31 @@ impl HirTypeFacts for PointerAnalysisFacts<'_> {
}
}

/// Types that can NEVER hold a heap pointer, and therefore cost a local its
/// shadow-stack slot in [`collect_pointer_typed_locals`].
///
/// **This is the single definition.** It used to be a nested `fn` inside
/// `collect_pointer_typed_locals`; it is module-level and `pub(crate)` because
/// anything that decides a value may be treated as a rooted pointer has to
/// agree with the pass that actually assigns the root slot. A second copy
/// drifting by one `Type` variant would mean a value this collector left
/// unrooted while another pass treated it as a live, movable pointer — a
/// use-after-move under the evacuating minor (#7019), not a cosmetic
/// inconsistency. `collectors/ptr_shape_returns.rs` (#7034 §4) is the current
/// second caller.
pub(crate) fn is_definitely_non_pointer_type(ty: &Type) -> bool {
matches!(
ty,
Type::Number
| Type::Int32
| Type::Boolean
| Type::Null
| Type::Void
| Type::Never
| Type::Symbol
) || matches!(ty, Type::Union(variants) if variants.iter().all(is_definitely_non_pointer_type))
}

pub fn collect_pointer_typed_locals(
params: &[perry_hir::Param],
stmts: &[perry_hir::Stmt],
Expand Down Expand Up @@ -222,19 +247,6 @@ pub fn collect_pointer_typed_locals(
) || matches!(ty, Type::Union(variants) if variants.iter().any(is_ptr_typed))
}

fn is_definitely_non_pointer_type(ty: &Type) -> bool {
matches!(
ty,
Type::Number
| Type::Int32
| Type::Boolean
| Type::Null
| Type::Void
| Type::Never
| Type::Symbol
) || matches!(ty, Type::Union(variants) if variants.iter().all(is_definitely_non_pointer_type))
}

fn expr_value_type(
expr: &Expr,
local_types: &HashMap<u32, Type>,
Expand Down
110 changes: 96 additions & 14 deletions crates/perry-codegen/src/collectors/ptr_shape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,28 @@
//! constructors cannot return an override object). Anon-shape literals
//! (`{k: v}` closed shapes) and `{}` builder sites also lower to
//! `Expr::New { class_name: "__AnonShape_…" }`, so records qualify through
//! the same test.
//! the same test. Since #7034 §4 a direct call to a module function
//! carrying a **return-shape fact** is provenance of the same strength —
//! that fact certifies the callee hands back a freshly allocated, unaliased
//! `C` on every return path (`collectors/ptr_shape_returns.rs`).
//! 2. **Containment**: every use of the local is a declared-chain field
//! read/write/update or a vetted method call. Any other use — reassignment,
//! closure capture, call argument, array/object element, return, throw,
//! closure capture, call argument, array/object element, throw,
//! `delete`, freeze/seal, aliasing — disqualifies. The object is therefore
//! unreachable from anywhere except this local, so no §5.2 barrier
//! (defineProperty / delete / setPrototypeOf / Proxy / mutating Reflect)
//! can reach it *through an alias*.
//!
//! **Exception — the return position (#7034 §4).** `return <the local>`
//! does NOT disqualify. Containment exists to bound the object's aliases
//! *while this function still reads it*, and a `return` is a terminator:
//! every use of the local in this body either precedes it on that path or
//! is unreachable from it, the sole exception being a `finally` block —
//! which still runs before the caller resumes, and whose uses this same
//! walk checks anyway. The caller cannot have touched the object yet, so
//! no shape transition can have happened at any access this pass licenses.
//! Returns nested inside a CLOSURE body are not exempt: that value escapes
//! at an unbounded later time (`UseWalk::in_closure`).
//! 3. **`this`-flow containment**: the constructor chain, chain field
//! initializers, and every method called on the local are walked with a
//! strict `this`-usage discipline (field access on `this`, vetted
Expand Down Expand Up @@ -169,6 +183,11 @@ fn note_ptr_shape_local(
names: &HashMap<u32, String>,
depths: &HashMap<u32, u32>,
) {
// #7034 §4: `report::suppressed()` is set while the return-shape module
// pre-pass re-runs this proof speculatively — see `SuppressScope`.
if report::suppressed() {
return;
}
if opt_report::enabled() {
let fallback = format!("<local {id}>");
let name = names.get(&id).map(String::as_str).unwrap_or(&fallback);
Expand Down Expand Up @@ -265,6 +284,15 @@ pub(crate) fn collect_shape_proven_ptr_locals(
// async-to-generator transform).
let mut candidates: HashMap<u32, String> = HashMap::new();
super::find_new_candidates(stmts, boxed_vars, module_globals, &mut candidates);
// #7034 §4: `const r = producer(...)` where `producer` carries a
// return-shape fact is provenance of `new`-strength (module doc, rule 1).
let return_seeded = super::ptr_shape_returns::find_return_shape_candidates(
stmts,
boxed_vars,
module_globals,
module_dispatch,
&mut candidates,
);
if candidates.is_empty() {
return HashMap::new();
}
Expand Down Expand Up @@ -326,6 +354,8 @@ pub(crate) fn collect_shape_proven_ptr_locals(
const_local_inits: HashMap::new(),
disq_reasons: HashMap::new(),
escape_ctx: report::ESC_BARE_REFERENCE,
return_seeded: &return_seeded,
in_closure: false,
};
walk.walk_stmts(stmts);
let UseWalk {
Expand Down Expand Up @@ -421,18 +451,31 @@ pub(crate) fn collect_shape_proven_ptr_locals(
.filter(|(_, r)| *r == id)
.map(|(m, _)| *m)
.collect();
let numeric_fields = prove_numeric_fields(
&chain,
&members,
&store_records,
field_stores.get(id).map(Vec::as_slice).unwrap_or(&[]),
new_args.get(id).copied().unwrap_or(&[]),
called,
&super_call_args,
&internally_invoked,
not_bigint_locals,
&const_local_inits,
);
// #7034 §4: a return-shape-seeded candidate NEVER claims numeric
// fields. The numeric proof is an EXHAUSTIVE-reachable-store proof,
// and the producer's own stores (`acc.weight = …` inside the callee)
// are not in this region at all — claiming `JsNumber` off the
// constructor's stores alone would let a guard-free `load double` in
// a number context read a slot the producer had put a string in. The
// shape proof by itself still retires the whole guard diamond; this
// is the same stand-down `collectors/proven_this.rs` makes, for the
// same reason.
let numeric_fields = if return_seeded.contains(id) {
HashSet::new()
} else {
prove_numeric_fields(
&chain,
&members,
&store_records,
field_stores.get(id).map(Vec::as_slice).unwrap_or(&[]),
new_args.get(id).copied().unwrap_or(&[]),
called,
&super_call_args,
&internally_invoked,
not_bigint_locals,
&const_local_inits,
)
};
let fact = PtrShapeLocal {
class_name: class_name.clone(),
numeric_fields,
Expand Down Expand Up @@ -610,6 +653,15 @@ struct UseWalk<'a> {
/// Parent arms narrow it (`return`, call argument, array element, …) so
/// the report can say *how* the object escaped, not just that it did.
escape_ctx: ShapeDenial,
/// #7034 §4: candidates whose provenance is a return-shape-carrying CALL
/// rather than a `new`. Their `Let` init is an `Expr::Call`, which rule 1
/// would otherwise reject as `LET_INIT_NOT_NEW`.
return_seeded: &'a HashSet<u32>,
/// #7034 §4: are we inside a closure body? A `return <candidate>` there
/// escapes at an unbounded later time, so the return exemption (module
/// doc, rule 2) does NOT apply — only the enclosing function's own
/// returns are terminators for this local's lifetime.
in_closure: bool,
}

impl<'a> UseWalk<'a> {
Expand Down Expand Up @@ -676,6 +728,20 @@ impl<'a> UseWalk<'a> {
}
return;
}
// #7034 §4: a return-shape-seeded candidate's provenance
// is the CALL. It records no `new_args` — the constructor
// ran in the callee, so the numeric-field proof stands
// down for these candidates entirely (see the `'cand`
// loop). The argument expressions are ordinary values;
// walk them so OTHER candidates passed there still escape.
if self.return_seeded.contains(id) {
if let Some(Expr::Call { args, .. }) = init.as_ref() {
for a in args {
self.with_ctx(report::ESC_CALL_ARGUMENT, |w| w.walk_expr(a));
}
return;
}
}
// A candidate whose Let init is not the New (var-redecl
// seed) is not provenance-stable.
self.disq(*id, report::LET_INIT_NOT_NEW);
Expand Down Expand Up @@ -724,6 +790,19 @@ impl<'a> UseWalk<'a> {
Stmt::Throw(e) => self.with_ctx(report::ESC_THROWN, |w| w.walk_expr(e)),
Stmt::Return(opt) => {
if let Some(e) = opt {
// #7034 §4: `return <tracked local>` is exempt — see the
// module doc, rule 2. Only the bare form: `return {a: o}`
// or `return f(o)` embeds the object in a value whose
// other references this walk has not bounded, and a
// return inside a closure body is not a terminator for
// the enclosing function's local.
if !self.in_closure {
if let Expr::LocalGet(id) = e {
if self.tracked_root(*id).is_some() {
return;
}
}
}
self.with_ctx(report::ESC_RETURN, |w| w.walk_expr(e));
}
}
Expand Down Expand Up @@ -1012,7 +1091,10 @@ impl<'a> UseWalk<'a> {
for c in captures.iter().chain(mutable_captures.iter()) {
self.disq(*c, report::ESC_CLOSURE_CAPTURE);
}
let outer = self.in_closure;
self.in_closure = true;
self.walk_stmts(body);
self.in_closure = outer;
}
// Everything else: recurse into children; a bare LocalGet of a
// candidate in any unhandled position hits the LocalGet arm above
Expand Down
19 changes: 17 additions & 2 deletions crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ fn run(stmts: &[Stmt], classes: &HashMap<String, &Class>) -> HashMap<u32, PtrSha

/// A contained local is promoted AND reported as a win; an escaping one
/// is denied AND reported with rule 2 naming the return position.
///
/// #7034 §4 narrowed what "the return position" means: a BARE `return o` is
/// now exempt (`ptr_shape.rs` rule 2, and
/// `ptr_shape_returns_tests::returned_local_is_promoted`). The escaping local
/// here therefore returns the object through a CONDITIONAL, which is what the
/// rule still denies and still reports as a return escape.
#[test]
fn contained_local_wins_and_returned_local_is_denied_with_its_rule() {
let c = class_with_fields("C", &["x"]);
Expand All @@ -119,7 +125,11 @@ fn contained_local_wins_and_returned_local_is_denied_with_its_rule() {
store_x(1),
let_c(2, "escaped"),
store_x(2),
Stmt::Return(Some(Expr::LocalGet(2))),
Stmt::Return(Some(Expr::Conditional {
condition: Box::new(Expr::Bool(true)),
then_expr: Box::new(Expr::LocalGet(2)),
else_expr: Box::new(Expr::Undefined),
})),
];

let session = Session::start();
Expand Down Expand Up @@ -301,7 +311,12 @@ fn nothing_is_recorded_when_the_report_is_off() {
let c = class_with_fields("C", &["x"]);
let mut classes = HashMap::new();
classes.insert("C".to_string(), &c);
let stmts = vec![let_c(1, "escaped"), Stmt::Return(Some(Expr::LocalGet(1)))];
// A container return, not a bare one: #7034 §4 exempts `return o`, and
// this test's subject is the report gate, not the proof.
let stmts = vec![
let_c(1, "escaped"),
Stmt::Return(Some(Expr::Array(vec![Expr::LocalGet(1)]))),
];

// Take the same lock the enabled sessions use — otherwise a
// concurrently-running enabled test would make this one flaky, since
Expand Down
Loading
Loading