string.c: keep what a string reads as across a write that cannot change it - #7180
Conversation
|
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 (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthrough
ChangesString coderange preservation
Estimated code review effort: 2 (Simple) | ~10 minutes Merge Risk: ⚪ Minimal · up to This localized change preserves string encoding state across specific in-place operations without introducing an actionable merge-blocking risk; it is merge-ready 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 |
…ge it
Five in-place operations write bytes that leave the reading alone.
`upcase!`, `downcase!` and `capitalize!` touch a byte only where
`ISUPPER` or `ISLOWER` holds, and those are `((unsigned)(c) - 'A') < 26`
in mruby.h, so a UTF-8 continuation byte is never among them. `chomp!`
cuts `\n` and `\r`, or a separator it has already found a character
boundary in front of. `chop!` asks `mrb_utf8_char_head` where the last
character starts before cutting there.
All five went through `mrb_str_modify_keep_ascii`, which keeps a string
standing at 7BIT and takes everything else back to UNKNOWN. So a string
holding multi-byte characters was read whole again by the next asker
that needed to know whether it is sound, however little the write could
have changed the answer.
`str_modify_keep_cr` keeps that answer and asks again only where the
string was already read as broken, which is the shape CRuby's
`str_modify_keep_cr` has. The promise it wants of a caller is that the
write leaves the reading standing, and nothing here can check that, so
it stays inside the file rather than joining the two that are offered
outside it.
Nothing about this is visible from Ruby: the answer a walk arrives at is
the answer already on the string, so both spell the same result and only
one of them pays for it. What it saves is the walk. A subject handed to
a regexp is checked through `mrb_str_valid_encoding_p` on every match,
so a loop that edits and then matches stops re-reading the subject:
s = ("日本語 text テキスト " * 200).dup
20000.times { s.upcase!; s =~ /TEXT/; s.downcase!; s =~ /text/ }
0.330s -> 0.176s (gcc -O3, MRB_UTF8_STRING, best of 9)
A write that leaves the string holding nothing but ASCII now keeps
saying VALID where before the next walk would have settled it at 7BIT.
That answer is worth less than the truth rather than being wrong, and
reaching the truth is the walk this is here to skip.
9f84c36 to
7e12663
Compare
Five in-place operations write bytes that leave the reading alone:
upcase!downcase!capitalize!ISUPPERorISLOWERholds, and both are((unsigned)(c) - 'A') < 26ininclude/mruby.h:1509-1510, so a UTF-8 continuation byte is never among themchomp!\nor\r, or a separator it has already found a character boundary in front of (src/string.c:2128-2131)chop!mrb_utf8_char_head()where the last character starts before cutting thereAll five go through
mrb_str_modify_keep_ascii(), which keeps a string standing at 7BIT and takes every other answer back toMRB_STR_CODERANGE_UNKNOWN. So a string holding multi-byte characters is read whole again by the next asker that needs to know whether it is sound, however little the write could have changed the answer.This PR gives those five a prepare that keeps the answer:
Only a string already read as broken has to be asked again, since a write is as likely to have mended it as to have left it broken. This is the shape CRuby's
str_modify_keep_cr()has, and like CRuby's it isstatic: the promise it wants of a caller is that the write leaves the reading standing, nothing here can check that, and the two prepares offered outside the library are unchanged.No answer changes
The answer a walk arrives at is the answer already on the string, so both spell the same result and only one of them pays for it. What is saved is the walk. A subject handed to a regexp is checked through
mrb_str_valid_encoding_p()on every match, so a loop that edits and then matches stops re-reading the subject:Best of 9 runs of the
bintestbin/mruby,gcc 13.3.0 -O3, x86-64.String#indexis not among the paths this helps:mrb_str_index_str()runs the check on the needle, not on the subject.What it gives up, and where that shows
A write that leaves the string holding nothing but ASCII now keeps saying VALID where before the next walk would have settled it at 7BIT. That answer is worth less than the truth rather than being wrong, but the
7BIT || BINARYtest thatmrb_str_char_to_byte()and its neighbours make reads false for such a string, so its indexing walks where it could have returned the byte offset.It does not stay that way for long. Three places walk a string and record what they found, and any of them takes the field to 7BIT:
mrb_str_char_len(),src/string.c:670, which isString#lengthand every character index that needs a count,String#[]among them;mrb_str_valid_encoding_p(),src/string.c:705, when the string comes in UNKNOWN;str_ascii_only_p(),mrbgems/mruby-string-ext/src/string.c:1516, which isString#ascii_only?.The second is the one this PR keeps away from: a string that comes in at VALID is answered off the field and never walked, so the recording never happens. That is what it costs, and where the string is long enough it costs a lot. Constructed to reach it:
String#rindexhands the whole search tomrb_str_byterindex_m()where the string stands at 7BIT and takes the character path otherwise, and neither path records what it walked, so nothing closes the gap from inside the loop. OneString#lengthin front of it does:So what is given up is one walk that master would have got for free out of the regexp's check, on a string that was read as multi-byte and whose last write left it holding nothing but ASCII, and only until something counts its characters. CRuby's
str_modify_keep_cr()leaves the same answer behind.Generated code
gcc 13.3.0 -O3, x86-64, against the same objects built from master..textofbin/mruby:build_config/default.rbfull-core,i686-linux-gnu-gccenable_debugObjects differing, comparing
objdump -dover every.oin the build:src/string.osrc/string.osrc/string.obuild_config/default.rbsrc/string.oIt is
src/string.oand nothing else in every build, and it is one inlining decision rather than five copies of a two-line body. Per symbol,bintest:On master
str_unshare_buffer()is inlined into every caller it has, including the exportedmrb_str_modify_keep_ascii(), which the five bang methods then call out to. Here the five call astaticinstead, gcc inlines it andstr_unshare_buffer()along with it, and stops inliningstr_unshare_buffer()into the twocstrfunctions, emitting one out-of-line copy that they call. The two halves are visible in the relocations ofmrb_str_chomp_bang(), master above and this branch below:and in
mrb_string_value_cstr(), where master'smrb_malloc/memcpy/mrb_freeare gone and a call tostr_unshare_bufferstands in their place.full-debugis the one build where gcc keepsstr_modify_keep_cr()out of line, and there the whole difference is that one function at 123 bytes.A build that indexes by byte writes no coderange, so
str_modify_keep_cr()andmrb_str_modify_keep_ascii()are the same two lines there and gcc treats them the same way.byte-stringanddefault.rbare unchanged to the byte; the one object that differs differs only in the ordermrb_str_chomp_bangandmrb_str_capitalize_bangare emitted in, with every symbol the size it was.Testing
rake -m test, all green, 0 KO, 0 crash, 0 warnings, and the same counts as master:build_config/default.rbfull-core,i686-linux-gnu-gccenable_debugbuild_config/host-m32.rbneeds a multilib toolchain, so the 32-bit build above isfull-corewith the compiler set toi686-linux-gnu-gccinstead.No test comes with this. What a walk answers and what the field says agree by construction here, so there is no result for a test to tell apart; the two benchmarks above are what the change is for and what it costs.
Not in this PR
mrb_str_modify_keep_ascii()stays where it is, still exported and still called bymrb_str_modify(). Which other in-place writes could make the same promise is a separate question from giving the five that already keep it a prepare that believes them, and the coderange clearing spread acrossmrb_str_modify()is untouched.Summary by CodeRabbit