Skip to content

perf(intl): one shared shape for Intl.Segmenter records — removes the program's largest allocation category - #9769

Closed
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:perf/keystroke-segmenter-shape
Closed

perf(intl): one shared shape for Intl.Segmenter records — removes the program's largest allocation category#9769
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:perf/keystroke-segmenter-shape

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

What

Intl.Segmenter builds its segment records property-by-property:

let obj = js_object_alloc(0, 4);
set_field(obj, "segment", segment_value);
set_field(obj, "index", index as f64);
set_field(obj, "input", input_value);
if let Some(word_like) = word_like { set_field(obj, "isWordLike",); }

set_field allocates a fresh StringHeader for the key name on every call
(js_string_from_bytes(key)) and then goes through js_object_set_field_by_name,
which clones the object's key list before writing — the copy-on-write
contract shared keys arrays depend on. So every record costs 3-4 fresh key
strings that carry no information (the same bytes every time), a keys array
cloned and regrown once per property, and one more descriptor in the shape
table.

Grapheme-aware text measurement — string-width, and therefore every terminal
UI built on ink — segments every string it renders. On the compiled claude-code
TUI, one 400-character assistant reply produces 175,797 segment records.

This is the defect #7564 fixed for { value, done } iterator results. It was
never applied to Intl.Segmenter.

Why it matters: it is the largest allocation category in the program

Independently measured by the gc-churn lane's allocation-site histogram, on its
own candidate (current main plus its two PRs, no pacing changes), attributing
every allocation in one streamed turn:

category streamed turn post-turn window
ordinary property set — keys and slot arrays 43.5 % 32.2 %
iterator result objects 11.6 % 15.9 %
for-in key arrays
string concat
regex construction 2.9 % 5.5 %

That lane names Intl.Segmenter's per-segment record as the source of the top
category — i.e. exactly the allocations this PR removes. Two lanes reached the
same site from opposite directions (an allocation histogram and an IC-miss
table), which is the main reason to trust it.

Campaign directive A3 is "the program allocates ~650 MB to produce 400
characters of output"; this is the largest single named contributor to it.

The change

The same construction iter_result.rs uses, for the two shapes a segment record
can have (isWordLike is attached only for word granularity, ECMA-402 18.5.1):

  • SEGMENT_RECORD_KEYS — a per-thread, GC_FLAG_SHAPE_SHARED keys array per
    shape, built at most twice per thread, with interned names so they are
    pointer-identical to the ones the read side hashes.
  • make_segment_record installs the shared array with js_object_set_keys and
    writes the fields by index — one allocation (the record) instead of five to
    eight
    , and one ShapeId for every segment record in the program.
  • scan_segment_record_keys_roots_mut, registered next to the iterator-result
    scanner in gc/mod.rs: nothing else in the heap references these arrays, and
    an evacuating collection moves them like any other array.

Rooting follows the same rule as build_iter_result_ordered: the caller's two
heap values are rooted first, the keys cache is filled before the record is
allocated, and every pointer used after an allocation is re-read from storage
the collector rewrites (the handle, or the scanned thread-local).

Measured

The allocation half ran, and is real. Main-thread leaf samples, 400-character
streamed reply, candidate cc_ks4 against the same branch without this commit:

leaf before (cc_ks2) after (cc_ks4)
js_string_from_bytes (the per-record key names) present 5 (0.03 %)

The bundle really does construct
new Intl.Segmenter(void 0,{granularity:"grapheme"}) and …{granularity:"word"},
so the code is on the path, and the throwaway key strings and keys-array clones
are gone.

End-to-end CPU on that candidate was flat (6.98 s vs 7.09 s turn CPU, 658 vs
659 MB peak RSS, node 0.29 s / 375 MB) — within the run-to-run spread. The claim
here is allocation volume, corroborated by the histogram above, not turn CPU on
one measurement.

Correction: the inline-cache rationale this PR was opened with was WRONG

The original description claimed a fresh keys array per record means a fresh
ShapeId, hence a guaranteed inline-cache miss on .segment, hence a slice of
the 2.5 M IC misses per turn. I tested that and it does not hold:

site cc_ks2 cc_ks4
.segment 175,797 174,948
.value 175,797 174,948
turn total 2,589,696 2,575,872

Unchanged. The falsifier was already in my own data — .value / .done are
read off iterator results whose keys array #7564 already shares, and they miss
~178k times per turn anyway.

The real cause has since been found and is unrelated to shapes: cc's module is
past the full-outline threshold (#5391 path 3), so every generic property read
lowers to one js_object_get_field_ic call, and that helper had no fast path
at all
— it called the miss handler unconditionally on every read. nm -u on
the compiled object shows js_object_get_field_ic_miss is not referenced by cc
at all: there was never an inline cache to hit. The prime split confirms it —
95.2 % of all primes re-write the token the site already held.

So: none of the 2.5 M were shape-instability misses, and this PR was never going
to move them. It is judged here purely as an allocation fix.

Tests

The five root-scanner tests pass (mark, rewrite, empty-cache no-op,
registration, build-once-per-shape). They are what makes the shared keys array
safe under an evacuating collection and should be kept whatever else changes.

Summary by CodeRabbit

  • Performance

    • Improved Intl.Segmenter performance by enabling segment records to share common internal metadata, reducing repeated allocation and lookup overhead.
  • Bug Fixes

    • Improved garbage-collection handling for shared segment-record data, including after memory relocation.
  • Tests

    • Added coverage for segment-record sharing, stability, and garbage-collection marking and rewriting.

@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: 0999b1e5-2ec8-4ab1-8fdb-bb1aac2502e2

📥 Commits

Reviewing files that changed from the base of the PR and between 131fa9e and 7eb183c.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/gc/mod.rs

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


📝 Walkthrough

Walkthrough

Intl.Segmenter now reuses one shared keys array for each segment-record shape. The GC scans and rewrites these thread-local arrays. Runtime-root tests verify marking, relocation, registration, empty-cache behavior, and cache stability.

Changes

Intl.Segmenter shared record shapes

Layer / File(s) Summary
Shared record construction
crates/perry-runtime/src/intl.rs, crates/perry-runtime/src/intl/segmenter.rs, changelog.d/keystroke-segmenter-shared-shape.md
The segmenter defines plain and word-like shapes, caches their shared keys arrays per thread, and writes records by field index.
GC cache integration and validation
crates/perry-runtime/src/gc/mod.rs, crates/perry-runtime/src/gc/tests/runtime_roots.rs, crates/perry-runtime/src/gc/tests/runtime_roots/segment_record_keys.rs, crates/perry-runtime/src/intl/segmenter.rs
The GC registers a scanner for the cached arrays. Tests verify marking, rewriting, registration, empty-cache behavior, and stable per-shape allocation.

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

Merge Risk: ⚪ Minimal · up to 7eb18

Intl.Segmenter records now reuse per-thread shared key arrays to reduce allocation overhead, with GC handling for cached arrays covered by runtime-root tests. No merge-blocking risk remains.

Sequence Diagram(s)

sequenceDiagram
  participant IntlSegmenter
  participant SegmentRecordCache
  participant GarbageCollector
  IntlSegmenter->>SegmentRecordCache: build or reuse shared keys array
  SegmentRecordCache-->>IntlSegmenter: create indexed segment record
  GarbageCollector->>SegmentRecordCache: scan cached keys arrays
  SegmentRecordCache-->>GarbageCollector: mark or rewrite array references
Loading

Suggested reviewers: jdalton, thehypnoo

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 18 functions across 5 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: sharing shapes for Intl.Segmenter records to reduce allocations. It is somewhat long but remains specific and relevant.
Description check ✅ Passed The description is detailed and on-topic. It explains the motivation, implementation, measured impact, correction to the original rationale, and test coverage. It does not use the repository template …
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.
  • Fix all pre-merge checks with AI
✨ 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

Marking draft: the rationale in the description is falsified by our own measurement.

This was proposed to remove inline-cache misses by giving every Intl.Segmenter record one shared shape. The allocation half works, but the misses did not move: .segment 175,797 → 174,948, turn total 2,589,696 → 2,575,872. The disproof was already in our data — .value/.done are read off iterator results whose keys array #7564 already shares, and they still miss 178,381 times per turn. A stable ShapeId is therefore not sufficient for these sites to hit, so the stated mechanism is wrong.

What remains true is the allocation: 175,797 records per 400-char reply each minting throwaway key strings and cloning the keys array. That is worth having, but it should be reviewed and titled as an allocation fix, not as an IC fix. Leaving it draft until it is either re-scoped that way or closed.

@proggeramlug proggeramlug changed the title perf(intl): give Intl.Segmenter records one shared shape instead of one per record perf(intl): one shared shape for Intl.Segmenter records — removes the program's largest allocation category Sep 5, 2026
@proggeramlug
proggeramlug marked this pull request as ready for review September 5, 2026 07:49
…ne per record

`make_segment_record` built its result property-by-property with `set_field`,
which allocates a fresh `StringHeader` for the key name on every call and
routes through `js_object_set_field_by_name`, which clones the object's key
list before writing. Every record therefore got 3-4 throwaway key strings, a
keys array cloned and regrown once per property, and — because
`shape_id_for_keys_ensure` keys the shape table on the keys array's ADDRESS —
its own ShapeId.

A fresh ShapeId per record makes every read of `.segment` / `.index` /
`.input` a guaranteed inline-cache miss (the PIC token is the ShapeId) and
adds one descriptor to the shape table per record. This is the defect PerryTS#7564
fixed for `{ value, done }` iterator results; `Intl.Segmenter` was not
covered, and grapheme-aware text measurement (`string-width`, and so every
terminal UI built on ink) segments every string it renders. One 400-character
reply in the compiled claude-code TUI produces 175,797 segment records;
`PERRY_IC_DIAG` attributes 175,797 of that turn's 2,589,696 IC misses to the
`.segment` read site alone.

* `SEGMENT_RECORD_KEYS`: a per-thread `GC_FLAG_SHAPE_SHARED` keys array for
  each of the two record shapes (`isWordLike` is attached only for word
  granularity, ECMA-402 18.5.1), built at most twice per thread with interned
  names so they are pointer-identical to the ones the read side hashes.
* `make_segment_record` installs the shared array with `js_object_set_keys`
  and writes fields by index: one allocation instead of five to eight, and one
  ShapeId for every segment record in the program.
* `scan_segment_record_keys_roots_mut`, registered beside the iterator-result
  scanner: nothing else references these arrays, and an evacuating collection
  moves them like any other array.

Rooting follows `build_iter_result_ordered`: the caller's two heap values are
rooted first, the keys cache is filled before the record is allocated, and
every pointer used after an allocation is re-read from storage the collector
rewrites.

Tests: the five root-scanner tests `iter_result_keys.rs` holds, mirrored for
this cache (mark, rewrite, empty-cache no-op, registration, build-once).

Claude-Session: https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2
@proggeramlug
proggeramlug force-pushed the perf/keystroke-segmenter-shape branch from 131fa9e to 7eb183c Compare September 5, 2026 08:56
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (1d63fa91f) and taken out of draft with the description rewritten as an allocation fix. The previous CI run was against a base that was itself red (fixed since by the train124 gate work), so those failures were the base's.

Re-scoping summary for reviewers: the IC rationale this PR was opened with was tested and did not hold — I have left the falsification in the body rather than quietly deleting it, and the real cause of those misses turned out to be unrelated to shapes (#9802: cc's module is full-outlined, so there was no inline property-read cache to hit at all). What stands is the allocation half, which the gc-churn lane's allocation-site histogram independently makes the largest single allocation category in the program: 43.5 % of the streamed turn, sourced to exactly these per-segment records.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main via merge train #9804 (rebase-merged, so your commits keep their authorship). Thanks!

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Attribution evidence for this PR's allocation half, from the gc-churn lane, with
one correction that matters for anyone reading the allocation-site census.

I ran the compiled claude-code TUI (cc_gc5, i.e. main + #9794 + #9795) on a
quiet box with a macOS sample call graph and a 4x finer alloc census
(PERRY_ALLOC_SITE_SAMPLE=16384) in the same 3300-character streamed turn, then
walked the sample's call tree upward from every js_string_from_bytes_with_capacity
leaf. Callers by sample weight:

caller share
intl::segmenter::build_segmentsintl::rooted_fields::set_field 63 %
js_regexp_new 18 %
object::iterator_prototypes::call_overridden_iterator_next 15 %
other 4 %

The correction: the alloc-site sampler labels those same allocations
js:get format, js:get unicodeSets and js:get firstDayOfWeek. Those are
nearest-preceding-symbol misattributions — they land in the right module with
the wrong name, which is the worst kind, since unicodeSets would send a reader
to RegExp flags. The independent call graph says build_segments. So the
~110 MB of small strings the census files under those three labels, plus the
44.4 MB of 56-byte objects under js_object_alloc_with_parent < js:get firstDayOfWeek, are Segmenter, not Intl getters and not regex.

Why this raises the PR's ceiling. My lane established a threshold the hard
way: a category below roughly 10 % of turn allocation cannot change the
collection schedule at all. I removed 80 % of the iterator-result objects
(154,557 → 31,535 per 400-character reply, counter-proved) and the copying-minor
count was 81 in both arms — the rig table was flat, because ~4.9 MB out of
~157 MB does not move the nursery fill rate. Segmenter is the one category on
this workload comfortably above that line, so it is the one where removing
allocation should actually change the number of collections rather than just the
bytes. That is worth knowing before you price the work.

Artefacts are in this session's scratchpad (strattr/sample3300.txt,
strattr/alloc.diag, 2,849 sites); happy to hand over the raw files or re-run
with different flags if it helps.

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