vm.c: restore the GC arena in the allocating inline opcodes - #7022
Conversation
|
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 (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe 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. ChangesGC arena restoration
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ 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.
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
📒 Files selected for processing (2)
src/vm.ctest/t/gc.rb
|
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: 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 {
/* 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: 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.
b6844ce to
65bc860
Compare
|
Rebased onto I took your shape for the {
/* 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 The other four branches applied unchanged, as you said they would. Re-checked at
and
All O(1) and in the same range as the controls, so the rebase did not cost the |
The bug
OP_GETIDX,OP_GETIDX0,OP_SETIDXandOP_MATHanswer 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.OP_GETIDX, Stringmrb_str_aref()allocatesOP_GETIDX, HashOP_GETIDX0, HashOP_SETIDX, Hashmrb_hash_set()makes of a String keyOP_MATH_OVERFLOW_INTmrb_intoverflow promotes toThe 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, atn= 20000:s = "hello"; s[1]h = Hash.new { Object.new }; h[1]h = Hash.new { Object.new }; h[0]h = {}; k = "a"; h[k] = 1h = {1=>2}; h[1]a = [0]; a[0] = 1The signal is O(
n) and the residue is O(1). In time, withwhile i < n; s[1]; i += 1; end:ns[1]befores[1]aftera[1], unaffectedand with
x = 4611686018427387904; while i < n; x + x; i += 1; end:nUnder
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. In such a build none of the five loops is merely slow:All five raise after roughly 95 iterations, which is
MRB_GC_ARENA_SIZEless what the setup already holds. The two default-proc loops report fromallocaterather than with thearena overflow errormessage, since the arena is full when the proc allocates rather than when the opcode stores. All five printokwith 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 ofOP_MATHa 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, atn= 20000:lives[1]s[1]; s.bytesize(a send)s[1]; x = ""(OP_STRING)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 intoregs[], 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 readsGC.stat[:live].GC.statis a cfunc, so it runs before the epilogue that would shrink the arena and still sees what the loop pinned. The threshold isn / 4, which leaves a factor of 4 below the signal and about 5 above the largest residue measured; a tighter one is a trap, sinceGC.interval_ratio = 1000alone takes the residue to 996.The
OP_ADDassertion is guarded rather than tested for. Withoutmruby-bigintthe overflow raisesRangeErrorinstead 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 ofmrb_intrange makes the build fail rather than raise. It then runs over both1 << 30and1 << 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.MRB_INT64, bigint)MRB_INT32, bigintMRB_GC_FIXED_ARENAMRB_INT64, no bigintOP_ADDskippedMRB_INT32, no bigintOP_ADDskippedThe
MRB_INT32rows are what the1 << 30case is for: theOP_ADDassertion fails there without the change, so it covers the overflow branch on that width too rather than passing vacuously.Summary by CodeRabbit
Bug Fixes
Tests