Skip to content

mruby-regexp: take every search's capture buffer from the stack - #7289

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-stack-capture-buffer
Aug 19, 2026
Merged

mruby-regexp: take every search's capture buffer from the stack#7289
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-stack-capture-buffer

Conversation

@takumin

@takumin takumin commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

A search fills a capture buffer pat->num_captures * 2 ints wide, and that width has a
compile-time bound. num_captures starts at 1 for group 0 and the compiler refuses the group
that would take it past RE_MAX_CAPTURES (re_compile.c:1560), so no pattern reaches the
engine asking for more than RE_MAX_CAPTURES * 2 ints, 256 bytes.

Most of the code that holds such a buffer says exactly that:

holder buffer
regexp_s_gsub_str(), regexp_s_sub_str(), regexp_s_gsub_block() int captures[RE_MAX_CAPTURES * 2]
re_lit_matchdata(), regexp_s_gsub_lit(), regexp_s_sub_lit() int captures[2], all a literal core can fill
mrb_re_rexec() int last[RE_MAX_CAPTURES * 2] (re_exec.c:1233)
exec_match(), regexp_s_byte_rsearch(), regexp_s_scan() mrb_malloc(mrb, sizeof(int) * cap_size)

The last row is what this changes. Those three reached for the heap to hold a buffer of that
same fixed width, so the allocation stood for a variability the width does not have. They
declare it like the rest now.

regexp_s_scan() carried the split inside one function: the buffer it searched into was
allocated, while last_captures, the copy it keeps of the match to publish at the end, was
already the array on the stack beside it.

What the allocation carried

mrb_malloc() is more than the allocator here. A fresh allocation adds its size to
gc.malloc_increase and may drive an incremental step at that point (src/gc.c:309-333), so
every search paced the collector by its pattern's capture count, for a block the collector
never sees. Under MRB_GC_STRESS with MRB_DEBUG each allocation runs a full collection
first (src/gc.c:275-279), which is where the full-debug figures below come from.

The two functions that answer with a MatchData also had two exits each to free the buffer
on. Both are plain returns now, so a return added later has nothing to remember, and a raise
from inside create_matchdata() cannot strand the block.

Behaviour

Unchanged. exec_match() and regexp_s_scan() clear the same cap_size entries they cleared
before, and regexp_s_byte_rsearch() still clears none, because mrb_re_rexec() fills the
buffer before anything reads it (re_exec.c:1240, :1249, :1264).

The three frames grow by 256 bytes, and regexp_s_scan() reaches 512 with the array it
already had. None of the three recurses, and the engine they call recurses to
MRB_REGEXP_RECURSION_LIMIT (1,000) inside bt_match(), so what a regexp search costs in
stack is unchanged in the term that decides it.

Speed

Not the point of the change, but it is where the collector shows. Instruction counts from
callgrind, Ir(2N) - Ir(N) so process startup cancels, per call. The subject is a 880 byte
ASCII string; match is /q(u)ick/, rindex is /l(a)zy/, scan is /(\w+)o(\w*)/ with
60 matches.

build call master this PR delta
bintest Regexp#match 11,994 11,779 -215 (-1.79%)
bintest String#rindex 30,418 30,130 -288 (-0.95%)
bintest String#scan 874,852 875,492 +641 (+0.07%)
full-debug Regexp#match 5,444,163 5,056,713 -387,450 (-7.12%)
full-debug String#rindex 15,588,751 15,200,238 -388,513 (-2.49%)
full-debug String#scan 155,040,329 154,598,170 -442,159 (-0.29%)

The three full-debug rows land on the same figure per call, near 390,000 instructions, which
is the full collection each search no longer draws under MRB_GC_STRESS. scan gains the
same amount but spends it against a call that is 155M instructions wide, since it allocated
once per call rather than once per match.

scan costs 641 instructions more in the optimised builds, reproducibly and in every
configuration measured. It is codegen around the loop rather than anything the change asks
for, and I did not chase it further at 0.07%.

Size

.text of bin/mruby, build_config/ci/gcc-clang.rb, each side built from a clean build
directory at the same path. regexp.o is the only object that changes.

build master this PR delta
bintest 1,286,742 1,286,662 -80
ascii-ctype 1,273,590 1,273,510 -80
byte-string 1,255,174 1,255,094 -80
cxx_abi 1,311,641 1,311,577 -64
full-debug (-O0) 1,890,006 1,889,942 -64

regexp.o itself: 28,917 → 28,837 in bintest and ascii-ctype, 28,437 → 28,357 in
byte-string, 29,013 → 28,949 in cxx_abi, and 30,710 → 30,657 at -O0, where the binary
moves 11 bytes more than the object by the padding the linker drops.

Verification

No test accompanies the change: the three functions answer with what they answered with
before, and the existing suites already drive all three (Regexp#match and #=~,
String#index, #rindex and #rpartition, String#scan).

rake test, build_config/ci/gcc-clang.rb, no compiler warning in any of the ten builds
this table and the size table above were taken from:

build total KO crash
full-debug 2,403 0 0
bintest 2,403 0 0
bintest (bintest suite) 123 0 0
cxx_abi 2,403 0 0
byte-string 2,329 0 0
ascii-ctype 2,396 0 0

The default configuration: 2,174 total, 0 KO, 0 crash, and 112 bintests.

Environment

Details
OS Ubuntu 24.04.4, Linux 7.0.0 x86_64, AMD Ryzen 9 5950X
gcc 13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04.1)
binutils 2.47
valgrind 3.27.1, for the instruction counts

Compile lines for mrbgems/mruby-regexp/src/regexp.c in the builds quoted above, paths
shortened:

# ci/gcc-clang full-debug
gcc -MMD -c -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -g3 -O0 -DMRB_GC_STRESS -DMRB_USE_DEBUG_HOOK -DMRBGEM_MRUBY_REGEXP_VERSION=0.0.0 -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 -I"include" -I"mrbgems/mruby-regexp/include" -I"build/full-debug/include" -o "build/full-debug/mrbgems/mruby-regexp/src/regexp.o" "mrbgems/mruby-regexp/src/regexp.c"

# ci/gcc-clang bintest
gcc -MMD -c -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_GC_FIXED_ARENA -DMRBGEM_MRUBY_REGEXP_VERSION=0.0.0 -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 -I"include" -I"mrbgems/mruby-regexp/include" -I"build/bintest/include" -o "build/bintest/mrbgems/mruby-regexp/src/regexp.o" "mrbgems/mruby-regexp/src/regexp.c"

# ci/gcc-clang cxx_abi
gcc -MMD -c -g -O3 -Wall -Wundef -Wwrite-strings -x c++ -std=gnu++03 -DMRB_GC_FIXED_ARENA -DMRBGEM_MRUBY_REGEXP_VERSION=0.0.0 -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 -I"include" -I"mrbgems/mruby-regexp/include" -I"build/cxx_abi/include" -o "build/cxx_abi/mrbgems/mruby-regexp/src/regexp.o" "mrbgems/mruby-regexp/src/regexp.c"

# ci/gcc-clang byte-string
gcc -MMD -c -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRBGEM_MRUBY_REGEXP_VERSION=0.0.0 -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER -I"include" -I"mrbgems/mruby-regexp/include" -I"build/byte-string/include" -o "build/byte-string/mrbgems/mruby-regexp/src/regexp.o" "mrbgems/mruby-regexp/src/regexp.c"

# ci/gcc-clang ascii-ctype
gcc -MMD -c -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_USE_ASCII_CTYPE -DMRBGEM_MRUBY_REGEXP_VERSION=0.0.0 -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 -I"include" -I"mrbgems/mruby-regexp/include" -I"build/ascii-ctype/include" -o "build/ascii-ctype/mrbgems/mruby-regexp/src/regexp.o" "mrbgems/mruby-regexp/src/regexp.c"

Summary by CodeRabbit

  • Performance
    • Improved regular expression matching and scanning by reducing temporary memory allocations.
  • Reliability
    • Preserved existing match creation and failure handling behavior.

The buffer a search fills is `pat->num_captures * 2` ints wide, and that width
has a compile-time bound. `num_captures` starts at 1 for group 0 and the
compiler refuses the group that would take it past `RE_MAX_CAPTURES`
(`re_compile.c:1560`), so no pattern reaches the engine asking for more than
`RE_MAX_CAPTURES * 2` ints, 256 bytes.

Most of the places that hold such a buffer say so. `regexp_s_gsub_str()`,
`regexp_s_sub_str()` and `regexp_s_gsub_block()` declare the full width on the
stack, the three literal cores declare the `int captures[2]` their pattern can
fill, and `mrb_re_rexec()` keeps its `last[]` there as well.

`exec_match()`, `regexp_s_byte_rsearch()` and `regexp_s_scan()` went to the
heap for a buffer of that same fixed width, so the allocation stood for a
variability the width does not have. They declare it like the rest now.

Two things follow from dropping the allocation. The buffer no longer adds to
`gc.malloc_increase`, so a search no longer paces the collector by its
pattern's capture count, and under `MRB_GC_STRESS` it no longer draws a
`mrb_full_gc()` of its own. And the early returns lose their `mrb_free()`, so
neither a return added later nor a raise from inside `create_matchdata()` can
leave the block behind.
@takumin
takumin requested a review from matz as a code owner August 19, 2026 17:20
@coderabbitai

coderabbitai Bot commented Aug 19, 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: ba1d0e2a-3f8c-483b-880e-360bac626e24

📥 Commits

Reviewing files that changed from the base of the PR and between 524586c and a8c0582.

📒 Files selected for processing (1)
  • mrbgems/mruby-regexp/src/regexp.c

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


📝 Walkthrough

Walkthrough

Regexp matching, reverse search, and scan now use fixed-size stack capture buffers. Heap allocation and cleanup for these buffers were removed.

Changes

Regexp capture buffers

Layer / File(s) Summary
Replace heap capture buffers
mrbgems/mruby-regexp/src/regexp.c
exec_match, reverse search, and scan use fixed-size stack capture buffers. Matching behavior, failure handling, and MatchData creation remain unchanged.

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

Merge Risk: ⚪ Minimal · up to a8c05

This localized change moves fixed-size regexp capture buffers from heap allocation to stack storage without changing behavior; no actionable merge-blocking risk remains after normal checks and review.

Possibly related PRs

  • mruby/mruby#7281: Both changes update regexp capture handling in mrbgems/mruby-regexp/src/regexp.c.

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 summarizes the main change: moving capture buffers for every search from the heap to the stack.
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.

@matz
matz merged commit 1a6a675 into mruby:master Aug 19, 2026
21 checks passed
matz added a commit to takumin/mruby that referenced this pull request Aug 20, 2026
mruby#7289 took every search's capture buffer from the stack, so the buffer
re_check_over_limit() freed before raising no longer comes from the heap.
The helper takes the limit alone now and frees nothing. Without this the
merged tree compiles without a warning and aborts with "double free or
corruption" the first time a search reaches a limit.

Co-authored-by: Claude <noreply@anthropic.com>
@takumin
takumin deleted the regexp-stack-capture-buffer branch August 20, 2026 01:12
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