Performance¶
go-ruby-base64/base64 is the pure-Go library that
rbgo binds for Ruby's Base64. Its
hot standard-alphabet paths run on the SIMD kernels of
go-simd/base64. This page records the
methodology for measuring it — both against the scalar encoding/base64
reference and against the reference Ruby runtimes.
Result (best of 5, ms)¶
Measured 2026-06-30 on Apple M4 Max, macOS (darwin/arm64), Go 1.26.4, with
ruby 4.0.5 +PRISM, jruby 10.1.0.0 (OpenJDK 25) and truffleruby 34.0.1
(GraalVM CE Native). The cross-runtime Base64.encode64 / strict_encode64 /
decode round-trip over a fixed ~3 KiB binary payload; output checked
byte-identical to MRI before timing.
| Runtime | time | vs MRI |
|---|---|---|
| rbgo (go-ruby-base64) | 680 | 4.00× |
| MRI (ruby 4.0.5) | 170 | 1.00× |
| MRI + YJIT | 180 | 1.06× |
| JRuby 10.1.0.0 | 1640 | 9.65× |
| TruffleRuby 34.0.1 | 1140 | 6.71× |
rbgo runs on go-ruby-base64 and is ~4× slower than MRI here. The
go-simd/base64 kernel is on the path and is faster on raw bytes (see the
SIMD-vs-scalar benchmark below), but on this Ruby-visible round-trip the cost
is dominated by per-call Ruby-string allocation and the interpreter dispatch
around each Base64.* call, not the transform itself: MRI's pack("m") is a tight
C path with cheaper string handling, so the SIMD advantage is swamped. The kernel
win shows up in the Go-internal go test -bench (bytes in/out); it does not at the
Ruby level, because the bottleneck has moved to string churn. This is the top
per-module optimization target for go-ruby-base64 (cut the per-call allocation in
the binding); output stays byte-identical to MRI.
Honest framing
JRuby and TruffleRuby are timed cold, single-shot, so they carry JVM /
Graal startup on every run — read them as one-shot ruby file.rb costs, the
same way rbgo and MRI are measured, not as steady-state JIT numbers. These
are real measured numbers from the 2026-06-30 run (Apple M4 Max;
ruby 4.0.5 +PRISM, jruby 10.1.0.0, truffleruby 34.0.1) — nothing is
fabricated or cherry-picked.
Two comparisons¶
1. SIMD vs scalar (Go-internal). The package ships go test -bench
benchmarks (Benchmark*SIMD vs Benchmark*Scalar) that time the go-simd/base64
kernel against the scalar encoding/base64 reference for encode and decode across
input sizes. Because both produce bit-identical output, this isolates the SIMD
speedup on whatever arch the bench runs on (amd64 SSE/AVX2, arm64 NEON, ppc64le
VSX, s390x vector). Reproduce:
2. Ruby-visible operation (cross-runtime). The same Ruby script — a
Base64.encode64 / Base64.decode64 round-trip of a representative payload — is
run under every runtime. rbgo's number reflects this pure-Go library doing the
work; every other column is that interpreter's own base64 stdlib. The script's
output is checked byte-identical to MRI before any timing is recorded.
How to reproduce the cross-runtime comparison¶
- Host: a single, recorded machine (CPU, OS, arch noted alongside any result table), so numbers are comparable run to run, and so the SIMD lane in use is unambiguous.
- Method: best-of-N wall time (best, not mean, to suppress scheduler noise); single-shot processes, no warm-up beyond the script's own loop.
- Runtimes: MRI (the oracle) and MRI
--yjit; the JVM-based and GraalVM-based Rubies are timed cold, single-shot, so they carry VM startup on every run — read them as one-shotruby file.rbcosts, the same wayrbgoand MRI are measured, not as steady-state JIT numbers. - The benchmark script and harness live in rbgo's repo under
bench/modules/.
Honest framing
Rows that complete in well under ~200 ms carry the most relative noise; treat their ratios as order-of-magnitude. Any numbers added here will be real measured numbers from a dated run, on a named host with the SIMD lane stated, with nothing cherry-picked.
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 recorded above. 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 base64? 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). - 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.
decode64-3KiB — Base64.decode64, newline-wrapped (the lenient hot path)¶
| Runtime | ns/op | vs MRI |
|---|---|---|
| go-ruby (pure Go) | 1653.4 | 0.92× |
| MRI | 1804.0 | 1.00× |
| MRI + YJIT | 1742.0 | 0.97× |
| JRuby | 2381.9 | 1.32× |
| TruffleRuby | 12218.1 | 6.77× |
Lenient Base64.decode64 on its real hot input — Base64.encode64 output,
standard base64 wrapped at 60 columns with a newline every line — now beats
both MRI and MRI + YJIT (0.92× MRI; 1653.4 / 1742.0 = 0.95× YJIT). It moved
here from ~1.13× MRI once Decode64 was wired to
go-simd/base64's vectorised Compact
de-space kernel and its in-place Decode contract, collapsing the scalar
de-space + a spare buffer that had kept it just behind the reference (see the
library's
docs/performance.md
and go-ruby-base64/base64#2).
The lead over YJIT is thin (~3–8% across runs, always < 1.00×); a planned
wide-vector Compact (go-asmgen left-pack) would widen it and lift the other
five arches too.
decode-3KiB — Base64.strict_decode64, no newlines¶
| Runtime | ns/op | vs MRI |
|---|---|---|
| go-ruby (pure Go) | 1708.7 | 1.34× |
| MRI | 1275.0 | 1.00× |
| MRI + YJIT | 1238.0 | 0.97× |
| JRuby | 1760.0 | 1.38× |
| TruffleRuby | 8753.7 | 6.87× |
encode-3KiB — Base64.strict_encode64¶
| Runtime | ns/op | vs MRI |
|---|---|---|
| go-ruby (pure Go) | 796.9 | 0.72× |
| MRI | 1106.5 | 1.00× |
| MRI + YJIT | 1083.0 | 0.98× |
| JRuby | 7206.3 | 6.51× |
| TruffleRuby | 9651.3 | 8.72× |
Encode beats MRI (0.72×): the go-simd/base64 kernel is on the encode path.
Strict decode64 (no newlines) is ~1.3× MRI — its byte-scan + strict validation
has no line breaks to strip, so Compact does not apply; it is the module's
remaining decode target. Every output is checked byte-identical to MRI against
the live oracle before timing.
Reproduce
The harness is committed under
benchmarks/:
a self-contained Go driver (go/, pins the published library via
go.mod), the equivalent ruby/base64.rb workload, and run.sh. Run
bash benchmarks/run.sh; 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 — most visibly TruffleRuby on the shortest loops (a few cold-JIT outliers are noted in the text). Sub-microsecond rows carry the most relative noise; treat those ratios as order-of-magnitude. 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 stdlib doing the equivalent work.