Skip to content

vm.c: answer str[0] from C in OP_GETIDX0 - #7040

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:getidx0-string-branch
Aug 9, 2026
Merged

vm.c: answer str[0] from C in OP_GETIDX0#7040
matz merged 1 commit into
mruby:masterfrom
takumin:getidx0-string-branch

Conversation

@takumin

@takumin takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

The gap

vm_op_getidx0() answers ary[0] and hash[0] from C and has no String
branch, so str[0] is the one index form of the three that leaves the opcode
through getidx0_fallback and reaches String#[] as an ordinary send. Its
sibling vm_op_getidx() does have a String branch, so str[1] is already
answered in C. The two opcodes disagree about String for no reason other than
the branch never having been written.

What it costs

n str[0] before after
800000 35 ms 21 ms
3200000 137 ms 87 ms

Best of seven runs each, alternating the two binaries, with
while i < n; s[0]; i += 1; end over a local. Both are linear, so this is a
constant factor rather than a complexity change.

The arena restore is not optional

mrb_str_aref() returns a freshly allocated String, and an inline opcode never
runs the cfunc epilogue that would shrink the arena. That is the bug #7022
fixed across the allocating inline opcodes, including the Hash branch of this
same vm_op_getidx0(). A String branch written without the save and restore
would re-open it one index form later: the loop above becomes quadratic, and
under MRB_GC_FIXED_ARENA, which build_config/ci/gcc-clang.rb and
build_config/ci/msvc.rb both define, it raises instead:

$ ./build/fixedarena/bin/mruby -e 's="hello"; i=0; while i < 20000; s[0]; i+=1; end'
-e:1: arena overflow error (NoMemoryError)

With the restore in place the same command prints nothing and exits 0.

Unlike the Hash branch above it, this one does not refresh ci afterwards:
mrb_str_aref() on a plain String with an Integer index cannot run Ruby code
and so cannot move the stack. The result needs no mrb_gc_protect() either,
since it is stored in regs[a] and the VM stack is a GC root, which is why the
cfunc epilogues can shrink unconditionally too.

It changes what a redefined String#[] sees

The class guard reads the receiver's class, not whether String#[] has been
redefined, so the branch bypasses a redefinition installed on String itself:

class String
  def [](i); "OVERRIDDEN"; end
end
s = "hello"
p s[0]              # => "OVERRIDDEN" before, "h" now
p s[1]              # => "e" before and now
p s.send(:[], 0)    # => "OVERRIDDEN" both ways

class Sub < String; end
p Sub.new("hello")[0]   # => "OVERRIDDEN" both ways

The change is to make s[0] agree with s[1], which has ignored such a
redefinition ever since vm_op_getidx() gained its String branch. A subclass
receiver keeps reaching the override, because the guard rejects it. Whether the
existing s[1] behaviour is right is a separate and much larger question,
since the Array and Hash branches of both opcodes work the same way; this
change does not widen it beyond one more index form.

Which shapes reach the opcode

OP_GETIDX0 is emitted for a literal zero index only when the receiver is
already in a register: a local, a method argument, a block-local. These are
OP_GETIDX0, and slow before this change:

s = "hello"; s[0]                          # GETIDX0
def m(u); u[0]; end                        # GETIDX0
[1].each { |x| t = "hi"; t[0] }            # GETIDX0

and these are not, because the receiver has to be materialized first, after
which the compiler emits the general OP_GETIDX with a LOADI_0 beside it and
the String branch of that opcode answers it in C:

s = "hello"; [1].each { s[0] }             # GETUPVAR + LOADI_0 + GETIDX
@iv[0]                                     # GETIV + LOADI_0 + GETIDX

A benchmark that puts the loop inside a block over an outer s therefore
measures the C path and shows no difference at all. The figures above all come
from a while loop over a local.

The tests

test/t/gc.rb already carries an OP_GETIDX0 arena assertion for the Hash
branch, added by #7022. The new one is a second assertion for the same opcode,
naming the branch it pins so the two do not read as one assertion moved. It
passes without this change, since there is nothing to leak without a String
branch, and is there to fail if the branch is ever added or rewritten without
the restore. Checked both ways: it passes with the branch as written, and fails
with the same branch minus the two arena calls.

test/t/string.rb gets an assertion for the redefinition case above, which
nothing in the suite covered, and it pins both index forms at once. It restores
String#[] in an ensure. The saved alias is removed only where
remove_method exists, since that comes from mruby-metaprog and the core test
build does not have it; for the same reason the assertion does not use send.

Checked configurations

All at mruby/mruby@9df343588, with the change applied.

build result
host (build_config/default.rb) 1961 OK, 0 KO, 0 Crash
full-debug (MRB_GC_STRESS, MRB_USE_DEBUG_HOOK) 2138 OK, 0 KO, 0 Crash
MRB_GC_FIXED_ARENA + bintest 2138 OK, 0 KO, 0 Crash
cxx_abi 2138 OK, 0 KO, 0 Crash

Summary by CodeRabbit

  • Performance

    • Improved string indexing at position 0 for faster execution with standard strings.
  • Bug Fixes

    • Prevented temporary indexing results from being retained during garbage collection.
    • Ensured indexed access respects custom behavior for string subclasses while preserving optimized behavior for standard strings.
  • Tests

    • Added coverage for garbage collection and string indexing overrides.

`vm_op_getidx0()` has branches for Array and Hash but none for String, so
`str[0]` is the one index form of the three that leaves the opcode through
`getidx0_fallback` and reaches `String#[]` as an ordinary send. Its sibling
`vm_op_getidx()` does have a String branch, so `str[1]` is already answered
in C. The two opcodes disagree about String for no reason other than the
branch never having been written.

`while i < n; s[0]; i += 1; end` takes 35 ms at `n` = 800000 and 137 ms at
3200000, against 21 ms and 87 ms with the branch. Both are linear, so this is
a constant factor rather than a complexity change.

`mrb_str_aref()` allocates, and an inline opcode never runs the cfunc epilogue
that would shrink the arena, so the branch saves and restores it the way the
Hash branch beside it does. That is the bug the allocating inline opcodes were
fixed for; a String branch written without it would re-open the same hole one
index form later, and under `MRB_GC_FIXED_ARENA`, which `build_config/ci`
defines, the loop above raises `NoMemoryError: arena overflow error` instead
of merely going quadratic. Unlike the Hash branch this one does not refresh
`ci`, since `mrb_str_aref()` on a plain String with an Integer index cannot
run Ruby code and so cannot move the stack. The result needs no
`mrb_gc_protect()` either: it is stored in `regs[a]`, and the VM stack is a
GC root.

The class guard reads the receiver's class rather than whether `String#[]` has
been redefined, so the branch bypasses a redefinition installed on `String`
itself. That is observable:

```ruby
class String
  def [](i); "OVERRIDDEN"; end
end
s = "hello"
s[0]   # => "OVERRIDDEN" before, "h" now
s[1]   # => "e" before and now
```

The change is to make `s[0]` agree with `s[1]`, which has ignored such a
redefinition ever since `vm_op_getidx()` gained its String branch. A subclass
receiver fails the guard and still reaches the override. The Array and Hash
branches of both opcodes work the same way, so this does not widen the
behaviour beyond one more index form.

The assertion in `test/t/gc.rb` is a second one for `OP_GETIDX0`, naming the
branch it pins so it does not read as the existing Hash one moved; it fails if
this branch is added without the arena restore. The one in `test/t/string.rb`
pins the redefinition behaviour, which nothing in the suite covered, for both
index forms at once.
@takumin
takumin requested a review from matz as a code owner August 9, 2026 13:04
@coderabbitai

coderabbitai Bot commented Aug 9, 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: 70dcd5d5-8d78-4c63-b931-4a3f6aefbec5

📥 Commits

Reviewing files that changed from the base of the PR and between 9a3d566 and 5701a34.

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

📝 Walkthrough

Walkthrough

vm_op_getidx0 now uses a GC-safe direct path for index-zero access on exact String instances. Tests cover GC retention, method override behavior, and subclass dispatch.

Changes

String index-zero fast path

Layer / File(s) Summary
Protected String fast path
src/vm.c, test/t/gc.rb
vm_op_getidx0 calls mrb_str_aref directly for exact String receivers with arena protection. The GC test checks repeated indexing for excessive live-object growth.
Override dispatch regression
test/t/string.rb
The test verifies that exact String receivers bypass a redefined String#[], while subclass receivers invoke the override. Cleanup restores the original method.

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

Possibly related PRs

  • mruby/mruby#7022: Both changes modify vm_op_getidx0 string indexing and test GC arena retention.
  • mruby/mruby#7023: Both changes add GC-safe handling to vm_op_getidx0 for different receiver cases.

Suggested labels: core

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR does not address the directly linked issue [#39], which requires fixing files left behind by make clean. Link the PR to an issue covering OP_GETIDX0 String handling, or modify the changes to implement the requirements of [#39].
Out of Scope Changes check ⚠️ Warning The OP_GETIDX0 and String indexing changes are unrelated to the linked issue [#39] about make clean file removal. Remove these changes or link the PR to a requirement that covers OP_GETIDX0 String indexing.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: handling String index 0 directly in C within OP_GETIDX0.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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