Skip to content

Skip the segmenter for runs of characters that never join a cluster - #81

Closed
ranihorev wants to merge 4 commits into
sindresorhus:mainfrom
ranihorev:perf/ascii-runs
Closed

Skip the segmenter for runs of characters that never join a cluster#81
ranihorev wants to merge 4 commits into
sindresorhus:mainfrom
ranihorev:perf/ascii-runs

Conversation

@ranihorev

Copy link
Copy Markdown

What changed and why

stringWidth() has a fast path for strings that are entirely printable ASCII, but everything else goes through Intl.Segmenter and, per cluster, the zero-width regex, the ^\p{RGI_Emoji}$ regex, the Hangul jamo scan and the East Asian Width lookup. Profiling a workload of CLI table cells and log lines on Node 22 (node --cpu-prof) put 60% of self time in that segmenter loop and another 14% in the RGI emoji regex, almost all of it spent on clusters that are a single ordinary character. A 73 character ASCII line took 0.14 us, the same line with one trailing emoji 75 us, a 23 character CJK cell 47 us and a line of accented French prose 51 us.

The branch removes that work in four steps, each its own commit:

  1. Inside segmented text, return early for a cluster that is a single printable ASCII character instead of running the cluster checks on it.
  2. Count runs of printable ASCII directly and hand only the text around other characters to the segmenter. Two printable ASCII characters never share a grapheme cluster, so every character of a run except the first and last is a cluster of width 1 on its own; the edge characters stay with the segmenter because they may join a neighbour (combining marks, ZWJ, keycaps, prepends). Runs in the middle of a string are only skipped when they are at least 16 characters long, because a fresh segment() call costs about as much as classifying a dozen clusters. The existing whole-string ASCII check is kept, so pure ASCII input takes exactly the same path as before.
  3. Cache the width of single code point clusters per ambiguous width option, in a bounded map that is cleared when full, since the same ideographs, accented letters and Hangul syllables repeat constantly.
  4. Extend the run fast path from printable ASCII to every character that provably never joins a neighbouring cluster. Under UAX Update to child dependency strip-ansi #29 a boundary between two characters can only be suppressed by an Extend (marks, Grapheme_Extend, Emoji_Modifier), ZWJ, SpacingMark, Prepend, Regional_Indicator, Hangul jamo (GCB L, V, T), Control, CR, LF or an Indic conjunct consonant or linker (GB9c). Between two characters from a set that contains none of those, GB999 always applies. Extended_Pictographic characters qualify because GB11 needs a ZWJ, and precomposed Hangul syllables qualify because GB6 to GB8 need a jamo. The set is given as explicit ranges (printable ASCII, Latin-1 and Latin Extended, IPA, Greek, Cyrillic, general punctuation, currency, letterlike symbols, arrows, math, box drawing, shapes, dingbats, CJK symbols, kana, Bopomofo, Hangul compatibility jamo, enclosed CJK, CJK Unified Ideographs and all extensions, Hangul syllables, CJK compatibility ideographs, fullwidth and halfwidth forms), with the holes for combining marks, the soft hyphen, the Hangul filler, format characters and the halfwidth voiced sound marks carved out. A run's width is the sum of its code points' cached widths; the first and last character of a run stay with the segmenter exactly as in step 2.

A test enumerates all 118,272 code points of the ranges and asserts, with \p{...} escapes from the same ICU data the segmenter uses, that none is a mark, Grapheme_Extend, Emoji_Modifier, Join_Control, Regional_Indicator, Control (Cc, Cf, Cs, Zl, Zp, default ignorable), Hangul jamo, a Grapheme_Cluster_Break=Prepend code point, or from a script with Prepend or Indic conjunct characters, and that every one is Latin, Greek, Coptic, Cyrillic, Han, Hiragana, Katakana, Hangul, Bopomofo, Braille, Common or unassigned. The same test then asks Intl.Segmenter itself to confirm that each code point does not merge with ASCII, an Extended_Pictographic character, a Hangul syllable, a CJK ideograph or a copy of itself, so a future Unicode version that assigned a joining character inside one of the ranges would fail CI rather than change results.

The ranges are exported as _standaloneRanges so the test can enumerate them, following the private underscore export that get-east-asian-width uses for the same purpose. No API change otherwise, no new dependencies. Results are unchanged: the existing suite passes and a differential check of 26,267 inputs (see test plan) gives byte-identical output against the base commit.

Benchmark

Node 22.22.2, Linux x64, run alone on an otherwise idle 4 core box. Each cell is the median over 7 interleaved runs of a fresh process per side, each run taking the best of 7 timed repeats. The first six rows are the average per call over 200 generated strings of that kind; the rest are single strings or small groups.

Benchmark Before After Speedup
ASCII log line, 73 chars (200 strings) 138 ns 145 ns 0.95x
Same line with one trailing emoji (200 strings) 75.2 us 5.1 us 14.87x
Same line with accents on every e (200 strings) 43.8 us 0.63 us 69.86x
CJK cell, 23 chars (200 strings) 46.6 us 0.77 us 60.28x
ANSI colored ASCII line, 108 chars (200 strings) 772 ns 749 ns 1.03x
Short ASCII cell, 8 chars (200 strings) 77 ns 81 ns 0.95x
1000 ASCII characters 1059 ns 1065 ns 0.99x
1000 ASCII characters plus one emoji 850.7 us 24.7 us 34.50x
500 CJK characters 974.7 us 11.5 us 85.12x
Alternating ASCII and CJK, 1000 chars 1407.4 us 19.5 us 72.17x
200 precomposed e-acute 167.9 us 3.3 us 51.37x
100 flag emoji 273.0 us 283.9 us 0.96x
100 precomposed Hangul syllables 207.3 us 2.6 us 80.11x
100 decomposed Hangul syllables (L+V jamo) 215.4 us 210.8 us 1.02x
Russian prose (2 strings) 71.7 us 1.1 us 65.43x
Greek prose 69.0 us 0.95 us 72.91x
Box drawing table row with CJK and ASCII cells 56.6 us 0.97 us 58.66x
Box drawing rule, 62 chars 134.4 us 1.7 us 80.80x
French, German, Spanish prose (3 strings) 51.2 us 0.58 us 88.52x
Japanese prose with ASCII words (2 strings) 53.1 us 0.92 us 57.45x

The two rows that still go through the segmenter for every cluster are untouched by the branch: 100 flags 273.0 us to 283.9 us (0.96x) and 100 decomposed Hangul syllables 215.4 us to 210.8 us (1.02x); they pay one class regex scan per string and one cache lookup attempt per cluster. Pure ASCII and ANSI rows run byte-identical code before and after and are within 5%.

To reproduce: check out the base commit and this branch side by side, npm install in both, and time stringWidth on the same inputs from each checkout in separate processes (best of 7 repeats per case, median over 7 runs). The harness used here is bench.sh in the notes directory: it imports index.js from each worktree and prints the table above.

Test plan

  • npm test (runs xo && ava && tsd): passes on the branch with 285 tests (229 existing plus 56 new). New tests cover the run boundaries for ASCII (emoji, combining marks, ZWJ, prepends, keycaps, CJK, ambiguous width, tabs and ANSI codes at the start, middle and end of long runs), the same boundaries for the wider class (accented letters next to combining marks, CJK next to variation selectors and emoji modifiers, precomposed syllables next to jamo, halfwidth kana next to voiced sound marks, hiragana next to combining dakuten, Cyrillic and Greek next to their combining marks, soft hyphen and Hangul filler inside runs, CJK Extension B runs next to marks and inside surrogate-pair edges, prepend and ZWJ and flags next to CJK runs, mixed script prose, box drawing rows, both ambiguous width settings), cache eviction (3,000 distinct ideographs and the Greek capitals checked twice through both the segmenter path and the run path, with both option values), the enumeration of the standalone ranges described above, and a structural check that the ranges are sorted, disjoint and exclude the known special cases. xo and tsd report no issues.
  • npm test on the base commit: passes with 229 tests.
  • Differential check against the base commit: 26,267 inputs, 26,267 identical, 0 differ. Outputs are compared as serialized JSON (the returned number, or error class plus message). Corpus: the 279 inline cases harvested from test.js plus 8 non-string inputs; 6,000 seeded fuzz strings (up to 30 pieces each) built from a 333 piece alphabet covering printable ASCII, control characters, ANSI CSI and OSC sequences and lone ESC and CSI bytes, Latin-1 and ambiguous width characters, Latin Extended, IPA, Greek, Cyrillic and their combining marks, combining and enclosing marks, spacing marks and Indic conjuncts, format and default ignorable characters, separators, variation selectors, emoji and pieces of ZWJ, keycap, skin tone, flag and tag sequences, emoji modifiers, CJK, kana with combining and spacing dakuten, halfwidth kana and voiced marks, fullwidth forms, fillers, precomposed and decomposed Hangul including extended jamo, CJK Extensions B, G and H and compatibility ideographs, lone surrogates, box drawing and punctuation, the first and last code point of every standalone range and the code points just outside them, and runs of ASCII, CJK and accented letters of lengths 15 to 18 around the run threshold; and 19,980 structured cases placing each alphabet piece at the start, middle and end of ASCII, CJK or accented runs of lengths 0 to 33. Every case uses one of the four combinations of ambiguousIsNarrow and countAnsiEscapeCodes. No inputs were excluded. An earlier revision of the last commit stepped one code unit instead of one code point at run edges, which the fuzz corpus caught on CJK Extension B characters (138 differences); the fix and regression tests are in the commit.

🤖 Generated with Claude Code

https://claude.ai/code/session_015B7oH2ZYikMUumRhNmMQmE


Generated by Claude Code

Inside segmented text, a grapheme cluster that is a single printable
ASCII character went through the zero-width, emoji, Hangul and East
Asian Width checks even though its width is always 1. Return early for
those clusters so mixed text such as accented prose or CJK with ASCII
words spends the regex work only on clusters that need it.
The ASCII fast path only applied to strings that were entirely printable
ASCII. A single emoji or accented character sent the whole string
through the grapheme segmenter and the per-cluster regex checks, so a
long ASCII line with one trailing emoji cost hundreds of times more
than the same line without it.

Printable ASCII characters never share a grapheme cluster with each
other, so runs of them can be counted directly and only the text around
other characters needs the segmenter. The first and last character of a
run may still join a neighbouring cluster (combining marks, ZWJ,
keycaps, prepends), so they are left to the segmenter, and a run in the
middle of the string is only skipped when it is long enough to pay for
the extra segmenter call. Strings that are entirely printable ASCII
keep the existing whole-string check, so that path is unchanged.

With this, a mostly ASCII string costs about as much as the segmenter
work for its few non-ASCII characters instead of for the whole string.
The measured numbers for the complete branch are in the last commit.
Most grapheme clusters are a single code point, and the same code
points repeat constantly in real text (CJK ideographs, accented Latin
letters, Hangul syllables). Their width depends only on the code point
and the ambiguous width option, so keep a small cache per option
instead of running the zero-width, emoji, Hangul and East Asian Width
checks again for every occurrence. The cache is bounded and cleared
when full, so memory stays constant even for adversarial input.

On its own this removes half to two thirds of the time for CJK,
accented Latin and Hangul text; the measured numbers for the complete
branch are in the last commit.
The ASCII run fast path only helped text that was mostly ASCII. Most
other text is made of characters that cannot join a neighbouring
grapheme cluster either: Latin, Greek and Cyrillic letters, CJK
ideographs, kana, precomposed Hangul syllables, punctuation and
symbols. Under UAX sindresorhus#29 a cluster boundary can only be suppressed by an
Extend, ZWJ, SpacingMark, Prepend, Regional_Indicator, Hangul jamo,
Control or Indic conjunct character, so between two characters from a
set that contains none of those GB999 always applies and each one is
its own cluster.

Extend the run fast path to that set, given as explicit ranges, and
count each code point of a run through the single code point width
cache. The first and last character of a run stay with the segmenter
exactly as before, because they may join a neighbour from outside the
set (combining marks, variation selectors, emoji modifiers, jamo,
ZWJ). A test enumerates every code point of the ranges and checks the
Unicode properties above, and also asks Intl.Segmenter itself to
confirm that none of them merges with ASCII, an Extended_Pictographic
character, a Hangul syllable, a CJK ideograph or a copy of itself.

Medians on Node 22 for the whole branch against the base commit: a 23
character CJK cell 46.6 us to 0.77 us, a 73 character accented line
43.8 us to 0.63 us, the same ASCII line with one trailing emoji
75.2 us to 5.1 us, 1000 ASCII characters plus one emoji 851 us to
24.7 us, 500 CJK characters 975 us to 11.5 us, 100 Hangul syllables
207 us to 2.6 us, French prose 51.2 us to 0.58 us, Russian prose
71.7 us to 1.1 us, a box drawing table row 56.6 us to 0.97 us. Rows
that still go through the segmenter are unchanged: 100 flags 273 us to
284 us (0.96x), 100 decomposed Hangul syllables 215 us to 211 us
(1.02x); pure ASCII and ANSI strings are within 5%.
@sindresorhus

Copy link
Copy Markdown
Owner

Thanks for the PR, but I'm not going to merge a huge AI generate PR like this. The benchmarks are also micro-benchmarks that don't reflect real-world situations.

https://sindresorhus.com/blog/micro-benchmark-fallacy

@ranihorev

Copy link
Copy Markdown
Author

@sindresorhus I apologize for posting it here. This is WIP and was meant to be opened in my fork. Sorry about that

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.

2 participants