Skip to content

vm.c: restore the GC arena where the Float boxing macro allocates - #7168

Merged
matz merged 2 commits into
mruby:masterfrom
takumin:vm-float-arena-restore
Aug 14, 2026
Merged

vm.c: restore the GC arena where the Float boxing macro allocates#7168
matz merged 2 commits into
mruby:masterfrom
takumin:vm-float-arena-restore

Conversation

@takumin

@takumin takumin commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

VM_SET_INT_VALUE() restores the GC arena at the Integer boxing sites in the interpreter loop, because those opcodes answer inline and have no cfunc epilogue behind them to shrink it. The Float sites box through SET_FLOAT_VALUE(), which under word boxing heap-allocates an RFloat whenever the mrb_value word cannot hold the value inline, and none of them restore. Each iteration of a loop built from those opcodes pins one more object, the mark phase then walks all of them, and the loop is quadratic.

This is not confined to MRB_WORDBOX_NO_INLINE_FLOAT, where every Float is a heap object. Default 64-bit word boxing also sends a subnormal, an exponent outside [-255, +256], and a rotation that would collide with a sentinel to mrb_obj_alloc() (mrb_word_boxing_float_value() in src/etc.c), so an ordinary host build is affected too.

There are five boxing sites:

site opcodes
OP_LOADL, IREP_TT_FLOAT OP_LOADL
OP_MATH_CASE_FLOAT OP_ADD, OP_SUB, OP_MUL
OP_MATHI_CASE_FLOAT OP_ADDI, OP_SUBI
OP_MATHILV_CASE_FLOAT OP_ADDILV, OP_SUBILV
Float tail of vm_op_div() OP_DIV

Four of them sit inside mrb_vm_exec() and take a new VM_SET_FLOAT_VALUE() built to the same shape as the Integer macro: store, then restore unless the store produced an immediate, which is the inline-float fast path and allocates nothing. On a boxing mode that keeps the Float in the word it expands to the bare store.

vm_op_div() is a helper outside mrb_vm_exec(), so neither the macro nor the arena index it restores to is in scope. Rather than thread ai through the signature, it saves its own index around the store and restores to the height on entry to that branch instead of to the height on entry to the frame. That still bounds the arena across a loop, and it is exactly what the Integer branch a few lines above in the same function already does. It is also strictly the more conservative of the two: nothing allocates between entry to vm_op_div() and the save, since the type switch only reads mrb_type(), mrb_integer() and mrb_float() and mrb_div_float() is float arithmetic, so the restore pops exactly the one RFloat the store created and never anything the caller pushed. Either way the result needs no mrb_gc_protect(): it is stored into regs[], and the VM stack is a GC root, which is the same reason the cfunc epilogues can shrink unconditionally.

Measurements

x86-64. Each figure is from a 20000-iteration while loop whose body holds only the opcode under test and i += 1. GC.start is run as the first send after the loop, while the arena still roots what the loop left there, since any cfunc return would drain it first, so the rise in GC.stat[:live] across that collection is the number of pinned objects. The last digit varies by one or two with what happens to be live when the base count is read; only the order of magnitude is meaningful.

Default word boxing, x = 1.0e100:

loop body before after
y = x + zero ~20000 <= 2
y = x - zero ~20000 <= 2
y = x * one ~20000 <= 2
y = x / one ~20000 <= 2
y = x + 1 ~20000 <= 2
y = x - 1 ~20000 <= 2
x += 1; x -= 1 ~40000 <= 2
y = 1.0e100 ~20000 <= 2

That is one pinned object per boxing store, and two per iteration for the fused pair. A subnormal (5.0e-324) gives the same figures, except through OP_ADDI and OP_SUBI where a non-zero integer operand normalises the result so nothing is allocated. Under MRB_WORDBOX_NO_INLINE_FLOAT the same figures hold for an ordinary 1.5.

Cost of the retention, while i < n; y = x + z; i += 1; end with x = 1.0e100:

n before after
200000 1975 ms 8 ms
800000 153357 ms 29 ms

Under MRB_GC_FIXED_ARENA, which the bintest build in build_config/ci/gcc-clang.rb defines, the same loop is not slow but broken. On that build:

$ ./build/bintest/bin/mruby -e 'x = 1.0e100; i = 0; while i < 20000; x + x; i += 1; end; puts "completed"'
trace (most recent call last):
-e:1: NoMemoryError      # before: gc_arena_keep() raises once the arena fills
completed                # after

Tests

The first commit is test-only and rewrites how the existing arena assertions in test/t/gc.rb measure. They read GC.stat[:live] immediately after the loop and compare the rise against a fixed 5000. That does catch a branch that stops restoring altogether, since reverting the OP_GETIDX String restore takes the first assertion from 1 to 20000 and it fails, but the count it reads is the mutator's live set mid-cycle, not what the arena pinned, so a passing run already reports 29 to 806 objects of incremental-sweep lag and the threshold has to clear that. Against the reverted restore, a branch that retained on 4000 of the 20000 iterations reported 4000 and still passed; so did 1000, and 200.

Running the full GC before reading removes the slack entirely: the arena is a GC root, so exactly the pinned objects survive and everything else is swept. The GC.start has to be the first send after the loop, because any cfunc return drains the arena. Every assertion now reports 1 on a restoring tree (OP_SETIDX reports 2), the margin drops from 5000 to 100, and the 4000, 1000 and 200 partial leaks are all reported exactly and all fail. The smallest leak the assertions can miss goes from roughly 22% of the iterations to 0.5%.

The second commit adds four Float assertions in that shape, covering all five sites at both a subnormal and an out-of-range exponent so they bite on a default host build as well as under MRB_WORDBOX_NO_INLINE_FLOAT. With src/vm.c reverted and the tests in place, all four fail:

Fail: OP_MATH does not retain a boxed Float in the GC arena (core)
Fail: OP_DIV does not retain a boxed Float in the GC arena (core)
Fail: OP_ADDI does not retain a boxed Float in the GC arena (core)
Fail: OP_LOADL does not retain a boxed Float in the GC arena (core)

rake -m test on the default host build passes at each of the two commits: mrbtest Total: 2075, KO: 0 after the first and Total: 2079, OK: 2050, KO: 0, Crash: 0, Warning: 0, Skip: 29 after the second, with bintest Total: 105, OK: 105, KO: 0. MRUBY_CONFIG=ci/gcc-clang rake all builds clean, and so does MRUBY_CONFIG=no-float rake all: under MRB_NO_FLOAT the new macro is defined but never expanded, since all five sites sit inside #ifndef MRB_NO_FLOAT.

Summary by CodeRabbit

  • Bug Fixes

    • Improved floating-point arithmetic, division, and literal loading across supported boxing configurations.
    • Prevented unnecessary memory retention during repeated floating-point operations.
    • Ensured boxed floating-point results preserve their expected values.
  • Tests

    • Expanded garbage-collection coverage for floating-point operations.
    • Added checks for memory growth and correct results across arithmetic scenarios.

The arena assertions read `GC.stat[:live]` immediately after the loop and
compare the rise against a fixed 5000. That does catch a branch that stops
restoring altogether, since reverting the `OP_GETIDX` String restore takes
the first assertion from 1 to 20000 and it fails, but it cannot see anything
smaller. The count it reads is the mutator's live set mid-cycle, not what
the arena pinned, so a passing run already reports 455 to 806 objects of
incremental-sweep lag, and the 5000 has to clear that. A branch that
retained on 4000 of the 20000 iterations reports 4000 and still passes;
so does one that retained on 1000, and on 200.

Read the arena instead. The arena is a GC root, so a full collection run
while it still holds the loop's objects keeps exactly those and sweeps
everything else, and the rise in `GC.stat[:live]` across it is the number
of pinned objects and nothing else. The `GC.start` has to be the first
send after the loop, because any cfunc return drains the arena and reading
`GC.stat` first would discard what is being measured.

On a restoring tree every assertion now reports 1, except `OP_SETIDX`
which reports 2; the margin drops from 5000 to 100 and exists only for the
objects `GC.stat` allocates for its own result. Against the reverted
`OP_GETIDX` restore the same partial leaks that used to pass now fail:
4000, 1000 and 200 pinned objects are reported exactly and are all over
the margin. The smallest leak the assertions can miss goes from roughly
22% of the iterations to 0.5%.

No behaviour changes; `src/vm.c` is untouched.
`VM_SET_INT_VALUE()` restores the arena at the Integer boxing sites in the
interpreter loop, because those opcodes answer inline and have no cfunc
epilogue behind them to shrink it. The Float sites box through
`SET_FLOAT_VALUE()`, which allocates an RFloat under word boxing for a
value the mrb_value word cannot hold inline, and none of them restore.

This is not confined to `MRB_WORDBOX_NO_INLINE_FLOAT`, where every Float
is a heap object. The default 64-bit word boxing also sends a subnormal,
an exponent outside [-255, +256], and a rotation that would collide with
a sentinel to `mrb_obj_alloc()`. There are five such sites: `OP_LOADL`,
`OP_MATH_CASE_FLOAT` for OP_ADD/OP_SUB/OP_MUL, `OP_MATHI_CASE_FLOAT` for
OP_ADDI/OP_SUBI, `OP_MATHILV_CASE_FLOAT` for the fused local forms, and
the Float tail of `vm_op_div()`.

Four of them sit inside `mrb_vm_exec()` and take a `VM_SET_FLOAT_VALUE()`
built to the same shape as the Integer macro: store, then restore unless
the store produced an immediate, which is the inline-float fast path.
`vm_op_div()` is a helper outside that function, so neither the macro nor
the arena index it restores to is in scope. It saves its own index around
the store instead, restoring to the height on entry to the branch rather
than to the height on entry to the frame. That still bounds the arena
across a loop, and it is what the Integer branch of the same function
already does. Nothing allocates between entry to `vm_op_div()` and that
save, because the type switch only reads `mrb_type()`, `mrb_integer()`
and `mrb_float()` and `mrb_div_float()` is float arithmetic, so the
restore pops exactly the RFloat the store created. The result needs no
`mrb_gc_protect()` either way: it is stored into `regs[]`, and the VM
stack is a GC root.

Measured on x86-64 over a 20000-iteration `while` loop whose body holds
only the opcode under test and `i += 1`, with `GC.start` run as the first
send after the loop so that the arena still roots what the loop left
there, and `GC.stat[:live]` read across it. Under default word boxing
with `x = 1.0e100`, each of OP_ADD, OP_SUB, OP_MUL, OP_DIV, OP_ADDI and
OP_SUBI pinned one object per iteration, so 20000 give or take the one or
two objects live when the base count is read; the fused
`x += 1; x -= 1` pair pinned 40000 for its two stores, and
`y = 1.0e100` pinned 20000. Afterwards none of them is above 2. A
subnormal behaves the same except through OP_ADDI and OP_SUBI, where a
non-zero integer operand normalises the result. Under
`MRB_WORDBOX_NO_INLINE_FLOAT` the same figures hold for an ordinary
`1.5`.

The retention is quadratic, because the mark phase walks everything the
arena pins: `while i < n; y = x + z; i += 1; end` with `x = 1.0e100` took
1975 ms at n = 200000 and 153357 ms at 800000, against 8 ms and 29 ms
with the restore. `build_config/ci` defines `MRB_GC_FIXED_ARENA`, where
the same loops do not merely slow down: `gc_arena_keep()` raises
`NoMemoryError` on the 97th iteration, once the arena fills.

The new assertions cover all five sites at both a subnormal and an
out-of-range exponent, so they fail on the default host build as well as
under `MRB_WORDBOX_NO_INLINE_FLOAT`; all four fail without this change.
@takumin
takumin requested a review from matz as a code owner August 14, 2026 09:08
@github-actions github-actions Bot added the core label Aug 14, 2026
@coderabbitai

coderabbitai Bot commented Aug 14, 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: f3b3fe19-5041-4687-860e-7db85606011b

📥 Commits

Reviewing files that changed from the base of the PR and between 783e3b2 and 97d5b6a.

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

📝 Walkthrough

Walkthrough

Float result paths now restore the GC arena after boxed Float allocation. GC tests tighten retention limits and cover arithmetic, division, immediate operations, fused operations, and Float literal loading.

Changes

Float arena retention

Layer / File(s) Summary
Arena-aware float storage
src/vm.c
VM_SET_FLOAT_VALUE restores the arena for non-immediate boxed Floats. Division, constants, and floating-point arithmetic use this storage path.
Float arena retention tests
test/t/gc.rb
Retention checks use a 100-object limit. New tests validate boxed Float results across VM operations and value ranges.

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

Merge Risk: ⚪ Minimal · up to 97d5b

The change restores garbage-collection arena cleanup for boxed Float results, preventing loop-driven object retention and slowdown; no actionable merge-blocking risk remains after normal checks and review.

Possibly related PRs

  • mruby/mruby#7022: Both changes restore the GC arena for VM operation results.
  • mruby/mruby#7042: This change applies boxed-result arena restoration to Float results, while the related PR targets Integer results.

Suggested reviewers: matz, hasumikin

🚥 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 and concisely describes the main change: restoring the GC arena after Float boxing allocations in vm.c.
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.

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