mruby-string-ext: read a String#casecmp? operand, not a copy of it - #7189
Conversation
`RSTR_SINGLE_BYTE_P()` reports what the coderange records, and a string nothing has read yet records nothing, so it answers no however plain the bytes are. Every caller standing on that answer then walks the string by character, and the walk it took to get there is thrown away. `str_single_byte_p()` asks the bytes where the coderange does not say. A string is walked whole at most once: the walk records what it finds, and it is the same walk the character indexing would go on to do anyway. `String#index` was reading the coderange for 7BIT alone, which left a binary string on the character path that a byte search answers.
`str_modify_keep_cr()` keeps the coderange across a write that cannot change it, and cutting bytes that are nothing but ASCII is such a write: what the rest is read as stands, non-ASCII and all. Cutting a non-ASCII byte can have taken the last of them, and a string of nothing but ASCII stands at 7BIT rather than VALID, so `chomp!` and `chop!` leave a VALID that no longer describes the bytes. That coderange is only ever read as a hint, so nothing answers wrongly today. It costs a walk at every later reader that could have taken the single byte path, which is what asking again buys back.
What a substring needs of the string is where two positions are, not how many characters the string has. `str_substr()` asked for the count first, which reads every byte however near the head the range sits. The walk now stops at the range: forward to `beg` for a position counted from the head, backward from the end for one counted from there. A position past the end is what the forward walk reports by coming back longer than the string. The byte build keeps the counting form, where a character index is a byte index and the count is already free.
`str_folds_beyond_ascii()` takes the coderange off the string and reads
anything short of 7BIT as a string the fold tables could speak about. A
string nobody has read through yet stands at UNKNOWN, so it is read as one,
and the folding path it is sent down copies it, walks the copy, folds the
copy and throws it away. The walk goes with it, so the next comparison of the
same string starts at UNKNOWN again and pays the same copy: a string of
nothing but ASCII, which is what the method is mostly handed, never stops
paying for the walk that would have said so.
What it is spelling out is what `str_single_byte_p()` in string.c already
answers. That one asks the bytes where the string does not say, and a string
is single byte where it holds nothing but ASCII and where it is read as
bytes, which are the two cases named here by hand. Publish it as
`mrb_str_single_byte_p()` and ask it instead. The first comparison walks,
every one after it reads a flag, and a pair holding nothing but ASCII orders
by its bytes the way it did before this method learned to fold.
A string read as bytes still costs nothing to answer for: the walk is made by
`mrb_str_valid_encoding_p()`, which hands such a string back before it reads
a byte of it.
Declare it inside the `MRB_UTF8_STRING` guard rather than beside
`mrb_str_valid_encoding_p()`, which stands outside one. That check answers on
both sides because mruby-regexp and mruby-string-ext call it without a guard;
this one is asked from inside a guard on either side of it, and a build
indexing by byte has no second way to arrive at a single byte string for the
answer to be about.
### Time
gcc -O3, the default gembox with `mruby-encoding` added so that strings
index by character, the two binaries run against each other five times over
and the best of each taken:
```
2,000,000 of an 8 byte ASCII pair 192.7 ms -> 68.9 ms
100,000 of a 4000 byte ASCII pair 1313.2 ms -> 355.4 ms
500,000 of an 8 byte pair above ASCII, one pair 165.6 ms -> 162.7 ms
100,000 of an 8 byte pair above ASCII, each fresh 516.6 ms -> 512.5 ms
10,000 of a 3900 byte pair above ASCII, each
fresh 990.8 ms -> 970.0 ms
```
The last three rows are where the reading cannot pay for itself: a pair
holding a character above ASCII is folded as it was before, and the bottom
two never read a flag back, since no pair in them is compared twice.
### Size
`.text` over every `.o` of ci/gcc-clang's `bintest` build, gcc -O3, rises
1,809,278 to 1,809,342. src/string.o rises 50,000 to 50,112, since a
published function needs one copy of itself to be called through where three
inlined ones needed none, and mruby-string-ext/src/string.o falls 20,469 to
20,421, where the two flag reads it spelled out become that call.
`full-debug`, which is `-O0` and so had the function as a call already, falls
21 bytes. A build reading its strings as bytes is unchanged, since what this
touches stands inside `MRB_UTF8_STRING`.
|
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 (4)
📝 WalkthroughWalkthroughThe change adds a UTF-8-only single-byte string predicate. UTF-8 substring indexing now resolves ranges locally, while search, mutation, and case-folding paths use the shared predicate. Tests cover negative indexing and slice boundaries. ChangesUTF-8 string handling
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to This localized change improves Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 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 |
Stacked on #7187, whose three commits are the first three here and where
str_single_byte_p()comes from. The fourth is this PR's own change, and the diff to read isgit diff cb767edb0..HEAD. Merging #7187 first leaves this one a single commit. Every number below is given against master and against that branch both, the master column so the whole stack can be read at once and the middle one so this PR's own change can be.String#casecmp?folds both sides before it compares them, and it decides whether the fold tables have anything to say about a string by reading the coderange off it:A string nothing has read yet records nothing, so it stands at
MRB_STR_CODERANGE_UNKNOWNand reads here as a string that folds beyond ASCII, however plain its bytes are. What that answer sends it down is the folding path, which copies both operands, folds the copies and throws them away:The walk that would have said "nothing but ASCII" is made on the copy, and it goes out with the copy. So the next comparison of the same string starts at UNKNOWN again and pays for the same two copies, and a pair of plain ASCII strings, which is what the method is mostly handed, never stops paying for the walk that would have said so.
Ask the bytes, and leave the answer on the string
What the predicate spells out is what
str_single_byte_p()in string.c already answers: it asks the bytes where the string does not say, and a string is single byte where it holds nothing but ASCII and where it is read as bytes, which are the two cases named here by hand. Publish it asmrb_str_single_byte_p()and ask it:The reading is left on the string it was made about rather than on a copy, so the first comparison walks and every one after it reads a flag. A pair holding nothing but ASCII then orders by its bytes the way it did before this method learned to fold.
A string read as bytes still costs nothing to answer for. The walk is made by
mrb_str_valid_encoding_p(), which hands such a string back before it reads a byte of it.The declaration goes inside the
MRB_UTF8_STRINGguard rather than besidemrb_str_valid_encoding_p(), which stands outside one. That check answers on both sides because mruby-regexp and mruby-string-ext call it without a guard; this one is asked from inside a guard on either side of it, and a build indexing by byte has no second way to arrive at a single byte string for the answer to be about.Measurements
The
bench-utf8build under Environment at the end, whose compile line isgcc -O3, the three binaries run against each other five times over and the best of each taken:Each loop is written out as a
whilein the script that was run, so what is timed is the call rather than a block around it.The last column is against master.
The first two rows are one flag read standing in for a pair of copies, and what separates them is the length of the pair: the longer the ASCII string, the more of it was being walked and copied for nothing.
The last three are where the reading cannot pay for itself. A pair holding a character above ASCII is folded as it was before, and the bottom two never read a flag back, since no pair in them is compared twice. Those are the rows to read for whether this is paid for elsewhere.
The two left columns are the same work timed twice: master and #7187 both fold, since neither has a flag to read, and the coderange goes out with the copy on both. What separates them is not this method. #7187 changes no function
casecmp?calls, and its first two commits leave the second row where master has it; the third, the one that addsstr_substr()'s walk tosrc/string.cand 720 of the 1,232 bytes that file's object gains, is where the middle column parts from the left one.Generated code
.textover every.o, against the same objects built from master and from this PR's base, for each of the four buildsci/gcc-clangmakes. Their compile lines are under Environment at the end:full-debugis-O0, the other three are-O3. Each figure in brackets is against the column to its left.The two objects that move, and the only two, over the whole stack:
The middle column is #7187's
str_substr()walk, which is all of what the stack adds tosrc/string.obefore this PR reaches it and none of what it adds to the gem.A published function needs one copy of itself to be called through where three inlined flag reads needed none, so
src/string.ogrows by the call it now has to be reachable through, and the gem falls by the two reads it spelled out.full-debugis the build where this PR's own change is a saving: at-O0the function was already a call, so publishing it costs nothing there and the gem still drops its two reads.byte-stringis unchanged throughout, since what either PR touches stands insideMRB_UTF8_STRING.Testing
rake -m testoverci/gcc-clang, all four 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.No test comes with this. The answers do not move; what moves is which of them is read off a flag and which is walked for, and there is nothing a script can ask a string that tells those two apart. What stands in for one is the comparison against the base binary, over 3480 cases: 58 operands spanning empty, short and long ASCII, Latin above ASCII, ß and ff and the dotted and dotless i, Greek final sigma, Cyrillic, Japanese, an emoji, bytes that spell no character and bytes below space, taken as every ordered pair and each pair asked
casecmp?andcasecmptwice over, once while both operands are fresh and once after the first pair of calls has left its reading on them, plus every operand against aSymboland anInteger. The answers are identical throughout, theArgumentErrorthat an operand spelling no character raises included. The same 3480 run identically on thebyte-stringbuild, and under the sanitizers, which report nothing.Environment
Versions, and the compile line of every build named above
build_config/asan.rbpicksg++forcxx_abiThe timings were taken on a build that is not one of the shipped configs, the default gembox with
mruby-encodingadded so that strings index by character: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.bench-utf8appends a second-O3of its own, which changes nothing over the toolchain's.cxx_abiis the C compiler driven as C++ with-x c++ -std=gnu++03, andg++links it.Summary by CodeRabbit
chomp!andchop!remove non-ASCII characters.nilconsistently for invalid positions or lengths.