From dc4131e34edef85c389cdc10f352cbc8214c5145 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 19:40:32 +0000 Subject: [PATCH 1/4] Skip unmatchable byte runs in the conversion hot path with SIMD scanning 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. --- CMakeLists.txt | 2 +- src/BUILD.bazel | 7 +- src/CMakeLists.txt | 1 + src/Conversion.cpp | 5 + src/ConversionTest.cpp | 127 ++++++++++++++++++ src/Dict.cpp | 14 ++ src/Dict.hpp | 12 ++ src/DictGroup.cpp | 10 ++ src/DictGroup.hpp | 7 + src/MarisaDict.cpp | 33 +++++ src/MarisaDict.hpp | 7 + src/MaxMatchSegmentation.cpp | 5 + src/MaxMatchSegmentationTest.cpp | 13 ++ src/PrefixMatch.cpp | 60 ++++++++- src/PrefixMatch.hpp | 15 +++ src/PrefixMatchTest.cpp | 182 +++++++++++++++++++++++++ src/UTF8Util.hpp | 9 ++ src/Utf8SkipScan.hpp | 223 +++++++++++++++++++++++++++++++ 18 files changed, 723 insertions(+), 9 deletions(-) create mode 100644 src/Utf8SkipScan.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ae5573caa..6ac8d6b4c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -96,7 +96,7 @@ include(GitVersion) # C++ interface/layout changes after 1.3.1 require downstream C++ consumers # such as librime to relink instead of loading an ABI-incompatible libopencc. -set (OPENCC_ABI_VERSION 1.4) +set (OPENCC_ABI_VERSION 1.5) set(_OPENCC_SOURCE_PACKAGE_SUFFIX "") set(_OPENCC_SOURCE_PACKAGE_IGNORE_FILES "") diff --git a/src/BUILD.bazel b/src/BUILD.bazel index 9156ad736..91f69966a 100644 --- a/src/BUILD.bazel +++ b/src/BUILD.bazel @@ -222,6 +222,7 @@ cc_test( deps = [ ":conversion_lib", ":dict_group_test_base_lib", + ":marisa_dict_lib", "@googletest//:gtest_main", ], ) @@ -441,6 +442,7 @@ cc_library( deps = [ ":common_lib", ":dict_entry_lib", + ":lexicon_lib", ], ) @@ -573,7 +575,10 @@ cc_library( cc_library( name = "prefix_match_lib", srcs = ["PrefixMatch.cpp"], - hdrs = ["PrefixMatch.hpp"], + hdrs = [ + "PrefixMatch.hpp", + "Utf8SkipScan.hpp", + ], deps = [ ":common_lib", ":dict_group_lib", diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7e99ab8ef..0cf4f3f81 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -56,6 +56,7 @@ set( SingleStageConverter.hpp StreamWindow.hpp UTF8StringSlice.hpp + Utf8SkipScan.hpp ) set( diff --git a/src/Conversion.cpp b/src/Conversion.cpp index 12fb61569..7f6b3d40d 100644 --- a/src/Conversion.cpp +++ b/src/Conversion.cpp @@ -51,6 +51,11 @@ void Conversion::AppendConverted(std::string_view phrase, if (matchedLength > remainingLength) { matchedLength = remainingLength; } + // Extend the unmatched run over characters that cannot begin any + // dictionary key; each of them would fail the prefix lookup, so they + // are consumed with a single vectorized scan and one append. + matchedLength += prefixMatch->SkipUnmatchable( + pstr + matchedLength, remainingLength - matchedLength); output->append(pstr, matchedLength); } else { matchedLength = matched.keyLength; diff --git a/src/ConversionTest.cpp b/src/ConversionTest.cpp index 1b2a568d1..199181685 100644 --- a/src/ConversionTest.cpp +++ b/src/ConversionTest.cpp @@ -16,11 +16,55 @@ * limitations under the License. */ +#include + #include "Conversion.hpp" #include "DictGroupTestBase.hpp" +#include "MarisaDict.hpp" +#include "PrefixMatch.hpp" +#include "UTF8Util.hpp" namespace opencc { +namespace { + +// Reference implementation of the conversion loop that advances one +// character (or IDS sequence) per unmatched position and never bulk-skips. +// This is the pre-skip-scan behavior; Conversion::Convert must stay +// byte-for-byte equivalent to it on any input, including malformed UTF-8. +std::string ReferenceConvert(const PrefixMatch& prefixMatch, + const std::string& input) { + std::string output; + const char* pstr = input.data(); + const char* end = pstr + input.size(); + while (pstr < end) { + const size_t remaining = end - pstr; + const PrefixMatchView matched = prefixMatch.MatchPrefixView(pstr, remaining); + size_t matchedLength; + if (!matched.matched) { + matchedLength = + UTF8Util::NextIdeographicDescriptionSequenceLength(pstr, remaining); + if (matchedLength == 0) { + matchedLength = UTF8Util::NextCharLength(pstr); + } + if (matchedLength > remaining) { + matchedLength = remaining; + } + output.append(pstr, matchedLength); + } else { + matchedLength = matched.keyLength; + if (matchedLength > remaining) { + matchedLength = remaining; + } + output.append(matched.value.data(), matched.value.size()); + } + pstr += matchedLength; + } + return output; +} + +} // namespace + class ConversionTest : public DictGroupTestBase { protected: ConversionTest() @@ -47,6 +91,17 @@ TEST_F(ConversionTest, ConvertCString) { EXPECT_EQ(expected, converted); } +TEST_F(ConversionTest, ConvertMixedAsciiAndChinese) { + // Long ASCII runs exercise the vectorized skip of unmatchable characters. + const std::string asciiPrefix = + "The quick brown fox jumps over the lazy dog 0123456789 "; + const std::string asciiSuffix = " tail"; + const std::string punctuation = utf8("、「」"); + EXPECT_EQ(asciiPrefix + expected + punctuation + expected + asciiSuffix, + conversion->Convert(asciiPrefix + input + punctuation + input + + asciiSuffix)); +} + TEST_F(ConversionTest, PreserveDictGroupPriority) { LexiconPtr firstLexicon(new Lexicon); firstLexicon->Add(DictEntryFactory::New("a", "X")); @@ -128,6 +183,78 @@ TEST_F(ConversionTest, DoesNotPreserveIsolatedIdeographicDescriptionCharacters) EXPECT_EQ(utf8("⿾证證"), idcDescriptionConversion.Convert(utf8("⿾证证"))); } +TEST_F(ConversionTest, DifferentialFuzzAgainstReferenceLoop) { + // Random inputs mixing dictionary keys, unrelated CJK, ASCII, IDS + // operators, and raw (possibly invalid or truncated) bytes. The skip-scan + // path must produce exactly what the per-character reference loop + // produces — same output or the same exception — on BOTH PrefixMatch + // constructions: the table path (dict group) and the single-dictionary + // fast path (MarisaDict answering prefix queries directly, whose skip + // table is built from a trie walk instead of materialized lexicons). + const MarisaDictPtr marisaDict = MarisaDict::NewFromDict(*dict); + const Conversion marisaConversion(marisaDict); + const PrefixMatch marisaReference(marisaDict); + PrefixMatch groupReference(dict); + + struct FuzzTarget { + const char* name; + const Conversion* conversion; + const PrefixMatch* reference; + }; + const std::vector targets = { + {"table-path", conversion.get(), &groupReference}, + {"fast-path", &marisaConversion, &marisaReference}, + }; + + const std::vector fragments = { + utf8("太后"), utf8("头发"), utf8("干燥"), utf8("鼠标"), + utf8("干"), utf8("后"), utf8("发"), utf8("里"), + utf8("天地"), utf8("、。"), utf8("⿰"), utf8("⿳"), + utf8("——"), utf8("🎉"), "ascii text ", "12345", + }; + for (const FuzzTarget& target : targets) { + std::mt19937 rng(20260724); + for (int iteration = 0; iteration < 300; iteration++) { + std::string input; + const int pieces = 1 + static_cast(rng() % 40); + for (int i = 0; i < pieces; i++) { + const uint32_t choice = rng() % 10; + if (choice < 7) { + input += fragments[rng() % fragments.size()]; + } else if (choice < 9) { + // Raw bytes, frequently invalid or truncated UTF-8. + const int count = 1 + static_cast(rng() % 4); + for (int b = 0; b < count; b++) { + input.push_back(static_cast(rng() % 256)); + } + } else if (!input.empty()) { + // Truncate the tail mid-character. + input.resize(input.size() - 1); + } + } + bool referenceThrew = false; + bool convertThrew = false; + std::string expected, actual; + try { + expected = ReferenceConvert(*target.reference, input); + } catch (const InvalidUTF8&) { + referenceThrew = true; + } + try { + actual = target.conversion->Convert(input); + } catch (const InvalidUTF8&) { + convertThrew = true; + } + ASSERT_EQ(referenceThrew, convertThrew) + << target.name << " iteration " << iteration; + if (!referenceThrew) { + ASSERT_EQ(expected, actual) + << target.name << " iteration " << iteration; + } + } + } +} + TEST_F(ConversionTest, TruncatedUtf8Sequence) { // This test specifically triggers the information disclosure vulnerability // in the old code. The bug occurs when a string ends with an incomplete diff --git a/src/Dict.cpp b/src/Dict.cpp index 2fc27b2c7..86e3bdd25 100644 --- a/src/Dict.cpp +++ b/src/Dict.cpp @@ -19,9 +19,23 @@ #include #include "Dict.hpp" +#include "Lexicon.hpp" using namespace opencc; +bool Dict::EnumerateKeys( + const std::function& cb) const { + const LexiconPtr lexicon = GetLexicon(); + if (lexicon == nullptr) { + return false; + } + for (const std::unique_ptr& entry : *lexicon) { + const std::string& key = entry->Key(); + cb(key.data(), key.length()); + } + return true; +} + Optional Dict::MatchPrefix(const char* word, size_t wordLen) const { long len = static_cast((std::min)(KeyMaxLength(), wordLen)); diff --git a/src/Dict.hpp b/src/Dict.hpp index a50d6fbfd..5f2f2e8b2 100644 --- a/src/Dict.hpp +++ b/src/Dict.hpp @@ -18,6 +18,7 @@ #pragma once +#include #include #include @@ -123,6 +124,17 @@ class OPENCC_EXPORT Dict { */ virtual LexiconPtr GetLexicon() const = 0; + /** + * Invokes cb once per key in the dictionary (order unspecified) with the + * key's bytes. Returns false when the keys cannot be enumerated cheaply, + * in which case cb may have been invoked for a subset of the keys. The + * default implementation walks GetLexicon(); subclasses whose GetLexicon() + * is expensive (e.g. MarisaDict, which reconstructs it from the trie) + * should override this with a direct enumeration. + */ + virtual bool + EnumerateKeys(const std::function& cb) const; + /** * Returns child dictionaries when this dictionary is a group. */ diff --git a/src/DictGroup.cpp b/src/DictGroup.cpp index 173bf0159..3e2e4c51b 100644 --- a/src/DictGroup.cpp +++ b/src/DictGroup.cpp @@ -93,6 +93,16 @@ std::vector DictGroup::MatchAllPrefixes(const char* word, return matchedEntries; } +bool DictGroup::EnumerateKeys( + const std::function& cb) const { + for (const auto& dict : dicts) { + if (!dict->EnumerateKeys(cb)) { + return false; + } + } + return true; +} + LexiconPtr DictGroup::GetLexicon() const { LexiconPtr allLexicon(new Lexicon); for (const auto& dict : dicts) { diff --git a/src/DictGroup.hpp b/src/DictGroup.hpp index 2e891e06a..4d9e03b9b 100644 --- a/src/DictGroup.hpp +++ b/src/DictGroup.hpp @@ -94,6 +94,13 @@ class OPENCC_EXPORT DictGroup : public Dict { */ virtual LexiconPtr GetLexicon() const; + /** + * Enumerates keys of all child dictionaries, letting each child use its + * cheapest enumeration (unlike GetLexicon(), which merges child lexicons). + */ + virtual bool EnumerateKeys( + const std::function& cb) const override; + /** * Exposes child dictionaries to callers that need group-aware behavior. * PrefixMatch uses this to preserve nested group boundaries and dictionary diff --git a/src/MarisaDict.cpp b/src/MarisaDict.cpp index 8bc096cbe..d04b8192b 100644 --- a/src/MarisaDict.cpp +++ b/src/MarisaDict.cpp @@ -258,6 +258,39 @@ void MarisaDict::SerializeToFile(FILE* fp) const { new SerializedValues(GetLexicon())); serialized_values->SerializeToFile(fp); } + +bool MarisaDict::EnumerateKeys( + const std::function& cb) const { + // Prefer the already-reconstructed lexicon (e.g. when a table-path + // PrefixMatch materialized it); fall back to walking the trie so lazy + // loading is never forced. Same convention as ReconstructLexicon(): + // lexicon is written once before the release store of the flag, so the + // acquire load makes the plain read safe. + if (lexiconReconstructed.load(std::memory_order_acquire) && + lexicon != nullptr) { + for (const std::unique_ptr& entry : *lexicon) { + const std::string& key = entry->Key(); + cb(key.data(), key.length()); + } + return true; + } + const marisa::Trie* trie = internal->marisa.get(); + if (trie == nullptr) { + return false; + } + try { + marisa::Agent agent; + agent.set_query(""); + while (trie->predictive_search(agent)) { + cb(agent.key().ptr(), agent.key().length()); + } + } catch (const marisa::Exception&) { + // The trie has not been built or mapped yet. + return false; + } + return true; +} + PrefixMatchView MarisaDict::MatchPrefixValue(const char* word, size_t len) const { const marisa::Trie* trie = internal->marisa.get(); diff --git a/src/MarisaDict.hpp b/src/MarisaDict.hpp index a1918f7ad..33eccb887 100644 --- a/src/MarisaDict.hpp +++ b/src/MarisaDict.hpp @@ -55,6 +55,13 @@ class OPENCC_EXPORT MarisaDict : public Dict, public SerializableDict { virtual PrefixMatchView MatchPrefixValue(const char* word, size_t len) const override; + /** + * Enumerates keys by walking the trie directly, so it does not trigger + * lexicon reconstruction. Returns false when the trie is unavailable. + */ + virtual bool EnumerateKeys( + const std::function& cb) const override; + virtual void SerializeToFile(FILE* fp) const override; /** diff --git a/src/MaxMatchSegmentation.cpp b/src/MaxMatchSegmentation.cpp index 0a7a19625..3383a104b 100644 --- a/src/MaxMatchSegmentation.cpp +++ b/src/MaxMatchSegmentation.cpp @@ -57,6 +57,11 @@ SegmentsPtr SegmentText(const std::shared_ptr& prefixMatch, if (matchedLength > remainingLength) { matchedLength = remainingLength; } + // Extend the unmatched run over characters that cannot begin any + // dictionary key; each of them would fail the prefix lookup, so they + // are consumed with a single vectorized scan. + matchedLength += prefixMatch->SkipUnmatchable( + pstr + matchedLength, remainingLength - matchedLength); segLength += matchedLength; } else { clearBuffer(); diff --git a/src/MaxMatchSegmentationTest.cpp b/src/MaxMatchSegmentationTest.cpp index bb1d6fa3d..81e60c85e 100644 --- a/src/MaxMatchSegmentationTest.cpp +++ b/src/MaxMatchSegmentationTest.cpp @@ -43,6 +43,19 @@ TEST_F(MaxMatchSegmentationTest, Segment) { EXPECT_EQ(utf8("干燥"), std::string(segments->At(3))); } +TEST_F(MaxMatchSegmentationTest, MixedAsciiAndChinese) { + // ASCII runs and non-candidate CJK punctuation are skipped in bulk but must + // still land in the same segments as the per-character path produced. + const auto& segments = + segmenter->Segment(utf8("Hello, world! 太后。头发 done")); + EXPECT_EQ(5, segments->Length()); + EXPECT_EQ(utf8("Hello, world! "), std::string(segments->At(0))); + EXPECT_EQ(utf8("太后"), std::string(segments->At(1))); + EXPECT_EQ(utf8("。"), std::string(segments->At(2))); + EXPECT_EQ(utf8("头发"), std::string(segments->At(3))); + EXPECT_EQ(utf8(" done"), std::string(segments->At(4))); +} + TEST_F(MaxMatchSegmentationTest, EmptyString) { const auto& segments = segmenter->Segment(""); EXPECT_EQ(0, segments->Length()); diff --git a/src/PrefixMatch.cpp b/src/PrefixMatch.cpp index e53e7cfde..be48370ff 100644 --- a/src/PrefixMatch.cpp +++ b/src/PrefixMatch.cpp @@ -21,6 +21,7 @@ #include "DictGroup.hpp" #include "Lexicon.hpp" #include "UTF8Util.hpp" +#include "Utf8SkipScan.hpp" #include #include @@ -54,6 +55,7 @@ class PrefixMatch::Tables { public: class Matcher; std::unique_ptr matcher; + internal::Utf8SkipTable skip; }; namespace { @@ -61,6 +63,9 @@ namespace { struct CacheEntry { std::vector> dicts; std::weak_ptr tables; + // Fast-path Tables carry no matcher; never hand one to the table path + // (and vice versa), independently of the cache-key encoding. + bool fastPath = false; }; bool SameOwner(const std::weak_ptr& cached, @@ -123,6 +128,32 @@ void Unreachable() { #endif } +// Marks a single key's first byte as a candidate in the skip table. +void MarkKeyFirstChar(internal::Utf8SkipTable* table, const char* key, + size_t len) { + if (len == 0) { + return; + } + // key may come straight from a trie enumeration buffer (e.g. marisa's + // Agent) and is NOT NUL-terminated; only the lead byte is read. + table->candidate[static_cast(key[0])] = true; +} + +// Builds the skip table for dict by enumerating every key. Each dictionary +// supplies its cheapest enumeration (MarisaDict walks the trie without +// reconstructing its lexicon; groups recurse into children). Falls back to +// treating every byte as a candidate when enumeration is unavailable. +void BuildSkipTable(const DictPtr& dict, internal::Utf8SkipTable* table) { + const bool enumerated = + dict->EnumerateKeys([table](const char* key, size_t len) { + MarkKeyFirstChar(table, key, len); + }); + if (!enumerated) { + table->MarkAllCandidates(); + } + table->Finalize(); +} + } // namespace class PrefixMatch::Tables::Matcher { @@ -338,18 +369,25 @@ PrefixMatch::PrefixMatch(const DictPtr& dict) { } } - if (actualDict && actualDict->SupportsFastPrefixMatch()) { + const bool fastPath = actualDict && actualDict->SupportsFastPrefixMatch(); + if (fastPath) { singleDict = actualDict; - return; } + // On the fast path the Tables only carry the skip table (the dictionary + // answers prefix queries itself); the cache is shared with the table path, + // with a distinct key prefix because fast-path Tables hold no matcher. + const DictPtr& tableDict = fastPath ? actualDict : dict; static std::mutex cacheMutex; static std::unordered_map> cache; std::string cacheKey; - AppendCacheKey(dict, &cacheKey); + if (fastPath) { + cacheKey.push_back('F'); + } + AppendCacheKey(tableDict, &cacheKey); std::vector> leafDicts; - CollectLeafDicts(dict, &leafDicts); + CollectLeafDicts(tableDict, &leafDicts); { std::lock_guard lock(cacheMutex); @@ -357,7 +395,7 @@ PrefixMatch::PrefixMatch(const DictPtr& dict) { const auto cached = cache.find(cacheKey); if (cached != cache.end()) { for (const CacheEntry& entry : cached->second) { - if (SameDicts(entry, leafDicts)) { + if (entry.fastPath == fastPath && SameDicts(entry, leafDicts)) { tables = entry.tables.lock(); if (tables != nullptr) { return; @@ -368,7 +406,10 @@ PrefixMatch::PrefixMatch(const DictPtr& dict) { } std::shared_ptr built(new Tables); - built->matcher = BuildMatcher(dict); + if (!fastPath) { + built->matcher = BuildMatcher(tableDict); + } + BuildSkipTable(tableDict, &built->skip); std::lock_guard lock(cacheMutex); PruneExpiredPrefixMatchCache(&cache); @@ -377,7 +418,7 @@ PrefixMatch::PrefixMatch(const DictPtr& dict) { it != entries.end();) { if (HasExpiredDict(*it)) { it = entries.erase(it); - } else if (SameDicts(*it, leafDicts)) { + } else if (it->fastPath == fastPath && SameDicts(*it, leafDicts)) { tables = it->tables.lock(); if (tables != nullptr) { return; @@ -391,6 +432,7 @@ PrefixMatch::PrefixMatch(const DictPtr& dict) { CacheEntry entry; entry.dicts = std::move(leafDicts); entry.tables = tables; + entry.fastPath = fastPath; entries.push_back(std::move(entry)); } @@ -423,6 +465,10 @@ PrefixMatch::Match PrefixMatch::MatchPrefix(const char* word, return Match{false, 0, nullptr, nullptr}; } +size_t PrefixMatch::SkipUnmatchable(const char* word, size_t len) const { + return internal::SkipNonCandidateBytes(tables->skip, word, len); +} + PrefixMatchView PrefixMatch::MatchPrefixView(const char* word, size_t len) const { if (singleDict != nullptr) { diff --git a/src/PrefixMatch.hpp b/src/PrefixMatch.hpp index 577fe8408..76d475848 100644 --- a/src/PrefixMatch.hpp +++ b/src/PrefixMatch.hpp @@ -54,11 +54,26 @@ class OPENCC_EXPORT PrefixMatch { */ PrefixMatchView MatchPrefixView(const char* word, size_t len) const; + /** + * Returns the number of leading bytes of @p word that are guaranteed not + * to begin any dictionary key: whole UTF-8 characters whose lead byte + * starts no key (ideographic description operators excluded, so the + * caller's IDS grouping is preserved). Callers may consume the returned + * run without calling MatchPrefixView() at any position inside it. The + * scan is vectorized for ASCII runs. Returns 0 when the next character + * requires a real lookup. + */ + size_t SkipUnmatchable(const char* word, size_t len) const; + private: static void AppendCacheKey(const DictPtr& dict, std::string* output); static void CollectLeafDicts(const DictPtr& dict, std::vector>* output); + // Always non-null after construction. On the single-dictionary fast path + // it carries only the skip table; on the table path it also owns the + // matcher. Kept opaque so this header stays free of the skip-scan and + // SIMD intrinsic headers. std::shared_ptr tables; DictPtr singleDict; }; diff --git a/src/PrefixMatchTest.cpp b/src/PrefixMatchTest.cpp index 38109caf8..7460a88b8 100644 --- a/src/PrefixMatchTest.cpp +++ b/src/PrefixMatchTest.cpp @@ -27,6 +27,7 @@ #include "MarisaDict.hpp" #include "TestUtilsUTF8.hpp" #include "TextDictTestBase.hpp" +#include "Utf8SkipScan.hpp" namespace opencc { @@ -221,4 +222,185 @@ TEST_F(PrefixMatchTest, UnionGroupPreservesNestedShortCircuitBoundary) { EXPECT_EQ("root-second", *m.value); } +TEST(Utf8SkipScanTest, AsciiRunLength) { + const std::string cjk = utf8("一"); + // Cover all vector-width boundaries and unaligned starting offsets. + for (size_t asciiLength = 0; asciiLength < 70; asciiLength++) { + const std::string text = std::string(asciiLength, 'a') + cjk; + for (size_t offset = 0; offset <= asciiLength; offset++) { + EXPECT_EQ(asciiLength - offset, + internal::AsciiRunLength(text.data() + offset, + text.size() - offset)); + } + } + const std::string pureAscii(100, 'x'); + EXPECT_EQ(pureAscii.size(), + internal::AsciiRunLength(pureAscii.data(), pureAscii.size())); + EXPECT_EQ(0u, internal::AsciiRunLength(cjk.data(), cjk.size())); + EXPECT_EQ(0u, internal::AsciiRunLength("", 0)); +} + +TEST_F(PrefixMatchTest, SkipUnmatchableStopsAtAsciiCandidates) { + // textDict contains ASCII keys ("BYVoid", "zigzagzig"), so the scalar + // ASCII path must stop at candidate bytes 'B' and 'z'. + PrefixMatch pm(marisaDict); + const std::string query = "abc BYVoid"; + EXPECT_EQ(4u, pm.SkipUnmatchable(query.data(), query.size())); + EXPECT_EQ(0u, pm.SkipUnmatchable(query.data() + 4, query.size() - 4)); + const std::string zQuery = "zoo"; + EXPECT_EQ(0u, pm.SkipUnmatchable(zQuery.data(), zQuery.size())); +} + +TEST_F(PrefixMatchTest, SkipNeverCrossesAnyKeyFirstChar) { + // Invariant: for every dictionary key, the skip scan must not consume the + // position where that key starts, on both the fast path and the table + // path. A false skip here would silently corrupt conversion output. + PrefixMatch pmFast(marisaDict); + std::list dictsMulti = {marisaDict, textDict}; + DictPtr dictGroupMulti(new DictGroup(dictsMulti)); + PrefixMatch pmTable(dictGroupMulti); + const LexiconPtr lexicon = textDict->GetLexicon(); + for (const std::unique_ptr& entry : *lexicon) { + const std::string& key = entry->Key(); + EXPECT_EQ(0u, pmFast.SkipUnmatchable(key.data(), key.length())) << key; + EXPECT_EQ(0u, pmTable.SkipUnmatchable(key.data(), key.length())) << key; + } +} + +class PrefixMatchSkipTest : public ::testing::Test { +protected: + PrefixMatchSkipTest() { + LexiconPtr lexicon(new Lexicon); + lexicon->Add(DictEntryFactory::New(utf8("太后"), utf8("太后"))); + lexicon->Add(DictEntryFactory::New(utf8("里"), utf8("裏"))); + lexicon->Sort(); + dict = TextDictPtr(new TextDict(lexicon)); + } + + TextDictPtr dict; +}; + +TEST_F(PrefixMatchSkipTest, SkipsVectorizedAsciiRuns) { + // No ASCII key exists, so long ASCII runs take the vectorized scan. + PrefixMatch pm(dict); + const std::string ascii(100, 'x'); + EXPECT_EQ(ascii.size(), pm.SkipUnmatchable(ascii.data(), ascii.size())); + const std::string mixed = ascii + utf8("太后"); + EXPECT_EQ(ascii.size(), pm.SkipUnmatchable(mixed.data(), mixed.size())); +} + +TEST_F(PrefixMatchSkipTest, SkipsNonCandidateCjkCharacters) { + // 、。「」 and kana share the lead byte 0xE3 which starts no key; they are + // skipped without a lookup. 太 (0xE5) is a candidate and stops the scan. + PrefixMatch pm(dict); + const std::string punct = utf8("、。「」あい"); + EXPECT_EQ(punct.size(), pm.SkipUnmatchable(punct.data(), punct.size())); + const std::string mixed = punct + utf8("太后"); + EXPECT_EQ(punct.size(), pm.SkipUnmatchable(mixed.data(), mixed.size())); + const std::string candidate = utf8("太x"); + EXPECT_EQ(0u, pm.SkipUnmatchable(candidate.data(), candidate.size())); +} + +TEST_F(PrefixMatchSkipTest, FourByteCharactersUseLeadByteFiltering) { + // No 4-byte key exists in dict, so lead byte 0xF0 is not a candidate and + // 4-byte characters are skipped whole. + PrefixMatch pm(dict); + const std::string emoji = utf8("🎉🚀x太后"); + EXPECT_EQ(9u, pm.SkipUnmatchable(emoji.data(), emoji.size())); + + // With a 4-byte key, its lead byte becomes a candidate; any 4-byte + // character sharing that lead byte conservatively stops the scan. + LexiconPtr lexicon(new Lexicon); + lexicon->Add(DictEntryFactory::New(utf8("𠀀"), "X")); + lexicon->Sort(); + DictPtr extDict(new TextDict(lexicon)); + PrefixMatch pmExt(extDict); + const std::string extB = utf8("𠀀"); + const std::string otherFourByte = utf8("🎉"); + EXPECT_EQ(0u, pmExt.SkipUnmatchable(extB.data(), extB.size())); + EXPECT_EQ(0u, pmExt.SkipUnmatchable(otherFourByte.data(), + otherFourByte.size())); +} + +namespace { + +// A dictionary that cannot enumerate its keys, forcing the conservative +// all-candidates fallback in the skip table builder. +class UnenumerableDict : public Dict { +public: + explicit UnenumerableDict(DictPtr _inner) : inner(std::move(_inner)) {} + + Optional Match(const char* word, + size_t len) const override { + return inner->Match(word, len); + } + + size_t KeyMaxLength() const override { return inner->KeyMaxLength(); } + + LexiconPtr GetLexicon() const override { return inner->GetLexicon(); } + + bool EnumerateKeys( + const std::function&) const override { + return false; + } + +private: + const DictPtr inner; +}; + +} // namespace + +TEST_F(PrefixMatchSkipTest, EnumerationFailureDisablesSkipping) { + // The enumerable dict is first so the bitmap is partially built before the + // failure is discovered; the fallback must discard it and mark every byte + // a candidate, disabling skipping entirely. + LexiconPtr lexicon(new Lexicon); + lexicon->Add(DictEntryFactory::New(utf8("鼠标"), utf8("滑鼠"))); + lexicon->Sort(); + DictPtr unenumerable( + new UnenumerableDict(DictPtr(new TextDict(lexicon)))); + DictPtr group(new DictGroup(std::list{dict, unenumerable})); + PrefixMatch pm(group); + + const std::string ascii = "abc"; + const std::string punct = utf8("、。"); + EXPECT_EQ(0u, pm.SkipUnmatchable(ascii.data(), ascii.size())); + EXPECT_EQ(0u, pm.SkipUnmatchable(punct.data(), punct.size())); + // Matching still works normally. + const std::string query = utf8("鼠标x"); + PrefixMatch::Match m = pm.MatchPrefix(query.data(), query.size()); + EXPECT_TRUE(m.matched); + EXPECT_EQ(utf8("滑鼠"), *m.value); +} + +TEST_F(PrefixMatchSkipTest, StopsAtIdeographicDescriptionOperator) { + // No key starts with 0xE2, but IDS operators (U+2FF0..U+2FFF) must still + // reach the conversion loop so IDS grouping is preserved. + PrefixMatch pm(dict); + const std::string query = "abc" + utf8("⿰氵青"); + EXPECT_EQ(3u, pm.SkipUnmatchable(query.data(), query.size())); + EXPECT_EQ(0u, pm.SkipUnmatchable(query.data() + 3, query.size() - 3)); +} + +TEST_F(PrefixMatchSkipTest, StopsAtInvalidOrTruncatedSequences) { + PrefixMatch pm(dict); + const std::string invalid = std::string("ab") + '\xFF'; + EXPECT_EQ(2u, pm.SkipUnmatchable(invalid.data(), invalid.size())); + // Truncated 3-byte sequence: lead byte expects more bytes than remain. + const std::string truncated = std::string("ab") + '\xE3'; + EXPECT_EQ(2u, pm.SkipUnmatchable(truncated.data(), truncated.size())); + EXPECT_EQ(0u, pm.SkipUnmatchable(invalid.data() + 2, 1)); +} + +TEST_F(PrefixMatchTest, SkipUnmatchableTablePath) { + // Multi-dict group exercises the table path; skip bytes are the union of + // all leaf dictionaries' key start bytes. + std::list dictsMulti = {marisaDict, textDict}; + DictPtr dictGroupMulti(new DictGroup(dictsMulti)); + PrefixMatch pm(dictGroupMulti); + const std::string query = utf8("abc 清華"); + EXPECT_EQ(4u, pm.SkipUnmatchable(query.data(), query.size())); + EXPECT_EQ(0u, pm.SkipUnmatchable(query.data() + 4, query.size() - 4)); +} + } // namespace opencc diff --git a/src/UTF8Util.hpp b/src/UTF8Util.hpp index 9411c7b3f..6e60cf0b0 100644 --- a/src/UTF8Util.hpp +++ b/src/UTF8Util.hpp @@ -112,6 +112,15 @@ class OPENCC_EXPORT UTF8Util { return str - PrevCharLength(str); } + /** + * Code point bounds of the ideographic description operators recognized + * by IdeographicDescriptionOperatorArity(). Skip-scan tables iterate this + * range to keep IDS handling consistent with the conversion loop; when + * extending the switch below (e.g. with U+31EF), update these bounds too. + */ + static const uint32_t kFirstIdeographicDescriptionOperator = 0x2FF0; + static const uint32_t kLastIdeographicDescriptionOperator = 0x2FFF; + static size_t IdeographicDescriptionOperatorArity(uint32_t codePoint) { switch (codePoint) { case 0x2FF2: diff --git a/src/Utf8SkipScan.hpp b/src/Utf8SkipScan.hpp new file mode 100644 index 000000000..52c6107d0 --- /dev/null +++ b/src/Utf8SkipScan.hpp @@ -0,0 +1,223 @@ +/* + * Open Chinese Convert + * + * Copyright 2010-2026 Carbo Kuo and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "UTF8Util.hpp" + +// The NEON movemask substitute (vshrn + ctz) assumes little-endian lane +// ordering, so big-endian ARM (rare, but existing in some 32-bit embedded +// toolchains) must fall back to the endian-checked SWAR path below. SSE2 and +// WASM SIMD128 targets are little-endian by definition. +#if defined(__SSE2__) || defined(_M_X64) || \ + (defined(_M_IX86_FP) && _M_IX86_FP >= 2) +#define OPENCC_UTF8_SKIP_SCAN_SSE2 1 +#include +#elif (defined(__aarch64__) || defined(_M_ARM64) || defined(__ARM_NEON)) && \ + ((defined(__BYTE_ORDER__) && \ + __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__) || \ + defined(_M_ARM64)) +#define OPENCC_UTF8_SKIP_SCAN_NEON 1 +#include +#elif defined(__wasm_simd128__) +#define OPENCC_UTF8_SKIP_SCAN_WASM 1 +#include +#endif + +#if defined(_MSC_VER) +#include +#endif + +namespace opencc { +namespace internal { + +/** + * Per-byte lookup table describing which byte values may begin a dictionary + * key. The conversion hot loop uses it to consume runs of characters that + * cannot possibly match any key without paying for a trie lookup per + * character, and to vectorize the common all-ASCII case. + */ +struct Utf8SkipTable { + /** + * candidate[b] is true when byte value b may begin a dictionary key (or is + * the lead byte of an ideographic description operator), so the conversion + * loop must run a full prefix lookup at that position. + */ + bool candidate[256] = {}; + /** + * True when any ASCII byte value is a candidate; disables the vectorized + * ASCII-run scan. + */ + bool asciiHasCandidates = false; + + void MarkAllCandidates() { + for (size_t b = 0; b < 256; b++) { + candidate[b] = true; + } + } + + /** + * Must be called after candidate[] is filled and before the table is used. + * Ideographic description operators must always stop the scan: the + * conversion loop groups IDS sequences without consulting the dictionary, + * and skipping over an operator would lose that grouping. The operator + * set is defined by UTF8Util::IdeographicDescriptionOperatorArity() and + * bounded by UTF8Util::kFirst/kLastIdeographicDescriptionOperator. + */ + void Finalize() { + static_assert(UTF8Util::kFirstIdeographicDescriptionOperator >= 0x0800 && + UTF8Util::kLastIdeographicDescriptionOperator <= 0xFFFF, + "IDS operator range must lie in the 3-byte UTF-8 block"); + for (uint32_t cp = UTF8Util::kFirstIdeographicDescriptionOperator; + cp <= UTF8Util::kLastIdeographicDescriptionOperator; cp++) { + if (UTF8Util::IdeographicDescriptionOperatorArity(cp) == 0) { + continue; + } + // The operator range lies in the 3-byte UTF-8 block of the BMP. + candidate[0xE0 | (cp >> 12)] = true; + } + asciiHasCandidates = false; + for (size_t b = 0; b < 0x80; b++) { + if (candidate[b]) { + asciiHasCandidates = true; + break; + } + } + } +}; + +inline size_t CountTrailingZeros(uint64_t mask) { +#if defined(__GNUC__) || defined(__clang__) + return static_cast(__builtin_ctzll(mask)); +#elif defined(_MSC_VER) && (defined(_M_X64) || defined(_M_ARM64)) + unsigned long index = 0; + _BitScanForward64(&index, mask); + return static_cast(index); +#else + size_t count = 0; + while ((mask & 1) == 0) { + mask >>= 1; + count++; + } + return count; +#endif +} + +/** + * Returns the number of leading bytes of [str, str + len) with the high bit + * clear, i.e. the length of the leading ASCII run. + */ +inline size_t AsciiRunLength(const char* str, size_t len) { + size_t pos = 0; +#if defined(OPENCC_UTF8_SKIP_SCAN_SSE2) + for (; pos + 16 <= len; pos += 16) { + const __m128i chunk = + _mm_loadu_si128(reinterpret_cast(str + pos)); + const uint32_t mask = static_cast(_mm_movemask_epi8(chunk)); + if (mask != 0) { + return pos + CountTrailingZeros(mask); + } + } +#elif defined(OPENCC_UTF8_SKIP_SCAN_NEON) + for (; pos + 16 <= len; pos += 16) { + const uint8x16_t chunk = + vld1q_u8(reinterpret_cast(str + pos)); + // 0xFF per non-ASCII byte, then narrow to 4 bits per byte to obtain a + // 64-bit mask (the NEON substitute for _mm_movemask_epi8). + const uint8x16_t high = + vcltq_s8(vreinterpretq_s8_u8(chunk), vdupq_n_s8(0)); + const uint8x8_t narrowed = vshrn_n_u16(vreinterpretq_u16_u8(high), 4); + const uint64_t mask = vget_lane_u64(vreinterpret_u64_u8(narrowed), 0); + if (mask != 0) { + return pos + (CountTrailingZeros(mask) >> 2); + } + } +#elif defined(OPENCC_UTF8_SKIP_SCAN_WASM) + for (; pos + 16 <= len; pos += 16) { + const v128_t chunk = wasm_v128_load(str + pos); + const uint32_t mask = static_cast(wasm_i8x16_bitmask(chunk)); + if (mask != 0) { + return pos + CountTrailingZeros(mask); + } + } +#else + // SWAR fallback: examine 8 bytes per iteration. + for (; pos + 8 <= len; pos += 8) { + uint64_t word; + std::memcpy(&word, str + pos, sizeof(word)); + const uint64_t mask = word & UINT64_C(0x8080808080808080); + if (mask != 0) { +#if (defined(__BYTE_ORDER__) && \ + __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__) || \ + defined(_MSC_VER) + return pos + CountTrailingZeros(mask) / 8; +#else + break; +#endif + } + } +#endif + for (; pos < len; pos++) { + if (static_cast(str[pos]) >= 0x80) { + break; + } + } + return pos; +} + +/** + * Returns the number of leading bytes of [str, str + len) that the conversion + * loop may consume without any dictionary lookup: whole UTF-8 characters + * whose lead byte is not a candidate key-start byte. Scanning stops at the + * first candidate lead byte and at invalid or truncated sequences (which the + * caller's per-character path must handle). + */ +inline size_t SkipNonCandidateBytes(const Utf8SkipTable& table, const char* str, + size_t len) { + size_t pos = 0; + while (pos < len) { + const unsigned char lead = static_cast(str[pos]); + if (lead < 0x80) { + if (!table.asciiHasCandidates) { + pos += AsciiRunLength(str + pos, len - pos); + continue; + } + if (table.candidate[lead]) { + break; + } + pos++; + continue; + } + const size_t charLength = UTF8Util::NextCharLengthNoException(str + pos); + if (charLength == 0 || charLength > len - pos) { + break; + } + if (table.candidate[lead]) { + break; + } + pos += charLength; + } + return pos; +} + +} // namespace internal +} // namespace opencc From 9ab4ca5992253023495ccf1814d9466e693d8f5a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 19:41:06 +0000 Subject: [PATCH 2/4] Refine skip-scan filtering to character granularity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/PrefixMatch.cpp | 40 +++++++++++++++++++++++-- src/PrefixMatchTest.cpp | 48 ++++++++++++++++++++++++++++++ src/Utf8SkipScan.hpp | 65 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 149 insertions(+), 4 deletions(-) diff --git a/src/PrefixMatch.cpp b/src/PrefixMatch.cpp index be48370ff..0decbe941 100644 --- a/src/PrefixMatch.cpp +++ b/src/PrefixMatch.cpp @@ -128,15 +128,48 @@ void Unreachable() { #endif } -// Marks a single key's first byte as a candidate in the skip table. +// Marks a single key's first character in the skip table: the lead byte +// always, plus the exact first code point for 2- and 3-byte characters so +// scanning can filter at character granularity. Uses the same decoder as the +// scanner (internal::DecodeCodePoint23) so a key's first character always +// maps to the bit the scanner will test. A key whose first character is +// truncated or invalid cannot be represented as a code point; character +// filtering is then disabled for the whole table and lead-byte filtering +// (which stops at the marked lead) remains in effect. void MarkKeyFirstChar(internal::Utf8SkipTable* table, const char* key, size_t len) { if (len == 0) { return; } // key may come straight from a trie enumeration buffer (e.g. marisa's - // Agent) and is NOT NUL-terminated; only the lead byte is read. - table->candidate[static_cast(key[0])] = true; + // Agent) and is NOT NUL-terminated. NextCharLengthNoException() reads only + // the lead byte, and DecodeCodePoint23() reads at most charLength bytes, + // which is checked against len first — no read may pass key + len. + const unsigned char lead = static_cast(key[0]); + table->candidate[lead] = true; + if (lead < 0x80) { + return; + } + const size_t charLength = UTF8Util::NextCharLengthNoException(key); + if (charLength == 0) { + // Invalid lead byte: the scanner stops at charLength == 0 before any + // candidate check, so the lead-byte mark alone is sufficient and + // character-level filtering can stay enabled. + return; + } + if (charLength > len) { + // Truncated first character: the key can still match its raw byte + // prefix, but the scanner would decode the code point using the text's + // following bytes and could skip such a position. This cannot be + // represented at character granularity, so fall back to lead-byte + // filtering (the marked lead stops the scan). + table->DisableCharLevel(); + return; + } + if (charLength == 2 || charLength == 3) { + table->MarkCharCandidate(internal::DecodeCodePoint23(key, charLength)); + } + // 4-byte (and longer legacy) first characters rely on lead-byte filtering. } // Builds the skip table for dict by enumerating every key. Each dictionary @@ -144,6 +177,7 @@ void MarkKeyFirstChar(internal::Utf8SkipTable* table, const char* key, // reconstructing its lexicon; groups recurse into children). Falls back to // treating every byte as a candidate when enumeration is unavailable. void BuildSkipTable(const DictPtr& dict, internal::Utf8SkipTable* table) { + table->EnableCharLevel(); const bool enumerated = dict->EnumerateKeys([table](const char* key, size_t len) { MarkKeyFirstChar(table, key, len); diff --git a/src/PrefixMatchTest.cpp b/src/PrefixMatchTest.cpp index 7460a88b8..5d63cb08a 100644 --- a/src/PrefixMatchTest.cpp +++ b/src/PrefixMatchTest.cpp @@ -373,6 +373,54 @@ TEST_F(PrefixMatchSkipTest, EnumerationFailureDisablesSkipping) { EXPECT_EQ(utf8("滑鼠"), *m.value); } +TEST_F(PrefixMatchSkipTest, CharLevelSkipsSameLeadByteNonCandidates) { + // Table path uses character-granularity filtering: 天/地 share the lead + // byte 0xE5 with the key 太后 but are not candidate first characters, so + // they are skipped; 太 itself stops the scan. + PrefixMatch pm(dict); + const std::string query = utf8("天地太后"); + EXPECT_EQ(6u, pm.SkipUnmatchable(query.data(), query.size())); + // 黑 shares the lead byte 0xE9 with the key 里. + const std::string query2 = utf8("黑里"); + EXPECT_EQ(3u, pm.SkipUnmatchable(query2.data(), query2.size())); +} + +TEST_F(PrefixMatchSkipTest, CharLevelSkipsNonIdsPunctuationWithLead0xE2) { + // Em dash and curly quotes share the lead byte 0xE2 with the IDS + // operators but are not operators themselves; character-level filtering + // skips them while U+2FF0..U+2FFF still stop the scan. + PrefixMatch pm(dict); + const std::string dashes = utf8("——“”…"); + EXPECT_EQ(dashes.size(), pm.SkipUnmatchable(dashes.data(), dashes.size())); + const std::string ids = utf8("—⿰氵青"); + EXPECT_EQ(3u, pm.SkipUnmatchable(ids.data(), ids.size())); +} + +TEST_F(PrefixMatchSkipTest, CharLevelSkipsTwoByteCharacters) { + PrefixMatch pm(dict); + const std::string query = utf8("Ünïcodé tail 太"); + EXPECT_EQ(query.size() - 3, pm.SkipUnmatchable(query.data(), query.size())); +} + +TEST_F(PrefixMatchSkipTest, AllIdsOperatorsStopTheScan) { + // Pins Finalize() to the arity table: every recognized IDS operator must + // stop the scan even though none of them begins a dictionary key, so the + // conversion loop's IDS grouping still sees them. + PrefixMatch pm(dict); + for (uint32_t cp = UTF8Util::kFirstIdeographicDescriptionOperator; + cp <= UTF8Util::kLastIdeographicDescriptionOperator; cp++) { + if (UTF8Util::IdeographicDescriptionOperatorArity(cp) == 0) { + continue; + } + const char encoded[3] = { + static_cast(0xE0 | (cp >> 12)), + static_cast(0x80 | ((cp >> 6) & 0x3F)), + static_cast(0x80 | (cp & 0x3F)), + }; + EXPECT_EQ(0u, pm.SkipUnmatchable(encoded, 3)) << "U+" << std::hex << cp; + } +} + TEST_F(PrefixMatchSkipTest, StopsAtIdeographicDescriptionOperator) { // No key starts with 0xE2, but IDS operators (U+2FF0..U+2FFF) must still // reach the conversion loop so IDS grouping is preserved. diff --git a/src/Utf8SkipScan.hpp b/src/Utf8SkipScan.hpp index 52c6107d0..238d4597f 100644 --- a/src/Utf8SkipScan.hpp +++ b/src/Utf8SkipScan.hpp @@ -18,9 +18,11 @@ #pragma once +#include #include #include #include +#include #include "UTF8Util.hpp" @@ -50,6 +52,24 @@ namespace opencc { namespace internal { +/** + * Decodes the code point of a 2- or 3-byte UTF-8 sequence from raw bytes, + * without validating continuation bytes (same masking as + * UTF8Util::CodePointNoException). Both the skip-table builder and the + * scanner must use this one decoder so that a key's first character always + * maps to the same bitmap bit as the text it can match. + */ +inline uint32_t DecodeCodePoint23(const char* str, size_t charLength) { + const unsigned char lead = static_cast(str[0]); + if (charLength == 2) { + return ((lead & 0x1FU) << 6) | + (static_cast(str[1]) & 0x3FU); + } + return ((lead & 0x0FU) << 12) | + ((static_cast(str[1]) & 0x3FU) << 6) | + (static_cast(str[2]) & 0x3FU); +} + /** * Per-byte lookup table describing which byte values may begin a dictionary * key. The conversion hot loop uses it to consume runs of characters that @@ -68,11 +88,44 @@ struct Utf8SkipTable { * ASCII-run scan. */ bool asciiHasCandidates = false; + /** + * Optional character-level refinement: one bit per BMP code point + * (U+0000..U+FFFF, 8 KiB). When non-empty, 2- and 3-byte UTF-8 characters + * are filtered by their exact code point instead of their lead byte, so + * e.g. a rare CJK character sharing its lead byte with common dictionary + * keys can still be skipped. 4-byte characters keep lead-byte filtering. + * Character-level mode is on exactly when this vector is non-empty; there + * is deliberately no separate flag to keep in sync. + */ + std::vector bmpCandidates; + + bool CharLevel() const { return !bmpCandidates.empty(); } + + void EnableCharLevel() { bmpCandidates.assign(0x10000 / 64, 0); } + + /** + * Permanently falls back to lead-byte filtering, e.g. when a key's first + * character is truncated or invalid and cannot be represented as a code + * point. Byte-level candidates accumulated so far remain valid. + */ + void DisableCharLevel() { bmpCandidates.clear(); } + + void MarkCharCandidate(uint32_t codePoint) { + if (CharLevel() && codePoint < 0x10000) { + bmpCandidates[codePoint >> 6] |= uint64_t(1) << (codePoint & 63); + } + } + + bool IsCharCandidate(uint32_t codePoint) const { + assert(CharLevel()); + return (bmpCandidates[codePoint >> 6] >> (codePoint & 63)) & 1; + } void MarkAllCandidates() { for (size_t b = 0; b < 256; b++) { candidate[b] = true; } + DisableCharLevel(); } /** @@ -94,6 +147,7 @@ struct Utf8SkipTable { } // The operator range lies in the 3-byte UTF-8 block of the BMP. candidate[0xE0 | (cp >> 12)] = true; + MarkCharCandidate(cp); } asciiHasCandidates = false; for (size_t b = 0; b < 0x80; b++) { @@ -211,7 +265,16 @@ inline size_t SkipNonCandidateBytes(const Utf8SkipTable& table, const char* str, if (charLength == 0 || charLength > len - pos) { break; } - if (table.candidate[lead]) { + if (table.CharLevel() && (charLength == 2 || charLength == 3)) { + // DecodeCodePoint23 does not validate continuation bytes; the table + // builder decodes key bytes with the same function, so a position is + // skipped only when its exact bytes cannot begin any key. Either way + // the scan consumes the same charLength bytes the per-character path + // would, so behavior stays equivalent even on malformed input. + if (table.IsCharCandidate(DecodeCodePoint23(str + pos, charLength))) { + break; + } + } else if (table.candidate[lead]) { break; } pos += charLength; From 7ae575d511270b8ed88e4408a2409af92e4a1067 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 20:03:42 +0000 Subject: [PATCH 3/4] Revert Dict::EnumerateKeys to a non-ABI-breaking dynamic_cast dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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 . - **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. --- CMakeLists.txt | 2 +- src/BUILD.bazel | 2 +- src/Dict.cpp | 14 ---------- src/Dict.hpp | 12 --------- src/DictGroup.cpp | 10 ------- src/DictGroup.hpp | 7 ----- src/MarisaDict.hpp | 7 +++-- src/PrefixMatch.cpp | 59 +++++++++++++++++++++++++++++++++-------- src/PrefixMatchTest.cpp | 51 ----------------------------------- 9 files changed, 55 insertions(+), 109 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6ac8d6b4c..ae5573caa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -96,7 +96,7 @@ include(GitVersion) # C++ interface/layout changes after 1.3.1 require downstream C++ consumers # such as librime to relink instead of loading an ABI-incompatible libopencc. -set (OPENCC_ABI_VERSION 1.5) +set (OPENCC_ABI_VERSION 1.4) set(_OPENCC_SOURCE_PACKAGE_SUFFIX "") set(_OPENCC_SOURCE_PACKAGE_IGNORE_FILES "") diff --git a/src/BUILD.bazel b/src/BUILD.bazel index 91f69966a..160ccb8af 100644 --- a/src/BUILD.bazel +++ b/src/BUILD.bazel @@ -442,7 +442,6 @@ cc_library( deps = [ ":common_lib", ":dict_entry_lib", - ":lexicon_lib", ], ) @@ -584,6 +583,7 @@ cc_library( ":dict_group_lib", ":dict_lib", ":lexicon_lib", + ":marisa_dict_lib", ":utf8_util_lib", ], ) diff --git a/src/Dict.cpp b/src/Dict.cpp index 86e3bdd25..2fc27b2c7 100644 --- a/src/Dict.cpp +++ b/src/Dict.cpp @@ -19,23 +19,9 @@ #include #include "Dict.hpp" -#include "Lexicon.hpp" using namespace opencc; -bool Dict::EnumerateKeys( - const std::function& cb) const { - const LexiconPtr lexicon = GetLexicon(); - if (lexicon == nullptr) { - return false; - } - for (const std::unique_ptr& entry : *lexicon) { - const std::string& key = entry->Key(); - cb(key.data(), key.length()); - } - return true; -} - Optional Dict::MatchPrefix(const char* word, size_t wordLen) const { long len = static_cast((std::min)(KeyMaxLength(), wordLen)); diff --git a/src/Dict.hpp b/src/Dict.hpp index 5f2f2e8b2..a50d6fbfd 100644 --- a/src/Dict.hpp +++ b/src/Dict.hpp @@ -18,7 +18,6 @@ #pragma once -#include #include #include @@ -124,17 +123,6 @@ class OPENCC_EXPORT Dict { */ virtual LexiconPtr GetLexicon() const = 0; - /** - * Invokes cb once per key in the dictionary (order unspecified) with the - * key's bytes. Returns false when the keys cannot be enumerated cheaply, - * in which case cb may have been invoked for a subset of the keys. The - * default implementation walks GetLexicon(); subclasses whose GetLexicon() - * is expensive (e.g. MarisaDict, which reconstructs it from the trie) - * should override this with a direct enumeration. - */ - virtual bool - EnumerateKeys(const std::function& cb) const; - /** * Returns child dictionaries when this dictionary is a group. */ diff --git a/src/DictGroup.cpp b/src/DictGroup.cpp index 3e2e4c51b..173bf0159 100644 --- a/src/DictGroup.cpp +++ b/src/DictGroup.cpp @@ -93,16 +93,6 @@ std::vector DictGroup::MatchAllPrefixes(const char* word, return matchedEntries; } -bool DictGroup::EnumerateKeys( - const std::function& cb) const { - for (const auto& dict : dicts) { - if (!dict->EnumerateKeys(cb)) { - return false; - } - } - return true; -} - LexiconPtr DictGroup::GetLexicon() const { LexiconPtr allLexicon(new Lexicon); for (const auto& dict : dicts) { diff --git a/src/DictGroup.hpp b/src/DictGroup.hpp index 4d9e03b9b..2e891e06a 100644 --- a/src/DictGroup.hpp +++ b/src/DictGroup.hpp @@ -94,13 +94,6 @@ class OPENCC_EXPORT DictGroup : public Dict { */ virtual LexiconPtr GetLexicon() const; - /** - * Enumerates keys of all child dictionaries, letting each child use its - * cheapest enumeration (unlike GetLexicon(), which merges child lexicons). - */ - virtual bool EnumerateKeys( - const std::function& cb) const override; - /** * Exposes child dictionaries to callers that need group-aware behavior. * PrefixMatch uses this to preserve nested group boundaries and dictionary diff --git a/src/MarisaDict.hpp b/src/MarisaDict.hpp index 33eccb887..86b864f96 100644 --- a/src/MarisaDict.hpp +++ b/src/MarisaDict.hpp @@ -19,6 +19,7 @@ #pragma once #include +#include #include #include "Common.hpp" @@ -58,9 +59,11 @@ class OPENCC_EXPORT MarisaDict : public Dict, public SerializableDict { /** * Enumerates keys by walking the trie directly, so it does not trigger * lexicon reconstruction. Returns false when the trie is unavailable. + * Not part of the Dict interface (MarisaDict-specific, non-virtual) so + * adding it does not affect Dict's or MarisaDict's ABI; callers that need + * it across dictionary types dynamic_cast to MarisaDict. */ - virtual bool EnumerateKeys( - const std::function& cb) const override; + bool EnumerateKeys(const std::function& cb) const; virtual void SerializeToFile(FILE* fp) const override; diff --git a/src/PrefixMatch.cpp b/src/PrefixMatch.cpp index 0decbe941..52b911b94 100644 --- a/src/PrefixMatch.cpp +++ b/src/PrefixMatch.cpp @@ -20,6 +20,7 @@ #include "Dict.hpp" #include "DictGroup.hpp" #include "Lexicon.hpp" +#include "MarisaDict.hpp" #include "UTF8Util.hpp" #include "Utf8SkipScan.hpp" @@ -172,19 +173,55 @@ void MarkKeyFirstChar(internal::Utf8SkipTable* table, const char* key, // 4-byte (and longer legacy) first characters rely on lead-byte filtering. } -// Builds the skip table for dict by enumerating every key. Each dictionary -// supplies its cheapest enumeration (MarisaDict walks the trie without -// reconstructing its lexicon; groups recurse into children). Falls back to -// treating every byte as a candidate when enumeration is unavailable. -void BuildSkipTable(const DictPtr& dict, internal::Utf8SkipTable* table) { - table->EnableCharLevel(); - const bool enumerated = - dict->EnumerateKeys([table](const char* key, size_t len) { - MarkKeyFirstChar(table, key, len); - }); - if (!enumerated) { +// Recursively marks every key's first character into table: groups recurse +// into children via GetDictGroupItems() (part of the Dict interface, so no +// dynamic_cast needed); MarisaDict is special-cased with a dynamic_cast so +// its keys can be walked directly from the trie without forcing lexicon +// reconstruction; any other leaf dict enumerates through GetLexicon(), whose +// contract (like LeafMatcher::AddDict's) assumes a non-null result. The +// nullptr branch below is an untested defensive guard, kept for symmetry +// with MarisaDict::EnumerateKeys() returning false when its trie is +// unavailable; it cannot currently be exercised through PrefixMatch's public +// API without a Dict subclass that violates the GetLexicon() contract +// elsewhere too (BuildMatcher's LeafMatcher::AddDict dereferences it +// unconditionally, so a real nullptr would already crash matcher +// construction). +void CollectSkipTable(const DictPtr& dict, internal::Utf8SkipTable* table) { + const std::list* items = dict->GetDictGroupItems(); + if (items != nullptr) { + for (const DictPtr& child : *items) { + CollectSkipTable(child, table); + } + return; + } + const MarisaDict* marisaDict = dynamic_cast(dict.get()); + if (marisaDict != nullptr) { + const bool enumerated = + marisaDict->EnumerateKeys([table](const char* key, size_t len) { + MarkKeyFirstChar(table, key, len); + }); + if (!enumerated) { + table->MarkAllCandidates(); + } + return; + } + const LexiconPtr lexicon = dict->GetLexicon(); + if (lexicon == nullptr) { table->MarkAllCandidates(); + return; } + for (const std::unique_ptr& entry : *lexicon) { + const std::string& key = entry->Key(); + MarkKeyFirstChar(table, key.data(), key.length()); + } +} + +// Builds the skip table for dict by enumerating every key at character +// granularity; falls back to treating every byte as a candidate when any +// leaf's keys are unavailable. +void BuildSkipTable(const DictPtr& dict, internal::Utf8SkipTable* table) { + table->EnableCharLevel(); + CollectSkipTable(dict, table); table->Finalize(); } diff --git a/src/PrefixMatchTest.cpp b/src/PrefixMatchTest.cpp index 5d63cb08a..4dcec9087 100644 --- a/src/PrefixMatchTest.cpp +++ b/src/PrefixMatchTest.cpp @@ -322,57 +322,6 @@ TEST_F(PrefixMatchSkipTest, FourByteCharactersUseLeadByteFiltering) { otherFourByte.size())); } -namespace { - -// A dictionary that cannot enumerate its keys, forcing the conservative -// all-candidates fallback in the skip table builder. -class UnenumerableDict : public Dict { -public: - explicit UnenumerableDict(DictPtr _inner) : inner(std::move(_inner)) {} - - Optional Match(const char* word, - size_t len) const override { - return inner->Match(word, len); - } - - size_t KeyMaxLength() const override { return inner->KeyMaxLength(); } - - LexiconPtr GetLexicon() const override { return inner->GetLexicon(); } - - bool EnumerateKeys( - const std::function&) const override { - return false; - } - -private: - const DictPtr inner; -}; - -} // namespace - -TEST_F(PrefixMatchSkipTest, EnumerationFailureDisablesSkipping) { - // The enumerable dict is first so the bitmap is partially built before the - // failure is discovered; the fallback must discard it and mark every byte - // a candidate, disabling skipping entirely. - LexiconPtr lexicon(new Lexicon); - lexicon->Add(DictEntryFactory::New(utf8("鼠标"), utf8("滑鼠"))); - lexicon->Sort(); - DictPtr unenumerable( - new UnenumerableDict(DictPtr(new TextDict(lexicon)))); - DictPtr group(new DictGroup(std::list{dict, unenumerable})); - PrefixMatch pm(group); - - const std::string ascii = "abc"; - const std::string punct = utf8("、。"); - EXPECT_EQ(0u, pm.SkipUnmatchable(ascii.data(), ascii.size())); - EXPECT_EQ(0u, pm.SkipUnmatchable(punct.data(), punct.size())); - // Matching still works normally. - const std::string query = utf8("鼠标x"); - PrefixMatch::Match m = pm.MatchPrefix(query.data(), query.size()); - EXPECT_TRUE(m.matched); - EXPECT_EQ(utf8("滑鼠"), *m.value); -} - TEST_F(PrefixMatchSkipTest, CharLevelSkipsSameLeadByteNonCandidates) { // Table path uses character-granularity filtering: 天/地 share the lead // byte 0xE5 with the key 太后 but are not candidate first characters, so From ea2edb6d0ec2f2f30d5b7ad3b2b867f17befd17e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 20:16:58 +0000 Subject: [PATCH 4/4] Replace the hand-written SIMD ASCII scan with portable word-at-a-time code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 // / 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. --- src/Conversion.cpp | 2 +- src/ConversionTest.cpp | 2 +- src/MaxMatchSegmentation.cpp | 2 +- src/PrefixMatch.hpp | 6 +-- src/PrefixMatchTest.cpp | 4 +- src/Utf8SkipScan.hpp | 98 +++++------------------------------- 6 files changed, 21 insertions(+), 93 deletions(-) diff --git a/src/Conversion.cpp b/src/Conversion.cpp index 7f6b3d40d..b1a21963a 100644 --- a/src/Conversion.cpp +++ b/src/Conversion.cpp @@ -53,7 +53,7 @@ void Conversion::AppendConverted(std::string_view phrase, } // Extend the unmatched run over characters that cannot begin any // dictionary key; each of them would fail the prefix lookup, so they - // are consumed with a single vectorized scan and one append. + // are consumed with a single bulk scan and one append. matchedLength += prefixMatch->SkipUnmatchable( pstr + matchedLength, remainingLength - matchedLength); output->append(pstr, matchedLength); diff --git a/src/ConversionTest.cpp b/src/ConversionTest.cpp index 199181685..f4f74b42d 100644 --- a/src/ConversionTest.cpp +++ b/src/ConversionTest.cpp @@ -92,7 +92,7 @@ TEST_F(ConversionTest, ConvertCString) { } TEST_F(ConversionTest, ConvertMixedAsciiAndChinese) { - // Long ASCII runs exercise the vectorized skip of unmatchable characters. + // Long ASCII runs exercise the bulk skip of unmatchable characters. const std::string asciiPrefix = "The quick brown fox jumps over the lazy dog 0123456789 "; const std::string asciiSuffix = " tail"; diff --git a/src/MaxMatchSegmentation.cpp b/src/MaxMatchSegmentation.cpp index 3383a104b..4ef587fc5 100644 --- a/src/MaxMatchSegmentation.cpp +++ b/src/MaxMatchSegmentation.cpp @@ -59,7 +59,7 @@ SegmentsPtr SegmentText(const std::shared_ptr& prefixMatch, } // Extend the unmatched run over characters that cannot begin any // dictionary key; each of them would fail the prefix lookup, so they - // are consumed with a single vectorized scan. + // are consumed with a single bulk scan. matchedLength += prefixMatch->SkipUnmatchable( pstr + matchedLength, remainingLength - matchedLength); segLength += matchedLength; diff --git a/src/PrefixMatch.hpp b/src/PrefixMatch.hpp index 76d475848..95c7ce2ef 100644 --- a/src/PrefixMatch.hpp +++ b/src/PrefixMatch.hpp @@ -60,7 +60,7 @@ class OPENCC_EXPORT PrefixMatch { * starts no key (ideographic description operators excluded, so the * caller's IDS grouping is preserved). Callers may consume the returned * run without calling MatchPrefixView() at any position inside it. The - * scan is vectorized for ASCII runs. Returns 0 when the next character + * scan is word-at-a-time for ASCII runs. Returns 0 when the next character * requires a real lookup. */ size_t SkipUnmatchable(const char* word, size_t len) const; @@ -72,8 +72,8 @@ class OPENCC_EXPORT PrefixMatch { // Always non-null after construction. On the single-dictionary fast path // it carries only the skip table; on the table path it also owns the - // matcher. Kept opaque so this header stays free of the skip-scan and - // SIMD intrinsic headers. + // matcher. Kept opaque so this header stays free of the skip-scan header + // and of PrefixMatch layout changes when the skip table evolves. std::shared_ptr tables; DictPtr singleDict; }; diff --git a/src/PrefixMatchTest.cpp b/src/PrefixMatchTest.cpp index 4dcec9087..000632d78 100644 --- a/src/PrefixMatchTest.cpp +++ b/src/PrefixMatchTest.cpp @@ -280,8 +280,8 @@ class PrefixMatchSkipTest : public ::testing::Test { TextDictPtr dict; }; -TEST_F(PrefixMatchSkipTest, SkipsVectorizedAsciiRuns) { - // No ASCII key exists, so long ASCII runs take the vectorized scan. +TEST_F(PrefixMatchSkipTest, SkipsLongAsciiRuns) { + // No ASCII key exists, so long ASCII runs take the word-at-a-time scan. PrefixMatch pm(dict); const std::string ascii(100, 'x'); EXPECT_EQ(ascii.size(), pm.SkipUnmatchable(ascii.data(), ascii.size())); diff --git a/src/Utf8SkipScan.hpp b/src/Utf8SkipScan.hpp index 238d4597f..4762cb586 100644 --- a/src/Utf8SkipScan.hpp +++ b/src/Utf8SkipScan.hpp @@ -26,29 +26,6 @@ #include "UTF8Util.hpp" -// The NEON movemask substitute (vshrn + ctz) assumes little-endian lane -// ordering, so big-endian ARM (rare, but existing in some 32-bit embedded -// toolchains) must fall back to the endian-checked SWAR path below. SSE2 and -// WASM SIMD128 targets are little-endian by definition. -#if defined(__SSE2__) || defined(_M_X64) || \ - (defined(_M_IX86_FP) && _M_IX86_FP >= 2) -#define OPENCC_UTF8_SKIP_SCAN_SSE2 1 -#include -#elif (defined(__aarch64__) || defined(_M_ARM64) || defined(__ARM_NEON)) && \ - ((defined(__BYTE_ORDER__) && \ - __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__) || \ - defined(_M_ARM64)) -#define OPENCC_UTF8_SKIP_SCAN_NEON 1 -#include -#elif defined(__wasm_simd128__) -#define OPENCC_UTF8_SKIP_SCAN_WASM 1 -#include -#endif - -#if defined(_MSC_VER) -#include -#endif - namespace opencc { namespace internal { @@ -74,7 +51,7 @@ inline uint32_t DecodeCodePoint23(const char* str, size_t charLength) { * Per-byte lookup table describing which byte values may begin a dictionary * key. The conversion hot loop uses it to consume runs of characters that * cannot possibly match any key without paying for a trie lookup per - * character, and to vectorize the common all-ASCII case. + * character, and to scan the common all-ASCII case a word at a time. */ struct Utf8SkipTable { /** @@ -84,7 +61,7 @@ struct Utf8SkipTable { */ bool candidate[256] = {}; /** - * True when any ASCII byte value is a candidate; disables the vectorized + * True when any ASCII byte value is a candidate; disables the bulk * ASCII-run scan. */ bool asciiHasCandidates = false; @@ -159,77 +136,28 @@ struct Utf8SkipTable { } }; -inline size_t CountTrailingZeros(uint64_t mask) { -#if defined(__GNUC__) || defined(__clang__) - return static_cast(__builtin_ctzll(mask)); -#elif defined(_MSC_VER) && (defined(_M_X64) || defined(_M_ARM64)) - unsigned long index = 0; - _BitScanForward64(&index, mask); - return static_cast(index); -#else - size_t count = 0; - while ((mask & 1) == 0) { - mask >>= 1; - count++; - } - return count; -#endif -} - /** * Returns the number of leading bytes of [str, str + len) with the high bit * clear, i.e. the length of the leading ASCII run. + * + * Word-at-a-time (SWAR) scan in plain C++: no intrinsics, no architecture + * or endianness special cases. Hand-written SSE2/NEON/WASM-SIMD kernels + * were measured against this and won by at most 3% end to end (see the + * commit that removed them): the candidate table has already eliminated the + * per-character trie lookups that dominated, so widening the scan from 8 to + * 16 bytes per step no longer moves the total. */ inline size_t AsciiRunLength(const char* str, size_t len) { size_t pos = 0; -#if defined(OPENCC_UTF8_SKIP_SCAN_SSE2) - for (; pos + 16 <= len; pos += 16) { - const __m128i chunk = - _mm_loadu_si128(reinterpret_cast(str + pos)); - const uint32_t mask = static_cast(_mm_movemask_epi8(chunk)); - if (mask != 0) { - return pos + CountTrailingZeros(mask); - } - } -#elif defined(OPENCC_UTF8_SKIP_SCAN_NEON) - for (; pos + 16 <= len; pos += 16) { - const uint8x16_t chunk = - vld1q_u8(reinterpret_cast(str + pos)); - // 0xFF per non-ASCII byte, then narrow to 4 bits per byte to obtain a - // 64-bit mask (the NEON substitute for _mm_movemask_epi8). - const uint8x16_t high = - vcltq_s8(vreinterpretq_s8_u8(chunk), vdupq_n_s8(0)); - const uint8x8_t narrowed = vshrn_n_u16(vreinterpretq_u16_u8(high), 4); - const uint64_t mask = vget_lane_u64(vreinterpret_u64_u8(narrowed), 0); - if (mask != 0) { - return pos + (CountTrailingZeros(mask) >> 2); - } - } -#elif defined(OPENCC_UTF8_SKIP_SCAN_WASM) - for (; pos + 16 <= len; pos += 16) { - const v128_t chunk = wasm_v128_load(str + pos); - const uint32_t mask = static_cast(wasm_i8x16_bitmask(chunk)); - if (mask != 0) { - return pos + CountTrailingZeros(mask); - } - } -#else - // SWAR fallback: examine 8 bytes per iteration. - for (; pos + 8 <= len; pos += 8) { + for (; pos + sizeof(uint64_t) <= len; pos += sizeof(uint64_t)) { uint64_t word; std::memcpy(&word, str + pos, sizeof(word)); - const uint64_t mask = word & UINT64_C(0x8080808080808080); - if (mask != 0) { -#if (defined(__BYTE_ORDER__) && \ - __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__) || \ - defined(_MSC_VER) - return pos + CountTrailingZeros(mask) / 8; -#else + if ((word & UINT64_C(0x8080808080808080)) != 0) { break; -#endif } } -#endif + // Resolve the exact position within the word that stopped the loop (at + // most 8 iterations), and handle the trailing partial word. for (; pos < len; pos++) { if (static_cast(str[pos]) >= 0x80) { break;