unicase.c: unfold an ASCII character without reading a table - #7225
Conversation
Compiling a pattern under `/i` asks `mrb_uni_case_unfold()` for the case
counterparts of every character it holds, and `mrb_uni_case_unfold_range()`
for the counterparts of every span a character class holds. Both finish with
two full scans, over the 24 runs of the folding difference and the 184 runs of
the lowercase mapping, each run unpacked from six packed bytes and each
candidate re-checked through `mrb_uni_case_fold()`, which is two more binary
searches. A pattern of nothing but ASCII, which is most patterns, makes those
scans once per character and once per span, and collects almost nothing.
Almost, rather than nothing, because ASCII is not in the tables at all: the
generator drops every source below `0x80`, since the callers fold ASCII inline
from the two rules above the scans. What the scans can still find for an ASCII
source is a source above ASCII that folds down into it, and there are two of
them, `U+017F` into `'s'` and `U+212A` into `'k'`. Nothing else in either table
folds below `0x80`.
So `tools/gen_unicase.rb` emits that list, `UNI_FOLD_TO_ASCII`, measured over
the same two maps the tables are built from rather than written out beside
them, for the reason `UNI_CASE_MAX_BYTES` is measured rather than declared: a
Unicode version that adds a third gets a third here without anyone noticing it
had to. Both entry points then answer an ASCII source, and the ASCII part of a
span, from the list, and hand the scans only the range they have anything to
say about.
Nothing else changes. `mrb_uni_case_fold_range()` keeps both of its walks: an
ASCII source is what the tables are the rest of, so its half of the closure has
no list to read, and the gem folds its bitmap inline already.
Measured with gcc 13.3.0 at `-O3`, `full-core`, Linux x86_64, as callgrind
`Ir(2N) - Ir(N)` so that interpreter start-up cancels:
| | master | this | `MRB_USE_ASCII_CASE` |
|----------------------------------------------|--------------:|--------------:|---------------------:|
| `Regexp.new("[a-z0-9_]", //i)` x20,000 | 1,056,511,426 | 461,287,040 | 410,565,952 |
| `Regexp.new("hello world", //i)` x20,000 | 1,797,022,034 | 447,367,788 | 433,127,448 |
| `/[a-z0-9_]/i =~ "hello"` x100,000 | 5,385,099,698 | 2,408,977,326 | 2,154,671,750 |
| `Regexp.new("[\u{80}-\u{2FFF}]", //i)` x2,000| 881,046,557 | 841,504,178 | `RegexpError` |
That is 2.29x, 4.02x and 2.24x on the three rows an ASCII pattern was paying
for, landing within 3% to 12% of what a build that drops the Unicode tables
altogether spends, and 1.05x on the row that genuinely wants them. The
remainder above the last column is the general closure the gem runs, not the
tables.
`.text` of `bin/mruby`, `build_config/ci/gcc-clang.rb`, gcc 13.3.0, against
master: byte-string and ascii-case are unchanged, since neither compiles this
file; bintest is 48 bytes smaller, cxx_abi 80 larger and full-debug 192.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
Included review availability: Your plan includes up to 8 reviews per rolling hour; 1 remains after this review. 📝 WalkthroughWalkthroughThe generator now emits non-ASCII-to-ASCII folding mappings. Unicode unfolding uses these mappings directly, returns early for ASCII inputs, and limits mixed-range scans to non-ASCII codepoints. ChangesASCII case unfolding
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to This localized optimization changes how ASCII Unicode case counterparts are derived while preserving matching behavior; no actionable merge-blocking risk remains after normal checks and review. Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Compiling a pattern under
/iwalks the Unicode case tables once percharacter and once per character class span, and a pattern of nothing but
ASCII, which is most patterns, walks them for an answer that is not in them.
mrb_uni_case_unfold()(src/unicase.c:238on master) answers the casecounterparts of one character. It reads ASCII inline from two rules and then
scans both tables anyway:
That is 24 + 184 runs, each unpacked from six packed bytes by the loop in
case_run_at(), and each surviving candidate re-checked throughmrb_uni_case_fold(), which is two more binary searches.mrb_uni_case_unfold_range()(src/unicase.c:359) ends the same way over aspan.
Both are on the
/icompile path for ASCII.emit_char()(
mrbgems/mruby-regexp/src/re_compile.c:1097) asks for the counterparts ofevery ASCII letter a pattern spells, and the closure
compile_charclass()runs over a finished class asks for the counterparts of every run of set bits
in its ASCII bitmap.
Why the answer is not in the tables
The generator drops every source below
0x80: ASCII is what the tables are therest of, and the callers fold it inline from the two rules above. What the
scans can still find for an ASCII source is a source above ASCII that folds
down into it, and over the Unicode 17 database there are two,
U+017Finto's'andU+212Ainto'k'. Nothing else in either table folds below0x80.They are the same two
re_internal.halready spells out asRE_FOLD_LONG_Sand
RE_FOLD_KELVINfor a build with no table at all.Fix
tools/gen_unicase.rbemits that list asUNI_FOLD_TO_ASCII, an X-macro overthe same two maps the tables themselves are built from:
Measured over the maps rather than written out beside them for the reason
UNI_CASE_MAX_BYTESis measured rather than declared: a Unicode version thatadds a third source gets a third entry here without anyone having to notice
that it had to, and
rake unicode:verifyfails if the committed header and thedatabase disagree.
mrb_uni_case_unfold()then answers an ASCII source from the list and returns,and
mrb_uni_case_unfold_range()answers the ASCII part of its span from thelist and hands the walks only what is left above
0x80.mrb_uni_case_fold_range()keeps both of its walks. It reports the folds ofthe sources in a span, and an ASCII source is in no table, so there is no list
for it to read; the gem folds its ASCII bitmap inline already and never asks
that function about a span below
0x80.Instruction count
callgrind,
Ir(2N) - Ir(N)to cancel interpreter start-up. gcc 13.3.0 at-O3,full-core.MRB_USE_ASCII_CASERegexp.new("[a-z0-9_]", //i)x20,000Regexp.new("hello world", //i)x20,000/[a-z0-9_]/i =~ "hello"x100,000Regexp.new("[\u{80}-\u{2FFF}]", //i)x2,000RegexpErrorThe last column is a build that drops the Unicode case tables altogether, as
the floor an ASCII-only pattern could reach. The three ASCII rows land within
3% to 12% of it; what remains is the closure the gem runs over a finished
class, not the tables. Under callgrind,
mrb_uni_case_unfold_range()andeverything below it is 1.88% of the first row after this change.
Matching itself is untouched. The third row is a plain literal in a loop, which
mruby recompiles on every pass, which is why it moves with the other two.
Size
.textofbin/mruby,build_config/ci/gcc-clang.rb, from a clean builddirectory.
byte-stringascii-casebintestcxx_abifull-debug(-O0)The first two compile none of this:
byte-stringhas noMRB_UTF8_STRINGandascii-casedefinesMRB_USE_ASCII_CASE, and the whole file is behind thatpair.
bintestis the build that pays for the tables, and it shrinks: the twoscans lose a call site each. The
cxx_abiandfull-debugfigures are the twonew branches not being folded away, at
-O0for the latter.Verification
No test is added. What the fast path has to preserve is already pinned, on
every build, by
Regexp - /i folds an ASCII letter's class whole(
mrbgems/mruby-regexp/test/regexp_syntax.rb:218), which asserts that[a-z],[j-l]and[k]under/iall reachU+212Aand that[^k]doesnot, and by
Regexp - Unicode case folding under /i(
mrbgems/mruby-regexp/test/unicode_case.rb:5), which pins thesingle-character forms. Both pass.
Beyond that, two differential checks:
Against the two functions themselves. A harness carrying master's
mrb_uni_case_unfold()andmrb_uni_case_unfold_range()verbatim, linkedagainst the patched
libmruby.a, comparing the two as sets, since the walksmay report the same source twice and in spans of a different shape:
U+0000toU+10FFFF, 1,114,112 of them: 0 differences[lo, hi]insideU+0000..U+0250, every table entryand its neighbourhood, and 20,000 random spans over the whole range: 0
differences
Against the binary. 15,277 patterns, each compiled under
/iand runagainst 118 subjects chosen to cover ASCII, both fold-to-ASCII sources, the
three-way sigma class, dotted and dotless i, and the
U+10400andU+1E900blocks. Single characters and their positive and negated classes over
U+0000..U+02FFand a stride through the rest, 34 ranges, the ASCII-onlyclasses the fast path answers whole, and mixed classes. Output of the master
binary and the patched one is byte-identical.
rake testonbuild_config/ci/gcc-clang.rb, all five builds plus bintest:bintest(bintest suite)full-debugbintestcxx_abibyte-stringascii-caserake testonbuild_config/asan.rb: 2,336 total, 0 KO, 0 crash, plus its 79bintests, with no sanitizer report.
rake unicode:verifyagainst the Unicode 17.0.0 database: the committedtables, including the new list, are what the database generates.
Environment
Details
tools/gen_unicase.rbCompile lines for
src/unicase.cin the builds quoted above, paths shortened:🤖 Generated with Claude Code
https://claude.ai/code/session_01Dhp4MAkemDdpxUSPQWZHZ3
Summary by CodeRabbit
Performance
Compatibility