Skip to content

further optimization based on the deep analysis of V8 opt chain - #144

Merged
cometkim merged 4 commits into
mainfrom
opt-v8-analysis
Jul 26, 2026
Merged

further optimization based on the deep analysis of V8 opt chain#144
cometkim merged 4 commits into
mainfrom
opt-v8-analysis

Conversation

@cometkim

Copy link
Copy Markdown
Owner

Mostly assisted by Claude Opus 5 (max effort)


Follow-up to #142. This round is mostly about one thing that turned out to dominate everything else: whether the engine inlines cat() and nextState() into the four segmentation loops. Both were sitting right on a cliff, and one extra comparison in either direction was worth far more than any arithmetic-level tuning.

On top of that, folding the boundary rules into the pair table as bit masks removed a helper entirely, and splitGraphemes() now owns its loop instead of delegating.

Bundle size, speed, and memory usage. All three axes improve.

Changes

1. The pair table holds boundary masks, not rule ids

grapheme_pairs now stores 0 | 1 | 2 | 4 | 8, where each value is a bit mask over the packed sequence state, so the entire boundary decision is a single test:

let boundary = !(st & PAIR[catBefore << 4 | catAfter]);
  • 0 - always a boundary; no state bit can match
  • 1 - never a boundary; bit 0 of the state is always set
  • 2 / 4 / 8 - GB12/13, GB11, GB9c; each rule owns exactly one state bit

This required repacking the state so that every rule-visible bit is one of bits 0–3 (the mask has to stay a single decimal digit, so Uint8Array.from(grapheme_pairs) keeps working), with the two internal bits above them. isBoundary() is deleted, the decision is branchless, and the always-set bit 0 is what lets the transitions write st & 21 instead of st & 20 | 1.

2. cat() split into a hot half and catRare()

The single ladder compiled to 453 bytecodes, against V8's 460-byte TurboFan inlining limit. I found this by pushing it over: adding one BMP/astral nesting level made cat uninlinable and cost 9–45% on every case, including plain ASCII.

The hot half is now 200 bytecodes with plenty of headroom, and two window changes let
it stay short:

  • T0 covers 0x0000–0x309F; it ends just past the last combining kana mark, so the entire CJK region resolves without a table (U+3297 and U+3299 are the only non-Any code points up to 0xA65F)
  • T1 starts at 0xA660 instead of 0xA000; nothing below that is non-Any

Both assumptions are checked in scripts/unicode.js against the raw UCD, like the existing inlined fast paths, so a future Unicode update cannot silently break them.

3. Hot-path bounds are literals again

BMP_MAX / T1_MIN / T2_MIN were let bindings to save bundled bytes.

But a module-scope binding is a mutable context slot that the engine cannot fold into the
comparison. Swapping just the hottest one for a literal moved countGraphemes from −4% to +32% on ASCII, and deleting all three was also smaller after compression, since repeated literals back-reference well.

4. splitGraphemes() owns its loop

It delegated through for (let s of graphemeSegments(input)) yield s.segment, which means a second generator, a segment object, and an iterator result per cluster.

Now it has its own loop yielding slices directly, just like countGraphemes. It is roughly free after compression, since the fourth byte-aligned copy of the loop back-references the other three.

5. nextState() is kept at 99 bytecodes

Maglev refuses to inline anything from 100 bytecodes up. At 110, the rewritten transition was up to 26% slower in that tier while TurboFan numbers still improved; easy to miss, since the default benchmark run tiers straight past Maglev.

It is back under the limit via an if chain instead of a switch (the switch spends 3 extra bytecodes materializing the discriminant) and the st & 21 / st & 41 masks.

There is a comment on the function recording the budget and how to check it.

6. decodeUnicodeData is callback-style

decodeUnicodeData(data, cats, emit) hands each range to a callback instead of building an array of tuples, so module load no longer allocates ~800 short-lived 3-element arrays.

decodeUnicodeFlatData sizes its buffers from data.length >> 1 (a safe upper bound) and slices. fill uses TypedArray.prototype.fill. This improves the initialization cost a bit.

Tried and rejected by perf agent

  • Caller-side 0xC418 >> cat & 1 gate around nextState — a win in the July experiments, now −43% worst case and +19 gzip. Inlining made it redundant, and the extra branch hurts.
  • ASCII fast path in the loops (cp < 0x7F && cp > 0x1F && !catBefore → count/push and skip the general path): +37% on ASCII but −14% Hindi / −19% Zalgo, and +29–46 gzip. One extra compare is 10–15% in these ~6-cycle loops.
  • count += (st & m) ? 0 : 1 — 10–15% slower than count += +!(st & m); V8 folds the boolean coercion but keeps a branch for the ternary.
  • Merging the lookup tables into one ArrayBuffer — V8 charges malloc-exact bytes, so four buffers cost the same as one (40 bytes of headers).
  • Nesting cat() by BMP/astral — the change that first exposed the inlining cliff.

@changeset-bot

changeset-bot Bot commented Jul 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4b8cdb3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
unicode-segmenter Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 26, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/unicode-segmenter@144

commit: 4b8cdb3

@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (b21a623) to head (4b8cdb3).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #144   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            9         9           
  Lines         1035      1057   +22     
=========================================
+ Hits          1035      1057   +22     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codspeed-hq

codspeed-hq Bot commented Jul 26, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 24.88%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 27 improved benchmarks
✅ 21 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
splitGraphemes - corpus=udhr_eng 6.8 ms 4.3 ms +59.15%
splitGraphemes - corpus=udhr_cmn_hans 2 ms 1.3 ms +57.65%
splitGraphemes - corpus=udhr_kor 3.1 ms 2 ms +57.56%
splitGraphemes - Code snippet (combined) 271.5 µs 172.4 µs +57.51%
splitGraphemes - corpus=udhr_hin 5.2 ms 3.4 ms +51.39%
splitGraphemes - Tweet text (combined) 128 µs 84.9 µs +50.78%
splitGraphemes - Lorem ipsum (ascii) 96.8 µs 65.3 µs +48.19%
splitGraphemes - corpus=udhr_mal 4 ms 2.7 ms +46.81%
splitGraphemes - corpus=emoji_test_sequences 5.1 ms 3.5 ms +46.41%
splitGraphemes - Hindi 83.9 µs 59.9 µs +40.1%
splitGraphemes - Emojis 44.7 µs 34.4 µs +29.94%
countGraphemes - corpus=emoji_test_sequences 1.4 ms 1.2 ms +20.16%
countGraphemes - corpus=udhr_cmn_hans 229.6 µs 196.1 µs +17.12%
splitGraphemes - Demonic characters 33.7 µs 29.2 µs +15.4%
countGraphemes - corpus=udhr_kor 358.2 µs 314.7 µs +13.81%
countGraphemes - corpus=udhr_mal 847.3 µs 754.3 µs +12.32%
collectGraphemes - corpus=emoji_test_sequences 2.3 ms 2.1 ms +12.26%
countGraphemes - corpus=udhr_hin 855.7 µs 763.1 µs +12.13%
countGraphemes - corpus=udhr_eng 744.9 µs 668.1 µs +11.5%
countGraphemes - Code snippet (combined) 43.1 µs 39.6 µs +8.73%
... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing opt-v8-analysis (4b8cdb3) with main (b21a623)

Open in CodSpeed

@cometkim

Copy link
Copy Markdown
Owner Author

CodSpeed is now too noisy because of GitHub Actions runner scheduling...

However, on a deeper level in the regression report, it looks like it's actually a great improvement: faster hot path even in the change machine env.

image

@cometkim

Copy link
Copy Markdown
Owner Author

CodSpeed ​​regression reports are consistent, the actual metrics being measured seem to deviate from the target being optimized (V8's e2e JIT pipeline).

However, I have wasted unnecessary time with CodSpeed ​​recently due to unreliable results and noise. I need a more stable performance measurement method, and I'm seriously considering whether I should allocate EC2 instances for my projects, including unicode-segmenter and rkyv-js...

@cometkim

Copy link
Copy Markdown
Owner Author
- count += +!(st & PAIR[catBefore << 4 | catAfter]);
- st = nextState(st, catAfter, cp);
+ let boundary = !(st & PAIR[catBefore << 4 | catAfter]);
+
+ st = nextState(st, catAfter, cp);
+
+ if (boundary) count += 1;

So this was the regression. Specifically for the Maglev tier.

TurboFan can opt well for the boolean coercion. Maglev doesn't. So the regression was a real user-facing problem, not just a CI cosmetic since a segmenter called a few hundred times per interaction lives there.

@cometkim
cometkim merged commit 957898b into main Jul 26, 2026
3 checks passed
@cometkim
cometkim deleted the opt-v8-analysis branch July 26, 2026 19:21
@github-actions github-actions Bot mentioned this pull request Jul 26, 2026
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