Repository navigation
[JSC] Freezing built-in prototypes should not permanently disable fast paths - #622
Jarred-Sumner wants to merge 1 commit into
Conversation
…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.
Preview Builds
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (14)
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. WalkthroughThe changes add JavaScriptCore support for frozen dense arrays, prototype-aware ChangesFreeze and assignment behavior
Priority: ⬇️ Low Merge Risk: ⚪ Minimal · up to The reviewed freeze, assignment, and watchpoint changes have no identified actionable merge risk. 🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
Full details: Description checkExplanation 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.
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 Comment |
| throw new Error("FAIL: " + msg); | ||
| } | ||
|
|
||
| function warm(f, n = 1e4) { |
There was a problem hiding this comment.
🟡 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…
…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>
…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>
…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>
Freezing built-in prototypes (
Object.freezeon 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 existingGetterSetterwhen 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.didFreeze, makeslengthread-only. This keeps the array prototype chain "sane" watchpoint valid.Verification
define-property-same-value-keeps-adaptive-watchpoints.js,object-assign-frozen-object-prototype.js,freeze-array-prototype.js.JSTests/stressfiles, 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.str.replace244→0 ms, hole reads 110→12 ms,slice131→16 ms, speciesmap30→4 ms, promisethenchains 28→12 ms,Object.assign72→41 ms (unfrozen: 29 ms).