Repository navigation
Skip unmatchable character runs in the conversion hot path - #1464
Merged
frankslin merged 4 commits intoJul 24, 2026
Merged
Conversation
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
force-pushed
the
claude/conversion-hotpath-simd-6nmtzp
branch
from
July 24, 2026 19:41
002ccb1 to
9ab4ca5
Compare
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.
Collaborator
Author
|
Benchmark before: (https://github.com/BYVoid/OpenCC/actions/runs/29971656400/job/89094753296) Benchmark after: (https://github.com/BYVoid/OpenCC/actions/runs/30123499046/job/89581405003?pr=1464) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


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):
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:
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.
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.
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.
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.
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.
within the reported length.
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.
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.
GetDictGroupItems() virtual and falls back to GetLexicon() for
non-Marisa leaves.
unmatchable run in one step instead of one lookup per character.
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.
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.
truncated, and 4-byte cases; mixed ASCII/Chinese conversion and
segmentation cases.