Roundhouse benchmark results — campfire

Run campfire · captured 2026-09-05T17:43:01Z · fccbe6a128d0 · 16 cells × 3 runs · 2 endpoints × 8 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 enabled, governor=schedutil, 63 GB RAM, Ubuntu 24.04.4 LTS. Toolchains pinned via mise. Harness: scripts/bench-campfire — workers=1, c=16, 3×20s runs per cell after 20s warmup; the median of the 3 runs is reported (the run-to-run spread is in the stability section below). Quiet machine throughout. Full hardware, toolchain versions, and the exact invocation are in the environment appendix below.

rails serves campfire's message fragments from Redis (cached: true); rails-nocache renders them. The emitted tree has no fragment cache, so rails-nocache is the lane that compares renderers and rails is the lane that says what a deployment costs. Publish both or publish neither.

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.

The -1p lanes are the diagnostic: one process each, no proxy and no job runner. They exist because a deployed number cannot say WHY it landed where it did — Puma's five threads share a GVL where the binary's workers do not, so these rows separate "their runtime is slower" from "we gave them fewer cores".

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.

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 rubyspinel 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

1101001,000req/secrails345ruby178spinel56rails-1p40spinel-1p36rails-nocache26ruby-1p24rails-nocache-1p5

/rooms/1/messages

1101001,000req/secrails677ruby180rails-1p84spinel57rails-nocache29spinel-1p28ruby-1p23rails-nocache-1p5

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.

0200400600800178345/rooms/10.5×180677/rooms/1/messages0.3×rubyrails

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,000req/sec/GBspinel-1p508spinel350ruby227rails183ruby-1p169rails-1p123rails-nocache-1p17rails-nocache16

/rooms/1/messages

101001,000req/sec/GBspinel-1p362rails360spinel359rails-1p261ruby216ruby-1p160rails-nocache-1p18rails-nocache17

4. Memory footprint (PSS)

Max PSS observed across all endpoints, per target, sorted low to high.

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-1p80 MBruby-1p148 MBspinel164 MBrails-nocache-1p332 MBrails-1p338 MBruby854 MBrails-nocache1,754 MBrails1,925 MB

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
rails40.3152.320.771.7
ruby88.2186.686.7197.2
spinel227.9527.9242.6536.3
rails-1p379.8470.6190.9208.0
rails-nocache604.7930.7642.5934.5
spinel-1p476.8656.6644.5836.2
ruby-1p651.51130.0676.41210.0
rails-nocache-1p1280.01970.01470.01880.0

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-1p327364
spinel327380
ruby-1p7491031
ruby10071318
rails-1p31553553
rails40864524
rails-nocache40995022
rails-nocache-1p3148122667 (3 boots never rendered)

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,000506.0 s0.01012933 / 85 ms0.0631,500/1,500
rails1,000502.3 s0.01056831 / 216 ms0.1741,500/1,500

The driver is one thread reading every socket; its longest single reading pass in this run was 58 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
rails-nocache/rooms/12003,963442,544
rails-nocache/rooms/1/messages2003,698409,027
ruby/rooms/12003,963423,409
ruby/rooms/1/messages2003,698392,267
spinel/rooms/12003,963423,409
spinel/rooms/1/messages2003,698392,267
rails-1p/rooms/12003,963442,544
rails-1p/rooms/1/messages2003,698409,027
rails-nocache-1p/rooms/12003,963442,544
rails-nocache-1p/rooms/1/messages2003,698409,027
ruby-1p/rooms/12003,963423,409
ruby-1p/rooms/1/messages2003,698392,267
spinel-1p/rooms/12003,963423,409
spinel-1p/rooms/1/messages2003,698392,267

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 16 cells the median run-to-run coefficient of variation in req/sec is 0.92% — 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 5 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-nocache/rooms/1/messages22293114.4%
spinel/rooms/15056585.7%
rails-nocache-1p/rooms/15554.3%
rails/rooms/13233453533.8%
rails-nocache/rooms/12528263.8%

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

Raw cell data (16 rows)
targetendpointreq/secp50 (ms)p99 (ms)PSS (MB)req/sec/GB
spinel/rooms/156227.88527.88164350
spinel/rooms/1/messages57242.65536.30163359
spinel-1p/rooms/136476.75656.6474508
spinel-1p/rooms/1/messages28644.49836.1580362
ruby/rooms/117888.15186.61803227
ruby/rooms/1/messages18086.73197.18854216
ruby-1p/rooms/124651.511130.00144169
ruby-1p/rooms/1/messages23676.351210.00148160
rails/rooms/134540.30152.341,924183
rails/rooms/1/messages67720.7171.721,925360
rails-nocache/rooms/126604.72930.701,69316
rails-nocache/rooms/1/messages29642.50934.541,75417
rails-1p/rooms/140379.76470.57338123
rails-1p/rooms/1/messages84190.90208.05329261
rails-nocache-1p/rooms/151280.001970.0033217
rails-nocache-1p/rooms/1/messages51470.001880.0032418
Environment (captured 2026-09-05T17:43:01Z)

Host

hostnameshowcase.party
OSUbuntu 24.04.4 LTS
kernelLinux showcase.party 6.8.0-138-generic #138-Ubuntu SMP PREEMPT_DYNAMIC Fri Jul 31 22:41:49 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=schedutil, boost enabled, 3600 MHz max
memory64,228 MB

Application under test

appcampfire
revision94a48aacb65a
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.

railsjemalloc=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2, resque-pool=pid 490722, 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-nocachejemalloc=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2, resque-pool=pid 502343, thruster=front on :19101 -> puma :20101, workers=campfire's own formula (WEB_CONCURRENCY unset)
rails-nocache-1pjemalloc=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2, workers=1, fragment-cache=0
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

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.0.0 (4.0.0) 2026-04-20 32f988b78c 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 aff3815f8acf [cc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0]
sqlite33.45.1 2024-01-30 16:01:20 e876e51a0ed5c5b3126f52e532044363a014bc594cfefa87ffb5b82257ccalt1 (64-bit)
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/20260905-185806 rails rails-nocache ruby spinel rails-1p rails-nocache-1p ruby-1p spinel-1p
workers1
concurrency16
runs3 × 20s after 20s warmup
wrk threads2
endpoints/rooms/1 /rooms/1/messages
targetsrails, rails-nocache, ruby, spinel, rails-1p, rails-nocache-1p, ruby-1p, spinel-1p

Source

commitfccbe6a128d0315caffd0fab893e8c78b7d1846e
branchmain
subjectThree more jobs build a spinel tree and needed the allocator too

Conditions at start

load average0.59 / 0.96 / 1.73
uptime19:43:01 up 23:26, 4 users, load average: 0.59, 0.96, 1.73

Generated 2026-09-06T00:25:00Z from summary.json.