Repository navigation
perf: an idle export poll parks resumed and frame reads without walking them - #7504
Conversation
Quest-Claim: 76f7b67d2577681c
A live export polled again from the same task with nothing new, swept over formats and tracks, so work each idle poll repeats per track shows as a slope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Waiter::noop said it discards registrations, but registering one allocated its shared handle and filled a list slot that went dead as soon as it dropped. moq-net's resume layer peeks through one on every idle read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured on top of #7263 and the no-op waiter fix: an idle export poll still costs about 5.7k to 10k instructions per track, mostly moq-net's resume reader and open group reads. Where to gate them is open. Also formats the idle export bench. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kio::Waiter::noop no longer registers, so it stood in for no reader at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… walking Extend the `track::idle` gate from plain cursors to the resume reader's group reads and to group frame reads, so a poll nothing woke answers `Pending` at once. - `idle.rs` moves up to `model/` and takes the read kind as a parameter. - `resume::Subscriber::poll_peek_group` parks through a gate. It registers on the route before looking, so a read that parks past a drained copy without looking at the route still wakes when it changes. A moved range and a moved handed-out floor (a nested route's `skip_ordered`) disarm it. - `group::Consumer` frame reads park through a gate, set up once a read parks twice in a row, so a group read woken only by its own data never allocates one. A moved range, a new expiry verdict, and an adopted replacement copy disarm it. - The export bench uses the ambient clock for its drain timeout and allows the host clock for `iter_custom`, as the other benches do. - Finishes `quest/m2/perf/export-idle-poll.md`, which this deletes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`poll_next_frame` hands out a frame still arriving, which a whole-frame read waits out, so taking it moves the cursor without a wake. A capped reader whose parked `poll_read_frame` never heard of the move stayed `Pending` past its cap. Found by the local Codex review, round 1. 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 26 seconds. 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. CodeRabbit Verify
|
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: dcbc807
No actionable bugs found in the changed code and relevant surrounding read/recovery paths. Reusing the existing idle gate in moq-net is a coherent direction and avoids duplicating scheduling state in the muxer. The route watch, task-identity check, and explicit disarms cover the key wake/invalidation boundaries examined. The earlier chunked-read finding documented in the PR is fixed by disarming after poll_next_frame advances the cursor, with a focused regression test.
Verification limits: static GitHub review only; I did not run tests, benchmarks, or independently reproduce the reported performance measurements.
(Written by OpenAI)
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Shared idle gate made generic (
rs/moq-net/src/model/idle.rs, moved frommodel/track/idle.rs):Idle<K>now keys by read kind, so plain cursors, resume readers, and group frame reads share one early-exit; docs cover how nested gates pass the outer waiter. - Resume reader parks through a gate (
rs/moq-net/src/model/resume.rs):poll_peek_groupparks perWalk; a full read now registers on the route before looking, closing a latent missed wake where a read parked past a drained serving copy never saw a new route. Disarmed bytake,set_range, and a nestedskip_orderedthat moves the handed-out floor. - Frame reads park through a gate (
rs/moq-net/src/model/group.rs):poll_read_as(whole-framepoll_read_frame,poll_read_frames, egresspoll_read_object) parks anIdle<()>once a read parks twice, disarming at every self-driven change (compressed,with_expiry,with_recover,with_range,with_end,skip_to,poll_next_framehanding out a frame, an expiry verdict, an adopted copy). - Track subscriber gates resumed reads (
rs/moq-net/src/model/track.rs):poll_groupchecks the resume gate ahead of the meter, andskip_ordereddisarms it when the floor moves. Waiter::noop()registers nothing (rs/kio/src/waiter.rs): anoop: boolshort-circuitsWaiterList::register, so a peek through it allocates no handle. Tests needing a live registration move toWaiter::new(Waker::noop().clone())(parked()/fresh()).- Idle-poll benchmark (
rs/moq-mux/benches/export.rs):container-export-idlemeasures a live export polled again with nothing new, swept over tracks. - Quest completed:
quest/m2/perf/export-idle-poll.mddeleted with itsquest/m2/perf/README.mdindex bullet.
Public API and wire unchanged; Waiter::noop() now matches its documented behavior.
I re-ran the touched tests locally: cargo test -p moq-net --lib idle, model::group::test::a_chunked_read_moves_a_parked_frame_read_on, model::resume::test::a_read_waiting_past_a_drained_copy_wakes_when_the_route_changes, and kio a_noop_waiter_registers_nothing all pass. The full cargo test -p moq-net --lib run is green except setup::tests::credentials_stay_out_of_traces, a global tracing-subscriber isolation failure under plain cargo test (passes per-test under nextest), unrelated to this diff.
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>
…a row The gate was allocated on the read after one park, before that read knew whether it would park, so every live group reader that parked once paid for a gate for good. A read now sets the gate up, unarmed, only when it parks a second time in a row; the read after arms it. Regression test `a_read_woken_by_its_frame_takes_no_gate`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: 6526c06
No new actionable bugs found in the update. Adopting the shared idle gate avoids parallel machinery, and delaying its allocation preserves the ordinary wake-per-frame path; the new test covers that case. The earlier chunked-read fix remains in place.
Static GitHub review only; I did not run tests or benchmarks or independently reproduce the reported measurements.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Frame-read gate set up lazily (
rs/moq-net/src/model/group.rs):poll_read_asnow creates theIdle<()>gate, unarmed, only when a read parks a second time in a row (the read after arms it), so a reader woken by its own frame never allocates one. Thematchnarrows fromself.idle.is_some() || self.parkedtoself.idle.is_some(); regression testa_read_woken_by_its_frame_takes_no_gate. - Adopted main's shared idle gate (
rs/moq-net/src/model/idle.rs, mergecc984cd23): the branch's own gate is replaced by the sharedcrate::util::Idle<K>andmodel/idle.rsnow only re-exports it plusWalk. Semantics are identical (parked/start/finish/disarm), and no disarm site was dropped across the three gated readers. - Re-verified the main
Paceinteraction (rs/moq-net/src/model/group.rs,model/pace.rs): a gated re-poll skipsPace::took_committed, but the record set by the arming full read still holds until the next commit, which reopens the gate through the value-channel wake, so the skip never changes writer pacing or a writer wake.
Public API and wire unchanged. I traced the new gate-setup path, the merge's gate/disarm set, and the Pace interaction, and dispatched two focused audits (adopt-equivalence / disarm completeness, and Pace vs the gated skip); all found no bug.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…p poll skips the probe A prefetched frame is served before the idle gate and route bookkeeping that #7504 put on every frame read, and the read path below it has one call site again so it inlines. A relay draining a 512-frame group per reader (the `group` fan-out scenario, group_relay_frames/plain/512) drops from 173,221 to 135,396 instructions per group, below its 149,368 before that rise. The relay's track side polls drops again after a receive that parked, to collect a stale skip the receive's probe queued. That probe just judged the live edge with the same waiter, so the second poll now only collects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Problem
quest/m2/perf/export-idle-poll.md: a container export poll that nothing new woke walks every track's read path again just to returnPending. Found by moq-dev/moq#6987's profile.moq-dev/moq#7263 (merged) gates repeated plain
Cursorreads (track::idle). It skips a track served through an origin front, which is read asInner::Resume, and it does not cover the open group's frame read. That left about 5.7k to 10k instructions per idle track.This PR finishes the quest and deletes it.
Decisions
Settled by the maintainer on 2026-11-01:
track::idlegate toInner::Resumereads (resume::Reader::poll_group) and to group frame reads (group::Consumer, including itsRecover). Every input to the resume reader and toRecover(route, subscription, copies, budgets) has to notify or disarm the gate, the same audit perf(moq-net): an idle track read parks again without walking the read path #7263 did forCursor.idle.rs.Re-checked on 2026-11-02, after the PR sat conflicting with
main: nothing newer contradicts it. The quest is still open onmain, andts-idle-jitterwaits on it. moq-dev/moq#7493 independently generalized #7263's gate intoutil::Idle<K>, the same shape this PR had built, so the merge adopts it. moq-dev/moq#7545's bounded-read ending and #7625's fetch-range work need no change here (see Changes).Changes
rs/moq-net/src/model/idle.rs(moved frommodel/track/idle.rs): holdsWalkand re-exportsmain's sharedcrate::util::Idle<K>(perf(moq-net): fanout subscribers stop contending the shared track lock #7493), so plain cursors, resume readers, and group frame reads share one gate. Gates nest: a gated resume read polls each copy's gated cursor through its own waiter, whose waker stays the same across reads, so the inner gate keeps its arm and its wake reaches the outer gate.rs/moq-net/src/model/resume.rs:resume::Subscriber::poll_peek_groupparks through a gate per walk (arrival or sequence).track::Subscriber::poll_groupchecks it before building a stats meter, as it does for a plain cursor.syncre-registers each read), each copy's pending answer and its cursor (through the copy's own gate), and the wall deadlinesCopy::expiredarms.Consumer::register). Before, a read that parked past a drained serving copy, waiting for a replaced copy to deliver a lower group, never looked at the route, so a new route did not wake it. That was a latent missed wake; the gate would have made it stick.set_range,take, and a nested route'sskip_orderedwhen it moves the handed-out floor.Reader(Mirror::sync,Producer::serve_from, a group'sRecover) only apply a preferences or route change, which already woke the gate.parksis fixed when the subscriber is built, so it needs no gate key. A non-parking reader's exhaustion check runs inside the full read, as on a plain cursor. Another front'sexhausted_forlets go of this reader's copies with the samelet_goits own full read runs, aspoll_dropalready did, so it answers what a fresh read would.rs/moq-net/src/model/group.rs: frame reads (poll_read_frame,poll_read_frames, and the egresspoll_read_object) park through a gate. A parked read registered on the group state, its expiry (whose contract is to register for anything that changes its answer), the reader's preferences (Recover::poll_budget), and the route (Recover::wants).a_read_woken_by_its_frame_takes_no_gate).main's writer pacing (moq-dev/moq#7542) records a parked reader at the writer's edge (Pace::took_committed) during the full read. A gated re-poll skips that, but the record holds until the next commit, which wakes the read.skip_to,with_range,with_end,compressed,with_expiry,with_recover,poll_next_framehanding out a frame (which may be one still arriving that a whole-frame read waits out), a new expiry verdict, and an adopted replacement copy.rs/kio/src/waiter.rs:Waiter::noop()now registers nothing, as its doc already said. It shares thenoopflagmainadded for channel parking (moq-dev/moq#7532). Before, registering one allocated its sharedArc<Waker>and filled a list slot that went dead once it dropped. moq-net's resume layer peeks through one on every read (resume::Reader::learn_end(None)). AWaiter::new(Waker::noop().clone()), asParkbuilds from a no-opContext, still registers normally. New testa_noop_waiter_registers_nothing.rs/moq-mux/benches/export.rs: newcontainer-export-idlegroup. A live export (flv, mkv, ts over 1, 4, 16 audio tracks) is drained, then polled again from the same task with nothing new. It now uses the ambient clock for its drain timeout and allows the host clock foriter_custom, as the other benches do, which main's newer clock lint requires.group::test::Switchnow registers and wakes, as anExpirymust.fresh()).quest/m2/perf/export-idle-poll.md: deleted, with its epic link.Measurements
User-space instructions per poll (
perf stat --no-scale -e cpu_core/instructions/u), pinned to P-core 2 (Core Ultra 7 270K Plus). Built with theprofilingprofile (fat LTO). The harness (an uncommittedmoq-muxexample mirroring the bench) runs identical setup for 0 and N polls and reports (I(N) - I(0)) / N. Repeat runs agree within about 0.5%, or about 3% for ts with a delay, whose setup reads the wall clock.de7c8f8100, this branch's previous head merged with main (so perf(moq-net): an idle track read parks again without walking the read path #7263 plus the kio fix).dcbc8073f3.Not re-measured after the 2026-11-02 merge. The gate logic is unchanged apart from the frame-read gate's setup, which now arms one read later, on the third idle read in a row instead of the second.
Idle poll (nothing new), N = 20,000:
The "main before #7263" column is from this PR's first measurement (main
30f23c3f8a). An idle track now costs about 1.2k instructions for flv and mkv and about 1.4k for ts, down from about 6.5k. A ts export with a delay costs about 3k.What remains is moq-mux bookkeeping. On the new build,
group::Consumer::poll_read_frameandtrack::Subscriber::poll_group(the two gate checks) are about 20% of an idle flv poll and about 3% of a delayed ts one. The delayed ts poll is dominated byjitter::Acquire::freshest(18%) andmemcmp(26%).Active path (each round writes one frame to every track, then drains the export), N = 2,000 rounds:
The active path gains too: one export task polls every track whenever one of them wakes, so most tracks it polls have nothing new.
Validation
rs/moq-net/src/model/resume_model.rs), which polls each reader only when its waiter fired:MOQ_RESUME_CASES=3000on seeds 1 to 4 andMOQ_RESUME_CASES=4000on seeds 11 to 16, all passed, and seed 17 (4000 cases) ondcbc8073f3.resume::test::an_idle_resumed_read_parks_for_the_task_polling_itandgroup::test::an_idle_frame_read_parks_for_the_task_polling_it: a repeated read parks without being woken, a read from another task parks anew, and the next group or frame wakes that task.resume::test::a_read_waiting_past_a_drained_copy_wakes_when_the_route_changes: the route registration.group::test::a_chunked_read_moves_a_parked_frame_read_on: thepoll_next_framedisarm (review round 1).will_wakeinIdle::parkedfails all three "parks for the task" tests, perf(moq-net): an idle track read parks again without walking the read path #7263's included.set_rangedisarm fails five existing resume range tests.poll_next_framedisarm fails the chunked-read test.skip_ordereddisarm fails nothing. It is kept because a raised floor can turn a replaced copy from waiting to done, which nothing notifies.main(2ca588bd7d):just rs check-test -p kio -p moq-net -p moq-mux --all-featuresoncc984cd233passed: 5500 passed, 16 skipped, plus clippy, fmt, and doc. On6526c064ac(same contents re-applied after a mutation check),-p moq-net -p moq-muxpassed: 5301 passed, 16 skipped. The group, resume, and idle filter passed too: 258 tests.cargo clippy --locked --all-targets -p moq-net --no-default-features -- -D warnings: clean.quest check --base origin/main: clean.self.idle.is_some() || self.parked) failsa_read_woken_by_its_frame_takes_no_gate.just check. Disk is about 98% full. Outside kio, the only check on waiter-list occupancy isutil::idle's test, which passed. No public API changed.Impact
Public API: none. Wire: none.
Waiter::noop()behaves as documented.A read polled again by the same task before anything it parked on fired now answers
Pendingwithout looking. That is the contract #7263 set for plain cursors: an input that changes without notifying no longer shows up on a re-poll it did not cause. Wall-clock deadlines on a pool no driver collects never fire, as before; a re-poll used to see them anyway.Choices made without asking
poll_finishedon pending groups every poll, and a disarm there would undo the gate.Review
Codex
gpt-6.1-sol, medium effort, viaquest review --base origin/main(session01a1a010-fa4d-7302-b2d7-402ae16f6937, a new session on the merged branch).cc984cd233(the merge withmain): 1 finding. Fixed in6526c064ac. [medium]group.rspoll_read_as: the frame-read gate was allocated on the read after one park, before it knew whether that read parked, so every live group reader that parked once paid for a gate. It is now set up only when a read parks twice in a row; regression testa_read_woken_by_its_frame_takes_no_gate.6526c064ac: 0 findings.Earlier rounds, before the merge (session
01a19964-66b4-7713-bcd4-d371821a64df): round 1 on965c742aaffound one issue, fixed indcbc8073f3. In that fix,poll_next_framedisarms a parked whole-frame read. Round 2 ondcbc8073f3was clean. The GitHub reviews ofdcbc8073f3(OpenAI review, pullfrog) reported no issues.Follow-ups
Suggested, for the caller to plan:
jitter::Acquire::freshest(18%) andmemcmp(26%) lead an idle ts poll with a delay, about 3k instructions per track against about 1.4k without one (moq-mux).Copy::retainskeeps a replaced copy waiting whilemay_landfinds a gap within its grace, but nothing wakes the reader when that grace runs out. Today it waits for the copy's own budget deadline instead, so a clean end can be reported up to one budget late. Pre-existing; the gate does not change it for a reader polled only when woken.(Written by Claude Opus 5.5)
🤖 Generated with Claude Code