Skip to content

perf(net): seek instead of scan for the next in-range group - #3088

Merged
kixelated merged 1 commit into
mainfrom
claude/trusting-tharp-6f9ac7
Aug 27, 2026
Merged

perf(net): seek instead of scan for the next in-range group#3088
kixelated merged 1 commit into
mainfrom
claude/trusting-tharp-6f9ac7

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Summary

  • Root cause: TrackState::lookup was a HashMap<u64, Slot>. A hash map has no order, so poll_next_in_range had to scan every cached slot to find the lowest sequence at or above the subscriber's cursor. Subscriber::next_group calls it once per delivery, so draining N cached groups was O(N * cache_size).
  • This is the path the media consumers use (moq-mux's container and MSF catalog readers, moq-json, moq-ffi, moq-transcode's feed, and moq-net's resume), and cache depth is the retained group count. A track publishing one group per frame (hang audio, any write_frame track) at the default 5s retention holds ~250 groups, so every delivery scanned ~250 entries.
  • Fix: make lookup a BTreeMap and seek with range(next_sequence..). The end_sequence cap becomes a take_while, which is only correct because iteration is now ascending; the old scan had to continue past it. Aborted slots (awaiting the next eviction scan to reclaim them) are still stepped over, and the early return that parks rather than ends the stream when end < next_sequence is unchanged, as is final_sequence termination.
  • Trade: lookup get/insert/remove go from O(1) to O(log n). Those are the eviction and fetch paths, which touch one entry at a time, so it is a good trade against removing a full scan per delivery. There is a single insert site and it keys on group.sequence, so the seek key is exact.

Numbers

New track_recv_groups bench arm, sweeping cache depth across both delivery orders. arrival is recv_group (an arrival-order index walk, already flat); sequence is next_group.

cached groups arrival sequence (before) sequence (after)
64 1.79 Melem/s 720 Kelem/s 1.64 Melem/s
512 1.72 Melem/s 110 Kelem/s 1.89 Melem/s
4096 1.76 Melem/s 16.7 Kelem/s 1.79 Melem/s

Flat across a 64x depth increase, ~107x at the top end. The "before" column is from the original report; the "after" column and the arrival baseline are from this branch.

Public API changes

None. TrackState and its lookup field are pub(crate); no pub item in rs/moq-* or js/* is added, renamed, removed, or resignatured. The only additions are private helpers in the benchmark. Targets main accordingly.

Test plan

  • just fix (no changes beyond the two files here), just check, just test: 2799 tests pass, 1 skipped.
  • Added the track_recv_groups arm to rs/moq-net/benches/group.rs as the regression guard. nextest runs criterion benches in test mode, so the arm is exercised in CI rather than rotting until someone next runs cargo bench.
  • Ran cargo bench -p moq-net --bench group -- track_recv_groups for the table above.

Cross-Package Sync

No row applies: this is an internal data-structure change in moq-net with no wire, catalog, config, or CLI surface touched, so js/net and the drafts are unaffected.

Follow-up

#3086 covers making the delivery order a handle (ordered()) rather than a method choice, and moving moq-mux's timestamp-based group skipping down into moq-net. Worth noting for reviewers: this fix removes the performance argument for treating sequence order as the slower opt-in path, but the API argument stands on its own, since recv_datagram bumps next_sequence and read_frame bumps both cursors, making the two orders quietly interact on one Subscriber.


(Written by Claude Opus 5)

`TrackState::lookup` was a `HashMap<u64, Slot>`, which has no order, so
`poll_next_in_range` had to scan every cached slot to find the lowest
sequence at or above the subscriber's cursor. `Subscriber::next_group`
calls it once per delivery, making a drain of N cached groups
O(N * cache_size).

That is the path the media consumers use (moq-mux's container and MSF
catalog readers, moq-json, moq-ffi, moq-transcode's feed, and resume),
and cache depth is the retained group count: a track publishing one
group per frame at the default 5s retention holds ~250 groups, so every
delivery scanned ~250 entries.

Make `lookup` a `BTreeMap` and seek with `range(next_sequence..)`. The
`end_sequence` cap becomes a `take_while` now that iteration is
ascending, and only aborted slots (awaiting the next eviction scan) are
stepped over. Lookups by sequence go from O(1) to O(log n), but those
paths touch one entry at a time.

Measured with the new `track_recv_groups` bench arm, sweeping cache
depth with both delivery orders. Sequence order was 720 Kelem/s at depth
64 and collapsed to 16.7 Kelem/s at 4096; it is now flat at ~1.8
Melem/s across the sweep, matching the arrival-order walk. nextest runs
criterion benches in test mode, so the arm is exercised in CI.

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

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bbdef98d-7ab4-4991-99b3-da04022c0dc4

📥 Commits

Reviewing files that changed from the base of the PR and between 5ddaed0 and f2081d6.

📒 Files selected for processing (2)
  • rs/moq-net/benches/group.rs
  • rs/moq-net/src/model/track.rs

Included review availability: Your plan provides up to 4 included reviews per hour; 1 remains after this review.


Walkthrough

The track cache now uses BTreeMap instead of HashMap. Sequence delivery seeks directly from the subscriber cursor and skips out-of-range or aborted groups. The group benchmark now builds tracks with cached groups, sweeps cache depths, measures recv_group and next_group, and registers the new benchmark.

Merge Risk: ⚪ Minimal · up to f2081

This changes internal group lookup to seek efficiently in sequence order without changing the public API or intended delivery behavior. No actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: replacing a scan with a seek to find the next in-range group.
Description check ✅ Passed The description directly explains the performance problem, the BTreeMap-based fix, benchmark results, preserved behavior, and test coverage.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 2 files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/trusting-tharp-6f9ac7

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

Comment @coderabbitai help to get the list of available commands.

@kixelated
kixelated merged commit 810d661 into main Aug 27, 2026
3 checks passed
@kixelated
kixelated deleted the claude/trusting-tharp-6f9ac7 branch August 27, 2026 15:07
@moq-bot moq-bot Bot mentioned this pull request Aug 27, 2026
kixelated added a commit that referenced this pull request Aug 28, 2026
Max age judged a group by where it *started*, so a group was convicted for being
behind rather than for having nothing left to give. That is the wrong axis. Priority
already transmits newer groups first, so an older group consumes only leftover
capacity and closes the gap faster than the live edge advances: being behind is
survivable, and a receiver that is behind converges without losing content. What
cannot be recovered is a group with nothing left worth delivering.

Measure a group by how far it could still *reach* instead: the first frame timestamp
of its successor, since a group cannot present past where the next group begins. Its
own frames prove nothing, because frame durations are not on the wire and a group's
last timestamp is where that frame starts, not where the group ends. A group whose
successor has not arrived is therefore never expired on timestamp age; the wall-clock
measure still backstops it.

Reach is an exclusive bound, so the comparison is `>=`: the freshest frame a group
could still hold sits strictly below its reach, and an age equal to the budget already
puts every frame in it past the budget. That also makes a zero budget fall out of the
general rule, so `wall_stale` loses its `budget.is_zero()` special case.

This retires all three competing measures at once: the original first-frame one, the
reader-position preference from #2890 (a drained-but-open group measured as level with
the edge, so nothing could convict a stall), and the newest-frame one from the previous
commit (which had the same defect and hung a reader outright).

`Edge` carries a suffix minimum over stamped groups, built in the scan `live_edge`
already did, so walking a backlog stays linear rather than rescanning per candidate. It
is `Clone` rather than `Copy` now, so `is_stale` and `poll_stale` take it by reference.
Once `lookup` becomes a `BTreeMap` (it already is on main, via #3088) this collapses to
a `range(sequence + 1..)` walk and the table goes away.

Six tests move to the new semantics. The shape they share: a two-group track can never
expire the older group on timestamp age, because its reach *is* the edge. Convicting it
needs a group beyond the successor, which is what each test now sets up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@moq-bot moq-bot Bot mentioned this pull request Sep 1, 2026
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