Roundhouse benchmark results

Run bench · captured 2026-10-04T04:00:03Z · 554873332c2c · 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/seckotlin50,803rust47,870csharp-aot38,979csharp37,409spinel33,437swift17,587crystal16,389jruby16,223typescript10,038truffleruby8,041go7,616elixir7,004python4,587ruby3,408ruby-int2,432rails-truffleruby1,853rails-jruby988rails-int533rails523

/articles/1

1001,00010,000100,000req/secrust70,587kotlin60,646csharp49,694csharp-aot49,472spinel40,674crystal21,629swift19,351jruby18,776elixir14,669typescript11,903truffleruby10,841go7,898python5,278ruby3,475ruby-int2,606rails-truffleruby2,014rails-jruby942rails-int507rails504

/articles/new

1001,00010,000100,0001,000,000req/seckotlin126,096csharp-aot96,684rust94,649csharp88,228spinel55,305crystal44,302elixir40,691jruby30,689swift24,671typescript23,546truffleruby20,126go10,133python7,724ruby4,566rails-truffleruby3,898ruby-int3,463rails-jruby1,497rails732rails-int731

/articles.json

1001,00010,000100,000req/secrust78,808kotlin59,245csharp50,649csharp-aot49,844spinel38,806swift35,114crystal24,390jruby21,988elixir18,623go18,611typescript11,983truffleruby9,169python5,989ruby5,122ruby-int3,666rails-truffleruby3,306rails-jruby1,523rails-int980rails957

/articles/1.json

1001,00010,000100,0001,000,000req/secrust139,649kotlin96,444csharp-aot93,351csharp88,072spinel59,052crystal47,304swift41,782jruby33,265go32,545elixir30,624truffleruby22,180typescript18,573python8,400rails-truffleruby6,895ruby6,413ruby-int4,922rails-jruby2,576rails-int1,395rails1,361

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 above each pair is the lift from the lowerer pipeline.

01,0002,0003,0004,0005,0006,0007,0008,0003,408523/articles6.5×3,475504/articles/16.9×4,566732/articles/new6.2×5,122957/articles.json5.3×6,4131,361/articles/1.json4.7×rubyrails

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

The same comparison on TruffleRuby: emitted truffleruby beats stock rails-truffleruby by 4.3× on /articles, against 6.5× for ruby over rails on CRuby — and the runtime swap alone is worth 3.5× to stock Rails, 2.4× 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,0006,0007,0008,0003,4082,432523533/articles3,4752,606504507/articles/14,5663,463732731/articles/new5,1223,666957980/articles.json6,4134,9221,3611,395/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/GBrust2,749,578crystal795,055csharp-aot547,484spinel512,486swift399,163csharp314,064go229,788kotlin95,387elixir59,553python58,108typescript33,302ruby27,554ruby-int21,964jruby17,117truffleruby10,476rails-int1,593rails1,505rails-truffleruby1,175rails-jruby978

/articles/1

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust3,993,957crystal1,103,959csharp-aot672,839spinel617,912swift425,928csharp381,935go232,297elixir124,163kotlin100,062python66,844typescript39,339ruby27,106ruby-int23,314jruby17,273truffleruby14,005rails-int1,458rails1,380rails-truffleruby1,233rails-jruby832

/articles/new

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust5,179,914crystal2,328,798csharp-aot1,308,003spinel859,037csharp668,902swift534,465elixir340,728go295,114kotlin192,035python97,817typescript77,814ruby34,749ruby-int29,801jruby28,210truffleruby25,805rails-truffleruby2,370rails-int2,085rails1,978rails-jruby1,309

/articles.json

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust4,318,395crystal1,352,626swift759,307csharp-aot678,667spinel614,234go508,474csharp380,486elixir155,225kotlin77,690python75,840typescript40,814ruby38,055ruby-int31,276jruby20,188truffleruby11,687rails-int2,744rails2,533rails-truffleruby1,978rails-jruby1,231

/articles/1.json

1,00010,000100,0001,000,00010,000,000req/sec/GBrust7,607,683crystal2,019,962csharp-aot1,289,470spinel915,941swift900,076go891,673csharp664,415elixir256,327kotlin123,028python106,290typescript63,506ruby46,524ruby-int38,943jruby30,540truffleruby28,248rails-truffleruby4,110rails-int3,518rails3,191rails-jruby2,082

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.

elixir2.7×go2.4×swift2.0×rails1.8×rails-truffleruby1.8×rust1.6×rails-jruby1.5×ruby1.5×crystal1.5×jruby1.4×csharp1.4×python1.3×csharp-aot1.3×typescript1.2×kotlin1.2×spinel1.2×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 MBcrystal23 MBgo37 MBswift47 MBspinel67 MBcsharp-aot75 MBpython80 MBelixir122 MBruby-int129 MBcsharp136 MBruby141 MBtypescript309 MBrails-int406 MBrails436 MBkotlin802 MBtruffleruby804 MBjruby1,115 MBrails-jruby1,267 MBrails-truffleruby1,717 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. Rows are ordered by measured median p50 across endpoints, fastest first.

target/articles/articles/1/articles/new/articles.json/articles/1.json
p50p99p50p99p50p99p50p99p50p99
rust1.32.40.82.70.51.70.72.00.41.0
kotlin1.04.70.84.10.42.40.94.30.52.9
csharp1.45.01.04.30.53.61.14.20.52.9
csharp-aot1.44.41.13.60.52.31.13.40.52.3
spinel1.84.41.54.01.13.31.63.71.03.1
swift3.46.53.06.62.36.01.73.01.52.6
crystal3.84.82.93.51.51.92.73.21.31.7
jruby3.86.13.35.62.04.22.84.91.84.0
go8.020.97.718.03.251.23.28.61.77.9
elixir8.219.84.012.21.44.53.210.01.96.1
typescript6.27.55.26.62.63.45.36.53.34.6
truffleruby7.121.25.513.73.18.06.514.92.87.7
python13.914.512.112.28.38.310.710.87.68.6
ruby18.820.418.420.214.015.912.414.89.712.3
rails-truffleruby31.053.628.250.714.830.617.831.38.618.4
ruby-int26.328.224.426.418.520.217.419.712.815.4
rails-jruby60.189.263.589.038.561.038.260.722.138.5
rails-int116.1191.7121.7358.484.8144.564.377.043.769.5
rails118.3191.3123.0325.485.4144.566.782.444.575.2

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.49% — the timed runs of a given cell agree to a fraction of a percent, so the reported medians aren't masking noise.

The 5 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 3 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
truffleruby/articles7,2468,8548,0418.2%
rails-truffleruby/articles1,6861,8601,8534.5%
csharp-aot/articles38,97939,29035,9464.0%
ruby-int/articles.json3,6173,6663,9013.3%
rails-truffleruby/articles.json3,3063,3933,1503.1%

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/articles47,8701.302.44172,749,578
rust/articles/170,5870.802.71183,993,957
rust/articles/new94,6490.521.73185,179,914
rust/articles.json78,8080.741.96184,318,395
rust/articles/1.json139,6490.381.05187,607,683
crystal/articles16,3893.794.8121795,055
crystal/articles/121,6292.943.51201,103,959
crystal/articles/new44,3021.521.86192,328,798
crystal/articles.json24,3902.723.25181,352,626
crystal/articles/1.json47,3041.311.74232,019,962
go/articles7,6167.9720.9033229,788
go/articles/17,8987.7017.9634232,297
go/articles/new10,1333.2051.2235295,114
go/articles.json18,6113.178.6137508,474
go/articles/1.json32,5451.727.9437891,673
csharp-aot/articles38,9791.434.3872547,484
csharp-aot/articles/149,4721.123.6075672,839
csharp-aot/articles/new96,6840.472.34751,308,003
csharp-aot/articles.json49,8441.123.3775678,667
csharp-aot/articles/1.json93,3510.502.33741,289,470
kotlin/articles50,8031.024.7354595,387
kotlin/articles/160,6460.854.08620100,062
kotlin/articles/new126,0960.372.35672192,035
kotlin/articles.json59,2450.884.3378077,690
kotlin/articles/1.json96,4440.482.92802123,028
swift/articles17,5873.426.5345399,163
swift/articles/119,3513.046.6146425,928
swift/articles/new24,6712.335.9647534,465
swift/articles.json35,1141.713.0547759,307
swift/articles/1.json41,7821.512.5847900,076
csharp/articles37,4091.395.00121314,064
csharp/articles/149,6941.054.30133381,935
csharp/articles/new88,2280.483.62135668,902
csharp/articles.json50,6491.074.17136380,486
csharp/articles/1.json88,0720.502.92135664,415
spinel/articles33,4371.804.3966512,486
spinel/articles/140,6741.464.0567617,912
spinel/articles/new55,3051.073.2965859,037
spinel/articles.json38,8061.593.6764614,234
spinel/articles/1.json59,0520.993.0966915,941
elixir/articles7,0048.2219.7512059,553
elixir/articles/114,6694.0212.18120124,163
elixir/articles/new40,6911.384.46122340,728
elixir/articles.json18,6233.259.99122155,225
elixir/articles/1.json30,6241.916.14122256,327
typescript/articles10,0386.227.4930833,302
typescript/articles/111,9035.256.6030939,339
typescript/articles/new23,5462.603.4430977,814
typescript/articles.json11,9835.266.5130040,814
typescript/articles/1.json18,5733.324.6429963,506
python/articles4,58713.8914.538058,108
python/articles/15,27812.1012.198066,844
python/articles/new7,7248.288.318097,817
python/articles.json5,98910.6710.758075,840
python/articles/1.json8,4007.558.6480106,290
ruby/articles3,40818.8320.3912627,554
ruby/articles/13,47518.4520.2113127,106
ruby/articles/new4,56613.9915.8713434,749
ruby/articles.json5,12212.4514.8213738,055
ruby/articles/1.json6,4139.6912.2814146,524
ruby-int/articles2,43226.3228.1911321,964
ruby-int/articles/12,60624.3726.3811423,314
ruby-int/articles/new3,46318.4820.1911929,801
ruby-int/articles.json3,66617.3919.6712031,276
ruby-int/articles/1.json4,92212.7715.4012938,943
jruby/articles16,2233.816.1397017,117
jruby/articles/118,7763.295.571,11317,273
jruby/articles/new30,6891.994.231,11328,210
jruby/articles.json21,9882.834.941,11520,188
jruby/articles/1.json33,2651.853.951,11530,540
truffleruby/articles8,0417.1121.2278610,476
truffleruby/articles/110,8415.4713.6879214,005
truffleruby/articles/new20,1263.098.0279825,805
truffleruby/articles.json9,1696.5314.9380311,687
truffleruby/articles/1.json22,1802.827.6880428,248
rails/articles523118.31191.303561,505
rails/articles/1504123.03325.443731,380
rails/articles/new73285.43144.523791,978
rails/articles.json95766.7182.393872,533
rails/articles/1.json1,36144.5275.164363,191
rails-int/articles533116.07191.713421,593
rails-int/articles/1507121.71358.413561,458
rails-int/articles/new73184.76144.523592,085
rails-int/articles.json98064.3376.963652,744
rails-int/articles/1.json1,39543.6969.484063,518
rails-jruby/articles98860.0789.191,034978
rails-jruby/articles/194263.5388.981,158832
rails-jruby/articles/new1,49738.4660.991,1711,309
rails-jruby/articles.json1,52338.2460.661,2671,231
rails-jruby/articles/1.json2,57622.1138.511,2672,082
rails-truffleruby/articles1,85330.9853.601,6141,175
rails-truffleruby/articles/12,01428.1750.681,6721,233
rails-truffleruby/articles/new3,89814.8130.571,6842,370
rails-truffleruby/articles.json3,30617.7631.261,7111,978
rails-truffleruby/articles/1.json6,8958.6318.371,7174,110
Environment (captured 2026-10-04T04:00:03Z)

Host

hostnameshowcase.party
OSUbuntu 24.04.5 LTS
kernelLinux showcase.party 6.8.0-139-generic #139-Ubuntu SMP PREEMPT_DYNAMIC Sat Aug 1 03:52:05 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=e53ff17674aa6190
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.09.12+4816 (1d26ef67c) [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

commit554873332c2c9e94485bad1c4a74febc5efc4c57
branchmain
subjectGenerate eager loaders for runtime Relations in scope-free apps (#376)

Conditions at start

load average0.1 / 0.07 / 0.09
uptime06:00:04 up 14 days, 19:15, 5 users, load average: 0.10, 0.07, 0.09

Generated 2026-10-05T05:41:37Z from summary.json.