Skip to content

Skip unmatchable character runs in the conversion hot path - #1464

Merged
frankslin merged 4 commits into
BYVoid:masterfrom
frankslin:claude/conversion-hotpath-simd-6nmtzp
Jul 24, 2026
Merged

frankslin merged 4 commits into
BYVoid:masterfrom
frankslin:claude/conversion-hotpath-simd-6nmtzp

Conversation

@frankslin

@frankslin frankslin commented Jul 24, 2026 •

Copy link
Copy Markdown
Collaborator

Conversion::AppendConverted and MaxMatchSegmentation advance one character
at a time and pay a trie prefix lookup per position, even for characters
that cannot possibly begin a dictionary key. PrefixMatch now builds a
key-start table at construction time and exposes SkipUnmatchable(), letting
both hot loops consume whole runs of unmatchable characters — ASCII,
punctuation, emoji, and CJK characters that start no key — with a single
scan and one append.

Filtering is by exact code point rather than by UTF-8 lead byte: the common
CJK lead bytes 0xE4..0xE9 all begin some key, so lead-byte filtering skips
only ~10% of characters in typical Traditional-Chinese input, while 69-76%
of positions match no key at all. Character-granularity filtering skips
60-67% of prefix lookups.

Conversion output is byte-for-byte unchanged, enforced by a differential
fuzz test against a per-character reference loop and verified across 9
configs on a corpus with 3 MB of random bytes prepended to 22 MB of Chinese
text. Whole-run CLI times (22 MB Traditional corpus, dictionary load
included):

  config   before   after
  t2s       3.40s   0.52s
  t2tw      3.43s   0.30s
  s2twp     8.40s   4.86s
  t2s (4 MB mixed Chinese/ASCII)
            0.83s   0.11s

Startup is unaffected: single-character input takes the same time as before
(s2twp 5.01s vs 4.87s over 20 runs), and max RSS and page fault counts are
unchanged — .ocd2 dictionaries are fread into a heap buffer at load, so the
skip-table trie walk touches only already-resident pages.

Detailed Changes:

  • Utf8SkipScan.hpp (new private header, not installed):
    • Utf8SkipTable: per-byte candidate table of key start bytes, plus an
      optional per-code-point bitmap over the BMP (8 KiB). 2- and 3-byte
      characters are filtered by exact code point; 4-byte characters keep
      lead-byte filtering. Character-level mode is on exactly when the bitmap
      is non-empty — no separate flag to keep in sync.
    • DecodeCodePoint23(): the single decoder shared by the table builder and
      the scanner, so a key's first character always maps to the bit the
      scanner tests. It never validates continuation bytes, and the scan
      consumes the same byte count as the per-character path, keeping
      behavior equivalent on malformed input.
    • Finalize(): marks the ideographic description operators and their lead
      bytes from UTF8Util::IdeographicDescriptionOperatorArity() over the
      operator range, so the conversion loop's IDS grouping is preserved;
      non-operator characters sharing the 0xE2 lead byte (em dashes, curly
      quotes) stay skippable. A static_assert pins the operator range to the
      3-byte UTF-8 block.
    • AsciiRunLength(): word-at-a-time (SWAR) scan of the leading ASCII run,
      in plain C++ with no intrinsics and no architecture or endianness
      special cases. SSE2/NEON/WASM-SIMD kernels were written and measured
      against it and won by at most 3% end to end, inside run-to-run noise on
      half the cases — the candidate table has already removed the
      per-character trie lookups that dominated, so widening the scan from 8
      to 16 bytes per step no longer moves the total. simdutf was considered
      and rejected: it exposes no API for scanning against a caller-supplied
      candidate table, which is where the speedup actually comes from.
  • MarisaDict::EnumerateKeys():
    • New non-virtual method that invokes a callback per key by walking the
      trie directly, reusing the lexicon when already materialized so lazy
      loading is never forced. Deliberately not a Dict virtual: Dict and
      MarisaDict are both OPENCC_EXPORT and installed, so adding a vtable
      slot would require an ABI bump; the skip-table builder dynamic_casts
      instead, matching how SupportsFastPrefixMatch() already special-cases
      MarisaDict/DartsDict. OPENCC_ABI_VERSION is unchanged.
    • Keys may not be NUL-terminated; the skip-table builder reads only
      within the reported length.
  • PrefixMatch:
    • SkipUnmatchable() returns how many leading bytes may be consumed
      without any prefix lookup. The skip table lives in the opaque Tables
      (no layout or header change for PrefixMatch itself) and is built for
      both the single-dictionary fast path and the table path; both go
      through the existing Tables cache, with fast-path entries marked so a
      matcher-less Tables can never be handed to the table path.
    • MarkKeyFirstChar() marks each key's exact first code point. A key with
      an invalid lead byte only marks its lead (the scanner already stops
      there); a key whose first character is truncated disables
      character-level filtering for the whole table, since its raw byte
      prefix cannot be represented as a code point.
    • CollectSkipTable() recurses into groups via the existing
      GetDictGroupItems() virtual and falls back to GetLexicon() for
      non-Marisa leaves.
  • Conversion / MaxMatchSegmentation:
    • After a failed match, extend the unmatched advance over the following
      unmatchable run in one step instead of one lookup per character.
  • Tests:
    • Differential fuzz (300 random inputs per path mixing dictionary keys,
      CJK, IDS operators, and raw invalid/truncated bytes) comparing
      Conversion::Convert with a per-character reference loop on both the
      single-dictionary fast path and the table path.
    • An invariant test that the scan never crosses any dictionary key's
      first character; a pin that every recognized IDS operator stops the
      scan; character-level cases for characters sharing a lead byte with
      keys, 0xE2-lead punctuation vs IDS operators, and 2-byte characters.
    • AsciiRunLength boundary/offset sweep; ASCII-candidate, invalid,
      truncated, and 4-byte cases; mixed ASCII/Chinese conversion and
      segmentation cases.

claude added 2 commits July 24, 2026 19:40
Conversion::AppendConverted and MaxMatchSegmentation advance one character
at a time and pay a trie prefix lookup per position, even for characters
that cannot possibly begin a dictionary key. PrefixMatch now builds a
key-start-byte table and exposes SkipUnmatchable(), letting both hot loops
consume whole runs of unmatchable characters (ASCII, punctuation and CJK
characters that start no key, emoji) with a vectorized scan and a single
append. Conversion output is byte-for-byte unchanged, enforced by a
differential fuzz test against a per-character reference loop.

Measured with src/tools/SpeedBenchmark.cpp and the CLI --measured_result
flag on corpora derived from test/benchmark/zuozhuan.txt and
test/golden/input/us_constitution_zhs.txt: throughput improves 13-35% on
pure-Chinese text, 1.4-1.8x on mixed Chinese/ASCII text, and 15-30x on
mostly-ASCII text; conversion output stays byte-identical across configs.

Detailed Changes:
- **Utf8SkipScan.hpp (new header, in LIBOPENCC_PRIVATE_HEADERS, not
  installed)**:
  - Utf8SkipTable: per-byte candidate table of key start bytes. Finalize()
    derives the ideographic description operator lead bytes from
    UTF8Util::IdeographicDescriptionOperatorArity() over the operator
    range so the conversion loop's IDS grouping is preserved.
  - AsciiRunLength(): finds the leading ASCII run with SSE2, NEON, or WASM
    SIMD128 (16 bytes per step), falling back to 8-byte SWAR. The NEON
    movemask substitute assumes little-endian lane order, so big-endian
    ARM takes the endian-checked SWAR path.
  - SkipNonCandidateBytes(): consumes whole UTF-8 characters whose lead
    byte starts no key; stops at candidate bytes and at invalid or
    truncated sequences so the callers' per-character path still runs.
- **Dict::EnumerateKeys() (ABI: OPENCC_ABI_VERSION 1.4 -> 1.5)**:
  - New virtual that invokes a callback per key, defaulting to a
    GetLexicon() walk. MarisaDict overrides it with a trie walk (reusing
    the lexicon when already materialized) so lazy loading is never
    forced; DictGroup recurses into children. Keys may not be
    NUL-terminated; the skip-table builder reads only within the reported
    length.
- **PrefixMatch**:
  - SkipUnmatchable() returns how many leading bytes may be consumed
    without any prefix lookup. The skip table lives in the opaque Tables
    (no layout or header change for PrefixMatch itself) and is built for
    both the single-dictionary fast path and the table path; both go
    through the existing Tables cache, with fast-path entries marked so a
    matcher-less Tables can never be handed to the table path.
- **Conversion / MaxMatchSegmentation**:
  - After a failed match, extend the unmatched advance over the following
    unmatchable run in one step instead of one lookup per character.
- **Tests**:
  - Differential fuzz (300 random inputs per path mixing dictionary keys,
    CJK, IDS operators, and raw invalid/truncated bytes) comparing
    Conversion::Convert with a per-character reference loop on both the
    fast path and the table path; an invariant test that the scan never
    crosses any dictionary key's first character; AsciiRunLength
    boundary/offset sweep; ASCII-candidate, IDS, invalid/truncated,
    4-byte, and enumeration-failure fallback cases; mixed ASCII/Chinese
    conversion and segmentation cases.
Lead-byte filtering skips only ~10% of the characters in typical
Traditional-Chinese input because the common CJK lead bytes 0xE4..0xE9 all
begin some key, yet 69-76% of positions match no key at all (t2s carries
about five thousand character keys). Filtering by the exact first code
point instead lets the scan skip 60-67% of prefix lookups. The semantics
are unchanged: ambiguity reporting reads dictionaries through the Dict
interface, not PrefixMatch, and is unaffected.

Measured convert-phase speedups vs the lead-byte baseline
(src/tools/SpeedBenchmark.cpp / CLI --measured_result, 1.9 MB corpora:
test/benchmark/zuozhuan.txt and its s2t-converted Traditional variant):
t2tw 5.3x, tw2sp 2.9x, t2s 1.7x, s2twp 1.3x, s2tw 1.2x, s2t unchanged.
Conversion output stays byte-identical across 8 configs x 6 corpora
(verified against the previous implementation), and max RSS / page fault
counts are unchanged: .ocd2 dictionaries are fread into a heap buffer at
load, so the skip-table trie walk touches only already-resident pages.

Detailed Changes:
- **Utf8SkipTable**:
  - Optional per-code-point bitmap over the BMP (8 KiB): 2- and 3-byte
    characters are filtered by exact code point; 4-byte characters keep
    lead-byte filtering. Character-level mode is on exactly when the
    bitmap is non-empty — no separate flag to keep in sync.
  - internal::DecodeCodePoint23() is the single decoder shared by the
    table builder and the scanner, so a key's first character always maps
    to the bit the scanner tests; it never validates continuation bytes,
    and the scan consumes the same byte count as the per-character path,
    keeping behavior equivalent on malformed input.
  - Finalize() marks ideographic description operators in the bitmap (and
    their lead bytes), with a static_assert pinning the operator range to
    the 3-byte UTF-8 block; non-operator characters sharing the 0xE2 lead
    byte (em dashes, curly quotes) become skippable.
- **PrefixMatch**:
  - MarkKeyFirstChar() marks the exact first code point per key. A key
    with an invalid lead byte only marks its lead (the scanner already
    stops at such bytes); a key whose first character is truncated
    disables character-level filtering for the whole table, since its raw
    byte prefix cannot be represented as a code point.
- **Tests**:
  - Character-level cases: skipping characters that share a lead byte
    with keys, 0xE2-lead punctuation vs IDS operators, 2-byte characters,
    and a pin that every recognized IDS operator stops the scan.
@frankslin
frankslin force-pushed the claude/conversion-hotpath-simd-6nmtzp branch from 002ccb1 to 9ab4ca5 Compare July 24, 2026 19:41
claude added 2 commits July 24, 2026 20:03
Dict and MarisaDict are both OPENCC_EXPORT and installed, so adding a
virtual to either changes their vtable layout for every consumer that
links against libopencc, requiring a relink (hence the ABI bump in the
previous commit). That trade-off is deferred until a release that intends
to bump the ABI anyway; for now, key enumeration for the skip-table
builder goes back to dynamic_cast<const MarisaDict*>, matching how
SupportsFastPrefixMatch() already special-cases MarisaDict/DartsDict
elsewhere in this same file.

Detailed Changes:
- **Dict / DictGroup**: EnumerateKeys() virtual and override removed;
  Dict.hpp no longer needs <functional>.
- **MarisaDict**: EnumerateKeys() keeps its trie-walking implementation
  (including the lexicon-reuse fast path) but is now a plain non-virtual
  method, not part of the Dict interface — adding it does not move any
  vtable slot.
- **PrefixMatch**: CollectSkipTable() recurses into dict groups via the
  existing (pre-existing, non-new) GetDictGroupItems() virtual,
  dynamic_casts each leaf to MarisaDict for the trie walk, and falls back
  to GetLexicon() for any other dict type — used for both the
  single-dictionary fast path and the table path, so character-level
  filtering still applies to both.
- **OPENCC_ABI_VERSION**: reverted 1.5 -> 1.4.
- **Tests**: PrefixMatchTest's UnenumerableDict/EnumerationFailureDisabled
  Skipping test is removed. It relied on a dict whose GetLexicon() forwards
  to a valid lexicon (for real matching) while EnumerateKeys()
  independently reports failure (for the skip table) — two decoupled
  hooks that no longer both exist. The only remaining trigger for the
  skip-table's nullptr-lexicon fallback is GetLexicon() itself returning
  nullptr, which PrefixMatch's existing matcher builder (LeafMatcher::
  AddDict) already dereferences unconditionally, so it cannot be exercised
  through the public API without crashing matcher construction first; the
  fallback branch stays as a defensive guard, documented as such.

Verified: 21/21 tests pass; conversion output stays byte-identical against
the previous (ABI-bumping) build across 6 corpora x 6 configs.
… code

The SSE2/NEON/WASM-SIMD kernels in AsciiRunLength() carried real
maintenance and correctness cost — the big-endian NEON lane-order bug
found in review is the kind of defect they invite — so they were measured
against the plain SWAR loop they fell back to. Convert-phase medians on
2 MB corpora (CLI --measured_result, 7 runs each):

  corpus              config   SIMD    SWAR   SIMD gain
  ascii-heavy 94%     t2s       3.9     4.1      1.03x
  ascii-heavy 94%     s2twp    13.2    14.5      1.10x
  mixed 20% ascii     t2s      12.7    12.4      0.97x
  zuozhuan trad       t2s      32.3    32.5      1.00x
  zuozhuan trad       t2tw      6.0     6.2      1.02x
  constitution simp   s2t      73.1    75.5      1.03x

At most 3% on realistic input, inside run-to-run noise on half the cases:
the candidate table already removed the per-character trie lookups that
dominated, so widening the scan from 8 to 16 bytes per step no longer
moves the total. The intrinsics are removed and the SWAR loop, which never
needed architecture or endianness special cases, is the only
implementation. The large end-to-end wins reported for the two earlier
commits are unaffected — they come from the candidate table, not from
vector width.

Detailed Changes:
- **Utf8SkipScan.hpp**: AsciiRunLength() is a plain uint64_t loop plus a
  byte tail; the SSE2/NEON/WASM branches, the <emmintrin.h>/<arm_neon.h>/
  <wasm_simd128.h>/<intrin.h> includes, the endianness guards, and
  CountTrailingZeros() are gone (-85 lines, +13).
- **Comments and one test name**: "vectorized" wording updated to describe
  what the code now does.

A third-party SIMD library was considered instead. simdutf exposes no API
for scanning against a caller-supplied candidate table, which is what
SkipNonCandidateBytes() needs and where the speedup actually comes from;
only AsciiRunLength() maps onto it (via validate_ascii_with_errors), and
that is precisely the part measured not to need SIMD. Vendoring it for
OpenCC's CMake, Bazel, wasm, Node prebuild, Python wheel, and trimmed
source-package paths would not have paid for itself.

Verified: 21/21 tests pass; conversion output byte-identical to the
previous build across 6 corpora x 6 configs.
@frankslin frankslin changed the title Skip unmatchable byte runs in the conversion hot path with SIMD scanning Skip unmatchable character runs in the conversion hot path Jul 24, 2026
@frankslin

Copy link
Copy Markdown
Collaborator Author

@frankslin
frankslin merged commit 3edfcc1 into BYVoid:master Jul 24, 2026
44 of 52 checks passed
@frankslin
frankslin deleted the claude/conversion-hotpath-simd-6nmtzp branch July 24, 2026 23:08
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.

2 participants