vm.c: restore the GC arena where the Integer boxing macro allocates - #7042
Conversation
📝 WalkthroughWalkthroughThe 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. ChangesBoxed Integer GC Handling
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 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 |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
test/t/gc.rb (1)
214-233: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd direct
OP_LOADI32arena coverage.The new loops execute arithmetic opcodes. The large-literal test executes
OP_LOADL. Neither test loads a signed 32-bit literal through the changedOP_LOADI32path insrc/vm.cLine 2472.Add a repeated assignment such as
z = 1073741824. This value exercises boxedOP_LOADI32on 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
📒 Files selected for processing (3)
mrbgems/mruby-bigint/test/bigint.rbsrc/vm.ctest/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.
fad7d8d to
a91b78a
Compare
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:2263and: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, andso does any inline opcode that restores on its own account,
OP_STRING,OP_ARRAYandOP_HASHamong them, which is why this goes unnoticed: the bodyhas to be built entirely from opcodes that do not restore for it to show.
Under word boxing an
mrb_intoutside the fixnum range is a heap object, soSET_INT_VALUE()allocates one. That is not a configuration to worry about atthe margin:
include/mrbconf.hselects word boxing whenever none of the threeboxing modes is defined, so it is what an ordinary build gets. The fixnum range
ends at
2**62-1there, well below themrb_intoverflow that reachesOP_MATH_OVERFLOW_INT, so the ordinary integer path allocates long before theBigInt path is considered and does so on builds without
MRB_USE_BIGINTtoo.SET_INT_VALUE()resolves tomrb_boxing_int_value(), which is compiled underMRB_WORD_BOXINGand underMRB_NAN_BOXINGwithMRB_INT64. The condition inthe fix is that disjunction rather than
#ifdef MRB_WORD_BOXINGbecause what itkeys on is whether the macro can allocate, not which boxing mode is in use.
The sites
OP_MATHfamily, five occurrences ofSET_INT_VALUE(mrb,regs[a], z). Three are live, reached fromOP_ADD,OP_SUB,OP_MUL,OP_ADDI,OP_SUBIand theILVforms; the two inOP_MATH_CASE_INTEGERandOP_MATHI_CASE_INTEGERsit in macros that aredefined and never expanded. Converting all five keeps the family consistent
vm_op_div(), whosemrb_div_int_value()call is a separate route with noaiin scope at allOP_LOADL, which boxes the pool entry afresh on every execution rather thanhanding back a stored object. The pool holds an integer as a raw
i32ori64and a big integer as its digits; there is nomrb_valuein itOP_LOADI32, which boxes the immediate the same way. It can only allocate ona 32-bit host under word boxing, where the fixnum range ends at
2**30Measurements
Growth in
GC.stat[:live]over a loop ofn = 20000, on a defaultbuild_config/default.rbbuild, with the change applied and then reverted:x = 4611686018427387903; x + 1x = 4611686018427387903; x - -1x = 9223372036854775806; x / 1z = 9223372036854775806(OP_LOADL,IREP_TT_INT64)z = 340282366920938463463374607431768211456(OP_LOADL,IREP_TT_BIGINT)x = 4611686018427387903; x + 1; x.classa = [1,2,3]; a[1]f = 1.5; f + fThe same on an i686 build, where
mrb_intis 32 bits wide and the fixnum rangeends at
2**30, which is the only configuration in whichOP_LOADI32canallocate:
z = 1073741824(OP_LOADI32)x = 1 << 30; x + 1x = 1 << 30; x / 1The 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:
nx + 1x / 1z = <int literal>z = <bigint literal>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_ARENAthey are a hard errorbuild_config/ci/gcc-clang.rbandbuild_config/ci/msvc.rbdefineMRB_GC_FIXED_ARENA, which caps the arena instead of growing it. There the sameloops raise after 96 or 97 iterations,
MRB_GC_ARENA_SIZEless what the setupalready holds:
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
okwith 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_MATHis the hottest path inthe VM and the fixnum case allocates nothing.
The other reason is that an unconditional restore quietly breaks the assertions
65bc860 added.
i += 1compiles toOP_ADDILV, so restoring there emptiesthe arena on every iteration of every
whileloop written that way. Measured ona tree carrying this change with
65bc860bf'ssrc/vm.chunks reverted, so thecode those assertions guard is broken again:
s[1]h[1],hwith a default proch[0],hwith a default proch[k] = 1x + x,x = 1 << 62Their 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_DIVloop reads 528 on atree whose
vm_op_div()restore has been removed, against 20001 with theconditional 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. Thattakes 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 reportsthe count before its own epilogue restores. The two arithmetic ones use
1 << shiftwith a variable shift forshiftin 30, 31 and 62, straddling thefixnum boundary of every mode that can allocate; a constant shift is folded at
compile time and a folded result out of
mrb_intrange makes the build failrather than raise. The
OP_LOADI32one uses2**30, which fits in the operandof 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 thirdexpansion of the family, because on a build where
xis a big integer thatcompiles to an
OP_ADDILVwhose send fallback corrupts the frame. That is anunrelated bug in the opcode and is being reported separately.
The
OP_LOADLassertion is with mruby-bigint's tests rather than intest/t/gc.rb, because the literal that reaches the opcode is wider than 32bits and there is no portable way to write one: without the gem it is out of
mrb_intrange on anMRB_INT32build, and an unrepresentable literal is acompile-time error rather than something a test can rescue, so
test/t/gc.rbwould fail to load whole and every assertion in it would be silently lost. With
the gem the literal reaches
IREP_TT_INT64wheremrb_intis 64 bits wide andIREP_TT_BIGINTwhere it is not.Without the change, a default
mrbtestreports 3 KO and aMRB_GC_FIXED_ARENAone reports 3 Crash on
NoMemoryError; an i686mrbtestreports 3 KO, theOP_LOADI32assertion among them. With the change all three report 0 KO and 0Crash. Also run green:
MRB_GC_STRESSwithMRB_USE_DEBUG_HOOK(full-debugfrom
build_config/ci/gcc-clang.rb, where the new assertions add about 0.9 s),and an
MRB_INT32build without mruby-bigint.Summary by CodeRabbit
Bug Fixes
Tests