Skip to content

vm.c: restore the GC arena where the Integer boxing macro allocates - #7042

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:vm-arena-int-boxing
Aug 9, 2026
Merged

vm.c: restore the GC arena where the Integer boxing macro allocates#7042
matz merged 1 commit into
mruby:masterfrom
takumin:vm-arena-int-boxing

Conversation

@takumin

@takumin takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Follow-up to 65bc860, which restored the GC arena in the five inline opcode
branches that allocate by calling an allocating C function. This covers the
sites of the other shape, where the allocation is inside the boxing macro and
there is no call to bracket.

The cause

An inline opcode answers from C without going through a method call, so nothing
runs the cfunc epilogue that shrinks the arena (src/vm.c:2263 and :2971).
Whatever such an opcode allocates stays pinned until the enclosing method
returns or some other call shrinks the arena back to the baseline
mrb_vm_exec() saved on entry. A send anywhere in the loop body clears it, and
so does any inline opcode that restores on its own account, OP_STRING,
OP_ARRAY and OP_HASH among them, which is why this goes unnoticed: the body
has to be built entirely from opcodes that do not restore for it to show.

Under word boxing an mrb_int outside the fixnum range is a heap object, so
SET_INT_VALUE() allocates one. That is not a configuration to worry about at
the margin: include/mrbconf.h selects word boxing whenever none of the three
boxing modes is defined, so it is what an ordinary build gets. The fixnum range
ends at 2**62-1 there, well below the mrb_int overflow that reaches
OP_MATH_OVERFLOW_INT, so the ordinary integer path allocates long before the
BigInt path is considered and does so on builds without MRB_USE_BIGINT too.

SET_INT_VALUE() resolves to mrb_boxing_int_value(), which is compiled under
MRB_WORD_BOXING and under MRB_NAN_BOXING with MRB_INT64. The condition in
the fix is that disjunction rather than #ifdef MRB_WORD_BOXING because what it
keys on is whether the macro can allocate, not which boxing mode is in use.

The sites

  • the non-overflow integer arm of the OP_MATH family, five occurrences of
    SET_INT_VALUE(mrb,regs[a], z). Three are live, reached from OP_ADD,
    OP_SUB, OP_MUL, OP_ADDI, OP_SUBI and the ILV forms; the two in
    OP_MATH_CASE_INTEGER and OP_MATHI_CASE_INTEGER sit in macros that are
    defined and never expanded. Converting all five keeps the family consistent
  • vm_op_div(), whose mrb_div_int_value() call is a separate route with no
    ai in scope at all
  • OP_LOADL, which boxes the pool entry afresh on every execution rather than
    handing back a stored object. The pool holds an integer as a raw i32 or
    i64 and a big integer as its digits; there is no mrb_value in it
  • OP_LOADI32, which boxes the immediate the same way. It can only allocate on
    a 32-bit host under word boxing, where the fixnum range ends at 2**30

Measurements

Growth in GC.stat[:live] over a loop of n = 20000, on a default
build_config/default.rb build, with the change applied and then reverted:

loop before after
x = 4611686018427387903; x + 1 20000 527
x = 4611686018427387903; x - -1 20000 527
x = 9223372036854775806; x / 1 20001 529
z = 9223372036854775806 (OP_LOADL, IREP_TT_INT64) 20000 546
z = 340282366920938463463374607431768211456 (OP_LOADL, IREP_TT_BIGINT) 20000 546
x = 4611686018427387903; x + 1; x.class 527 527
a = [1,2,3]; a[1] 2 2
f = 1.5; f + f 1 1

The same on an i686 build, where mrb_int is 32 bits wide and the fixnum range
ends at 2**30, which is the only configuration in which OP_LOADI32 can
allocate:

loop before after
z = 1073741824 (OP_LOADI32) 20001 547
x = 1 << 30; x + 1 20001 528
x = 1 << 30; x / 1 20001 528

The last three rows are the same binary and are what make the first five a
finding rather than a retest: adding a send to the loop body takes the growth
down to the same residue, which is the signature of arena retention rather than
of ordinary garbage.

The curve is quadratic, time roughly quadrupling per doubling:

n x + 1 after x / 1 after z = <int literal> after z = <bigint literal> after
200000 127 ms 4 ms 124 ms 4 ms 123 ms 4 ms 150 ms 33 ms
400000 492 ms 9 ms 491 ms 10 ms 492 ms 8 ms 550 ms 66 ms
800000 2144 ms 18 ms 2086 ms 19 ms 2099 ms 15 ms 2602 ms 131 ms

The BigInt column stays heavier because the object is genuinely allocated on
every iteration there. What the change removes is the quadratic mark cost, not
the allocation. The timings are one run on one machine; the shape is the point.

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. There the same
loops raise after 96 or 97 iterations, MRB_GC_ARENA_SIZE less what the setup
already holds:

$ ./build/fixedarena/bin/mruby -e 'x=4611686018427387903; i=0; while i < 100000; x+1; i+=1; end; puts "ok"'
trace (most recent call last):
-e:1: arena overflow error (NoMemoryError)
$ ./build/fixedarena/bin/mruby -e 'a=[1,2,3]; i=0; while i < 100000; a[1]; i+=1; end; puts "ok"'
ok

The last line is the same binary. That makes this a functional bug in a
supported configuration and not only a performance one. All four such loops
print ok with the change applied.

Why the restore is conditional on having allocated

VM_SET_INT_VALUE() skips the restore unless the store produced a heap object.
The obvious reason is that the integer arm of OP_MATH is the hottest path in
the VM and the fixnum case allocates nothing.

The other reason is that an unconditional restore quietly breaks the assertions
65bc860 added. i += 1 compiles to OP_ADDILV, so restoring there empties
the arena on every iteration of every while loop written that way. Measured on
a tree carrying this change with 65bc860bf's src/vm.c hunks reverted, so the
code those assertions guard is broken again:

assertion body from 65bc860 unconditional restore conditional restore
s[1] 529 20001
h[1], h with a default proc 204 20514
h[0], h with a default proc 204 20514
h[k] = 1 678 20002
x + x, x = 1 << 62 529 20001

Their threshold is 5000, so with an unconditional restore all five pass on a
tree where the code they cover is broken, and with the conditional one all five
fail, which is what they are supposed to do. The same trap applies to the new
assertions here: with an unconditional restore the OP_DIV loop reads 528 on a
tree whose vm_op_div() restore has been removed, against 20001 with the
conditional one.

The alternative worth weighing, if a conditional on that path is unwelcome, is
to put the restore inside SET_INT_VALUE() under word boxing instead. That
takes it off the opcode and onto every caller of the macro, including C code
that legitimately holds unrooted objects across the call, so it is offered as
the question rather than as the proposal.

Tests

Four assertions, all reading GC.stat[:live], which is a cfunc and so reports
the count before its own epilogue restores. The two arithmetic ones use
1 << shift with a variable shift for shift in 30, 31 and 62, straddling the
fixnum boundary of every mode that can allocate; a constant shift is folded at
compile time and a folded result out of mrb_int range makes the build fail
rather than raise. The OP_LOADI32 one uses 2**30, which fits in the operand
of that opcode rather than going to the pool and is representable on every
build, so it needs no guard; it is the assertion that only bites on a 32-bit
host.

The arithmetic loops do not include x += 1, which would be the third
expansion of the family, because on a build where x is a big integer that
compiles to an OP_ADDILV whose send fallback corrupts the frame. That is an
unrelated bug in the opcode and is being reported separately.

The OP_LOADL assertion is with mruby-bigint's tests rather than in
test/t/gc.rb, because the literal that reaches the opcode is wider than 32
bits and there is no portable way to write one: without the gem it is out of
mrb_int range on an MRB_INT32 build, and an unrepresentable literal is a
compile-time error rather than something a test can rescue, so test/t/gc.rb
would fail to load whole and every assertion in it would be silently lost. With
the gem the literal reaches IREP_TT_INT64 where mrb_int is 64 bits wide and
IREP_TT_BIGINT where it is not.

Without the change, a default mrbtest reports 3 KO and a MRB_GC_FIXED_ARENA
one reports 3 Crash on NoMemoryError; an i686 mrbtest reports 3 KO, the
OP_LOADI32 assertion among them. With the change all three report 0 KO and 0
Crash. Also run green: MRB_GC_STRESS with MRB_USE_DEBUG_HOOK (full-debug
from build_config/ci/gcc-clang.rb, where the new assertions add about 0.9 s),
and an MRB_INT32 build without mruby-bigint.

Summary by CodeRabbit

  • Bug Fixes

    • Reduced unnecessary memory retention when processing large integer values.
    • Improved garbage collection behavior for integer arithmetic, division, and constant loading.
    • Preserved correct results while preventing excessive growth of retained objects.
  • Tests

    • Added coverage for repeated large-integer operations and loads.
    • Added validation across multiple integer sizes and arithmetic operations.

@takumin
takumin requested a review from matz as a code owner August 9, 2026 13:04
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The VM restores the GC arena after creating boxed integers in division, constant-loading, and arithmetic paths. Regression tests cover repeated large-integer loads and arithmetic operations across supported integer widths.

Changes

Boxed Integer GC Handling

Layer / File(s) Summary
Arena-aware integer assignment
src/vm.c
Adds VM_SET_INT_VALUE and applies arena restoration to boxed integer results, including integer division.
Arena-aware constant loading
src/vm.c, mrbgems/mruby-bigint/test/bigint.rb
Updates integer and big integer constant-loading paths. Adds repeated large-literal loading coverage.
Arena-aware arithmetic and regression coverage
src/vm.c, test/t/gc.rb
Updates arithmetic opcode paths and tests repeated boxed results from addition, division, and OP_LOADI32 across supported widths.

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

Possibly related PRs

  • mruby/mruby#7022: Updates similar VM boxed-integer arena handling and GC-retention tests.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR addresses VM GC arena retention, but linked issue #39 requires fixing files omitted by make clean. Implement the make clean cleanup required by issue #39, or link the PR to an issue that specifies the VM GC arena fix.
Out of Scope Changes check ⚠️ Warning The VM boxing changes and GC tests are unrelated to issue #39, which concerns make clean file removal. Split the VM GC arena changes into a separate PR, or update the linked issue to match the actual scope.
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main VM change: restoring the GC arena after Integer boxing allocates a heap object.
✨ 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.

🧹 Nitpick comments (1)
test/t/gc.rb (1)

214-233: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add direct OP_LOADI32 arena coverage.

The new loops execute arithmetic opcodes. The large-literal test executes OP_LOADL. Neither test loads a signed 32-bit literal through the changed OP_LOADI32 path in src/vm.c Line 2472.

Add a repeated assignment such as z = 1073741824. This value exercises boxed OP_LOADI32 on applicable word-boxing builds. Other configurations can pass without allocation.

Proposed regression test
+assert('OP_LOADI32 does not retain a boxed Integer in the GC arena') do
+  GC.start
+  base = GC.stat[:live]
+  i = 0
+  while i < 20000
+    z = 1073741824
+    i += 1
+  end
+  assert_operator GC.stat[:live] - base, :<, 5000
+  assert_equal 1073741824, z
+end
🤖 Prompt for 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.

In `@test/t/gc.rb` around lines 214 - 233, Extend the GC arena test around the
loop in the OP_ADD assertion to repeatedly assign a signed 32-bit literal such
as 1073741824, directly exercising the OP_LOADI32 path. Keep the assignment
inside the existing repetition and preserve behavior on configurations where the
literal is immediate or unboxed.
🤖 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.

Nitpick comments:
In `@test/t/gc.rb`:
- Around line 214-233: Extend the GC arena test around the loop in the OP_ADD
assertion to repeatedly assign a signed 32-bit literal such as 1073741824,
directly exercising the OP_LOADI32 path. Keep the assignment inside the existing
repetition and preserve behavior on configurations where the literal is
immediate or unboxed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a88028d9-f745-4f2b-83b8-1e8710974d8d

📥 Commits

Reviewing files that changed from the base of the PR and between 9a3d566 and fad7d8d.

📒 Files selected for processing (3)
  • mrbgems/mruby-bigint/test/bigint.rb
  • src/vm.c
  • test/t/gc.rb

`SET_INT_VALUE()` heap-allocates an RInteger for an `mrb_int` outside
the fixnum range, and the inline opcodes that box one have no cfunc
epilogue behind them to shrink the arena, so what they allocate stays
pinned until the enclosing method returns. This is the cause 65bc860
was about, but the sites are a different shape: nothing is called that
could be bracketed, because the allocation is in the macro itself.

The non-overflow integer arm of `OP_MATH`, `OP_MATHI` and `OP_MATHILV`
boxes its result, `OP_LOADL` and `OP_LOADI32` box a literal afresh
on every execution rather than handing back a stored object, and
`vm_op_div()` boxes through `mrb_div_int_value()`. A loop built only
from opcodes that do not restore pins one object per iteration and
the mark phase walks all of them, so the loop is quadratic. With
`x = 4611686018427387903`, one below the fixnum maximum of a 64-bit
word boxing build, `while i < n; x + 1; i += 1; end` takes 127 ms at
`n` = 200000 and 2144 ms at 800000, against 4 ms and 18 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. `include/mrbconf.h` selects word boxing when
no boxing mode is defined, so this is what an ordinary build gets.

`VM_SET_INT_VALUE()` carries the restore and is conditional twice
over. It expands to the bare store where the macro cannot allocate,
which is every boxing mode other than word boxing and NaN boxing
with `MRB_INT64`, the two that `mrb_boxing_int_value()` is compiled
for. Where it can, the restore is still skipped unless the store
produced a heap object, which keeps it off the fixnum fast path the
arithmetic opcodes are on.

That second condition is also what keeps the assertions 65bc860
added meaningful. `i += 1` compiles to `OP_ADDILV`, so an
unconditional restore would empty the arena on every iteration
of every `while` loop written that way, including the five those
assertions measure, and all five then pass on a tree where the code
they cover is broken.

The BigInt arm of `OP_LOADL` allocates whatever the boxing mode is,
so its restore is unconditional and is the shape 65bc860 was about
rather than the shape the macro is about. It is the last site of
that shape left, and folding it in here beats splitting one `switch`.

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 two occurrences that store into
`regs[a+1]` in `OP_MATHI` and `OP_MATHILV` are left alone: they hold
the immediate operand of `OP_ADDI` and `OP_SUBI`, which is 16 bits at
most and inside the fixnum range of every configuration. `OP_LOADI32`
can only allocate on a 32-bit host under word boxing, where the fixnum
range ends at `2**30`; measured there with an i686 build,
`while i < n; z = 1073741824; i += 1; end` grows `live` by 20000 at
`n` = 20000 and by 547 with the restore.

The `OP_LOADL` assertion lives with mruby-bigint's tests rather than
with the others in `test/t/gc.rb` because the literal that reaches the
opcode is wider than 32 bits. Without the gem such a literal is out
of `mrb_int` range on an `MRB_INT32` build, and an unrepresentable
literal is a compile-time error rather than something a test can
rescue: the file then fails to load and every assertion in it is
silently lost. With the gem the same literal reaches `IREP_TT_INT64`
where `mrb_int` is 64 bits wide and `IREP_TT_BIGINT` where it is not,
which are the two branches that allocate.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants