Skip to content

perf(runtime): recycle the for-of result object for array iterators (167,946 objects per 400-char claude-code reply) - #9816

Closed
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:perf/for-of-array-iter-result
Closed

perf(runtime): recycle the for-of result object for array iterators (167,946 objects per 400-char claude-code reply)#9816
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:perf/for-of-array-iter-result

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

What

The compiler's for…of desugar emits one runtime call per advance,
js_for_of_next(__iter). That entry already recycles one { value, done }
object per ITERATOR
for builtin Map/Set iterators — the result local is a
compiler temporary the loop body cannot name, and the driver reads done and
value out of it before the next advance, so mutating one cached object is
unobservable. Array iterators fell through to the generic arm and minted a
fresh 40-byte object per element.

This gives them the same arm, and moves the recycling routine into
iter_result::emit_iter_result_cached so the two families share ONE
implementation instead of a second copy — the drift #7564 removed from the
five result constructors that module replaced.

Why this one

The allocation-site census of the compiled claude-code TUI attributes 100 % of
its iterator-result bytes
to array::iter_object under js_native_call_method
(14.4 MB of a 3300-character reply; 15.5 % of all attributed arena bytes, the
third-largest category), and the [gc-primitive-dispatch] counter says a
400-character reply builds 167,946 of them. for_of_guard.rs's index loop
only covers a statically proven array, which a minified bundle almost never
gives you — so every generic for…of over an array pays one object per element.

The campaign's ranking rule is "prefer removing work over making work cheaper".
This deletes the allocation rather than speeding it up, so CPU and resident bytes
move together.

What had to stay intact

  • A patched next still wins. The override probe runs first on the fused
    path exactly as on the manual one.
  • node:sqlite's { done, value } key order is observable, so the cache is
    built with that iterator's own order and the shared keys array (and therefore
    the shape) is picked per order.
  • The reserved-slot floor. Field 5 now holds the cache, so
    reserved_slot_floor_for_class_id rises from 5 to 6 for the array iterator.
    Without that, the first user property added to an iterator (it.foo = 1)
    would land on the cache field — the exact defect for…of over a Set iterator with a patched .next() segfaults (pre-existing, not #9017) #9019 fixed for the other
    families.
  • Only the desugar reaches it. for await lowers to the raw .next()
    property call (needs_await in stmt_loops.rs), not the fused helper, and
    spread / Array.from / yield* / manual .next() all take the manual
    dispatcher. They keep allocating fresh results, so a caller that retains one
    still sees spec behaviour.

Tests

  • fused_next_walks_an_array_and_recycles_its_result — correct walk, terminates,
    the cache field is installed on the first advance, and the driver hands back
    the same object twice (without that assertion the test would pass with no
    recycling at all). It also pins the negative: two manual .next() calls return
    different objects and the first still reads its own value after the second.
  • fused_next_routes_other_iterators_through_the_generic_arm — a string iterator
    still gets fresh results, so the fused branch cannot be "always taken".

Status

Draft until its rig table is posted here. The candidate binary is building;
the A/B is same-binary (PERRY_FOR_OF_ARRAY_CACHE switches the array arm off),
against cc_base_new and node in the same session under the campaign's
measurement lock.

The falsifier is stated in advance: [gc-iter-result] allocated must fall by
roughly the element count while fused_array_advances does not, and the
iter_result site must leave the allocation-site census. If allocated does
not move, the calls are not coming from the for…of desugar and this fix is
aimed at the wrong caller
— in which case I will say so and close it rather
than look for another justification.

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m

Summary by CodeRabbit

  • Performance

    • Improved for…of performance when iterating over arrays by reusing iterator result objects and reducing per-element allocations.
  • Compatibility

    • Preserved fresh iterator results for manual .next(), spread, Array.from, yield*, and for await usage.

@proggeramlug proggeramlug added the run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke label Sep 5, 2026
@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 2498c6d4-285e-431e-91b7-ba2f4d95a3d0

📥 Commits

Reviewing files that changed from the base of the PR and between c7361c8 and b67b4d7.

📒 Files selected for processing (6)
  • changelog.d/9816-for-of-array-iter-result.md
  • crates/perry-runtime/src/array/iter_object.rs
  • crates/perry-runtime/src/array/mod.rs
  • crates/perry-runtime/src/collection_iter_object.rs
  • crates/perry-runtime/src/iter_result.rs
  • crates/perry-runtime/src/object/reserved_floor.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Array iterators now reuse one { value, done } object during fused for…of iteration. Manual .next(), spread, Array.from, yield*, and for await retain fresh iterator results.

Changes

Array for-of result caching

Layer / File(s) Summary
Shared result caching and iterator storage
crates/perry-runtime/src/iter_result.rs, crates/perry-runtime/src/array/iter_object.rs, crates/perry-runtime/src/collection_iter_object.rs, crates/perry-runtime/src/object/reserved_floor.rs
The runtime centralizes cached result emission and adds cache storage to iterator layouts. Result updates preserve the required field order.
Fused array iterator dispatch
crates/perry-runtime/src/array/iter_object.rs, crates/perry-runtime/src/array/mod.rs, crates/perry-runtime/src/collection_iter_object.rs
js_for_of_next routes array iterators through the cached dispatch path. Manual dispatch remains uncached. SQLite and standard iterator result ordering remain distinct.
Caching behavior validation and release notes
crates/perry-runtime/src/collection_iter_object.rs, crates/perry-runtime/src/object/reserved_floor.rs, changelog.d/9816-for-of-array-iter-result.md
Tests verify result reuse for fused iteration, fresh results for manual iteration, generic routing for other iterators, and the updated reserved field floor. The changelog records the behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to b67b4

Fused array for-of iteration now reuses iterator result objects to reduce allocations while manual iterator use retains fresh results. The behavior and layout contracts are covered, with no current merge-blocking risk identified.

Sequence Diagram(s)

sequenceDiagram
  participant js_for_of_next
  participant dispatch_array_iterator_method_emit
  participant emit_iter_result_cached
  js_for_of_next->>dispatch_array_iterator_method_emit: dispatch array next with caching
  dispatch_array_iterator_method_emit->>emit_iter_result_cached: emit value and done
  emit_iter_result_cached-->>dispatch_array_iterator_method_emit: return cached result
  dispatch_array_iterator_method_emit-->>js_for_of_next: return validated result
Loading

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: recycling the for-of result object for array iterators. The performance metric is relevant but adds some length and noise.
Description check ✅ Passed The description is detailed and directly explains the change, rationale, preserved behavior, implementation scope, tests, measurements, and status. It does not use the repository template headings and…
Docstring Coverage ✅ Passed Docstring coverage is 82.35% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 17 functions across 5 files. (1 skipped: 1 …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Scope boundary, measured rather than assumed. The same allocation-site
census that ranked this also says where the rest of the category is, so I am
recording what this PR deliberately does not cover:

window array::iter_object string::iter_object
streamed 3300-char turn 15.07 MB (100 %) 0
14 s after the turn 15.07 MB (97 %) 0.46 MB (3 %)

The string iterator is the same shape — a 2-field object with reserved floor 2,
building a fresh { value, done } per code point — and would take the same arm
with the same helper. I am not doing it here: 3 % of one window is not enough to
justify a second layout change and a second floor bump in the same PR, and it
should be its own diff with its own number. If someone wants it, the recipe is
this diff with STRING_ITERATOR_CLASS_ID, a cache field at index 2 and the
floor moved 2 → 3.

Everything else in the category is already excluded by construction: for await
lowers to the raw .next() property call rather than the fused helper, and
spread / Array.from / yield* / manual .next() all go through the manual
dispatcher, which keeps allocating fresh results.

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m

@proggeramlug
proggeramlug force-pushed the perf/for-of-array-iter-result branch from c52d3d9 to 16f4c3f Compare September 5, 2026 15:40
@proggeramlug

Copy link
Copy Markdown
Contributor Author

The falsifier is met — counter first, numbers to follow

I said before building that [gc-iter-result] allocated must collapse while
fused_array_advances does not, and that if allocated did not move the fix
was aimed at the wrong caller. Same binary, one env gate, a 200-character
streamed claude-code reply, PERRY_GC_DIAG=1, last per-minor line of each run:

arm fused_array_advances allocated
PERRY_FOR_OF_ARRAY_CACHE=0 (generic arm) 135,032 149,151
default (fused arm, recycling) 134,652 34,621

The population is the same to within 0.3 %, and iterator-result allocations fall
−77 %. The residue is exactly what the change does not claim: the first
advance of every iterator (one object each), plus manual .next(), spread,
Array.from, yield* and the string iterator, all of which still allocate
fresh results by design.

For scale on the workload this was ranked from: the same counter on a
400-character reply reports 167,946 iterator-result objects built today.

Turn CPU on that smoke pair was 6.25 s → 5.83 s, but that is n=1 on a contended
box and I am not claiming it. The proper interleaved table — 400 and 3300
stream_scale, timed_turn, three repeats, cc_base_new and node in the same
session under measure_lock — is running now and will be posted here; the draft
comes off only if it holds on CPU and footprint.

Provenance: the candidate /tmp/cc_iter1 was built from this branch plus two
measurement-only commits (the env gate and these counters). It links the same
emitted-JS object as cc_base_new — both derive codegen id 22c732a67c78a2ac
from perry-codegen + perry-hir, so the A/B is runtime-only. The branch has
since been amended once, and the entire diff from the built tree is inside
#[cfg(test)] (the recycling assertion now compares each result against the
cache field read at the same moment, rather than comparing two NaN-boxed
pointers across a possible collection, which a copying minor between the two
advances would have failed for the wrong reason).

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Suite on this branch, rebuilt from scratch after the test amendment:
cargo test --release -p perry-runtime -- --test-threads=13,143 passed,
0 failed, 4 ignored
, plus the doc-test and per-binary runs, and
cargo check --release -p perry-runtime --all-targets is warning-free.

That covers the two behavioural risks the diff carries beyond the recycling
itself: the reserved_slot_floor_for_class_id bump from 5 to 6 (its own
assertion is updated and the #9019 seed tests still pass, so a user property
added to an array iterator still lands past every raw field), and the
node:sqlite { done, value } key order, which the existing sqlite fixtures
cover.

The rig table is still queued behind two other lanes' builds; it will follow.

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m

@proggeramlug

Copy link
Copy Markdown
Contributor Author

The rig table — and it does not move the needle on claude-code

Quiet box this time (load 4–15 for every run, against 11–22 for my last table),
one measure_lock hold, arms interleaved, cc_base_new and node in the same
session. off = PERRY_FOR_OF_ARRAY_CACHE=0 (generic arm), on = default.

400-character streamed reply, three repeats per arm

arm turn CPU (s) CPU next 12 s peak RSS (MB) settled FP (MB)
off 6.47 / 6.37 / 6.44 5.33 / 4.91 / 5.61 651 / 648 / 650 469 / 487 / 435
on 6.48 / 6.23 / 6.07 5.21 / 4.47 / 4.52 653 / 660 / 654 476 / 449 / 455
cc_base_new (n=1) 6.46 4.98 652 482
node (n=1) 0.27 0.01 369 326

3300-character streamed reply, two repeats per arm

arm turn CPU (s) CPU next 12 s peak RSS (MB) settled FP (MB)
off 51.24 / 50.38 12.10 / 12.11 1336 / 1358 1177 / 1175
on 50.91 / 50.33 11.97 / 11.95 1237 / 1339 1132 / 1175
cc_base_new (n=1) 40.70 12.14 1287 927
node (n=1) 0.50 0.01 416 210

Typing + short turn (timed_turn, n=1 per arm)

arm startup typing r2 turn r2 r3 turn CPU idle 10 s RSS end
off 1.70 0.84 1.00 60.63 † 5.11 1477
on 1.78 0.86 1.33 37.54 1.88 1212
base 1.66 0.81 1.19 60.68 † 6.02 1205
node 1.23 0.30 0.11 0.23 0.03 343

† the r3 turn did not complete inside the 60 s window in those two arms, so
their CPU figures are floors, not completions. I am not claiming that row:
it is n=1, and the stream_scale 3300 pair — the same reply length, two repeats
per arm, uncensored — is flat at ~50 s in both arms. One of the two is noise and
the uncensored one is the more trustworthy.

Verdict, stated plainly

The change removes exactly what it claims, and on claude-code that is worth
nothing measurable.
The counters on the same 400-character reply:

off: allocated=154,557  fused_array_advances=142,091   copying minors: 81
on:  allocated= 31,535  fused_array_advances=145,307   copying minors: 81

−80 % of iterator-result objects at the same advance count — and the same 81
copying minors in both arms.
That is the whole explanation for the flat table,
and it is worth stating rather than burying: ~123,000 removed objects × 40 bytes
is ~4.9 MB out of the ~157 MB a 400-character reply allocates, so the nursery
still fills at the same rate from everything else and the collection schedule
does not change at all.
CPU and footprint differences between the arms are
inside their own spread in both directions.

Neither metric regresses, which is the directive's condition, but "does not
regress" is not a result and I am not going to dress it up as one.

What I checked before saying that

The counterweight to the removed allocations is that every array iterator now
carries six reserved fields instead of three (+24 bytes, and possibly a size
class) so field 5 can hold the cache without a per-iterator shape transition.
I did not count iterators, so I cannot price that directly — but the measured
settled footprint and peak RSS are flat-to-slightly-better in both arms of both
reply lengths, so the net is not negative on this workload.

Where it would pay, and why I still think it should land

The cc render loop is not iteration-bound; it is bound by the property-set and
GC work the other lanes are attacking. A program whose hot loop is generic
for…of over arrays pays one 40-byte object per element today, and this deletes
that permanently rather than making it cheaper — which is the campaign's own
ranking rule. It also collapses two copies of the recycling routine into one, so
the array and Map/Set families cannot drift the way the five result constructors
#7564 replaced had.

Staying draft, for a specific reason

Not because of the flat table — because the base has moved: this branch is
on 1d63fa91f and main is 22+ commits past it. I will rebase onto current
main, re-run the suite, and take it out of draft then, so what CI measures is
what the diff means. Tests on the current base are green (3,143 passed, 0
failed, warning-free).

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m

The fused `for…of` advance (`js_for_of_next`, the runtime entry the compiler's
desugar emits) already recycled ONE `{ value, done }` object per ITERATOR for
builtin Map/Set iterators: the result local is a compiler temporary the loop
body cannot name, and the driver reads `done`/`value` out of it before the next
advance, so mutating one cached object is unobservable. Array iterators fell
through to the generic arm and minted a fresh 40-byte object per element.

The allocation-site census of the compiled claude-code TUI attributes 100 % of
its iterator-result bytes — 14.4 MB of a 3300-character reply, 15.5 % of all
attributed arena bytes and the third-largest category — to exactly that:
`array::iter_object` under `js_native_call_method`, one object per element of
every generic `for…of`. A minified bundle reaches it whenever the iterated
value is not a statically proven array, which is nearly always.

So the array iterator takes the same fused arm, and the recycling routine moves
to `iter_result::emit_iter_result_cached` so the two families share ONE
implementation instead of a second copy — the drift PerryTS#7564 removed from the five
result constructors this module replaced.

Three things the change had to keep intact:

  * The override probe still runs first, so a patched own `next` wins on the
    fused path exactly as it does on the manual one.
  * `node:sqlite`'s `{ done, value }` key order is observable, so the cache is
    built with that iterator's own order.
  * Field 5 now holds the cache, so `reserved_slot_floor_for_class_id` rises
    from 5 to 6 for the array iterator — without that, the first user property
    added to an iterator (`it.foo = 1`) would land on the cache field.

Manual `.next()`, spread, `Array.from`, `yield*` and `for await` are unchanged
and keep allocating fresh results.

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m
@proggeramlug
proggeramlug force-pushed the perf/for-of-array-iter-result branch from 16f4c3f to b67b4d7 Compare September 5, 2026 16:14
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (c7361c87c) and re-tested: 3,150 passed, 0
failed, 4 ignored
, cargo check --all-targets warning-free, thread-local
ratchet clean.

Invariant re-derived on the new base rather than trusted to a clean merge: main
touched none of the five files this depends on (array/iter_object.rs,
collection_iter_object.rs, iter_result.rs, object/reserved_floor.rs,
perry-hir/src/lower/stmt_loops.rs), and js_for_of_next still has exactly one
emitter — the for…of desugar at stmt_loops.rs:357 — so "only the compiler's
desugar reaches the recycling path" still holds.

Changelog fragment renamed to changelog.d/9816-for-of-array-iter-result.md;
verified directly against scripts/check_changeset_fragment.sh, which passes it
silently.

Taking this out of draft. Both conditions I set are met, and I want the flat
rig table read as what it is rather than as a reason to hide the PR: the change
removes 80 % of iterator-result allocations at an unchanged advance count, the
collection schedule is provably identical (81 copying minors in both arms), and
neither CPU nor footprint regresses. It deletes an allocation with no observable
purpose and collapses two copies of the recycling routine into one — which is
the ranking rule this campaign set — and it will pay on any program whose hot
loop is generic for…of over arrays, which claude-code's render path is not.

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main via merge train #9855 (rebase-merged, so your commit keeps its authorship). The earlier pipe_keeps_locks_until_async_abort_settles failure was investigated and is a flake — it passes on clean main, with each of these PRs alone, and with both together. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant