Skip to content

Performance

go-ruby-rack/rack is the pure-Go library that rbgo binds for Ruby's rack. This page records the module's place in the ecosystem-wide per-module parity suite: the same Ruby-visible operation is run through the pure-Go library and through each reference runtime's own rack gem; the Go output is verified byte-identical to MRI first, then timed.

The harness lives under benchmarks/ in this repository — a self-contained Go driver that pins the published library by pseudo-version (no replace), the equivalent Ruby workload over the real rack gem, and a run.sh that gates on byte-identical parity before timing.

Setup

Date 2026-07-05
Host Apple M4 Max, 16 cores, macOS 26.5.1 (arm64), on the host — no VM
Go 1.26.4 (CGO_ENABLED=0), library github.com/go-ruby-rack/rack v0.0.0-20260705195519-3d6eb0d8d96b
MRI ruby 4.0.5 +PRISM, with and without --yjit
JRuby 10.1.0 (OpenJDK 25.0.2)
TruffleRuby 34.0.1 (GraalVM CE Native, like ruby 3.4.9)
rack gem 3.2.6 on every reference runtime

Method. Each process runs 3 untimed warm-up passes (to let the JVM/GraalVM JITs warm up), then 25 timed passes of a fixed inner loop, timed with a monotonic clock; the best pass is reported as ns/op (best, not mean, to suppress scheduler noise). Interpreter start-up is outside the timed region. Every op's output is checked byte-identical to MRI before any timing is recorded — the structural ops (parse_*, request, response) through a length-prefixed canonical serialiser shared by the Go and Ruby drivers.

Reading the numbers

JRuby and TruffleRuby are timed cold / single-shot (one short-lived process, best of 25 passes) and can understate their true steady-state peak; their columns are indicative, not their JIT ceiling. Sub-microsecond rows carry the most relative noise and should be read as order-of-magnitude. Every number below is a real measured value from the dated run above — nothing estimated or cherry-picked.

Headline: pure-Go vs MRI + YJIT

Rack's compute surface is pure Ruby, so the pure-Go port is broadly far ahead of even the JIT. go-ruby-rack beats MRI + YJIT on all 9 ops. The closest race is escape_html, where Rack::Utils.escape_html dispatches into a C-implemented core (cgi/escape): a two-pass strings.Replacer used to lose to it, but the table-driven single-pass escaper (a 256-entry byte→entity lookup that bulk-copies verbatim runs and returns no-escape input allocation-free) now runs ~1.5× ahead of even YJIT's C path.

Op go-ruby (ns/op) MRI + YJIT (ns/op) go vs YJIT
escape 245.7 4999.2 20.3× faster
unescape 116.2 5659.6 48.7× faster
escape_html 104.8 162.2 1.55× faster
parse_query 1798.6 8985.5 5.00× faster
build_query 779.9 5386.5 6.91× faster
parse_nested_query 1735.4 7001.5 4.03× faster
build_nested_query 1029.4 7337.5 7.13× faster
request 2310.6 8559.5 3.70× faster
response 854.8 2585.5 3.02× faster

Full results

Nine representative primitives from Rack's pure-compute surface, over fixed inputs present in both go-ruby-rack and rack 3.2.6. (unescape_html is omitted — it is not part of Rack::Utils 3.2.6.) vs MRI is the ratio to plain MRI (lower is faster).

escape

Rack::Utils.escape — form-encode a realistic value (spaces, reserved bytes, multi-byte UTF-8).

Runtime ns/op vs MRI
go-ruby (pure Go) 245.7 0.05×
MRI 5213.2 1.00×
MRI + YJIT 4999.2 0.96×
JRuby 2231.5 0.43×
TruffleRuby 3844.0 0.74×

unescape

Rack::Utils.unescape — the inverse of escape.

Runtime ns/op vs MRI
go-ruby (pure Go) 116.2 0.02×
MRI 5583.4 1.00×
MRI + YJIT 5659.6 1.01×
JRuby 3267.4 0.59×
TruffleRuby 3983.9 0.71×

escape_html

Rack::Utils.escape_html — the five-character HTML-entity escape (&, <, >, ", '). MRI/YJIT dispatch to a C core (cgi/escape) here, so this is the closest race — and the pure-Go table-driven escaper now wins it outright.

Runtime ns/op vs MRI
go-ruby (pure Go) 104.8 0.55×
MRI 191.8 1.00×
MRI + YJIT 162.2 0.85×
JRuby 304.4 1.59×
TruffleRuby 2065.3 10.77×

parse_query

Rack::Utils.parse_query — a flat query string (repeated array keys, %/+ values, a valueless key) into a key → value(s) map.

Runtime ns/op vs MRI
go-ruby (pure Go) 1798.6 0.17×
MRI 10550.5 1.00×
MRI + YJIT 8985.5 0.85×
JRuby 4354.3 0.41×
TruffleRuby 13347.3 1.27×

build_query

Rack::Utils.build_query — the inverse of parse_query.

Runtime ns/op vs MRI
go-ruby (pure Go) 779.9 0.12×
MRI 6628.0 1.00×
MRI + YJIT 5386.5 0.81×
JRuby 2440.7 0.37×
TruffleRuby 6311.9 0.95×

parse_nested_query

Rack::Utils.parse_nested_query — a foo[bar][]=… nested body into structural Hash/Array/String/nil.

Runtime ns/op vs MRI
go-ruby (pure Go) 1735.4 0.19×
MRI 9019.5 1.00×
MRI + YJIT 7001.5 0.78×
JRuby 3418.7 0.38×
TruffleRuby 5787.0 0.64×

build_nested_query

Rack::Utils.build_nested_query — the inverse of parse_nested_query.

Runtime ns/op vs MRI
go-ruby (pure Go) 1029.4 0.11×
MRI 9012.0 1.00×
MRI + YJIT 7337.5 0.81×
JRuby 3417.9 0.38×
TruffleRuby 7747.8 0.86×

request

Build a Rack::Request from a fixed env Hash and read a fixed set of accessors (request_method, host, port, scheme, ssl?, media_type, xhr?, url, …).

Runtime ns/op vs MRI
go-ruby (pure Go) 2310.6 0.17×
MRI 13675.0 1.00×
MRI + YJIT 8559.5 0.63×
JRuby 6967.3 0.51×
TruffleRuby 10863.8 0.79×

response

Build a Rack::Response, write a body, and finish it into the SPEC [status, headers, body] tuple (content-length filled in).

Runtime ns/op vs MRI
go-ruby (pure Go) 854.8 0.21×
MRI 4083.5 1.00×
MRI + YJIT 2585.5 0.63×
JRuby 2024.5 0.50×
TruffleRuby 4284.7 1.05×

Reproduce

bash benchmarks/run.sh

The script re-verifies byte-identical parity with MRI, then re-runs every available runtime. Knobs: OUTER (timed passes, default 25), WARM (warm-up passes, default 3), and RUBY/JRUBY/TRUFFLERUBY to select binaries.