Skip to content

perf(runtime): O(1) wide property insertion on class instances#5528

Merged
proggeramlug merged 2 commits into
mainfrom
fix/class-instance-wide-set-fastpath
Jun 22, 2026
Merged

perf(runtime): O(1) wide property insertion on class instances#5528
proggeramlug merged 2 commits into
mainfrom
fix/class-instance-wide-set-fastpath

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Problem

Writing many fresh own properties to a class instance was O(n²), while the identical build on a plain {} is O(1):

const o = new C();
for (let i = 0; i < n; i++) o["k" + i] = i;   // O(n²) for class C; O(1) for {}

A 20,000-property class-instance build took ~16s; the plain-object equivalent is ~30ms. Any startup path that builds a wide object on a class instance hung.

There were two independent quadratic factors, both in the hot dynamic-write / interception path.

Layer 1 — extend the [[Set]] fast path to class instances

ordinary_set_with_receiver gated its direct own-data store on class_id == 0, forcing every class-instance write onto the full interception walk. It now consults class_instance_set_may_intercept(obj, class_id, key), which is precise per key: a class getter/setter anywhere in the extends chain (class_chain_has_instance_accessor), an address-keyed accessor / non-writable descriptor on any class prototype, or an intercepting Object.prototype entry all still take the slow walk. An absent key cannot be intercepted, so the fast store stays correct.

Layer 2 — make the actual insertion O(1)

  • The dynamic-write sidecar key index is keyed on the stable object pointer, but the inline-slot append path registered the entry under the keys-array pointer — so the obj-keyed lookup never hit and rebuilt the full O(key_count) index on every write. Register under the object address to match the lookup.
  • Object.getPrototypeOf on a declared-class instance resolved [[Prototype]] via a constructor-field probe that does a linear scan over the instance's own keys before missing. The per-set interception check re-runs that walk every iteration, so it grew by one each time. Resolve the prototype directly from the class id (the same declared-class prototype object the class-id table already returns) — gated on a real declared class id, so synthetic-ctor instances and plain objects keep the existing resolution.

Correctness

Interception semantics are unchanged — inherited setters, getter-only accessors, method shadowing, non-writable inherited data, and a fresh own-data property all behave as before (covered by the new test). Object.getPrototypeOf returns the identical chain. Full perry-runtime lib suite passes single-threaded (1067/1067).

Result

20k class-instance build: ~16s → ~30ms, scaling linearly (on par with plain objects).

Test

crates/perry/tests/issue_class_instance_wide_set.rs — a 20k fresh-key build on a class instance completes and reads back, while an inherited setter still intercepts.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed tracking of dynamically added properties for class instances to prevent sidecar index orphaning during key/shape sharing.
  • Performance Improvements
    • Improved property write handling for class instances by choosing faster set paths when prototype-chain interception is unlikely.
    • Added a faster prototype resolution path for declared-class instances.
  • Tests
    • Added a regression test covering fast, wide dynamic property writes with inherited accessors interception.

Writing many fresh own properties to a class instance
(`class C {}; const o = new C(); for (i) o["k"+i] = i`) was O(n²),
while the identical build on a plain `{}` is O(1). Two independent
quadratic factors, both in the hot dynamic-write / interception path,
are fixed here:

LAYER 1 — extend the `[[Set]]` fast path to class instances.
`ordinary_set_with_receiver` previously gated its direct own-data store
on `class_id == 0`, forcing every class-instance write onto the full
interception walk. It now consults
`class_instance_set_may_intercept(obj, class_id, key)`, which is precise
per key: a class getter/setter anywhere in the `extends` chain
(`class_chain_has_instance_accessor`), an address-keyed accessor /
non-writable descriptor on any class prototype, or an intercepting
`Object.prototype` entry all still take the slow `[[Set]]` walk; an
absent key cannot be intercepted, so the fast store stays correct.

LAYER 2 — make the actual insertion O(1):
  * The dynamic-write sidecar key index is keyed on the (stable) OBJECT
    pointer, but the inline-slot append path registered the new entry
    under the keys-array pointer. The obj-keyed lookup therefore never
    hit and rebuilt the full O(key_count) index on every write — an
    object that stays on the inline-slot path (a class instance whose
    pre-sized inline capacity keeps appends below the overflow
    threshold) degraded to O(n²). Register under the object address to
    match the lookup and the overflow path.
  * `Object.getPrototypeOf` on a declared-class instance resolved the
    `[[Prototype]]` via a `constructor`-field probe, which does a LINEAR
    scan over the instance's own keys before missing. The per-set
    interception check re-runs that walk every iteration, so the scan
    grew by one each time. Resolve the prototype directly from the class
    id (the same declared-class prototype object the class-id table
    already returns, just hoisted ahead of the linear probe) — gated on
    a real declared class id so synthetic-ctor instances and plain
    objects keep their existing `constructor`-based resolution.

Interception semantics are unchanged (inherited setters, getter-only
accessors, method shadowing, non-writable inherited data, and a fresh
own data prop all behave as before). A wide class-instance build now
scales linearly: a 20k build drops from ~16s to ~30ms, on par with the
plain-object build.

Adds an e2e regression test (`issue_class_instance_wide_set`) asserting
the wide build completes and reads back while an inherited setter still
intercepts.
@coderabbitai

coderabbitai Bot commented Jun 21, 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: d9cf3174-2c70-4400-ad6b-51f8b0dd1eb1

📥 Commits

Reviewing files that changed from the base of the PR and between a789fe7 and e097c58.

📒 Files selected for processing (2)
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/proxy.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/proxy.rs

📝 Walkthrough

Walkthrough

Adds a class-instance-aware fast path in ordinary_set_with_receiver that skips the slow [[Set]] walk when no inherited accessor or non-writable descriptor can intercept the write. Introduces two new helpers (class_chain_has_instance_accessor, class_instance_set_may_intercept), fixes a sidecar index key bug, promotes key_to_rust_string to pub(crate), adds a getPrototypeOf fast path for declared-class instances, and includes a regression test covering 20,000 dynamic writes with an inherited setter.

Changes

Class Instance Set Fast Path with Prototype Interception

Layer / File(s) Summary
Shared utilities: key_to_rust_string visibility and sidecar index bug fix
crates/perry-runtime/src/object/reflect_support.rs, crates/perry-runtime/src/object/field_set_by_name.rs
key_to_rust_string is promoted to pub(crate). The keys_index_insert call is corrected to key the sidecar shape-transition entry by the receiver object pointer (obj as usize) instead of the keys-array pointer (new_keys as usize).
Class vtable accessor chain lookup
crates/perry-runtime/src/object/class_registry.rs
Adds class_chain_has_instance_accessor, which walks the extends parent-chain up to depth 32 and returns true if any vtable in the chain defines an instance getter or setter for the given name.
class_instance_set_may_intercept: prototype chain scan
crates/perry-runtime/src/object/mod.rs
Adds pub(crate) unsafe fn class_instance_set_may_intercept, which decodes the JS key, checks the class vtable chain, walks the [[Prototype]] chain with depth bounding, and returns true (slow-path required) whenever an inherited accessor, non-writable descriptor, or decoding failure is encountered.
Fast path integration: ordinary_set_with_receiver and getPrototypeOf
crates/perry-runtime/src/proxy.rs, crates/perry-runtime/src/object/object_ops.rs
Replaces the old class_id == 0 condition with a fast_safe branching scheme using class_instance_set_may_intercept for non-plain instances. object_proto_may_intercept_key moves into the fast_safe computation. Object.getPrototypeOf gains an early fast path for declared-class instances via class_decl_prototype_value_for_instance_class.
Regression test: class instance wide set with inherited accessor
crates/perry/tests/issue_class_instance_wide_set.rs
Adds a regression test that performs 20,000 dynamic key writes on a class instance with an inherited set baz(...) accessor and asserts correct key counts, read-back values, setter interception, and own-property semantics.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • PerryTS/perry#5524: Both PRs modify ordinary_set_with_receiver's dynamic-write fast-path guard in crates/perry-runtime/src/proxy.rs by changing when prototype/accessor interception is considered (prior PR retrieves per-key Object.prototype via object_proto_may_intercept_key; this PR refines fast-path conditions using that check for plain objects and introduces class_instance_set_may_intercept for class instances).

Poem

🐇 Hoppity-hop through the prototype chain,
No slow descriptor walk shall cause me pain!
class_id non-zero? I'll check the vtable,
Fast-safe or slow-path — each write gets a label.
Inherited setters still intercept baz,
But 20,000 own keys fly forth with pizzazz! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main optimization: O(1) wide property insertion on class instances, which is the core performance improvement delivered by this PR.
Description check ✅ Passed The description comprehensively covers the problem, two-layer solution, correctness guarantees, performance results, and test verification, but lacks explicit checkbox completion status and test command execution details required by the template.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 fix/class-instance-wide-set-fastpath

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

@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: 1

🧹 Nitpick comments (1)
crates/perry-runtime/src/object/object_ops.rs (1)

2789-2816: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Mirror this fast path in the raw-pointer branch.

This handles declared-class instances only when top16 == 0x7FFD. The later top16 == 0 branch still reaches constructor_dynamic_prototype(obj) before the class-id lookup, so module-level raw-pointer class instances can keep the linear own-key scan this PR is removing.

⚡ Proposed follow-up in the raw-pointer branch before `constructor_dynamic_prototype(obj)`
+            if (*gc).obj_type == crate::gc::GC_TYPE_OBJECT
+                && (*obj).class_id != 0
+                && !is_anon_shape_class_id((*obj).class_id)
+            {
+                if let Some(proto) =
+                    super::class_registry::class_decl_prototype_value_for_instance_class(
+                        (*obj).class_id,
+                    )
+                {
+                    return proto;
+                }
+            }
             if let Some(proto) = constructor_dynamic_prototype(obj) {
                 return proto;
             }
🤖 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-runtime/src/object/object_ops.rs` around lines 2789 - 2816, The
fast path optimization for declared-class instances shown in the diff is only
applied when top16 == 0x7FFD, but the raw-pointer branch (top16 == 0) still
calls constructor_dynamic_prototype(obj) which performs a linear scan over own
keys before the class-id lookup. To fix this, mirror the same fast-path logic in
the raw-pointer branch by adding a check for declared-class instances before the
constructor_dynamic_prototype(obj) call, using the same
class_decl_prototype_value_for_instance_class function to detect and return the
prototype directly when applicable, ensuring consistent O(1) performance across
both branches.
🤖 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-runtime/src/object/mod.rs`:
- Around line 800-807: The prototype chain validation in the proto pointer check
is incorrectly treating all non-POINTER_TAG values as indicating the end of the
chain. Before returning false when the bits >> 48 check fails, you need to
distinguish between raw heap-object pointers (top16 == 0) and small handle
references. According to the coding guidelines, small pointer detection should
check if the value is less than 0x100000 to identify handle references. Modify
the logic around the bits >> 48 comparison to first check if the pointer is a
valid small handle before concluding there is no heap prototype, ensuring that
raw proto values and small handles are properly classified before deciding the
prototype chain has ended.

---

Nitpick comments:
In `@crates/perry-runtime/src/object/object_ops.rs`:
- Around line 2789-2816: The fast path optimization for declared-class instances
shown in the diff is only applied when top16 == 0x7FFD, but the raw-pointer
branch (top16 == 0) still calls constructor_dynamic_prototype(obj) which
performs a linear scan over own keys before the class-id lookup. To fix this,
mirror the same fast-path logic in the raw-pointer branch by adding a check for
declared-class instances before the constructor_dynamic_prototype(obj) call,
using the same class_decl_prototype_value_for_instance_class function to detect
and return the prototype directly when applicable, ensuring consistent O(1)
performance across both branches.
🪄 Autofix (Beta)

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: 0060f17c-dd00-449b-ba2e-bb7cb458ada7

📥 Commits

Reviewing files that changed from the base of the PR and between d032369 and a789fe7.

📒 Files selected for processing (7)
  • crates/perry-runtime/src/object/class_registry.rs
  • crates/perry-runtime/src/object/field_set_by_name.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/object/object_ops.rs
  • crates/perry-runtime/src/object/reflect_support.rs
  • crates/perry-runtime/src/proxy.rs
  • crates/perry/tests/issue_class_instance_wide_set.rs

Comment thread crates/perry-runtime/src/object/mod.rs Outdated
…5528)

Resolves the cargo-fmt lint failure (proxy.rs one-line let-chain) and
incorporates CodeRabbit review feedback on the new wide-set fast path:
`class_instance_set_may_intercept` now classifies the prototype value
before dereferencing it, rather than treating every non-POINTER_TAG bit
pattern as "end of chain". A small-handle payload (e.g. a Proxy
prototype set via Object.setPrototypeOf) is now treated conservatively
(may intercept) instead of being read as an ObjectHeader, and raw
top16==0 heap pointers (module-level object literals) are followed
instead of mistaken for a null prototype.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@proggeramlug proggeramlug merged commit e7ac4c8 into main Jun 22, 2026
15 checks passed
@proggeramlug proggeramlug deleted the fix/class-instance-wide-set-fastpath branch June 22, 2026 02:28
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