Skip to content

string.c: compare the first byte before memcmp() searching backward - #7186

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:string-rindex-first-byte
Aug 15, 2026
Merged

string.c: compare the first byte before memcmp() searching backward#7186
matz merged 1 commit into
mruby:masterfrom
takumin:string-rindex-first-byte

Conversation

@takumin

@takumin takumin commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

A backward search tries the needle at every position from pos down to the head, and hands each one to memcmp():

for (mrb_int i = pos; 0 <= i; i--) {
  if (memcmp(sbeg+i, t, len) == 0) {
    return i;
  }
}

Where the needle is not there, that is a call per byte of the string, and the call is what the search spends nearly all of its time on: memcmp() has to be entered, has its own head to run before it looks at anything, and then almost always disagrees on the first byte it reads.

The needle's first byte has to match wherever the rest of it does. Reading it inline settles every position but the ones that carry that byte, and only those reach memcmp():

const char head = t[0];
for (mrb_int i = pos; 0 <= i; i--) {
  if (sbeg[i] == head && memcmp(sbeg+i, t, len) == 0) {
    return i;
  }
}

str_char_rindex() searches the same way over character boundaries, and gets the same guard. There the byte is read at that position anyway, since stepping back to the previous character starts by looking at it.

Both functions have already returned by then for an empty needle, so t[0] is a byte of the needle in both. In str_char_rindex() the clamp above the loop puts s at the last byte the needle fits at, and the walk back only lowers it, so *s is inside the string at every turn.

Measurements

The bench-utf8 build under Environment at the end, whose compile line is gcc -O3, best of 5 runs of best of 5:

a = "a" * 100000
200.times { a.rindex("zzz") }
200.times { a.byterindex("zzz") }

u = "あ" * 50000
200.times { u.rindex("zzz") }
master this PR
a.rindex("zzz") 0.0588 0.0090 6.5x
a.byterindex("zzz") 0.0515 0.0045 11.4x
u.rindex("zzz") 0.0503 0.0358 1.4x

The multi-byte receiver gains least because there the positions tried are characters rather than bytes, a third as many here, and stepping between them is work of its own that stays.

A search that finds its needle at the first position tried pays one byte comparison it did not pay before. A search that has to look for it is the case this is about.

Generated code

.text over every .o, against the same objects built from master, 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.

build master this PR
full-debug 2834967 2835018 (+51)
bintest 1808046 1808110 (+64)
cxx_abi 1817650 1817714 (+64)
byte-string 1767586 1767618 (+32)

byte-string is the build without mruby-encoding. str_byterindex() is what a string answers with there, so it gains the guard too, and str_char_rindex() is not compiled.

Testing

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

build result
full-debug 2311 tests, 2308 OK, 3 skip
bintest 2312 tests, 2301 OK, 11 skip, plus 117 bintests
cxx_abi 2312 tests, 2301 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, tests and bintests. The reordered read is the reason to run it: the guard reads *s before the length check that used to come first.

Against master over 6720 cases of String#index and String#rindex, spanning ASCII, multi-byte, broken and binary receivers and needles with offsets on both sides of every edge, the answers are identical byte for byte, under the sanitizers as well.

No test accompanies the change. Both searches answer what they answered, so there is nothing new to pin.

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.

The needle's first byte has to match wherever the rest does, and
comparing it settles every position but the ones that carry it. Handing
each position to `memcmp()` instead pays for a call at all of them,
which is what a backward search spends nearly all of its time on where
the needle is not there to find.

`str_char_rindex()` reads that byte to step back from anyway.
@takumin
takumin requested a review from matz as a code owner August 15, 2026 15:47
@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: 76f708c8-cd15-4e89-974e-c40014774fa2

📥 Commits

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

📒 Files selected for processing (1)
  • src/string.c

📝 Walkthrough

Walkthrough

Backward byte and UTF-8 substring searches now check the needle’s first byte before calling memcmp. Existing backward traversal, UTF-8 boundary handling, length checks, and return behavior remain unchanged.

Changes

Backward substring search

Layer / File(s) Summary
First-byte comparison checks
src/string.c
str_byterindex and str_char_rindex cache the substring’s first byte and call memcmp only at matching positions. Existing scan and boundary behavior remains unchanged.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to fa6a4

This localized optimization reduces unnecessary backward-search work without changing string-search results; no actionable merge-blocking risk remains after normal checks and review.

Possibly related PRs

  • mruby/mruby#7099: Both changes modify backward byte and UTF-8 substring matching in src/string.c.
  • mruby/mruby#7107: Both changes modify backward UTF-8 substring search and its comparison path.

Suggested labels: core

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 and concisely describes the main optimization in backward searches.
✨ 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.

@github-actions github-actions Bot added the core label Aug 15, 2026
@matz
matz merged commit e186102 into mruby:master Aug 15, 2026
21 checks passed
@takumin
takumin deleted the string-rindex-first-byte branch August 15, 2026 21:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants