Performance¶
go-ruby-rexml/rexml is the pure-Go library that
rbgo binds for Ruby's rexml — a
tree model, an XML parser, the two serialisers (the default compact formatter
and Formatters::Pretty), and a widely-used subset of XPath, all with no cgo.
This page records a comparative, library-level benchmark of that module
against the reference Ruby runtimes, part of the ecosystem-wide per-module parity
suite.
Library-level benchmark (Go API vs runtimes) — 2026-07-03¶
This measures the pure-Go library directly, through its Go API, isolated from
any interpreter dispatch, answering the parity question head-on: is the pure-Go
implementation as fast as the reference runtime's own rexml? The same
workload, same fixed input document, same iteration counts run through the Go
library and through each reference runtime's rexml stdlib; every operation's
output was checked byte-identical to MRI before any timing (see Reproduce
below).
- Host: Apple M4 Max (
Mac16,5, arm64), macOS 26.5.1 — date 2026-07-03. All runtimes measured on the host, no VM. - Runtimes: Go 1.26.4 · MRI
ruby 4.0.5 +PRISM(the oracle) · MRI + YJIT · JRuby 10.1.0.0 (OpenJDK 25) · TruffleRuby 34.0.1 (GraalVM CE Native). - Fixed input (reproducible, identical bytes in both drivers): a small
<catalog>document — an XML declaration, two nested<book>elements each withid/currencyattributes and<title>/<author>/<price>text children, ameta:namespace (xmlns:meta+meta:rev), and the whitespace between elements. - Operations:
Document.new(parse), a descendant XPath query (//title), an absolute XPath query (/catalog/book), the default compact serialiser (Document#to_s), andFormatters::Prettyat indent 2. - XPath scope: the library implements the widely-used REXML subset —
child/descendantsteps,[n]positional,[@attr]/[@attr='v']predicates,@attrandtext(). The two queries benchmarked (//title,/catalog/book) sit inside that subset; axes such asancestor/following-siblingand functions likecount()/position()are out of scope and are not benchmarked. - 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.
parse¶
| Runtime | ns/op | vs MRI |
|---|---|---|
| go-ruby (pure Go) | 1883.4 | 0.02× |
| MRI | 95424.0 | 1.00× |
| MRI + YJIT | 47020.0 | 0.49× |
| JRuby | 155751.1 | 1.63× |
| TruffleRuby | 73867.1 | 0.77× |
xpath-descendant¶
| Runtime | ns/op | vs MRI |
|---|---|---|
| go-ruby (pure Go) | 303.4 | 0.01× |
| MRI | 47122.0 | 1.00× |
| MRI + YJIT | 29298.0 | 0.62× |
| JRuby | 49504.4 | 1.05× |
| TruffleRuby | 21546.3 | 0.46× |
xpath-absolute¶
| Runtime | ns/op | vs MRI |
|---|---|---|
| go-ruby (pure Go) | 157.0 | 0.01× |
| MRI | 18170.5 | 1.00× |
| MRI + YJIT | 13004.5 | 0.72× |
| JRuby | 19157.4 | 1.05× |
| TruffleRuby | 14717.8 | 0.81× |
serialize¶
| Runtime | ns/op | vs MRI |
|---|---|---|
| go-ruby (pure Go) | 771.1 | 0.05× |
| MRI | 14249.0 | 1.00× |
| MRI + YJIT | 7325.0 | 0.51× |
| JRuby | 11631.8 | 0.82× |
| TruffleRuby | 11302.4 | 0.79× |
pretty¶
| Runtime | ns/op | vs MRI |
|---|---|---|
| go-ruby (pure Go) | 1053.9 | 0.06× |
| MRI | 17031.0 | 1.00× |
| MRI + YJIT | 11192.0 | 0.66× |
| JRuby | 8844.0 | 0.52× |
| TruffleRuby | 17223.5 | 1.01× |
REXML is a pure-Ruby XML library — parser, XPath engine, and serialisers are
all interpreted Ruby — so a compiled pure-Go implementation dominates every
operation. Parsing is ~51× faster than MRI (0.02×): MRI's Document.new
tokenises and builds the tree in Ruby, while the Go library scans bytes directly.
The XPath queries are the widest gaps — ~100–150× faster (both 0.01×) —
because REXML's XPath compiles and walks an expression tree in Ruby per call,
whereas the Go subset resolves the path against the node set directly.
Serialisation is ~18× faster (0.05×) and the Pretty formatter ~16×
(0.06×). YJIT roughly halves MRI on parse and serialise (0.49×–0.66×) but cannot
close an order-of-magnitude interpreter gap. JRuby's warm-up shows on parse,
where it lands slower than cold MRI (1.63×) inside this fixed pass budget.
Reproduce
The harness is committed under
benchmarks/:
a self-contained Go driver (go/, pins the published library via go.mod
pseudo-version — no replace), the equivalent ruby/rexml.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. Both
drivers accept a verify argument ((cd benchmarks/go && go run . verify)
vs ruby benchmarks/ruby/rexml.rb verify) that prints each operation's
canonical output — the parsed root name, the //book and //title /
/catalog/book match counts and title texts, and the full to_s and
Pretty(2) serialisations; the two were confirmed byte-identical before
timing.
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 JRuby on parse, a cold-JIT figure, not
steady-state. Sub-microsecond rows (the two XPath queries) 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 rexml stdlib doing
the equivalent work.