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.
Version
v26.4.0 through v26.8.2, and
mainnightlies 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
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 insrc/env_properties.hby thesrc/fficommit; see "Proposed fix" below.What steps will reproduce the bug?
Save as
repro.mjsand runnode repro.mjs. No dependencies.Both parts are needed. Any one of these changes removes the difference between versions (numbers in "Additional information"):
Object.defineProperty(f, "length", ...)line, or define a fresh property ("foo") instead;Any of these keeps the difference: redefining
"name"instead of"length";delete f.lengthinstead ofdefineProperty; a constant getter; the accessor added viaObject.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 adefinePropertycall 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:6e8c1717e2)b09155d975)81c4f0a22e(parent of the FFI commit)3e55527eef(ffi: add experimental fast FFI call API)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-valuesis identical on both (967 lines, no diff) and both carry V8 14.6.202.34-node.21. Buildingmainat3e55527eef27f4c16f3c4d9098b57869eec5ce6f("ffi: add experimental fast FFI call API for AArch64 and x86_64", PR #63068, in v26.4.0 asf52cf5eeaa) and at its parent81c4f0a22e(./configure --ninja, default options, this machine) gives 49 vs 7 ns on the script above. The script never loadsnode:ffi.Where the time goes.
sample(1)on the two source builds during the timed loop, main thread, top-of-stack frames innode:81c4f0a22e:Builtins_StoreIC193,Builtins_RecordWriteSaveFP157, then scavenger frames under 10 each.3e55527eef:LookupIterator::PrepareForDataProperty99,StoreIC::Store89,Runtime_StoreIC_Miss79,Object::SetPropertyInternal77,StoreIC::LookupForWrite75,IC::SetCache74,JSObject::MakePrototypesFast72,Builtins_StoreIC_Megamorphic70,StubCache::Set69.--log-icfor 25 passes over 25,000 objects (625,000 stores at thes.calls = []site): on the parent build 1 StoreIC event namescalls(the initial0 -> 1transition; 695 events in total, the rest from startup); on3e55527eef624,291 events namecalls, of which 624,286 areN -> 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 innodenative code called fromclearAll.Hidden classes. With
--allow-natives-syntax, after the setup loop:3e55527eef)%HaveSameMap(states[0], states[i])true for%HaveSameMapacross three of thelength-redefined functions%HasFastProperties(states[0])definePropertyline (control)Heap statistics (
--expose-gc, twogc()calls after setup, thenv8.getHeapSpaceStatistics()):defineProperty)The difference is about 72 bytes per item, present only on the slow builds and only with the
definePropertyline. new_space, code_space, large_object_space, global handles, external and malloced memory do not differ between the builds. The parent /3e55527eefsource 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;3e55527eef22 + 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/spycode 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:
--no-opt--max-lazy--single-threaded-gc--no-concurrent-marking--no-allocation-site-pretenuring--max-semi-space-size=64--max-semi-space-size=1--no-sparkplug --no-maglev--jitless--no-lazy-feedback-allocation--single-threaded--predictable--minor-ms--no-parallel-scavenge--no-concurrent-sweepingRoot 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 thelength-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 ofFunction.prototype/Object.prototypeare fixed by the snapshot). The two new per-isolate symbols in3e55527eefshift those identity hashes; removing them restores 7 ns. The same-prototype case (function withdelete f.lengthinterleaved withObject.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/spy4.1.11:createMockInstancecallsObject.defineProperty(mock, "length", ...)on everyvi.fn()andgetDefaultState()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):@vitest/spysource inlined, 25,000 mocks,clearAllMocks()mockClearWeakMapdefinePropertycalls (length,mock)mockdefineProperty keptlengthdefineProperty keptlengthdefineProperty + state with getterfor..of, one sharedmockClearlastCall()instead of getterdefineProperty(state, "lastCall", { get() {} })defineProperty(fn, "foo")instead of"length"defineProperty(fn, "name")delete fn.lengthlengthdefined on itSet, never touched in the walkdelete o.a) instead of the functionReal-world impact. Vitest with
isolate: falseandclearMocks: truerunsclearAllMocks()before every test over every mock created so far in the worker, andvi.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,000vi.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 redefineslengthornameon 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
81c4f0a22eand3e55527eefwith./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 byMap::Hash(), which hashes only the prototype's identity hash andbit_field2. Two normalization sites whose maps hash to the same slot but are notEquivalentToForNormalizationevict each other on everyMap::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 tosrc/env_properties.heach draw one value from the isolate's random generator while the startup snapshot is built, which shifts the identity hashes later assigned toObject.prototypeandFunction.prototypeand 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-snapshotremoves 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/spywalk 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;parallelandsequentialpass 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 underdeps/v8; once the change lands upstream, or before, adeps: V8: cherry-pickof it would be the way to fix 26.x.