Repository navigation
perf(moq-net): fanout subscribers stop contending the shared track lock - #7493
Conversation
Quest-Claim: 5d62a7c0f2948cbf
…n it holds news A relay serving one track to many subscribers woke every subscription's serve task for each group, and each walked the logical-track reader and its copy's cursor several times per wake, taking the shared track lock about 155 times per delivered group. Spread across Tokio workers, those tasks serialized on it. - The lite publisher skips its track side when nothing it parked on fired, so a group machine's frame or the writer's credit no longer walks the track. - A reader learning the end checks a lock-free flag the track sets once it declares one, instead of polling every copy's end under the lock. - The retention edge is read only when a replaced copy needs it, a settled window syncs without the lock, and a probe judges and clones the fills under one lock, skipping expiry and filled-group lookups with nothing owed. - session_fanout_threads serves 64 viewers through one relay on 1 and 16 workers, timed in process CPU. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uiet host Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/fanout-lock # Conflicts: # rs/moq-net/src/util.rs
…s CPU clock here Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # quest/m1/release-perf/README.md # rs/moq-net/src/model/track.rs
…he lite publisher #7263 landed its cursor gate first, so this PR's gate becomes the shared one: util::Idle is keyed by the read that parked, and the cursor's gate is util::Idle<Walk>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eads Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 36 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: feafb43
No actionable bugs found in the changed paths. The direction is sound: eliminate redundant shared-track reads, and reuse the existing keyed idle gate rather than introduce a second wake-tracking mechanism. The publisher keeps flushing and driving group machines outside the gate; updates, stream-credit progress and machine retirement explicitly reopen it. The end flag still defers reading the actual end to the track lock. No public API or wire-format changes identified.
Verification: static inspection of the diff and relevant wake, cursor, end-learning and benchmark code only. I did not run tests or benchmarks or independently reproduce the reported performance results.
(Written by OpenAI)
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Lite publisher idle gate:
TrackRun::poll_stepis split so the track side (poll_track) is skipped while itsIdlegate is parked, disarmed onSUBSCRIBE_UPDATE, a resolved opening group, and any child-machine retirement. Everypoll_trackPendingreturn is backed by a registration on the gate's waiter or one of those disarms. - Lock-free end flag:
cache::Trackrecords that an end was declared, soSubscriber::declared_end(used byresume::Reader::learn_end) skips the track lock until there is an end to read, and otherwise returns exactly the previouspoll_finishedresult. - Cheaper locked reads:
let_gocomputes the retention edge only when a copy is replaced,Cursor::syncreturns early for a settled window,probejudges and clones fills under one lock, andexpire/next_filledare skipped when they owe nothing, each matching the removed code's own early-outs. - Shared
util::Idle<K>: the cursor gate from #7263 is generalized and reused by the publisher withK = (); park/wake semantics are unchanged and the keyed-read test is added. - Bench + quests:
session_fanout_threadsreproduces the fanout contention on 1/16 workers in process CPU (Unix-only, skipped under the alloc/expiry modes); the finished quest is deleted and the epic plus bisect note are repointed with no dangling links.
I verified cargo check -p moq-net --all-targets --all-features and --no-default-features are clean (including the new bench), and cargo test -p moq-net --lib util::idle passes.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
#7539 landed, so quic-lb-forward-extras drops its Required on the deleted relay-quic-lb-forward quest. Follow-ups that extend still-open quest PRs now require them: ts-small-step-back (#7221), quic-long-type (#7492), and ts-idle-jitter (#7504). release-fanout-bench notes #7493 landed. Apply the pending edits whose PRs have landed: lite-07 ranges drops its fetch-range Required (#7545 landed the miss judging and cached backfill) and records #7207's range-identity question; quic-lock-contention records #7531's recommendation to keep the Mutex and RefCell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main generalized #7263's gate into `util::Idle<K>` (#7493), so the PR's own `model/idle.rs` copy of it goes: the model module keeps `Walk` and re-exports the shared gate. Main's `Waiter::noop` flag (#7532) and this branch's merge into one, which both skips channel parking and registers nothing. A resumed read passes main's `parks` (#7545) to `poll_lowest` behind its gate, and the route registration is renamed `register`, since main added a `Consumer::watch`. `ts-idle-jitter` no longer waits on this quest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Problem
Under the relay
fanoutworkload (64 subscribers of one track),main's relay CPU was bimodal. In some rounds the 64 subscription tasks spread over every Tokio worker and serialized on the track's sharedkio::Lock, and relay CPU went to 0.18 to 0.23 cores with about 10k context switches/s. Release stayed at about 0.08. See the release-perf epic and moq-dev/moq#7299.Finishes
quest/m1/release-perf/fanout-lock.md, which this PR deletes. The epic keeps its own remaining work: a re-pair on a quieter host once this lands.What release did differently
I counted every
kio::Lock::lockby type and caller, using an instrumented build on one worker under the real fanout workload. Per delivered group:mainTrackState(shared by all 64 readers)Route/ release'sResumeState)Both branches poll each subscription about 4 times per group. On
main, each of those polls walked the logical-track reader (resume::Reader) several times (poll_droptwice,poll_recv_group, datagrams,poll_finished), and each walk re-took the track lock:Reader::learn_end(None)ran about 10 times per poll (fromsync,let_go, andpoll_finished), and each run polled every copy's end under the lock. That was 33% of the futex wait in a high-mode profile.Cursor::probetook the lock 3 times: once to clone the fills, once to judge, and once to expire. That was 17%.let_goread the serving copy'slatest_sequence(2 locks) even when no replaced copy needed it. That was 12%.Cursor::synctook the lock with a settled window and nothing to read. That was 13%.The high-mode profile came from
perfcontext-switch stacks throughlock_contended. 98% of the futex wait was onTrackState.Approach
The Plan offered two directions: take the lock once per wake, or keep per-reader state outside the producer's mutex. This PR does both, each as a narrow change:
rs/moq-net/src/lite/publisher.rs, newrs/moq-net/src/util/idle.rs).TrackRun::poll_stepis split. The prelude (flush, stream credit, group machines, drops) runs on every wake. The track side (poll_track) parks on a waiter of its own, which records any wake before it wakes the task. While that waiter hasn't fired, a repeated poll returnsPendingwithout touching the track. Three things change the track side's answer without a notification, so each disarms the gate: aSUBSCRIBE_UPDATE, the group waiting for credit getting a stream, and a group machine retiring.cache::Track::end_declared,track::Subscriber::declared_end).TrackState::set_endsets it under the lock, and nothing clears it. Until a track declares an end,learn_end(None)skips its lock. Once it has, the reader takes the same locked read as before.model/resume.rs,model/track.rs,model/track/miss.rs).let_goreads the edge only when a copy is replaced, which is the only case that reads it.Cursor::syncreturns early for a settled window with no new ranges.probejudges and clones the fills under one lock. It skipsexpirewhen no fill owes anything, andnext_filledskips the lookup when nothing is filled. Each of these returns exactly what the locked read returned before.session_fanout_threadsserves one broadcast's one-frame groups through one relay to 64 viewers, on 1 and 16 Tokio workers. It is timed in process CPU, which addsClock::Processbeside the existing thread clock. It is skipped off Unix. moq-net's dev dependency on tokio gainsrt-multi-thread.Public API: none. Wire: none.
Measurements
Three setups:
perf stat -e instructions:uover a 5 s window (3,200 deliveries).profilingprofile, plus frame pointers.All runs used the same load generator, built from
main. The host is a shared 24-core machine at load 7 to 28. I had no quiet host:nixos2stopped answering.Pinned relay, user-space instructions per 5 s
mainDefault runtime, 7 alternating rounds (cores; vol. switches/s; busy workers; instructions)
mainDelivery was the same in every run: 640 frames/s, p99 within the run-to-run spread. A second set of 7 rounds of release against this PR gave 0.062 to 0.104 cores for release and 0.066 to 0.090 for this PR.
Over the 14 rounds, this PR never entered the high mode: at most 5 busy workers and 2.1k switches/s. Its paired CPU deltas against release have mixed sign, 4 of 14 above release, all within release's own round-to-round spread. It ran 5 to 36% fewer user-space instructions than release in every round.
mainwent high in 4 of 7.session_fanout_threads, process CPU per roundmainmoq-lite-06/workers=1moq-lite-07-wip/workers=1moq-lite-06/workers=16moq-lite-07-wip/workers=16Validation
nix develop -c quest gate just ci origin/mainpassed ate36eb37755: 9,263 tests passed, 101 skipped, and every check module passed.just rs testpassed for moq-net, moq-net-tests, and moq-relay: 3,661 tests.cargo clippy -p moq-net --all-targets --all-featuresand--no-default-features --all-targetsare clean with-D warnings.credit_after_a_stall_serves_the_live_edge.updateor retired-machine disarm fails nothing, so something the track side parked on also fires on those paths today. I kept both disarms because the track side's answer reads the state they guard, and I didn't want correctness to depend on another module's notification.util/idle.rs: a parked read stays parked until it is woken or disarmed, or until another task reads. A waiter parked past its records is retired.Choices made without asking
util::Idleis the same mechanism as the cursor gate from moq-dev/moq#7263, which landed first. That one gatesCursorgroup reads on a plain track. This one gates the publisher's whole track side, which reaches a relay's logical track throughresume::Reader, where perf(moq-net): an idle track read parks again without walking the read path #7263's gate doesn't apply. Since this PR landed second, the two now share one type:util::Idle<K>is keyed by the read that parked, the cursor's gate isutil::Idle<Walk>(model/track/idle.rskeeps onlyWalk), and the publisher's isutil::Idle<()>.session_fanout_threadssweeps 1 and 16 workers. 16 shows the contention on a 24-core host without tying the bench to the host's core count.Decisions
session_fanout_threadsin the release pairing? The Plan says to keep the new Criterion case in the release pairing, butreleasedoesn't have it, and a pairing compares only cases both sides have.release, so the pre-cut pairing compares it. Decided 2026-11-01 (maintainer): "I'm fine with bench only backports going forward, check what other benchmarks could be backported." The other backportable benches are in moq-dev/moq#7586; this case follows it once this PR lands, ported intorelease's single-file session bench. The rule change that allows bench-only backports is moq-dev/moq#7581.Follow-ups
Reader::syncreads the route state on every call, andRoute::endandpoll_changedare called on each walk. An atomic copy of the route generation would let an unchanged route skip it.GroupExpiry::is_expiredtakes the track lock on each group-machine poll once a newer group has landed. That was 5.7% of the futex wait inmain's profile.bench/profile.py lockaccepts onlymoq-uring-Nandmoq-quic-Nworkers. Acceptingtokio-rt-workerwould letjust bench-profilerun the default shared runtime; the quest noted it could not.setup::tests::credentials_stay_out_of_tracesfailed twice under plaincargo test --lib. It passes alone and under nextest, and the failure looks unrelated to this change. I didn't check it onmain.Review
Codex
gpt-6.1-sol, medium effort, viaquest review --base origin/main.e36eb37755: 1 finding, fixed in5211f4fbc4. [medium]session_fanout_threadsselectedClock::Process, which panics off Unix. It now returns first when!cfg!(unix).5211f4fbc4: 0 findings.feafb43d8c: 0 findings. This head mergesorigin/main(bf4e726f66), resolving a conflict inCursor::probeagainst moq-dev/moq#7263's lazyMisses::retain: the probe keeps main's unconditionalretaincall and this PR's single lock for the judgement and the fills. It also shares oneutil::Idlebetween the two gates, records the decision above in the epic, and pointsrelay-cpu-bisect.mdat this PR instead of the deleted quest.Checks at
feafb43d8c:cargo clippy -p moq-net --all-targetswith--all-featuresand with--no-default-features,-D warnings, clean.cargo nextest run -p moq-net --all-features: 3,223 passed.cargo nextest run -p moq-net-tests: 39 passed.quest check --base origin/main: ok.🤖 Generated with Claude Code
(Written by Claude Opus 5.5)