Performance¶
go-ruby-stringio/stringio is the pure-Go library that
rbgo binds for Ruby's StringIO. This
page records a comparative benchmark of that module against the reference
Ruby runtimes, part of the ecosystem-wide per-module parity suite.
What is measured¶
The same workload — a set of representative StringIO usage patterns (streaming
write + string, line iteration with gets/each_line, byte-chunk read
scanning, character-by-character getc scanning, and a puts/printf writer) —
is run through the pure-Go library's Go API and through each reference runtime's
own StringIO. Ruby's StringIO is a C extension, so the MRI / YJIT columns
are hand-written C doing the byte shuffling; the go column is
this pure-Go library doing the work. The comparison is therefore the
Ruby-visible operation, apples-to-apples across implementations. Every operation's
output is checked byte-identical to MRI (SHA-256 of the final buffer / the
reassembled read result) before any timing is trusted — the harness aborts on a
mismatch.
- Method: each process runs 3 untimed warm-up passes, then 25 timed passes of
a fixed inner loop, timed with a monotonic clock; the best pass is reported
as ns/op (lower is better).
vs MRI< 1.00× means faster than MRI. Interpreter start-up is outside the timed region, so these are operation costs, notruby file.rbprocess costs. - Runtimes:
ruby(MRI, the oracle) andruby --yjit;jruby(on the JVM);truffleruby(GraalVM CE Native).
rbgo end-to-end row
The whole-interpreter rbgo-vs-MRI row (single-shot ruby file.rb wall time,
the format used by the other module pages) has not yet been captured for
StringIO on a controlled host, so it is not shown here rather than printed as
a fabricated figure. The library-level section below is the real, measured
parity result for this module.
Library-level benchmark (Go API vs runtimes) — 2026-07-03¶
This section measures the pure-Go library directly, through its Go API — not
the rbgo interpreter path. It isolates the library primitive from
Ruby-interpreter dispatch, answering the parity question head-on: is the pure-Go
implementation as fast as the reference runtime's own StringIO C extension? The
same workload, same inputs, same iteration counts run through the Go library
and through each reference runtime's stdlib; outputs were checked identical to MRI
before any timing.
- Host: Apple M4 Max (
Mac16,5, arm64), macOS 26.5.1 — date 2026-07-03. - Runtimes: Go 1.26.4 · MRI
ruby 4.0.5 +PRISM· MRI + YJIT · JRuby 10.1.0.0 (OpenJDK 25) · TruffleRuby 34.0.1 (GraalVM CE Native). - Inputs: a deterministic 256-line document (
"line %05d the quick brown fox jumps %d\n"); theputswriter emits 128row/printfpairs;readscans in 64-byte chunks. - Method: 3 untimed warm-up passes, then 25 timed passes of a fixed inner loop, monotonic clock, best pass reported as ns/op.
write¶
| Runtime | ns/op | vs MRI |
|---|---|---|
| go-ruby (pure Go) | 4487.6 | 0.35× |
| MRI | 12693.3 | 1.00× |
| MRI + YJIT | 8216.7 | 0.65× |
| JRuby | 7701.9 | 0.61× |
| TruffleRuby | 5504.9 | 0.43× |
read¶
| Runtime | ns/op | vs MRI |
|---|---|---|
| go-ruby (pure Go) | 5155.1 | 0.50× |
| MRI | 10310.0 | 1.00× |
| MRI + YJIT | 8340.0 | 0.81× |
| JRuby | 3451.1 | 0.33× |
| TruffleRuby | 6397.4 | 0.62× |
getc¶
| Runtime | ns/op | vs MRI |
|---|---|---|
| go-ruby (pure Go) | 51875.1 | 0.11× |
| MRI | 473253.3 | 1.00× |
| MRI + YJIT | 404340.0 | 0.85× |
| JRuby | 136266.0 | 0.29× |
| TruffleRuby | 262840.4 | 0.56× |
puts¶
| Runtime | ns/op | vs MRI |
|---|---|---|
| go-ruby (pure Go) | 12715.4 | 0.31× |
| MRI | 41253.3 | 1.00× |
| MRI + YJIT | 35060.0 | 0.85× |
| JRuby | 26284.7 | 0.64× |
| TruffleRuby | 16698.1 | 0.40× |
gets¶
| Runtime | ns/op | vs MRI |
|---|---|---|
| go-ruby (pure Go) | 102233.2 | 5.80× |
| MRI | 17620.0 | 1.00× |
| MRI + YJIT | 17066.7 | 0.97× |
| JRuby | 8284.9 | 0.47× |
| TruffleRuby | 11813.1 | 0.67× |
Mixed, and instructive. On the byte- and buffer-shuffling paths the pure-Go
library beats MRI's C extension: write 0.35×, puts 0.31×, read 0.50×,
and most strikingly getc ~9× faster (0.11×) — MRI's per-character getc
pays a full method-dispatch + object-allocation round trip in C, where the Go
EachChar decode loop stays tight. The one regression is gets/each_line
(5.80× slower): the library's line core calls strings.Index(string(rest), sep)
on the remaining buffer for every line, which reallocates and rescans a shrinking
copy each iteration (≈ O(n²) over the line count), whereas MRI's gets is a single
C memchr over the live buffer. That line-scan is the concrete optimization target
for this module — indexing on the []byte directly (no string(rest) copy) and
advancing a saved offset removes both the allocation and the re-scan. Output stays
byte-identical to MRI throughout.
Reproduce
The harness is committed under
benchmarks/:
a self-contained Go driver (go/, pins the published library by
pseudo-version in go.mod, no replace), the equivalent ruby/stringio.rb
workload, and run.sh. Run bash benchmarks/run.sh; it first verifies the Go
output is byte-identical to MRI (SHA-256 per op) and aborts on mismatch, then
times. Env OUTER/WARM tune the pass budget and RUBY/JRUBY/TRUFFLERUBY
select the runtime binaries.
Warm-up budget & noise — honest framing
Numbers reflect a fixed warm-process budget (3 warm-up + 25 timed passes
in one process). The JVM/GraalVM JITs (JRuby, TruffleRuby) may need a larger
warm-up to reach steady state, so their columns can understate peak
throughput. Sub-microsecond-per-op work is not reached here (the smallest op,
write, is ~4.5 µs), so per-row relative noise is modest, but treat
differences under ~10% as noise. Every number here is a real measured value
from the dated run above — nothing is fabricated, estimated, or cherry-picked.
The go-ruby column is the pure-Go library; every other column is that
interpreter's own StringIO (a C extension in MRI) doing the equivalent work.