Roundhouse benchmark results

Run bench · captured 2026-10-31T05:01:40Z · 544377934c32 · 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,369kotlin49,498csharp-aot38,992csharp37,306crystal17,353swift14,634jruby12,282spinel10,005typescript9,939elixir7,372truffleruby6,649go6,335python4,947ruby2,951ruby-int2,082rails-truffleruby1,787rails-jruby996rails-int534rails523

/articles/1

1001,00010,000100,000req/secrust72,640kotlin60,058csharp-aot49,895csharp49,721crystal22,555swift17,075elixir15,738jruby13,500typescript11,221spinel10,224truffleruby8,822go6,442python5,552ruby3,045ruby-int2,150rails-truffleruby2,012rails-jruby926rails-int513rails503

/articles/new

1001,00010,000100,0001,000,000req/seckotlin124,700csharp-aot97,337rust93,388csharp87,948elixir43,663crystal41,697swift22,400typescript22,322jruby21,351truffleruby17,080spinel14,078python7,929go7,664ruby4,313rails-truffleruby3,860ruby-int3,037rails-jruby1,478rails-int741rails728

/articles.json

1001,00010,000100,000req/secrust78,309kotlin56,736csharp49,321csharp-aot48,319swift32,465crystal24,407elixir19,292jruby15,724spinel14,760go14,010typescript11,429truffleruby7,708python5,628ruby3,544rails-truffleruby3,397ruby-int2,525rails-jruby1,532rails-int926rails909

/articles/1.json

1001,00010,000100,0001,000,000req/secrust139,092kotlin94,264csharp-aot90,600csharp84,808crystal46,640swift40,104elixir29,796jruby21,625spinel19,354go16,425typescript15,808truffleruby14,891python7,720rails-truffleruby6,947ruby4,213ruby-int3,146rails-jruby2,502rails-int1,420rails1,385

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,951523/articles5.6×3,045503/articles/16.0×4,313728/articles/new5.9×3,544909/articles.json3.9×4,2131,385/articles/1.json3.0×rubyrails

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

The same comparison on TruffleRuby: emitted truffleruby beats stock rails-truffleruby by 3.7× on /articles, against 5.6× for ruby over rails on CRuby — and the runtime swap alone is worth 3.4× 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,9512,082523534/articles3,0452,150503513/articles/14,3133,037728741/articles/new3,5442,525909926/articles.json4,2133,1461,3851,420/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,467,454crystal673,932csharp-aot543,971swift326,187csharp301,943go194,017spinel133,524kotlin93,202elixir63,353python61,705typescript30,917ruby21,098ruby-int16,523jruby11,777truffleruby8,388rails-int1,556rails1,489rails-truffleruby1,123rails-jruby890

/articles/1

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust4,339,652crystal950,313csharp-aot673,279csharp398,499swift371,080go186,597spinel141,011elixir130,218kotlin92,490python69,175typescript35,616ruby20,581ruby-int17,059truffleruby10,996jruby10,777rails-int1,439rails1,383rails-truffleruby1,224rails-jruby767

/articles/new

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust5,453,582crystal1,969,502csharp-aot1,291,115csharp695,400swift481,241elixir354,247go221,851spinel201,038kotlin177,888python98,780typescript71,136ruby28,732ruby-int23,738truffleruby21,170jruby16,617rails-truffleruby2,332rails-int2,059rails1,974rails-jruby1,187

/articles.json

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust4,395,776crystal1,154,077swift692,764csharp-aot644,740csharp388,771go386,434spinel209,387elixir154,312kotlin70,818python70,111typescript36,765ruby23,901ruby-int19,806jruby12,254truffleruby9,526rails-int2,564rails2,457rails-truffleruby2,020rails-jruby1,207

/articles/1.json

1,00010,000100,0001,000,00010,000,000req/sec/GBrust7,744,725crystal2,220,983csharp-aot1,205,547swift846,751csharp672,212go454,873spinel282,463elixir239,304kotlin114,489python96,128typescript51,405ruby27,886ruby-int24,275truffleruby18,348jruby16,614rails-truffleruby4,113rails-int3,466rails3,345rails-jruby1,971

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.6×swift2.2×go2.2×rails-truffleruby1.9×rails1.7×rails-jruby1.5×spinel1.5×rust1.4×crystal1.4×csharp1.3×jruby1.3×csharp-aot1.2×ruby1.2×truffleruby1.2×typescript1.1×kotlin1.1×python1.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 MBcrystal26 MBgo37 MBswift48 MBspinel76 MBcsharp-aot77 MBpython82 MBelixir128 MBcsharp129 MBruby-int132 MBruby154 MBtypescript329 MBrails-int419 MBrails424 MBtruffleruby831 MBkotlin843 MBrails-jruby1,299 MBjruby1,332 MBrails-truffleruby1,729 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.71.90.41.0
kotlin1.04.70.93.80.42.50.94.10.53.0
csharp1.45.01.04.20.53.61.14.10.52.8
csharp-aot1.44.21.13.70.52.51.23.70.52.5
crystal3.64.02.73.61.42.12.53.41.31.8
swift4.27.63.57.22.66.51.83.41.52.8
elixir7.819.03.612.61.34.53.110.32.06.2
jruby5.17.54.67.02.85.24.06.22.95.1
go9.624.59.521.74.271.24.211.92.925.3
spinel6.210.86.011.24.28.64.27.53.17.1
typescript6.37.55.66.92.83.85.46.83.95.2
truffleruby8.725.56.813.33.58.67.815.24.18.7
python12.913.211.511.68.08.611.411.48.29.1
rails-truffleruby31.363.028.348.714.930.917.132.08.518.4
ruby21.537.320.924.014.717.718.120.915.317.7
ruby-int30.642.929.643.020.932.625.427.820.322.9
rails-jruby59.888.264.390.539.362.638.459.922.739.9
rails-int115.9189.6120.7298.884.0143.969.491.642.574.3
rails118.1197.0123.2371.185.5142.271.187.543.774.8

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

The 3 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 2 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/articles1,4471,7871,87410.8%
truffleruby/articles6,1846,6497,3156.9%
rails-int/articles.json9269199873.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,3691.102.24163,467,454
rust/articles/172,6400.772.48174,339,652
rust/articles/new93,3880.521.77175,453,582
rust/articles.json78,3090.751.94184,395,776
rust/articles/1.json139,0920.381.05187,744,725
crystal/articles17,3533.564.0526673,932
crystal/articles/122,5552.703.5624950,313
crystal/articles/new41,6971.432.11211,969,502
crystal/articles.json24,4072.503.39211,154,077
crystal/articles/1.json46,6401.321.76212,220,983
go/articles6,3359.6124.5433194,017
go/articles/16,4429.4721.7435186,597
go/articles/new7,6644.1671.2035221,851
go/articles.json14,0104.2311.9437386,434
go/articles/1.json16,4252.9025.3036454,873
csharp-aot/articles38,9921.434.2273543,971
csharp-aot/articles/149,8951.113.7375673,279
csharp-aot/articles/new97,3370.472.47771,291,115
csharp-aot/articles.json48,3191.153.7076644,740
csharp-aot/articles/1.json90,6000.532.50761,205,547
kotlin/articles49,4981.054.7054393,202
kotlin/articles/160,0580.863.8566492,490
kotlin/articles/new124,7000.382.49717177,888
kotlin/articles.json56,7360.934.0782070,818
kotlin/articles/1.json94,2640.503.00843114,489
swift/articles14,6344.167.5945326,187
swift/articles/117,0753.507.2247371,080
swift/articles/new22,4002.556.4847481,241
swift/articles.json32,4651.853.3647692,764
swift/articles/1.json40,1041.542.8248846,751
csharp/articles37,3061.385.05126301,943
csharp/articles/149,7211.054.24127398,499
csharp/articles/new87,9480.483.62129695,400
csharp/articles.json49,3211.094.10129388,771
csharp/articles/1.json84,8080.542.80129672,212
spinel/articles10,0056.1910.7976133,524
spinel/articles/110,2246.0011.1874141,011
spinel/articles/new14,0784.238.5971201,038
spinel/articles.json14,7604.207.5272209,387
spinel/articles/1.json19,3543.137.1170282,463
elixir/articles7,3727.7719.0511963,353
elixir/articles/115,7383.6512.62123130,218
elixir/articles/new43,6631.274.49126354,247
elixir/articles.json19,2923.1010.27128154,312
elixir/articles/1.json29,7961.956.25127239,304
typescript/articles9,9396.267.5332930,917
typescript/articles/111,2215.566.9032235,616
typescript/articles/new22,3222.763.7732171,136
typescript/articles.json11,4295.436.7831836,765
typescript/articles/1.json15,8083.865.2231451,405
python/articles4,94712.8913.158261,705
python/articles/15,55211.5211.608269,175
python/articles/new7,9298.008.638298,780
python/articles.json5,62811.3511.428270,111
python/articles/1.json7,7208.249.088296,128
ruby/articles2,95121.5037.2814321,098
ruby/articles/13,04520.9024.0115120,581
ruby/articles/new4,31314.6917.7115328,732
ruby/articles.json3,54418.0820.9115123,901
ruby/articles/1.json4,21315.3217.7115427,886
ruby-int/articles2,08230.5642.9412916,523
ruby-int/articles/12,15029.5743.0112917,059
ruby-int/articles/new3,03720.9232.6013123,738
ruby-int/articles.json2,52525.3527.8213019,806
ruby-int/articles/1.json3,14620.3222.9313224,275
jruby/articles12,2825.077.491,06711,777
jruby/articles/113,5004.597.051,28210,777
jruby/articles/new21,3512.795.251,31516,617
jruby/articles.json15,7243.956.251,31312,254
jruby/articles/1.json21,6252.865.061,33216,614
truffleruby/articles6,6498.7325.478118,388
truffleruby/articles/18,8226.7913.2882110,996
truffleruby/articles/new17,0803.518.5982621,170
truffleruby/articles.json7,7087.7915.208289,526
truffleruby/articles/1.json14,8914.098.6883118,348
rails/articles523118.13196.973601,489
rails/articles/1503123.17371.103721,383
rails/articles/new72885.47142.163771,974
rails/articles.json90971.1187.473782,457
rails/articles/1.json1,38543.7374.764243,345
rails-int/articles534115.87189.623521,556
rails-int/articles/1513120.71298.823651,439
rails-int/articles/new74184.00143.903682,059
rails-int/articles.json92669.4591.583692,564
rails-int/articles/1.json1,42042.5174.274193,466
rails-jruby/articles99659.8488.191,145890
rails-jruby/articles/192664.3290.471,236767
rails-jruby/articles/new1,47839.2662.641,2741,187
rails-jruby/articles.json1,53238.4159.891,2991,207
rails-jruby/articles/1.json2,50222.7039.901,2991,971
rails-truffleruby/articles1,78731.3463.051,6281,123
rails-truffleruby/articles/12,01228.2848.741,6831,224
rails-truffleruby/articles/new3,86014.8630.891,6942,332
rails-truffleruby/articles.json3,39717.0832.051,7212,020
rails-truffleruby/articles/1.json6,9478.5018.451,7294,113
Environment (captured 2026-10-31T05:01:40Z)

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+1469 (b513f72fb) [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

commit544377934c32b5623ce820c8bd86f4d47aa89cf7
branchmain
subjectMerge pull request #1598 from eddygarcas/own-url-method-20261031

Conditions at start

load average2.99 / 1.45 / 1.81
uptime06:01:41 up 15 days, 17:46, 1 user, load average: 2.99, 1.45, 1.81

Generated 2026-10-31T12:55:19Z from summary.json.