intertwingly

It’s just data

Campfire, Measured


Four days ago Campfire chatted, and I wrote that the sockets would get measured "when the measurement can't quietly lie." The page is up:

rubys.github.io/roundhouse/bench/campfire/

It is rebuilt from a run that takes over an hour, and it publishes the lanes that make me look bad next to the ones that don't.

The headline is that Rails wins

On /rooms/1 — a 420 KB room page — deployed Campfire serves 345 requests a second and the binary serves 56. Rails is six times faster, and the reason is not subtle: Campfire renders its message list as render partial: …, cached: true, so with Redis up those forty fragments are read back rather than rendered. We have no fragment cache, so we render all forty, every time.

That gap is mine to close, not a framing problem to write around. The page carries a rails-nocache lane that renders the same fragments — it serves 26 req/s, and the binary's 56 is more than twice that — but that lane is a diagnostic, not the score. A deployment is a worker count, an allocator, a proxy and a cache. Comparing against a Rails configured worse than Basecamp ships it would be a way of losing while sounding like winning.

Where the binary does win, it wins on the axes a self-hoster feels:

deployed Rails the binary
memory (PSS) 1,925 MB 164 MB
cold start to a page 4,524 ms 364 ms
1,000 idle WebSockets, CPU 0.010 cores 0.010 cores
1,000 idle WebSockets, memory 568 MB 129 MB

And one row I did not expect: per process, the binary now serves 36 req/s against the CRuby transpile's 24. This morning those were level.

What the four days actually were

The socket number was the promise. What consumed the time was a different question: the binary got nothing from twelve OS workers. On the room page it did 25 req/s with one worker and 24.6 with twelve, while per-request CPU went from 40 ms to 97 ms. The process never exceeded 1.3 cores on a 6c/12t box.

I filed that upstream as an allocator problem, with a standalone repro: two threads building strings ran slower than one. Matz found it, and the diagnosis was better than mine. Every allocation asked the collector whether it should run; the ask was refused 99.99% of the time — 7.2 million asks for 941 collections — and every refusal took the global scheduler lock on the way to being refused. A worker that was told no did not record having asked, so it asked again on its very next allocation. The fix makes a refused worker wait one threshold's worth of allocation before asking again. Two threads stopped being slower than one.

Our numbers moved with it: the deployed lane went 28.1 → 30.1 → 43.8 req/s across three runs while the single-worker lane moved 6%, which is exactly the shape a contention fix predicts.

And the binary still would not use more than about 1.4 of 12 cores.

The part where it turns out to be mine

So I filed a second issue — a cheap authenticated request pinning the server to two OS workers, with eight times the concurrency buying 7% — and I attached a mechanism: requests too short to park stay on the worker that accepted them.

Then I built the minimal reproduction I should have built before filing. Seventy lines: TCPServer, a thread per connection, a knob for how much CPU the handler burns. It spread across eleven workers at every request cost, from 35 microseconds to 314. My mechanism was dead, killed by my own repro.

One line brought the shape back. A single process-global Mutex, taken twice per request around an empty critical section:

req/s cores share on the top two workers
no mutex 190,776 6.97 16%
one shared mutex 58,055 1.49 99%

Two workers holding 99% of the CPU while ten sat idle — not blocked, idle. That was Campfire's signature exactly, and Campfire had exactly that mutex: Db.with_connection wrapped every request in one process-global pool lock, lease on the way in and release on the way out.

Matz, meanwhile, had built his own server and could not reproduce my report at all. His first run did look like my table — 1.4 cores, two workers at 91% — and it was his client: one Python load generator is GIL-bound around 90k req/s, so his server was idle and only the workers nearest the accept path had work. Eight client processes later the same server did 239k req/s across eleven workers. He then added a shared mutex around an empty block, watched it collapse to two workers, and asked me the question that ended it: is your session lookup going through a single shared handle?

It was. The bug was ours, and it had been ours the whole time.

What changed

In Spinel, from measurements this application produced:

In Roundhouse:

The W-ladder that closed the issue, after the shard:

OS workers 1 2 4 12
req/s 4,134 5,980 9,743 12,263
cores used 1.04 1.77 3.34 5.38

One worker with the pool fixed now beats twelve with it broken.

The gem that stopped being needed

The readiness-set change deserves more than a bullet, because of what it displaces.

Action Cable depends on nio4r. It has to: CRuby's selector is IO.select, which is O(n) in the set you hand it, so a server watching five thousand sockets pays for five thousand on every wake. nio4r is a C extension that wraps epoll and kqueue to give Ruby a kernel-managed readiness set instead, and Action Cable runs an event loop around the selector object it returns. Campfire's Gemfile.lock names it under actioncable (8.2.0.alpha), and Puma pulls it in separately.

In the four days this post covers, Spinel grew the same capability — 692e50b2 for epoll on the 4th, 1c34d424 for kqueue the same day, after the 1st made a timed readiness wait park the green thread rather than its OS worker. The 1,000-socket row in the table up top, the one reading 0.010 cores idle, is downstream of those commits. It could not have read that before them.

But "the gem got built into the compiler" undersells it. nio4r hands you a selector you then have to drive; the multiplexing stays the application's problem, which is why Action Cable has an event loop in it at all. Spinel put the readiness set in the scheduler, so a green thread blocked in recv parks and frees its OS worker, and the monitor wakes it when the fd is ready. Our server is written as one thread per connection doing ordinary blocking reads and writes. There is no event loop in it, no selector, and no callback — the code looks like the naive version you would write if you had never heard of the problem.

The gem's job did not move into the runtime. It stopped existing.

The honest footnote: the emitted tree still lists nio4r in its Gemfile, because that same tree runs under CRuby and Puma as the ruby lane on the benchmark page. The gem is not gone from the project. It is irrelevant to the binary.

Two things I'd rather have learned cheaply

A discriminator beats a mechanism. Matz nearly sent me a story about thread placement; I sent him one about request cost. Both wrong. What closed the issue in a single round trip was a much smaller claim: the mutex shape is flat from two workers onward and a scheduler problem is not — a question my machine could answer and his could not. A wrong mechanism costs the other person a day building a server to disprove it. A wrong discriminator costs one run.

The thing measured was not the thing changed. I added the allocator to the manifest, rebuilt, ran ldd, saw no jemalloc, and briefly concluded the merged feature didn't work. make had printed "Nothing to be done" — the manifest wasn't a prerequisite of the binary — and ldd was reading an hour-old file. Matz was caught the same week by a stale runtime archive, which is what that second PR turned out to be. Different tools, same failure.

What's next

The six-times gap is a fragment cache, and that is the next real piece of work: Campfire's own cached: true partials, served from something in-process rather than Redis.

And one candidate I want to name without claiming: there is a second process-global mutex on the query path. Preparing a statement takes it, because SQLite's out-buffer here is one eight-byte slot in static storage for the whole process — so unlike the pool, it cannot be sharded, only avoided. Today the emitted SQL inlines its literals, so the statement cache keys per value and misses reach that lock; with placeholders the key would be the query shape and the misses would mostly stop. Whether that matters depends entirely on how often it misses, which I have not measured — the profile that would have told me was taken while the pool lock was serializing everything anyway, and a second lock behind a first one costs nothing you can see. It is the same shape, one lock further down, and it is the first place I'll look.

Below that, three smaller threads. Matz's uncontended-mutex fix pays out precisely where contention has been engineered away, so the shard count — currently capped at eight — is now worth more than when I set it. Per-request CPU still rises with worker count, which is the tail of the original complaint and not yet explained. And that benchmark page measures two expensive pages only, which makes it blind to exactly the class of bug this whole hunt was about: the sharded pool was worth more than three times on a cheap route and shows as +1.9% there.

The caveats from the last post still hold. Attachments and uploaded avatars are out of scope, web push logs its failure per message on purpose, and our fan-out is in-process where deployed Campfire's crosses Redis. The socket numbers are published with the driver's own cost beside them, because a fan-out tail above a thousand sockets is a measurement of the client as much as the server.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.