Title: Property store to object literals with an accessor is 7x slower since 26.4.0 when created after `Object.defineProperty(fn, "length", ...)` (bisected to 3e55527eef, PR #63068) · Issue #66011 · nodejs/node · GitHub
Open Graph Title: Property store to object literals with an accessor is 7x slower since 26.4.0 when created after `Object.defineProperty(fn, "length", ...)` (bisected to 3e55527eef, PR #63068) · Issue #66011 · nodejs/node
X Title: Property store to object literals with an accessor is 7x slower since 26.4.0 when created after `Object.defineProperty(fn, "length", ...)` (bisected to 3e55527eef, PR #63068) · Issue #66011 · nodejs/node
Description: Version v26.4.0 through v26.8.2, and main nightlies from v27.0.0-nightly20260617b09155d975 onward. Not present in v26.3.1, v26.0.0 to v26.2.0, v25.9.0, v24.16.0 to v24.21.0. Platform Darwin 25.6.0 arm64 (macOS 26.6.2), Apple M4 Max, 16 c...
Open Graph Description: Version v26.4.0 through v26.8.2, and main nightlies from v27.0.0-nightly20260617b09155d975 onward. Not present in v26.3.1, v26.0.0 to v26.2.0, v25.9.0, v24.16.0 to v24.21.0. Platform Darwin 25.6.0 ...
X Description: Version v26.4.0 through v26.8.2, and main nightlies from v27.0.0-nightly20260617b09155d975 onward. Not present in v26.3.1, v26.0.0 to v26.2.0, v25.9.0, v24.16.0 to v24.21.0. Platform Darwin 25.6.0 ...
Opengraph URL: https://github.com/nodejs/node/issues/66011
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"Property store to object literals with an accessor is 7x slower since 26.4.0 when created after `Object.defineProperty(fn, \"length\", ...)` (bisected to 3e55527eef, PR #63068)","articleBody":"### Version\n\nv26.4.0 through v26.8.2, and `main` nightlies from v27.0.0-nightly20260617b09155d975 onward. Not present in v26.3.1, v26.0.0 to v26.2.0, v25.9.0, v24.16.0 to v24.21.0.\n\n### Platform\n\n```\nDarwin 25.6.0 arm64 (macOS 26.6.2), Apple M4 Max, 16 cores, 64 GB\n```\n\nThe downstream effect (a Vitest suite doubling in wall time) was first seen on Linux x64 CI (Node 26.8.1) and then reproduced on this machine; all numbers below are from this machine.\n\n### Subsystem\n\nv8 (`NormalizedMapCache`, `deps/v8/src/objects/map.cc`). Triggered by two per-isolate symbols added in `src/env_properties.h` by the `src/ffi` commit; see \"Proposed fix\" below.\n\n### What steps will reproduce the bug?\n\nSave as `repro.mjs` and run `node repro.mjs`. No dependencies.\n\n```js\n// K items, each: an object literal with an accessor property, created right after a function whose own\n// \"length\" was redefined with Object.defineProperty (the function itself is not retained).\n// Then W passes that assign a fresh empty array into each object. Reports ns per item per pass.\nconst K = +(process.argv[2] ?? 25000), W = +(process.argv[3] ?? 200);\nconst states = [];\nfor (let i = 0; i \u003c K; i++) {\n const f = function () {};\n Object.defineProperty(f, \"length\", { value: 0, writable: true, configurable: true });\n states.push({ calls: [], get lastCall() { return this.calls.at(-1); } });\n}\nfunction clearAll() { for (const s of states) s.calls = []; }\nfor (let i = 0; i \u003c 20; i++) clearAll();\nconst t0 = performance.now();\nfor (let w = 0; w \u003c W; w++) clearAll();\nconst ms = performance.now() - t0;\nconsole.log(`${process.version} v8 ${process.versions.v8} K=${K} W=${W}: ${(ms / W).toFixed(2)} ms per pass, ns=${(ms * 1e6 / W / K).toFixed(0)} per item`);\n```\n\nBoth parts are needed. Any one of these changes removes the difference between versions (numbers in \"Additional information\"):\n\n- drop the `Object.defineProperty(f, \"length\", ...)` line, or define a fresh property (`\"foo\"`) instead;\n- replace the getter with a method, or with an accessor whose getter is one shared function;\n- create the functions in a separate loop before or after the objects, instead of interleaved per item.\n\nAny of these keeps the difference: redefining `\"name\"` instead of `\"length\"`; `delete f.length` instead of `defineProperty`; a constant getter; the accessor added via `Object.defineProperty(state, \"lastCall\", { get() {...} })`; retaining or not retaining the functions.\n\n### How often does it reproduce? Is there a required condition?\n\nEvery run. Only the interleaving described above is required.\n\n### What is the expected behavior? Why is that the expected behavior?\n\nThe per-item cost of `s.calls = []` should be the same as in v26.3.1, which ships the same V8 (14.6.202.34), and should be independent of a `defineProperty` call on an unrelated function object that is not retained.\n\n### What do you see instead?\n\nMedian of 5 runs, `node repro.mjs` (K=25000, W=200), ns per item:\n\n| Binary | V8 | ns per item |\n|---|---|---|\n| v26.3.1 (release) | 14.6.202.34-node.20 | 7 |\n| v26.4.0 (release) | 14.6.202.34-node.21 | 48 |\n| v27.0.0-nightly20260616 (main `6e8c1717e2`) | 14.6.202.34-node.21 | 7 |\n| v27.0.0-nightly20260617 (main `b09155d975`) | 14.6.202.34-node.21 | 49 |\n| source build at `81c4f0a22e` (parent of the FFI commit) | 14.6.202.34-node.21 | 7 |\n| source build at `3e55527eef` (ffi: add experimental fast FFI call API) | 14.6.202.34-node.21 | 49 |\n\nAll installed releases (K=25000, W=200, one run each): v24.16.0 10, v24.19.0 11, v24.20.0 11, v24.21.0 11, v25.9.0 10, v26.0.0 7, v26.1.0 6, v26.2.0 7, v26.3.1 7, v26.4.0 51, v26.5.1 51, v26.6.0 50, v26.7.0 49, v26.8.1 48, v26.8.2 52, v27.0.0-nightly20260624 50.\n\nScaling with K (W=100): v26.3.1 stays at 7 to 8 ns for K = 2,000 / 10,000 / 50,000 / 100,000; v26.4.0 is 30 / 48 / 52 / 58.\n\n### Additional information\n\n**Bisection.** Official darwin-arm64 nightlies: 2026-06-16 (`6e8c1717e2`) fast, 2026-06-17 (`b09155d975`) slow. Seven commits landed between them; `--print-flag-values` is identical on both (967 lines, no diff) and both carry V8 14.6.202.34-node.21. Building `main` at `3e55527eef27f4c16f3c4d9098b57869eec5ce6f` (\"ffi: add experimental fast FFI call API for AArch64 and x86_64\", PR #63068, in v26.4.0 as `f52cf5eeaa`) and at its parent `81c4f0a22e` (`./configure --ninja`, default options, this machine) gives 49 vs 7 ns on the script above. The script never loads `node:ffi`.\n\n**Where the time goes.** `sample(1)` on the two source builds during the timed loop, main thread, top-of-stack frames in `node`:\n\n- parent `81c4f0a22e`: `Builtins_StoreIC` 193, `Builtins_RecordWriteSaveFP` 157, then scavenger frames under 10 each.\n- `3e55527eef`: `LookupIterator::PrepareForDataProperty` 99, `StoreIC::Store` 89, `Runtime_StoreIC_Miss` 79, `Object::SetPropertyInternal` 77, `StoreIC::LookupForWrite` 75, `IC::SetCache` 74, `JSObject::MakePrototypesFast` 72, `Builtins_StoreIC_Megamorphic` 70, `StubCache::Set` 69.\n\n`--log-ic` for 25 passes over 25,000 objects (625,000 stores at the `s.calls = []` site): on the parent build 1 StoreIC event names `calls` (the initial `0 -> 1` transition; 695 events in total, the rest from startup); on `3e55527eef` 624,291 events name `calls`, of which 624,286 are `N -> N` (megamorphic, stays megamorphic), i.e. one runtime miss per store.\n\n`--prof` (W=1000): v26.3.1 157 ticks total (JavaScript 21 %, C++ 79 %, GC 3 %); v26.4.0 977 ticks (JavaScript 3 %, C++ 96 %, GC 1 %). The extra ticks are in `node` native code called from `clearAll`.\n\n**Hidden classes.** With `--allow-natives-syntax`, after the setup loop:\n\n| | fast builds (26.3.1, parent) | slow builds (26.4.0, `3e55527eef`) |\n|---|---|---|\n| `%HaveSameMap(states[0], states[i])` true for | 25,000 of 25,000 | 1 of 25,000 |\n| distinct maps among the first 1,000 objects | 1 | 1,000 |\n| `%HaveSameMap` across three of the `length`-redefined functions | true | false |\n| `%HasFastProperties(states[0])` | false | false |\n| same script without the `defineProperty` line (control) | 25,000 of 25,000 | 25,000 of 25,000 |\n\n**Heap statistics** (`--expose-gc`, two `gc()` calls after setup, then `v8.getHeapSpaceStatistics()`):\n\n| | old_space used, K=25,000 | old_space used, K=100,000 |\n|---|---|---|\n| v26.3.1 repro | 12.08 MB | 40.12 MB |\n| v26.4.0 repro | 13.83 MB | 47.02 MB |\n| v26.3.1 control (no `defineProperty`) | 12.08 MB | 40.12 MB |\n| v26.4.0 control | 12.12 MB | 40.15 MB |\n\nThe difference is about 72 bytes per item, present only on the slow builds and only with the `defineProperty` line. new_space, code_space, large_object_space, global handles, external and malloced memory do not differ between the builds. The parent / `3e55527eef` source builds give the same figures as 26.3.1 / 26.4.0.\n\n**GC** (`--trace-gc-nvp`, whole process, K=25000 W=200): v26.3.1 20 scavenges + 1 mark-compact, total pause 4.2 ms, promoted 10.27 MB, mutator 59.8 ms; v26.4.0 21 scavenges + 1 mark-compact, total pause 4.1 ms, promoted 10.29 MB, mutator 287.9 ms. Parent build 20 + 1, 4.9 ms, 10.28 MB, 56.8 ms; `3e55527eef` 22 + 1, 4.4 ms, 10.29 MB, 283.9 ms. GC work is the same; the extra time is mutator time. (On the original, larger reproduction with the real `@vitest/spy` code the GC pattern did differ, 14 scavenges + 4 mark-compacts vs 50 + 2, which is why this report first looked like a GC change; the minimal script shows it is not.)\n\n**Flags tried** (ns per item, three runs, v26.3.1 / v26.4.0). None removes the difference:\n\n| flags | v26.3.1 | v26.4.0 |\n|---|---|---|\n| (none) | 6,6,6 | 48,50,49 |\n| `--no-opt` | 10,10,11 | 53,53,53 |\n| `--max-lazy` | 6,7,7 | 50,50,53 |\n| `--single-threaded-gc` | 7,7,7 | 51,51,47 |\n| `--no-concurrent-marking` | 6,6,6 | 48,48,47 |\n| `--no-allocation-site-pretenuring` | 6,7,6 | 47,47,51 |\n| `--max-semi-space-size=64` | 7,6,6 | 48,48,49 |\n| `--max-semi-space-size=1` | 20,20,19 | 49,50,51 |\n| `--no-sparkplug --no-maglev` | 10,11,11 | 55,51,53 |\n| `--jitless` | 24,24,24 | 67,69,69 |\n| `--no-lazy-feedback-allocation` | 7,7,6 | 49,49,48 |\n| `--single-threaded` | 9,9,9 | 50,53,51 |\n| `--predictable` | 9,9,9 | 52,51,51 |\n| `--minor-ms` | 12,12,14 | 60,61,59 |\n| `--no-parallel-scavenge` | 7,7,7 | 52,50,49 |\n| `--no-concurrent-sweeping` | 6,7,6 | 51,48,47 |\n\n**Root cause (found after the numbers above; details and a proven fix in \"Proposed fix\" at the end).** The extra maps come from V8's 64-entry direct-mapped `NormalizedMapCache`: the normalized map of the `length`-redefined function and the normalized map of the accessor literal hash to the same slot in the 26.4.0 snapshot (`Map::Hash` = identity hash of the prototype ^ `bit_field2`, and the identity hashes of `Function.prototype` / `Object.prototype` are fixed by the snapshot). The two new per-isolate symbols in `3e55527eef` shift those identity hashes; removing them restores 7 ns. The same-prototype case (function with `delete f.length` interleaved with `Object.create(Function.prototype)` + `delete o.a`) thrashes on every Node version, so the defect is in V8; a 2-way LRU cache fixes both.\n\n**Reduction from the real-world case.** The script is reduced from `@vitest/spy` 4.1.11: `createMockInstance` calls `Object.defineProperty(mock, \"length\", ...)` on every `vi.fn()` and `getDefaultState()` creates `{ calls: [], ..., get lastCall() {...} }` for it; `clearAllMocks()` assigns six fresh arrays into each state. Median ns per mock, three runs, fast pairs first (26.3.1 / 0616 nightly / parent) then slow (26.4.0 / 0617 nightly / `3e55527eef`):\n\n| step | 26.3.1 | 0616 | parent | 26.4.0 | 0617 | ffi | regression |\n|---|---|---|---|---|---|---|---|\n| verbatim `@vitest/spy` source inlined, 25,000 mocks, `clearAllMocks()` | 339 | 153 | 100 | 946 | 553 | 623 | yes |\n| minus all mock.* methods except `mockClear` | 68 | 95 | 104 | 382 | 436 | 444 | yes |\n| minus the `WeakMap` | 94 | 62 | 69 | 414 | 391 | 423 | yes |\n| minus both `defineProperty` calls (`length`, `mock`) | 39 | 38 | 42 | 42 | 39 | 37 | no |\n| only the `mock` defineProperty kept | 39 | 43 | 42 | 68 | 41 | 42 | no |\n| only the `length` defineProperty kept | 183 | 126 | 165 | 454 | 480 | 633 | yes |\n| small recording function + `length` defineProperty + state with getter | 35 | 34 | 51 | 342 | 410 | 409 | yes |\n| same, getter removed from state | 33 | 33 | 32 | 32 | 32 | 33 | no |\n| getter kept; no per-pass call, `for..of`, one shared `mockClear` | 33 | 32 | 32 | 396 | 393 | 413 | yes |\n| same, one array instead of six | 15 | 14 | 14 | 153 | 153 | 188 | yes |\n| control: getter returns a constant | 35 | 33 | 34 | 402 | 394 | 396 | yes |\n| control: method `lastCall()` instead of getter | 30 | 31 | 30 | 31 | 31 | 32 | no |\n| control: accessor via `defineProperty(state, \"lastCall\", { get() {} })` | 38 | 38 | 37 | 400 | 400 | 400 | yes |\n| control: accessor with one shared getter function | 4 | 4 | 4 | 4 | 4 | 4 | no |\n| control: `defineProperty(fn, \"foo\")` instead of `\"length\"` | 30 | 30 | 30 | 31 | 31 | 30 | no |\n| control: `defineProperty(fn, \"name\")` | 34 | 34 | 32 | 401 | 466 | 407 | yes |\n| control: `delete fn.length` | 33 | 31 | 32 | 395 | 392 | 401 | yes |\n| control: plain object instead of function, `length` defined on it | 43 | 44 | 42 | 45 | 45 | 42 | no |\n| states in an array; functions only held in a `Set`, never touched in the walk | 25 | 24 | 24 | 288 | 285 | 288 | yes |\n| function body does not reference state; retained | 7 | 7 | 7 | 49 | 49 | 49 | yes |\n| function not retained at all (final script above) | 7 | 7 | 7 | 48 | 49 | 49 | yes |\n| control: no accessor on state | 4 | 4 | 4 | 4 | 4 | 4 | no |\n| control: all functions created in a loop before the objects | 6 | 6 | 7 | 6 | 6 | 7 | no |\n| control: all objects created, then all functions | 6 | 6 | 6 | 6 | 6 | 6 | no |\n| function created after the object in the same iteration | 6 | 6 | 6 | 49 | 49 | 48 | yes |\n| control: dictionary-mode plain object (`delete o.a`) instead of the function | 6 | 7 | 6 | 7 | 7 | 6 | no |\n\n**Real-world impact.** Vitest with `isolate: false` and `clearMocks: true` runs `clearAllMocks()` before every test over every mock created so far in the worker, and `vi.fn()` does exactly the two things above for every mock. A large Angular test suite (about 175 spec files and 2,600 tests in one worker, roughly 325,000 `vi.fn()` mocks by the last file) runs about 15% slower on v26.3.1 than on v24.21.0 and about 1.5 times slower again on v26.4.0 and v26.8.1 on this machine with a fixed file order; on Linux CI the same suite went from 7 to 10 minutes to about 20 when the runtime moved to v26.8.1. The Vitest side is vitest-dev/vitest#9492, fixed in Vitest 5 by vitest-dev/vitest#11092 (the registry stops rescanning cleared mocks), which reduces the exposure but not the per-store cost shown above. Any code that redefines `length` or `name` on functions and then creates objects with accessors in the same loop is affected.\n\nBinaries used: official darwin-arm64 release and nightly tarballs; source builds from git checkouts of `81c4f0a22e` and `3e55527eef` with `./configure --ninja && ninja -C out/Release node`, default options.\n\n\n---\n\n### Cause and fix\n\nThe cause is in V8, exposed by the commit rather than introduced by it. `NormalizedMapCache` (`deps/v8/src/objects/map.cc`) is a 64-entry direct-mapped cache indexed by `Map::Hash()`, which hashes only the prototype's identity hash and `bit_field2`. Two normalization sites whose maps hash to the same slot but are not `EquivalentToForNormalization` evict each other on every `Map::Normalize`, so every object created at either site gets its own dictionary map and every store into it misses. The two per-isolate symbols the commit adds to `src/env_properties.h` each draw one value from the isolate's random generator while the startup snapshot is built, which shifts the identity hashes later assigned to `Object.prototype` and `Function.prototype` and baked into the snapshot; in the 26.4.0 snapshot the two collide modulo 64. Rebuilding the commit with only those two lines removed restores 8 ns per item and shared maps; `--no-node-snapshot` removes the slowdown on 26.4.0 and makes it appear at random on 26.3.1. Removing the symbols is not a fix: it only moves the hashes so that another pair of prototypes collides.\n\nThe fix is one line in V8: include the map's instance type in `Map::Hash`, so that a function and a plain object never share a cache entry whatever their prototypes hash to. Tracked as https://issues.chromium.org/issues/560730175, change https://chromium-review.googlesource.com/c/v8/v8/+/8397811. With it, on a source build of v26.8.2, the script above runs at 6 to 8 ns per item with all 25,000 objects on one map, the `@vitest/spy` walk at 93 to 107 ns per mock against 416 to 453 on the release, and a 174-file Vitest suite in fixed order in 4 min 31 s against 7 min 36 s; `parallel` and `sequential` pass 4,697 of 4,697. Since v26.x carries V8 14.6 for its lifetime, the fix reaches 26.x only as a floated V8 patch under `deps/v8`; once the change lands upstream, or before, a `deps: V8: cherry-pick` of it would be the way to fix 26.x.\n","author":{"url":"https://github.com/sdjayna","@type":"Person","name":"sdjayna"},"datePublished":"2026-09-13T06:44:23Z","url":"https://github.com/nodejs/node/issues/66011"}
| react-profiling | 0 |
| react-import-map | react,react-dom,react-dom/client,react-dom/profiling,react-is,react-reconciler,react/compiler-runtime,react/jsx-dev-runtime,react/jsx-runtime,scheduler@d5750093facf |
| route-pattern | /_view_fragments/issues/show/:user_id/:repository/:id/issue_layout(.:format) |
| route-controller | voltron_issues_fragments |
| route-action | issue_layout |
| fetch-nonce | v2:b01730eb-d4de-a2e3-1785-2932cb0285da |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | B85A:33C72F:DBA03A:11E4D61:6AED5363 |
| html-safe-nonce | d184e67ca8c98651c6ba0e5e23cfacd06d0199ab4735cc28cfc05e60f908f3ef |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJCODVBOjMzQzcyRjpEQkEwM0E6MTFFNEQ2MTo2QUVENTM2MyIsInZpc2l0b3JfaWQiOiI0NzYwMTY0NTQyODgyNTk5Nzc5IiwicmVnaW9uX2VkZ2UiOiJpYWQiLCJyZWdpb25fcmVuZGVyIjoiaWFkIn0= |
| visitor-hmac | eac777840102e4eef0d032bdfab1df9971226acd0ac02201c22c4dcb474edc41 |
| hovercard-subject-tag | issue:5438572369 |
| github-keyboard-shortcuts | repository,issues,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/_view_fragments/issues/show/nodejs/node/66011/issue_layout |
| twitter:image | https://opengraph.githubassets.com/44fe7a19bd37f4a1aafcc07b3cf05634a6ae4063cdebae2b9d95fd295e056be9/nodejs/node/issues/66011 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/44fe7a19bd37f4a1aafcc07b3cf05634a6ae4063cdebae2b9d95fd295e056be9/nodejs/node/issues/66011 |
| og:image:alt | Version v26.4.0 through v26.8.2, and main nightlies from v27.0.0-nightly20260617b09155d975 onward. Not present in v26.3.1, v26.0.0 to v26.2.0, v25.9.0, v24.16.0 to v24.21.0. Platform Darwin 25.6.0 ... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | sdjayna |
| hostname | github.com |
| expected-hostname | github.com |
| None | 56fb42677ae87ed3e64410323d67980aa65d0485568a7858f874e7edfc6d1c11 |
| turbo-cache-control | no-preview |
| go-import | github.com/nodejs/node git https://github.com/nodejs/node.git |
| octolytics-dimension-user_id | 9950313 |
| octolytics-dimension-user_login | nodejs |
| octolytics-dimension-repository_id | 27193779 |
| octolytics-dimension-repository_nwo | nodejs/node |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 27193779 |
| octolytics-dimension-repository_network_root_nwo | nodejs/node |
| turbo-body-classes | logged-out env-production page-responsive |
| disable-turbo | false |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | 9c9a37e2423ed382cf39f70b5aa4b4858b40a3b0 |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
| react-app-name | issues |
Links:
Viewport: width=device-width