Ask the array again when join meets a hole - #3003
Merged
Merged
Conversation
lahma
force-pushed
the
fix/array-join-hole-reread
branch
from
August 12, 2026 06:49
995edf0 to
399e5cd
Compare
join reads each element with Get(O, ToString(k)) on its own iteration, so a
side effect from one element's ToString is visible to every later one:
var funky = { toString() { Object.prototype[3] = "fnord"; return "funky"; } };
[0, funky, , ,].join(""); // was "0funky", node "0funkyfnord"
The read-only array lane snapshots the backing store and answered a null slot
as undefined without ever asking the array. A hole is the absence of an own
element, not the value undefined, so it now resolves through JsArray.Get(uint)
- the same read JsArrayOperations performs, which still answers undefined
without walking anything while CanUseFastAccess holds. Array.prototype.toString
is defined as a call to join and is fixed with it.
The packed lane is untouched: a slot the snapshot holds is still one bounds
compare, one array load and one null test, with no extra field read and no
call. Only a hole - or an index past the snapshot - reaches the new branch, and
only once the realm's array prototype chain has actually gained an index
property does that branch walk anything.
Three sibling divergences share the shape and are deliberately left alone here:
toLocaleString reads through TryGetValue rather than Get; forEach and the other
callback generics skip a hole the prototype gained mid-iteration because
HasProperty answers from the same snapshot; and a side effect that reallocates
the backing store leaves the snapshot itself stale, which cannot be fixed
without a live _dense load on the per-element path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lahma
force-pushed
the
fix/array-join-hole-reread
branch
from
August 12, 2026 17:06
399e5cd to
49bd6be
Compare
Collaborator
Author
Benchmark gate — measured, reworked once, now PASSThe first measurement caught a real cost: Re-gated after the rework:
The residual +3.4% on the hole-heavy row is the honest cost of the correctness fix — per-hole flag read and branch where there used to be nothing — disclosed rather than hidden. Allocation byte-identical everywhere. ArrayTests 167/167 on both TFMs after the rework; the amended commit is force-pushed. |
lahma
added a commit
that referenced
this pull request
Aug 13, 2026
Full re-measure of both suites (script + interop, one session, default job, idle machine: 96 script rows and 20 interop rows) on the 4.16.0 release candidate 7b56c83. Every figure in the narrative sections is recomputed from the new reports; no number is carried forward from the 4.15.0 tables. Where the table stands now: - Jint is fastest outright on 5 of 12 scripts (minimal ~345x V8's compiled lane, evaluation-modern ~80x, linq-js ~6.7x, dromaeo-core-eval-modern ~5%, dromaeo-object-regexp-modern 1.25x ahead of V8's fresh-context lane and 1.46x ahead of its compiled lane), fastest managed engine on 10 of 12, and fastest interpreter on all 12 - V8 keeps the tight-loop rows: base64 9.8x, object-string 6.6x, stopwatch 6.0x, 3d-cube 3.4x, json-parse 2.2x, plus narrow leads on object-array (1.08x) and array-stress (1.09x) - array-stress being the one script row that changes hands, out of the rank-1 tie it held at 4.15.0 - Allocation: Jint is lowest of the managed engines on 10 of 12 scripts (Okojo on object-array, NiL.JS on minimal) and on all four interop rows, 3.9x-12.4x under the nearest managed competitor there - Interop: rank 1 on string-passing, and back into a rank-1 tie with NiL.JS on collection-traversal (1,251.0 vs 1,242.7 us, 0.7% apart); rank 2 on method-calls (NiL.JS ahead) and property-access (YantraJS by 2.6%). Plain ClearScript costs 8.6x-11.2x against Jint, FastProxy 3.4x-7.0x Adds a "What changed for 4.16.0" section: proper tail calls (#2975, which measured -15.6% time and -40.4% allocation on the Jint-only controlflow-recursive row against 4.15.3), the fast-call lane's growth (#2968, #2980, #2984), the wrapped-dictionary probe lane (#2969), and the disclosed cost of the join-hole re-read (#3003, +3.4% on hole-heavy joins). The comparison against the 4.15.0 tables is stated as directional only - the two sessions ran on .NET 10.0.10 -> 10.0.11 with YantraJS 1.2.419 -> 1.2.422 in between, so no row-for-row delta is claimed - and the two stale prose claims naming 4.15.0 outside the history sections are refreshed from this session's data. Environment: AMD Ryzen 9 5950X, .NET 10.0.11 (SDK 10.0.400), BenchmarkDotNet 0.15.8, default job, otherwise idle machine. ClearScript's V8 lanes land within ~3% of the 2026-07-28 session on the identical package. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
[0, funky, , ,].join("")wherefunky.toString()setsObject.prototype[3]answered"0funky"— node and the spec (Get(O, ToString(k))per element, every iteration) answer"0funkyfnord".ArrayReadOperations.Get, the read-only fast lane, snapshotted_denseat construction and turned a hole intoundefinedwithout ever asking the array.A
nullslot (or an index past the snapshot) now routes toJsArray.Get(uint), which still short-circuits toUndefinedwhileCanUseFastAccessholds and walks the chain only after the realm's array prototype chain has actually gained an index property. A slot the snapshot holds is untouched: one bounds compare, one load, one null test — identical to before. The red matrix needed one engine per case to expose, because the first prototype index write sets a sticky realm-wide flag that clears fast access — that mechanism is exactly why the pristine-realm fast path is safe.Siblings disclosed, deliberately not fixed here:
toLocaleStringreads viaTryGetValueand still short-circuits (spec saysGet);ArrayReadOperations.HasPropertyanswers existence from the same snapshot, soforEach/every/some/filter/indexOfmiss an index the prototype gained mid-iteration; and a side effect that reallocates_denseleaves the snapshot itself stale — fixing that one means a per-element field load on the hot path and needs its own benchmark gate.12 new tests, red on exactly the 4 join/toString rows, both TFMs. test262 standalone 0 / 99,744 / 157, identical.
🤖 Generated with Claude Code