string.c: write the case walk's answer into the buffer it is building - #7208
Conversation
The walk builds the answer beside the string, since a mapping changes how many bytes a character takes, and it appended what it made of every character with `mrb_str_cat()`. An append from anywhere has questions to ask that an append of one's own bytes does not: it checks the length for overflow, works out whether the source overlaps the string, modifies the string, and carries the coderange across. Every character paid all of that, and every character was read through `mrb_utf8_decode()` first, ASCII included. One character above ASCII is what puts a whole string through this walk: the four callers keep an ASCII loop of their own and reach the walk only where the string holds more, so a string holding one such character is walked whole. `String#downcase` over 100,000 ASCII characters with one non-ASCII among them cost 194 instructions per character, where the ASCII loop costs 9.5. Write into the buffer instead, holding the length beside it, and convert a run of ASCII where it stands rather than through the decoder and an append. What the walk asks the string for is then room for one more character's mapping, and only where fewer than that many bytes are left. Instructions per call, callgrind on `bin/mruby`, gcc 13.3.0 -O3, `full-core` with `mruby-encoding`, as Ir(2N) - Ir(N) so the startup cancels. Every string is 100,000 characters but the last: | call | master | here | | ------------------------------- | ---------: | ---------: | | `downcase`, all ASCII (no walk) | 946,016 | 946,000 | | `downcase`, one above ASCII | 19,448,454 | 1,748,005 | | `upcase`, one above ASCII | 19,548,601 | 1,748,131 | | `swapcase`, one above ASCII | 19,449,096 | 1,948,469 | | `capitalize`, one above ASCII | 19,248,471 | 1,548,007 | | `downcase`, 50,000 of U+00C9 | 31,304,731 | 25,491,515 | | `upcase`, 50,000 of U+00DF | 24,854,725 | 19,291,468 | | `downcase`, four characters | 2,906 | 2,263 | `.text` over every `.o`, each side built from an empty build directory, for the five builds ci/gcc-clang makes: | build | master | here | | ----------- | --------: | --------: | | full-debug | 3,474,822 | 3,475,876 | | bintest | 2,351,723 | 2,354,995 | | cxx_abi | 2,356,881 | 2,359,489 | | byte-string | 2,285,149 | 2,285,149 | | ascii-case | 2,311,380 | 2,311,380 | The 3,272 bytes are an inlining decision rather than code, and `src/string.o` is the only object that moves in any of the five. The walk itself is 1,426 bytes against the 1,601 of the one it replaces, and gcc now inlines it into the callers where `mode` reaches it as a constant: | symbol | master | here | | -------------------------------- | -----: | ----: | | `mrb_str_case_convert_unicode()` | 1,601 | 1,426 | | `mrb_str_downcase_bang()` | 156 | 1,094 | | `mrb_str_upcase_bang()` | 156 | 1,102 | | `mrb_str_capitalize_bang()` | 227 | 1,286 | | `mrb_str_downcase()` | 212 | 539 | | `mrb_str_upcase()` | 212 | 539 | A build whose strings index by byte, and one that converts case by ASCII, compile none of this and are unchanged to the byte. The test is for what building beside the string costs: a string short enough to live inside its own object, converting to one that cannot, moves to a buffer that carries over as many bytes as the object says it holds, which is not the number the walk has written.
|
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 (2)
Included review availability: Your plan includes up to 8 reviews per rolling hour; 5 remain after this review. 📝 WalkthroughWalkthroughUTF-8 Unicode case conversion now writes directly into a dynamically grown output buffer, handles expanded mappings and invalid sequences, tracks output metadata, and includes regression tests for repeated expanding uppercase mappings. ChangesUnicode case conversion
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: ⚪ Minimal · up to The change updates UTF-8 case conversion to write directly into its destination buffer while preserving string results and error 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 |
A string with one character above ASCII in it is converted a character at a time through an append, and that is what the whole string costs.
What it costs
str_case_convert_utf8()builds the answer beside the string, since a mapping changes how many bytes a character takes, and it appended what it made of every character withmrb_str_cat(). An append from anywhere has questions to ask that an append of one's own bytes does not: it checks the length for overflow, works out whether the source overlaps the string, modifies the string, and carries the coderange across. Every character paid all of that, and every character was read throughmrb_utf8_decode()first, ASCII included.String#downcase,#upcase,#capitalizeand#swapcaseeach keep an ASCII loop of their own and reach this walk only where the string holds more, so a string of ASCII with one character above it in the middle is walked whole. Over 100,000 characters that is 194 instructions per character, where the ASCII loop those methods kept costs 9.5.What changes
The walk writes into the buffer and holds the length beside it, so what it asks the string for is room for one more character's mapping, and only where fewer than that many bytes are left. A run of ASCII is converted where it stands, needing neither the decoder nor the tables.
The result is the same string. The coderange it records, the
nilthe bang forms answer with when nothing changed, and theArgumentErrorfor bytes that spell no character are all where they were.Speed
Instructions per call, callgrind on
bin/mruby, asIr(2N) - Ir(N)so the startup cancels. Every build is a clean one.perfis not available on this machine and its wall clock moves by up to 80% between runs of the same binary, so the instruction count is the measurement rather than a check on one. Every string is 100,000 characters but the last:downcase, all ASCII, where the walk is not reacheddowncase, one character above ASCIIupcase, one character above ASCIIswapcase, one character above ASCIIcapitalize, one character above ASCIIdowncase, 50,000 of U+00C9upcase, 50,000 of U+00DF, whose mapping growsdowncase, four charactersThe first row is the loop this walk is not reached from, which is what says the walk is where the cost was. The two long non-ASCII rows keep the walk for every character and save the append alone.
Generated code
.textover every.o, each side built from an empty build directory, for the five buildsci/gcc-clangmakes.src/string.ois the only object that moves in any of them:The 3,272 bytes are an inlining decision rather than code. The walk is smaller than the one it replaces, and gcc now inlines it into the callers where
modereaches it as a constant, which is what the bytes are:src/string.omrb_str_case_convert_unicode()mrb_str_downcase_bang()mrb_str_upcase_bang()mrb_str_capitalize_bang()mrb_str_downcase()mrb_str_upcase()The builds that carry it are the ones already carrying the case tables. A build whose strings index by byte, and one that converts case by ASCII, compile none of this and measure to the byte what master measures.
A version that writes one character per turn of the loop rather than a run of them costs 128 bytes on
bintestinstead of 3,272 and lands at 3,548,010 instructions for the second row above rather than 1,748,005. It is the same fix at half the recovery; this PR is the other choice.Testing
rake -m testoverci/gcc-clangfrom an empty build directory, all five builds green, 0 KO, 0 crash, no new warnings:rake -m testoverbuild_config/asan.rbis green too, address and undefined sanitizers both: 2313 tests, 2310 OK, 3 skip, plus 79 bintests. Writing into a buffer the walk grows itself is what to run it for.The new test is for what building beside the string costs. A buffer that leaves an object carries over as many bytes as the object says it holds, and the walk holds its length apart from the string, so it has to say how much it has written or the bytes written so far are dropped where the buffer moves. U+0390 is two bytes and upper cases to six, so a string short enough to live inside its own object converts to one that cannot:
That assertion fails on the version of this commit that does not record the length, which is how the case was found in the first place.
Against master over 2,000 randomly built strings, each put through
downcase,upcase,capitalizeandswapcaseand both of the bang forms, the answers are identical: the bytes, the length,valid_encoding?and whether the bang form said it changed anything. The strings are 0 to 300 characters, spanning the lengths on both sides of what fits inside an object, and are drawn from ASCII, Latin-1, Latin Extended, Greek, Cyrillic, the Roman numerals, the ligatures whose mapping spells several characters, the fullwidth forms, Deseret and Hiragana. Identical under the sanitizers as well.Environment
Versions, and the compile line of every build named above
build_config/asan.rbpicksg++forcxx_abiThe instruction counts and the symbol sizes were taken on a build that is not one of the shipped configs,
full-coreat-O3with nothing else on it:What each build actually compiles
src/string.cwith,-MMD -c, the-Ipaths and-ostripped:-g -O3is what thegcctoolchain sets.full-debugand the sanitizer build then append-g3 -O0throughenable_debug(), so those two are-O0, not-O3.perf-utf8replaces the toolchain's flags rather than appending to them.cxx_abiis the C compiler driven as C++ with-x c++ -std=gnu++03, andg++links it.Summary by CodeRabbit
Bug Fixes
Tests