vm.c: honor a [] redefinition installed on a core class - #7198
Conversation
The four assertions in `test/t/gc.rb` measure whether a C branch of `OP_GETIDX` or `OP_GETIDX0` leaves its result in the GC arena, and they measure it only while the loop body reaches the opcode. A send does not: its cfunc epilogue drains the arena, so the assertion would pass whatever the branch under test does. Nothing in this tree redefines `Hash#[]`, so the Hash loops reach the opcode as they stand. mruby-regexp does replace `String#[]` with a Ruby method, keeping the C implementation under `__aref`, so put that back around the two String loops through `with_builtin_string_aref`. The helper is a no-op in a build where no gem replaced the operator.
`OP_GETIDX`, `OP_GETIDX0` and `OP_SETIDX` answer `[]` and `[]=` from C
for an Array, Hash or String receiver, guarded only by the receiver's
class pointer being the core class. A redefinition installed on the
core class itself passes that guard and is silently ignored; only a
subclass or singleton receiver, which fails the pointer test for an
unrelated reason, ever reached one.
$ ruby -e 'class String; def [](i); "hooked"; end; end; p "ab"[0]'
"hooked"
$ mruby -e 'class String; def [](i); "hooked"; end; end; p "ab"[0]'
"a"
The bypass was not even self-consistent, because `self[idx]` compiles
to `OP_SSEND :[]` rather than to an index opcode:
$ mrbc -v -o /dev/null ssend.rb # def bar(i); self[i]; end
3 007 SSEND R3 :[] n=1
so the mrblib core methods that index `self`, `Array#each` among them
with its `yield self[idx]`, went through the method table and honored
the very redefinition the opcode ignored. Redefining a core `[]`
half-worked: iterators changed, direct indexing did not.
$ mruby -e 'class Array; def [](i); :x; end; end
r = ""; [7,8].each {|y| r << y.to_s}
puts r; puts [7,8][1].to_s'
xx
8
Give each (class, operator) pair a slot in `mrb->idx_class[]` holding
the core class while the name still resolves to the builtin the opcode
reimplements, and NULL once it does not. The opcodes compare the
receiver's class against that slot instead of against the core class,
so nothing is added to the hot path: the guard is the same single
compare it always was, and a NULL slot fails it because no live object
has a NULL class pointer. In the generated code each guard is still
one load from `mrb` followed by one compare; only the displacement
widens, because the slots sit past the method cache. Under callgrind
the executed instruction count per iteration is unchanged for all eight
fast paths.
Validity is the resolved `mrb_method_t` itself rather than a "was
assigned" flag, so `def`, `alias_method`, `undef_method`,
`remove_method`, visibility changes and `prepend` are covered without
enumerating them; aliasing the original implementation back re-arms the
slot, and including a module that has no `[]` leaves it armed. The
recheck hangs off the places that already invalidate the method cache
and does nothing unless the changed name is `[]` or `[]=`.
One consequence is not free. mruby-regexp replaces `String#[]` with a
Ruby method at gem initialization, so in a build that includes it the
String read fast path is now correctly off and `str[i]` costs a send,
measured at 635 to 2379 instructions and 32ns to 130ns per iteration in
a tight loop. That is the price of its regexp forms being reachable at
all; the comment there claiming the opcode keeps bypassing the override
is corrected.
`test/t/string.rb` had a named test pinning the bypass as intended; it
now pins the redefinition being honored, and Array and Hash gain the
equivalent. The swap `test/t/gc.rb` wraps its two String assertions in
becomes load-bearing here: without it those loop bodies would stop
reaching the opcode in any build that carries mruby-regexp.
|
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 (10)
Included review availability: Your plan includes up to 8 reviews per rolling hour; 3 remain after this review. 📝 WalkthroughWalkthroughThe change adds cached guards for Array, Hash, and String index operators. Method changes refresh these guards, and VM fast paths fall back to dynamic dispatch when operators are overridden. Tests cover overrides, subclasses, restoration, and GC behavior. ChangesInline index operation guards
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: ⚪ Minimal · up to The change makes indexed access honor core-class redefinitions while preserving the existing fast path when the builtin remains active; no actionable merge-blocking risk remains beyond normal checks and review. Possibly related PRs
Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant RubyCode
participant MethodTable
participant IndexGuards
participant VM
RubyCode->>MethodTable: redefine Array, Hash, or String index method
MethodTable->>IndexGuards: refresh affected slot
RubyCode->>VM: execute indexed operation
VM->>IndexGuards: validate cached receiver class
alt builtin implementation remains active
VM->>VM: use fast path
else method override is active
VM->>MethodTable: perform dynamic dispatch
end
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 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 |
…nition vm.c: honor a `[]` redefinition installed on a core class
OP_GETIDX,OP_GETIDX0andOP_SETIDXanswer[]and[]=from C for an Array, Hash or String receiver, guarded only by the receiver's class pointer being the core class. A redefinition installed on the core class itself passes that guard and is silently ignored; only a subclass or singleton receiver, which fails the pointer test for an unrelated reason, ever reached one.The bypass is not self-consistent
self[idx]compiles toOP_SSEND :[]rather than to an index opcode:so the mrblib core methods that index
self,Array#eachamong them with itsyield self[idx], go through the method table and honor the very redefinition the opcode ignores. Redefining a core[]half-works today: iterators change, direct indexing does not.What this changes
Each (class, operator) pair gets a slot in
mrb->idx_class[]. The slot holds the core class while the name still resolves to the builtin the opcode reimplements, and NULL once it does not. The opcodes compare the receiver's class against that slot instead of against the core class, so a disarmed slot fails the comparison and the operator is sent like any other method. NULL is safe as the disabled value because no live object has a NULL class pointer.Validity is the resolved
mrb_method_titself, not a "was assigned" flag: a slot is armed while the operator resolves, from the core class, to exactly the method recorded at startup. That coversdef,alias_method,undef_method,remove_method, visibility changes andprependwithout enumerating them, re-arms when an override is aliased back away, and stays armed when a module without[]is included. The recheck hangs off the four places that already invalidate the method cache,mrb_define_method_raw(),include_module_at(),mrb_mod_visibility()andmrb_remove_method(), and returns immediately unless the changed name is[]or[]=.mrb_idx_op_init()runs at the end ofmrb_open_core(), after bootstrapping, so a core[]that mrblib itself replaces is recorded as replaced rather than as the builtin. A slot whose operator was already not a C function at that point is never armed.Cost on the fast path
The guard is the same single compare it always was. In the generated code only the displacement widens, because the slots sit past the method cache (
build_config/default.rb,objdump -d src/vm.o, a String branch of the index opcodes):All eight guard sites, three in
vm_op_getidx(), three invm_op_getidx0()and two invm_op_setidx(), change the same way, for+16bytes invm.o.Under callgrind the executed instruction count per iteration is unchanged for every one of them. Measured in a build without mruby-regexp, so that all eight are live; each figure is
Ir(200,000 iterations) - Ir(100,000 iterations), divided by100,000, which cancels startup:ary[1]OP_GETIDXhsh[1]OP_GETIDXstr[1]OP_GETIDXary[0]OP_GETIDX0hsh[0]OP_GETIDX0str[0]OP_GETIDX0ary[1] = 9OP_SETIDXhsh[1] = 9OP_SETIDXSize
build_config/default.rb,.textfromsize:src/vm.osrc/class.osrc/state.obin/mrubysizeof(struct mrb_state)goes from 24,536 to 24,656, once per state:5class pointers plus5mrb_method_t. The slots sit at the end of the struct, so no existing field moves.One consequence is not free
mruby-regexp replaces
String#[]with a Ruby method at gem initialization, so in a build that includes it the String read fast path is now correctly off andstr[i]costs a send. The comment inmrbgems/mruby-regexp/mrblib/string_regexp.rbclaiming the opcode keeps bypassing the override is corrected in the same commit.build_config/default.rb, which carries mruby-regexp, best of 25:str[1]str[0]ary[1]ary[0]hsh[1]Only the String read moves. The Array and Hash rows sit at the measurement floor on this box: run to run they swing by more than the difference between the two columns, in either direction, which is why the ratios there are noise and not a result. The same build under callgrind separates the two cleanly:
str[1]str[0]ary[1]ary[0]hsh[1]hsh[0]ary[1] = 9hsh[1] = 9That slowdown is the price of the regexp forms of
String#[]being reachable at all. A build without mruby-regexp keeps all eight fast paths, andstr[1]there measures 38.25 ns on master and 39.02 ns with this PR, at the identical instruction count shown above.Tests
test/t/string.rbhad a named test pinning the bypass as intended. It now pins the redefinition being honored, and Array and Hash gain the equivalent: two named tests intest/t/array.rb, one intest/t/hash.rb. Between them they cover the read, the write, a subclass receiver, the receiver being left untouched by a redefined[]=, and re-arming by aliasing the original implementation back.The four GC arena assertions in
test/t/gc.rbmeasure whether a C branch ofOP_GETIDXorOP_GETIDX0leaves its result in the arena, and they measure it only while the loop body reaches the opcode. A send does not: its cfunc epilogue drains the arena, so the assertion would pass whatever the branch under test does. The first commit puts the C implementation back around the two String loops through awith_builtin_string_arefhelper, which is a no-op where no gem replaced the operator. Without it those loop bodies would stop reaching the opcode in any build that carries mruby-regexp.hostbuild_config/default.rbfull-debugbuild_config/ci/gcc-clang.rbbintestbuild_config/ci/gcc-clang.rbcxx_abibuild_config/ci/gcc-clang.rbbyte-stringbuild_config/ci/gcc-clang.rbascii-casebuild_config/ci/gcc-clang.rbMRB_NO_BOXINGenable_debugMRB_WORD_BOXINGenable_debugMRB_NAN_BOXINGenable_debugMRB_NO_METHOD_CACHEenable_debugbintestis green in both builds that enable it: 106 of 106 underbuild_config/default.rb, 117 of 117 underbuild_config/ci/gcc-clang.rb. Both commits are green on their own underbuild_config/default.rb.Environment
Details
Compile line per build,
src/vm.c, with-MMD -c,-Iand-oremoved:Summary by CodeRabbit