diff --git a/.changeset/huge-hotels-ask.md b/.changeset/huge-hotels-ask.md new file mode 100644 index 0000000..b67b733 --- /dev/null +++ b/.changeset/huge-hotels-ask.md @@ -0,0 +1,7 @@ +--- +"unicode-segmenter": patch +--- + +Removed pinned `graphemeSegments()` in the module scope to make all APIs able to be three-shaken properly. + +It was introduced when they all use `graphemeSegments()` as the core. But now they are all have their own loop. diff --git a/.claude/skills/codspeed-debug/SKILL.md b/.claude/skills/codspeed-debug/SKILL.md index c8ba65b..5d3b6a8 100644 --- a/.claude/skills/codspeed-debug/SKILL.md +++ b/.claude/skills/codspeed-debug/SKILL.md @@ -12,8 +12,8 @@ CI (`.github/workflows/codspeed.yml`) runs `node benchmark/grapheme/codspeed.js` - V8 runs with stability flags (e.g. `--predictable --hash-seed=1`) and the plugin measures roughly: a few warmup calls → forced `gc()` → **one** measured call. - Consequence 1: the measured call sees whatever tier the code reached during warmup. The repo defends with a 2000-iteration `beforeAll` warmup in `codspeed.js` — do not remove it. - Consequence 2 (the big trap): **forced GC clears weak references embedded in optimized code** (deopt reason: `embedded weak objects cleared` — e.g. hidden-class maps of objects with no live instances), so optimized code deopts right before the measured call, producing phantom regressions up to ~60%. - - Defense in `src/grapheme.js`: the pin block stashing a live generator and its first result on `PAIR._keep`, which keeps the segment-object hidden classes strongly reachable across GC. Do not remove it. - - A module-scope `const _keep = ...` does NOT work — unreferenced module bindings don't survive module evaluation; the pin must hang off an object that stays reachable (`PAIR`). + - `src/grapheme.js` carried a defense against this from #133 to #145: a block stashing a live generator and its first result on `PAIR._keep`, so the segment-object hidden classes stayed strongly reachable across GC. It was removed after measuring it inert on the current (generator-based) code — the deopt it guarded was diagnosed on a class-iterator rewrite that never shipped. If you re-add anything like it, note that a module-scope `const _keep = ...` does NOT work: unreferenced module bindings don't survive module evaluation, so the pin has to hang off an object that stays reachable. + - Before re-adding it, measure it properly: a single instrumented call cannot resolve this for `graphemeSegments`, which allocates ~120 objects per call, so whether a scavenge lands inside the measured call swings it ±30%. Run 50 `{ gc(); one call }` cycles under callgrind and subtract a `{ gc() }`-only run of the same shape; that showed 92,965 vs 92,915 instructions per call (0.05%), with the pin making each major GC ~26 k instructions *more* expensive. ## Local replication recipe diff --git a/README.md b/README.md index fba998c..90d9e40 100644 --- a/README.md +++ b/README.md @@ -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 | ✔️ | 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 | +| `unicode-segmenter/grapheme` | 17.0.0 | ✔️ | 7,767 | 4,876 | 2,325 | 2,104 | 2,374 | +| `unicode-segmenter/grapheme` (full*) | 17.0.0 | ✔️ | 10,173 | 5,725 | 2,607 | 2,322 | 2,668 | | `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 | @@ -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` | 15,892 | 9,012 | -| `unicode-segmenter/grapheme` (full) | 16,097 | 9,176 | +| `unicode-segmenter/grapheme` | 15,794 | 8,916 | +| `unicode-segmenter/grapheme` (full) | 16,003 | 9,073 | | `graphemer` | 134,085 | 31,770 | | `grapheme-splitter` | 63,942 | 19,165 | | `@formatjs/intl-segmenter` | 329,547 | 136,751 | diff --git a/src/grapheme.js b/src/grapheme.js index 163ae96..bf0ec83 100644 --- a/src/grapheme.js +++ b/src/grapheme.js @@ -437,15 +437,3 @@ export function collectGraphemes(input) { result.push(input.slice(index)); return result; } - -// Keep one live segmenter and its result reachable so their hidden classes stay strongly referenced. -// Otherwise a major GC while no segmenter is alive clears the maps embedded weakly in JIT-optimized code, -// and the next run pays deoptimization and re-learning costs. -// -// The instances are stashed on an always-retained module object, -// since unreferenced module-scope bindings do not survive module evaluation. -{ - let keep = graphemeSegments('_'); - // @ts-ignore intended expando - PAIR._keep = [keep, keep.next()]; -}