Repository navigation
Replace the _simple_case_mappings map with a block table - #25
Merged
Merged
Conversation
Contributor
|
Yes, leveraging |
simple_case_mapping() searched a sorted array of 2989 entries with
std::lower_bound, which is about 12 dependent loads per lookup. It is
called once per character, so it dominates to_uppercase() and friends.
Emit the table the way every other property table is already emitted
instead: a uint16_t index per code point (block size 256, 27 of 490
blocks materialised) plus a flat char32_t[3] value array. Row 0 is a
"no mapping" sentinel, so the index doubles as the found/not-found
flag and no separate presence check is needed. The index table stops
after the last mapped code point (U+1E943) rather than covering the
whole code point space.
generateTable() gains two things to support this, both defaulting to
the previous behaviour so every other generated table comes out byte
for byte identical:
- an optional blockSize override. findBestBlockSize() hardcodes an
8-byte item size, so it picks 64 for this uint16_t index table where
256 is about 10 kB smaller.
- tables that do not span the whole code point space are padded to a
whole number of blocks, and get_value() gets a bounds check against
the table length instead of 0x10FFFF.
to_uppercase() over a 500k character string, 20 iterations, Apple
M-series, clang -O2, median of 3 runs:
sorted array block table
ASCII 318.7 ms 153.9 ms
mixed scripts 305.1 ms 134.8 ms
simple_case_mapping() returns identical results for all 1114112 code
points and all three mapping types.
yhirose
force-pushed
the
blocktable-case-mapping
branch
from
August 26, 2026 21:18
60be497 to
ac6f0d8
Compare
full_case_mapping() searches _special_case_mappings and
_special_case_mappings_default with std::lower_bound for every
character, same as the simple case mapping table did before the
previous commit. case_folding()/simple_case_folding() do the same
against _case_foldings. Convert all three to block tables for the
same reason: they are looked up once per character, so std::lower_bound
dominates to_uppercase()/to_case_fold() even though the tables
themselves are small.
_special_case_mappings has several code points with more than one
entry (full_case_mapping() walks them in SpecialCasing.txt order and
applies the first whose language/context condition matches), so it
is grouped as {offset, count} into a flat SpecialCasing array, the
same shape as the existing script extension tables, keyed by code
point through a block table. _special_case_mappings_default and
_case_foldings have one entry per code point, so they follow the
simple case mapping table's shape directly: a block table index plus
a flat value array with row 0 as a "no entry" sentinel.
_find_code_point() and the SpecialCaseMapping/CaseFoldingEntry
wrapper structs it operated on are no longer used by anything and are
removed.
to_uppercase() over a 500k character string, 20 iterations, Apple
M-series, clang -O2, median of 3 runs:
master (sorted array) this commit
ASCII 319.5 ms 52.6 ms
mixed scripts 306.2 ms 51.7 ms
Isolating just the two special-casing searches previously accounted
for 118 of the 155 ms this branch spent per to_uppercase() call
before this commit; that's gone now.
simple_case_mapping(), simple_case_folding(), full_case_mapping()
(all four locales/contexts x code points), and normalization all
return identical results to master before the sorted-array commit,
checked exhaustively over all 1114112 code points. Full test suite
passes (770963 assertions in 59 test cases), clean with
-Wall -Wextra -Werror on clang.
- scripts/gen_tables.py: extract the "grow indices to cp+1, stamp index i" two-line idiom (used once by the pre-existing _simple_case_mappings table and three more times by the tables added in the previous commit) into a small assign_index() helper, and point each blockSize=256 override at the rationale already written above _simple_case_mappings instead of leaving it unexplained. - unicodelib.h: simple_case_folding() and case_folding() had grown an identical "look up the block-table index, deref into _case_folding_values" preamble. Factor it into find_case_folding(), restoring the single lookup point _find_code_point() used to provide before the previous commit removed it. - unicodelib.h: full_case_mapping()'s default-entry branch exceeded the file's ~80-column width; name the looked-up SpecialCasing locally instead of indexing inline, matching the pattern already used a few lines above for the context-dependent branch. No behavioral change: full test suite passes (770963 assertions in 59 test cases), clean with -Wall -Wextra -Werror, and simple_case_mapping()/simple_case_folding()/full_case_mapping()/ normalization all still match the pre-refactor unordered_map implementation exhaustively over all 1114112 code points.
yhirose
added a commit
that referenced
this pull request
Aug 28, 2026
Every table was emitted as one pointer per block, one value per block, and the non-uniform blocks written out in full with no deduplication: 3,014,400 bytes of fixed overhead across the seventeen tables before any of the values themselves. findBestBlockSize() also priced every table's entries at 8 bytes, so the size it picked was not the smallest one for a 4-byte enum or a 2-byte index. Emit each table as a two-stage trie instead: a first stage mapping a block of code points to a block of the second stage, a second stage with one entry per code point, and, where a value is wider than an index into the distinct values, a pool holding each distinct value once. Blocks with equal contents share one second-stage block, which is where the compression comes from. The block size is searched per table over shifts 5..9, priced with the table's real entry size. The four case mapping tables keep the 256 that #25 picked by hand, now for a measured reason rather than the 8-byte assumption: the search minimises bytes and picks 32 or 64 for them, which is 10% slower on to_lowercase/to_uppercase. Those read all four tables per character, so the four first stages compete for cache, and a first stage is dense and touched on every lookup while a second stage is sparse -- none of which byte count can see. Pinning costs 9,246 bytes. The property tables are the opposite case: pinning 256 on them costs 39,040 bytes and changes no measurement. Nothing about the tables' contents changes. Every one of the 1,114,112 code points was dumped through all thirteen property tables and through the case mapping and folding accessors, before and after, and the two dumps are byte for byte identical. Bytes a program links, over an empty program that includes the header (g++ -O2, --gc-sections, stripped): before after is_alphabetic 237,712 45,128 general_category 237,712 45,128 grapheme_length 536,864 82,128 to_nfkd 1,085,816 385,400 to_lowercase 1,769,848 541,048 Throughput over a 200k character string, best of 7 (g++ -O2): before after general_category 0.36 ms 0.10 ms is_alphabetic 0.35 ms 0.11 ms combining_class 0.68 ms 0.11 ms to_nfkd 2.51 ms 1.25 ms to_nfc 21.28 ms 18.83 ms to_lowercase 2.61 ms 2.54 ms A sorted range table with binary search was tried first and rejected. It is smaller still (362,132 bytes for the property tables) but 9.4x slower on general_category, 10.5x on is_alphabetic and 2.9x on to_nfkd, and a block index over the ranges does not rescue it: the ranges are not spread evenly, and U+0000..U+0FFF alone holds 1,169 general category ranges and 1,241 derived core property ranges, so the search does not get shorter where real text lives. Making the index fine enough to help would cost more than the ranges save.
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.
Replaces the four code-point-indexed case tables (
_simple_case_mappings,_special_case_mappings,_special_case_mappings_default,_case_foldings) with block tables instead of the sorted arrays +std::lower_boundthat master switched to._normalization_compositionstays a sorted array since its key is a code point pair, not a single code point, so it can't be block-tabled the same way; a spot check found no measurable difference there.Motivation
Each of these four tables is looked up once per character, so
std::lower_boundshows up directly into_uppercase(),to_case_fold(), and friends. A block table keeps the static-data benefit master's commit was going for (no map construction at start-up) and is faster than binary search.Layout
Same shape master already uses for every other property table: a
uint16_tindex per code point through a block table, plus a flat value array. Row 0 of the value array is a "no entry" sentinel, so the index doubles as the found/not-found flag._special_case_mappingshas several code points with more than one entry (full_case_mapping()walks them inSpecialCasing.txtorder and applies the first whose language/context condition matches), so it's grouped as{offset, count}into a flatSpecialCasingarray, the same shape as the existing script extension tables, keyed by code point through a block table._special_case_mappings_defaultand_case_foldingshave one entry per code point, so they follow_simple_case_mappings's shape directly._find_code_point()and theSpecialCaseMapping/CaseFoldingEntrywrapper structs it operated on are no longer used by anything and are removed.Measurements
Apple M-series, Release
-O2, clang, 20 iterations, median of 3 runs:to_uppercase(), ASCII, 500k charsto_uppercase(), mixed scripts, 500k charsto_case_fold(), ASCII, 500k charsto_case_fold(), mixed scripts, 500k charsto_nfc()(unaffected, sorted array kept)Isolating just the two special-casing searches accounted for about 75% of
to_uppercase()'s time on master despite_special_case_mappings/_special_case_mappings_defaulthaving only 16 and 103 entries respectively; a handful of dependent loads per character adds up.Static data: startup allocations stay at 2933 (unchanged from master, since master already removed the map construction). Test binary is about 17 KB larger than master's sorted-array version, in exchange for the above.
Correctness
Exhaustive differential test against the pre-sorted-array commit (
bfdd356, theunordered_map/unordered_multimapversion):simple_case_mapping(),simple_case_folding(),is_script(), andfull_case_mapping()(upper/lower/title/fold, across 6 locales and 7 surrounding contexts exercising everySpecialCasingContextcondition) return identical results for all 1114112 code points. Full test suite passes (770963 assertions in 59 test cases), clean with-Wall -Wextra -Werroron clang.Changes to
generateTable()Two additions, both defaulting to the previous behaviour so every other generated table comes out byte for byte identical:
blockSizeoverride.findBestBlockSize()hardcodes an 8-byte item size, so it picks 64 for auint16_tindex table where 256 is smaller.get_value()gets a bounds check against the table length instead of0x10FFFF.While looking at this I also noticed that
findDataSize()ignores the cost of the_blocks[]pointer array, sofindBestBlockSize()systematically picks blocks that are too small for the existing tables as well. That's unrelated to case mapping and left out of this PR.