Skip to content

feat(relay): serve QUIC from pinned per-core workers, steered by connection ID (M1) - #2921

Merged
kixelated merged 8 commits into
devfrom
claude/issue-2875-m1-e22385
Aug 20, 2026
Merged

feat(relay): serve QUIC from pinned per-core workers, steered by connection ID (M1)#2921
kixelated merged 8 commits into
devfrom
claude/issue-2875-m1-e22385

Conversation

@kixelated

@kixelated kixelated commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Milestone 1 of #2875. Targets dev because moq-tokio only exists there (M2's rename landed in #2896) and because bind::udp changes signature.

The Linux-only tests were last run on kernel 6.19 aarch64 against the pre-restructure code. They type-check but have not been re-run since; CI is the gate.

What this does

runtime.workers = N moves QUIC off the shared work-stealing runtime and onto N threads, each pinned to a core, each running a current_thread runtime and owning one socket in a SO_REUSEPORT group on the listen address. A connection is handled start to finish by one worker: its QUIC driver, its session, and its tasks never leave that thread.

Everything that is not QUIC stays on the shared runtime (web, internal, tcp/unix, clustering, signals), matching decision 4 in the epic. The workers reach the rest of the relay through the shared Cluster, exactly as sessions on one runtime already do, so 1:N fanout across worker threads goes through the existing origin model rather than anything new.

Off by default; the shared runtime is unchanged when runtime.workers is unset.

Steering

Address hashing is the wrong key for QUIC. The kernel picks a reuseport member by hashing the packet's 4-tuple, so a client that changes address — a NAT rebinding, a network change, plain connection migration — hashes to a different worker and its packets arrive at a socket that has never heard of the connection. It dies rather than migrates. A worker pool without steering is not a mode with a caveat, it is a mode that drops connections, which is why this landed here rather than in a follow-up.

The new moq_tokio::steer module does the standard thing:

  • Each worker issues connection IDs whose first byte is congruent to its index modulo the group size. That reserves count values of one byte and keeps 256 / count of its randomness; the other 19 bytes stay fully random. Connection IDs are not secrets, but they are unlinkable only while they look random, so this spends as little of that byte as it can.
  • A classic-BPF filter on the group reads that byte back and reduces it, which is the index the kernel selects with. SO_ATTACH_REUSEPORT_CBPF runs the program with the UDP payload at offset 0 (the kernel pulls the header off first), so the whole rule is seven instructions: branch on the header form bit, load the connection ID's first byte from offset 1 (short) or 6 (long), mod, return.

A client's first packets carry an ID the client invented, which encodes nothing. Those hash arbitrarily, and that is correct: whoever takes the Initial owns the connection and issues IDs naming itself, so every later packet steers to it. A retransmitted Initial repeats the same client-chosen ID and so reaches the same worker, which is what stops a retry from starting a second handshake somewhere else.

Classic BPF rather than the SK_REUSEPORT + BPF_MAP_TYPE_REUSEPORT_SOCKARRAY program named in the epic. The rule is small enough to express in cBPF, which needs no CAP_BPF, no clang or aya in the build, no map lifecycle, and no precompiled object in the repo. The trade is that the kernel selects by position, so the group must be built once, in order, and never resized — which is what Workers::bind already does, and it now says so. eBPF stays available later if dynamic membership or richer steering turns out to matter; the steer module's surface hides which one is in use.

Steering needs a backend whose connection IDs we choose. quinn (the default) and noq expose a CID generator hook; quiche does not, through web-transport-quiche's ez builder, so it refuses to start with workers rather than silently falling back to address hashing. QUIC-LB wants the same bytes of the connection ID, so that combination is refused too.

API

The worker pool lives in moq_tokio::worker, not in the relay: everything about it is generic except which cluster a worker serves into, what its accept loop does, and the flags an operator types. The bind order is the reason it belongs there at all — the cBPF filter selects a member by position, and that invariant should not be held by a for loop in another crate.

let mut workers = worker::Workers::bind(listen, quic, worker::Config::new(8))?;
for (server, spawner) in workers.split() {
    spawner.run(my_accept_loop(server));   // runs on that worker's thread
}
workers.shutdown().await;
  • worker::Workers owns the threads, the pinning, the ordered bind, and the teardown. It refuses what it cannot serve correctly: a generated certificate, an ephemeral port, and a group larger than the 256 members the one-byte steering prefix can name.
  • worker::Spawner<'_> borrows the group and can only spawn onto its own worker's thread. The lifetime is load-bearing: a member that could be dropped on its own would leave the reuseport group, and the kernel moves the last socket into the vacated slot, renumbering a sibling out from under every connection ID already issued against it. Threads are released together or not at all. Not yet closed: split hands back an owned Server, which owns the socket, so dropping one without running it — or letting its accept loop return while siblings serve — still resizes the group. The relay stops every worker as soon as one finishes, so its window is the shutdown it was already in, but moq-tokio does not enforce that for an embedder. Documented on split and tracked in QUIC workers: dropping one split() Server resizes the reuseport group #2964; the SK_REUSEPORT map-based selector from Thread-per-core relay runtime: io-uring + quiche, eBPF connection steering, rename moq-native to moq-tokio #2875 retires the whole class.
  • Workers::shutdown is async and hands the thread joins to the blocking pool. Drop still joins, so a group dropped without it releases its port rather than leaking threads; it is just not the path the relay takes, where the drop would land on a shared executor thread.
  • bind::Udp, an options struct for bind::udp (the repo's "options struct, not positional parameters" rule), carrying with_reuse_port. It is a signature change, so bind::udp(addr) becomes bind::udp(Udp::new(addr)). I first tried impl Into<Udp> to keep those compiling and backed it out: it breaks inference at every bind::udp("...".parse().unwrap()) site with an E0283, a papercut every downstream caller would hit.
  • listen::Config::init_streams, a server with only the tcp/unix listeners, for a process whose QUIC lives elsewhere. This makes "no QUIC" expressible: bind: None already means "open the default QUIC listener" when nothing else is configured, so the stream-only case needed a constructor rather than a config field.
  • steer, listen::Shard, and the per-server listener selection are all crate-private. A shard is only meaningful as a member of a group that was bound once, in order, so Workers is the only thing that can mint one.

moq-relay keeps RuntimeConfig — the --runtime-* flags, the genuinely relay-specific half — and Relay::workers is an Option<moq_tokio::worker::Workers>, bound during Relay::load (so a port conflict is a startup error, not a worker that quietly died) and split in Relay::run, because the cluster it serves into does not exist yet at bind time.

Fixed after adversarial review

Two Codex review passes. Round one, against the first commit:

  • Workers were detached. serve consumed the pool and kept only the done receivers, so cancelling it left the threads accepting into a cluster nobody was driving, and a replacement pool would join the same reuseport group as the orphans. The group now owns its threads and stops them as a unit. dropping_the_workers_releases_the_port is the regression; with the teardown neutered it fails with AddrInUse.
  • An ephemeral listen port silently split the group. :0 gave each worker a port of its own, leaving all but the first unreachable behind an address that read as bound. Each worker's bound address is now compared against the first.
  • The noq backend never installed its shard CID generator. It defined ShardIdGenerator and left endpoint_config on random IDs, so a noq worker group would steer on a byte encoding nothing and blackhole every packet after the handshake. It compiled clean because noq is not a default feature.
  • A doc claim that was not true. Each worker loads and watches the certificate files itself, so a rotation is not atomic across the group (moq-relay: TLS rotation is not atomic across thread-per-core QUIC workers #2924).

Round two, against the restructure:

  • The group could be broken up. IntoIterator for Workers handed out owned members, each releasing its own socket — the resize the module documents as never happening. split + a borrowed Spawner replaces it.
  • Drop blocked a shared executor thread on an unbounded thread::join. Workers::shutdown does the joins on the blocking pool, and Relay::run awaits it.
  • Groups above 256 panicked during connection-ID generation: 256 / count is zero at 257, so random_range(0..0) fired on the first server-issued CID, and members past 255 could never be addressed anyway. Refused at bind, with the 256 boundary covered in the encoding test.
  • A stream-only server tripped the no-QUIC-backend guard, which keyed off config.bind rather than asking whether the caller wanted QUIC at all.

Not fixed here, with reasons:

Constraints

  • Linux only. bind::udp fails with Unsupported elsewhere rather than binding a group the platform will not balance: macOS and the BSDs accept SO_REUSEPORT and then deliver a unicast flow to a single member, so the workers would come up looking healthy with one of them serving everything.
  • Needs quinn or noq, not quiche (no CID hook), and cannot be combined with --listen-quic-lb-id.
  • The listen address needs an explicit non-zero port, and a group is capped at 256 members by the steering prefix.
  • --listen-tls-generate is refused with workers.
  • The shared runtime still sizes its pool to the machine, so --runtime-workers N on an N-core box gives N pinned threads plus a full stealing pool. Documented: set TOKIO_WORKER_THREADS to bound it. Worth revisiting if the M1 profile shows it as noise.

Testing

just check and just test pass on macOS (3368 tests), rebased onto current dev. The reuseport and steering paths do not compile there, so they ran in a container on kernel 6.19 aarch64 — 21 tests, all passing:

  • steer::a_connection_id_reaches_its_own_member — the one that matters. Four members; for each, a short-header and a long-header packet carrying that member's connection ID, each sent from a fresh ephemeral source port so the 4-tuple hash cannot be what routes it. Every packet must land on its own socket. Mutation-checked: with the filter detached it fails on the very first packet, so it measures steering rather than luck.
  • steer::a_prefix_reduces_to_its_own_shard / prefixes_cover_every_shard — the codec round-trips for group sizes 1..255, and no member is unreachable.
  • steer::the_program_branches_to_the_right_loads — the cBPF jumps are offsets from the following instruction, so an off-by-one reads the wrong byte instead of failing to load.
  • bind::udp_reuse_port_* — the group forms, a member that forgets the option loses the port, and the kernel really does spread across it.
  • moq-tokio tests/worker.rs — the group releases its port both via shutdown and via Drop, an ephemeral port is refused, a lone worker may use one, a generated certificate is refused, and a group of 257 is refused.
  • moq-relay tests/runtime_workers.rs — a real relay with 4 workers serving a publisher and 4 subscribers over QUIC, so the frame crosses worker threads through the shared origin.

Cross-package sync

moq-relay config change → doc/bin/relay/config.md (new [runtime] section). No wire format change — connection IDs are opaque to the peer and this changes only how we choose ours — so no drafts/ update. No moq-ffi surface change, so no binding rows apply. moq-bench is unaffected; the M0 suite runs against this mode unchanged.

Completes M1 of #2875, which stays open for M3 onward.

🤖 Generated with Claude Code

(written by Opus 5)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2a85de2680

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-relay/src/runtime.rs Outdated
Comment on lines +120 to +123
std::thread::Builder::new()
.name(format!("moq-quic-{shard}"))
.spawn(move || run(shard, core, listen, quic, ready_tx, start_rx, done_tx))
.with_context(|| format!("failed to spawn QUIC worker {shard}"))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Shut down workers when the serve future is dropped

If an embedder cancels Workers::serve, or Relay::run returns because the cluster, web, internal, or shared server branch fails, these detached threads retain their servers and continue accepting on the QUIC sockets indefinitely. The discarded JoinHandle and lack of a cancellation path also prevent the caller from reclaiming the port or reliably restarting the relay in-process. Keep an owned shutdown/join guard whose Drop stops every started worker. rs/CLAUDE.mdL128-L128 (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Comment thread rs/moq-relay/src/runtime.rs Outdated
Comment on lines +111 to +112
for shard in 0..count {
let listen = shard_config(config, shard, count)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bind all shards to the first allocated port

When listen.bind uses port 0, each call to shard_config preserves :0, so Linux allocates a different ephemeral port for every worker instead of forming one reuseport group. Only the first address is stored and exposed through Relay::addr, leaving the remaining workers unreachable at that address and defeating the configured pool; bind the first shard, then use its selected port for subsequent shards. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

@kixelated kixelated changed the title feat(relay): serve QUIC from pinned per-core workers (M1, part 1) feat(relay): serve QUIC from pinned per-core workers, steered by connection ID (M1) Aug 19, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f7d666bd13

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-tokio/src/noq.rs Outdated
}

let socket = crate::bind::udp(listen).map_err(Error::BindSocket)?;
let socket = crate::steer::bind(listen, config.shard).map_err(Error::BindSocket)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Install the shard CID generator for noq workers

When listen.backend = "noq" and more than one runtime worker is configured, this creates a steered reuseport group but leaves endpoint_config using noq's default random connection IDs (or the QUIC-LB generator). Once the client switches to a server-issued CID, its packets are therefore routed to cid[0] % worker_count rather than necessarily returning to the worker that owns the connection, so handshakes and established connections fail with probability increasing with the worker count. Select ShardIdGenerator when config.shard is set and reject the QUIC-LB combination as the Quinn path does. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Comment thread rs/moq-tokio/src/steer.rs
Comment on lines +75 to +76
let strides = 256 / count;
let stride = rand::rng().random_range(0..strides);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject shard counts above the one-byte encoding limit

When --runtime-workers is greater than 256, Shard::new still accepts the count, but 256 / count is zero here and random_range(0..0) panics as soon as a backend generates a server CID. Even without the panic, a one-byte prefix cannot select workers with indices above 255. Reject these counts while constructing Shard or use a wider steering encoding so this invalid pool cannot reach the serving path. rs/CLAUDE.mdL128-L128 (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Comment thread rs/moq-tokio/src/server.rs Outdated
quic.validate()?;

let build_quic = config.bind.is_some() || !config.has_stream_listener();
let build_quic = config.listeners.quic() && (config.bind.is_some() || !config.has_stream_listener());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor stream-only mode in backend-free builds

When moq-tokio is compiled with tcp or uds but without any QUIC backend, a caller using Listeners::Stream with a populated bind still hits the NoBackend guard below because it tests config.bind rather than build_quic. This contradicts the new stream-only mode's contract that QUIC is suppressed even when the shared config contains a QUIC bind, preventing backend-free consumers from reusing that configuration. Gate the error on build_quic. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Comment thread rs/moq-relay/src/runtime.rs Outdated
Comment on lines +167 to +170
let ready = ready_rx
.recv()
.map_err(|_| anyhow::anyhow!("QUIC worker {shard} exited before binding"))?
.with_context(|| format!("QUIC worker {shard} failed to listen"))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Join workers when pool construction fails

Although the new Workers::Drop addresses the prior detached-worker report for successfully constructed pools, this fresh error path still returns after spawning the current thread but before storing its JoinHandle in a Worker; previously stored Worker values also have no individual Drop. If any later worker fails to bind or initialize, all those handles are detached and Workers::bind can return before their sockets close, so an immediate in-process retry can join a reuseport group that still contains orphaned members. Keep partial construction under a guard that signals and joins every spawned thread before returning the error. rs/CLAUDE.mdL128-L128 (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

@kixelated

Copy link
Copy Markdown
Collaborator Author

Benchmarked with perf, before and after

Took this over and measured it on x86-64 Linux (Ryzen 7 5800X, 8 cores / 16 threads, kernel 7.1.3, perf_event_paranoid=1 so the counters include kernel time).

Setup

Built the PR head at f7d666bd with the repo's profiling profile. The relay gets logical CPUs 0-3, which are four distinct physical cores on this part, and moq-bench gets 4-7,12-15, so the two never share a physical core. TOKIO_WORKER_THREADS=4 in every arm, so both modes get an identical four-core budget: this measures how the relay spends four cores, not how many it can grab.

Load is 100 publishers x 8 subscribers each, every knob a scalar rather than a [min, max] range so each arm sees byte-identical work instead of a fresh roll. Three repeats per configuration, modes interleaved within a repeat rather than run in blocks, so drift lands on all of them equally.

Efficiency, at 3.02 Gbps egress delivered identically in all three arms

mode relay CPU cyc/byte IPC ctxsw/s migr/s LLC miss/byte
shared runtime (before) 152% 14.84 0.87 6382 56 0.353
workers = 4, pinned 123% 12.75 0.98 2668 13 0.287
workers = 4, pin = false 125% 12.88 0.97 2747 54 0.286

Per-repeat ranges do not overlap (before 14.20-15.48 cyc/byte, after 12.62-13.18). That is 18.6% less relay CPU per delivered Gbps.

Capacity, at the knee

mode egress (median [min-max]) relay CPU loss
shared runtime 8.42 Gbps [7.74-8.64] 379% 0.02%
workers = 4 9.07 Gbps [9.06-9.07] 344% 0.00%

7.7% more throughput on 9% less CPU, so 15.8% less CPU per Gbps. Worth noting the ranges as much as the medians: the baseline oscillates at the cliff while the worker pool sits flat across all three repeats.

Where the saving actually comes from

Not from doing less work, and not from the syscall or lock path:

  • instructions/byte: 12.90 -> 12.55 (-2.7%, essentially flat)
  • IPC: 0.87 -> 0.98 (+13%)
  • LLC misses/byte: 0.353 -> 0.287 (-19%)

The same instructions retire, about 13% faster, because a connection's state stays in one core's cache instead of bouncing between threads. That accounts for essentially the whole 14% drop in cycles per byte. Lock contention does halve (native_queued_spin_lock_slowpath 1.43% -> 0.64% of a core, futex 1.46% -> 0.85%), but at roughly 1.4% of a core it is far too small to explain a 29 point CPU saving. The flat profiles agree: no symbol changes rank between modes, the whole profile just gets cheaper. So the mechanism is memory locality, which is exactly what the module doc claims, but the evidence for it is IPC and cache misses rather than context switches.

Two findings that argue with the PR

pin = true buys nothing measurable here. Pinned is 123% CPU and 12.75 cyc/byte; unpinned is 125% and 12.88. That gap is inside the run-to-run range, in both directions across repeats. The win is the socket-and-runtime-per-worker split; core pinning is neutral on this box. RuntimeConfig::pin currently documents pinning as "the point of the mode", and this data does not support that. It may well pay off on a multi-socket box or under NUMA, but on a single-socket part it does not, so the doc comment is overclaiming for the common case.

The workers are imbalanced 1.1x to 1.6x, busiest to idlest (median 1.5x pinned, 1.2x unpinned). Per-worker CPU ticks over one 30s window, pinned: 657 706 460 551. Unpinned: 672 608 415 530. So pinning is not the cause. Connection to worker assignment is the kernel's 4-tuple hash of each client's first packet, and 100 connections into 4 buckets carries real binomial spread, which then persists for each connection's lifetime because every later packet follows the connection ID. This is inherent to the design rather than a bug, but it means N workers deliver meaningfully less than N cores of capacity, and it is worth a line in doc/bin/relay/config.md next to the TOKIO_WORKER_THREADS note. It should improve with connection count.

Test coverage on a second architecture

The PR body notes the reuseport and steering paths only ran in an aarch64 container. They pass here on x86-64 Linux: all 200 moq-tokio lib tests (including the four steer::* and bind::udp_reuse_port_*) plus the four runtime_workers integration tests.

Caveats

Loopback, both processes on one box, so part of the network stack cost is charged to whichever side sent. Equal across arms, but it is not a NIC. Four workers on four cores with 100 connections. And the shared runtime was idle throughout (0-2 ticks across its four threads) because there is no web or cluster load in this profile, so the PR's own concern about workers competing with a full second pool is untested by these numbers.

(Written by Opus 5)

@kixelated

Copy link
Copy Markdown
Collaborator Author

Follow-up: quinn's single-threaded receive loop, and UDP buffer tuning

Two questions came out of the numbers above. Same box and setup as before (Ryzen 7 5800X, relay pinned to 4 physical cores, load generator on 4 others).

1. The single-threaded receive loop is real, but it is not what limits us at this size

EndpointDriver (quinn 0.11.11, src/endpoint.rs:364) is one future per endpoint, and its poll holds self.0.state.lock() across both drive_recv and handle_events. So inbound routing for an endpoint can never exceed one core no matter how many you give it. It self-wakes rather than pinning a thread, which is why per-thread CPU looks evenly balanced while the work is still serialized.

I could not saturate it here. At the highest packet rate this box can produce:

baseline (1 endpoint) workers = 4 (4 endpoints)
relay CPU 260% 327%
receive syscall path 0.77% of samples = 2.0% of a core 1.56% = 5.1% of a core, about 1.3% each
__sys_sendmsg inclusive 18.0% 21.3%

The driver is at roughly 2% of its one-core ceiling, and EndpointDriver::poll does not reach the top 20 symbols. The relay is bottlenecked on the send path.

The reason is structural rather than a bad test: at this workload the driver costs about one core for every 130 cores of other relay work, so it only becomes binding once the rest of the relay wants ~100+ cores. A four-core test saturates everything else long first. Saturating it needs roughly 2.5-5M inbound datagrams/sec, which is ~25-50 Gbps of MTU-sized traffic, or much less if the packets are small (an ACK flood from high fanout is the realistic path).

Worth being explicit about two measurement limits. Loopback charges the receiver's IP/UDP/netfilter work to the sender's syscall context, visible in the call graph as udp_sendmsg -> udp_send_skb -> ... -> udp_rcv -> udp_queue_rcv_one_skb, so the relay's inbound cost is understated. And the split makes receive slightly more expensive in absolute terms (5.1% of a core across four sockets vs 2.0% on one) because each socket batches fewer datagrams per recvmmsg.

So: this PR does fix the limitation structurally, taking the ceiling from one core to N. It is just not the source of the 15-19% gain measured above, which was cache locality.

2. We never tune the UDP socket buffers, and that turns out to be correct

There is no setsockopt(SO_RCVBUF/SO_SNDBUF) anywhere in the tree. quinn does not set one either, and quinn-udp only exposes set_recv_buffer_size for callers to use. So every socket runs at the kernel default, rb212992, about 208 KB, which is roughly 290 microseconds of buffering at 5 Gbps.

It visibly overflows. A single relay socket dropped 722,122 datagrams in 55 seconds (about 22k/sec), and RcvbufErrors accounted for 100% of UDP errors on the machine.

That looks like a clear bug, so I fixed it: 4 MB request, read the value back, warn once when the kernel caps it. Drops went to zero and throughput fell by half. Three repeats per size, alternating binaries so drift lands on both:

receive buffer goodput median (3 reps) group loss socket drops
kernel default, 208 KB 7598 Mbps (7598/6972/7799) 0.02-0.09% ~270,000
1 MB 4515 Mbps (5679/4515/4067) 0.26-0.55% 0
4 MB 3911 Mbps (3911/3880/4316) 0.50-0.81% 0

Monotonic, so there is no sweet spot above the default. SndbufErrors was 4 for an entire multi-hour session, so the send side needs nothing either. I reverted the change and left the measurement in a comment on bind::udp, because ~270k visible drops is exactly the kind of thing the next person will "fix" the same way I did.

3. Why the smaller buffer is faster

Two mechanisms, measured rather than assumed. I had guessed quinn's 50us RECV_TIME_BOUND would make the driver spin and re-wake; that was wrong, context switches went down, not up.

stock, 208 KB buffered, 8 MB
receive queue max 213,360 B (pegged at the cap) 963,791 B
context switches/s 7,978 4,002
IPC 0.75 0.54
L3 miss rate 26.7% 34.4%
relay CPU 381% 356%
groups shed/s 29 339

Cache locality is the bigger effect. A deep queue means a packet is touched up to a megabyte of traffic after it arrived, by which point its skb and that connection's state are out of cache. IPC drops 28% and the L3 miss rate climbs by a quarter. The relay runs the same instructions at three quarters of the speed, and burns less CPU while delivering half as much, which is a stall signature rather than extra work.

The drop also moves to the most expensive possible place. At 208 KB the kernel discards a packet at the socket before we spend a cycle on it. At 8 MB we admit it, decrypt it, route it, and then discard the group at the moq layer for being too old to serve: shed rate goes from 29/s to 339/s, close to 12x.

The two compound, since slower processing deepens the queue, which ages more groups out, which wastes more cycles on data that gets discarded. The arithmetic lines up: instruction throughput ratio is (0.54 x 356) / (0.75 x 381) = 0.67 while goodput ratio is 0.55, and the gap is the extra work thrown away. The small buffer is acting as an accidental AQM, and shedding early is the right behavior for live media where late data has no value.

4. This is the strongest argument yet for the worker split

In workers mode the two builds are indistinguishable: 9004/9070/9036 Mbps buffered vs 9064/9073/9075 stock, 0% loss and zero socket drops in both. Workers never fills the buffer, because four sockets each absorb a quarter of the burst, so the setsockopt is inert there.

That is also the control proving the regression above is the buffer and not some unrelated slowdown in the patched binary: it only appears when the buffer actually fills.

So the worker split fixes the socket drops in the right place. Baseline drops ~270k datagrams at the default size; workers drop zero at the same size, without paying any queueing delay for it.

5. Corrections, and how to read these numbers

  • The throughput column is goodput, not egress. moq-bench counts frame.payload.len() for frames a subscriber actually read, so it excludes headers, AEAD tags, ACKs and retransmits. My table in the comment above labels it "egress", which is wrong. Wire bytes track it closely here (8.6-9.3 Gbps of loopback tx against 8.2-8.8 Gbps of goodput for stock, 164-172k packets/sec), so the conclusions hold, but the label was sloppy.
  • moq-bench subscribes to the first want announcements in arrival order (rs/moq-bench/src/connection.rs:239-251), not to random peers. So --subscribe 48 with 100 connections is not 100 publishers each watching 48 varied peers; it is ~48 hot broadcasts with ~100 subscribers each. That is a legitimate fanout shape and it was identical across every arm, so nothing above is invalidated. But the README describes C as "peer broadcasts each connection watches", which implies a spread that does not happen, and --subscribe 1 degenerates to every subscriber piling onto one broadcast. Worth either sampling randomly or correcting the README.
  • All of this is loopback, which has no link jitter or arrival burstiness. On a real NIC 208 KB may genuinely be too small to absorb legitimate bursts and the tradeoff could land differently. The defensible claim is that raising it is not free and should not be done without measuring.

(Written by Opus 5)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 42460eaebf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-tokio/src/steer.rs
Comment on lines +50 to +52
pub(crate) fn bind(addr: SocketAddr, shard: Option<Shard>) -> io::Result<UdpSocket> {
let options = crate::bind::Udp::new(addr).with_reuse_port(shard.is_some());
let socket = crate::bind::udp(options)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent pools from joining an existing reuseport group

When two worker-enabled relay processes overlap on the same Linux address, as during a rolling restart, SO_REUSEPORT makes the new sockets join the old group instead of reporting a port conflict. The new pool then installs a filter using only its own worker count, although the old sockets occupy the first kernel indices, so traffic continues selecting the old pool; as those sockets close, Linux compacts the group and the surviving workers' encoded shard indices no longer match their positions, preventing the replacement from accepting connections reliably. The pool needs exclusive ownership of the address or cross-process coordination that preserves stable indices.

Useful? React with 👍 / 👎.

Comment thread rs/moq-tokio/src/worker.rs Outdated
Comment on lines +260 to +262
pub fn split(self) -> (Server, Runner) {
(self.server, self.runner)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the worker group under one lifetime guard

When an embedder drops one returned Runner while keeping the other workers alive, this split API removes or stalls an individual reuseport member even though the BPF program and every CID generator retain the original count and indices. Linux may move the last socket into the removed slot, so existing CIDs begin reaching the wrong worker or falling back to tuple hashing; retaining the separately returned Server can instead leave a bound socket with no runtime servicing it. Make partial teardown unrepresentable by keeping the servers and threads under one pool-owned shutdown guard.

AGENTS.md reference: AGENTS.md:L143-L148

Useful? React with 👍 / 👎.

kixelated and others added 5 commits August 20, 2026 13:31
Milestone 1 of the thread-per-core runtime plan (#2875), first half.

The relay runs one work-stealing runtime over one UDP socket, so every
packet can cross threads and every wakeup is a candidate context switch.
`runtime.workers` switches QUIC to the opposite shape: N threads, each
pinned to a core, each running a `current_thread` runtime and owning one
socket in a `SO_REUSEPORT` group on the listen address. A connection
lands on whichever worker the kernel steers its first packet to and
stays there, so its QUIC driver, session, and tasks never leave that
thread. Everything that is not QUIC (web, internal, tcp/unix,
clustering, signals, cert reload) stays on the shared runtime, and the
workers reach the rest of the relay through the shared Cluster.

Off by default. The kernel picks a group member by hashing the packet's
4-tuple, which holds a connection to one worker only for as long as the
client keeps its address: a NAT rebinding or network change hashes
somewhere else and reaches a worker that has never seen the connection
ID. Steering by connection ID is the second half of M1 and needs a
CID codec plus a reuseport filter; until then this is a measurement
knob, which is also why the shared runtime stays the default.

moq-tokio gains the two primitives the relay drives:

- `bind::Udp`, an options struct for `bind::udp`, carrying
  `with_reuse_port`. It fails with `Unsupported` off Linux rather than
  binding a group the platform will not balance: macOS and the BSDs
  accept `SO_REUSEPORT` and then feed a unicast flow to one member, so
  the workers would come up healthy with one of them serving everything.
- `listen::Shard` and `listen::Listeners`, so one process can build a
  QUIC-only `Server` per group member while a stream-only `Server`
  keeps tcp/unix on its main runtime. `Listeners` also makes "no QUIC"
  expressible, which was previously only implied by configuring a
  stream listener.

`--listen-tls-generate` is rejected with workers, since each would
generate and serve a certificate of its own; the fingerprint endpoint
reads worker 0's, and every worker loads the same files.

Verified on Linux (container, kernel 6.19 aarch64), since neither
reuseport load balancing nor the integration test compiles on macOS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Address hashing is the wrong key for QUIC. The kernel picks a reuseport
member by hashing the packet's 4-tuple, so a client that changes address
(a NAT rebinding, a network change, plain connection migration) hashes
to a different worker and its packets arrive at a socket that has never
heard of the connection. It dies instead of migrating. Without steering,
`runtime.workers` is not a mode with a caveat, it is a mode that drops
connections.

So the group is steered by connection ID, in the new `moq_tokio::steer`:

- Each worker issues connection IDs whose first byte is congruent to its
  index modulo the group size, keeping 256/count of that byte's
  randomness and all of the other 19 bytes.
- A classic-BPF filter on the group reads that byte back and returns it
  modulo the group size, which is the index the kernel selects with.
  `SO_ATTACH_REUSEPORT_CBPF` runs with the UDP payload at offset 0, so
  the whole rule is seven instructions: branch on the header form bit,
  load the connection ID's first byte from offset 1 or 6, reduce, return.

A client's first packets carry an ID the client invented, which encodes
nothing. Those hash arbitrarily, and that is correct: whoever takes the
Initial owns the connection and issues IDs naming itself, so every later
packet steers to it, and a retransmitted Initial repeats the same
client-chosen ID and reaches the same worker rather than starting a
second handshake elsewhere.

Classic BPF rather than the SK_REUSEPORT + SOCKARRAY eBPF program: the
rule is small enough, and cBPF needs no CAP_BPF, no BPF toolchain in the
build, and no map to keep alive across restarts. It selects by position,
so the group must be built once, in order, and never resized, which is
what `Workers::bind` already does.

This needs a backend whose connection IDs we choose. quinn and noq allow
it; quiche exposes no such hook through `web-transport-quiche`, so it
refuses to start with workers rather than silently hashing addresses.
QUIC-LB wants the same bytes, so that combination is refused too.

Also from review of the first commit:

- Workers were detached: `serve` consumed the pool and kept only the
  `done` receivers, so cancelling it left the threads accepting into a
  cluster nobody was driving, and a replacement pool would join the same
  reuseport group as the orphans. The pool now keeps its `JoinHandle`s
  and a per-worker stop channel, `serve` borrows instead of consuming,
  and `Drop` signals every worker and joins every thread.
- An ephemeral listen port gave each worker a port of its own instead of
  a shared one, leaving all but the first unreachable behind an address
  that read as bound. Every worker's bound address is now compared
  against the first.
- The module claimed cert reload stays on the shared runtime; each
  worker in fact watches the files itself. Documented, with the
  non-atomic rotation window that implies (#2924).

Verified on Linux (container, kernel 6.19 aarch64). The steering test
sends short- and long-header packets carrying each member's connection
ID from a fresh source port every time and asserts each lands on its own
socket; with the filter detached it fails on the first packet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The thread-per-core mode landed entirely inside moq-relay, which made the
relay the only server that can have it. Everything about it is generic
except three things: the cluster/auth/shutdown a worker serves into, the
accept loop it runs, and the flags an operator types.

So `moq_tokio::worker` now owns the pool: the threads, the pinning, the
ordered bind, and the teardown that joins them. `Workers::bind` returns a
group that is already listening; each `Worker` splits into the `Server` it
bound and the `Runner` that must drive it, so a caller supplies its own
accept loop without moq-tokio needing a callback or a handler trait. The
bind order is the reason this belongs here at all: the cBPF filter selects
a member by *position*, and that invariant was being held by a `for` loop
in another crate.

moq-relay keeps `RuntimeConfig` (the `--runtime-*` flags), which is the
only genuinely relay-specific half, and `Relay::workers` is now a plain
`Vec<Worker>`.

Two shapes came off the public surface on the way:

- `listen::Listeners` is gone. It existed because `bind: None` means two
  things ("default QUIC listener" when nothing else is configured, "no
  QUIC" when a stream listener is), so "no QUIC listener" was
  inexpressible. It is a construction choice, not configuration, so it is
  now a crate-private `server::Parts` argument plus one public
  constructor, `listen::Config::init_streams`. The enum also could not
  classify iroh, which is attached after `Server::new` and escaped the
  filter entirely.

- `listen::shard` is gone, and `listen::Shard` is crate-private. It was
  `#[arg(skip)] #[serde(skip)]` on a struct whose whole identity is clap
  and serde: the relay re-parses its config after the TOML merge, so the
  field survived only by being set afterwards. Worse, `Shard` is `Copy`
  and `Shard::new` was public, so a caller could clone one into two
  configs and bind position 2 twice, breaking steering with no error. The
  group is the only thing that can hold that invariant, so it is the only
  thing that can mint one.

Fixes a bug in the noq backend while wiring this: it defined
`ShardIdGenerator` but never installed it, so a noq worker group would
issue connection IDs encoding nothing while the filter steered on them.
Every packet after the handshake would hash to an arbitrary member. It
now installs the generator and refuses QUIC-LB, matching quinn.

Also fixes the workers being built with a default `quic::Config` rather
than the operator's transport tuning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three findings from review, all in the worker/steering path.

`cid_prefix` spends one byte of the connection ID on the member index, so
a group can have at most 256 members. Nothing enforced that: the count is
a `u16`, and at 257 `256 / count` is zero, so `random_range(0..0)` panics
on the first server-issued connection ID. A relay configured that way
binds fine and then loses a worker to a panic on its first connection.
Even without the panic, `byte % count` can never return an index past
255, so those members would sit idle. `Workers::bind` now refuses the
group before spawning a thread or binding a socket, and the encoding test
covers the 256 boundary where each member has exactly one stride.

A stream-only server no longer trips the no-QUIC-backend guard. It keyed
off `config.bind`, so a build with `tcp` but no backend feature refused to
start with a bind configured, which is precisely the case
`Config::init_streams` exists to serve. It now also asks whether the
caller wanted QUIC at all; leaving everything else configured is still the
same error.

A worker that dies before reporting is joined rather than detached. Its
siblings were already released by `Runner`'s `Drop` when the group's `Vec`
unwinds, but the thread that actually failed was the one left behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two problems with the shape `worker::Workers` landed in, both found in
review.

`IntoIterator for Workers` handed out owned `Worker`s, each releasing its
own socket on drop. The kernel numbers a reuseport group by position and
moves the last socket into whatever slot a `close` vacates, so one member
leaving renumbers a sibling out from under every connection ID already
issued against it: the steering filter still reduces modulo the original
count, and traffic for the moved member goes somewhere else entirely. The
module documents that the group is bound once and never resized, and then
handed out the pieces to resize it with.

So the group keeps its members. `Workers::split` hands out each bound
`Server` paired with a `Spawner<'_>` that borrows the group and can only
spawn onto its thread, which keeps the caller supplying its own accept
loop without letting a member leave. Sockets are released together, by
`shutdown` or by dropping the group.

`Runner::drop` also joined its thread, and the relay holds these inside
async `Relay::run`, so a shutdown or a failure elsewhere in the `select!`
blocked a shared executor thread on a join with no bound. `Workers` now
has an async `shutdown` that signals every worker and does the joins on
the blocking pool, and `Relay::run` awaits it after the loop resolves.
`Drop` still joins, so a group dropped without `shutdown` releases its
port rather than leaking threads; it is just no longer the path the stock
relay takes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kixelated
kixelated force-pushed the claude/issue-2875-m1-e22385 branch from f91816c to ca558c3 Compare August 20, 2026 20:31

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ca558c30f3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

let core = cores.get(index as usize % cores.len().max(1)).copied();
let shard = Shard::new(index, count).expect("index is below count");

let worker = Worker::spawn(listen.clone(), quic.clone(), shard, core)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve the worker bind hostname only once

When the documented listen.bind form is a DNS hostname with multiple local A/AAAA addresses whose ordering rotates between queries, every Worker::spawn resolves the cloned hostname independently, so members can bind different addresses and Workers::bind fails with WorkerPortMismatch nondeterministically. Resolve the hostname to one SocketAddr before this loop and pass that same address to every worker.

Useful? React with 👍 / 👎.

kixelated and others added 3 commits August 20, 2026 14:36
`Workers::split` hands back an owned `Server`, and a `Server` owns its
socket. The `Spawner` borrow keeps a caller from dropping a worker's
thread on its own, but not its socket, so dropping one of these servers
(or letting the future built from it return while its siblings serve)
still takes a member out of the reuseport group. The kernel then moves the
last socket into the vacated slot and every connection ID encoding the
moved member steers out of range.

Making that unrepresentable means handing the group a closure to build
each accept loop from, which this crate keeps out of its public API and
which is why `split` exists in the first place. So the obligation is
documented and tracked in #2964 rather than half-enforced: run every
server, and treat the first one that finishes as the end of the group,
which is what `Relay::run` does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The PR's benchmarks measured pinned and unpinned workers inside run-to-run
noise of each other on a single-socket box: the win is the socket-and-runtime
-per-worker split, not core affinity. Saying pinning is the point of the mode
would steer an operator toward the wrong knob, so the pin docs now say what
was measured and why the default stays on (NUMA, and it costs nothing).

Also document the measured 1.1-1.6x worker imbalance in the relay config doc:
connection placement is the kernel's hash of the first packet, so a worker is
somewhat less than a core of capacity, narrowing as connection counts grow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g failure

A Workers::bind that failed midway (a port mismatch, a member that failed to
spawn) dropped the members it had already started without joining them, so its
error returned while their sockets were still closing. An owner that
immediately rebound the address could join the half-dead reuseport group and
be renumbered by the kernel when it finished dying. Worker now signals and
joins in Drop; a whole group still tears down through Workers, which takes the
threads first and leaves the member Drop a no-op.

The regression test drives the real partial-construction path (an ephemeral
port with two members) and then plain-binds the first member's address, which
only succeeds once that socket is really gone. Mutation-checked on Linux:
with the join removed it fails immediately.

Also resolve a hostname listen address once for the whole group instead of
once per worker, so a DNS answer that rotates between queries cannot hand
members different addresses and fail the bind nondeterministically. An unset
bind stays unset, since the backends fall back to a literal default and
folding it in would flip a stream-only config into opening a QUIC listener.

Linux-only paths ran in a container on kernel 6.19 aarch64: the 7 worker
integration tests, all 207 moq-tokio lib tests, and the relay's
runtime_workers test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 091f846434

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-relay/src/relay.rs
#[allow(unused_mut)]
let mut server = config.listen.init(config.quic.clone())?;
let mut server = match &workers {
Some(_) => config.listen.clone().init_streams()?,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip the empty shared server in worker-only mode

When runtime.workers is set without a listen.tcp or listen.unix listener, as in the documented QUIC-only configuration, init_streams() creates a server with no transports. Relay::run still passes that server to serve, whose first accept() returns None; it then reports stopped accepting connections, wins the select!, and shuts down every QUIC worker immediately. The integration test bypasses Relay::run, so it does not exercise this failure. Skip the shared-server arm when it has no listener, or keep that arm pending in worker-only mode. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

@kixelated
kixelated enabled auto-merge (squash) August 20, 2026 23:46
@kixelated
kixelated merged commit ee5c8be into dev Aug 20, 2026
5 checks passed
@kixelated
kixelated deleted the claude/issue-2875-m1-e22385 branch August 20, 2026 23:49
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