Skip to content

[JSC] Freezing built-in prototypes should not permanently disable fast paths - #622

Open
Jarred-Sumner wants to merge 1 commit into
mainfrom
jarred/cheaper-freeze
Open

Jarred-Sumner wants to merge 1 commit into
mainfrom
jarred/cheaper-freeze

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Freezing built-in prototypes (Object.freeze on Array.prototype, RegExp.prototype, String.prototype, Promise.prototype, Map/Set.prototype, Array, Object.prototype) permanently fired JSGlobalObject fast-path watchpoints even though no values changed, leaving common operations up to 200x slower for the rest of the process. This keeps those fast paths alive after freezing.

Changes

  • putDirectInternal: fire a property's replacement watchpoint only when the structure is watching and the stored value actually changes, so attribute-only redefinitions no longer invalidate value-based adaptive watchpoints.
  • validateAndApplyPropertyDescriptor: reuse the existing GetterSetter when getter and setter are unchanged.
  • Object.assign: when only a prototype (not the target) has read-only or accessor properties, check the source keys against the chain instead of always taking the generic path.
  • Freezing an array with no indexed storage (Array.prototype) keeps blank indexing; a new Structure bit, didFreeze, makes length read-only. This keeps the array prototype chain "sane" watchpoint valid.

Verification

  • New stress tests: define-property-same-value-keeps-adaptive-watchpoints.js, object-assign-frozen-object-prototype.js, freeze-array-prototype.js.
  • All 5,836 JSTests/stress files, clean HEAD vs an earlier revision of this branch: no unexpected differences (two watchdog/timeout tests flipped between timeout and failure under load; not re-run in isolation). Final revision: ~450-test targeted subset, no unexpected differences.
  • After freezing: str.replace 244→0 ms, hole reads 110→12 ms, slice 131→16 ms, species map 30→4 ms, promise then chains 28→12 ms, Object.assign 72→41 ms (unfrozen: 29 ms).
  • Unfrozen: 45 put/define/assign/freeze microbenchmarks stay within the measured HEAD-vs-HEAD noise floor (±4%).

…t paths

Object.freeze on intrinsics (Array.prototype, RegExp.prototype, String.prototype,
Promise.prototype, Map/Set.prototype, the Array constructor, Object.prototype)
permanently fired JSGlobalObject fast-path watchpoints even though no property
value changed. This made str.replace, spread, species creation, hole reads,
slice/includes, promise resolution and Object.assign up to 200x slower for the
rest of the process.

- putDirectInternal: only fire a property's replacement watchpoint when the
  stored value actually changes, and only when the structure is watching.
  A defineProperty that only changes attributes no longer invalidates adaptive
  watchpoints that check the value.
- validateAndApplyPropertyDescriptor: reuse the existing GetterSetter when the
  getter and setter are unchanged, so attribute-only redefinitions keep the
  same cell and Equivalence conditions on it stay valid.
- Object.assign: when only a prototype (not the target) has read-only or
  accessor properties, check the source keys against the prototype chain
  instead of always taking the generic path.
- Freezing a JSArray with no indexed storage (Array.prototype) keeps blank
  indexing instead of converting to sparse ArrayStorage. A new Structure bit,
  didFreeze, makes length read-only. Rejected indexed writes on such arrays
  throw without converting storage. This keeps the array prototype chain sane.
@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
fc961567 autobuild-preview-pr-622-fc961567 2026-09-11 00:04:12 UTC

@coderabbitai

coderabbitai Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: b8551311-5dcf-49bc-a0d6-ffcac5680d2f

📥 Commits

Reviewing files that changed from the base of the PR and between 4b9ff99 and fc96156.

📒 Files selected for processing (14)
  • JSTests/stress/define-property-same-value-keeps-adaptive-watchpoints.js
  • JSTests/stress/freeze-array-prototype.js
  • JSTests/stress/object-assign-frozen-object-prototype.js
  • Source/JavaScriptCore/dfg/DFGOperations.cpp
  • Source/JavaScriptCore/runtime/JSArray.cpp
  • Source/JavaScriptCore/runtime/JSArray.h
  • Source/JavaScriptCore/runtime/JSArrayInlines.h
  • Source/JavaScriptCore/runtime/JSObject.cpp
  • Source/JavaScriptCore/runtime/JSObject.h
  • Source/JavaScriptCore/runtime/JSObjectInlines.h
  • Source/JavaScriptCore/runtime/ObjectConstructor.cpp
  • Source/JavaScriptCore/runtime/ObjectConstructorInlines.h
  • Source/JavaScriptCore/runtime/Structure.cpp
  • Source/JavaScriptCore/runtime/Structure.h

Included review availability: 2 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 3 reviews per hour.


Walkthrough

The changes add JavaScriptCore support for frozen dense arrays, prototype-aware Object.assign fast paths, structure freeze tracking, and conditional replacement watchpoints. New stress tests cover array freezing, frozen-prototype assignment, descriptor changes, and built-in operations.

Changes

Freeze and assignment behavior

Layer / File(s) Summary
Array freeze state and mutation paths
Source/JavaScriptCore/runtime/Structure.*, Source/JavaScriptCore/runtime/JSArray.*, Source/JavaScriptCore/runtime/JSArrayInlines.h, Source/JavaScriptCore/runtime/JSObject.cpp, Source/JavaScriptCore/runtime/ObjectConstructor.cpp
Structures record freeze state. Dense arrays preserve indexing during freezing. Length, push, pop, and indexed writes reject invalid mutations.
Prototype-aware Object.assign fast paths
Source/JavaScriptCore/runtime/JSObject.*, Source/JavaScriptCore/runtime/ObjectConstructor.*, Source/JavaScriptCore/runtime/ObjectConstructorInlines.h, Source/JavaScriptCore/dfg/DFGOperations.cpp
Fast-put analysis reports prototype constraints. Object.assign checks read-only and accessor properties when required.
Accessor descriptors and replacement watchpoints
Source/JavaScriptCore/runtime/JSObject.cpp, Source/JavaScriptCore/runtime/JSObjectInlines.h
Matching accessor objects are reused. Replacement watchpoints fire only for enabled watches and changed values.
Freeze, assignment, and watchpoint stress coverage
JSTests/stress/define-property-same-value-keeps-adaptive-watchpoints.js, JSTests/stress/freeze-array-prototype.js, JSTests/stress/object-assign-frozen-object-prototype.js
Stress tests cover frozen arrays, frozen prototypes, Object.assign, accessor descriptors, adaptive watchpoints, and built-in operations.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to fc961

The reviewed freeze, assignment, and watchpoint changes have no identified actionable merge risk.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description clearly explains the bug, implementation changes, tests, and performance results. It omits the required Bugzilla bug title and link, review-status line, and path/function change list f… Add the associated Bugzilla bug title and URL, include the required review-status line, and list the changed paths with relevant functions or classes.
✅ Passed checks (3 passed)
Check name Status Explanation
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 and concisely describes the primary change: preserving fast paths when built-in prototypes are frozen.
Full details: Description check

Explanation

The description clearly explains the bug, implementation changes, tests, and performance results. It omits the required Bugzilla bug title and link, review-status line, and path/function change list from the repository template.

  • Fix all pre-merge checks with AI

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.


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

@claude claude 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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

throw new Error("FAIL: " + msg);
}

function warm(f, n = 1e4) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit (optional): New stress tests hard-code iteration counts (1e4/1e3) instead of using testLoopCount, which JSTests/README.md (imported by JSTests/CLAUDE.md) lists as a required rule so tests tier up only in configurations where it matters and stay under 200ms elsewhere. sweep:\b1e[34]\b in the three added JSTests/stress files. Fix: drive warm-up loops with testLoopCount (e.g. function warm(f, n = testLoopCount) here; for (let i = 0; i < testLoopCount; i++) in freeze-array-prototype.js:61 and object-assign-frozen-object-prototype.js:21/26/32/42/51). [also at: JSTests/stress/object-assign-frozen-object-prototype.js:20 - nit: new stress tests hard-code 1e4/1e3 iteration counts instead of testLoopCount; JSTests/README.md (imported by…]

Extended reasoning...

JSTests/CLAUDE.md imports JSTests/README.md, whose rule 2 states new tests are required to use testLoopCount/wasmTestLoopCount so the harness can scale iterations per configuration (eager tier-up vs. no-JIT vs. GC-heavy). All three new tests instead hard-code 1e4 (and 1e3) loops: define-property-same-value-keeps-adaptive-watchpoints.js:6/75, freeze-array-prototype.js:27/61/67, object-assign-frozen-object-prototype.js:21/26/32/42/51. In no-JIT or GC-stress configurations these loops run at fixed cost with no tier-up benefit, risking the 200ms budget, and in eager configurations they may over-iterate. Base branch has no such files; the diff introduces the violation.

Verification: nit — JSTests/README.md:20 (imported by JSTests/CLAUDE.md:1 via @ README.md) states as a hard rule for new tests: "Use testLoopCount or wasmTestLoopCount to control how many iterations a test runs. The jsc CLI sets these based on the configuration of the test, so tests iterate enough to tier up where that matters and exit early where it doesn't." All three new tests hard-code counts…

robobun added a commit that referenced this pull request Oct 1, 2026
…ArrayStorage vector

Object.preventExtensions, Object.seal, Object.freeze and a read-only
"length" all went through JSObject::enterDictionaryIndexingMode, which
moved every element of the object into the SparseArrayValueMap (sparse
mode, vector length 0). After that every a[i] read missed the JIT vector
fast paths and did a hash lookup in C++, and the freeze itself cost two
butterfly conversions, a map cell and one hash insert per element.

Now enterDictionaryIndexingMode keeps the elements in the vector and only
switches the object to the SlowPutArrayStorage shape. LLInt and the
baseline JIT never store into a SlowPutArrayStorage vector inline, and
DFG / FTL now check the structure before they do, so every write reaches
the C++ paths. The attributes of the elements live on the Structure:
vectorElementsAreNonConfigurable (Seal), vectorElementsAreReadOnly
(Freeze) and arrayLengthIsReadOnly (Freeze and the new
SetArrayLengthReadOnly transition, which replaces the LengthIsReadOnly
flag of the sparse map). Entries of the sparse map keep their own
attributes; elements only move into the map when a single index needs
attributes of its own (Object.defineProperty on an element), and they
carry the structure's attributes with them.

A non-extensible object therefore never has the plain ArrayStorage shape
(suggestedArrayStorageTransition), which is what lets the plain
ArrayStorage paths stay as they were. The C++ vector writers and readers
consult the bits: putByIndex, trySetIndexQuickly, putDirectIndex and the
beyond-vector-length paths, deletePropertyByIndex, defineOwnIndexedProperty,
getOwnPropertySlotByIndex, the prototype-chain put intercept,
JSArray::setLength / pop / push and Array.prototype.reverse.

An array with no elements (ArrayClass, such as Array.prototype) stays
blank: the bits and isStructureExtensible() cover the later writes, so
freezing Array.prototype keeps the array prototype chain watchpoint. A
frozen object tells the objects that inherit from it about its read-only
elements (notifyPresenceOfIndexedAccessors) when it becomes a prototype,
not when it is frozen, so a frozen array that is no prototype keeps the
builtin fast paths.

The DFG folds a read of a frozen element from the vector as it folded one
from the sparse map; JSObject::freeze fences the element stores before the
structure store. ArrayAllocationProfile never records the
SlowPutArrayStorage shape, so one frozen array does not make the arrays
its allocation site creates next SlowPutArrayStorage. The baseline indexed
load and "in" ICs accept the SlowPutArrayStorage shape, whose vector they
already read. Object.isSealed / isFrozen answer from the structure for an
object that JSObject::seal / freeze sealed or froze.

Object.seal / Object.freeze take the JSObject::seal / freeze path for an
exact JSArray and for a JSFinalObject with indexed properties instead of
the generic per-index SetIntegrityLevel loop (from #337,
which this supersedes); JSArray has no lazily reified own properties, so
materializeLazyOwnProperties skips it.

On the oven-sh/bun#44305 repro (300k arrays of 16 ints, x86_64 release
jsc): freeze 970 ms -> 35-47 ms, reads from the frozen arrays 9.5x plain
-> 1.0-1.2x plain.

Supersedes #337. The ArrayClass handling and
freeze-array-prototype.js come from #622.

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
robobun added a commit that referenced this pull request Oct 1, 2026
…ArrayStorage vector

Object.preventExtensions, Object.seal, Object.freeze and a read-only
"length" all went through JSObject::enterDictionaryIndexingMode, which
moved every element of the object into the SparseArrayValueMap (sparse
mode, vector length 0). After that every a[i] read missed the JIT vector
fast paths and did a hash lookup in C++, and the freeze itself cost two
butterfly conversions, a map cell and one hash insert per element.

Now enterDictionaryIndexingMode keeps the elements in the vector and only
switches the object to the SlowPutArrayStorage shape. LLInt and the
baseline JIT never store into a SlowPutArrayStorage vector inline, and
DFG / FTL now check the structure before they do, so every write reaches
the C++ paths. The attributes of the elements live on the Structure:
vectorElementsAreNonConfigurable (Seal), vectorElementsAreReadOnly
(Freeze) and arrayLengthIsReadOnly (Freeze and the new
SetArrayLengthReadOnly transition, which replaces the LengthIsReadOnly
flag of the sparse map). Entries of the sparse map keep their own
attributes; elements only move into the map when a single index needs
attributes of its own (Object.defineProperty on an element), and they
carry the structure's attributes with them.

A non-extensible object therefore never has the plain ArrayStorage shape
(suggestedArrayStorageTransition), which is what lets the plain
ArrayStorage paths stay as they were. The C++ vector writers and readers
consult the bits: putByIndex, trySetIndexQuickly, putDirectIndex and the
beyond-vector-length paths, deletePropertyByIndex, defineOwnIndexedProperty,
getOwnPropertySlotByIndex, the prototype-chain put intercept,
JSArray::setLength / pop / push and Array.prototype.reverse.

An array with no elements (ArrayClass, such as Array.prototype) stays
blank: the bits and isStructureExtensible() cover the later writes, so
freezing Array.prototype keeps the array prototype chain watchpoint. A
frozen object tells the objects that inherit from it about its read-only
elements (notifyPresenceOfIndexedAccessors) when it becomes a prototype,
not when it is frozen, so a frozen array that is no prototype keeps the
builtin fast paths.

The DFG folds a read of a frozen element from the vector as it folded one
from the sparse map; JSObject::freeze fences the element stores before the
structure store. ArrayAllocationProfile never records the
SlowPutArrayStorage shape, so one frozen array does not make the arrays
its allocation site creates next SlowPutArrayStorage. The baseline indexed
load and "in" ICs accept the SlowPutArrayStorage shape, whose vector they
already read. Object.isSealed / isFrozen answer from the structure for an
object that JSObject::seal / freeze sealed or froze.

Object.seal / Object.freeze take the JSObject::seal / freeze path for an
exact JSArray and for a JSFinalObject with indexed properties instead of
the generic per-index SetIntegrityLevel loop (from #337,
which this supersedes); JSArray has no lazily reified own properties, so
materializeLazyOwnProperties skips it.

On the oven-sh/bun#44305 repro (300k arrays of 16 ints, x86_64 release
jsc): freeze 970 ms -> 35-47 ms, reads from the frozen arrays 9.5x plain
-> 1.0-1.2x plain.

Supersedes #337. The ArrayClass handling and
freeze-array-prototype.js come from #622.

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
robobun added a commit that referenced this pull request Oct 1, 2026
…ArrayStorage vector

Object.preventExtensions, Object.seal, Object.freeze and a read-only
"length" all went through JSObject::enterDictionaryIndexingMode, which
moved every element of the object into the SparseArrayValueMap (sparse
mode, vector length 0). After that every a[i] read missed the JIT vector
fast paths and did a hash lookup in C++, and the freeze itself cost two
butterfly conversions, a map cell and one hash insert per element.

Now enterDictionaryIndexingMode keeps the elements in the vector and only
switches the object to the SlowPutArrayStorage shape. LLInt and the
baseline JIT never store into a SlowPutArrayStorage vector inline, and
DFG / FTL now check the structure before they do, so every write reaches
the C++ paths. The attributes of the elements live on the Structure:
vectorElementsAreNonConfigurable (Seal), vectorElementsAreReadOnly
(Freeze) and arrayLengthIsReadOnly (Freeze and the new
SetArrayLengthReadOnly transition, which replaces the LengthIsReadOnly
flag of the sparse map). Entries of the sparse map keep their own
attributes; elements only move into the map when a single index needs
attributes of its own (Object.defineProperty on an element), and they
carry the structure's attributes with them.

A non-extensible object therefore never has the plain ArrayStorage shape
(suggestedArrayStorageTransition), which is what lets the plain
ArrayStorage paths stay as they were. The C++ vector writers and readers
consult the bits: putByIndex, trySetIndexQuickly, putDirectIndex and the
beyond-vector-length paths, deletePropertyByIndex, defineOwnIndexedProperty,
getOwnPropertySlotByIndex, the prototype-chain put intercept,
JSArray::setLength / pop / push and Array.prototype.reverse.

An array with no elements (ArrayClass, such as Array.prototype) stays
blank: the bits and isStructureExtensible() cover the later writes, so
freezing Array.prototype keeps the array prototype chain watchpoint. A
frozen object tells the objects that inherit from it about its read-only
elements (notifyPresenceOfIndexedAccessors) when it becomes a prototype,
not when it is frozen, so a frozen array that is no prototype keeps the
builtin fast paths.

The DFG folds a read of a frozen element from the vector as it folded one
from the sparse map; JSObject::freeze fences the element stores before the
structure store. ArrayAllocationProfile never records the
SlowPutArrayStorage shape, so one frozen array does not make the arrays
its allocation site creates next SlowPutArrayStorage. The baseline indexed
load and "in" ICs accept the SlowPutArrayStorage shape, whose vector they
already read. Object.isSealed / isFrozen answer from the structure for an
object that JSObject::seal / freeze sealed or froze.

Object.seal / Object.freeze take the JSObject::seal / freeze path for an
exact JSArray and for a JSFinalObject with indexed properties instead of
the generic per-index SetIntegrityLevel loop (from #337,
which this supersedes); JSArray has no lazily reified own properties, so
materializeLazyOwnProperties skips it.

On the oven-sh/bun#44305 repro (300k arrays of 16 ints, x86_64 release
jsc): freeze 970 ms -> 35-47 ms, reads from the frozen arrays 9.5x plain
-> 1.0-1.2x plain.

Supersedes #337. The ArrayClass handling and
freeze-array-prototype.js come from #622.

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
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