Skip to content

string.c: take the single-byte path for a string of nothing but ASCII - #7209

Merged
matz merged 3 commits into
mruby:masterfrom
takumin:string-single-byte-path
Aug 16, 2026
Merged

string.c: take the single-byte path for a string of nothing but ASCII#7209
matz merged 3 commits into
mruby:masterfrom
takumin:string-single-byte-path

Conversation

@takumin

@takumin takumin commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

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 is
written down:

#define RSTR_SINGLE_BYTE_P(s) \
  (RSTR_CODERANGE(s) == MRB_STR_CODERANGE_7BIT || RSTR_BINARY_P(s))

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.c read only the
first 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.

function master reads this PR reads
mrb_str_char_len() RSTR_BINARY_P(), then RSTR_CODERANGE() == MRB_STR_CODERANGE_7BIT in a second if RSTR_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#inspect passes a whole character through unescaped so it stays
readable, which is why it reads the character at every byte to learn where the
next one starts. String#dump does not, and a byte-read string was already
sent 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 dump already does, so the read is
work with no reader. Asking RSTR_SINGLE_BYTE_P() drops it, and
("a" * 100000).inspect comes to cost exactly what .dump of 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 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.

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 #else arm 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 callgrind as Ir(2N) - Ir(N)
so that interpreter start-up cancels.

n = ARGV[0].to_i
s = "a" * 100000
s.length          # settle the coderange, as any earlier question would
n.times { s.inspect }
case N master this PR ratio
("a" * 100000).inspect 20 366,136,060 302,135,900 1.21x
("a" * 100000).dump 20 302,135,920 302,135,900 1.00x
("abcあ" * 25000).inspect 20 367,951,160 367,951,240 1.00x
(("\xff" * 100000).b).inspect 20 328,348,100 328,348,200 1.00x
s.chop!; s << "a", 100 KB ASCII 2000 3,387,524 3,363,524 1.01x
s.chop!; s << "あ", 150 KB UTF-8 2000 3,486,800 3,482,800 1.00x
("a" * 100000).length 2000 1,682,000 1,674,000 1.00x

The one row that moves is the escaping of an ASCII string, and it lands on the
count dump of the same string already had. chop! loses 12 instructions per
call and length 4, which is the shape of the change rather than a saving
worth 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 plus mruby-encoding:

def bench(name, n)
  best = nil
  5.times do
    t = Time.now
    n.times { yield }
    d = Time.now - t
    best = d if best.nil? || d < best
  end
  puts "#{name}\t#{'%.4f' % best}"
end

ascii = "a" * 100000
ascii.length
bench("inspect ascii", 100) { ascii.inspect }
case master this PR ratio
("a" * 100000).inspect, 100 calls 0.1100 s 0.0977 s 1.13x
("abcあ" * 25000).inspect, 100 calls 0.1156 s 0.1175 s 1.00x
("a" * 100000).dump, 100 calls 0.0963 s 0.0974 s 1.00x
("a" * 100000).length, 200000 calls 0.0134 s 0.0134 s 1.00x

Size

.text summed over every .o, each side built from an empty build directory:

build master this PR delta
full-debug (-O0) 2,778,363 2,778,409 +46
bintest 1,777,133 1,777,117 -16
cxx_abi 1,785,780 1,785,748 -32
byte-string 1,730,937 1,730,937 0
ascii-case 1,752,187 1,752,171 -16

byte-string is unchanged to the byte, which is what a build indexing strings
by byte should show: RSTR_CODERANGE() is the constant MRB_STR_CODERANGE_7BIT
there, so RSTR_SINGLE_BYTE_P() folds away and the two touched functions are
compiled from the arms they already were.

By commit, on bintest:

commit src/string.o .text delta
master 50,128
fold the test in mrb_str_char_len() 50,128 0
escape a single-byte string byte by byte 50,128 0
cut the last byte in String#chop! 50,112 -16

The -16 is the arm that goes away: with the cut of the last byte as the
local's initial value, the byte-read case is no longer a branch of its own.

Testing

ci/gcc-clang and build_config/asan.rb, run per build so the counts are
attributable:

build tests OK KO skip
full-debug 2312 2309 0 3
bintest 2312 2301 0 11
cxx_abi 2312 2301 0 11
byte-string 2243 2195 0 48
ascii-case 2309 2296 0 13
asan 2312 2309 0 3

bintest (the binary tests) passes 117 of 117 under ci/gcc-clang and 79 of
79 under asan.

Since every claim here is that an answer does not move, the two binaries were
also asked the same questions and compared:

  • a hand written corpus of 38 strings, each in 5 states (plain, b, and with
    the coderange settled beforehand by length or by valid_encoding?), each
    asked 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.
  • 20000 random byte strings of up to 8 bytes over an alphabet of ASCII, UTF-8
    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
OS Ubuntu 24.04
Kernel Linux 7.0.0-28-generic x86_64
CPU AMD Ryzen 9 5950X (16 cores, 32 threads)
C compiler gcc 13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04.1)
C compiler (asan) clang 22.1.8 (Homebrew)
binutils GNU ld 2.47.20260726
CRuby (build host) ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM
valgrind valgrind-3.27.1

The optimization level is not the same in every build, so these are the lines
that actually compiled src/string.c, with -MMD -c, -I and -o dropped.
cxx_abi compiles with gcc -x c++, not with g++; g++ only links.

# 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_DEBUG -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER src/string.c

# 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 -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER -DMRB_USE_DEBUG_HOOK src/string.c

# 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 -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER src/string.c

# 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 -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER src/string.c

# ci/gcc-clang, ascii-case
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_USE_ASCII_CASE -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER src/string.c

# 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 -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER src/string.c

# the build the timings were taken on: default gembox plus mruby-encoding
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DHAVE_MRUBY_REGEXP_GEM -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 src/string.c

Summary by CodeRabbit

  • Bug Fixes
    • Improved character-length handling for single-byte strings.
    • String inspection now consistently handles single-byte content without unnecessary character-preserving behavior.
    • Fixed String#chop! behavior for single-byte strings, while preserving correct UTF-8 boundary handling for multi-byte strings.
    • These updates provide more consistent results when working with ASCII, binary, and multi-byte string data.

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.
@takumin
takumin requested a review from matz as a code owner August 16, 2026 17:43
@github-actions github-actions Bot added the core label Aug 16, 2026
@coderabbitai

coderabbitai Bot commented Aug 16, 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: 54632934-5e91-46e6-821b-ca526fae6b88

📥 Commits

Reviewing files that changed from the base of the PR and between 9710e46 and 21bd476.

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

Included review availability: Your plan includes up to 8 reviews per rolling hour; 5 remain after this review.


📝 Walkthrough

Walkthrough

String length, inspection, and String#chop! now use RSTR_SINGLE_BYTE_P. Byte-oriented behavior applies to all single-byte strings, while UTF-8 boundary handling remains for multi-byte strings.

Changes

Single-byte string behavior

Layer / File(s) Summary
Length and inspection handling
src/string.c
mrb_str_char_len and str_escape use single-byte detection for byte-oriented handling.
Chop boundary handling
src/string.c
mrb_str_chop_bang skips UTF-8 boundary adjustment for single-byte strings and retains it for multi-byte strings.

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

Merge Risk: ⚪ Minimal · up to 21bd4

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: 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: routing ASCII-only strings through the single-byte path in string.c.
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.
✨ 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 e7db7c2 into mruby:master Aug 16, 2026
21 checks passed
@takumin
takumin deleted the string-single-byte-path branch August 16, 2026 23: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