Skip to content

perf(class-fields): typed slot stores for pointer-typed class fields (#5094) - #7686

Merged
proggeramlug merged 5 commits into
mainfrom
worktree-p1-pointer-field-stores
Aug 9, 2026
Merged

perf(class-fields): typed slot stores for pointer-typed class fields (#5094)#7686
proggeramlug merged 5 commits into
mainfrom
worktree-p1-pointer-field-stores

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

P1 (#5094): one pointer field no longer demotes an object's whole store set

A single pointer-typed class field (peer: Cell | null, next: LNode | null,
left: Tree | null) put every field store on that object — including its
number fields — on js_object_set_field_by_name: by-name dispatch, a
RuntimeHandleScope, layout_note_slot, and a per-object side-table entry, for
stores whose slot index is a compile-time constant.

Measured — quiet M1 mini, best-of-3 wall, both arms run back-to-back

bench before after scriptc 0.0.22 node 26.5.1
cycles 0.79 0.24 0.31 0.07
deeplist 1.14 0.33 0.22 0.09
tree_wide 12.18 3.02 7.01 0.89
tree 5.92 4.43 4.80 0.45

cycles, tree and tree_wide now beat scriptc; tree_wide is 4.0×.
Unchanged, as required: push_cls 0.35, churn 0.66, churn_alloc 0.36,
push_num 0.13, retain 1.32, retain1 0.42, churn_read 0.35. Every
benchmark's stdout is byte-identical to the pre-change binary.

Three changes, and they must land together

  1. The sloppy-mode class-field route reaches boxed slots (expr/property_set.rs).
    build: byte-identical source compiles to a 44x-slower object depending on where the .ts file lives; the published baseline measures the fast arm #7288 opened it for raw-f64 slots only, so n.next = head fell through to the
    PutValue write IC whose miss is js_put_value_set → by-name. The
    guard-free store is licensed by the same perf(method dispatch): method_calls ~290× Node — remaining cost is per-field-access shape-guard calls (plan + standby) #5093 inline precheck the raw arm
    uses (it rejects frozen and descriptor-bearing receivers — the only thing
    sloppy and strict PutValue disagree about) and the miss stays
    js_put_value_set(..., strict = 0).

  2. A pointer-bearing class declares its layout at allocation (typed_shape.rs).
    perf(gc): layout side tables are 34% of object construction — the construction/death half of #5094 (allocation is 7.7%) #7510 required an EMPTY pointer mask, which excluded exactly these classes —
    so their descriptor arrived after every store in their constructor and none
    could pass its intact-bit guard (perf: new Klass(v,w) is 63% slower than the equivalent object literal (28.5x vs 17.4x Node) #7512's defect, still open for this shape).
    Obligation 2 is now discharged rather than avoided: both new allocation
    paths pre-fill every slot with TAG_UNDEFINED, which the tracer rejects at
    its tag check, so a pointer-masked slot visited before its first write cannot
    strand anything. Obligation 1 (no read may observe a raw-f64 slot before its
    first write) is unchanged and still required of every number field.

  3. The constructor prologue admits literals and pure operator trees
    (lower_call/field_init.rs). constructor(v) { this.next = null; this.v = v }
    truncated the prologue at statement 0 and came back EMPTY, which cost both the
    dead-undefined-store elision and (via the same set) the declaration in (2).
    tree_wide's this.b = s + 1 needs the operator-tree half.

Why together: (1) alone REGRESSES tree_wide. Measured directly by
compiling the benchmarks as ESM — which already takes the class-field route —
against the pre-change compiler: tree_wide 12.21 → 14.88 s. Routing a
constructor's stores to a guard the construction path has made unsatisfiable is
slower than the inline cache it displaces. (2) is what makes the guard passable.

GC

The dangerous half. A pointer store must still reach the remembered set.

  • PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 (depth 800), binaries compiled
    with PERRY_GC_MOVING_LOOP_POLLS=1, over pointer-cycle / linked-list /
    wide-tree probes: clean, and not vacuous — 20005 / 40005 / 5
    [gc-fromspace-protect] retirements, i.e. the copying minor really ran. All
    three match the node 26.5.1 oracle, including a walk that sums every numeric
    slot as well as every pointer edge.
  • PERRY_GC_VERIFY_EVACUATION, PERRY_GC_VERIFY_MARK and
    PERRY_GC_FROMSPACE_SCAN_ABORT, each under zeal: clean.
  • PERRY_GC_TRACE drift: churn is bit-identical (105 cycles, 0.0039 GB
    copied, positive reclamation every cycle). deeplist/tree/tree_wide keep
    their cycle counts and kinds exactly (4 / 42 / 44); cycles copies 10,758 → 14
    objects and promotes 4,746 → 2, which is the side-table bookkeeping this PR
    removes no longer keeping dead objects reachable.
  • gc-root-dominance, both arms, exact CI invocation: 0 violations with the
    40-seeded-violation control catching 40/40, and --unrooted-allocas 0.

One latent gate bug this exposed

scripts/gc_root_dominance_check.py's NONCOLLECTING set is a second copy of a
fact perry-codegen/src/gc_call_effects.rs already states, and the two had
drifted: #7510 added js_gc_declare_typed_shape_layout beside
js_gc_init_typed_shape_layout in the Rust match and not in the Python set.
That stayed invisible only because the corpus then contained no class the #7510
gate admitted. Widening the gate printed 358 spurious violations, every one
js_object_alloc_class_inline_keys->js_gc_declare_typed_shape_layout. The two
entry points share a body and differ only in a TypedShapeProof that makes
declare do strictly less work, so the classification is the same one.

Pre-existing, not from this PR

typed_shape_descriptors::integer_arithmetic_array_push_omits_inbounds_layout_note_and_barrier
fails identically at 6cdcd79ec with this PR's sources reverted (verified by
checkout, not by inspection). It is an array-push contract with no class-field
involvement.

⚠️ This PR MASKS the interp.ts GC canary — do not use it as the probe

gc-handoff/BUG-interp-silent-wrong-answer.md reports a moving-collector bug
(silently wrong answer, 1708662 vs node's 1708840, 6/6) and proposes adding
interp.ts to the GC-ratchet corpus as a correctness probe. Under this PR
that probe passes while the bug is fully intact.

Measured by building two compilers from the same base commit, one with this
diff and one with the codegen sources reverted:

interp.ts ×6 iso_miss.ts ×6
node 26.5.1 1708840 misses 0
base 1708662 (wrong) ×6 misses 6, 4, 2, 4, 8, 6
this PR 1708840 (correct) ×6 misses 4, 6, 4, 4, 4, 4 — still wrong

The bug is present in both arms. This PR perturbs allocation/promotion timing
enough that interp.ts lands on the right answer; iso_miss.ts, the tighter
isolation, stays red in both. That matches the bug report's own observation that
the miss count varies with allocation timing while the wrongness does not.

Use iso_miss.ts with misses == 0 asserted (or its checksum against
node's 437840). interp.ts would become a probe that cannot fail — CLAUDE.md's
"four ways a gate can be unable to fail", item 4.

Nothing here is caused or fixed by this PR; it is flagged because landing it
silently changes what that canary means. Possibly the same root cause as #7682
(alloc-point minor moving past the immobility guard), which would also explain
why PERRY_GC_SCAVENGE=0 is one of the four knobs that restores the answer.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR extends sloppy class-field lowering to boxed fields, permits pointer-bearing typed layouts at allocation, broadens safe constructor-prologue analysis, updates GC classification, and adds regression coverage and benchmark documentation.

Changes

Class-field lowering and typed layouts

Layer / File(s) Summary
Prologue analysis and typed-shape eligibility
crates/perry-codegen/src/lower_call/field_init.rs, crates/perry-codegen/src/typed_shape.rs
Constructor prologues now admit literals and pure expressions that cannot observe this. Allocation-time typed layouts now support pointer-bearing fields while retaining raw-f64 initialization checks.
Sloppy boxed-field lowering
crates/perry-codegen/src/expr/property_set.rs, crates/perry-codegen/src/expr/proxy_reflect.rs
Sloppy class-field stores now lower boxed fields with shape checks, pointer-aware slot stores, GC bookkeeping, and a non-strict runtime fallback.
Regression coverage and GC analysis
crates/perry-codegen/tests/native_proof_regressions.rs, crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs, scripts/gc_root_dominance_check.py, changelog.d/7686-pointer-class-field-slot-stores.md
Tests validate boxed stores and allocation-time pointer masks. The GC checker classifies typed-shape declaration as non-collecting. The changelog records benchmarks and GC validation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Constructor
  participant field_init
  participant typed_shape
  participant PutValueSet
  participant property_set
  participant js_put_value_set
  Constructor->>field_init: analyze constructor field assignments
  field_init->>typed_shape: determine allocation-time layout eligibility
  typed_shape-->>Constructor: declare typed layout with pointer mask
  PutValueSet->>property_set: lower sloppy class-field store
  property_set-->>PutValueSet: perform guarded boxed slot store
  property_set->>js_put_value_set: use strict = 0 fallback on guard miss
Loading

Possibly related issues

  • None.

Possibly related PRs

  • PerryTS/perry#7423: Directly related to extending sloppy class-field lowering from raw-f64 fields to boxed and pointer fields.
  • PerryTS/perry#7486: Related through class-field lowering and constructor field-initialization elision.
  • PerryTS/perry#7532: Directly related to allocation-time typed-shape support for pointer-bearing fields.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the performance optimization for typed slot stores on pointer-typed class fields.
Description check ✅ Passed The description explains the motivation, implementation, benchmarks, tests, GC validation, related issue, and known limitations in sufficient detail.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-p1-pointer-field-stores

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-codegen/src/expr/property_set.rs`:
- Around line 341-366: Wrap the body of try_lower_sloppy_class_field_boxed_store
in rooting::with_operands_rooted(ctx, &[object, value], ...). Ensure recv_box,
value, and all subsequent store-address and write-barrier operands are derived
inside that rooted closure so they remain valid after an evacuating RHS
collection.

In `@crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs`:
- Around line 410-413: Strengthen the `Type::Any` assertion in the test by
extracting the typed-shape declaration line, verifying it contains
`@perry_typed_shape_mask_`, and asserting `INIT_CALL` is absent. Keep the
existing `DECLARE_CALL` check and mirror the pointer-field test’s ABI
assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c4d87f4-d572-4c4d-874a-620f3849caf3

📥 Commits

Reviewing files that changed from the base of the PR and between 5296e3c and 3c394b7.

📒 Files selected for processing (8)
  • changelog.d/7686-pointer-class-field-slot-stores.md
  • crates/perry-codegen/src/expr/property_set.rs
  • crates/perry-codegen/src/expr/proxy_reflect.rs
  • crates/perry-codegen/src/lower_call/field_init.rs
  • crates/perry-codegen/src/typed_shape.rs
  • crates/perry-codegen/tests/native_proof_regressions.rs
  • crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs
  • scripts/gc_root_dominance_check.py

Comment on lines +341 to +366
// Operand order mirrors the raw-f64 arm and the strict class-field arm
// verbatim: the assignment reference is evaluated before the RHS, and the
// receiver's relocation across an allocating RHS is handled by the same
// statepoint re-read those arms rely on.
let recv_box = lower_expr(ctx, object)?;
let val_double = lower_expr(ctx, value)?;

// Computed before the block builder is borrowed below.
let barrier_needed = !expr_produces_non_pointer_bits_by_construction(ctx, value);
let layout_note_needed = class_field_store_needs_layout_note(ctx, value);
let string_addref_needed = class_field_store_needs_string_addref(ctx, value);

let key_idx = ctx.strings.intern(property);
let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global);
let field_idx_str = field_index.to_string();
let expected_class_id_str = expected_class_id.to_string();

let (obj_bits, obj_handle, key_box, val_bits, expected_keys) = {
let blk = ctx.block();
let obj_bits = blk.bitcast_double_to_i64(&recv_box);
let obj_handle = blk.and(I64, &obj_bits, POINTER_MASK_I64);
let key_box = blk.load(DOUBLE, &key_handle_global);
let val_bits = blk.bitcast_double_to_i64(&val_double);
let expected_keys = blk.load(I64, &format!("@{}", keys_global_name));
(obj_bits, obj_handle, key_box, val_bits, expected_keys)
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Look for the statepoint re-read mechanism that would relocate `recv_box` across the RHS.
set -euo pipefail

rg -n -C 6 'statepoint' crates/perry-codegen/src | head -100
rg -n -C 4 'with_operands_rooted|guard_store_operand|StoreOperandGuard|with_rooted_group' crates/perry-codegen/src/expr/property_set.rs
ast-grep outline crates/perry-codegen/src/expr/rooting.rs --items all

Repository: PerryTS/perry

Length of output: 10436


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching property_set.rs/rooting:"
fd -a 'property_set|rooting' crates/perry-codegen/src | sed 's#^\./##'

echo
echo "Outline property_set:"
ast-grep outline crates/perry-codegen/src/expr/property_set.rs --match try_lower_sloppy_class_field_boxed_store --view expanded || true
ast-grep outline crates/perry-codegen/src/expr/property_set.rs --match try_lower_strict_class_field_boxed_store --view expanded || true

echo
echo "Relevant slices in property_set.rs:"
sed -n '332,450p' crates/perry-codegen/src/expr/property_set.rs
echo "----"
sed -n '800,880p' crates/perry-codegen/src/expr/property_set.rs
echo "----"
sed -n '1430,1495p' crates/perry-codegen/src/expr/property_set.rs

echo
echo "Search rooting.rs references:"
rg -n 'pub\(in crate::rooting\) mod|mod rooting|with_operands_rooted|with_operands_rooted_across|GuardStoreOperand|GuardStoreOperand' crates/perry-codegen/src -g '*.rs' | head -200

Repository: PerryTS/perry

Length of output: 38036


🏁 Script executed:

#!/bin/bash
set - euo pipefail

echo "Rooting module outline:"
ast-grep outline crates/perry-codegen/src/rooting/mod.rs --view expanded | sed -n '1,240p' || true

echo
echo "Relevant rooting implementation slices:"
sed -n '500,680p' crates/perry-codegen/src/rooting/mod.rs
echo "----"
sed -n '900,945p' crates/perry-codegen/src/rooting/mod.rs
echo "----"
sed -n '1360,1405p' crates/perry-codegen/src/rooting/mod.rs

echo
echo "Adjacent strict/raw-f64 arms around try_lower_sloppy_class_field_boxed_store:"
sed -n '260,350p' crates/perry-codegen/src/expr/property_set.rs
sed -n '350,410p' crates/perry-codegen/src/expr/property_set.rs

Repository: PerryTS/perry

Length of output: 26874


Root the receiver across the boxed-class-field store.

try_lower_sloppy_class_field_boxed_store lowers object for recv_box, then lowers value. The subsequent store address and write-barrier operands are derived from recv_box/obj_handle, so they are stale if the RHS triggers an evacuating collection. Wrap this helper body in rooting::with_operands_rooted(ctx, &[object, value], ...) so the receiver, value, and post-collection store/barrier operands come from the same rooted group.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/expr/property_set.rs` around lines 341 - 366, Wrap
the body of try_lower_sloppy_class_field_boxed_store in
rooting::with_operands_rooted(ctx, &[object, value], ...). Ensure recv_box,
value, and all subsequent store-address and write-barrier operands are derived
inside that rooted closure so they remain valid after an evacuating RHS
collection.

Source: Coding guidelines

Comment on lines 410 to 413
assert!(
!ir.contains(DECLARE_CALL),
"`Any` is pointer-bearing:\n{ir}"
ir.contains(DECLARE_CALL),
"`Any` is pointer-bearing, which is now a reason TO declare:\n{ir}"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the Any pointer-mask ABI.

Type::Any is pointer-bearing, but this assertion only checks for a declaration call. A regression can pass this test while passing a null pointer mask or retaining js_gc_init_typed_shape_layout.

Extract the declaration line and assert @perry_typed_shape_mask_ is present. Also assert that INIT_CALL is absent, as in the pointer-field test.

Proposed test strengthening
-    assert!(
-        ir.contains(DECLARE_CALL),
-        "`Any` is pointer-bearing, which is now a reason TO declare:\n{ir}"
-    );
+    let line = ir
+        .lines()
+        .find(|l| l.contains(DECLARE_CALL))
+        .unwrap_or_else(|| panic!("`Any` must declare:\n{ir}"));
+    assert!(
+        line.contains("`@perry_typed_shape_mask_`"),
+        "`Any` must pass a non-null pointer mask: {line}"
+    );
+    assert!(
+        !ir.contains(INIT_CALL),
+        "the declaration must replace post-constructor initialization:\n{ir}"
+    );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assert!(
!ir.contains(DECLARE_CALL),
"`Any` is pointer-bearing:\n{ir}"
ir.contains(DECLARE_CALL),
"`Any` is pointer-bearing, which is now a reason TO declare:\n{ir}"
);
let line = ir
.lines()
.find(|l| l.contains(DECLARE_CALL))
.unwrap_or_else(|| panic!("`Any` must declare:\n{ir}"));
assert!(
line.contains("`@perry_typed_shape_mask_`"),
"`Any` must pass a non-null pointer mask: {line}"
);
assert!(
!ir.contains(INIT_CALL),
"the declaration must replace post-constructor initialization:\n{ir}"
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs` around
lines 410 - 413, Strengthen the `Type::Any` assertion in the test by extracting
the typed-shape declaration line, verifying it contains
`@perry_typed_shape_mask_`, and asserting `INIT_CALL` is absent. Keep the
existing `DECLARE_CALL` check and mirror the pointer-field test’s ABI
assertions.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Blocking: a pointer store on the new path does not reach the remembered set

The perf work looks right and the writeup is careful — but the obligation this PR names as "the dangerous half" is not met. A young pointer stored into an old object never enters the remembered set on this branch. main is clean on the identical probe.

Evidence

Both arms built --profile perry-dev (-p perry -p perry-runtime-static -p perry-stdlib-static, PERRY_RUNTIME_DIR pinned), probe compiled with PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_NO_AUTO_OPTIMIZE=1, run under PERRY_GC_ZEAL=1 PERRY_GC_FROMSPACE_SCAN_ABORT=1:

arm 6 consecutive runs
main (6cdcd79ec+) 0 0 0 0 0 0 — every scan dangling=0
this branch 134 134 134 134 134 134
[gc-fromspace-scan abort] objects=1281 words=25309 fwd_owners_skipped=0
    missing_rewrites=0 dangling=1 owners=1 | never_dirty=1 lost_dirty=0 dirty_but_missed=0
[gc-fromspace-scan abort]   in_snapshot=0 not_in_snapshot=1
  owner=0x5e4830f3c60 type=3 space=Survivor0 +40 nanbox -> 0x5e47dea656d (type=0 NurseryEden)
    DANGLING (target not evacuated)
    [slot dirty_now=false ever_dirty=false owner_flags=0x43 marked=true]

ever_dirty=false on a Survivor0 → NurseryEden edge is the whole finding. Not "the dirty bit was cleared early" (lost_dirty=0) and not "the scan missed it" (dirty_but_missed=0) — the slot was never marked dirty, so the remembered set never learned the edge exists and the minor did not evacuate the target.

Why the PR's own GC section didn't catch it

Those probes are pointer-cycle / linked-list / wide-tree shapes, which build their edges young-to-young. The barrier obligation only bites when the parent has already been promoted and then takes a pointer to a fresh object. My probe forces that ordering explicitly:

class LNode { v: number; next: LNode | null; constructor(v: number) { this.v = v; this.next = null; } }
class Cell  { n: number; peer: Cell  | null; constructor(n: number) { this.n = n; this.peer = null; } }

function build(n: number): LNode {
  let head = new LNode(0);
  for (let i = 1; i < n; i++) { const nd = new LNode(i); nd.next = head; head = nd; }
  return head;
}

// 1. allocate the parents, 2. churn hard so they get promoted,
// 3. THEN store a fresh young pointer into them.
const olds: Cell[] = [];
for (let i = 0; i < 400; i++) olds.push(new Cell(i));
let churn: any[] = [];
for (let i = 0; i < 200000; i++) { churn.push({ a: i, b: "s" + (i & 255) }); if (churn.length > 1000) churn = []; }
for (let i = 0; i < 400; i++) olds[i]!.peer = new Cell(i + 10000);   // OLD <- YOUNG

let psum = 0; for (let i = 0; i < 400; i++) psum += olds[i]!.peer!.n + olds[i]!.n;
const head = build(30000);
let walk = 0, nodes = 0; let cur: LNode | null = head;
while (cur) { walk += cur.v; nodes++; cur = cur.next; }
console.log(psum, walk, nodes);

Both arms print 4159600 449985000 30000, matching node 26.5.1 — the wrong answer is not observable at the source level here, which is the usual shape: the collector had a live edge it did not know about, and this run happened not to reuse the bytes.

It is also invisible without zeal — PERRY_GC_FROMSPACE_SCAN_ABORT=1 alone gives 0 0 0 on both arms. The fault needs a collection to land inside the window.

Where I'd look

Change 1 — the sloppy-mode class-field route reaching boxed slots. #7288 opened that route for raw-f64 slots, which need no barrier; the boxed arm does. If the guard-free constant-slot store inherited the raw arm's barrier-free emission, this is exactly what you would see: correct value, correct dispatch, no dirty bit.

Worth checking against #7602's rule while you're there — the array-push barrier gates on the parent's GC_FLAG_TENURED or an active incremental mark, and "the parent is young so no barrier" was explicitly measured and rejected as unsound.

Everything else I checked, which is clean

  • PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 …_DEPTH=800: exit 0 on both arms, and not vacuous — 18 [gc-fromspace-protect] / [gc-copy-minor] lines, so the copying minor really ran on my probe too.
  • PERRY_GC_VERIFY_EVACUATION and PERRY_GC_VERIFY_MARK under zeal: exit 0. Consistent with the diagnosis rather than against it — VERIFY_EVACUATION walks the same enumeration the rewrite pass walks, so it is blind to an edge the remembered set never recorded (this is why FROMSPACE_SCAN is the layout-independent instrument, see GC_LAYOUT_POINTER_FREE's doc comment).
  • Output byte-identical to node on both arms.

The gate-drift finding is worth keeping regardless

NONCOLLECTING in gc_root_dominance_check.py being a second copy of gc_call_effects.rs's fact, drifted since #7510 and invisible only because the corpus contained no class the gate admitted — that is a real latent bug and the 358 spurious violations are the proof. Whatever happens to the rest, that half should land.

Happy to re-audit as soon as the barrier is in; the reproducer above is self-contained.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Withdrawing my block — and the cause was not in this PR

My earlier review reported a deterministic FROMSPACE_SCAN_ABORT on this branch (134 ×6) against a clean main, with dangling=1 … never_dirty=1 on a Survivor0 → NurseryEden edge. That reproduction was real, and my diagnosis of it was wrong.

Re-tested on current main, both arms rebuilt:

arm 6 runs of my probe under ZEAL=1 FROMSPACE_SCAN_ABORT=1
main 0 0 0 0 0 0
this branch 0 0 0 0 0 0

Not vacuous — the copying minor still runs on both (10 and 14 [gc-fromspace-protect]/[gc-copy-minor] lines), and both print 4159600 449985000 30000, matching node.

What changed is main, not this branch: #7687 landed. That was #7682 — an allocation-point collection that moved things while running at a program point neither root lowering can describe. My probe's dangling edge was that bug, and this PR is what made it reachable on that shape: moving the store off the by-name path onto a register-held fast path is exactly what turns an imprecise alloc point from theoretical into observable. The interaction was real; the defect was upstream.

And the barrier is present. I read the emitted IR rather than inferring from the runtime result. class_field_sloppy_set.boxed_fast stores the value, then tag-checks it (lshr 48 against 0x7FFD / 0x7FFF / 0x7FFA, plus the raw-pointer range test) and branches to class_field_set.gc_bookkeeping when it is pointer-like. Both arms emit exactly one js_write_barrier_slot and three js_gc_note_slot_layout sites. My "the guard-free store inherited the raw arm's barrier-free emission" was a guess, and it was wrong.

I should have read the IR before writing the review. The runtime abort was strong evidence something was wrong, and it was — but attributing it to a missing barrier rather than to an upstream moving collection cost you a round trip.

The audit, now that it stands

Verified on current main: 24/24 lint, cargo fmt --check clean, perry-runtime --lib 1930, perry-codegen --lib 778, native_root_coverage 14/14.

Pre-existing failure confirmed as claimed, by checkout rather than inspection: typed_shape_descriptors::integer_arithmetic_array_push_omits_inbounds_layout_note_and_barrier fails identically on main at f3e14a61e — same test, same 17-passed/1-failed count. Not from this PR.

The gate-drift finding stands on its own merits. NONCOLLECTING in gc_root_dominance_check.py being a second copy of a fact gc_call_effects.rs already states, drifted since #7510 and invisible only because the corpus then contained no class the gate admitted — that is the same defect class as everything else this week, and the 358 spurious violations are the proof it was load-bearing.

The three changes landing together is right, and the reason is worth keeping: #7510 required an empty pointer mask, which excluded exactly these classes, so their descriptor arrived after every constructor store and none could pass its intact-bit guard. Discharging obligation 2 by pre-filling every slot with TAG_UNDEFINED — which the tracer rejects at its tag check — is a real discharge rather than an avoidance.

cycles 0.79 → 0.24, tree_wide 12.18 → 3.02 (4.0×), three benchmarks now ahead of scriptc, and the seven that must not move all unchanged with byte-identical stdout.

@proggeramlug
proggeramlug force-pushed the worktree-p1-pointer-field-stores branch from 3c394b7 to 13ba119 Compare August 9, 2026 07:57
@proggeramlug
proggeramlug merged commit 94ad784 into main Aug 9, 2026
@proggeramlug
proggeramlug deleted the worktree-p1-pointer-field-stores branch August 9, 2026 07:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant