Skip to content

string.c: write the case walk's answer into the buffer it is building - #7208

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:string-case-walk-buffer
Aug 16, 2026
Merged

string.c: write the case walk's answer into the buffer it is building#7208
matz merged 1 commit into
mruby:masterfrom
takumin:string-case-walk-buffer

Conversation

@takumin

@takumin takumin commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

A string with one character above ASCII in it is converted a character at a time through an append, and that is what the whole string costs.

What it costs

str_case_convert_utf8() builds the answer beside the string, since a mapping changes how many bytes a character takes, and it appended what it made of every character with mrb_str_cat(). An append from anywhere has questions to ask that an append of one's own bytes does not: it checks the length for overflow, works out whether the source overlaps the string, modifies the string, and carries the coderange across. Every character paid all of that, and every character was read through mrb_utf8_decode() first, ASCII included.

String#downcase, #upcase, #capitalize and #swapcase each keep an ASCII loop of their own and reach this walk only where the string holds more, so a string of ASCII with one character above it in the middle is walked whole. Over 100,000 characters that is 194 instructions per character, where the ASCII loop those methods kept costs 9.5.

What changes

The walk writes into the buffer and holds the length beside it, so what it asks the string for is room for one more character's mapping, and only where fewer than that many bytes are left. A run of ASCII is converted where it stands, needing neither the decoder nor the tables.

The result is the same string. The coderange it records, the nil the bang forms answer with when nothing changed, and the ArgumentError for bytes that spell no character are all where they were.

Speed

Instructions per call, callgrind on bin/mruby, as Ir(2N) - Ir(N) so the startup cancels. Every build is a clean one. perf is not available on this machine and its wall clock moves by up to 80% between runs of the same binary, so the instruction count is the measurement rather than a check on one. Every string is 100,000 characters but the last:

call master this PR
downcase, all ASCII, where the walk is not reached 946,016 946,000
downcase, one character above ASCII 19,448,454 1,748,005
upcase, one character above ASCII 19,548,601 1,748,131
swapcase, one character above ASCII 19,449,096 1,948,469
capitalize, one character above ASCII 19,248,471 1,548,007
downcase, 50,000 of U+00C9 31,304,731 25,491,515
upcase, 50,000 of U+00DF, whose mapping grows 24,854,725 19,291,468
downcase, four characters 2,906 2,263

The first row is the loop this walk is not reached from, which is what says the walk is where the cost was. The two long non-ASCII rows keep the walk for every character and save the append alone.

Generated code

.text over every .o, each side built from an empty build directory, for the five builds ci/gcc-clang makes. src/string.o is the only object that moves in any of them:

build master this PR
full-debug 3,474,822 3,475,876 (+1,054)
bintest 2,351,723 2,354,995 (+3,272)
cxx_abi 2,356,881 2,359,489 (+2,608)
byte-string 2,285,149 2,285,149 (±0)
ascii-case 2,311,380 2,311,380 (±0)

The 3,272 bytes are an inlining decision rather than code. The walk is smaller than the one it replaces, and gcc now inlines it into the callers where mode reaches it as a constant, which is what the bytes are:

symbol in src/string.o master this PR
mrb_str_case_convert_unicode() 1,601 1,426
mrb_str_downcase_bang() 156 1,094
mrb_str_upcase_bang() 156 1,102
mrb_str_capitalize_bang() 227 1,286
mrb_str_downcase() 212 539
mrb_str_upcase() 212 539

The builds that carry it are the ones already carrying the case tables. A build whose strings index by byte, and one that converts case by ASCII, compile none of this and measure to the byte what master measures.

A version that writes one character per turn of the loop rather than a run of them costs 128 bytes on bintest instead of 3,272 and lands at 3,548,010 instructions for the second row above rather than 1,748,005. It is the same fix at half the recovery; this PR is the other choice.

Testing

rake -m test over ci/gcc-clang from an empty build directory, all five builds green, 0 KO, 0 crash, no new warnings:

build result
full-debug 2313 tests, 2310 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
ascii-case 2309 tests, 2296 OK, 13 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. Writing into a buffer the walk grows itself is what to run it for.

The new test is for what building beside the string costs. A buffer that leaves an object carries over as many bytes as the object says it holds, and the walk holds its length apart from the string, so it has to say how much it has written or the bytes written so far are dropped where the buffer moves. U+0390 is two bytes and upper cases to six, so a string short enough to live inside its own object converts to one that cannot:

assert_equal "Ϊ́" * 4, ("ΐ" * 4).upcase
assert_equal 24, ("ΐ" * 4).upcase.bytesize
1.upto(12) do |n|
  assert_equal "Ϊ́" * n, ("ΐ" * n).upcase
end

That assertion fails on the version of this commit that does not record the length, which is how the case was found in the first place.

Against master over 2,000 randomly built strings, each put through downcase, upcase, capitalize and swapcase and both of the bang forms, the answers are identical: the bytes, the length, valid_encoding? and whether the bang form said it changed anything. The strings are 0 to 300 characters, spanning the lengths on both sides of what fits inside an object, and are drawn from ASCII, Latin-1, Latin Extended, Greek, Cyrillic, the Roman numerals, the ligatures whose mapping spells several characters, the fullwidth forms, Deseret and Hiragana. Identical under the sanitizers as well.

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
Profiler valgrind 3.27.1, callgrind
CRuby 4.0.6 (2026-07-14) +PRISM, running rake

The instruction counts and the symbol sizes were taken on a build that is not one of the shipped configs, full-core at -O3 with nothing else on it:

MRuby::Build.new('perf-utf8') do |conf|
  conf.toolchain :gcc
  conf.gembox 'full-core'
  conf.cc.flags = %w(-O3 -g -std=gnu99 -Wall -Wundef)
  conf.enable_test
end

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

# perf-utf8, the build the instruction counts and symbol sizes come from
gcc -O3 -g -std=gnu99 -Wall -Wundef -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

# 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

# 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

# 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

# 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

# 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

# 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

-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. perf-utf8 replaces the toolchain's flags rather than appending to them. 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 Unicode uppercase conversion for strings requiring expanded output.
    • Invalid UTF-8 input is now rejected consistently during case conversion.
    • Improved handling of ASCII and mixed Unicode text during uppercase conversion.
  • Tests

    • Added regression coverage for uppercase conversions that expand string length across multiple input sizes.

The walk builds the answer beside the string, since a mapping changes how many
bytes a character takes, and it appended what it made of every character with
`mrb_str_cat()`. An append from anywhere has questions to ask that an append
of one's own bytes does not: it checks the length for overflow, works out
whether the source overlaps the string, modifies the string, and carries the
coderange across. Every character paid all of that, and every character was
read through `mrb_utf8_decode()` first, ASCII included.

One character above ASCII is what puts a whole string through this walk: the
four callers keep an ASCII loop of their own and reach the walk only where the
string holds more, so a string holding one such character is walked whole.
`String#downcase` over 100,000 ASCII characters with one non-ASCII among them
cost 194 instructions per character, where the ASCII loop costs 9.5.

Write into the buffer instead, holding the length beside it, and convert a run
of ASCII where it stands rather than through the decoder and an append. What
the walk asks the string for is then room for one more character's mapping,
and only where fewer than that many bytes are left.

Instructions per call, callgrind on `bin/mruby`, gcc 13.3.0 -O3, `full-core`
with `mruby-encoding`, as Ir(2N) - Ir(N) so the startup cancels. Every string
is 100,000 characters but the last:

| call                            |     master |       here |
| ------------------------------- | ---------: | ---------: |
| `downcase`, all ASCII (no walk) |    946,016 |    946,000 |
| `downcase`, one above ASCII     | 19,448,454 |  1,748,005 |
| `upcase`, one above ASCII       | 19,548,601 |  1,748,131 |
| `swapcase`, one above ASCII     | 19,449,096 |  1,948,469 |
| `capitalize`, one above ASCII   | 19,248,471 |  1,548,007 |
| `downcase`, 50,000 of U+00C9    | 31,304,731 | 25,491,515 |
| `upcase`, 50,000 of U+00DF      | 24,854,725 | 19,291,468 |
| `downcase`, four characters     |      2,906 |      2,263 |

`.text` over every `.o`, each side built from an empty build directory, for
the five builds ci/gcc-clang makes:

| build       |    master |      here |
| ----------- | --------: | --------: |
| full-debug  | 3,474,822 | 3,475,876 |
| bintest     | 2,351,723 | 2,354,995 |
| cxx_abi     | 2,356,881 | 2,359,489 |
| byte-string | 2,285,149 | 2,285,149 |
| ascii-case  | 2,311,380 | 2,311,380 |

The 3,272 bytes are an inlining decision rather than code, and `src/string.o`
is the only object that moves in any of the five. The walk itself is 1,426
bytes against the 1,601 of the one it replaces, and gcc now inlines it into
the callers where `mode` reaches it as a constant:

| symbol                           | master |  here |
| -------------------------------- | -----: | ----: |
| `mrb_str_case_convert_unicode()` |  1,601 | 1,426 |
| `mrb_str_downcase_bang()`        |    156 | 1,094 |
| `mrb_str_upcase_bang()`          |    156 | 1,102 |
| `mrb_str_capitalize_bang()`      |    227 | 1,286 |
| `mrb_str_downcase()`             |    212 |   539 |
| `mrb_str_upcase()`               |    212 |   539 |

A build whose strings index by byte, and one that converts case by ASCII,
compile none of this and are unchanged to the byte.

The test is for what building beside the string costs: a string short enough
to live inside its own object, converting to one that cannot, moves to a
buffer that carries over as many bytes as the object says it holds, which is
not the number the walk has written.
@takumin
takumin requested a review from matz as a code owner August 16, 2026 17:33
@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: 834b643c-4b66-4763-95fb-fb5a479f31fe

📥 Commits

Reviewing files that changed from the base of the PR and between 9710e46 and 2b72a75.

📒 Files selected for processing (2)
  • src/string.c
  • test/t/string.rb

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


📝 Walkthrough

Walkthrough

UTF-8 Unicode case conversion now writes directly into a dynamically grown output buffer, handles expanded mappings and invalid sequences, tracks output metadata, and includes regression tests for repeated expanding uppercase mappings.

Changes

Unicode case conversion

Layer / File(s) Summary
Output capacity management
src/string.c
Adds geometric buffer growth with overflow checks for case-conversion output.
Direct conversion and regression coverage
src/string.c, test/t/string.rb
The conversion path writes directly to the output buffer, batches ASCII runs, validates UTF-8, tracks coderange, and tests expanded uppercase mappings.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 2b72a

The change updates UTF-8 case conversion to write directly into its destination buffer while preserving string results and error behavior; no actionable merge-blocking risk remains after normal checks and review.

Possibly related PRs

  • mruby/mruby#7180: Modifies Unicode case-conversion handling and coderange-preserving mutation logic.
  • mruby/mruby#7182: Introduces related changes to Unicode case-conversion buffering.
  • mruby/mruby#7191: Modifies mrb_str_case_convert_unicode() and its UTF-8 output handling.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 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: writing UTF-8 case-conversion output directly into the buffer instead of appending it.
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.
✨ 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.

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