Skip to content

Commit a6b246a

Browse files
authored
add grapheme-counter entry for fast counting (#135)
* add grapheme-counter entry for fast counting extracted the shared category tables and pair-rule machine into an internal grapheme-core module, and adds `unicode-segmenter/grapheme-counter` * udpate bundle stats using UPDATE_README skill
1 parent 47953b4 commit a6b246a

18 files changed

Lines changed: 586 additions & 256 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"unicode-segmenter": minor
3+
---
4+
5+
Add `unicode-segmenter/grapheme-counter`, a standalone fast counter for extended grapheme clusters.
6+
7+
`countGraphemes()` from the new entry returns exactly the same result as the one in `unicode-segmenter/grapheme`, but runs the boundary rules directly without allocating segment objects or slicing strings:
8+
9+
- 4\~7x faster counting on V8, ~4x on Hermes, with zero allocation
10+
- Standalone entry (4,852 bytes minified, 2,353 bytes gzipped); doesn't carry the segmenter code, and the segmenter entry doesn't carry the counter - even without tree-shaking
11+
- The `countGraphemes()` wrapper in `unicode-segmenter/grapheme` is unchanged and stays as the convenience API

.claude/skills/benchmark/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ This library's priorities, in order: bundle size, runtime perf, memory footprint
2121

2222
Benchmarks import `src/*.js` directly — no build step needed, current checkout state is what gets measured.
2323

24+
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).
25+
2426
## Interpreting results
2527

2628
- **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.

README.md

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Unicode® Standard Annex \#29 - [Revision 47](https://www.unicode.org/reports/tr
3636
Entries for Unicode text segmentation.
3737

3838
- [`unicode-segmenter/grapheme`](#export-unicode-segmentergrapheme): Segments and counts **extended grapheme clusters**
39+
- [`unicode-segmenter/grapheme-counter`](#export-unicode-segmentergrapheme-counter): Standalone fast counter for **extended grapheme clusters**
3940
- [`unicode-segmenter/intl-adapter`](#export-unicode-segmenterintl-adapter): [`Intl.Segmenter`] adapter
4041
- [`unicode-segmenter/intl-polyfill`](#export-unicode-segmenterintl-polyfill): [`Intl.Segmenter`] polyfill
4142

@@ -93,6 +94,8 @@ countGraphemes('a̐éö̲');
9394
> `countGraphemes()` is a small wrapper around `graphemeSegments()`.
9495
>
9596
> If you need it more than once at a time, consider memoization or use `graphemeSegments()` or `splitGraphemes()` once instead.
97+
>
98+
> If counting is on your hot path, use the standalone [`unicode-segmenter/grapheme-counter`](#export-unicode-segmentergrapheme-counter) entry instead.
9699
97100
#### Example: Build an advanced grapheme matcher
98101

@@ -122,6 +125,31 @@ function* matchEmoji(str) {
122125

123126
Or build even more advanced one like an Unicode-aware [TTY string width](https://github.com/cometkim/unicode-string-width) utility.
124127

128+
### Export `unicode-segmenter/grapheme-counter`
129+
130+
A standalone counter for extended grapheme clusters.
131+
132+
The result is identical to `countGraphemes()` of `unicode-segmenter/grapheme`, but it runs the boundary rules directly without allocating segment objects or slicing strings, so counting is 4+ times faster and zero heap usage.
133+
134+
It is a separate entry so that counting doesn't have to carry the segmenter code, and vice versa.
135+
136+
#### Example: Count graphemes fast
137+
138+
```js
139+
import { countGraphemes } from 'unicode-segmenter/grapheme-counter';
140+
141+
countGraphemes('👋 안녕!');
142+
// => 5
143+
```
144+
145+
> [!NOTE]
146+
> The counter module contains duplicate code from `graphemeSegments()`.
147+
>
148+
> If you want a smaller bundle size and performance multipliers are not very important, use the `unicode-segmenter/grapheme` module instead.
149+
>
150+
> Full Segmenter is already optimized enough.
151+
152+
125153
### Export `unicode-segmenter/intl-adapter`
126154

127155
[`Intl.Segmenter`] API adapter (only `granularity: "grapheme"` available yet)
@@ -214,14 +242,16 @@ Since [Hermes doesn't support the `Intl.Segmenter` API](https://github.com/faceb
214242

215243
#### JS Bundle Stats
216244

217-
| Name | Unicode® | ESM? | Size | Size (min) | Size (min+gzip) | Size (min+br) | Size (min+zstd) |
218-
|------------------------------|----------|------|----------:|-----------:|----------------:|--------------:|----------------:|
219-
| `unicode-segmenter/grapheme` | 17.0.0 | ✔️ | 9,032 | 5,175 | 2,507 | 2,254 | 2,551 |
220-
| `graphemer` | 15.0.0 | ✖️ ️| 410,435 | 95,104 | 15,752 | 10,660 | 15,911 |
221-
| `grapheme-splittetr` | 10.0.0 | ✖️ | 122,254 | 23,682 | 7,852 | 4,802 | 6,753 |
222-
| `@formatjs/intl-segmenter`* | 17.0.0 | ✖️ | 268,301 | 176,759 | 45,988 | 31,701 | 45,370 |
223-
| `unicode-segmentation`* | 15.1.0 | - | 56,529 | 52,439 | 24,108 | 17,343 | 24,375 |
224-
| `Intl.Segmenter`* | - | - | 0 | 0 | 0 | 0 | 0 |
245+
| Name | Unicode® | ESM? | Size | Size (min) | Size (min+gzip) | Size (min+br) | Size (min+zstd) |
246+
|-------------------------------------------------|----------|------|--------:|-----------:|----------------:|--------------:|----------------:|
247+
| `unicode-segmenter/grapheme` | 17.0.0 | ✔️ | 9,009 | 5,171 | 2,497 | 2,249 | 2,544 |
248+
| `unicode-segmenter/grapheme-counter` | 17.0.0 | ✔️ | 7,912 | 4,852 | 2,353 | 2,120 | 2,396 |
249+
| `unicode-segmenter/grapheme + grapheme-counter` | 17.0.0 | ✔️ | 9,985 | 5,489 | 2,571 | 2,310 | 2,612 |
250+
| `graphemer` | 15.0.0 | ✖️ ️ | 410,435 | 95,104 | 15,752 | 10,660 | 15,911 |
251+
| `grapheme-splitter` | 10.0.0 | ✖️ | 122,254 | 23,682 | 7,852 | 4,802 | 6,753 |
252+
| `@formatjs/intl-segmenter`* | 17.0.0 | ✖️ | 268,301 | 176,759 | 45,988 | 31,701 | 45,370 |
253+
| `unicode-segmentation`* | 15.1.0 | - | 56,529 | 52,439 | 24,108 | 17,343 | 24,375 |
254+
| `Intl.Segmenter`* | - | - | 0 | 0 | 0 | 0 | 0 |
225255

226256
* `@formatjs/intl-segmenter` handles grapheme, word, and sentence, but it's not tree-shakable.
227257
* `unicode-segmentation` size contains only minimum WASM binary and its bindings to execute benchmarking. It will increases to expose more features.
@@ -230,12 +260,14 @@ Since [Hermes doesn't support the `Intl.Segmenter` API](https://github.com/faceb
230260

231261
#### Hermes Bytecode Stats
232262

233-
| Name | Bytecode size | Bytecode size (gzip)* |
234-
|------------------------------|--------------:|----------------------:|
235-
| `unicode-segmenter/grapheme` | 19,354 | 10,713 |
236-
| `graphemer` | 134,085 | 31,770 |
237-
| `grapheme-splitter` | 63,942 | 19,165 |
238-
| `@formatjs/intl-segmenter` | 329,547 | 136,751 |
263+
| Name | Bytecode size | Bytecode size (gzip)* |
264+
|-------------------------------------------------|--------------:|----------------------:|
265+
| `unicode-segmenter/grapheme` | 19,721 | 10,874 |
266+
| `unicode-segmenter/grapheme-counter` | 18,210 | 10,069 |
267+
| `unicode-segmenter/grapheme + grapheme-counter` | 20,667 | 11,371 |
268+
| `graphemer` | 134,085 | 31,770 |
269+
| `grapheme-splitter` | 63,942 | 19,165 |
270+
| `@formatjs/intl-segmenter` | 329,547 | 136,751 |
239271

240272
* The installation size contains _compressed_ assets.
241273

benchmark/_helper.js

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import * as fs from 'node:fs/promises';
12
import * as zlib from 'node:zlib';
23
import { promisify } from 'node:util';
34

5+
import prettyBytes from 'pretty-bytes';
6+
47
let gzip = promisify(zlib.gzip);
58
let brotli = promisify(zlib.brotliCompress);
69
let zstd = promisify(zlib.zstdCompress);
@@ -42,3 +45,140 @@ export async function reportHermesStats(name, bin) {
4245
'Bytecode size (gzip)': byteLength(await gzip(bin)),
4346
};
4447
}
48+
49+
/**
50+
* Print stat reports as a table.
51+
*
52+
* Defaults to `console.table`; set `PRINT_FORMAT=markdown` to emit a
53+
* README-ready markdown table instead (names in backticks, numbers
54+
* comma-formatted and right-aligned).
55+
*/
56+
export function printStats(reports) {
57+
if (process.env.PRINT_FORMAT !== 'markdown') {
58+
console.table(reports);
59+
return;
60+
}
61+
62+
let columns = [];
63+
for (let report of reports) {
64+
for (let key of Object.keys(report)) {
65+
if (!columns.includes(key)) columns.push(key);
66+
}
67+
}
68+
69+
let rows = reports.map(report => columns.map((key, i) => {
70+
let value = report[key];
71+
if (value == null) return '';
72+
if (typeof value === 'number') return value.toLocaleString('en-US');
73+
return i === 0 ? `\`${value}\`` : String(value);
74+
}));
75+
76+
let widths = columns.map((key, i) => {
77+
return Math.max(key.length, ...rows.map(row => row[i].length));
78+
});
79+
80+
let lines = [
81+
`| ${columns.map((key, i) => key.padEnd(widths[i])).join(' | ')} |`,
82+
`|${widths.map((w, i) => i === 0 ? '-'.repeat(w + 2) : `${'-'.repeat(w + 1)}:`).join('|')}|`,
83+
...rows.map(row => {
84+
return `| ${row.map((cell, i) => i === 0 ? cell.padEnd(widths[i]) : cell.padStart(widths[i])).join(' | ')} |`;
85+
}),
86+
];
87+
console.log(lines.join('\n'));
88+
}
89+
90+
/**
91+
* Update a stats table in README.md with fresh report values, when
92+
* `UPDATE_README=true`.
93+
*
94+
* The table is located by its `#### <section>` heading. Rows are
95+
* matched by library name and columns by header text, both ignoring
96+
* the `*` footnote marks, which are preserved as-is. Cells with no
97+
* matching report column (e.g. Unicode®, ESM?) and rows with no
98+
* matching report (e.g. `Intl.Segmenter`) are left untouched.
99+
*
100+
* @param {Array<Record<string, unknown>>} reports
101+
* @param {string} section heading text, e.g. 'JS Bundle Stats'
102+
*/
103+
export async function updateReadmeStats(reports, section) {
104+
if (process.env.UPDATE_README !== 'true') return;
105+
106+
let readmeUrl = new URL('../README.md', import.meta.url);
107+
let source = await fs.readFile(readmeUrl, 'utf-8');
108+
let lines = source.split('\n');
109+
110+
let headingPattern = new RegExp(
111+
`^#{1,6}\\s+${section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`,
112+
);
113+
let heading = lines.findIndex(line => headingPattern.test(line));
114+
let head = heading < 0
115+
? -1
116+
: lines.findIndex((line, i) => i > heading && line.trimStart().startsWith('|'));
117+
if (head < 0) {
118+
console.warn(`README: no "${section}" table found, skipped`);
119+
return;
120+
}
121+
let end = head + 1;
122+
while (end < lines.length && lines[end].trimStart().startsWith('|')) end += 1;
123+
124+
let split = line => line.trim().replace(/^\||\|$/g, '').split('|').map(cell => cell.trim());
125+
let stripMark = text => text.replace(/\*+$/, '').trim();
126+
127+
let header = split(lines[head]);
128+
let aligns = split(lines[head + 1]).map(cell => {
129+
return cell.endsWith(':') ? (cell.startsWith(':') ? 'center' : 'right') : 'left';
130+
});
131+
let body = lines.slice(head + 2, end).map(split);
132+
let colKeys = header.map(stripMark);
133+
134+
let byName = new Map(reports.map(report => [String(report.name), report]));
135+
for (let report of reports) {
136+
// e.g. the 'unicode-segmentation (wasm-bindgen)' report matches
137+
// the `unicode-segmentation` row
138+
let alias = String(report.name).replace(/\s*\([^)]*\)$/, '');
139+
if (!byName.has(alias)) byName.set(alias, report);
140+
}
141+
142+
let matched = new Set();
143+
for (let row of body) {
144+
let report = byName.get(stripMark(row[0]).replace(/`/g, '').trim());
145+
if (!report) continue;
146+
matched.add(report);
147+
for (let i = 1; i < header.length; i++) {
148+
let value = report[colKeys[i]];
149+
if (value === undefined) continue;
150+
row[i] = typeof value === 'number' ? value.toLocaleString('en-US') : String(value);
151+
}
152+
}
153+
for (let report of reports) {
154+
if (!matched.has(report)) {
155+
console.warn(`README: no "${section}" row for '${report.name}'; add it manually`);
156+
}
157+
}
158+
159+
let widths = header.map((cell, i) => {
160+
return Math.max(cell.length, 3, ...body.map(row => (row[i] ?? '').length));
161+
});
162+
let pad = (cell, i) => aligns[i] === 'right' ? cell.padStart(widths[i]) : cell.padEnd(widths[i]);
163+
let render = row => `| ${header.map((_, i) => pad(row[i] ?? '', i)).join(' | ')} |`;
164+
let separator = `|${widths.map((w, i) => {
165+
if (aligns[i] === 'right') return `${'-'.repeat(w + 1)}:`;
166+
if (aligns[i] === 'center') return `:${'-'.repeat(w)}:`;
167+
return '-'.repeat(w + 2);
168+
}).join('|')}|`;
169+
170+
let next = [
171+
...lines.slice(0, head),
172+
render(header),
173+
separator,
174+
...body.map(render),
175+
...lines.slice(end),
176+
].join('\n');
177+
178+
if (next === source) {
179+
console.log(`README: "${section}" table is up to date`);
180+
return;
181+
}
182+
await fs.writeFile(readmeUrl, next);
183+
console.log(`README: updated "${section}" table`);
184+
}

benchmark/emoji/bundle-stats.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import * as path from 'node:path';
44
import { build } from 'esbuild';
55

6-
import { reportBundleStats } from '../_helper.js';
6+
import { printStats, reportBundleStats } from '../_helper.js';
77

88
let baseDir = import.meta.dirname;
99

@@ -37,4 +37,4 @@ let reports = await Promise.all(
3737
}),
3838
);
3939

40-
console.table(reports);
40+
printStats(reports);

benchmark/general/bundle-stats.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import * as path from 'node:path';
44
import { build } from 'esbuild';
55

6-
import { reportBundleStats } from '../_helper.js';
6+
import { printStats, reportBundleStats } from '../_helper.js';
77

88
let baseDir = import.meta.dirname;
99

@@ -35,4 +35,4 @@ let reports = await Promise.all(
3535
}),
3636
);
3737

38-
console.table(reports);
38+
printStats(reports);
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Importing both entries shares `grapheme-core.js`; the counter adds
2+
// only its counting loop on top of the segmenter.
3+
export {
4+
graphemeSegments,
5+
splitGraphemes,
6+
countGraphemes,
7+
} from '../../../src/grapheme.js';
8+
export {
9+
countGraphemes as countGraphemesFast,
10+
} from '../../../src/grapheme-counter.js';
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export {
2+
countGraphemes,
3+
} from '../../../src/grapheme-counter.js';

benchmark/grapheme/bundle-stats-hermes.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as path from 'node:path';
22
import * as fs from 'node:fs/promises';
33
import Metro from 'metro';
44
import { $ } from 'zx';
5-
import { reportHermesStats } from '../_helper.js';
5+
import { printStats, reportHermesStats, updateReadmeStats } from '../_helper.js';
66

77
let baseDir = import.meta.dirname;
88

@@ -18,6 +18,8 @@ let config = await Metro.loadConfig(undefined, {
1818

1919
let libs = [
2020
['unicode-segmenter/grapheme', 'bundle-entries/unicode-segmenter.js'],
21+
['unicode-segmenter/grapheme-counter', 'bundle-entries/unicode-segmenter-counter.js'],
22+
['unicode-segmenter/grapheme + grapheme-counter', 'bundle-entries/unicode-segmenter-both.js'],
2123
['graphemer', 'bundle-entries/graphemer.js'],
2224
['grapheme-splitter', 'bundle-entries/grapheme-splitter.js'],
2325
['@formatjs/intl-segmenter', 'bundle-entries/formatjs-intl-segmenter.js'],
@@ -47,4 +49,5 @@ for (let lib of libs) {
4749
);
4850
}
4951

50-
console.table(reports);
52+
printStats(reports);
53+
await updateReadmeStats(reports, 'Hermes Bytecode Stats');

benchmark/grapheme/bundle-stats.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import * as path from 'node:path';
44
import { build } from 'esbuild';
55
import { getBinary as getBinary_unicode_segmentation } from 'unicode-segmentation-wasm/wasm';
66

7-
import { reportBundleStats, reportWasmBindingStats } from '../_helper.js';
7+
import { printStats, reportBundleStats, reportWasmBindingStats, updateReadmeStats } from '../_helper.js';
88

99
let baseDir = import.meta.dirname;
1010

1111
let libs = [
1212
['unicode-segmenter/grapheme', 'bundle-entries/unicode-segmenter.js'],
13+
['unicode-segmenter/grapheme-counter', 'bundle-entries/unicode-segmenter-counter.js'],
14+
['unicode-segmenter/grapheme + grapheme-counter', 'bundle-entries/unicode-segmenter-both.js'],
1315
['graphemer', 'bundle-entries/graphemer.js'],
1416
['grapheme-splitter', 'bundle-entries/grapheme-splitter.js'],
1517
['@formatjs/intl-segmenter', 'bundle-entries/formatjs-intl-segmenter.js'],
@@ -63,4 +65,5 @@ let reports = await Promise.all(
6365
reports.push(bindingReport);
6466
}
6567

66-
console.table(reports);
68+
printStats(reports);
69+
await updateReadmeStats(reports, 'JS Bundle Stats');

0 commit comments

Comments
 (0)