Skip to content

vm.c: restore the GC arena in the allocating inline opcodes - #7022

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:vm-inline-opcode-arena-restore
Aug 9, 2026
Merged

vm.c: restore the GC arena in the allocating inline opcodes#7022
matz merged 1 commit into
mruby:masterfrom
takumin:vm-inline-opcode-arena-restore

Conversation

@takumin

@takumin takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

The bug

OP_GETIDX, OP_GETIDX0, OP_SETIDX and OP_MATH answer from C without going through a method call, so the arena shrink that every cfunc return performs never runs for them. Five of their branches allocate and leave the result in the GC arena, where it stays until the enclosing method returns.

branch what it leaves in the arena
OP_GETIDX, String the substring mrb_str_aref() allocates
OP_GETIDX, Hash what a default proc returns
OP_GETIDX0, Hash the same
OP_SETIDX, Hash the frozen dup mrb_hash_set() makes of a String key
OP_MATH_OVERFLOW_INT the big integer an mrb_int overflow promotes to

The Array branches return an element that already exists or write through an existing buffer, so they allocate nothing and are left alone. A Hash with a default value rather than a default proc allocates nothing either, and an Integer key needs no dup, which is why the plain shapes look clean.

What it costs

A loop whose body is built only from opcodes that do not restore pins one object per iteration, and the mark phase then walks all of them, so the loop is quadratic. GC.stat[:live] shows the retention directly, at n = 20000:

loop body before after
s = "hello"; s[1] 20003 531
h = Hash.new { Object.new }; h[1] 20515 205
h = Hash.new { Object.new }; h[0] 20515 205
h = {}; k = "a"; h[k] = 1 20004 688
h = {1=>2}; h[1] 4 4
a = [0]; a[0] = 1 4 4

The signal is O(n) and the residue is O(1). In time, with while i < n; s[1]; i += 1; end:

n s[1] before s[1] after a[1], unaffected
200000 147 ms 5 ms 3 ms
400000 562 ms 11 ms 6 ms
800000 2437 ms 22 ms 13 ms

and with x = 4611686018427387904; while i < n; x + x; i += 1; end:

n before after
400000 562 ms 66 ms
800000 2336 ms 130 ms
1600000 10758 ms 253 ms

Under MRB_GC_FIXED_ARENA they are a hard error

build_config/ci/gcc-clang.rb and build_config/ci/msvc.rb define MRB_GC_FIXED_ARENA, which caps the arena instead of growing it. In such a build none of the five loops is merely slow:

$ ./build/fixedarena/bin/mruby -e 's="hello"; i=0; while i < 100000; s[1]; i+=1; end'
-e:1: arena overflow error (NoMemoryError)
$ ./build/fixedarena/bin/mruby -e 'x=4611686018427387904; i=0; while i < 100000; x+x; i+=1; end'
-e:1: arena overflow error (NoMemoryError)
$ ./build/fixedarena/bin/mruby -e 'h=Hash.new { Object.new }; i=0; while i < 100000; h[1]; i+=1; end'
-e:1:in allocate: NoMemoryError
$ ./build/fixedarena/bin/mruby -e 'h=Hash.new { Object.new }; i=0; while i < 100000; h[0]; i+=1; end'
-e:1:in allocate: NoMemoryError
$ ./build/fixedarena/bin/mruby -e 'h={}; k="a"; i=0; while i < 100000; h[k]=1; i+=1; end'
-e:1: arena overflow error (NoMemoryError)

All five raise after roughly 95 iterations, which is MRB_GC_ARENA_SIZE less what the setup already holds. The two default-proc loops report from allocate rather than with the arena overflow error message, since the arena is full when the proc allocates rather than when the opcode stores. All five print ok with the restores in place.

Why this has gone unnoticed

The arena is restored to the baseline mrb_vm_exec() saves on entry, and several inline opcodes already restore to it after allocating: OP_STRING, OP_ARRAY, OP_ARRAY2, OP_ARYCAT, OP_ARYSPLAT, OP_HASH, and the String branch of OP_MATH a few lines above the macro this changes. So anything in the loop body that either sends or is one of those opcodes empties what the leaking opcode accumulated, and the loop is linear again, at n = 20000:

loop body growth in live
s[1] 20003
s[1]; s.bytesize (a send) 531
s[1]; x = "" (OP_STRING) 89

A loop that indexes a string almost always does something with the result, and doing almost anything with it is a send. What is left is narrow but real, and the memory consequence needs no pathological loop: until the enclosing method returns, every substring is pinned, so a long loop holds the whole run of them live at once even though the program keeps none.

The fix

Restore the arena around each allocating branch, exactly as the epilogues and the neighbouring opcodes do. The results need no mrb_gc_protect(): they are stored into regs[], and the VM stack is a GC root, which is why the cfunc epilogues can shrink unconditionally too.

The tests

Five assertions at the end of test/t/gc.rb, which already reads GC.stat[:live]. GC.stat is a cfunc, so it runs before the epilogue that would shrink the arena and still sees what the loop pinned. The threshold is n / 4, which leaves a factor of 4 below the signal and about 5 above the largest residue measured; a tighter one is a trap, since GC.interval_ratio = 1000 alone takes the residue to 996.

The OP_ADD assertion is guarded rather than tested for. Without mruby-bigint the overflow raises RangeError instead of promoting, so the guard skips; the shift count is held in a variable because a constant shift is folded at compile time, and a folded result out of mrb_int range makes the build fail rather than raise. It then runs over both 1 << 30 and 1 << 62, so that whichever width the build has, one of the two takes the overflow branch.

Checked configurations

All at mruby/mruby@fd6a18284, with the change applied and reverted.

build with the change without
host (MRB_INT64, bigint) 1929 OK, 0 KO, 0 Crash 5 KO
MRB_INT32, bigint 1834 OK, 0 KO, 0 Crash 5 KO
MRB_GC_FIXED_ARENA 1929 OK, 0 KO, 0 Crash
MRB_INT64, no bigint 1756 OK, 0 KO, 0 Crash, OP_ADD skipped
MRB_INT32, no bigint 1678 OK, 0 KO, 0 Crash, OP_ADD skipped

The MRB_INT32 rows are what the 1 << 30 case is for: the OP_ADD assertion fails there without the change, so it covers the overflow branch on that width too rather than passing vacuously.

Summary by CodeRabbit

  • Bug Fixes

    • Improved memory management during optimized indexing, hash operations, string access, and large-integer arithmetic.
    • Reduced unnecessary retention of temporary objects during repeated operations.
  • Tests

    • Added regression coverage for memory growth across indexing, hash defaults, key assignment, and overflowing integer calculations.
    • Added compatibility handling for environments without large-integer support.

@takumin
takumin requested a review from matz as a code owner August 9, 2026 07:04
@github-actions github-actions Bot added the core label Aug 9, 2026
@coderabbitai

coderabbitai Bot commented Aug 9, 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: a2cb2744-19b0-4d80-b9d3-699f264a076e

📥 Commits

Reviewing files that changed from the base of the PR and between b6844ce and 65bc860.

📒 Files selected for processing (1)
  • src/vm.c
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/vm.c

📝 Walkthrough

Walkthrough

The VM now restores the GC arena around optimized hash and string indexing, hash assignment, zero-index hash reads, and overflowing integer operations. GC tests verify bounded live-object growth for these operations.

Changes

GC arena restoration

Layer / File(s) Summary
Optimized indexing and hash arena handling
src/vm.c, test/t/gc.rb
Optimized hash lookup, string indexing, zero-index hash lookup, and hash assignment now save and restore the GC arena. Regression tests cover repeated OP_GETIDX, OP_GETIDX0, and OP_SETIDX operations.
Overflow arithmetic arena handling
src/vm.c, test/t/gc.rb
Overflowing integer arithmetic restores the GC arena after bigint creation. Tests cover repeated OP_ADD operations and skip unsupported bigint behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • mruby/mruby#7023: Both changes modify optimized hash indexing in src/vm.c and handle GC arena state during hash reads.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The VM and GC test changes address GC arena retention, while issue #39 concerns make clean file removal. Link the correct GC arena issue and remove or justify the unrelated make clean issue reference.
Linked Issues check ❓ Inconclusive Issue #39 concerns removing files during make clean and provides no requirements for GC arena restoration. Link the PR to an issue that defines the GC arena restoration requirements, or update the linked issue description.
✅ Passed checks (3 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: restoring the GC arena in allocating VM opcodes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/vm.c`:
- Around line 2133-2135: Update the Hash result path around mrb_hash_get to
store its return value in a local variable, refresh ci after the call, then
assign that local to regs[a]. Match the ordering used by vm_op_getidx so regs is
accessed only after VM stack storage may have been relocated.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 55523eba-2609-466e-ae57-46f0f8a0ff5e

📥 Commits

Reviewing files that changed from the base of the PR and between 0b4e3f1 and b6844ce.

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

Comment thread src/vm.c Outdated
@matz

matz commented Aug 9, 2026

Copy link
Copy Markdown
Member

This needs a rebase: #7023 landed first as 4461d87 and it touches the same lines.

I merged that one first because it is a use-after-free rather than a cost, not because this one is any less right. I reproduced your numbers before merging anything:

                        arena residue over 20000 iterations
s = "hello"; s[1]                20002
h = Hash.new { Object.new }; h[1] 20635
h = {}; k = "a"; h[k] = 1        20003
a = [0]; a[0] = 1                    3   (control)
h = {1=>2}; h[1]                     3   (control)

O(n) retention against O(1) for the shapes that allocate nothing, exactly as your table says, and the quadratic mark cost follows from it.

The only conflict is the Hash branch of vm_op_getidx0(). I resolved it locally to check the two fixes compose, and they do:

    {
      /* same as the Hash branch of vm_op_getidx(): mrb_hash_get() can run a
         default proc and move the stack, so take the result first and store
         it through the refreshed `regs`.  The arena is restored only after
         that store, which is what roots the result. */
      int ai = mrb_gc_arena_save(mrb);
      mrb_value val = mrb_hash_get(mrb, recv, mrb_fixnum_value(0));
      ci = mrb->c->ci;
      regs[a] = val;
      mrb_gc_arena_restore(mrb, ai);
    }

The ordering matters in one direction: mrb_gc_arena_restore() has to come after the store, since regs[a] is what roots the result. Restoring first would unprotect a freshly allocated value with nothing holding it. Nothing allocates between the call and the store, so the value is safe in the C local across the ci refresh.

With that resolution the whole suite passes, and the residue drops to 529 / 678 / 678 / 1158 for the four rows above.

Your other four branches do not conflict. Take the shape above if it suits you, or write it however you prefer.

`OP_GETIDX`, `OP_GETIDX0`, `OP_SETIDX` and `OP_MATH` answer from C
without going through a method call, so the arena shrink that every
cfunc return performs never runs for them. Five of their branches
allocate and leave the result in the arena, where it stays until the
enclosing method returns: `OP_GETIDX` on a String allocates the
substring, `OP_GETIDX` and `OP_GETIDX0` on a Hash run a default proc,
`OP_SETIDX` on a Hash duplicates and freezes a String key, and
`OP_MATH_OVERFLOW_INT` allocates a big integer.

A loop whose body is built only from opcodes that do not restore then
pins one object per iteration, and the mark phase walks all of them, so
the loop is quadratic. `while i < n; s[1]; i += 1; end` takes 147 ms at
`n` = 200000 and 2437 ms at 800000, against 5 ms and 22 ms with the
restore. Under `MRB_GC_FIXED_ARENA`, which `build_config/ci` defines,
the same loops are not slow but broken: they raise `NoMemoryError` once
the arena fills.

The neighbouring inline opcodes already restore, `OP_STRING`,
`OP_ARRAY`, `OP_ARYCAT`, `OP_HASH` and the String branch of `OP_MATH`
among them, so this follows the convention rather than adding one. The
results need no `mrb_gc_protect()`: they are stored into `regs[]`, and
the VM stack is a GC root, which is why the cfunc epilogues can shrink
unconditionally too. The Array branches return an element that already
exists or write through an existing buffer, allocate nothing, and are
left alone.

The tests read `GC.stat[:live]`, which is a cfunc and so reports the
count before its own epilogue restores. Their loop bodies avoid sends
for the same reason a real program rarely trips this: a single send in
the body empties the arena and hides the retention.
@takumin
takumin force-pushed the vm-inline-opcode-arena-restore branch from b6844ce to 65bc860 Compare August 9, 2026 11:09
@takumin

takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto 4461d87a1. Thank you for resolving it locally first and for reproducing the numbers.

I took your shape for the vm_op_getidx0() Hash branch verbatim, comment included, since the ordering constraint it records is the part a later reader is most likely to get wrong:

    {
      /* same as the Hash branch of vm_op_getidx(): mrb_hash_get() can run a
         default proc and move the stack, so take the result first and store
         it through the refreshed `regs`. The arena is restored only after
         that store, which is what roots the result. */
      int ai = mrb_gc_arena_save(mrb);
      mrb_value val = mrb_hash_get(mrb, recv, mrb_fixnum_value(0));
      ci = mrb->c->ci;
      regs[a] = val;
      mrb_gc_arena_restore(mrb, ai);
    }

The save sits before the call rather than after the ci refresh, so that whatever the default proc allocates on its way to the result is released as well, not only the result. The Hash branch of vm_op_getidx() already had the refresh and the restore in this order, so the two branches now read the same.

The other four branches applied unchanged, as you said they would.

Re-checked at 65bc860bf:

build result
host (MRB_INT64, bigint) 1935 OK, 0 KO, 0 Crash
MRB_GC_FIXED_ARENA 2128 OK, 0 KO, 0 Crash

and GC.stat[:live] after 20000 iterations, each loop in its own process:

loop body live
s = "hello"; s[1] 1130
h = Hash.new { Object.new }; h[1] 801
h = Hash.new { Object.new }; h[0] 801
h = {}; k = "a"; h[k] = 1 2280
a = [0]; a[0] = 1 850 (control)
h = {1=>2}; h[1] 850 (control)

All O(1) and in the same range as the controls, so the rebase did not cost the OP_GETIDX0 row anything.

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