Roundhouse benchmark results — campfire

Run campfire · captured 2026-09-26T10:30:51Z · e4578395c2c1 · 18 cells × 3 runs · 2 endpoints × 9 targets
Artifacts: env.json · per-run.json · summary.json · summary.md · parity.txt
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-campfire — workers=1, c=16, 3×20s runs per cell after 20s warmup (jruby 60s, truffleruby 120s, rails-truffleruby 240s — 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 cell via -Xmx=-Xms (jruby 1g), and the TruffleRuby cells via --vm.Xmx (truffleruby 1g, rails-truffleruby 2g). Full hardware, toolchain versions, and the exact invocation are in the environment appendix below.

rails and the emit lanes BOTH serve campfire's message fragments from a cache — Rails from Redis (cached: true), the emitted tree from its own process-local store — so the deployed lanes compare like with like. Until 2026-09-07 the emit had no fragment cache and this page carried a rails-nocache column so the comparison was renderer against renderer rather than emit against Redis; that column is now a diagnostic, requested by name, and is not published here.

The deployed lanes are what each project would run on this box. rails is once-campfire AS ITS OWN DOCKERFILE RUNS IT: Thruster in front of Puma, (processor_count * 0.666).ceil workers x 5 threads from its own config/puma.rb, jemalloc preloaded, resque-pool alongside. ruby is the CRuby emit in the same Puma shape with the same allocator; spinel is the binary with its workers autodetected.

ruby IS A MATCHED-SHAPE RUNTIME COMPARISON, NOT A DEPLOYMENT CLAIM. It runs Rails' worker count so the number is not a core count, but the emitted tree's Action Cable registry and its job queue are both in-process: at more than one worker a broadcast reaches only the subscribers sharing a process with the poster, so campfire could not actually run this shape. Its deployable CRuby shape today is one worker, which is the ruby-1p row. Cross-worker pubsub would go behind the existing Broadcasts.set_transport seam and is not implemented. spinel needs none of this: one process, green threads, one shared registry — which is why it is the emit lane the WebSocket tier can measure at its full worker count.

The -1p lanes are the diagnostic, and what they pin is ONE CPU rather than one process — CRuby needs a process per core because of the GVL, while the binary is a single process either way and varies its OS worker count instead (SPINEL_WORKERS=1). They exist because a deployed number cannot say WHY it landed where it did, so these rows separate "their runtime is slower" from "we gave them fewer cores". Read them per lane-family: ruby and spinel differ from their -1p twins in worker count and NOTHING else, but rails-1p also sheds Thruster and resque-pool, so its delta is not attributable to cores alone.

Known omissions, stated rather than discovered: the WebSocket phase's rails lane runs Puma with campfire's worker count and its allocator but WITHOUT Thruster and resque-pool, and no lane terminates TLS.

MEMORY IS THE SERVER PROCESS TREE, and the rails lanes keep part of themselves outside it. Their fragment cache, their Action Cable adapter and Resque's queue all live in a system Redis (~16 MB RSS here) that is nobody's descendant and so is in no lane's number — while the emit lanes hold their equivalent inside the measured process. Throughput is unaffected: the rails lanes pay for every Redis round trip in their req/s. Counted for rails: Thruster, the Puma master and its workers, resque-pool and its workers.

The CRuby fragment store is PER PROCESS, so the ruby lane holds one cache per worker where spinel holds one for the whole binary.

jruby and truffleruby are the SAME emitted tree under another Ruby — jruby re-emitted with --target jruby (identical app and framework; SQLite over JDBC instead of the C extension), truffleruby the ruby tree unmodified with a bundle TruffleRuby compiled itself. Both are ONE process, because neither can fork: jruby with a Puma thread per hardware thread (no GVL — measured on this box, 12 threads beat 40 by 19%), truffleruby in the ruby-1p shape of one process × 5 threads (it keeps a GIL for Ruby code, and 4 threads beat 40 by 56%). That makes jruby the one multi-core CRuby-family row campfire could actually deploy: the JVM has no GVL, and one process means one Action Cable registry, one fragment cache and one job queue shared by every thread — the exact property the eight-worker ruby row lacks. Heaps are pinned at 1 GB (512 MB, the blog's budget, holds the tree but costs it 24% to GC pressure).

truffleruby reads the other way from the blog page, and the cause is the GEM, not the runtime. TruffleRuby has no GVL for Ruby code; it gives a GLOBAL C-EXTENSION LOCK to any extension that has not declared itself thread-safe, and sqlite3 2.9.3 declares neither rb_ext_ractor_safe() nor rb_ext_thread_safe() — so every row every thread steps queues behind every other thread's. This lane runs the DEFAULT, locked, because that is what bundle install gives a deployer; --cexts-lock=false, TruffleRuby's own documented benchmarking advice, is worth 2.5× on this page (432 → 1,078 req/s at 5 threads, 393 → 977 at 12, measured 2026-09-22), which puts the emit at 2.3× the CRuby one-process row rather than 0.9×. What the flag does NOT remove is the per-call cost: single-threaded, a row stepped through the gem is 2.4 µs here against CRuby's 0.83, and the lock is only ~3% of that. Read this row against ruby-1p, the same one-process shape on CRuby.

rails-truffleruby is once-campfire ITSELF under TruffleRuby: the same oracle tree, rows, production config and Redis fragment cache, with a second bundle whose C extensions TruffleRuby compiled. It is ONE Puma process × 5 threads — TruffleRuby has no fork, so neither campfire's worker formula nor resque-pool can run, and Thruster is not in front — which makes it a runtime swap on the one-process rails-1p row, not on rails. Heap pinned at 2 GB, twice the blog's stock-Rails budget, for twice the working set. There is no rails-jruby: the AR-JDBC adapter drives Rails 8.0 only and campfire tracks rails/rails main, a gap recorded here rather than shimmed with a hand-pinned app.

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.

/rooms/1

101001,00010,000req/secspinel2,532ruby2,357jruby1,729spinel-1p614ruby-1p468truffleruby420rails310rails-truffleruby115rails-1p65

/rooms/1/messages

101001,00010,000req/secruby4,671spinel4,432jruby3,550spinel-1p1,170ruby-1p984truffleruby795rails620rails-truffleruby156rails-1p135

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,0002,357310/rooms/17.6×4,671620/rooms/1/messages7.5×rubyrails

The same comparison on TruffleRuby: emitted truffleruby beats stock rails-truffleruby by 3.7× on /rooms/1, against 7.2× for ruby-1p over rails-1p on CRuby — and the runtime swap alone is worth 1.8× to stock Rails, 0.9× to the emit.

Against the deployed shapes on /rooms/1: one jruby process is 0.73× the eight-worker ruby row and 5.6× the deployed rails row — and, unlike ruby, it is a shape campfire could run (one registry, one cache, one queue).

3. 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.

/rooms/1

101001,00010,000100,000req/sec/GBspinel18,574spinel-1p5,085ruby2,614ruby-1p2,261jruby1,097truffleruby311rails-1p194rails186rails-truffleruby40

/rooms/1/messages

101001,00010,000100,000req/sec/GBspinel37,622spinel-1p11,510ruby4,463ruby-1p4,066jruby2,251truffleruby586rails-1p408rails356rails-truffleruby52

4. Memory footprint (PSS)

Max PSS 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.

PSS, not a sum of RSS: adding RSS across a process tree counts every shared page once per process, which reads high for a clustered lane and changes nothing for a single-process one. PSS divides each shared page among its sharers.

spinel-1p123 MBspinel139 MBruby-1p247 MBrails-1p342 MBruby1,071 MBtruffleruby1,388 MBjruby1,614 MBrails1,781 MBrails-truffleruby3,040 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.

5. Latency (p50 / p99 at c=16)

Latencies are at c=16 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/rooms/1/rooms/1/messages
p50p99p50p99
spinel5.512.53.212.4
ruby6.323.83.110.4
jruby8.221.94.19.6
spinel-1p25.529.013.815.0
ruby-1p33.744.516.122.2
truffleruby36.758.119.727.9
rails45.9169.822.771.9
rails-truffleruby132.1192.299.2132.8
rails-1p238.8304.9118.1146.2

All values in milliseconds. Lower is better.

6. Cold start

Time from exec to a server that answers, and to a signed-in /rooms/1 that comes back 200. Median of 3 boots per lane, all on the shape named in the row. The room page is the number that matters: it contains the first render, and on a fragment-cached lane a restart keeps the Redis its own Procfile runs in a separate process, so this is a warm-cache restart rather than a cold deployment.

targetanswers (ms)room page (ms)
spinel-1p328362
spinel328362
ruby-1p10971382
ruby13261641
rails-1p34243878
rails46725166
jruby53046326
truffleruby75088119
rails-truffleruby81518983

Milliseconds. Lower is better. A deployed Rails lane boots a proxy, a Puma master and one worker per (processor_count × 0.666).ceil; the binary boots one process.

7. WebSocket connections

Sockets held open, subscribed to a room, then idled and driven: each lane pinned to the same cores, one client, exact delivered/expected frame counts. The idle column is the cost of holding connections that are doing nothing, which is what a chat deployment does most of the time. Read the fan-out latency against the per-room column — one message costs one write per subscriber in its room, so a room of 200 and a room of 50 are different workloads at the same socket count.

targetsocketsper roomconnect stormidle (cores)PSS (MB)fan-out p50 / p99delivering (cores)frames
spinel1,000500.5 s0.0062595 / 8 ms—100/1,480 short
ruby1,000501.0 s0.00522117 / 163 ms0.0281,500/1,500
rails1,000502.5 s0.01060023 / 171 ms0.1261,500/1,500

The driver is one thread reading every socket; its longest single reading pass in this run was 56 ms. A fan-out p99 near that number is the client's backlog rather than the server's fan-out.

Memory is PSS where the lane is multi-process, RSS otherwise. Lower is better in every column except frames, which must be exact.

8. What each lane rendered

One authenticated GET per lane per endpoint, captured before any load. Equal tag counts are what makes the throughput numbers above a comparison rather than two measurements.

laneendpointstatustagsbytes
rails/rooms/12003,963442,544
rails/rooms/1/messages2003,698409,027
ruby/rooms/12003,963423,465
ruby/rooms/1/messages2003,698392,267
spinel/rooms/12003,963423,465
spinel/rooms/1/messages2003,698392,267
rails-1p/rooms/12003,963442,544
rails-1p/rooms/1/messages2003,698409,027
ruby-1p/rooms/12003,963423,465
ruby-1p/rooms/1/messages2003,698392,267
spinel-1p/rooms/12003,963423,465
spinel-1p/rooms/1/messages2003,698392,267
jruby/rooms/12003,963423,465
jruby/rooms/1/messages2003,698392,267
truffleruby/rooms/12003,963423,465
truffleruby/rooms/1/messages2003,698392,267
rails-truffleruby/rooms/12003,963442,544
rails-truffleruby/rooms/1/messages2003,698409,027

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 18 cells the median run-to-run coefficient of variation in req/sec is 0.64% — the timed runs of a given cell agree to a fraction of a percent, so the reported medians aren't masking noise.

The 2 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/rooms/19011511611.1%
rails/rooms/12843103144.4%

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

Raw cell data (18 rows)
targetendpointreq/secp50 (ms)p99 (ms)PSS (MB)req/sec/GB
spinel/rooms/12,5325.4612.4613918,574
spinel/rooms/1/messages4,4323.1812.4512037,622
spinel-1p/rooms/161425.4728.961235,085
spinel-1p/rooms/1/messages1,17013.7514.9710411,510
ruby/rooms/12,3576.2823.789232,614
ruby/rooms/1/messages4,6713.1210.371,0714,463
ruby-1p/rooms/146833.6944.472122,261
ruby-1p/rooms/1/messages98416.1422.152474,066
jruby/rooms/11,7298.1621.861,6131,097
jruby/rooms/1/messages3,5504.079.591,6142,251
truffleruby/rooms/142036.7158.141,382311
truffleruby/rooms/1/messages79519.6627.911,388586
rails/rooms/131045.94169.771,702186
rails/rooms/1/messages62022.6671.871,781356
rails-1p/rooms/165238.83304.93342194
rails-1p/rooms/1/messages135118.14146.18339408
rails-truffleruby/rooms/1115132.06192.152,89640
rails-truffleruby/rooms/1/messages15699.16132.803,04052
Environment (captured 2026-09-26T10:30:51Z)

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

Application under test

appcampfire
revision91d294f4a09f
Rails8.2.0.alpha (1a02651ac37f)
Ruby4.0.5
servedproduction
seed50 users, 5 rooms, 100 messages/room
app server (rails lanes)puma 7.2.1
app server (emit lanes)puma 8.0.1

What each lane actually ran

Recorded at boot, not copied from intent: a lane that asked for jemalloc and did not get one says so here.

jrubyruntime=jruby, heap=1g, workers=0 (one process, no fork), threads=12
railsjemalloc=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2, resque-pool=pid 2622928, thruster=front on :19100 -> puma :20100, workers=campfire's own formula (WEB_CONCURRENCY unset)
rails-1pjemalloc=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2, workers=1, fragment-cache=1
rails-trufflerubyruntime=truffleruby, bundler=4.0.6, own lockfile at /home/rubys/roundhouse/build/campfire-oracle-bundle-truffleruby/Gemfile.lock, jemalloc=n/a (managed heap, --vm.Xmx2g), heap=2g, workers=0 (one process, no fork), fragment-cache=1
rubyjemalloc=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2, workers=8
ruby-1pjemalloc=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2, workers=1
spinelworkers=autodetected
spinel-1pworkers=1
trufflerubyruntime=truffleruby, heap=1g, workers=0 (one process, no fork), threads=5, cexts-lock=1

Toolchains

cargocargo 1.95.0 (f2d3ce0bd 2026-03-21)
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.95.0 (59807616e 2026-04-14)
shardsShards 0.20.0 [b2b98ca] (2025-12-19)
spinelspinel 2026.09.12+1042 (24b846bb) [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-campfire --app /home/rubys/once-campfire --port 19100 --out bench/results/campfire/20260926-111200 rails ruby spinel rails-1p ruby-1p spinel-1p jruby truffleruby rails-truffleruby
workers1
concurrency16
runs3 × 20s after 20s warmup
wrk threads2
endpoints/rooms/1 /rooms/1/messages
targetsrails, ruby, spinel, rails-1p, ruby-1p, spinel-1p, jruby, truffleruby, rails-truffleruby

Source

commite4578395c2c1afa87600d5ea26e18720d348b26b
branchmain
subjectFilter conditions and view symbol-to-proc predicates are blank-grounded

Conditions at start

load average0.66 / 3.64 / 5.24
uptime12:30:51 up 7 days, 1:46, 4 users, load average: 0.66, 3.64, 5.24

Generated 2026-09-26T17:25:20Z from summary.json.