Skip to content

Add benchmark for holey-array key enumeration cost#2508

Merged
lahma merged 2 commits into
sebastienros:mainfrom
lahma:bench-holey-array-enumeration
Jun 9, 2026
Merged

Add benchmark for holey-array key enumeration cost#2508
lahma merged 2 commits into
sebastienros:mainfrom
lahma:bench-holey-array-enumeration

Conversation

@lahma

@lahma lahma commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Benchmark showing array key enumeration scales with logical length, not element count.

Details

While benchmarking #2501 I hit a surprisingly large allocation enumerating a holey array, and tracked it to a general property worth recording: Object.keys / values / entries (and any [[OwnPropertyKeys]] consumer) on an array scale with the array's logical length rather than the number of populated elements.

Root cause: ordinary a[index] = value assignment keeps the backing store dense for any index below the 10M dense-array threshold. SetIndexValue calls EnsureCorrectLength(index), which bumps the length to index + 1 before WriteArrayValueUnlikely runs its "looks sparse" check:

var denseHeadroom = Math.Max((uint) (dense?.Length ?? 0), declaredLength);
var canUseDense = dense != null
                  && index < MaxDenseArrayLength
                  && newSize < MaxDenseArrayLength
                  && index < denseHeadroom + 50; // looks sparse

Because declaredLength was just set to index + 1, the guard is effectively index < (index + 1) + 50, which is always true — so plain assignment never converts to sparse. (Sparse mode is only reached via new Array(n) with n >= 10M, or Object.defineProperty on an index, which goes through ConvertToSparse.)

A holey array therefore keeps a backing store proportional to its max index, and GetOwnPropertyKeys both allocates a List<JsValue> of that size and scans every slot, even though only the populated indices are returned.

The benchmark holds the populated count fixed at 500 and varies only the gap between indices (so the logical length is 500 * Gap):

Gap Logical length Keys returned Mean Allocated
1 (packed) 500 500 12 µs 8.8 KB
100 49,901 500 87 µs 435 KB
1000 499,001 500 563 µs 4,039 KB

AMD Ryzen 9 5950X, .NET 10.0.8, --job short. Same number of keys throughout, yet the gap-1000 case is ~47× slower and allocates ~457× more than the packed case.

This PR only adds the benchmark to document and quantify the behavior — there is no functional change. An actual fix would mean reworking the dense/sparse heuristic so plain assignment can fall back to sparse for genuinely holey arrays (distinguishing a holey scatter from a legitimate lazy new Array(N) capacity fill), which is a broader change best validated separately against Test262.

Linked issue

Refs #2501

Test plan

  • [N/A] Added or updated unit tests in Jint.Tests
  • dotnet run -c Release --project Jint.Benchmark -- --filter *ArrayHoleyKeysBenchmark*
  • [N/A] For ECMAScript spec changes: ran Jint.Tests.Test262
  • [N/A] For interop changes
  • For perf changes: numbers included above

Breaking change?

No — benchmark only, no runtime change.

Object.keys/values/entries on an array scale with the array's logical
length rather than the number of populated elements. Ordinary
'a[index] = value' assignment keeps the backing store dense for any index
below the 10M dense-array threshold: SetIndexValue bumps the length to
index+1 via EnsureCorrectLength before the 'looks sparse' heuristic in
WriteArrayValueUnlikely runs, so that heuristic (index < denseHeadroom + 50)
never fires for plain assignment. A holey array therefore keeps a backing
store proportional to its max index, and GetOwnPropertyKeys allocates a
List of that size and scans every slot to produce only the populated keys.

The benchmark holds the populated count fixed at 500 and varies only the
gap between indices; time and allocations grow ~linearly with the gap
(47x slower / 457x more allocated from gap 1 to gap 1000).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lahma
lahma force-pushed the bench-holey-array-enumeration branch from 63ca24a to bed385a Compare June 8, 2026 17:50
@lahma
lahma enabled auto-merge (squash) June 8, 2026 17:53
@lahma
lahma merged commit 407efc8 into sebastienros:main Jun 9, 2026
7 of 8 checks passed
@lahma
lahma deleted the bench-holey-array-enumeration branch July 13, 2026 06:52
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