Skip to content

for-in over arrays: enumerate dense indices lazily without materializing a key list#2656

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf-quickjs-ws1-forin-array
Jul 13, 2026
Merged

for-in over arrays: enumerate dense indices lazily without materializing a key list#2656
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf-quickjs-ws1-forin-array

Conversation

@lahma

@lahma lahma commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Second PR of the quickjs-ng learnings campaign (lanes: #2654). Stacked on #2654 and #2655 — the first two commits are those PRs; review the last commit. Will rebase as they merge.

What

for (var k in arr) currently materializes a full key list on every enumeration: a length-presized List<JsValue>, a JsString per index, a numeric-order pass — then re-probes each key through ProbeOwnProperty, which parses the string back to an index per step.

QuickJS'''s build_for_in_iterator has a fast path for this: when the receiver is a fast array and the prototype chain contributes no enumerable keys, it just records the length and yields indices lazily. This PR ports that idea:

  • A dense JsArray with no materialized per-index descriptors and no named own props, whose prototype chain provably has no enumerable string keys, enumerates its present indices directly. Indices < 1024 come from the shared numeric-string cache, so the enumeration allocates nothing.
  • When the index range is exhausted the enumeration is complete (the chain was clean at head evaluation) — the level/shadowing machinery never engages.
  • Everything else — extra named own props, sparse arrays, dirty chains, proxies — takes the existing exact snapshot path unchanged. Mid-loop representation changes (dense→sparse conversion, defineProperty materializing descriptors) are picked up per step and fall back to per-index probing that honors live enumerability.

The chain precheck introduces HasNoEnumerableOwnStringKeys(): a conservative virtual (default false = "cannot prove it, take the exact path") with exact overrides for plain objects, Object.prototype and array-backed hosts, backed by a precomputed all-fixed-slots-non-enumerable bit on BuiltinShape plus a live scan of materialized descriptors and hybrid additions. A wrong true would drop keys, so exotic key providers never opt in.

Numbers

ForInGuardBenchmark, default job:

Lane Before After Δ
ForInDenseArray 14.49 ms / 1,620 KB 11.96 ms / 1.32 KB −17.5% time, −99.9% alloc
ForInHoleyArray 3.82 ms / 1,620 KB 3.07 ms / 1.32 KB −19.7% time, −99.9% alloc
ForInArrayExtraProps 16.98 ms / 2,470 KB 16.68 ms / 2,470 KB unchanged (exact path, by design)
ForInSmall / Wide / HasOwnGuard / ProtoChain within noise (A/B/A re-measure attributed the small ForInSmall delta to ambient drift — the before-state re-measured slower than the after-state in the adjacent window)

Tests

14 new enumeration tests pinning the fast path'''s edges: holey arrays, empty-with-large-length, delete/push/length-truncation mid-loop, defineProperty mid-loop honoring new enumerability, dense→sparse conversion mid-loop, dirty chains via Array.prototype indices and Object.prototype additions, array subclasses, nested/generator-interleaved enumerations, pooled-iterator reuse after break.

Full gate green: Jint.Tests, PublicInterface, CommonScripts, and a clean 99,429-test Test262 run.

🤖 Generated with Claude Code

@lahma
lahma enabled auto-merge (squash) July 13, 2026 08:04
…ing a key list

QuickJS-style fast path: a dense JsArray with no materialized per-index
descriptors, no named own props, and a prototype chain that provably
contributes no enumerable string keys enumerates its present indices
directly - no per-enumeration List allocation, no numeric sort, no
per-step string->index parse in ProbeOwnProperty, and (indices < 1024)
no per-key JsString allocation. When the index range is exhausted the
enumeration is complete; anything else - extra named own props, sparse
arrays, dirty chains, proxies, or mid-loop deopts - takes the existing
exact snapshot path, and mid-loop representation changes fall back to
per-index probing that honors live enumerability.

The chain precheck introduces HasNoEnumerableOwnStringKeys: a
conservative virtual (default false) with exact overrides for plain
objects, Object.prototype and array-backed hosts, backed by a
precomputed all-fixed-slots-non-enumerable bit on BuiltinShape plus a
live scan of materialized descriptors and hybrid additions.

ForInGuardBenchmark (default job):

| Lane                 | Before              | After               |
|--------------------- |--------------------:|--------------------:|
| ForInDenseArray      | 14.49 ms / 1,620 KB | 11.96 ms / 1.32 KB  |
| ForInHoleyArray      |  3.82 ms / 1,620 KB |  3.07 ms / 1.32 KB  |
| ForInArrayExtraProps | 16.98 ms / 2,470 KB | 16.68 ms / 2,470 KB |

-17.5%/-19.7% time and -99.9% allocation on the array lanes; object
receivers and the legacy-path array lane are unchanged (A/B/A confirmed
the small ForInSmall delta as ambient drift).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lahma
lahma force-pushed the perf-quickjs-ws1-forin-array branch from 442c51c to e4ffa37 Compare July 13, 2026 08:06
@lahma

lahma commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto latest main now that #2654 and #2655 have merged — the stacked commits dropped out; the diff is just the fast-path commit.

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