string.c: take the single-byte path for a string of nothing but ASCII - #7209
Conversation
The two early returns are what `RSTR_SINGLE_BYTE_P()` reads, written out: a string read as bytes has one position per byte, and so does one whose bytes are nothing but ASCII. Reading the macro says that in one place, and the comment no longer has to name the halves separately. No behavior change; the pair of tests is the same pair the macro makes.
`String#inspect` passes a whole character through unescaped so it stays readable, and reads the character at every byte to know where the next one starts. A byte-read string has no characters to keep readable, so it was already sent down the byte by byte path `String#dump` takes. A string of nothing but ASCII has no character spelled in more than one byte either, so the read finds a character of one byte and nothing else, and the escaping writes each of those bytes out the way `dump` does. Ask `RSTR_SINGLE_BYTE_P()`, which is the pair, and the read goes away for a string already known to hold nothing but ASCII. Only a string whose bytes have been looked at already takes the shorter path: one nothing has read yet still reads a character per byte, and the walk records what it finds as it goes.
`String#chop!` cuts the last byte outright for a byte-read string and walks back from the end with `mrb_utf8_char_head()` for every other one, which is how it finds the head of a character spelled in more than one byte. A string of nothing but ASCII spells none of those, so the walk finds the last byte and nothing else. Ask `RSTR_SINGLE_BYTE_P()`, which is what both sides arrive at, and the walk is left to the strings that have a character to look for. Cutting the last byte is what the two builds have in common, so it is what the local starts at and the walk is what overrides it, which is also the shape the byte-indexed build was already compiled to.
|
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)
Included review availability: Your plan includes up to 8 reviews per rolling hour; 5 remain after this review. 📝 WalkthroughWalkthroughString length, inspection, and ChangesSingle-byte string behavior
Estimated code review effort: 2 (Simple) | ~10 minutes Merge Risk: ⚪ Minimal · up to This localized change routes already single-byte strings through equivalent byte-based paths to reduce unnecessary work, with no actionable merge-blocking risk remaining beyond normal checks and review. 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 |
A character index into a string is already a byte index when every byte of it
stands for a character of its own, and
RSTR_SINGLE_BYTE_P()is where that iswritten down:
Two sides arrive at it. A string read as bytes indexes by byte because that is
what it is, and a string of nothing but ASCII indexes by byte because ASCII
spells one character per byte. Three places in
src/string.cread only thefirst of the two, so a string already known to hold nothing but ASCII was sent
down the path written for a string that can spell a character in several bytes.
mrb_str_char_len()RSTR_BINARY_P(), thenRSTR_CODERANGE() == MRB_STR_CODERANGE_7BITin a secondifRSTR_SINGLE_BYTE_P()str_escape()RSTR_BINARY_P()RSTR_SINGLE_BYTE_P()mrb_str_chop_bang()RSTR_BINARY_P()RSTR_SINGLE_BYTE_P()The answers do not move. What moves is how many bytes are read to produce them.
mrb_str_char_len()The two early returns are the macro written out, so this commit is a fold and
nothing else. The comment above them named the halves separately; now it names
the one thing they are.
str_escape()String#inspectpasses a whole character through unescaped so it staysreadable, which is why it reads the character at every byte to learn where the
next one starts.
String#dumpdoes not, and a byte-read string was alreadysent down
dump's path because it holds no characters to keep readable.A string of nothing but ASCII holds none spelled in more than one byte either.
The read finds a character of one byte and nothing else, and the escaping
writes each of those bytes out the way
dumpalready does, so the read iswork with no reader. Asking
RSTR_SINGLE_BYTE_P()drops it, and("a" * 100000).inspectcomes to cost exactly what.dumpof the same string costs.Only a string whose bytes have been looked at already takes the shorter path.
One that nothing has read yet still reads a character per byte, and records
what it finds as it goes, so the next question about it is answered off the
flag.
mrb_str_chop_bang()String#chop!cuts the last byte outright for a byte-read string and walksback from the end with
mrb_utf8_char_head()for every other one, which is howit finds the head of a character spelled in more than one byte. A string of
nothing but ASCII spells none of those, so the walk finds the last byte and
nothing else.
Cutting the last byte is what the two builds already have in common, so it is
what the local starts at and the walk is what overrides it. That is the shape
the byte-indexed build was compiled to all along, and writing it that way is
what takes the
#elsearm away.Instruction counts
Wall clock on this machine carries a few percent of noise, which is wider than
most of the rows below, so the counts come from
callgrindasIr(2N) - Ir(N)so that interpreter start-up cancels.
("a" * 100000).inspect("a" * 100000).dump("abcあ" * 25000).inspect(("\xff" * 100000).b).inspects.chop!; s << "a", 100 KB ASCIIs.chop!; s << "あ", 150 KB UTF-8("a" * 100000).lengthThe one row that moves is the escaping of an ASCII string, and it lands on the
count
dumpof the same string already had.chop!loses 12 instructions percall and
length4, which is the shape of the change rather than a savingworth claiming: neither was reading the string, only testing it.
Wall clock agrees where the difference is wide enough to see. Best of five
inside the process, with master and this branch alternated five times and the
minimum taken,
-O3, default gembox plusmruby-encoding:("a" * 100000).inspect, 100 calls("abcあ" * 25000).inspect, 100 calls("a" * 100000).dump, 100 calls("a" * 100000).length, 200000 callsSize
.textsummed over every.o, each side built from an empty build directory:full-debug(-O0)bintestcxx_abibyte-stringascii-casebyte-stringis unchanged to the byte, which is what a build indexing stringsby byte should show:
RSTR_CODERANGE()is the constantMRB_STR_CODERANGE_7BITthere, so
RSTR_SINGLE_BYTE_P()folds away and the two touched functions arecompiled from the arms they already were.
By commit, on
bintest:src/string.o.textmrb_str_char_len()String#chop!The
-16is the arm that goes away: with the cut of the last byte as thelocal's initial value, the byte-read case is no longer a branch of its own.
Testing
ci/gcc-clangandbuild_config/asan.rb, run per build so the counts areattributable:
full-debugbintestcxx_abibyte-stringascii-caseasanbintest(the binary tests) passes 117 of 117 underci/gcc-clangand 79 of79 under
asan.Since every claim here is that an answer does not move, the two binaries were
also asked the same questions and compared:
b, and withthe coderange settled beforehand by
lengthor byvalid_encoding?), eachasked 25 questions covering
length,inspect,dump,chop,chop!,chomp,chomp!,valid_encoding?,ascii_only?,succ,reverse,chars,codepoints,ljust,rjust,center,[],index,rindex,and what the string stands at afterwards: 4750 answers, identical.
lead and continuation bytes, overlong and surrogate sequences, and
0xff,a third of them byte-read and half of them measured first: identical.
No new tests come with this. The existing suite already asks each of the three
functions about a string of nothing but ASCII, which is the case that changes
paths, and the answers are what it already asserts.
Environment
Details
The optimization level is not the same in every build, so these are the lines
that actually compiled
src/string.c, with-MMD -c,-Iand-odropped.cxx_abicompiles withgcc -x c++, not withg++;g++only links.Summary by CodeRabbit
String#chop!behavior for single-byte strings, while preserving correct UTF-8 boundary handling for multi-byte strings.