Skip to content

perf(runtime): restore method-scoped prototype guards - #8672

Closed
proggeramlug wants to merge 1 commit into
mainfrom
perf/method-name-prototype-guard
Closed

perf(runtime): restore method-scoped prototype guards#8672
proggeramlug wants to merge 1 commit into
mainfrom
perf/method-name-prototype-guard

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Restore method-name-scoped invalidation for direct method guards after the
    later class-semantics integration reinstated the process-global latch.
  • Keep assignment, descriptor installation, and deletion fail-closed while
    allowing unrelated prototype keys to leave hot method guards usable.
  • Make generic inherited dispatch observe declared-method replacement,
    deletion, and re-creation correctly.

The all-method latch remains as an escape hatch for any future keyless
prototype mutation. Keyed writes use the low 16 bits of the existing FNV-1a
dispatch hash; collisions conservatively retire additional method names.

Root cause

The original scoped implementation in 2897cb521 was superseded when the
class-semantics work was integrated, restoring a sticky process-wide
invalidation byte. The unchanged codehz/ecs comprehensive suite performs
prototype activity before its query benchmarks, so every later direct method
guard failed and each entity call entered generic dispatch.

The performance and composition artifacts below were rebuilt with the fix as
one commit on main at 60f4fabde. The final PR head is rebased on
850e6f1d5; that intervening async/RegExp batch does not overlap this patch.

Mac mini benchmark

Apple M1 Mac mini, macOS 26.5.1, AC power, taskpolicy -t 0 -l 0.
The frozen source is upstream codehz/ecs commit
58d729682eeb88d7361796de0420f3d673e27479, benchmark
10k entities: forEach query with accumulation, repeat 256, two warmups and
six measured rounds per process.

An exact-parent 11-pair alternating cohort at 8224d879a measured:

arm median ms/op MAD
exact parent 7.268065 0.008355
scoped guard 0.635431 0.000364

Median paired improvement was 91.26%, with 11/11 candidate wins and 22/22
process semantic oracles. The admitted 60-s quiet gate stayed between 4.10%
and 16.60% active CPU. Executable SHA-256 values were
f6532a88ad6635cdbd8cfb72918b9587a9c85c907aaa72a7c44cfbb5d5262614
(control) and
2aa1de3236aa37958c914353f737da3b26f7f803d09aae5b2b36b6734c6e4477
(candidate).

After rebasing, a fresh current-main candidate was rebuilt and hashed as
52b96f3d8f22fa391310ea889b88d51a17a2c52765d5696574aa707916bc54c2.
A quiet three-pair Mac screen against Node 26.5.1 measured 0.615689 ms/op for
Perry and 0.109858 ms/op for Node, with all six process oracles passing.
Perry therefore remains 5.60x slower in this workload; this PR is a large
regression repair, not a parity claim.

Validation

  • cargo check -p perry-codegen -p perry-runtime
  • Focused runtime tests for exact compiler/runtime slot pairing, descriptor-key
    isolation, same-name invalidation, and generic replacement fallback
  • Focused codegen IR test pinning both global and per-method guard loads
  • New Node/Perry fixture covering unrelated writes, inherited replacement,
    deletion, and same-name writes on another class; byte-identical normally and
    under forced verified-evacuation settings on the rebased compiler
  • Frozen ECS suite: 7/7 tests and exact 50,005,000 checksum locally and on
    the Mac mini with the rebased compiler
  • cargo fmt --all -- --check
  • git diff --check

The full ECS executable still exposes the existing forced-evacuation verifier
panic. The exact parent reproduces the same seventh-copying-minor failure and
signature under the identical command; the focused fixture is green under
those flags. This PR does not claim to fix that separate baseline GC defect.

Summary by CodeRabbit

  • Performance

    • Improved method-call performance by invalidating optimized lookups only when the relevant prototype method changes.
    • Preserved safe fallback behavior for prototype replacements and hash collisions.
  • Bug Fixes

    • Fixed method resolution after prototype methods are deleted or replaced.
    • Ensured deleted methods are not invoked through inherited or registered dispatch paths.
  • Tests

    • Added coverage for inherited methods, deletions, unrelated prototype changes, and same-name replacements across inheritance chains.

@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9f9bf230-a4e2-4048-9539-d82a700d51a5

📥 Commits

Reviewing files that changed from the base of the PR and between 850e6f1 and 9a1e57a.

📒 Files selected for processing (17)
  • changelog.d/8672-method-name-prototype-guards.md
  • crates/perry-codegen/src/collectors/proven_this_routing_tests.rs
  • crates/perry-codegen/src/lower_call/method_override.rs
  • crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs
  • crates/perry-codegen/src/runtime_decls/objects.rs
  • crates/perry-runtime/src/object/class_registry.rs
  • crates/perry-runtime/src/object/class_registry/construct.rs
  • crates/perry-runtime/src/object/class_registry/gc_roots.rs
  • crates/perry-runtime/src/object/class_registry/prototype_methods.rs
  • crates/perry-runtime/src/object/class_registry/state.rs
  • crates/perry-runtime/src/object/delete_rest.rs
  • crates/perry-runtime/src/object/descriptor_state.rs
  • crates/perry-runtime/src/object/native_call_method.rs
  • crates/perry-runtime/src/object/native_call_method/handle_methods.rs
  • crates/perry-runtime/src/typed_feedback/guards.rs
  • crates/perry-runtime/src/typed_feedback/tests.rs
  • test-files/test_method_guard_name_invalidation.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 2 remain after this review.


📝 Walkthrough

Walkthrough

The change adds hashed per-method prototype guard invalidation. Code generation and runtime guards pass method slots, prototype mutations invalidate targeted slots, and method resolution skips deleted entries. Tests cover guard preservation, same-name invalidation, deletion, inheritance, and method replacement.

Changes

Method-scoped prototype guard invalidation

Layer / File(s) Summary
Guard contracts and code generation
crates/perry-codegen/src/runtime_decls/objects.rs, crates/perry-codegen/src/lower_call/..., crates/perry-runtime/src/typed_feedback/...
Direct shape guard declarations and implementations now accept method guard slots. Inline, shape-only, and multi-arm paths pass the slot and check method-specific invalidation. Tests verify guard behavior and generated IR.
Prototype invalidation state and mutation wiring
crates/perry-runtime/src/object/class_registry/..., crates/perry-runtime/src/object/descriptor_state.rs, crates/perry-runtime/src/object/delete_rest.rs, changelog.d/8672-method-name-prototype-guards.md
Prototype writes and deletions invalidate the matching hashed slot while preserving global fail-closed invalidation and shared cache retirement. Re-exports, cleanup, and changelog text reflect the new APIs.
Deleted and overridden method resolution
crates/perry-runtime/src/object/class_registry/construct.rs, crates/perry-runtime/src/object/native_call_method.rs, crates/perry-runtime/src/object/native_call_method/handle_methods.rs, test-files/test_method_guard_name_invalidation.ts
Lookup skips deleted entries, own prototype methods take precedence over vtable methods, and deleted methods do not reach vtable dispatch. Inheritance tests cover deletion, unrelated writes, same-name writes, and replacement.

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

Merge Risk: ⚪ Minimal · up to 9a1e5

This PR restores method-scoped prototype-guard invalidation so unrelated prototype writes do not unnecessarily disable optimized method calls, while preserving invalidation for affected methods. The documented validation supports merge readiness, and no actionable merge-blocking risk remains.

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: restoring method-scoped prototype guards.
Description check ✅ Passed The description is detailed, relevant, and includes the change rationale, validation results, benchmarks, and known limitations.
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
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.
✨ 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 perf/method-name-prototype-guard

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.

proggeramlug added a commit that referenced this pull request Aug 24, 2026
…ape guards (#8682)

Lands three reviewed PRs as one squash: #8674, #8675, #8676.

- #8674: compress array forwarding chains (`clean_arr_ptr` multi-hop walk).
- #8675: share numeric guards across dynamic add trees.
- #8676: pack monomorphic method shape guards.

These three were authored as a stack on top of #8672, but their contents
touch disjoint files, so they are cherry-picked onto main on their own.
#8672 is NOT included: it defines its own
`is_bound_native_method_closure_value` (true for any bound native-module
export with a non-empty module name), which #8662 superseded on main with
the strictly narrower `is_bound_native_constructor_closure_value` (gated
on explicit constructor metadata). Those predicates have different truth
sets, so the substitution is a behavioural change at every call site and
is left to the author to rebase.

Also splits `array/tests.rs`, which #8674 pushed over the 2000-line cap,
into an `array/forwarding_tests.rs` sibling. Pure relocation.

Version bump not included per maintainer policy.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Holding this one for a rebase — the rest of your stack (#8674, #8675, #8676) landed via #8682, cherry-picked onto main individually since their contents touch disjoint files.

The blocker is a genuine semantic collision, not a mechanical conflict. This PR adds to class_registry/state.rs:

pub(crate) fn is_bound_native_method_closure_value(value: f64) -> bool {
    unsafe {
        bound_native_callable_module_and_method(value)
            .map(|(module, _)| !module.is_empty())
            .unwrap_or(false)
    }
}

Meanwhile #8662 landed on main (in f5739b532) and solved the same problem with a different predicate, at state.rs:17:

/// True when `value` is a bound native-module *constructor* export. Native
/// constructors and ordinary module functions share `BOUND_METHOD_FUNC_PTR`,
/// so the export's explicit constructor metadata must make the distinction.
pub(crate) fn is_bound_native_constructor_closure_value(value: f64) -> bool {
    bound_native_callable_is_constructor_value(value)
}

These are not the same function under two names. Yours is true for any bound native-module export with a non-empty module name — constructor or ordinary method. main's is true only for an actual constructor. main's is strictly narrower, so swapping it in flips behaviour for every bound native method export. I didn't want to guess which polarity each of your call sites wants, so I left it to you.

Both comments describe the same goal — making class X extends obj.method {} throw the spec-required TypeError instead of silently staying parentless — so I suspect adopting main's function and dropping your definition is right, but that's your call to confirm.

Two smaller mechanical items in the same rebase, both from class_registry.rs being split into a class_registry/ module directory:

  1. function_would_have_own_prototype and ordinary_function_prototype_value_for_read now live in class_registry/function_prototype.rs, not construct.rs. Importing them via pub(crate) use construct::{...} fails with E0603 (construct's own import is private) — they need their own use function_prototype::{...}.
  2. class_unmark_key_deleted (yours) merges into the state::{...} list cleanly.

The only conflicted file is the class_registry.rs module root, and it's purely use-list unions — everything else in your PR auto-merges. Once rebased I'll re-run the batch validation and land it.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Followed up on my earlier comment — I dug into the call site and I think this unblocks cleanly, with no judgment call needed after all. I was wrong to leave it open-ended; the evidence settles it.

Your predicate has exactly one call site, class_registry/parent_static.rs:132, and the block immediately above it is:

if let Some((module, method)) = unsafe {
    native_module::bound_native_callable_module_and_method(parent_value)
} {
    if normalize_native_module_alias(&module) == "wasi" && method == "WASI" {
        register_class(class_id, crate::wasi::CLASS_ID_WASI);
    }
    return;                                    // <-- unconditional
}
if is_bound_native_method_closure_value(parent_value) {
    return;                                    // <-- line 132
}

Now compare the two candidate predicates. Both are built on the same underlying query:

// yours
bound_native_callable_module_and_method(value)
    .map(|(module, _)| !module.is_empty()).unwrap_or(false)

// main's (#8662)
bound_native_callable_module_and_method(value)
    .is_some_and(|(m, p)| is_native_module_constructor_export(&m, &p))

Each can only be true when bound_native_callable_module_and_method returns Some — and the block above already returns unconditionally in exactly that case. So line 132 is unreachable dead code, and it is unreachable under either predicate.

That means the conflict has no behavioural content: substituting is_bound_native_constructor_closure_value is a no-op, and so is deleting the branch. My earlier concern — that the two predicates have different truth sets — is true in the abstract but cannot be observed here, because neither is reachable.

Suggested resolution: drop line 132 and its import entirely, rather than porting either predicate. The comment above it ("Keep the parentless baseline rather than mis-throwing") is already satisfied by the return in the if let Some(...) block. If you'd rather keep a belt-and-braces guard, use main's is_bound_native_constructor_closure_value so there's one predicate in the tree instead of two — but it will never fire.

Worth double-checking my reading before you act on it, since dead-code arguments are easy to get subtly wrong — but if you agree, the class_registry.rs use-list conflict collapses to a plain union with nothing to reconcile, and I'll land it as soon as it's pushed. The rest of the PR auto-merges against current main (60f4fabde).

@proggeramlug
proggeramlug force-pushed the perf/method-name-prototype-guard branch from ba12e94 to 2ea037b Compare August 24, 2026 09:36
@proggeramlug proggeramlug changed the title perf(runtime): preserve method guards across unrelated prototype writes perf(runtime): restore method-scoped prototype guards Aug 24, 2026
@proggeramlug
proggeramlug changed the base branch from fix/array-growth-generation to main August 24, 2026 09:36
@proggeramlug
proggeramlug force-pushed the perf/method-name-prototype-guard branch from 2ea037b to 9a1e57a Compare August 24, 2026 09:38
@proggeramlug
proggeramlug force-pushed the perf/method-name-prototype-guard branch from 9a1e57a to e03d82f Compare August 24, 2026 10:13
proggeramlug added a commit that referenced this pull request Aug 24, 2026
…Intl worklist (#8723)

Lands #8672, #8718, #8720 and #8659.

#8672's blocker is resolved the way the evidence pointed. Its own
`is_bound_native_method_closure_value` is gone; only main's
`is_bound_native_constructor_closure_value` remains, and the branch that
called it in `parent_static.rs` is deleted. That branch was unreachable
under either predicate -- the `if let Some(..) = bound_native_callable_
module_and_method(..)` block directly above returns unconditionally, and
both predicates require that same query to be `Some` -- so removing it is
behaviour-preserving rather than a choice between two semantics.

#8718 (closes #6620) routes `server.listen(path)`, `net.connect(path)` and
the `{ path }` overloads through real Windows named pipes and Unix-domain
sockets instead of falling back to TCP.

#8720 stabilizes native value profile boundaries; #8659 completes the
Intl 402 test262 worklist.

One fix on top: a changelog fragment for #8718, which had neither one nor
a skip-changelog label.

#8719 is NOT in this batch -- it conflicts with #8672 on
`lower_call/method_override.rs`, which both touch.

No version bump.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main via #8723 (squash 8beca2f29). Thanks for taking the deletion route — and for sanity-checking the reasoning rather than just applying it.

Validated on the merged result: all 30 lint checkers, runtime 2667/0, codegen 1214/0 plus all integration suites clean, transform 93/0.

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