Skip the segmenter for runs of characters that never join a cluster - #81
Closed
ranihorev wants to merge 4 commits into
Closed
Skip the segmenter for runs of characters that never join a cluster#81ranihorev wants to merge 4 commits into
ranihorev wants to merge 4 commits into
Conversation
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%.
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. |
Author
|
@sindresorhus I apologize for posting it here. This is WIP and was meant to be opened in my fork. Sorry about that |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed and why
stringWidth()has a fast path for strings that are entirely printable ASCII, but everything else goes throughIntl.Segmenterand, 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:
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.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_Pictographiccharacters 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, aGrapheme_Cluster_Break=Prependcode 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 asksIntl.Segmenteritself to confirm that each code point does not merge with ASCII, anExtended_Pictographiccharacter, 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
_standaloneRangesso the test can enumerate them, following the private underscore export thatget-east-asian-widthuses 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.
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 installin both, and timestringWidthon the same inputs from each checkout in separate processes (best of 7 repeats per case, median over 7 runs). The harness used here isbench.shin the notes directory: it importsindex.jsfrom each worktree and prints the table above.Test plan
npm test(runsxo && 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.xoandtsdreport no issues.npm teston the base commit: passes with 229 tests.test.jsplus 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 ofambiguousIsNarrowandcountAnsiEscapeCodes. 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