further optimization based on the deep analysis of V8 opt chain - #144
Conversation
🦋 Changeset detectedLatest commit: 4b8cdb3 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
commit: |
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
Merging this PR will improve performance by 24.88%
|
d610a02 to
74d8d58
Compare
|
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... |
- 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. |

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()andnextState()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_pairsnow stores0 | 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:0- always a boundary; no state bit can match1- never a boundary; bit 0 of the state is always set2/4/8- GB12/13, GB11, GB9c; each rule owns exactly one state bitThis 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 writest & 21instead ofst & 20 | 1.2.
cat()split into a hot half andcatRare()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
catuninlinable 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:
T0covers0x0000–0x309F; it ends just past the last combining kana mark, so the entire CJK region resolves without a table (U+3297andU+3299are the only non-Anycode points up to0xA65F)T1starts at0xA660instead of0xA000; nothing below that is non-AnyBoth assumptions are checked in
scripts/unicode.jsagainst 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_MINwereletbindings 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
countGraphemesfrom −4% to +32% on ASCII, and deleting all three was also smaller after compression, since repeated literals back-reference well.4.
splitGraphemes()owns its loopIt 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 bytecodesMaglev 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
ifchain instead of aswitch(the switch spends 3 extra bytecodes materializing the discriminant) and thest & 21/st & 41masks.There is a comment on the function recording the budget and how to check it.
6.
decodeUnicodeDatais callback-styledecodeUnicodeData(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.decodeUnicodeFlatDatasizes its buffers fromdata.length >> 1(a safe upper bound) and slices.fillusesTypedArray.prototype.fill. This improves the initialization cost a bit.Tried and rejected by perf agent
0xC418 >> cat & 1gate aroundnextState— a win in the July experiments, now −43% worst case and +19 gzip. Inlining made it redundant, and the extra branch hurts.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 thancount += +!(st & m); V8 folds the boolean coercion but keeps a branch for the ternary.cat()by BMP/astral — the change that first exposed the inlining cliff.