--- name: benchmark description: Measure runtime perf, bundle size, and memory impact of unicode-segmenter changes. Use when evaluating any src/ change, running yarn perf / bundle-stats / memory-stats, comparing against a baseline revision, or when benchmark numbers look noisy or contradictory. --- # Benchmarking unicode-segmenter This library's priorities, in order: bundle size, runtime perf, memory footprint. Measure all three before claiming a change is a win — they regularly trade against each other. ## Commands | Axis | Command | Notes | |---|---|---| | Runtime perf (Node) | `yarn perf:grapheme` (also `:general`, `:emoji`) | mitata; script passes `--expose-gc` | | Runtime perf (Bun) | `bun --bun run perf:grapheme` | `--bun` forces the Bun runtime instead of Node | | Runtime perf (browser) | `yarn perf:grapheme:browser` | vite page; capture procedure in the `bench-records` skill | | Runtime perf (Hermes / QuickJS) | `yarn perf:grapheme:hermes`, `yarn perf:grapheme:quickjs` | Metro-based; see Metro warning | | Bundle size | `yarn bundle-stats:grapheme` | esbuild; minified + gzip + brotli per library | | Hermes bytecode | `yarn bundle-stats:grapheme:hermes` | React Native proxy metric | | Memory | `yarn memory-stats:grapheme` | retained heap per library; forked child per lib, median of 5 samples × 10 uses | Benchmarks import `src/*.js` directly — no build step needed, current checkout state is what gets measured. Bundle-stats env options: `PRINT_FORMAT=markdown` prints a README-ready markdown table instead of `console.table`; `UPDATE_README=true` rewrites the matching README comparison table in place (rows/columns matched by name ignoring `*` footnote marks, which are preserved; hand-maintained cells like Unicode®/ESM? and the `Intl.Segmenter` row are untouched). ## Interpreting results - **Compare ratios within a run, not absolute ns across runs.** Apple Silicon clock scaling (3.7–4.5 GHz observed on M4 Pro) swings absolute numbers ±20% between runs. Each perf run includes the competitors, so the `summary` "x faster than" lines are the stable signal. - Take best-of-3 when absolute numbers matter; close heavy apps first. - Do not add mitata's `.gc('inner')` when comparing — it attributes per-iteration GC cost to the benchmark and distorts small cases. - For bundle size, gzip/brotli are what users pay. Raw or minified chars can move opposite to compressed size (the base36-VLQ data encoding beats base64 after gzip despite more raw characters). - Per-case reporting: the six shared cases (ASCII, emoji/ZWJ, Hindi/InCB, Zalgo, tweet, code) rarely move in one direction. Report each; flag any case regressing >10%. ## A/B against a baseline revision Never move test inputs by copy-paste: they contain invisible codepoints (ZWJ, variation selectors) and were mangled exactly that way once. Always import `benchmark/grapheme/_testcases.js`. 1. Materialize the baseline's module closure with read-only git into the scratchpad: `git show origin/main:src/grapheme.js > /baseline/grapheme.js` — repeat for `core.js`, `_grapheme_data.js`, and whatever else that revision imports. 2. Write one scratch mitata script importing BOTH implementations plus `_testcases.js`, registering old/new for each case in the same process. 3. Run at least twice with declaration order swapped to detect order bias. ## Size-sensitive coding notes (learned the hard 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. ## Metro warning (Hermes scripts) Metro spawns a worker farm per invocation. When calling Metro programmatically (custom measurement pipelines, loops), always pass `maxWorkers: 1` in the `Metro.loadConfig` overrides — a runaway farm once spawned enough node processes to freeze the machine. One-off runs of the repo's own scripts are fine.