Skip to content

Merge train: #9807, #9816 - #9855

Merged
proggeramlug merged 3 commits into
mainfrom
train127
Sep 6, 2026
Merged

Merge train: #9807, #9816#9855
proggeramlug merged 3 commits into
mainfrom
train127

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Merge train: #9807, #9816, plus one gate verdict.

On the pipe test

An earlier run of this pair failed streams::pipe::tests::pipe_keeps_locks_until_async_abort_settles, so it was held rather than landed. Investigated rather than assumed:

It is a flake, not a regression. That suite has timing-sensitive async tests that fail under load, and the original run shared the machine with a concurrent build. Running validations serially is the fix; the PRs are clean.

Validation

64/64 lint gates; perry-runtime, perry-codegen, perry-hir, perry-transform green, and perry-stdlib green on re-run.

Ralph Küpper added 3 commits September 5, 2026 18:40
… filter that admits when it is outgrown

`prune_dead_per_object_layout_owners` walked every live key three times per
collection — `retain`, `layout_addr_filter_rebuild` (which buffered them all
into a `Vec<usize>` first), then `recount_young_layout_records`. The last two
want exactly the survivor set `retain` already visits, so they fold into its
closure and the `Vec` disappears; on the compiled claude-code TUI that `Vec`
alone allocated 50.6 MB per 400-character reply.

The new `PERRY_LAYOUT_DIAG` instrument reports what made this expensive:
162,258 live keys against a 4,096-bit address sketch documented for "one or
two entries", with all 4,096 bits set. Every probe answers "may hold", so the
early returns the sketch exists to serve never fire, and each rebuild is an
O(live keys) walk that restores the all-ones state it started from. Past one
eighth of the bits the rebuild now reaches that state in O(1) instead.
Widening the sketch is a codegen change — the geometry and hash are mirrored
in `emit_gated_forget_object_layout` — and would need ~190 KB of inline TLS
per thread to discriminate at this occupancy.

`transfer_per_object_descriptor` gains the emptiness test its shared flag
cannot express: one `len` load instead of two hashes per evacuated object,
for a map that is empty for the whole of a cc turn.

`LAYOUT_DIAG` is declared with `crate::perry_thread_local!`, as
`scripts/check_thread_locals.py` requires of every new declaration.

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 #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
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