Skip to content

Skip the prototype walk for array hole reads when the chain is provably clean#2659

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf-quickjs-ws3-hole-reads
Jul 13, 2026
Merged

Skip the prototype walk for array hole reads when the chain is provably clean#2659
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf-quickjs-ws3-hole-reads

Conversation

@lahma

@lahma lahma commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Fourth PR of the quickjs-ng learnings campaign (#2654 lanes, #2656 for-in array fast path, #2658 guard fusion). QuickJS guards its entire array element fast path — including hole semantics — with a single std_array_prototype invariant flag; Jint already tracks the equivalent invariant (CanUseFastAccess: no exotic own descriptors, prototype identity intact, no index properties placed on Array.prototype/Object.prototype) and trusts it on the write side. This PR extends that trust to the read side.

What

Reading a hole (or any absent index) fell off the dense fast path into a full prototype-chain probe that allocates a JsString per index — a loop summing a 75%-holey array allocated 7 MB per op, and in over the same array the same. Now Get(uint), the indexed Get(JsValue) miss and HasProperty return undefined/false directly when CanUseFastAccess holds.

Every escape hatch falls through to the unchanged walk, and each is pinned by a new test:

  • an index getter on the array itself sets NonDefaultDataDescriptorUsage (via TrackChanges),
  • placing an index property on Array.prototype or Object.prototype sets ObjectChangeFlags.ArrayIndex on the prototype,
  • subclass instances and custom prototypes fail the prototype-identity check.

Numbers

ArrayHoleTraversalBenchmark (default job):

Lane Before After Δ
SumHoley 21.03 ms / 7,015.7 KB 14.50 ms / 1.32 KB −31% time, −99.98% alloc
InOperatorHoley 16.70 ms / 7,015.7 KB 8.45 ms / 1.32 KB −49% time, −99.98% alloc
SumDense 10.44 ms 9.97–11.01 ms within noise (this lane bounces ±5% between identical binaries)
Join / IndexOf lanes unchanged (already dense-aware)

Gate

Full gate green: Jint.Tests (incl. three new escape-hatch tests), PublicInterface, CommonScripts, and a clean 99,429-test Test262 run — which includes the Array.prototype index-shadowing suites this change must respect.

🤖 Generated with Claude Code

…ly clean

Reading a hole (or any absent index) fell off the dense fast path into a
full prototype-chain probe that allocates a JsString per index - a loop
summing a 75%-holey array allocated 7 MB per op and the in operator the
same. The write fast path already trusts CanUseFastAccess (no exotic
own descriptors, prototype identity intact, no index properties placed
on Array.prototype/Object.prototype - QuickJS guards its array fast
path with the same idea as a single std_array_prototype flag); the read
side now trusts it too: Get(uint), the indexed Get(JsValue) miss and
HasProperty return undefined/false directly when the invariant holds.

Index getters set NonDefaultDataDescriptorUsage, prototype index
additions set ObjectChangeFlags.ArrayIndex, and subclass or custom
prototypes fail the identity check - all of those fall through to the
unchanged walk. New tests pin each escape hatch.

ArrayHoleTraversalBenchmark (default job):

| Lane            |               Before |             After |
|---------------- |---------------------:|------------------:|
| SumHoley        | 21.03 ms / 7,015.7 KB | 14.50 ms / 1.32 KB |
| InOperatorHoley | 16.70 ms / 7,015.7 KB |  8.45 ms / 1.32 KB |
| SumDense        |             10.44 ms | within noise       |
| Join/IndexOf    |            unchanged | unchanged          |

-31% / -49% time and -99.98% allocation on the holey lanes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant