Skip to content

perf: an idle export poll parks resumed and frame reads without walking them - #7504

Merged
kixelated merged 10 commits into
mainfrom
quest/m2/perf/export-idle-poll
Nov 3, 2026
Merged

kixelated merged 10 commits into
mainfrom
quest/m2/perf/export-idle-poll

Conversation

@kixelated

@kixelated kixelated commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

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 return Pending. Found by moq-dev/moq#6987's profile.

moq-dev/moq#7263 (merged) gates repeated plain Cursor reads (track::idle). It skips a track served through an origin front, which is read as Inner::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:

  1. Where does the early exit for the remaining walk go?
  2. Does this PR land on its own, or carry the gate too?

Re-checked on 2026-11-02, after the PR sat conflicting with main: nothing newer contradicts it. The quest is still open on main, and ts-idle-jitter waits on it. moq-dev/moq#7493 independently generalized #7263's gate into util::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 from model/track/idle.rs): holds Walk and re-exports main's shared crate::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_group parks through a gate per walk (arrival or sequence). track::Subscriber::poll_group checks it before building a stats meter, as it does for a plain cursor.
    • Inputs that notify: the reader's preferences (sync re-registers each read), each copy's pending answer and its cursor (through the copy's own gate), and the wall deadlines Copy::expired arms.
    • The route: a full read now registers on it before looking (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.
    • What the reader changes itself disarms: set_range, take, and a nested route's skip_ordered when it moves the handed-out floor.
    • Other handles that mutate the shared Reader (Mirror::sync, Producer::serve_from, a group's Recover) only apply a preferences or route change, which already woke the gate.
    • feat(net)!: end bounded reads and owe cached backfill (fetch as a range, part 1) #7545's parks is 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's exhausted_for lets go of this reader's copies with the same let_go its own full read runs, as poll_drop already 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 egress poll_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).
    • The gate is set up, unarmed, only once a read parks twice in a row, and the next read arms it. A group read whose next poll is its own wake, as one task per group has it, never allocates one (review round 1 on the merged head; test 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.
    • Disarmed by what the consumer changes itself: skip_to, with_range, with_end, compressed, with_expiry, with_recover, poll_next_frame handing 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 the noop flag main added for channel parking (moq-dev/moq#7532). Before, registering one allocated its shared Arc<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)). A Waiter::new(Waker::noop().clone()), as Park builds from a no-op Context, still registers normally. New test a_noop_waiter_registers_nothing.
  • rs/moq-mux/benches/export.rs: new container-export-idle group. 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 for iter_custom, as the other benches do, which main's newer clock lint requires.
  • Tests that relied on reads answering differently without a wake:
    • group::test::Switch now registers and wakes, as an Expiry must.
    • Two resume tests let time pass on undriven pools, where no wall deadline ever fires. They now look again from a fresh task (fresh()).
    • The expiry sweep tests park their stand-in readers on waiters that register (from the kio fix).
  • 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 the profiling profile (fat LTO). The harness (an uncommitted moq-mux example 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.

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:

export, tracks main before #7263 base new
ts, 1 16,722 8,153 2,618
ts, 4 46,259 27,478 6,046
ts, 16 164,453 109,751 23,321
ts 500 ms delay, 1 18,525 9,477 3,913
ts 500 ms delay, 16 207,495 135,012 50,656
flv, 1 12,254 5,859 1,878
flv, 16 156,729 104,429 19,843
mkv, 1 12,069 5,686 1,667
mkv, 16 156,365 103,991 19,185

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_frame and track::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 by jitter::Acquire::freshest (18%) and memcmp (26%).

Active path (each round writes one frame to every track, then drains the export), N = 2,000 rounds:

export, tracks base new
ts, 1 35,161 26,853
ts, 16 602,860 462,198
flv, 1 19,681 14,163
flv, 16 1,369,354 413,399
mkv, 1 20,675 15,010
mkv, 16 1,423,208 457,907

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

  • Resume model oracle (rs/moq-net/src/model/resume_model.rs), which polls each reader only when its waiter fired: MOQ_RESUME_CASES=3000 on seeds 1 to 4 and MOQ_RESUME_CASES=4000 on seeds 11 to 16, all passed, and seed 17 (4000 cases) on dcbc8073f3.
  • New tests:
    • resume::test::an_idle_resumed_read_parks_for_the_task_polling_it and group::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: the poll_next_frame disarm (review round 1).
  • Mutation checks:
    • Ignoring will_wake in Idle::parked fails all three "parks for the task" tests, perf(moq-net): an idle track read parks again without walking the read path #7263's included.
    • Removing the route registration fails the route-change test.
    • Removing the set_range disarm fails five existing resume range tests.
    • Removing the poll_next_frame disarm fails the chunked-read test.
    • Removing the nested skip_ordered disarm fails nothing. It is kept because a raised floor can turn a replaced copy from waiting to done, which nothing notifies.
  • After the merge with main (2ca588bd7d): just rs check-test -p kio -p moq-net -p moq-mux --all-features on cc984cd233 passed: 5500 passed, 16 skipped, plus clippy, fmt, and doc. On 6526c064ac (same contents re-applied after a mutation check), -p moq-net -p moq-mux passed: 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.
  • Mutation: restoring the old gate trigger (self.idle.is_some() || self.parked) fails a_read_woken_by_its_frame_takes_no_gate.
  • Not run: the full-workspace just check. Disk is about 98% full. Outside kio, the only check on waiter-list occupancy is util::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 Pending without 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

  • Kept the gate in moq-net's three readers rather than adding one in moq-mux, per decision 1.
  • Set up the frame-read gate lazily, on the second park in a row, so a group read pays nothing unless something else keeps repolling it.
  • Registered every full resume read on the route, which also fixes the missed wake above, rather than adding the registration only on the path that lacked it.
  • Disarmed group reads at the points that change their answer (a new expiry verdict, an adopted copy, a handed-out chunked frame) rather than in every other read method. moq-mux calls poll_finished on pending groups every poll, and a disarm there would undo the gate.
  • Fixed the two resume tests with a fresh task after the sleep rather than driving their pools, matching what an undriven pool promises.

Review

Codex gpt-6.1-sol, medium effort, via quest review --base origin/main (session 01a1a010-fa4d-7302-b2d7-402ae16f6937, a new session on the merged branch).

  • Round 1, head cc984cd233 (the merge with main): 1 finding. Fixed in 6526c064ac. [medium] group.rs poll_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 test a_read_woken_by_its_frame_takes_no_gate.
  • Round 2, head 6526c064ac: 0 findings.

Earlier rounds, before the merge (session 01a19964-66b4-7713-bcd4-d371821a64df): round 1 on 965c742aaf found one issue, fixed in dcbc8073f3. In that fix, poll_next_frame disarms a parked whole-frame read. Round 2 on dcbc8073f3 was clean. The GitHub reviews of dcbc8073f3 (OpenAI review, pullfrog) reported no issues.

Follow-ups

Suggested, for the caller to plan:

  • The ts export's jitter release on an idle poll. jitter::Acquire::freshest (18%) and memcmp (26%) lead an idle ts poll with a delay, about 3k instructions per track against about 1.4k without one (moq-mux).
  • A replaced copy's grace fold arms no wall deadline. Copy::retains keeps a replaced copy waiting while may_land finds 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

kixelated and others added 5 commits October 31, 2026 23:35
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>
@kixelated kixelated changed the title An export poll nothing woke is cheap perf: measure the idle export poll, and a no-op waiter registers nothing Nov 1, 2026
kixelated and others added 3 commits November 1, 2026 14:34
… 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>
@kixelated kixelated changed the title perf: measure the idle export poll, and a no-op waiter registers nothing perf: an idle export poll parks resumed and frame reads without walking them Nov 1, 2026
@kixelated
kixelated marked this pull request as ready for review November 1, 2026 23:23
@coderabbitai

coderabbitai Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You'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.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: cddf1ec2-0bbc-4b04-9c4f-5d082c6dc985
Commits

Reviewing files that changed from the base of the PR and between 2ca588b and 6526c06.

Files selected for processing: 12
  • quest/m2/perf/README.md
  • quest/m2/perf/export-idle-poll.md
  • quest/m2/perf/ts-idle-jitter.md
  • rs/kio/src/waiter.rs
  • rs/moq-mux/benches/export.rs
  • rs/moq-net/src/model/expiry.rs
  • rs/moq-net/src/model/group.rs
  • rs/moq-net/src/model/idle.rs
  • rs/moq-net/src/model/mod.rs
  • rs/moq-net/src/model/resume.rs
  • rs/moq-net/src/model/track.rs
  • rs/moq-net/src/model/track/idle.rs
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

Share

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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)

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • Shared idle gate made generic (rs/moq-net/src/model/idle.rs, moved from model/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_group parks per Walk; 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 by take, set_range, and a nested skip_ordered that moves the handed-out floor.
  • Frame reads park through a gate (rs/moq-net/src/model/group.rs): poll_read_as (whole-frame poll_read_frame, poll_read_frames, egress poll_read_object) parks an Idle<()> once a read parks twice, disarming at every self-driven change (compressed, with_expiry, with_recover, with_range, with_end, skip_to, poll_next_frame handing out a frame, an expiry verdict, an adopted copy).
  • Track subscriber gates resumed reads (rs/moq-net/src/model/track.rs): poll_group checks the resume gate ahead of the meter, and skip_ordered disarms it when the floor moves.
  • Waiter::noop() registers nothing (rs/kio/src/waiter.rs): a noop: bool short-circuits WaiterList::register, so a peek through it allocates no handle. Tests needing a live registration move to Waiter::new(Waker::noop().clone()) (parked() / fresh()).
  • Idle-poll benchmark (rs/moq-mux/benches/export.rs): container-export-idle measures a live export polled again with nothing new, swept over tracks.
  • Quest completed: quest/m2/perf/export-idle-poll.md deleted with its quest/m2/perf/README.md index 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.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@kixelated
kixelated enabled auto-merge (squash) November 1, 2026 23:33
kixelated added a commit that referenced this pull request Nov 2, 2026
#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>
@kixelated
kixelated disabled auto-merge November 3, 2026 05:53
kixelated and others added 2 commits November 2, 2026 22:16
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
kixelated added this pull request to the merge queue Nov 3, 2026

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • Frame-read gate set up lazily (rs/moq-net/src/model/group.rs): poll_read_as now creates the Idle<()> 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. The match narrows from self.idle.is_some() || self.parked to self.idle.is_some(); regression test a_read_woken_by_its_frame_takes_no_gate.
  • Adopted main's shared idle gate (rs/moq-net/src/model/idle.rs, merge cc984cd23): the branch's own gate is replaced by the shared crate::util::Idle<K> and model/idle.rs now only re-exports it plus Walk. Semantics are identical (parked/start/finish/disarm), and no disarm site was dropped across the three gated readers.
  • Re-verified the main Pace interaction (rs/moq-net/src/model/group.rs, model/pace.rs): a gated re-poll skips Pace::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.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Merged via the queue into main with commit 25ff6e9 Nov 3, 2026
9 of 10 checks passed
@kixelated
kixelated deleted the quest/m2/perf/export-idle-poll branch November 3, 2026 07:24
kixelated added a commit that referenced this pull request Nov 5, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant