mruby-regexp: take every search's capture buffer from the stack - #7289
Conversation
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review. 📝 WalkthroughWalkthroughRegexp matching, reverse search, and scan now use fixed-size stack capture buffers. Heap allocation and cleanup for these buffers were removed. ChangesRegexp capture buffers
Estimated code review effort: 2 (Simple) | ~10 minutes Merge Risk: ⚪ Minimal · up to 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
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
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>
A search fills a capture buffer
pat->num_captures * 2ints wide, and that width has acompile-time bound.
num_capturesstarts at 1 for group 0 and the compiler refuses the groupthat would take it past
RE_MAX_CAPTURES(re_compile.c:1560), so no pattern reaches theengine asking for more than
RE_MAX_CAPTURES * 2ints, 256 bytes.Most of the code that holds such a buffer says exactly that:
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 fillmrb_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 wasallocated, while
last_captures, the copy it keeps of the match to publish at the end, wasalready 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 togc.malloc_increaseand may drive an incremental step at that point (src/gc.c:309-333), soevery search paced the collector by its pattern's capture count, for a block the collector
never sees. Under
MRB_GC_STRESSwithMRB_DEBUGeach allocation runs a full collectionfirst (
src/gc.c:275-279), which is where thefull-debugfigures below come from.The two functions that answer with a
MatchDataalso had two exits each to free the bufferon. 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()andregexp_s_scan()clear the samecap_sizeentries they clearedbefore, and
regexp_s_byte_rsearch()still clears none, becausemrb_re_rexec()fills thebuffer 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 italready had. None of the three recurses, and the engine they call recurses to
MRB_REGEXP_RECURSION_LIMIT(1,000) insidebt_match(), so what a regexp search costs instack 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 byteASCII string;
matchis/q(u)ick/,rindexis/l(a)zy/,scanis/(\w+)o(\w*)/with60 matches.
bintestRegexp#matchbintestString#rindexbintestString#scanfull-debugRegexp#matchfull-debugString#rindexfull-debugString#scanThe three
full-debugrows land on the same figure per call, near 390,000 instructions, whichis the full collection each search no longer draws under
MRB_GC_STRESS.scangains thesame amount but spends it against a call that is 155M instructions wide, since it allocated
once per call rather than once per match.
scancosts 641 instructions more in the optimised builds, reproducibly and in everyconfiguration 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
.textofbin/mruby,build_config/ci/gcc-clang.rb, each side built from a clean builddirectory at the same path.
regexp.ois the only object that changes.bintestascii-ctypebyte-stringcxx_abifull-debug(-O0)regexp.oitself: 28,917 → 28,837 inbintestandascii-ctype, 28,437 → 28,357 inbyte-string, 29,013 → 28,949 incxx_abi, and 30,710 → 30,657 at-O0, where the binarymoves 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#matchand#=~,String#index,#rindexand#rpartition,String#scan).rake test,build_config/ci/gcc-clang.rb, no compiler warning in any of the ten buildsthis table and the size table above were taken from:
full-debugbintestbintest(bintest suite)cxx_abibyte-stringascii-ctypeThe default configuration: 2,174 total, 0 KO, 0 crash, and 112 bintests.
Environment
Details
Compile lines for
mrbgems/mruby-regexp/src/regexp.cin the builds quoted above, pathsshortened:
Summary by CodeRabbit