Roundhouse benchmark results

Run bench · captured 2026-10-26T05:01:14Z · 2b44b3721a0a · 95 cells × 3 runs · 5 endpoints × 19 targets
Artifacts: env.json · per-run.json · skipped.json · summary.json · summary.md
Methodology. AMD Ryzen 5 3600 6-Core Processor, 6c/12t, 3.6 GHz, boost disabled, governor=performance, 63 GB RAM, Ubuntu 24.04.5 LTS. Toolchains pinned via mise. Harness: scripts/bench — workers=1, c=64, 3×20s runs per cell after 20s warmup (rails-jruby 60s, rails-truffleruby 240s, truffleruby 120s — those lanes plateau later); the median of the 3 runs is reported (the run-to-run spread is in the stability section below). Quiet machine throughout. The managed-heap cells run under a fixed heap budget so their RSS is comparable to the other cells' working sets rather than the runtime's default share of host RAM: the JVM cells (jruby, kotlin) via -Xmx512m=-Xms512m, and the .NET cells (csharp, csharp-aot) via the equivalent DOTNET_GCHeapHardLimit (512 MB), and the TruffleRuby cells (truffleruby, rails-truffleruby) via --vm.Xmx — 512 MB for the emitted tree, 1 GB for stock Rails, which runs out of a 512 MB heap under TruffleRuby within a minute of load. Full hardware, toolchain versions, and the exact invocation are in the environment appendix below.

These numbers measure one specific Rails reference app — not arbitrary Rails workloads. See #16 for the fixture-fragility caveats.

rails-jruby is the stock-Rails-on-the-JVM baseline — the same blog, pinned to Rails 8.0 (one minor behind the others' 8.1) on a prerelease AR-JDBC adapter (activerecord-jdbcsqlite3-adapter 80.0.pre1), the only combination that drives ActiveRecord on JRuby 10 today.

rails-truffleruby is the stock-Rails-on-TruffleRuby baseline — the same Rails 8.1 fixture the rails lane serves, since TruffleRuby compiles the sqlite3 C extension and needs no JDBC adapter; truffleruby is the ruby lane's tree, unmodified, under the same interpreter. Both run the native standalone, single process, threads only (no fork).

spinel runs the same Roundhouse-emitted framework as the CRuby ruby cell, but compiled by Spinel behind its thread-per-connection server (a green thread per connection on the runtime's autodetected OS worker count) rather than CRuby + Puma — so the ruby → spinel gap is a runtime swap, not a framework or source change.

1. Throughput across targets

Each endpoint is its own chart. Bars are log-scaled; raw req/sec is shown at the right.

/articles

1001,00010,000100,000req/secrust55,088kotlin49,564csharp-aot39,485csharp37,373crystal16,767swift14,721jruby11,649typescript9,900spinel9,661elixir7,424truffleruby6,913go6,337python4,937ruby2,987ruby-int2,071rails-jruby482rails-truffleruby397rails-int271rails178

/articles/1

1001,00010,000100,000req/secrust71,742kotlin59,964csharp-aot49,961csharp49,724crystal22,315swift17,159elixir15,805jruby12,796typescript11,086spinel10,219truffleruby8,785go6,423python5,525ruby2,994ruby-int2,179rails-truffleruby815rails-jruby395rails269rails-int261

/articles/new

1001,00010,000100,0001,000,000req/seckotlin125,155csharp-aot95,867rust92,025csharp88,228elixir43,486crystal40,777swift22,473typescript22,337jruby21,805truffleruby17,229spinel14,018python7,968go7,662ruby4,285ruby-int3,091rails-truffleruby1,474rails-jruby678rails-int383rails355

/articles.json

1001,00010,000100,000req/secrust77,585kotlin56,781csharp48,863csharp-aot48,237swift32,813crystal24,977elixir19,331jruby15,623go14,001spinel13,617typescript11,491truffleruby7,729python5,603ruby3,542rails-truffleruby2,650ruby-int2,476rails-jruby721rails302rails-int301

/articles/1.json

1001,00010,000100,0001,000,000req/secrust138,913kotlin94,927csharp-aot87,437csharp85,697crystal47,010swift40,579elixir29,696jruby21,585spinel19,094go16,411typescript16,064truffleruby14,802python7,672rails-truffleruby7,218ruby4,309ruby-int3,214rails-jruby940rails-int787rails686

2. Lowerer dividend (ruby vs rails)

Same Ruby interpreter, same YJIT, same Puma — the only variable is whether the framework runtime is Rails or Roundhouse-emitted. The multiplier under each pair is the lift from the lowerer pipeline. Bars are log-scaled, so the multiplier, not bar height, is the ratio.

1001,00010,0002,987178/articles16.7×2,994269/articles/111.1×4,285355/articles/new12.0×3,542302/articles.json11.7×4,309686/articles/1.json6.3×rubyrails

The same comparison on the JVM: emitted jruby beats stock rails-jruby by 24.1× on /articles, against 16.7× for ruby over rails on CRuby — and the runtime swap alone is worth 2.7× to stock Rails, 3.9× to the emit.

The same comparison on TruffleRuby: emitted truffleruby beats stock rails-truffleruby by 17.4× on /articles, against 16.7× for ruby over rails on CRuby — and the runtime swap alone is worth 2.2× to stock Rails, 2.3× to the emit.

3. YJIT contribution

Each pair compares a cell with YJIT (ruby, rails) against its interpreter-only variant (ruby-int, rails-int). The bars show how far YJIT moves each.

01,0002,0003,0004,0005,0002,9872,071178271/articles2,9942,179269261/articles/14,2853,091355383/articles/new3,5422,476302301/articles.json4,3093,214686787/articles/1.jsonrubyruby-intrailsrails-int

4. Cost economics (req/sec per GB of RSS)

Throughput normalized by memory footprint — req/sec divided by RSS in GB. This reorders the raw throughput charts above: targets with small working sets rise and high-RSS targets fall. Bars are log-scaled. How much the metric matters depends on the deployment shape — most on metered or serverless surfaces, least on bare metal with memory headroom.

/articles

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust3,451,527crystal751,899csharp-aot545,874swift330,496csharp309,846go192,964spinel125,851kotlin97,157elixir63,149python61,613typescript31,042ruby21,602ruby-int16,795jruby11,079truffleruby8,783rails-int814rails511rails-jruby451rails-truffleruby252

/articles/1

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust4,311,531crystal938,551csharp-aot665,131csharp391,239swift376,483go187,151spinel138,211elixir133,838kotlin94,614python68,861typescript35,077ruby21,399ruby-int17,109truffleruby11,040jruby9,774rails-int764rails748rails-truffleruby497rails-jruby318

/articles/new

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust5,341,859crystal1,958,853csharp-aot1,271,038csharp689,540swift484,750elixir360,947go222,211spinel196,668kotlin182,618python99,283typescript70,377ruby29,779ruby-int23,695truffleruby21,512jruby16,209rails-int1,101rails963rails-truffleruby886rails-jruby528

/articles.json

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust4,337,491crystal1,165,698swift697,750csharp-aot652,113go386,247csharp375,739spinel189,700elixir154,077kotlin73,117python69,803typescript36,757ruby24,867ruby-int19,250jruby11,442truffleruby9,618rails-truffleruby1,569rails-int854rails811rails-jruby534

/articles/1.json

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust7,693,931crystal2,257,043csharp-aot1,168,729swift862,952csharp665,931go453,419spinel273,736elixir243,568kotlin119,090python95,479typescript52,069ruby29,251ruby-int24,220truffleruby18,412jruby15,804rails-truffleruby4,253rails-int2,093rails1,723rails-jruby696

5. HTML → JSON multiplier

Per-target lift going from /articles (HTML) to /articles.json (JSON). The asymmetry isn't JSON-escape vs HTML-escape — both paths concatenate and escape strings. What JSON skips is the layout chain (csrf_meta_tag, importmap, asset-manifest scan, content_for slots) and the per-row view helpers (link_to, button_to, dom_id, tag.*) that the HTML index template invokes for every record. The chart below shows the multiplier per target.

rails-truffleruby6.7×elixir2.6×swift2.2×go2.2×rails1.7×rails-jruby1.5×crystal1.5×spinel1.4×rust1.4×jruby1.3×csharp1.3×csharp-aot1.2×ruby1.2×typescript1.2×kotlin1.1×python1.1×truffleruby1.1× 1×2×3×4×5×6×

6. Memory footprint (RSS)

Max RSS observed across all endpoints, per target, sorted low to high. The field spans more than two orders of magnitude. The managed-heap cells (the JVM and .NET targets) run under a fixed heap budget rather than an organically-grown working set — see the note below the chart.

rust18 MBcrystal24 MBgo37 MBswift48 MBcsharp-aot77 MBspinel78 MBpython82 MBelixir128 MBcsharp133 MBruby-int135 MBruby150 MBtypescript326 MBrails-int385 MBrails407 MBkotlin816 MBtruffleruby823 MBrails-jruby1,382 MBjruby1,398 MBrails-truffleruby1,737 MB

The JVM cells run under a fixed -Xmx512m heap (the TruffleRuby cells under --vm.Xmx: 512 MB for the emit, 1 GB for stock Rails), so the heap can't grab a host-dependent share of RAM; the rest is metaspace and code-cache, bounded by the app's class count rather than the box. Read the JVM bars as a pinned budget, not an organically-grown working set like the other cells.

The .NET cells are capped the same way — DOTNET_GCHeapHardLimit at 512 MB, the -Xmx analog — so their RSS is a budget the GC collects within, not the host-proportional reservation Server GC takes by default. NativeAOT (csharp-aot) carries no JIT or warmup, so its working set is the leanest of the managed-heap cells.

7. Latency (p50 / p99 at c=64)

Latencies are at c=64 concurrent connections; treat p50 as the median per-connection wait and p99 as the tail. Lanes are ordered by measured median p50 across endpoints, fastest first.

target/articles/articles/1/articles/new/articles.json/articles/1.json
p50p99p50p99p50p99p50p99p50p99
rust1.12.20.82.50.51.80.82.00.41.0
kotlin1.04.90.93.90.42.60.94.20.52.9
csharp1.45.01.04.30.53.51.14.50.53.2
csharp-aot1.44.31.14.10.52.51.23.80.52.8
crystal3.84.52.73.61.52.22.52.91.31.8
swift4.17.53.57.22.66.51.83.31.52.8
elixir7.719.13.612.81.34.43.110.22.06.3
jruby5.37.84.87.32.85.24.06.22.95.1
go9.624.69.521.84.168.84.211.92.925.6
spinel6.411.26.011.24.38.84.68.03.27.0
typescript6.37.45.67.32.83.55.56.53.94.7
truffleruby8.521.16.813.03.58.57.815.04.18.6
python12.913.211.611.88.08.611.412.08.38.9
ruby21.336.621.236.914.729.218.120.015.017.3
ruby-int30.743.229.233.220.623.625.728.719.922.8
rails-truffleruby151.3274.068.7144.638.484.722.343.58.118.0
rails-jruby118.9217.7143.2325.286.6162.979.9156.960.5126.4
rails-int229.6321.8236.4376.3164.6223.6165.7395.379.0351.6
rails270.3596.2232.8408.7176.8238.9166.0388.588.6436.9

All values in milliseconds. Lower is better.

Run-to-run stability

Each cell is timed 3 times and the charts above report the median run. This section reads per-run.json directly to show how far the individual runs strayed from it. Across all 95 cells the median run-to-run coefficient of variation in req/sec is 0.75% — the timed runs of a given cell agree to a fraction of a percent, so the reported medians aren't masking noise.

The 19 cells that vary by more than 3% are concentrated in the lowest-throughput cells, where a small absolute swing is a larger fraction of the rate; in 9 of them the first timed run is the slowest, consistent with residual warmup the fixed 20s warmup doesn't fully absorb:

targetendpointrun 1run 2run 3CV
rails-truffleruby/articles33939776037.4%
rails-int/articles.json22951030134.3%
rails/articles/1.json34168675130.3%
rails-jruby/articles/121045539529.4%
rails-truffleruby/articles/new1,4741,47680125.4%
rails-truffleruby/articles/181547487324.4%
rails/articles.json22630240323.4%
rails-int/articles27127917818.8%
rails-int/articles/1.json78792358418.2%
rails/articles24917817417.1%
rails-jruby/articles/1.json1,04994070516.0%
rails-truffleruby/articles.json2,4182,6503,14311.0%
rails-jruby/articles4144984827.8%
rails-jruby/articles/new6787055927.3%
truffleruby/articles6,1086,9137,2267.0%
rails-int/articles/12832612564.5%
ruby/articles/1.json4,2484,3094,6754.3%
rails/articles/new3863553524.2%
rails/articles/12902632694.2%

req/sec per timed run; CV = standard deviation ÷ mean.

Raw cell data (95 rows)
targetendpointreq/secp50 (ms)p99 (ms)RSS (MB)req/sec/GB
rust/articles55,0881.102.23163,451,527
rust/articles/171,7420.782.51174,311,531
rust/articles/new92,0250.531.80175,341,859
rust/articles.json77,5850.761.97184,337,491
rust/articles/1.json138,9130.381.05187,693,931
crystal/articles16,7673.844.5122751,899
crystal/articles/122,3152.733.5924938,551
crystal/articles/new40,7771.472.15211,958,853
crystal/articles.json24,9772.462.90211,165,698
crystal/articles/1.json47,0101.301.75212,257,043
go/articles6,3379.6024.5933192,964
go/articles/16,4239.5021.8135187,151
go/articles/new7,6624.1268.8435222,211
go/articles.json14,0014.2311.8937386,247
go/articles/1.json16,4112.8825.5637453,419
csharp-aot/articles39,4851.424.3474545,874
csharp-aot/articles/149,9611.124.0876665,131
csharp-aot/articles/new95,8670.472.51771,271,038
csharp-aot/articles.json48,2371.153.8275652,113
csharp-aot/articles/1.json87,4370.532.79761,168,729
kotlin/articles49,5641.054.8652297,157
kotlin/articles/159,9640.863.8864894,614
kotlin/articles/new125,1550.372.59701182,618
kotlin/articles.json56,7810.934.2179573,117
kotlin/articles/1.json94,9270.492.93816119,090
swift/articles14,7214.147.5345330,496
swift/articles/117,1593.497.2446376,483
swift/articles/new22,4732.566.5047484,750
swift/articles.json32,8131.833.3148697,750
swift/articles/1.json40,5791.532.7748862,952
csharp/articles37,3731.395.05123309,846
csharp/articles/149,7241.054.31130391,239
csharp/articles/new88,2280.483.52131689,540
csharp/articles.json48,8631.114.51133375,739
csharp/articles/1.json85,6970.513.23131665,931
spinel/articles9,6616.4011.2378125,851
spinel/articles/110,2196.0211.2475138,211
spinel/articles/new14,0184.338.8472196,668
spinel/articles.json13,6174.558.0173189,700
spinel/articles/1.json19,0943.157.0471273,736
elixir/articles7,4247.7319.0612063,149
elixir/articles/115,8053.6312.75120133,838
elixir/articles/new43,4861.284.36123360,947
elixir/articles.json19,3313.0910.21128154,077
elixir/articles/1.json29,6961.966.29124243,568
typescript/articles9,9006.327.3832631,042
typescript/articles/111,0865.597.2932335,077
typescript/articles/new22,3372.783.5332570,377
typescript/articles.json11,4915.486.4832036,757
typescript/articles/1.json16,0643.864.6731552,069
python/articles4,93712.9413.168261,613
python/articles/15,52511.5711.788268,861
python/articles/new7,9688.028.588299,283
python/articles.json5,60311.4012.008269,803
python/articles/1.json7,6728.308.938295,479
ruby/articles2,98721.2636.6314121,602
ruby/articles/12,99421.2136.8914321,399
ruby/articles/new4,28514.7329.2014729,779
ruby/articles.json3,54218.0819.9814524,867
ruby/articles/1.json4,30915.0017.3315029,251
ruby-int/articles2,07130.7043.2012616,795
ruby-int/articles/12,17929.1733.2213017,109
ruby-int/articles/new3,09120.6123.6213323,695
ruby-int/articles.json2,47625.6928.7013119,250
ruby-int/articles/1.json3,21419.8822.8013524,220
jruby/articles11,6495.337.841,07611,079
jruby/articles/112,7964.857.301,3409,774
jruby/articles/new21,8052.825.231,37716,209
jruby/articles.json15,6233.986.241,39811,442
jruby/articles/1.json21,5852.875.101,39815,804
truffleruby/articles6,9138.5021.148068,783
truffleruby/articles/18,7856.8513.0381411,040
truffleruby/articles/new17,2293.478.5082021,512
truffleruby/articles.json7,7297.7715.058229,618
truffleruby/articles/1.json14,8024.108.6382318,412
rails/articles178270.33596.19358511
rails/articles/1269232.77408.68368748
rails/articles/new355176.85238.94378963
rails/articles.json302166.02388.46381811
rails/articles/1.json68688.64436.904071,723
rails-int/articles271229.59321.83341814
rails-int/articles/1261236.43376.33350764
rails-int/articles/new383164.62223.623561,101
rails-int/articles.json301165.66395.33361854
rails-int/articles/1.json78779.05351.593852,093
rails-jruby/articles482118.94217.711,093451
rails-jruby/articles/1395143.17325.151,273318
rails-jruby/articles/new67886.58162.941,315528
rails-jruby/articles.json72179.90156.941,381534
rails-jruby/articles/1.json94060.49126.401,382696
rails-truffleruby/articles397151.32274.001,611252
rails-truffleruby/articles/181568.66144.571,679497
rails-truffleruby/articles/new1,47438.3984.741,702886
rails-truffleruby/articles.json2,65022.2643.481,7291,569
rails-truffleruby/articles/1.json7,2188.1318.011,7374,253
Environment (captured 2026-10-26T05:01:14Z)

Host

hostnameshowcase.party
OSUbuntu 24.04.5 LTS
kernelLinux showcase.party 6.8.0-147-generic #147-Ubuntu SMP PREEMPT_DYNAMIC Thu Sep 24 11:33:16 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
boardASRock B450 Pro4 R2.0
CPUAMD Ryzen 5 3600 6-Core Processor
topology6 cores / 12 threads
clockgovernor=performance, boost disabled, 3600 MHz max
memory64,228 MB

Toolchains

cargocargo 1.98.1 (797e8a9bc 2026-08-05)
crystalCrystal 1.20.2 [2482c62c1] (2026-05-15)
curlcurl 8.5.0 (x86_64-pc-linux-gnu) libcurl/8.5.0 OpenSSL/3.0.13 zlib/1.3 brotli/1.1.0 zstd/1.5.5 libidn2/2.3.7 libpsl/0.21.2 (+libidn2/2.3.7) libssh/0.10.6/openssl/zlib nghttp2/1.59.0 librtmp/2.3 OpenLDAP/2.6.10
dotnet10.0.301
gogo version go1.26.4 linux/amd64
jrubyjruby 10.1.1.0 (4.0.0) 2026-07-22 aaa95d57e1 OpenJDK 64-Bit Server VM 25.0.3+9-LTS on 25.0.3+9-LTS +indy +jit [x86_64-linux]
mise2026.5.15 linux-x64 (2026-05-23)
nodev26.3.0
python3Python 3.14.6
redis-serverRedis server v=7.0.15 sha=00000000:0 malloc=jemalloc-5.3.0 bits=64 build=d064f9f48c901a92
rubyruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
rustcrustc 1.98.1 (48a229cea 2026-09-01)
shardsShards 0.20.0 [b2b98ca] (2025-12-19)
spinelspinel 2026.10.25+165 (fac2462a0) [gcc 13.3.0 (cc)]
sqlite33.45.1 2024-01-30 16:01:20 e876e51a0ed5c5b3126f52e532044363a014bc594cfefa87ffb5b82257ccalt1 (64-bit)
trufflerubytruffleruby 40.0.0 (2026-09-17), like ruby 4.0.2, Oracle GraalVM Native [x86_64-linux]
uvuv 0.11.22 (x86_64-unknown-linux-musl)
wrkwrk debian/4.1.0-4build2 [epoll] Copyright (C) 2012 Will Glozer

Harness

commandscripts/bench --port 19000 rails rails-int rails-jruby rails-truffleruby ruby ruby-int jruby truffleruby python spinel typescript elixir go csharp csharp-aot swift kotlin crystal rust
workers1
concurrency64
runs3 × 20s after 20s warmup
wrk threads2
endpoints/articles /articles/1 /articles/new /articles.json /articles/1.json
targetsrails, rails-int, rails-jruby, rails-truffleruby, ruby, ruby-int, jruby, truffleruby, python, spinel, typescript, elixir, go, csharp, csharp-aot, swift, kotlin, crystal, rust

Source

commit2b44b3721a0abf37f798a4b9fd8716bda6cf7e2c
branchmain
subjectClear the broadcast log before each job the Sidekiq worker runs

Conditions at start

load average10.91 / 11.18 / 11.1
uptime06:01:15 up 10 days, 17:45, 3 users, load average: 10.91, 11.18, 11.10

Generated 2026-10-26T21:01:02Z from summary.json.