Roundhouse benchmark results — campfire

Run campfire · captured 2026-10-31T12:23:57Z · 544377934c32 · 48 cells × 3 runs · 8 endpoints × 6 targets
Artifacts: env.json · per-run.json · skipped.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; 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 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 — the ruby-1p row, read it against tobi for the deployable comparison; one process serving every core (Falcon on Ractors, the shape spinel already has) is the planned replacement. 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.

ruby-falcon is the SAME emitted tree and config.ru as ruby, served by Falcon (the emit's falcon.rb: forked workers, the same count and allocator, cache hits answered ahead of Falcon's Rack adapter) instead of Puma: the server tobi runs. It isolates what the app server costs. Falcon is added to the emit's bundle by this harness, at the version tobi's lock pins; the emitted Gemfile names only Puma, and the WebSocket phase still runs Puma.

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. ruby-1p is ALSO the emit's deployable CRuby shape: one Puma worker, so one cable registry and one job queue, the shape the WebSocket tier runs it in.

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.

tobi is tobi/campfire-once-ruby-ractor: campfire ported BY HAND to plain Ruby, no Rails, served by Falcon from worker Ractors in one process, with in-process job and cable Ractors. It is the measured answer to "how fast can careful Ruby serve this app", and the target for the emitted ruby lane. It reads the same seeded database. Its deliberate differences from Rails (header-only CSRF, so its pages carry no tokens; part-hashed ETags; fewer cookie writes) are listed in its README.

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

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.

Incomplete run. 6 of 6 requested targets produced numbers:

Every chart and table below is drawn from the targets that were measured; a target that failed to build is absent, not slow. Where a lane reached the run and then produced no numbers its cells are shown as no data, not as zero — an unmeasured cell means wrk found nothing serving, or found a server answering with errors (a cell with any non-2xx response is not a measurement of the endpoint — an error page is cheap to serve), which is a different claim from a runtime that was measured and is slow. Such lanes are left out of the derived charts (multipliers, RSS ranking) for the same reason.

1. Throughput across targets

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

/rooms/1

1,00010,000100,000req/secruby-falcon11,143ruby10,726tobi7,367spinel4,714ruby-1p2,522rails1,865

/rooms/1/messages?before=50

1,00010,000100,000req/secruby-falcon12,265ruby11,647tobi11,438spinel5,334ruby-1p2,736rails1,935

/users/me/sidebar

1,00010,000100,000req/secruby17,188ruby-falcon16,966tobi12,218spinel8,464ruby-1p3,684rails3,156

/searches?q=coffee

1,00010,000100,000req/secruby11,474ruby-falcon10,296tobi7,096spinel5,362ruby-1p2,719rails1,994

/users/:avatar_token/avatar

1,00010,000100,000req/sectobi89,261ruby-falcon71,798ruby47,287spinel47,272rails19,409ruby-1p12,309

a stylesheet (/assets/*.css)

1,00010,000100,000req/secruby-falcon89,377tobi83,106ruby52,073spinel37,719rails34,509ruby-1p13,256

/up

1,00010,000100,000req/sectobi67,746ruby-falcon35,263ruby30,453spinel25,951ruby-1p7,475rails4,677

POST /rooms/2/messages

1001,00010,000req/sectobi1,812ruby-falcon1,155ruby968ruby-1p347rails298spinelno data — server not answering

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,000100,00010,7261,865room5.7×11,6471,935messages6.0×17,1883,156sidebar5.4×11,4741,994search5.8×47,28719,409avatar2.4×52,07334,509css1.5×30,4534,677/up6.5×968298POST3.2×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

1001,00010,000100,000req/sec/GBspinel43,990tobi42,166ruby-falcon22,017ruby-1p13,188ruby12,079rails1,341

/rooms/1/messages?before=50

1001,00010,000100,000req/sec/GBtobi77,129spinel49,540ruby-falcon23,168ruby-1p14,278ruby12,825rails1,310

/users/me/sidebar

1,00010,000100,000req/sec/GBtobi90,013spinel85,962ruby-falcon30,154ruby19,599ruby-1p16,095rails1,983

/searches?q=coffee

1001,00010,000100,000req/sec/GBspinel48,419tobi41,434ruby-falcon17,698ruby-1p14,013ruby12,332rails1,294

/users/:avatar_token/avatar

1,00010,000100,0001,000,000req/sec/GBtobi703,261spinel439,884ruby-falcon120,300ruby-1p62,669ruby46,168rails12,509

a stylesheet (/assets/*.css)

10,000100,0001,000,000req/sec/GBtobi654,252spinel358,274ruby-falcon150,576ruby-1p66,004ruby43,778rails22,247

/up

1,00010,000100,0001,000,000req/sec/GBtobi516,281spinel238,313ruby-falcon59,260ruby-1p38,241ruby31,786rails2,908

POST /rooms/2/messages

101001,00010,000req/sec/GBtobi2,672ruby-falcon871ruby-1p726ruby465rails145spinelno data — server not answering

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.

spinel113 MBruby-1p490 MBtobi694 MBruby-falcon1,357 MBrails2,095 MBruby2,129 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. Lanes are ordered by measured median p50 across endpoints, fastest first.

routeruby-falconrubytobispinelruby-1prails
p50p99p50p99p50p99p50p99p50p99p50p99
/rooms/11.23.21.313.42.04.22.98.96.317.88.145.4
/rooms/1/messages?before=501.13.01.216.21.33.22.57.75.818.67.641.2
/users/me/sidebar0.610.30.92.41.22.81.85.74.49.24.712.2
/searches?q=coffee1.24.11.215.81.85.12.57.65.918.27.246.3
/users/:avatar_token/avatar0.20.80.31.20.10.40.21.41.33.70.72.9
a stylesheet (/assets/*.css)0.10.70.32.00.20.60.31.61.16.10.41.6
/up0.41.50.53.10.20.50.52.02.24.43.28.2
POST /rooms/2/messages13.144.315.558.56.341.4——45.6109.444.6243.6

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)
spinel330484
ruby-1p10181276
tobi13381489
ruby-falcon13211577
ruby13271595
rails40914953

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
spinel
4 workers, one process
1,000500.5 s0.00827219 / 41 ms0.0241,500/1,500
tobi
4 worker Ractors, one process
1,000500.6 s0.00818219 / 79 ms0.0331,500/1,500
ruby
1 Puma worker x 5 threads
1,000501.1 s0.00623034 / 173 ms0.0321,500/1,500
rails
3 Puma workers x 5 threads
1,000502.5 s0.01261337 / 222 ms0.0651,500/1,500

The driver is one thread reading every socket; its longest single reading pass in this run was 63 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,642401,235
rails/rooms/1/messages?before=502003,375367,595
rails/users/me/sidebar20033337,855
rails/searches?q=coffee2003,547391,028
rails/users/:avatar_token/avatar2007631
railsa stylesheet (/assets/*.css)20001,218
rails/up200273
ruby/rooms/12003,642398,910
ruby/rooms/1/messages?before=502003,375367,035
ruby/users/me/sidebar20033336,084
ruby/searches?q=coffee2003,547388,721
ruby/users/:avatar_token/avatar2007631
rubya stylesheet (/assets/*.css)20001,218
ruby/up200273
ruby-1p/rooms/12003,642398,910
ruby-1p/rooms/1/messages?before=502003,375367,035
ruby-1p/users/me/sidebar20033336,084
ruby-1p/searches?q=coffee2003,547388,721
ruby-1p/users/:avatar_token/avatar2007631
ruby-1pa stylesheet (/assets/*.css)20001,218
ruby-1p/up200273
ruby-falcon/rooms/12003,642398,910
ruby-falcon/rooms/1/messages?before=502003,375367,035
ruby-falcon/users/me/sidebar20033336,084
ruby-falcon/searches?q=coffee2003,547388,721
ruby-falcon/users/:avatar_token/avatar2007631
ruby-falcona stylesheet (/assets/*.css)20001,218
ruby-falcon/up200273
spinel/rooms/12003,642398,910
spinel/rooms/1/messages?before=502003,375367,035
spinel/users/me/sidebar20033336,084
spinel/searches?q=coffee2003,547388,721
spinel/users/:avatar_token/avatar2007631
spinela stylesheet (/assets/*.css)20001,218
spinel/up200273
tobi/rooms/12003,639395,861
tobi/rooms/1/messages?before=502003,375362,755
tobi/users/me/sidebar20033037,365
tobi/searches?q=coffee2003,544385,127
tobi/users/:avatar_token/avatar2007631
tobia stylesheet (/assets/*.css)20001,218
tobi/up200273

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

The 9 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 1 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
tobi/up67,74673,59556,18811.0%
tobi/rooms/1/messages?before=5010,05712,80011,4389.8%
tobi{css}83,10673,08091,1259.0%
ruby-falcon/up35,26338,66431,4408.4%
tobi/users/me/sidebar12,21812,63111,0125.7%
ruby-falcon{css}97,50485,12589,3775.7%
ruby-falcon/users/me/sidebar18,45816,96616,5434.7%
tobi/searches?q=coffee7,0966,8977,5073.5%
ruby-falcon/users/{avatar}/avatar71,79876,88371,6903.3%

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

Raw cell data (48 rows)
targetendpointreq/secp50 (ms)p99 (ms)PSS (MB)req/sec/GB
spinel/rooms/14,7142.938.8910943,990
spinel/users/me/sidebar8,4641.765.7410085,962
spinel/searches?q=coffee5,3622.547.5511348,419
spinel/rooms/1/messages?before=505,3342.527.7011049,540
spinel/users/{avatar}/avatar47,2720.251.35110439,884
spinel{css}37,7190.311.63107358,274
spinel/up25,9510.461.96111238,313
spinelPOST:/rooms/2/messages—————
tobi/rooms/17,3672.044.1617842,166
tobi/users/me/sidebar12,2181.242.8313890,013
tobi/searches?q=coffee7,0961.795.1217541,434
tobi/rooms/1/messages?before=5011,4381.273.2015177,129
tobi/users/{avatar}/avatar89,2610.140.40129703,261
tobi{css}83,1060.170.57130654,252
tobi/up67,7460.220.53134516,281
tobiPOST:/rooms/2/messages1,8126.3341.356942,672
ruby-falcon/rooms/111,1431.253.1751822,017
ruby-falcon/users/me/sidebar16,9660.6110.3257630,154
ruby-falcon/searches?q=coffee10,2961.204.1159517,698
ruby-falcon/rooms/1/messages?before=5012,2651.142.9954223,168
ruby-falcon/users/{avatar}/avatar71,7980.190.85611120,300
ruby-falcon{css}89,3770.140.73607150,576
ruby-falcon/up35,2630.431.5360959,260
ruby-falconPOST:/rooms/2/messages1,15513.1444.341,357871
ruby/rooms/110,7261.3013.3990912,079
ruby/users/me/sidebar17,1880.862.3689819,599
ruby/searches?q=coffee11,4741.2115.8495212,332
ruby/rooms/1/messages?before=5011,6471.1916.2192912,825
ruby/users/{avatar}/avatar47,2870.301.191,04846,168
ruby{css}52,0730.281.961,21843,778
ruby/up30,4530.473.1198131,786
rubyPOST:/rooms/2/messages96815.4958.512,129465
ruby-1p/rooms/12,5226.3417.7919513,188
ruby-1p/users/me/sidebar3,6844.389.2423416,095
ruby-1p/searches?q=coffee2,7195.8918.2019814,013
ruby-1p/rooms/1/messages?before=502,7365.8318.6319614,278
ruby-1p/users/{avatar}/avatar12,3091.283.7420162,669
ruby-1p{css}13,2561.146.1120566,004
ruby-1p/up7,4752.154.3520038,241
ruby-1pPOST:/rooms/2/messages34745.61109.43490726
rails/rooms/11,8658.0645.361,4241,341
rails/users/me/sidebar3,1564.6812.231,6291,983
rails/searches?q=coffee1,9947.1846.321,5771,294
rails/rooms/1/messages?before=501,9357.6441.241,5121,310
rails/users/{avatar}/avatar19,4090.722.921,58812,509
rails{css}34,5090.431.561,58822,247
rails/up4,6773.178.231,6462,908
railsPOST:/rooms/2/messages29844.59243.602,095145
Environment (captured 2026-10-31T12:23:57Z)

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

Application under test

appcampfire
revisionc8f74a0b0e1e
Rails8.2.0.alpha (e3d5c569d217)
Ruby4.1.0
servedproduction
seed50 users, 5 rooms, 100 messages/room
app server (rails lanes)puma 8.0.2
app server (emit lanes)puma 8.0.2

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 3133397, thruster=front on :19100 -> puma :20100, workers=campfire's own formula (WEB_CONCURRENCY unset)
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
ruby-falconserver=falcon 0.57.0 via falcon.rb (forked), jemalloc=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2, workers=8
spinelworkers=autodetected
tobiroot=/home/rubys/git/campfire-once-ruby-ractor, revision=353e899, ruby=4.1.0, jemalloc=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2, workers=8 ractors (one process)

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.1.0dev (2026-10-31T04:35:19Z master 3c6a1c7a86) +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-campfire --app /home/rubys/once-campfire --port 19100 --ruby /home/rubys/.rubies/ruby-head-3c6a1c7a86 --out bench/results/campfire/20261031-113543 rails ruby ruby-1p ruby-falcon spinel tobi
workers1
concurrency16
runs3 × 20s after 20s warmup
wrk threads2
endpoints/rooms/1 /rooms/1/messages?before=50 /users/me/sidebar /searches?q=coffee /users/{avatar}/avatar {css} /up POST:/rooms/2/messages
targetsrails, ruby, ruby-1p, ruby-falcon, spinel, tobi

Source

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

Conditions at start

load average0.39 / 3.78 / 6.01
uptime13:23:58 up 16 days, 1:08, 0 user, load average: 0.39, 3.78, 6.01

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