Skip to content

mruby-string-ext: read a String#casecmp? operand, not a copy of it - #7189

Merged
matz merged 4 commits into
mruby:masterfrom
takumin:string-casecmp-single-byte
Aug 15, 2026
Merged

mruby-string-ext: read a String#casecmp? operand, not a copy of it#7189
matz merged 4 commits into
mruby:masterfrom
takumin:string-casecmp-single-byte

Conversation

@takumin

@takumin takumin commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

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 is git 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:

static mrb_bool
str_folds_beyond_ascii(mrb_value str)
{
  struct RString *s = mrb_str_ptr(str);
  return RSTR_CODERANGE(s) != MRB_STR_CODERANGE_7BIT && !RSTR_BINARY_P(s);
}

A string nothing has read yet records nothing, so it stands at MRB_STR_CODERANGE_UNKNOWN and 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:

if (str_folds_beyond_ascii(self) || str_folds_beyond_ascii(other)) {
  mrb_value a = mrb_str_dup(mrb, self);
  mrb_value b = mrb_str_dup(mrb, other);
  if (mrb_str_case_convert_unicode(mrb, a, MRB_CASE_FOLD) < 0) str_fold_ascii(mrb, a);
  ...
}

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 as mrb_str_single_byte_p() and ask it:

static mrb_bool
str_folds_beyond_ascii(mrb_state *mrb, mrb_value str)
{
  return !mrb_str_single_byte_p(mrb, str);
}

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_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.

Measurements

The bench-utf8 build under Environment at the end, whose compile line is gcc -O3, the three binaries run against each other five times over and the best of each taken:

a = "abcdefgh"; b = "ABCDEFGH"
2_000_000.times { a.casecmp?(b) }

c = "a" * 4000; d = "A" * 4000
100_000.times { c.casecmp?(d) }

e = "aあbcde"; f = "Aあbcde"
500_000.times { e.casecmp?(f) }

xs = Array.new(100_000) { "aあbcde".dup }
ys = Array.new(100_000) { "Aあbcde".dup }
100_000.times { |i| xs[i].casecmp?(ys[i]) }

gs = Array.new(10_000) { ("a" * 3000 + "あ" * 300).dup }
hs = Array.new(10_000) { ("A" * 3000 + "あ" * 300).dup }
10_000.times { |i| gs[i].casecmp?(hs[i]) }

Each loop is written out as a while in the script that was run, so what is timed is the call rather than a block around it.

ms master #7187 this PR
2,000,000 of an 8 byte ASCII pair 189.2 194.2 69.9 2.7x
100,000 of a 4000 byte ASCII pair 1228.0 1311.0 348.3 3.5x
500,000 of an 8 byte pair above ASCII, one pair 169.0 165.0 164.2 1.0x
100,000 of an 8 byte pair above ASCII, each fresh 514.3 514.6 517.3 1.0x
10,000 of a 3900 byte pair above ASCII, each fresh 965.6 992.8 977.0 1.0x

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 adds str_substr()'s walk to src/string.c and 720 of the 1,232 bytes that file's object gains, is where the middle column parts from the left one.

Generated code

.text over every .o, against the same objects built from master and from this PR's base, for each of the four builds ci/gcc-clang makes. Their compile lines are under Environment at the end: full-debug is -O0, the other three are -O3. Each figure in brackets is against the column to its left.

build master #7187 this PR
full-debug 2834967 2835611 (+644) 2835590 (-21)
bintest 1808046 1809278 (+1232) 1809342 (+64)
cxx_abi 1817650 1818402 (+752) 1818482 (+80)
byte-string 1767586 1767586 (±0) 1767586 (±0)

The two objects that move, and the only two, over the whole stack:

master #7187 this PR
bintest src/string.o 48768 50000 (+1232) 50112 (+112)
bintest mruby-string-ext/src/string.o 20469 20469 (±0) 20421 (-48)

The middle column is #7187's str_substr() walk, which is all of what the stack adds to src/string.o before 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.o grows by the call it now has to be reachable through, and the gem falls by the two reads it spelled out. full-debug is the build where this PR's own change is a saving: at -O0 the function was already a call, so publishing it costs nothing there and the gem still drops its two reads. byte-string is unchanged throughout, since what either PR touches stands inside MRB_UTF8_STRING.

Testing

rake -m test over ci/gcc-clang, all four builds green, 0 KO, 0 crash, no new warnings:

build result
full-debug 2312 tests, 2309 OK, 3 skip
bintest 2313 tests, 2302 OK, 11 skip, plus 117 bintests
cxx_abi 2313 tests, 2302 OK, 11 skip
byte-string 2243 tests, 2195 OK, 48 skip

rake -m test over build_config/asan.rb is 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? and casecmp twice 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 a Symbol and an Integer. The answers are identical throughout, the ArgumentError that an operand spelling no character raises included. The same 3480 run identically on the byte-string build, and under the sanitizers, which report nothing.

Environment

Versions, and the compile line of every build named above
OS Ubuntu 24.04.4 LTS, Linux 7.0.0-28-generic x86_64
CPU AMD Ryzen 9 5950X, 16 cores
C compiler gcc 13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04.1)
Sanitizer compiler clang 22.1.8 (Homebrew), which is what build_config/asan.rb picks
Linker GNU ld 2.47.20260726, and g++ for cxx_abi
CRuby 4.0.6 (2026-07-14) +PRISM, running rake

The timings were taken on a build that is not one of the shipped configs, the default gembox with mruby-encoding added so that strings index by character:

MRuby::Build.new('bench-utf8') do |conf|
  toolchain :gcc
  conf.cc.flags << '-O3'

  conf.gembox 'default'
  conf.gem :core => 'mruby-encoding'
end

What each build actually compiles src/string.c with, -MMD -c, the -I paths and -o stripped:

# bench-utf8, the build every timing was taken on
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -O3 -DMRB_USE_SET -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DMRB_USE_COMPLEX -DMRB_USE_BIGINT -DMRB_USE_DEBUG_HOOK -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING

# ci/gcc-clang, full-debug
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -g3 -O0 -DMRB_GC_STRESS -DMRB_USE_DEBUG_HOOK -DMRB_UNICODE_CASE -DMRB_DEBUG -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER

# ci/gcc-clang, bintest
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_GC_FIXED_ARENA -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER -DMRB_USE_DEBUG_HOOK

# ci/gcc-clang, cxx_abi
gcc -g -O3 -Wall -Wundef -Wwrite-strings -x c++ -std=gnu++03 -DMRB_GC_FIXED_ARENA -DMRB_USE_CXX_EXCEPTION -DMRB_USE_CXX_ABI -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER

# ci/gcc-clang, byte-string
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER

# build_config/asan.rb
clang -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -Wzero-length-array -fsanitize=address,undefined -g3 -O0 -DMRB_DEBUG -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER

-g -O3 is what the gcc toolchain sets. full-debug and the sanitizer build then append -g3 -O0 through enable_debug(), so those two are -O0, not -O3. bench-utf8 appends a second -O3 of its own, which changes nothing over the toolchain's. cxx_abi is the C compiler driven as C++ with -x c++ -std=gnu++03, and g++ links it.

Summary by CodeRabbit

  • Bug Fixes
    • Improved UTF-8 string indexing, including negative indexes, bounded lengths, and out-of-range handling.
    • Corrected UTF-8-aware case-insensitive comparisons and string searching for single-byte content.
    • Fixed cached string character information after chomp! and chop! remove non-ASCII characters.
    • Improved substring operations to return nil consistently for invalid positions or lengths.

`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`.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ff2ba1a0-d63a-4ea6-bf03-06323e8120b2

📥 Commits

Reviewing files that changed from the base of the PR and between c02991b and 739d96a.

📒 Files selected for processing (4)
  • include/mruby/internal.h
  • mrbgems/mruby-string-ext/src/string.c
  • src/string.c
  • test/t/string.rb

📝 Walkthrough

Walkthrough

The 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.

Changes

UTF-8 string handling

Layer / File(s) Summary
Single-byte detection contract
include/mruby/internal.h, src/string.c
Adds the UTF-8-only mrb_str_single_byte_p declaration and implementation.
Indexing and mutation behavior
src/string.c, test/t/string.rb
Updates UTF-8 substring range handling, index, and rindex. Invalidates coderange data after relevant chomp! and chop! mutations. Adds negative-index and slice-boundary tests.
Case-folding integration
mrbgems/mruby-string-ext/src/string.c
Updates casecmp? folding checks to pass VM state and use mrb_str_single_byte_p.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 739d9

This localized change improves String#casecmp? performance while preserving comparison behavior across the tested builds; no actionable merge-blocking risk remains after normal checks and review.

Possibly related PRs

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: String#casecmp? now reads its operand directly instead of copying it.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@matz
matz merged commit cbc3b45 into mruby:master Aug 15, 2026
21 checks passed
@takumin
takumin deleted the string-casecmp-single-byte branch August 15, 2026 21:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants