Skip to content

fix(bench): make the recv-geometry sweep measurable at all (#416 Phase 0) - #417

Closed
brayniac wants to merge 17 commits into
ringline-rs:mainfrom
brayniac:feat/bench-metrics-dump
Closed

brayniac wants to merge 17 commits into
ringline-rs:mainfrom
brayniac:feat/bench-metrics-dump

Conversation

@brayniac

Copy link
Copy Markdown
Contributor

Phase 0 of #416. Two bugs, both of which would have turned the sweep into a confident null.

The echo arm ignored the geometry flags

run_ringline hardcoded recv_buffer(256, msg_size-derived), while --recv-buffer-bytes and --recv-ring-size were read only by the proxy arm. Every echo arm of a buffer-geometry sweep would have run the same configuration under different labels — 27 arms of #416's Phase A — and reported "no effect" with a straight face. It also means any earlier echo-path buffer comparison run through bench-server measured nothing.

It now runs what it is told, and the ready line prints the effective geometry so a log proves which config ran rather than which was requested.

Nothing read the runtime counters back out

BUFFER_RING_EMPTY, RECV_PARKED, RECV_FALLBACK, FORWARD_THROTTLED, RECV_ARM_FAILURES are all counted and none were readable — no exporter anywhere in the workspace. A sweep could report that an arm was slower without being able to say whether it starved its provided ring, degraded to the fallback recv, or neither.

bench-server --metrics-out <path> walks the metriken registry to JSON at shutdown; the interesting counters also print to stderr for whoever is reading a job log.

Two things the tests caught while writing it:

  • A counter nobody has incremented reads back as None, not Some(0), so the first version dumped nothing at all for a healthy run — byte-identical to what a broken harness produces. Declared entries now report 0, so "no starvation" and "no dump" look different.
  • ringline/bytes was incremented in ten places on io_uring and none on mio, so every mio arm would have read zero bytes and any per-byte figure derived from the dump would have silently divided by it. The mio recv (plaintext and TLS ciphertext) and send paths now count too.

Gate

The spec requires a deliberately starved ring to actually show starvation, or the dump is not wired to anything. On hv02, io_uring, 64 connections at 256 KiB:

arm geometry buffer_ring_empty recv_parked bytes moved
tiny 8 × 4 KiB 11,950,066 11,950,066 12.4 GB
roomy 256 × 64 KiB 0 0 28.4 GB

The counters discriminate, and the 2.3× throughput gap is the starvation being paid for. This gate is what found the ignored-flags bug — the first run reported zero starvation on the tiny ring, which is only possible if the flags did nothing.

Also included: experiments/recv-buffer-sweep/generate.py, which generates the sweep as barrier-per-arm two-guest experiments (one guest boot per chunk of 12 arms, server foreground under timeout -s TERM so each arm dumps its counters and rezolus scopes to that arm). Validated: 54 arms in 5 chunks for Phase A, all 24 barriers per chunk paired across both jobs.

Verified: cargo test --all and clippy -D warnings on both backends; the gate above on real io_uring hardware.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FDHdYwqsnPN4EkhRBvbDgE

brayniac and others added 17 commits September 17, 2026 11:34
The runtime counts what explains a benchmark result rather than merely
states it — buffer_ring_empty, recv_parked, recv_fallback,
forward_throttled, recv_arm_failures — but nothing in the workspace read
them back out. A sweep over buffer geometry could report that an arm was
slower without being able to say whether it starved its provided ring,
degraded to the fallback recv, or neither. ringline-rs#416 is blocked on this.

`bench-server --metrics-out <path>` walks the metriken registry and
writes it as JSON at shutdown, and the interesting counters print to
stderr for whoever is reading a job log.

Two things the tests caught:

A counter nobody has incremented reads back as `None`, not `Some(0)`, so
the first version dumped nothing at all for a healthy run — identical to
what a broken harness produces. Declared entries now report 0.

And `ringline/bytes` was incremented in ten places on io_uring and none
on mio, so every mio arm would have read zero bytes and any per-byte
figure derived from the dump would have silently divided by it. The mio
recv (plaintext and TLS ciphertext) and send paths now count too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDHdYwqsnPN4EkhRBvbDgE
`run_ringline` hardcoded `recv_buffer(256, msg_size-derived)` while
`--recv-buffer-bytes` and `--recv-ring-size` were read only by the proxy
arm. Every echo arm of a buffer-geometry sweep would therefore have run
the *same* configuration under different labels and reported a confident
"no effect" — 27 arms of Phase A in ringline-rs#416, and any earlier echo-path
buffer comparison anyone believes.

Found by the Phase 0 gate rather than by reading: a deliberately starved
8x4KiB ring counted zero starvation, which is only possible if the flags
did nothing. It now runs what it is told, and the ready line prints the
effective geometry so a log proves which config ran:

  ready (backend=io_uring, echo_mode=direct, recv_buffer=8x4096 = 0 MiB/worker)
  ringline/pool/buffer_ring_empty = 11950066
  ringline/pool/recv_parked = 11950066

against zero starvation at 256x65536, with throughput 12.4 GB vs 28.4 GB
across the same window. The arguments move into an `EchoCfg`, as the
proxy arm's already had to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDHdYwqsnPN4EkhRBvbDgE
…ghput

Joins each chunk's client results with its server metrics dump by arm id.
An arm whose server never dumped prints `?`, never 0 — silence is not a
clean ring, and the two must not read the same.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDHdYwqsnPN4EkhRBvbDgE
Intent recorded before building, per the journal's ground rules.

The question: provided-buffer size and ring depth are one knob at a fixed
memory budget, so every workload picks which of the two to be wrong
about. ringline-rs#415 measured the size edge — ~6,600 instructions per completion
cycle on the forward path, worth 25% between 16 KiB and 64 KiB buffers —
and ringline-rs#416 Phase A chunk 1 measured the depth edge, where the same 1 MiB
buffer starves 3,066,467 times at a 4-deep ring and zero times at 256
deep, the deep one being the fastest arm in its group.
`IOU_PBUF_RING_INC` (Linux 6.12) is the mechanism that would sever the
tie.

Recorded honestly: I recall a prior 200 GbE sweep finding INC not a win,
and that result is not checked in anywhere in this repo. It is marked
unverified rather than cited. The adjacent 2026-07 NO-GO on *adaptive*
sizing stands and does not cover this, whose criteria A-D none of ringline-rs#415's
finding matches — so this would add a criterion E rather than quietly
contradict it.

Also recorded: why the kernel floor must be a runtime probe (build.rs
gates at 6.1; gating at 6.12 would drop every 6.1-6.11 host), and the
bid-lifecycle sites that assume one bid is one discrete segment, which
INC breaks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDHdYwqsnPN4EkhRBvbDgE
The collector read the percentiles from a nested `latency` object;
bench-client emits them at the top level, so every arm came back with
p99 = None and the decision rule's latency half would have been silently
absent from the table. Accept both shapes, print p99, and warn loudly if
an echo arm still has none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDHdYwqsnPN4EkhRBvbDgE
The first criteria asked INC to win on a tuned homogeneous workload,
which is the case it is least likely to win: if you know the message
size you can already pick the geometry for it. The value is robustness
without tuning, and mixed traffic is where fixed geometry hurts — owner's
framing, and the right one.

Criterion 1 becomes an oracle comparison: the shipped default vs the
best geometry chosen with hindsight vs a plausible misconfiguration, on
a size distribution. The default-to-hindsight gap is the tuning burden
INC would remove, and if it is small there is nothing here however
elegant the mechanism.

Also recorded: Phase A is entirely homogeneous, so its flat copy-path
surface is evidence about tuned workloads only — the same omission that
left the 2026-07 NO-GO's mixed-workload gate unmet. And the memory
question is sharpened: the ring backing is not mlocked and not charged
to RLIMIT_MEMLOCK (provided.rs:54), so over-provisioning is virtual up
front — but alloc_zeroed pages go resident when touched and stay, so the
2026-07 RSS figure describes what a steady benchmark touched, not what a
bursty production workload will.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDHdYwqsnPN4EkhRBvbDgE
… feature

A per-connection size mix in bench-client would have meant threading a
distribution through ClientState and the per-worker connection slices.
Two concurrent client processes against one server give the same
property — one ring, two size populations — with no client change, and
report each population separately, which is the more informative
experiment: the failure worth finding is the small messages starving
while the large ones hold the ring, and an aggregate would hide exactly
that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDHdYwqsnPN4EkhRBvbDgE
… a precondition

The first Phase A run was not server-bound in any arm — ~8 of 24 cores
on the echo arms, ~9 on the forward arms, every forward arm from 16 KiB
to 1 MiB pinned at the ~22 Gbit/s wire ceiling, including a 1 MiB x 4
arm that starved 441,351 times and still tied the clean arms. A flat
surface from an unsaturated server is indistinguishable from geometry
not mattering, so that run decides nothing.

--workers and --conns are now generator flags with the reason recorded
next to them, and the design doc states saturation as a precondition to
reading any arm as a result. Client threads scale with connection count,
since one blocking writer per connection round-robined by too few
threads makes the load generator the limiter; the connect wait grows to
120s for high fan-in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDHdYwqsnPN4EkhRBvbDgE
`ConfigBuilder::prefault_buffers(bool)`, default off while the trade is
measured (ringline-rs#416).

`vec![0u8; n]` goes through `alloc_zeroed`, so a large allocation is
mapped but untouched: every page is the shared zero page until something
writes to it. On the recv path that first write is the *kernel* copying
an skb in, so the minor fault lands on the completion path rather than
at startup. Prefaulting walks the provided recv ring and the send copy
pool once, on the worker thread that owns them.

What it buys is predictability, not throughput: faults stop appearing as
ramp-phase p99 outliers, and RSS after startup equals RSS under load —
so a ring the machine cannot back fails at launch rather than during a
traffic burst, which is how RLIMIT_NOFILE and RLIMIT_MEMLOCK are already
handled. What it costs is that over-provisioning stops being free, which
is the point but is a behaviour change, hence default off.

NUMA is why this lives in the driver constructor rather than anywhere
convenient: `worker.rs` pins the thread before building the driver, so
the pages fault in on the owning worker's node. Prefaulting from the
launching thread would pull every worker's buffers onto one node.

The write is volatile because a plain store of 0 to an already-zeroed
page that nothing later reads is exactly the store an optimizer may
delete, and deleting it would leave the page cold.

Tests assert the property, not the call: `mincore` residency before and
after (the "before" half matters — without it a platform that eagerly
populated mappings would pass while prefaulting did nothing), and an
end-to-end RSS check that a 32 MiB pool moves RSS by >=90% of its size
and was not already resident from allocation.

A follow-up could use MADV_POPULATE_WRITE (Linux 5.14+) instead of the
touch loop; the loop is portable across both backends and macOS, which
is what a prototype wants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDHdYwqsnPN4EkhRBvbDgE
Per workload: the shipped default, the best arm inside the default 4 MiB
budget, and the best arm at any budget. The equal-memory column is the
honest comparison; the any-budget column says what more memory buys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDHdYwqsnPN4EkhRBvbDgE
Two saturated passes, 2 workers, 64 vs 1024 connections, 108 arms. The
shipped `recv_buffer(256, 16384)` is minimax-optimal across the measured
space: worst case -34%, median -6%, against -36/-59/-70/-83% worst for
every alternative that fits the same 4 MiB budget. It never wins a
workload and never loses badly, which is what a default is for.

The reason no geometry wins is that the optimum moves along two axes in
opposite directions. Message size pulls toward bigger buffers (payload
per completion amortises a fixed ~6,600-instruction cost, ringline-rs#415); fan-in
pulls toward deeper rings (depth bounds concurrent arrivals). At fixed
memory those trade directly, and six different geometries win the twelve
workloads. 1 MiB x 4 wins four of them outright and is 83% down on
another, with a 1.03-second p99 at 256 B x 1024 connections from
137,895,774 ring starvations.

The one systematic exception is forwarding: -34% at 64 connections,
-18% at 1024. `forward_to`'s sizing note is load-bearing rather than
advisory, and now gives a range with the fan-in dependence explained —
1 MiB x 4 is best at low fan-in and collapses at high, so 256 KiB x 16
is the safer proxy default.

Cross-validation: the forward arms reproduce ringline-rs#415 on an independently
built harness, 11.32 vs 11.2 Gbit/s at the default and 14.26 vs 14.0 at
64 KiB.

This also narrows ringline-rs#416's INC case, recorded in that journal: the tuning
burden a geometry-aware runtime could remove is ~6% for request/response
— below criterion 1's bar — and 18-34% only for forwarding. Criterion 1
now targets a mixed workload on the forwarding path specifically.

Raw per-arm results and the regret analysis are checked in beside the
generator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDHdYwqsnPN4EkhRBvbDgE
ringline-rs#416 Phase A found the recv geometry default sound everywhere except
forwarding (-34% at 64 conns, -18% at 1024). Instructions per byte says
that gap is not about buffer size: at the same 16 KiB default, direct
echo runs 0.985 instr/byte against Mode A's 1.26, over a ~0.86 kernel
floor. Echo at the default buffer is already as efficient as forwarding
at four times the buffer.

The difference is that Mode A wakes a task per provided buffer, where
run_direct_echo submits from inside the CQE handler. The fix is to move
the forward state into the driver -- the shape the mio backend already
has since ringline-rs#415 -- and submit the next write from the write-completion
handler.

Also recorded: buffers stop filling past ~100 KB (a multishot recv
completes with what TCP has queued, not a full buffer), so bigger
buffers are not a substitute; that ceiling is what made a 4-deep 1 MiB
ring post a 1.03-second p99.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDHdYwqsnPN4EkhRBvbDgE
Mode A woke a task for every provided buffer: recv CQE -> hold push ->
wake_recv -> collect_wakeups -> poll_ready_tasks -> future poll -> SQE
build -> write CQE -> replenish -> wake_recv. `run_direct_echo` has
never done this, and ringline-rs#416 measured the difference: 1.26 instructions per
byte for Mode A against direct echo's 0.985 at the same 16 KiB buffer,
over a ~0.86 kernel floor. Echo at the default buffer was already as
efficient as forwarding at four times the buffer.

Move the forward state (target, len, forwarded) out of ForwardToFuture
and into the driver as ForwardProgress -- the shape the mio backend has
had since ringline-rs#415 -- so `handle_forward_write` and the segmented recv
branch can pop the next held buffer and submit it themselves.
`advance_forward` is the one driver method both call;
`finish_forward_if_done` is the one place a terminal result is recorded
and the task woken.

Ordering is unchanged: one write in flight per connection, held buffers
in arrival order. Only who issues the write changes.

The future still drives `advance_forward` on every poll, and that is not
redundant. The first attempt made it park-only, on the assumption that
the handlers covered every path -- they cover the ones carrying data.
EOF does not: `handle_recv_multi` notes the FIN and wakes, the future
parked again, and a truncated forward hung forever. 47 of 60 proxy runs.
Driving it from every poll means any wake, from any path, can finish the
forward; the handlers remain the reason the common path never needs one.

Verified on io_uring: clippy -D warnings clean, full suite green, and
the ringline-rs#415 proxy regression test 0/60 (47/60 before the EOF fix).
Performance not yet claimed -- the A/B is next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDHdYwqsnPN4EkhRBvbDgE
Built, correct (proxy gate 0/60 on io_uring), and ~3% slower. The
diagnostic that matters: instructions per byte is unchanged, 1.236
baseline vs 1.243 with the change. Removing a task wake per provided
buffer moved nothing, so the ~6,600 instructions per completion from
ringline-rs#415 does not live in the scheduler round-trip.

That leaves exactly one difference from run_direct_echo, the one this
entry deferred: direct echo gathers a drain's worth of buffers into one
send (ringline-rs#397), Mode A writes one buffer per write. Per N buffers that is
N+1 completions against 2N, which is ~4,300 instructions per buffer at
16 KiB and is now the only remaining explanation. Gathering is the next
attempt.

The prototype stays on a branch rather than landing: it is a correct
regression, and its only value is as a prerequisite for gathering, which
has not been shown to work yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDHdYwqsnPN4EkhRBvbDgE
Mode A wrote one held buffer per write; `run_direct_echo` has coalesced
a drain's worth into a single send since ringline-rs#397. ringline-rs#416 measured what that
costs: 1.24 instructions/byte against direct echo's 0.985 at the same
16 KiB buffer, over a ~0.86 kernel floor. Removing the per-buffer task
wake recovered none of it (the direct-forward NO-GO), which left the
completion count as the only remaining explanation: per N buffers, N
recv + N write CQEs against direct echo's N + 1.

So a forward write now takes up to MAX_FORWARD_IOV (16) held buffers as
one `sendmsg` (sockets, keeping MSG_WAITALL, which a bare writev would
lose) or `writev` (files). Ordering is unchanged: iovecs in hold order,
still one write in flight per connection.

SQE memory must outlive the operation, and here that is three things —
the buffers, the iovec array, and the msghdr pointing at it. All live in
the driver's ForwardWriteState, and the state is installed *before*
pointers into it are taken, since moving it afterwards would leave
msg_iov's owner dangling even though the iovec heap block survives.

Bid release is now batch-wide, on completion, submit failure, and
fail_forward_write. A short write rebuilds the iovec array from
`written` rather than re-slicing: whole consumed buffers drop out and
the first survivor starts mid-buffer. The rebuild only ever runs between
operations.

The cap is 16 because a batch's bids all return together, so a large
batch delays them and coarsens the hold-cap throttle -- and 16 x 16 KiB
is already past the ~100 KB where ringline-rs#416 measured socket queues stop
filling recv completions.

Two unit tests asserted the single-buffer shape (`total == 5`, hold
dropping by one) and now assert the batch, strengthened rather than
merely renumbered: gathered iovec counts, the rebuilt array covering
exactly the bytes still owed, a fully-written buffer dropping out of the
iovecs, and no bid returning until the batch completes -- that last one
guards the new way to break "exactly one replenish per bid".

Verified on io_uring: clippy -D warnings clean, full suite green, the
ringline-rs#415 proxy regression test 0/60. Performance not yet claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDHdYwqsnPN4EkhRBvbDgE
Three interleaved reps each at the default geometry: 11.23 -> 14.60
Gbit/s, instructions per byte 1.236 -> 0.906 (past direct echo's 0.985,
within 5% of the ~0.86 kernel floor). Per-buffer overhead falls from
0.38 to 0.05 instr/byte, so 87% of Mode A's avoidable cost was the
completion count. Ranges do not overlap.

With the failed direct-forward attempt this decomposes the ~6,600
instructions per completion: the scheduler round-trip was ~0, the
completion count was ~all of it. The NO-GO earned its cost by
eliminating the wrong explanation.

It also closes ringline-rs#416's one exception. At equal memory the default now
measures 14.60 against 15.13 at 64 KiB and 14.22 at 256 KiB -- within
3.5% of best, the same order as its deficit on every echo workload, and
256 KiB is now worse than the default. So 's sizing section
is retired rather than revised: the answer to 'what geometry should I
use' is the default, for everything measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDHdYwqsnPN4EkhRBvbDgE
@brayniac

Copy link
Copy Markdown
Contributor Author

Split into #418 (bench harness), #419 (prefault knob), #420 (sweep docs + tooling), #421 (gathering, +30%). Each is independent off main and carries only its own diff; this branch had grown to cover harness work, a config knob, two journals, and a runtime change, which is too much for one review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant