Skip to content

Scan the shape instead of enumerating prototype properties in for-in - #1654

Open
andreasrosdal wants to merge 3 commits into
quickjs-ng:masterfrom
nordstjernen-web:perf-for-in-proto-scan
Open

Scan the shape instead of enumerating prototype properties in for-in#1654
andreasrosdal wants to merge 3 commits into
quickjs-ng:masterfrom
nordstjernen-web:perf-for-in-proto-scan

Conversation

@andreasrosdal

Copy link
Copy Markdown
Contributor

build_for_in_iterator() decides whether it can take the fast path by asking, for every object in the prototype chain, whether it has any enumerable string-keyed own property. It answers that by calling JS_GetOwnPropertyNamesInternal(), which allocates a JSPropertyEnum array, duplicates every matching atom into it, and then frees it again — all to compare a count against zero.

For an ordinary object every own property lives in the shape, so walk the shape's property table and stop at the first enumerable string key. Exotic objects (arrays, typed arrays, String objects, proxies, module namespaces) keep going through JS_GetOwnPropertyNamesInternal(), since their own keys are not all in the shape.

Benchmarks

Best of 3, x86-64 -O2:

case master this PR
for-in plain object, 2M iterations 1088 ms 932 ms
for-in class instance (3 protos), 1M 638 ms 484 ms
for-in array, 1M 855 ms 710 ms

Testing

language/statements/for-in (119) and built-ins/Object (3411) unchanged. Spot-checked against V8 that for-in over array, String-object and Proxy prototypes, non-enumerable prototype properties and symbol-only prototypes all still produce identical key order:

array proto:               ["own","0","1","extra"]
String object proto:       ["p","0","1"]
proxy proto:               ["r","q"]
deep proto:                ["own2","inherited"]
non-enumerable proto prop: ["v"]
symbol-only proto:         ["w"]

🤖 Generated with Claude Code

https://claude.ai/code/session_014mv33YvfHz7t9mmkituBnn


Generated by Claude Code

claude and others added 3 commits August 6, 2026 18:12
build_for_in_iterator() decides whether it can take the fast path by asking,
for every object in the prototype chain, whether it has any enumerable
string-keyed own property. It answers that by calling
JS_GetOwnPropertyNamesInternal(), which allocates a JSPropertyEnum array,
duplicates every matching atom into it, and is immediately freed again --
just to compare a count against zero.

For an ordinary object every own property lives in the shape, so walk the
shape's property table and stop at the first enumerable string key. Exotic
objects (arrays, typed arrays, string objects, proxies, module namespaces)
keep going through JS_GetOwnPropertyNamesInternal(), since their own keys
are not all in the shape.

Best of 3, x86-64 -O2:

    for-in plain object, 2M iterations       1088 ms -> 932 ms
    for-in class instance (3 protos), 1M      638 ms -> 484 ms
    for-in array, 1M                          855 ms -> 710 ms

language/statements/for-in (119) and built-ins/Object (3411) are unchanged,
and for-in over array, String-object and Proxy prototypes still produces the
same key order as V8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mv33YvfHz7t9mmkituBnn
Both changes are meant to be invisible, so the tests pin behaviour rather
than a fix; they pass before and after.

The for-in test covers what the shape scan may and may not conclude: plain
prototypes, non-enumerable and symbol keyed properties, the holes deleting a
property leaves in a shape, and the exotic prototypes whose properties do
not live in the shape at all -- arrays, string objects, typed arrays,
arguments objects, a proxy (checking its traps run) and a module namespace.

The equality test covers ==, !=, === and !== across object identity, strings
built several different ways including wide and rope-sized ones, and every
cross-type coercion, plus that === never coerces and a throwing valueOf
propagates out of == only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6eRbuuuCujKgQkrHgvMrc
js_eq_slow() dispatches on the two operands' tags, and a string that is
still a rope carries JS_TAG_STRING_ROPE rather than JS_TAG_STRING. Two
equal strings therefore fell past every branch and out of the final else
as not equal whenever exactly one of them was a rope, so == and != could
disagree with === and !==:

    let s = ""; for (let i = 0; i < 20000; i++) s += "a";
    s === "a".repeat(20000)   // true
    s ==  "a".repeat(20000)   // false

Ropes below JS_STRING_ROPE_SHORT2_LEN are flattened eagerly, which is why
the two tags only ever meet above 8192 characters. js_strict_eq2() already
handles the mixed pair, so the fix is to let a string/string pair take the
same branch a same-tag pair does.

tests/equality-operators.js covers both rope thresholds, in both
directions, against flat strings, other ropes and the interned atom the
same characters produce.
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.

2 participants