Replies: 2 comments
|
The two concrete Net-layer asks from Part 1 are now filed upstream as narrow, self-contained issues, so they can be acted on without carrying this whole document:
Part 2 (the collector) stays here for now — those are theories with questions attached, and we do not have measurements yet. Expect to replace them with numbers rather than argument once the binary is up. The roundhouse-side plan those feed into is #71. |
|
Part 1 is largely answered. matz fixed both issues the same day, in
Two details in the fixes that are better than what this document asked for, and poll(2) remains the backend by design — the new contract is meant to survive an Part 2 (the collector) stands unchanged, as does the TLS question. Those are |
Uh oh!
There was an error while loading. Please reload this page.
The long-lived-connection workload: notes on the Net layer and the collector
Why this workload is new
Everything spinel has been driven with so far — the blog, lobsters, campfire's
HTTP lane — is request-shaped: work arrives, allocates, answers, and the
live set returns to roughly where it started.
scripts/benchmeasures req/secand RSS against exactly that shape.
A chat server is not that shape. Basecamp publishes Campfire's requirements as
concurrent users, each holding a WebSocket:
Perfectly linear — ~6.5–8 MB and ~1/300th of a core per idle connection. That
is a CRuby-shaped budget (a connection object graph, a share of the DB pool, a
Redis subscriber, COW decay across forked workers). It is a low bar on memory
and it is the wrong axis anyway: what separates runtimes here is fan-out
latency in a hot room and tail latency under collection.
The interesting property is that the workload inverts the usual generational
assumption. The live set is a large, long-lived connection table that survives
every cycle; the allocation is per-frame and dies immediately. Steady state is
"almost everything I allocate is garbage, and almost everything that is live
has been live for hours."
Campfire is also pinned to one machine by
adapter: sqlite3and ActiveStorage's
service: Disk, and ONCE's framing is "one person installs it on aserver you control." So there is no distributed tier to fall back on: whatever
one process does is the whole product. That is what makes these two layers
load-bearing rather than interesting.
Part 1 — The Net layer
Five observations, roughly in order of how soon each one bites. The first is a
hard wall; the rest are design shape.
1.
SP_NET_POLL_MAXis 256, and overflow is silentConsumers that treat
-1as "not polled" — which is the natural reading —leave those fds permanently unwatched. There is no error, no log; the
connections simply go deaf. Campfire's smallest published tier is 250
concurrent users, so this is reached before tier one.
Whatever else changes,
-1here would be more useful as a loud failure than asilent one.
2. The readiness API's shape forces O(n) per tick, independent of backend
reset/add/run/readyis a rebuild-the-set-every-time contract. Acaller with N parked fds walks all N, makes N FFI calls to re-register what has
not changed, calls
poll, then walks N again to read results — per tick.Moving the backend to
epoll/kqueueunder this API buys a better syscall andkeeps the rebuild.
The shape that matches an event-driven server is persistent registration:
register(fd, mode)/modify(fd, mode)/unregister(fd)/wait(timeout) -> ready list. Registration cost is paid once per connection rather than onceper tick, and
waitreturns only what is ready, so the caller's work isproportional to events rather than to connections.
This matters more than the backend swap. It is worth settling the API before
the backend, because a
poll-shaped API can hide behindepolland still bequadratic.
3. Writes block below the scheduler
sp_net_wait_iois a blockingpoll(fd, ..., 1000)inside C. For afiber-scheduled server this is below the scheduler's floor: a single slow
subscriber with a full socket buffer stalls the entire worker's event loop, up
to a second at a time, and no Ruby-level scheduler can see it happening.
This is the failure mode most likely to be misdiagnosed, because the symptom
is "the runtime is slow" and the cause is "the runtime is parked waiting on one
client."
A partial-write return (
bytes written, or-EAGAIN) would let the callerown buffering and backpressure, which is where that policy belongs — the caller
knows whether to buffer, drop, or disconnect; the socket layer does not.
4. The recv buffers are file-static, so the layer is not reentrant
One shared buffer per function. That is fine for a cooperative single-worker
model and correct for everything shipped so far. It does mean the net layer
cannot be called from two green threads on two OS workers at once — which is
precisely spinel's differentiator for this workload, since no GVL means one
process can hold every connection and fan out across cores without a broker in
the path.
Caller-supplied buffers would make the layer thread-safe without changing what
the single-worker path does.
5. No TLS
lib/sp_net.h:10describessp_net_tlsin the future tense. That is fine forinbound (a chat server behind a proxy is normal) but gates outbound HTTPS,
which in Campfire's case is bot webhooks and web push — two of its three
background jobs. Not urgent for a first binary; it is the next dependency after
one, and it interacts with the single-binary story (static-link vs. a vendored
small TLS stack is a decision, not a detail).
Part 2 — The collector
Stated as theory and questions rather than findings — this workload has not
been run yet, so what follows is what we expect to matter and would like to be
wrong about in specific ways.
What the docs already say
docs/internals/gc.md: mark and sweep, non-moving, precise, two heaps — object(48-byte header) and string (24-byte header plus a marker byte, swept through
its own gate).
docs/thread.md: collection stops every worker at asafepoint; the object sweep is parallel across parked workers; the string
sweep is still serial and is what bounds that shape today;
SPINEL_GC_THRESHOLD_KB(default 256, per worker) is the tuning lever andscales with worker count.
Why this workload probes an untested corner
The good news first: the allocation pattern is close to ideal for a
generational collector. Frames are born and die within a tick; a minor cycle
should reclaim nearly all of it and, per
sp_gc.c, does not free the old list.The concern is the other half. The old generation is the product: N
connections × (buffer + subscription rows + per-connection state), live for
hours. Every major cycle walks it, and the pause is stop-the-world across all
workers. For a request server a pause is amortized into a percentile nobody
looks at. For a chat server the pause is the product — it is simultaneously
visible to every connected user, because they are all waiting on the same
process.
The specific things we expect to bite, offered as theories:
is exactly the structure that never dies. Any per-connection object we can
fold into fewer, flatter allocations pays off at 10^4 connections in a way
it would not at 10^2.
fragment; every subscription holds an identifier string. If the string sweep
is the serial bound under threads, this workload is close to the worst case
for it — not because there are many live strings, but because there is a
high allocation rate of short-lived ones alongside a large long-lived set.
SPINEL_GC_THRESHOLD_KBwas tuned against allocation-bound benchmarks,where retained garbage is the cost being traded. Here the tradeoff is
different: memory is genuinely plentiful (the published budget is 64 GB) and
pause is the scarce resource, so the right setting is plausibly much higher
than anything a request benchmark would suggest.
The competitive frame, stated honestly
This is the one axis where BEAM's structure gives it something spinel's does
not: per-process heaps collected independently mean the pause is proportional
to one connection's own tiny heap, and there is no global stop. Go's answer is
different — a concurrent collector whose pause does not scale with live heap at
all. Both are architectural, not tuning.
Spinel's counter-position is real and worth naming: it is the only one of the
five that runs the actual Rails application code, with no GVL and AOT
compilation — on the HTTP lane the emitted campfire already renders these pages
~4.8× faster than Rails does, on ~30% less RSS. The question this document is
asking is what the tail looks like when the live set stops being small.
Questions
scoped to something the program knows about — a request, or here a
connection? Roundhouse has stage-1 AOT zones landed and per-request zones
sketched; a connection-scoped zone would take most of the long-lived set out
of the general heap, which would address theory (1) directly.
rough shape (linear in live objects? in live bytes? dominated by the string
heap?) would tell us whether to attack allocation shape or pause frequency
first.
SPINEL_GC_THRESHOLD_KBa reasonable interim answer for amemory-rich/pause-sensitive deployment, or does something else degrade at
large thresholds?
We expect to have measurements rather than theories within a few weeks — the
binary is close, and the harness shape is already proven on two other lanes.
Happy to run whatever instrumentation would be most useful and report back
rather than guess.
All reactions