Skip to content

Perf/grapheme break match - #11

Merged
redvers merged 12 commits into
mainfrom
perf/grapheme-break-match
Jun 24, 2026
Merged

Perf/grapheme break match#11
redvers merged 12 commits into
mainfrom
perf/grapheme-break-match

Conversation

@redvers

@redvers redvers commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

No description provided.

redvers added 12 commits June 18, 2026 14:10
Replaces the hex-blob-plus-binary-search lookup in
`_UcdGraphemeBreak.of` with a code-generated `match cp` whose arms
are the coalesced ranges from GraphemeBreakProperty.txt + emoji-data
(Extended_Pictographic). Each coalesced range becomes one arm:

    | let c: U32 if (c >= 0x0000) and (c <= 0x0009) => GBControl
    | let c: U32 if (c >= 0x000A) and (c <= 0x000A) => GBLF
    ...
    else GBOther

The binary search used `_UcdHex.byte` to ASCII-decode the table
twice per iteration (range_lo + range_hi, 8 hex pairs each); each
of ~7 iterations did ~16 hex decodes plus the U32 reassembly and
two compares. The match form does one comparison pair per arm
(c >= lo, c <= hi) with no decoding overhead.

The emitter (`unicode_build/grapheme_break_table.pony`) is now the
only place that knows the byte → variant name mapping; the
hand-written `unicode/grapheme_break.pony` closed union is
unchanged. Conformance preserved:

  * 195 unit tests pass
  * GraphemeBreakTest: 1,093 / 1,093 (100%)
  * Normalization Part 1+2: 295,411 / 295,411 (100%)
  * Word / Sentence / LineBreakTest: 100% across all

Source file grows from 27 KB (hex blob) to 93 KB (1,454 match arms).
Runtime cost of the lookup is the question this commit is set up to
answer; bench it against the previous encoding via
`Codepoints.grapheme_break/{ascii,latin1,cjk,emoji}` and
`Graphemes.count/<corpus>/<size>` from the bench suite.
bench_codepoints.pony already benched 7 of the per-codepoint table
lookups (category / script / script_extensions / east_asian_width /
has_binary_property / name / combining_class). Adds the remaining
single-cp `Codepoints.*(u)` lookups so every UCD table has a
measurable point:

  grapheme_break
  canonical_decomposition / compat_decomposition
  simple_upper / simple_lower / simple_title / simple_casefold
  full_upper  / full_lower  / full_title  / full_casefold
  is_full_composition_excluded

Plus the binary `compose_canonical(lhs, rhs)` with both hit
(e + combining acute → é) and miss (A + B → None) cases — that
lookup is on the `Normalize.nfc` hot path so the two-arm cost
matters.

Each new bench runs against the same four representative codepoints
(ASCII 'A', Latin-1 'é', CJK U+4E2D, emoji U+1F600), so per-cp cache
behavior is visible across the lookup family.
Splits the previous flat match (one arm per coalesced range, linear
scan across all 1,800+ arms) into a two-level structure:

  match cp >> 12      // 4096-cp bucket index (no guards)
  | 0x0000 =>
    match cp          // linear chain of upper-bound arms inside this bucket
    | let c: U32 if c <= 0x0009 => GBControl
    | ...
    else GBOther
    end
  | 0x0001 => ...
  ...
  else GBOther
  end

The top-level `match cp >> 12` has no guards — the patterns are
plain integer literals — so LLVM lowers it to a switch / jump
table. The inner per-bucket match has at most ~30 arms, so even a
linear scan inside one bucket is cheap.

Ranges that span more than one bucket (e.g. CJK Unified Ideographs
0x4E00..0x9FFF) get split at bucket boundaries in `_split_at_buckets`
before emission so every emitted arm stays inside exactly one bucket.

Before/after (smoke 1.6KB single-cp probes — `_BenchU32` repeats the
same constant cp, so LLVM can constant-fold most of the dispatch
once these become pure code, but the structural improvement is real
for cursor-driven workloads that pass varying cps through):

                       OLD (binary search)   REFINED (flat match)   NESTED
  ascii  'A' U+0041    580 ns                7 ns                   8 ns
  latin1 'é' U+00E9    687 ns                10 ns                  12 ns
  cjk    '中' U+4E2D    595 ns                739 ns                 6 ns
  emoji  '😀' U+1F600   675 ns                2122 ns                24 ns

The REFINED form (flat single-bound match with gap arms) was great
for ASCII but degraded sharply with cp value because the compiler
emitted a linear scan. The nested form fixes the high-cp regression.

Conformance preserved: 195 unit tests + all five conformance suites
at 100% (Grapheme/Word/Sentence/LineBreakTest + NormalizationTest
Part 1 + Part 2).
Extracts the bucketed-match pattern from `_UcdGraphemeBreak` into
`unicode_build/range_match_emitter.pony` and re-uses it across every
remaining cp-range → variant table:

  _UcdCategory          (UnicodeData.txt General_Category)
  _UcdScript            (Scripts.txt)
  _UcdLineBreak         (LineBreak.txt)
  _UcdEastAsianWidth    (EastAsianWidth.txt)
  _UcdSentenceBreak     (auxiliary/SentenceBreakProperty.txt)
  _UcdWordBreak         (auxiliary/WordBreakProperty.txt)
  _UcdGraphemeBreak     (auxiliary/GraphemeBreakProperty.txt + emoji-data)

Every emitter now ends in a call of the form

    _RangeMatchEmitter.emit(out, ranges, to_variant, "<default variant>")

where `to_variant: {(U8): String val} val` is a lambda mapping the
emitter's property byte to the runtime variant name. The shared helper
handles bucket-splitting (so ranges that cross a 4096-cp boundary get
chopped at the boundary), gap arms (so each match arm is one
upper-bound comparison), and the two-level `match cp >> 12` /
`match cp` emission.

Real-world delta from the bench smoke (1.6KB corpora, NESTED vs OLD
encoding for _UcdGraphemeBreak — same pattern now applies to the
other six tables):

  Graphemes.count/ascii   3759 µs → 1859 µs   2.0x
  Graphemes.count/latin   3223 µs → 1520 µs   2.1x
  Graphemes.count/cjk     1453 µs →  674 µs   2.2x
  Graphemes.count/emoji   1099 µs →  538 µs   2.0x

Single-cp lookups (with constant-cp `_BenchU32`) show 30-100x
because LLVM constant-folds the new pure-code dispatch — that's
optimization headroom that's realized whenever a caller passes a
constant cp.

Conformance preserved: 195 unit tests + every UCD conformance suite
(NormalizationTest Part 1 + 2, GraphemeBreakTest, WordBreakTest,
SentenceBreakTest, LineBreakTest) at 100%.
The remaining holdout. _UcdBinaryProps stored one hex-encoded
(range_lo, range_hi) blob per binary property and dispatched via
`_UcdBinaryRange.has(table, cp)` (binary search). Replaces the
table-getter + shared binary-search pair with one code-generated
`fun _p_<property>(cp: U32): Bool` per property, each body emitted
through the shared `_RangeMatchEmitter` and tagged with U8(1) for
every range — `byte_to_variant` returns `"true"` for any byte and
the `default_variant` is `"false"`.

  primitive _UcdBinaryProps
    fun has(cp: U32, p: BinaryProperty): Bool =>
      match p
      | PropAlphabetic           => _p_alphabetic(cp)
      | PropExtendedPictographic => _p_extended_pictographic(cp)
      ...
      end

    fun _p_alphabetic(cp: U32): Bool =>
      match cp >> 12
      | 0x0000 =>
        match cp
        | let c: U32 if c <= 0x0040 => false
        | let c: U32 if c <= 0x005A => true
        ...

`_UcdBinaryRange` and the per-property `_t_<property>` table getters
are gone — there's no shared runtime helper anymore; each property's
match is self-contained.

Single-cp `Codepoints.has_binary_property` smoke vs OLD encoding:

                OLD    NESTED   speedup
  ascii         643 →  15 ns     43x
  latin-1       678 →  20 ns     34x
  cjk           491 →  15 ns     33x
  emoji         638 →  15 ns     43x

Segmentation cursors (which don't heavily exercise binary
properties) move only marginally — the bigger win was in the
previous commit. Anywhere `has_binary_property` IS in the hot path
(`Trim` with `White_Space`, identifier validators, etc.) gets the
30-40x.

Conformance preserved: 195 unit tests + every UCD conformance suite
(NormalizationTest Part 1 + 2, Grapheme/Word/Sentence/LineBreakTest)
at 100%. Source size: 27 KB → 26800 lines for the generated file —
the trade against the 30-40x runtime win at cost of ponyc compile
time, per the explicit priority you set.
`_UcdCombiningClass.of` was the last table on the hex-blob + binary-
search shape. The source data is `Array[(U32, U8)] val` — one
(codepoint, combining-class) pair per non-zero CCC entry (zero is the
default; absent codepoints fall through to it). Many neighbouring
codepoints share a class (the U+0300..U+0314 combining-above marks
all have class 230), so the entries are first run through a
`_coalesce_ccc` pass that merges adjacent same-class entries into
ranges. The coalesced ranges then feed into the shared
`_RangeMatchEmitter`; `byte_to_variant` simply maps each byte to its
decimal string and `default_variant` is `"0"`.

Single-cp `Codepoints.combining_class` smoke vs OLD encoding:

                OLD    NESTED   speedup
  ascii         263 →  12 ns     22x
  latin-1       268 →  10 ns     27x
  cjk           278 →  12 ns     23x
  emoji         254 →  11 ns     23x

Real-world `Normalize` over 1.6 KB corpora — the cursor walks every
codepoint and calls `combining_class` plus the existing nested
property tables — finally moves on the lookup-heavy paths that the
binary props change couldn't unblock:

                          OLD       NESTED    speedup
  nfd/ascii              1586 µs →  670 µs    2.4x
  nfd/latin-pre          1777 µs →  780 µs    2.3x
  nfc/ascii              3044 µs → 1657 µs    1.8x
  nfc/latin-pre          3171 µs → 1855 µs    1.7x
  nfc/latin-dec          2611 µs → 1442 µs    1.8x
  nfc/combining          2840 µs →  713 µs    4.0x  *
  nfkd/latin-pre         2320 µs → 1389 µs    1.7x
  nfkc/latin-pre         3960 µs → 2476 µs    1.6x

(*) The pathological `combining` corpus — every base letter
followed by five combining marks that need to be canonically
reordered then composed — went 4x. That's the headline case the
NFC fast-path was buying against.

Conformance preserved: 195 unit tests + NormalizationTest Part 1 + 2
(295,411 cases) + every UCD conformance suite at 100%.
The varlen-payload sibling of the other cp-range tables. Each input
entry is (range_lo, range_hi, script_byte_array); the previous
encoding split that into two hex blobs (range/length index + script
data) and ran a binary search. Replaces with a two-level
`match cp >> 12` / `match cp` whose arms each emit an
`Array[Script] val` literal directly:

  match cp >> 12
  | 0x0000 =>
    match cp
    | let c: U32 if c <= 0x00B6 => None
    | let c: U32 if c <= 0x00B7 =>
        recover val [as Script: ScriptAvestan; ScriptCarian; ...] end
    | let c: U32 if c <= 0x02BB => None
    ...
    else None
    end
  | ...
  else None
  end

A local `_split_at_buckets` mirrors `_RangeMatchEmitter`'s helper
for this payload type — the shared helper can't be used as-is
because the variant ISN'T a single byte-keyed string, it's a
per-range array-literal expression. The bucket-split + linear-
within-bucket pattern is the same.

Single-cp `Codepoints.script_extensions` smoke vs OLD encoding:

                OLD    NESTED   speedup
  ascii        1189 →   42 ns    28x
  latin-1      1115 →   53 ns    21x
  cjk          1102 →   48 ns    23x
  emoji        1198 →   65 ns    18x

(Floor here is the per-call `Array[Script] val` allocation —
the dispatch itself is sub-10 ns; the rest is the literal recover.)

Downstream `Scripts.*` ops over 1.6 KB corpora — these walk every
codepoint and call `script_extensions` per cp:

                                       OLD       NESTED    speedup
  Scripts.is_single_script/ascii     1964 µs →  288 µs     6.8x
  Scripts.is_single_script/mixed     12.0 µs →  1.1 µs    11.0x
  Scripts.resolved_script_set/mixed   8.8 µs →  1.2 µs     7.6x
  Scripts.restrict_to[Latin]/ascii   1658 µs →  202 µs     8.2x
  Scripts.restrict_to[Latin]/mixed    7.8 µs →  1.2 µs     6.4x
  Scripts.dominant/mixed             629 µs →  61 µs      10.3x

Trim.trim/1600B also lands its previously-projected
`has_binary_property`/`White_Space` win — 369 µs → 89 µs (4.1x).

Conformance preserved: 195 unit tests + every UCD conformance suite
at 100%.
…ables

Both decomposition tables used hex-blob + binary search (with the
record-decoded payload as the result). Convert each to a two-level
`match cp >> 12` / exact `match cp` dispatch, where every arm emits
the decomposition as an `Array[U32] val` literal:

  primitive _UcdCanonicalDecomp
    fun of(cp: U32): (Array[U32] val | None) =>
      match cp >> 12
      | 0x0000 =>
        match cp
        | 0x00C0 => recover val [as U32: 0x0041; 0x0300] end
        | 0x00C1 => recover val [as U32: 0x0041; 0x0301] end
        ...
        else None
        end
      | ...
      else None
      end

Same shape for `_UcdCompatDecomp` (varlen — up to ~18 cps per
decomposition). Both go through new `_emit_decomp_match` /
`_emit_varlen_decomp_match` helpers in `decomp_table.pony` —
they're decomp-specific rather than shared via `_RangeMatchEmitter`
because the inner match is exact-cp (no upper-bound guards, no gap
arms).

Single-cp `Codepoints.canonical_decomposition` smoke vs OLD:

                OLD    NESTED   speedup
  ascii         391 →  17 ns    23x
  latin-1       466 →  63 ns     7x  (Latin-1 'é' is in the table —
                                       this includes the recover val
                                       Array allocation)
  cjk           363 →   9 ns    40x
  emoji         337 →   8 ns    42x

The biggest payoff is `Normalize.nfd` / `Normalize.nfkd`, which
walk every cp and call `canonical_decomposition` / `compat_decomposition`
per cp. Over 1.6 KB corpora:

                                       OLD       NESTED   speedup
  Normalize.nfd/ascii                 1585 µs →   84 µs    18.9x
  Normalize.nfd/latin-pre             1777 µs →  123 µs    14.4x
  Normalize.nfkd/latin-pre            2320 µs →  144 µs    16.1x

NFC/NFKC still allocate the composition lookup (`_UcdCanonicalCompose`)
on the hex-blob shape, so they see a smaller (but still real) win:

                                       OLD       NESTED   speedup
  Normalize.nfc/ascii                 3044 µs → 1081 µs     2.8x
  Normalize.nfc/latin-pre             3171 µs → 1147 µs     2.8x
  Normalize.nfc/latin-dec             2611 µs →  916 µs     2.8x
  Normalize.nfc/combining             2840 µs →  443 µs     6.4x
  Normalize.nfkc/latin-pre            3960 µs → 1163 µs     3.4x

Doing `_UcdCanonicalCompose` next would close most of the remaining
NFC gap.

Conformance preserved: 195 unit tests + NormalizationTest Part 1+2
(295,411 cases) + every UCD conformance suite at 100%.
The compose lookup is keyed on two codepoints (lhs, rhs) — the
binary search wasn't just doing one set of decodes, it was comparing
lex-ordered (lhs, rhs) pairs per iteration. Replaces with a three-
level dispatch that mirrors the data's natural structure: every cp
has a small set of base letters (lhs) and each base letter combines
with only a handful of combining marks (rhs).

  primitive _UcdCanonicalCompose
    fun of(lhs: U32, rhs: U32): (U32 | None) =>
      match lhs >> 12              ← outer 4096-cp bucket
      | 0x0000 =>
        match lhs                  ← exact lhs in this bucket
        | 0x0041 =>
          match rhs                ← exact rhs combiner
          | 0x0300 => 0x00C0
          | 0x0301 => 0x00C1
          ...
          else None
          end
        | 0x0043 =>
          match rhs
          | 0x0301 => 0x0106
          ...
          else None
          end
        ...
        else None
        end
      | ...
      else None
      end

All three levels are exact-integer matches (no guards), which LLVM
lowers to switches. The data is sorted by (lhs, rhs) so the
emitter streams once through and tracks bucket/lhs transitions.

Single-cp smoke vs OLD:

                              OLD     NESTED   speedup
  compose_canonical[hit]     587 →    20 ns     29x
  compose_canonical[miss]    538 →     4 ns    135x  *

(*) The miss case constant-folds completely under `_BenchU32`
because both inputs are constant; real-world callers (Normalize.nfc)
see the dispatch cost.

But the real prize is `Normalize.nfc` / `Normalize.nfkc` — they
walk every cp and call `compose_canonical` for primary-composite
recombination. Previously NFC was held back by the still-hex-blob
compose table while NFD was already 14-19x faster. The compose
table closes that gap:

                                       OLD       NESTED   speedup
  Normalize.nfc/ascii                 3044 µs →  129 µs    23.6x
  Normalize.nfc/latin-pre             3171 µs →  158 µs    20.1x
  Normalize.nfc/latin-dec             2611 µs →  126 µs    20.7x
  Normalize.nfc/combining             2840 µs →  119 µs    23.9x
  Normalize.nfkc/latin-pre            3960 µs →  216 µs    18.3x

Every NFC variant is now in the same 18-24x league as NFD. The full
Normalize pipeline (decompose → reorder → compose) now compares to
its raw byte-walk cost rather than to its table-lookup cost.

Conformance preserved: 195 unit tests + NormalizationTest Part 1+2
(295,411 cases) + every UCD conformance suite at 100%.
Convert _UcdSimpleUpper/Lower/Title/CaseFold (cp → cp) and
_UcdFullUpper/Lower/Title/CaseFold (cp → Array[U32] val | None) from
hex-blob binary search to the two-level `match cp >> 12` pattern used
by the other UCD tables. The simple tables default the outer/inner
`else` arms to `cp` (identity-preserving). The full tables emit each
mapping as a `recover val [as U32: …] end` literal and default to None.

All 5 conformance suites still at 100% (195 unit + 295,411
NormalizationTest cases + GraphemeBreakTest 1,093 + WordBreakTest
1,826 + SentenceBreakTest 512 + LineBreakTest 16,672).

Case.* corpora benchmarks vs old hex-blob baseline (1.6 KB inputs):
  Case.upper/ascii:    886 µs → 65 µs   (13.7×)
  Case.upper/latin:    767 µs → 57 µs   (13.4×)
  Case.lower/latin:    691 µs → 63 µs   (11.1×)
  Case.title/latin:  1,031 µs → 131 µs  (7.9×)
  Case.fold/latin:     937 µs → 84 µs   (11.2×)
  Case.fold/mixed:     792 µs → 64 µs   (12.3×)
Convert _UcdName.of(cp) from hex-blob binary search + per-call String
allocation to the standard two-level `match cp >> 12` pattern. Each
arm returns a String val literal directly — no allocation. The data
blob is gone; a slim cp-only hex index (`_cps()`) survives only for
from_name's O(n) reverse scan, which now dispatches each cp through
the match-based `of()` instead of building a fresh String per probe.

Source file goes from 2.7 MB / 80 lines (hex blobs + binary search)
to 2.2 MB / 40,146 lines (one match arm per assigned codepoint).
Worth flagging: ponyc takes ~15 minutes to compile the bench binary
with this in place — the CJK Unified bucket alone is ~21k arms in
one inner match. The conformance build is similar. We've been
prioritizing runtime over compile-time throughout this pass; this is
the table where that tradeoff is most pronounced.

All 5 conformance suites still at 100% (195 unit + 295,411
NormalizationTest cases + 1,093 Grapheme + 1,826 Word + 512
Sentence + 16,672 Line).

Codepoints.name + from_name vs hex-blob baseline:
  name/ascii:       770 ns →   8 ns  (96×)
  name/latin1:      835 ns →   9 ns  (93×)
  name/cjk:         477 ns →   5 ns  (95×)
  name/emoji:       709 ns →  20 ns  (35×)
  from_name:     23,066 ns → 1,079 ns (21×)
Convert all 20 generated cp-indexed UCD lookup tables from two-level
`match cp >> 12` dispatch (plus a couple of binary searches) to
two-stage tries backed by static `const` C blobs, compiled by ponyc's
bundled C compiler and reached via FFI.

Why: match-dispatch cost scaled with linear-scan depth inside each
4096-cp bucket (up to ~295 comparisons / ~367 ns for dense BMP scripts).
A trie is two array loads, constant-time. C blobs sidestep the two
embedding traps hit earlier: Pony `Array` literals send ponyc's
typechecker into a multi-hour spin, and `\xNN` String literals corrupt
bytes >= 0x80 (Pony reads `\xNN` as a Unicode codepoint escape). The
blobs live in mmap'd `.rodata`.

New codegen lives in unicode_build/trie_emitter.pony:
  - byte/enum tables (emit)        -> uint8 stage2
  - binary properties (emit_u64)   -> per-cp U64 bitmask; `has` is one
    lookup + bit test, replacing 64 per-property match tables
  - simple case maps (emit_u32)    -> uint32 value, 0 = identity
  - varlen tables (emit_varlen)    -> cp->offset trie over a dedup'd
    payload blob (canonical/compat decomp, full case, script_extensions)
  - name (emit_name)               -> byte payload + named-cp list so
    from_name keeps its reverse scan
  - canonical_compose (emit_compose) -> lhs-trie + per-lhs (rhs,result)
    scan (O(1) reject for non-starters), replacing the 3-level match
Per-table block size is chosen to minimise total bytes; stage1 promotes
from uint8 to uint16 past 256 unique blocks.

Throughput (1600B corpora, varying cps): grapheme segmentation ~15x
faster; word/sentence/line/casefold 1.1-1.4x; NFC/NFKC 1.0-1.15x;
Scripts.dominant/restrict_to 1.3-5x. Known residuals: Scripts.of ~9%
slower (per-cp FFI floor on a near-zero-work op) and name lookup slower
(per-byte String rebuild), traded for the name table shrinking from
2.2 MB to 1.1 KB of generated Pony.

Removes the now-unused _RangeMatchEmitter and _UcdHex. All 195 unit
tests and every UCD conformance suite (NormalizationTest Part 1 + 2,
Grapheme/Word/Sentence/LineBreakTest) pass at 100%.
@redvers
redvers merged commit bf67585 into main Jun 24, 2026
5 of 6 checks passed
@redvers
redvers deleted the perf/grapheme-break-match branch June 24, 2026 23:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant