The measurement
On main @ v0.5.1448, a Ptr<Shape>-promoted local — the case that already works — still coerces every numeric field read.
class P { constructor(public x: number, public y: number) {} }
function run(n: number): number {
const a: P[] = [];
for (let i = 0; i < n; i++) a.push(new P(i, i + 1));
let s = 0;
for (let i = 0; i < a.length; i++) { const r = a[i]; s += r.x + r.y; }
return s;
}
--opt-report confirms the promotion succeeds: local r -> Ptr<Shape> (in function run). And the by-name machinery is gone from the IR — no js_object_get_field_by_name_f64, no js_typed_feedback_class_field_get_guard, no js_throw_type_error_property_access.
But subtracting a build-only variant to isolate the read loop leaves:
js_array_get_f64 x1 <- separate issue, see "Related"
js_number_coerce x2 <- THIS ISSUE, one per field read
js_gc_loop_safepoint x1 <- by design (#7721)
Two declared-number fields, on a shape that is proven, read through a path with no dynamism left — and each one still pays a coercion, because the slot it loads from holds a NaN-boxed double.
Why this is small
The representation already exists on both sides:
- Runtime: typed shapes carry a
raw_f64_mask beside the pointer mask (gc/layout.rs), and the GC already understands slots that hold raw f64 rather than NaN-boxed values.
- Codegen:
typed_shape.rs computes raw_f64_mask_words and declares it, and expr/property_get/helpers.rs::lower_raw_f64_class_field_get_for_number_context already lowers a numeric field read to a raw load.
That function is gated on .scalar_replaced — objects proven never to escape — via scalar_replaced_field_is_raw_f64 and scalar_replaced_field_raw_f64_store_state.
Ptr<Shape> is a weaker condition that the collector already computes (collectors/ptr_shape.rs: provenance + containment). A Ptr<Shape> local's dynamic class is exactly C, which is precisely what the raw-f64 decision needs. The work is extending an existing gate to an existing proof, not building new machinery.
Note emit_element_shape_field_load (expr/element_shape_guard.rs, "run per access inside the fast clone") is the other place a proven field load happens — check whether the coercion in the measurement above comes from there rather than from the generic path, and fix whichever is live.
Acceptance criteria
- The read loop above emits zero
js_number_coerce for r.x / r.y.
--opt-report still shows local r -> Ptr<Shape>; the promotion must not be traded away for the coercion.
- The store side agrees with the load side. A slot read as raw f64 must have been written as raw f64. A mismatch is a silent wrong value, not a crash — this is the direction the change can be quietly wrong in, and it needs a test that writes through every path that can reach the field (constructor,
this.x = v, obj.x = v from outside, Object.assign, a setter, a delete then re-add).
- The GC still traces correctly.
raw_f64_mask and the pointer mask must stay disjoint — a slot in both, or in neither, is either a missed root or a scanned number. Run PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 over the reproducer and assert the [gc-zeal] verdict shows non-zero copying_minors and moved_objects — a clean run that collected nothing proves nothing.
- A field whose declared type is
number but which receives a non-number at runtime (Perry does not enforce declared types — CLAUDE.md, Known Limitations) must still produce Node-identical output. Cover: (p as any).x = "s", = null, = {}, = 1n.
- Protected floors hold on the pinned quiet mini, interleaved, best-of-N, output byte-verified against Node before timing.
Traps
Related
#7766 (rule-1 provenance, the hop before this one), and the element-fetch issue filed alongside this one (the js_array_get_f64 in the same measurement). Closed context: #7510 (layout side tables), #7149 / #7034 §3 (the element rule), #7480 / #7669 / #7701 (the element-shape guarded clone).
The measurement
On main @ v0.5.1448, a
Ptr<Shape>-promoted local — the case that already works — still coerces every numeric field read.--opt-reportconfirms the promotion succeeds:local r -> Ptr<Shape> (in function run). And the by-name machinery is gone from the IR — nojs_object_get_field_by_name_f64, nojs_typed_feedback_class_field_get_guard, nojs_throw_type_error_property_access.But subtracting a build-only variant to isolate the read loop leaves:
Two declared-
numberfields, on a shape that is proven, read through a path with no dynamism left — and each one still pays a coercion, because the slot it loads from holds a NaN-boxed double.Why this is small
The representation already exists on both sides:
raw_f64_maskbeside the pointer mask (gc/layout.rs), and the GC already understands slots that hold raw f64 rather than NaN-boxed values.typed_shape.rscomputesraw_f64_mask_wordsand declares it, andexpr/property_get/helpers.rs::lower_raw_f64_class_field_get_for_number_contextalready lowers a numeric field read to a raw load.That function is gated on
.scalar_replaced— objects proven never to escape — viascalar_replaced_field_is_raw_f64andscalar_replaced_field_raw_f64_store_state.Ptr<Shape>is a weaker condition that the collector already computes (collectors/ptr_shape.rs: provenance + containment). APtr<Shape>local's dynamic class is exactlyC, which is precisely what the raw-f64 decision needs. The work is extending an existing gate to an existing proof, not building new machinery.Note
emit_element_shape_field_load(expr/element_shape_guard.rs, "run per access inside the fast clone") is the other place a proven field load happens — check whether the coercion in the measurement above comes from there rather than from the generic path, and fix whichever is live.Acceptance criteria
js_number_coerceforr.x/r.y.--opt-reportstill showslocal r -> Ptr<Shape>; the promotion must not be traded away for the coercion.this.x = v,obj.x = vfrom outside,Object.assign, a setter, adeletethen re-add).raw_f64_maskand the pointer mask must stay disjoint — a slot in both, or in neither, is either a missed root or a scanned number. RunPERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800over the reproducer and assert the[gc-zeal]verdict shows non-zerocopying_minorsandmoved_objects— a clean run that collected nothing proves nothing.numberbut which receives a non-number at runtime (Perry does not enforce declared types — CLAUDE.md, Known Limitations) must still produce Node-identical output. Cover:(p as any).x = "s",= null,= {},= 1n.Traps
--opt-reportis the instrument. It names the rule and the promoted local directly; do not infer from emitted calls.PERRY_NO_AUTO_OPTIMIZE=1changes nothing here — verified identical IR with and without, so it is safe for iteration.-p perry -p perry-runtime-static -p perry-stdlib-staticand pinPERRY_RUNTIME_DIR, orperry compilelinks a stale.aand both arms of an A/B behave identically.P[]parameter still reads fields by name (first-party half of #7152/#7170) #7766 — the symptom reproduces on the already-promoted local above — but repsel: rule-1 provenance does not survive a function boundary — a typedP[]parameter still reads fields by name (first-party half of #7152/#7170) #7766 is what makes it fire on code where the array crosses a function boundary.Related
#7766 (rule-1 provenance, the hop before this one), and the element-fetch issue filed alongside this one (the
js_array_get_f64in the same measurement). Closed context: #7510 (layout side tables), #7149 / #7034 §3 (the element rule), #7480 / #7669 / #7701 (the element-shape guarded clone).