vm.c: restore the GC arena where the Float boxing macro allocates - #7168
Conversation
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.
|
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 (2)
📝 WalkthroughWalkthroughFloat 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. ChangesFloat arena retention
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to 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
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 |
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 throughSET_FLOAT_VALUE(), which under word boxing heap-allocates anRFloatwhenever themrb_valueword 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 tomrb_obj_alloc()(mrb_word_boxing_float_value()insrc/etc.c), so an ordinary host build is affected too.There are five boxing sites:
OP_LOADL,IREP_TT_FLOATOP_MATH_CASE_FLOATOP_MATHI_CASE_FLOATOP_MATHILV_CASE_FLOATvm_op_div()Four of them sit inside
mrb_vm_exec()and take a newVM_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 outsidemrb_vm_exec(), so neither the macro nor the arena index it restores to is in scope. Rather than threadaithrough 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 tovm_op_div()and the save, since the type switch only readsmrb_type(),mrb_integer()andmrb_float()andmrb_div_float()is float arithmetic, so the restore pops exactly the oneRFloatthe store created and never anything the caller pushed. Either way the result needs nomrb_gc_protect(): it is stored intoregs[], 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
whileloop whose body holds only the opcode under test andi += 1.GC.startis 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 inGC.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:y = x + zeroy = x - zeroy = x * oney = x / oney = x + 1y = x - 1x += 1; x -= 1y = 1.0e100That 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. UnderMRB_WORDBOX_NO_INLINE_FLOATthe same figures hold for an ordinary1.5.Cost of the retention,
while i < n; y = x + z; i += 1; endwithx = 1.0e100:Under
MRB_GC_FIXED_ARENA, which thebintestbuild inbuild_config/ci/gcc-clang.rbdefines, the same loop is not slow but broken. On that build:Tests
The first commit is test-only and rewrites how the existing arena assertions in
test/t/gc.rbmeasure. They readGC.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 theOP_GETIDXString 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.starthas 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_SETIDXreports 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. Withsrc/vm.creverted and the tests in place, all four fail:rake -m teston the default host build passes at each of the two commits: mrbtestTotal: 2075, KO: 0after the first andTotal: 2079, OK: 2050, KO: 0, Crash: 0, Warning: 0, Skip: 29after the second, with bintestTotal: 105, OK: 105, KO: 0.MRUBY_CONFIG=ci/gcc-clang rake allbuilds clean, and so doesMRUBY_CONFIG=no-float rake all: underMRB_NO_FLOATthe new macro is defined but never expanded, since all five sites sit inside#ifndef MRB_NO_FLOAT.Summary by CodeRabbit
Bug Fixes
Tests