Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/smooth-dragons-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"unicode-segmenter": patch
---

Optimize the hot loop based on a eep analysis of the V8 optimization chain.

As the result, the bundle size, speed, and memory usage. All three axes are improved. See PR [#144](https://github.com/cometkim/unicode-segmenter/pull/144) for detailed explanation.

- Bundle: −4.2% min+gzip, −3.9% min+brotli on `unicode-segmenter/grapheme` (2,453 → 2,351 gzip); −3.4% / −2.8% on the full entry
- Hermes bytecode: −20.6% (20,015 → 15,892 bytes), −18.6% gzipped
- Runtime (Node.js/V8, per benchmark case)
- `splitGraphemes()` 1.5–2.2x, `countGraphemes()` 1.20–1.43x, `graphemeSegments()` 1.05–1.21x, `collectGraphemes()` 1.01–1.19x.
- Bun/JSC gains are larger, and the interpreter tiers (Hermes, QuickJS) improve 5–23%
- Memory: lookup tables 20.6 kB → 19.1 kB, retained heap 228 kB → 218 kB, module init 1.7 ms → 1.5 ms

The state compaction strategy is the major part. It is valid across all optimization tiers of the V8 runtime (Jitless, Maglev, TurboFan) and has been consistently improved across all other engines.

Another noticeable change is `splitGraphemes()`, it now owns its loop, just like `countGraphemes()`.
It produces a 30-60% performance improvement. The increase in size is roughly free after compression, since the fourth byte-aligned copy of the loop back-references the other three. And margins are amortized by other improvements.

All the analysis have done by Claude Opus 5, well-done!
3 changes: 2 additions & 1 deletion .claude/skills/benchmark/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ Never move test inputs by copy-paste: they contain invisible codepoints (ZWJ, va

## Size-sensitive coding notes (learned the hard way)

- esbuild inlines `const`-bound numeric literals into every use site but preserves `let` bindings. Shared numeric bounds in `src/grapheme.js` (`BMP_MAX`, `T1_MIN`, `T2_MIN`) are deliberately `let` — keep them that way.
- esbuild inlines `const`-bound numeric literals into every use site but preserves `let` bindings. That makes a shared `let` bound tempting for size, but it compiles to a mutable context slot the engine cannot fold into the comparison: hoisting the hottest bound in `src/grapheme.js` into a `let` measured up to 25% slower on the count loop, and removing the three shared bounds was *also* smaller after gzip. Spell hot-path bounds out as literals.
- Hot-loop helpers live near engine inlining cliffs — check them with `--trace-turbo-inlining` (prints `bytecode size: N`) after any edit to `cat()` or `nextState()`. TurboFan refuses callees at 460 bytecodes (`cat` sat at 453 before it was split into `cat`/`catRare`; falling off cost 15-45%), Maglev refuses at 100 (`nextState` must stay ≤ 99, worth 8-26% there). Measure the mid/low tiers with `node --no-turbofan` and `node --jitless`; a change can win big on TurboFan and still lose on Maglev.
- Generator syntax is the deliberate choice over a class-based iterator: a class was 13–34% faster on V8 but cost ~+1.6 KiB Hermes bytecode after Babel class lowering (Hermes can't parse class syntax natively). Don't convert to a class without re-measuring V8, JSC, Hermes, and all three size metrics.
- `codePointAt()` beat manual `charCodeAt` surrogate pairing on every measured engine (17–20% faster on Hermes) and is smaller. Don't "optimize" it back.

Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Utilities for text segmentation by extended grapheme cluster rules.

You can split a string into graphemes by simply consuming the `splitGraphemes()` generator.

`splitGraphemes()` is a simple wrapper around `graphemeSegments()`.
It yields substrings directly, so it allocates less than `graphemeSegments()`.

```js
import { splitGraphemes } from 'unicode-segmenter/grapheme';
Expand All @@ -69,7 +69,7 @@ import { splitGraphemes } from 'unicode-segmenter/grapheme';

If you need the result as an array, use `collectGraphemes()` for convenience.

This is a fast version of `[...splitGraphemes(str)]`, 2-4x faster.
This is a fast version of `[...splitGraphemes(str)]`, 1.5-2.5x faster.

```js
import { collectGraphemes } from 'unicode-segmenter/grapheme';
Expand Down Expand Up @@ -237,8 +237,8 @@ Since [Hermes doesn't support the `Intl.Segmenter` API](https://github.com/faceb

| Name | Unicode® | ESM? | Size | Size (min) | Size (min+gzip) | Size (min+br) | Size (min+zstd) |
|--------------------------------------|----------|------|--------:|-----------:|----------------:|--------------:|----------------:|
| `unicode-segmenter/grapheme` | 17.0.0 | ✔️ | 8,684 | 5,133 | 2,453 | 2,220 | 2,505 |
| `unicode-segmenter/grapheme` (full*) | 17.0.0 | ✔️ | 10,494 | 5,777 | 2,732 | 2,435 | 2,796 |
| `unicode-segmenter/grapheme` | 17.0.0 | ✔️ | 7,851 | 4,911 | 2,351 | 2,134 | 2,402 |
| `unicode-segmenter/grapheme` (full*) | 17.0.0 | ✔️ | 10,257 | 5,760 | 2,638 | 2,367 | 2,695 |
| `graphemer` | 15.0.0 | ✖️ | 410,435 | 95,104 | 15,752 | 10,660 | 15,911 |
| `grapheme-splitter` | 10.0.0 | ✖️ | 122,254 | 23,682 | 7,852 | 4,802 | 6,753 |
| `@formatjs/intl-segmenter`* | 17.0.0 | ✖️ | 268,301 | 176,759 | 45,988 | 31,701 | 45,370 |
Expand All @@ -255,8 +255,8 @@ Since [Hermes doesn't support the `Intl.Segmenter` API](https://github.com/faceb

| Name | Bytecode size | Bytecode size (gzip)* |
|-------------------------------------|--------------:|----------------------:|
| `unicode-segmenter/grapheme` | 20,015 | 11,075 |
| `unicode-segmenter/grapheme` (full) | 20,224 | 11,213 |
| `unicode-segmenter/grapheme` | 15,892 | 9,012 |
| `unicode-segmenter/grapheme` (full) | 16,097 | 9,176 |
| `graphemer` | 134,085 | 31,770 |
| `grapheme-splitter` | 63,942 | 19,165 |
| `@formatjs/intl-segmenter` | 329,547 | 136,751 |
Expand All @@ -270,7 +270,7 @@ Retained memory per library after segmenting the benchmark corpus, measured by `

| Name | JS heap | ArrayBuffers* |
|------------------------------|--------:|--------------:|
| `unicode-segmenter/grapheme` | 228 kB | 20.6 kB |
| `unicode-segmenter/grapheme` | 217 kB | 19.1 kB |
| `graphemer` | 2.32 MB | 0 B |
| `grapheme-splitter` | 570 kB | 0 B |
| `@formatjs/intl-segmenter` | 1.95 MB | 0 B |
Expand Down
Loading
Loading