Skip to content

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) #66011

Description

@sdjayna

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 cores, 64 GB

The 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.

Subsystem

v8 (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.

What steps will reproduce the bug?

Save as repro.mjs and run node repro.mjs. No dependencies.

// K items, each: an object literal with an accessor property, created right after a function whose own
// "length" was redefined with Object.defineProperty (the function itself is not retained).
// Then W passes that assign a fresh empty array into each object. Reports ns per item per pass.
const K = +(process.argv[2] ?? 25000), W = +(process.argv[3] ?? 200);
const states = [];
for (let i = 0; i < K; i++) {
  const f = function () {};
  Object.defineProperty(f, "length", { value: 0, writable: true, configurable: true });
  states.push({ calls: [], get lastCall() { return this.calls.at(-1); } });
}
function clearAll() { for (const s of states) s.calls = []; }
for (let i = 0; i < 20; i++) clearAll();
const t0 = performance.now();
for (let w = 0; w < W; w++) clearAll();
const ms = performance.now() - t0;
console.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`);

Both parts are needed. Any one of these changes removes the difference between versions (numbers in "Additional information"):

  • drop the Object.defineProperty(f, "length", ...) line, or define a fresh property ("foo") instead;
  • replace the getter with a method, or with an accessor whose getter is one shared function;
  • create the functions in a separate loop before or after the objects, instead of interleaved per item.

Any 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.

How often does it reproduce? Is there a required condition?

Every run. Only the interleaving described above is required.

What is the expected behavior? Why is that the expected behavior?

The 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.

What do you see instead?

Median of 5 runs, node repro.mjs (K=25000, W=200), ns per item:

Binary V8 ns per item
v26.3.1 (release) 14.6.202.34-node.20 7
v26.4.0 (release) 14.6.202.34-node.21 48
v27.0.0-nightly20260616 (main 6e8c1717e2) 14.6.202.34-node.21 7
v27.0.0-nightly20260617 (main b09155d975) 14.6.202.34-node.21 49
source build at 81c4f0a22e (parent of the FFI commit) 14.6.202.34-node.21 7
source build at 3e55527eef (ffi: add experimental fast FFI call API) 14.6.202.34-node.21 49

All 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.

Scaling 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.

Additional information

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.

Where the time goes. sample(1) on the two source builds during the timed loop, main thread, top-of-stack frames in node:

  • parent 81c4f0a22e: Builtins_StoreIC 193, Builtins_RecordWriteSaveFP 157, then scavenger frames under 10 each.
  • 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.

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

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

Hidden classes. With --allow-natives-syntax, after the setup loop:

fast builds (26.3.1, parent) slow builds (26.4.0, 3e55527eef)
%HaveSameMap(states[0], states[i]) true for 25,000 of 25,000 1 of 25,000
distinct maps among the first 1,000 objects 1 1,000
%HaveSameMap across three of the length-redefined functions true false
%HasFastProperties(states[0]) false false
same script without the defineProperty line (control) 25,000 of 25,000 25,000 of 25,000

Heap statistics (--expose-gc, two gc() calls after setup, then v8.getHeapSpaceStatistics()):

old_space used, K=25,000 old_space used, K=100,000
v26.3.1 repro 12.08 MB 40.12 MB
v26.4.0 repro 13.83 MB 47.02 MB
v26.3.1 control (no defineProperty) 12.08 MB 40.12 MB
v26.4.0 control 12.12 MB 40.15 MB

The 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.

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.)

Flags tried (ns per item, three runs, v26.3.1 / v26.4.0). None removes the difference:

flags v26.3.1 v26.4.0
(none) 6,6,6 48,50,49
--no-opt 10,10,11 53,53,53
--max-lazy 6,7,7 50,50,53
--single-threaded-gc 7,7,7 51,51,47
--no-concurrent-marking 6,6,6 48,48,47
--no-allocation-site-pretenuring 6,7,6 47,47,51
--max-semi-space-size=64 7,6,6 48,48,49
--max-semi-space-size=1 20,20,19 49,50,51
--no-sparkplug --no-maglev 10,11,11 55,51,53
--jitless 24,24,24 67,69,69
--no-lazy-feedback-allocation 7,7,6 49,49,48
--single-threaded 9,9,9 50,53,51
--predictable 9,9,9 52,51,51
--minor-ms 12,12,14 60,61,59
--no-parallel-scavenge 7,7,7 52,50,49
--no-concurrent-sweeping 6,7,6 51,48,47

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.

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):

step 26.3.1 0616 parent 26.4.0 0617 ffi regression
verbatim @vitest/spy source inlined, 25,000 mocks, clearAllMocks() 339 153 100 946 553 623 yes
minus all mock.* methods except mockClear 68 95 104 382 436 444 yes
minus the WeakMap 94 62 69 414 391 423 yes
minus both defineProperty calls (length, mock) 39 38 42 42 39 37 no
only the mock defineProperty kept 39 43 42 68 41 42 no
only the length defineProperty kept 183 126 165 454 480 633 yes
small recording function + length defineProperty + state with getter 35 34 51 342 410 409 yes
same, getter removed from state 33 33 32 32 32 33 no
getter kept; no per-pass call, for..of, one shared mockClear 33 32 32 396 393 413 yes
same, one array instead of six 15 14 14 153 153 188 yes
control: getter returns a constant 35 33 34 402 394 396 yes
control: method lastCall() instead of getter 30 31 30 31 31 32 no
control: accessor via defineProperty(state, "lastCall", { get() {} }) 38 38 37 400 400 400 yes
control: accessor with one shared getter function 4 4 4 4 4 4 no
control: defineProperty(fn, "foo") instead of "length" 30 30 30 31 31 30 no
control: defineProperty(fn, "name") 34 34 32 401 466 407 yes
control: delete fn.length 33 31 32 395 392 401 yes
control: plain object instead of function, length defined on it 43 44 42 45 45 42 no
states in an array; functions only held in a Set, never touched in the walk 25 24 24 288 285 288 yes
function body does not reference state; retained 7 7 7 49 49 49 yes
function not retained at all (final script above) 7 7 7 48 49 49 yes
control: no accessor on state 4 4 4 4 4 4 no
control: all functions created in a loop before the objects 6 6 7 6 6 7 no
control: all objects created, then all functions 6 6 6 6 6 6 no
function created after the object in the same iteration 6 6 6 49 49 48 yes
control: dictionary-mode plain object (delete o.a) instead of the function 6 7 6 7 7 6 no

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.

Binaries 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.


Cause and fix

The 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.

The 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions