Skip to content

repsel Phase 5a: route the class-id dispatch tower to __pshape under an inline keys check (batch.ts: 4 guard calls + 3 by-name field calls per call) #7142

Description

@proggeramlug

Follow-up from #7141 (which fixed Phase 5a's arm-ordering defect: the
{method}__pshape clone had zero call sites because the typed-clone arms
shadowed the routing).

#7141 deliberately left the largest single win on the table, because taking it
needs a guard that does not exist yet. Filing it with the measurement so the
decision is on the record.

The site

benchmarks/app-patterns/kernels/batch.ts is the workload Ptr<Shape> exists
for. Its hot method call is r.rescore(1.5), inside rows.map((r) => …). The
closure parameter r has no static class, so receiver_class_name() returns
None, emit_guarded_direct_method_call is never reached, and the call lowers
to the class-id switch tower in
lower_call/property_get/dynamic_dispatch.rs (idispatch.caseN, blocks built
around :294-311).

Inside idispatch.case0 the receiver's exact dynamic class is already proven by
a runtime class_id compare, and it calls the guard-ridden public body.

What routing it would buy

Measured on the emitted IR (perry-dev, aarch64), Row::rescore:

body IR lines js_typed_feedback_class_field_*_guard js_object_get_field_by_name_f64
…__Row__rescore (public) 311 4 3
…__Row__rescore__pshape 95 0 0

So one routed call site deletes 4 opaque guard calls and 3 by-name field calls
per invocation, on a 40,000-iteration loop. batch.ts totals today are 32
field guards / 47 by-name gets across the module.

Why #7141 did not do it

A class_id match is not sufficient for the clone's bare fixed-slot loads.
delete inst.field compacts the packed inline slots — js_object_delete_field
reaches the keys-scan/compaction path for any GC_TYPE_OBJECT carrying a keys
array, which is exactly what js_object_alloc_class_inline_keys installs
(crates/perry-runtime/src/object/alloc.rs:275), and there is no
"this is a class instance, tombstone instead" branch
(delete_rest.rs:291-344, field_count recomputed at :380). So on
class C { a; b; c }, delete inst.b moves c from slot 2 to slot 1 while
class_id is unchanged.

That is precisely what the keys token catches, and it is why the two
existing routing sites are safe: method_direct.fast is dominated by the
class-id + keys-token guard, and the Phase 3b site is dominated by containment.
The tower has neither.

Everything else about the layout is append-only and slot-stable: all four
allocators size the inline region once and never realloc (alloc.rs:242-294),
non-declared writes append past the declared fields and spill to
ObjectMeta::spill (field_set_by_name.rs:1785-1811, spill.rs:122-217),
js_object_set_field refuses OOB rather than growing
(field_get_set/field_ops.rs:127-136), and the shape/hidden-class machinery is
explicitly an accelerator that re-validates key bytes at the slot
(shapes.rs:12-18). delete is the sole slot relocator.

Proposed shape

Emit an inline keys check at idispatch.caseN before routing — a load of
@perry_class_keys__<mod>__<Class> plus a pointer compare, no call. The
existing inline class-field guard already does exactly this pair
(expr/class_field_inline_guard.rs:286-292 checks field_count > field_index
AND keys-array pointer equality inline), so there is precedent and a working
emitter to copy.

Net: ~3 inline instructions at the call site in exchange for 4 opaque guard
calls + 3 by-name field calls inside the body.

Needs its own soundness review for the freeze/descriptor latches the guarded
path also reads (descriptors_in_use() /
class_prototype_fast_guards_invalidated()), plus a call-site ratchet in
collectors/proven_this_routing_tests.rs and a gap test under the moving-GC
arms.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions