Skip to content

perf(net): park memory-transport reads and writes on the close only when they wait - #7262

Merged
moq-bot[bot] merged 6 commits into
mainfrom
quest/m1/ietf22-fetch-poll-cost
Nov 1, 2026
Merged

moq-bot[bot] merged 6 commits into
mainfrom
quest/m1/ietf22-fetch-poll-cost

Conversation

@kixelated

@kixelated kixelated commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Finishes quest/m1/ietf22-fetch-poll-cost.md.

Problem

moq-dev/moq#7141 reported that moq-dev/moq#7042 (6eff65506e, transport polls take kio waiters through &self) adds +12.8% instructions per round to moq-transport-22 FETCH of 256-frame groups.

Reproduced. The model bench's fetch_frames case with draft 22, 1 publisher, 1 viewer and 256 frames costs 8.03M user instructions per round at the parent (4fdbad719e), 9.13M at 6eff65506e (+13.6%), and 8.98M on main 2fff7ea7ae (+11.8%). Allocations went from 8228 to 11285 per round (10256 on main), about 12 more per frame.

Cause

The in-memory transport, which both session benches run on, polled the session close before every channel read and every write. That registers the poll's waiter on the session-wide close list even when the read returns immediately.

Before #7042, each stream kept one kio::Park, so the same waiter re-registered as a no-op. Since #7042, every kio::wait future holds its own waiter, and draft 18/22 FETCH reads each frame header and payload through a fresh one. Each read therefore allocated the waiter's shared Arc<Waker> and left another Weak in the close list for the list to collect.

The QUIC adapters (moq-tokio, moq-uring) don't poll the session close on each read, so this cost belongs to the model transport. Lite's allocations don't change with the fix, so its re-registration was already a no-op. It saves only the per-read close lock and registration check.

Fix

rs/moq-net/src/transport/memory.rs: a read or write registers on the close only when it parks.

  • Read. Poll the channel first. Only when it's empty, poll the close, then look at the channel once more, so a message queued before the close is still read, as before.
  • Write. Peek at the close without registering. Only a write that parks on a full channel registers on the close.

A new test, only_parked_streams_wait_on_the_close, checks that a read or write that returns immediately is not woken by the close, and that a parked one is. It fails on the previous code.

Measurements

User-space instructions per steady round on a performance core (cpu_core, pinned to one CPU) via perf_event_open. They exclude the cache tick and setup, and the counter was never multiplexed. Each value is the median of Criterion's 10 samples. Every revision was built from its own git archive export into its own target directory, with a bench-only patch that adds draft 18 to VERSIONS and the counter. Same-build reruns of main agree within ±0.15% everywhere, so wall-clock noise from host load doesn't enter.

Draft 22, model bench (rust/net/session_delivery_fetch_frames):

publishers, viewers, frames parent 4fdbad719e #7042 6eff65506e main this PR vs main vs parent
1, 1, 256 8.033M 9.131M 8.980M 7.862M -12.5% -2.1%
1, 16, 256 66.36M 75.59M 74.40M 65.01M -12.6% -2.0%
4, 16, 256 78.88M 89.37M 88.13M 77.34M -12.2% -2.0%
1, 1, 4 0.744M 0.735M 0.731M 0.708M -3.1% -4.8%
4, 16, 4 7.338M 7.260M 7.215M 6.991M -3.1% -4.7%

Allocations per 256-frame round drop from 10256 to 7180 (parent 8228).

The full sweep covers lite-06, lite-07-wip, draft 18 and draft 22, publishers 1/4, viewers 1/16, and 4/256 frames, on both the model and relay benches. Every case improves against main, and every model case is at or below the parent. Draft 18 and 22 drop 12.2-13.1% at 256 frames and 2.6-3.8% at 4 frames. Lite drops 2.2-2.3% at 256 frames and 0.4-0.6% at 4 frames, with unchanged allocations. The relay bench matches the model bench to within 0.6 points.

Full sweep (main measured twice from the same build)
bench version pub view frames parent main main rerun fix fix vs main fix vs parent allocs main -> fix
model moq-lite-06 1 1 4 0.380M 0.365M 0.365M (+0.02%) 0.363M -0.5% -4.5% 267 -> 267
model moq-lite-06 1 1 256 5.025M 4.928M 4.927M (-0.02%) 4.817M -2.3% -4.2% 3862 -> 3862
model moq-lite-06 1 16 4 4.028M 3.947M 3.947M (+0.01%) 3.931M -0.4% -2.4% 2470 -> 2470
model moq-lite-06 1 16 256 43.098M 42.172M 42.183M (+0.03%) 41.229M -2.2% -4.3% 32994 -> 32994
model moq-lite-06 4 1 4 0.380M 0.365M 0.365M (+0.01%) 0.363M -0.5% -4.5% 267 -> 267
model moq-lite-06 4 1 256 5.025M 4.927M 4.927M (+0.01%) 4.817M -2.2% -4.2% 3862 -> 3862
model moq-lite-06 4 16 4 4.990M 4.933M 4.933M (-0.00%) 4.914M -0.4% -1.5% 3222 -> 3222
model moq-lite-06 4 16 256 50.825M 50.113M 50.112M (-0.00%) 49.024M -2.2% -3.5% 39149 -> 39149
model moq-lite-07-wip 1 1 4 0.403M 0.398M 0.398M (-0.00%) 0.396M -0.5% -1.9% 280 -> 280
model moq-lite-07-wip 1 1 256 4.976M 4.903M 4.903M (-0.00%) 4.793M -2.3% -3.7% 3889 -> 3889
model moq-lite-07-wip 1 16 4 4.274M 4.247M 4.248M (+0.03%) 4.231M -0.4% -1.0% 2542 -> 2542
model moq-lite-07-wip 1 16 256 41.460M 40.797M 40.795M (-0.00%) 39.852M -2.3% -3.9% 33066 -> 33065
model moq-lite-07-wip 4 1 4 0.403M 0.398M 0.398M (+0.01%) 0.396M -0.5% -1.9% 280 -> 280
model moq-lite-07-wip 4 1 256 4.974M 4.903M 4.903M (-0.00%) 4.793M -2.2% -3.6% 3889 -> 3889
model moq-lite-07-wip 4 16 4 5.284M 5.293M 5.294M (+0.00%) 5.273M -0.4% -0.2% 3304 -> 3304
model moq-lite-07-wip 4 16 256 49.315M 48.898M 48.897M (-0.00%) 47.795M -2.3% -3.1% 39231 -> 39231
model moq-transport-18 1 1 4 0.745M 0.730M 0.730M (-0.01%) 0.708M -3.0% -5.0% 592 -> 528
model moq-transport-18 1 1 256 8.030M 8.966M 8.972M (+0.07%) 7.858M -12.4% -2.1% 10254 -> 7178
model moq-transport-18 1 16 4 6.025M 5.924M 5.924M (-0.01%) 5.734M -3.2% -4.8% 4375 -> 3831
model moq-transport-18 1 16 256 66.334M 74.415M 74.335M (-0.11%) 64.971M -12.7% -2.1% 86462 -> 60316
model moq-transport-18 4 1 4 0.745M 0.731M 0.731M (-0.00%) 0.708M -3.1% -5.0% 592 -> 528
model moq-transport-18 4 1 256 8.029M 8.984M 8.970M (-0.15%) 7.859M -12.5% -2.1% 10254 -> 7178
model moq-transport-18 4 16 4 7.348M 7.199M 7.200M (+0.02%) 7.009M -2.6% -4.6% 5409 -> 4774
model moq-transport-18 4 16 256 78.874M 88.097M 88.058M (-0.04%) 77.302M -12.3% -2.0% 102017 -> 71256
model moq-transport-22 1 1 4 0.744M 0.731M 0.731M (+0.00%) 0.708M -3.1% -4.8% 592 -> 528
model moq-transport-22 1 1 256 8.033M 8.980M 8.977M (-0.04%) 7.862M -12.5% -2.1% 10256 -> 7180
model moq-transport-22 1 16 4 6.015M 5.936M 5.936M (-0.00%) 5.745M -3.2% -4.5% 4392 -> 3848
model moq-transport-22 1 16 256 66.357M 74.399M 74.405M (+0.01%) 65.005M -12.6% -2.0% 86479 -> 60334
model moq-transport-22 4 1 4 0.745M 0.731M 0.731M (+0.00%) 0.709M -3.1% -4.9% 592 -> 528
model moq-transport-22 4 1 256 8.031M 8.979M 8.976M (-0.04%) 7.863M -12.4% -2.1% 10256 -> 7180
model moq-transport-22 4 16 4 7.338M 7.215M 7.214M (-0.01%) 6.991M -3.1% -4.7% 5429 -> 4790
model moq-transport-22 4 16 256 78.884M 88.132M 88.177M (+0.05%) 77.343M -12.2% -2.0% 102037 -> 71276
relay moq-lite-06 1 1 4 - 0.371M 0.371M (+0.01%) 0.368M -0.6% - 274 -> 274
relay moq-lite-06 1 1 256 - 4.956M 4.955M (-0.00%) 4.844M -2.3% - 3869 -> 3869
relay moq-lite-06 1 16 4 - 4.046M 4.046M (-0.00%) 4.027M -0.5% - 2599 -> 2599
relay moq-lite-06 1 16 256 - 42.443M 42.443M (+0.00%) 41.510M -2.2% - 33121 -> 33121
relay moq-lite-06 4 1 4 - 0.371M 0.371M (-0.01%) 0.368M -0.6% - 274 -> 274
relay moq-lite-06 4 1 256 - 4.955M 4.955M (-0.02%) 4.843M -2.3% - 3869 -> 3869
relay moq-lite-06 4 16 4 - 5.044M 5.045M (+0.01%) 5.026M -0.4% - 3346 -> 3346
relay moq-lite-06 4 16 256 - 50.425M 50.428M (+0.01%) 49.339M -2.2% - 39274 -> 39274
relay moq-lite-07-wip 1 1 4 - 0.402M 0.402M (+0.00%) 0.400M -0.5% - 288 -> 288
relay moq-lite-07-wip 1 1 256 - 4.928M 4.928M (-0.00%) 4.816M -2.3% - 3896 -> 3896
relay moq-lite-07-wip 1 16 4 - 4.344M 4.344M (-0.00%) 4.328M -0.4% - 2672 -> 2672
relay moq-lite-07-wip 1 16 256 - 41.004M 41.006M (+0.00%) 40.062M -2.3% - 33193 -> 33193
relay moq-lite-07-wip 4 1 4 - 0.403M 0.403M (+0.00%) 0.400M -0.5% - 288 -> 288
relay moq-lite-07-wip 4 1 256 - 4.928M 4.928M (-0.00%) 4.816M -2.3% - 3896 -> 3896
relay moq-lite-07-wip 4 16 4 - 5.399M 5.399M (+0.01%) 5.378M -0.4% - 3429 -> 3429
relay moq-lite-07-wip 4 16 256 - 49.156M 49.156M (+0.00%) 48.046M -2.3% - 39356 -> 39356
relay moq-transport-18 1 1 4 - 0.759M 0.759M (-0.01%) 0.732M -3.7% - 607 -> 543
relay moq-transport-18 1 1 256 - 9.087M 9.084M (-0.04%) 7.913M -12.9% - 10269 -> 7193
relay moq-transport-18 1 16 4 - 6.070M 6.070M (-0.01%) 5.882M -3.1% - 4633 -> 4089
relay moq-transport-18 1 16 256 - 74.918M 74.886M (-0.04%) 65.473M -12.6% - 86718 -> 60572
relay moq-transport-18 4 1 4 - 0.760M 0.760M (+0.04%) 0.732M -3.7% - 607 -> 543
relay moq-transport-18 4 1 256 - 9.107M 9.110M (+0.03%) 7.913M -13.1% - 10269 -> 7193
relay moq-transport-18 4 16 4 - 7.400M 7.398M (-0.02%) 7.172M -3.1% - 5666 -> 5026
relay moq-transport-18 4 16 256 - 88.819M 88.746M (-0.08%) 77.855M -12.3% - 102265 -> 71505
relay moq-transport-22 1 1 4 - 0.762M 0.762M (-0.08%) 0.733M -3.8% - 607 -> 543
relay moq-transport-22 1 1 256 - 9.096M 9.091M (-0.05%) 7.918M -13.0% - 10271 -> 7195
relay moq-transport-22 1 16 4 - 6.083M 6.082M (-0.01%) 5.895M -3.1% - 4650 -> 4106
relay moq-transport-22 1 16 256 - 74.942M 74.915M (-0.04%) 65.505M -12.6% - 86735 -> 60590
relay moq-transport-22 4 1 4 - 0.761M 0.762M (+0.03%) 0.733M -3.7% - 607 -> 543
relay moq-transport-22 4 1 256 - 9.116M 9.112M (-0.05%) 7.918M -13.1% - 10271 -> 7195
relay moq-transport-22 4 16 4 - 7.414M 7.415M (+0.01%) 7.188M -3.1% - 5686 -> 5046
relay moq-transport-22 4 16 256 - 88.824M 88.862M (+0.04%) 77.896M -12.3% - 102285 -> 71525

Impact

Choices made without asking

  • Fixed in the transport, not in kio::wait. Reusing one waiter across a stream's futures would have restored the old no-op re-registration, but it would bring back per-stream parking state that refactor(net)!: transport polls take kio waiters through &self #7042 removed. Registering only when parking removes the cost on the ready path altogether, so the result is below the parent.
  • Draft 18 stays out of the committed bench's VERSIONS. The sweep added it only in the measurement exports. Draft 18 and 22 measure within 0.2% of each other everywhere, and a fourth version would add a third to every session delivery group's Nightly time.
  • Accept/open/credit paths keep polling the close first. They run once per stream rather than per frame, and the 4-frame cases already improve.

Follow-ups

  • Production adapters also give each kio::wait future a fresh waiter, so a read that parks allocates its shared waker each time, where a per-stream Park reused one before refactor(net)!: transport polls take kio waiters through &self #7042. The memory-transport benches can't see this (their reads rarely park). It may deserve a measurement on moq-tokio/moq-uring session benches. Suggested for /quest-plan, not filed.

Validation

  • quest gate nix develop --command just ci origin/main: PASS (9218 tests, plus lints and quest check).
  • cargo test -p moq-net --lib transport::memory: PASS. The new test fails against the old implementation.
  • Benchmark sweep as above. The scratch exports were outside any checkout and have been deleted.

Review

  • Round 1: Codex gpt-6.1-sol, medium effort, head 33dab5994c4ab91e6b5dd3be3fd9b1194c3d0e93 against origin/main 89575b519c. 0 findings.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 2 commits October 29, 2026 10:28
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
@kixelated kixelated changed the title moq-transport-22 FETCH costs what it did before the waiter refactor docs(quest): checkpoint FETCH poll cost measurement admission Oct 29, 2026
kixelated and others added 3 commits October 31, 2026 20:47
…hen they wait

Each kio::wait future holds its own waiter since #7042, so registering on the
session close before every channel read allocated a fresh shared waker per
frame and grew the close list. Check the close only when the read or write
parks; a ready one peeks or skips it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title docs(quest): checkpoint FETCH poll cost measurement admission perf(net): park memory-transport reads and writes on the close only when they wait Nov 1, 2026
@kixelated
kixelated marked this pull request as ready for review November 1, 2026 04:04

@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: 33dab5994c4ab91e6b5dd3be3fd9b1194c3d0e93.

No actionable bugs found. The memory-transport optimization is appropriately local and simpler than restoring per-stream waiter state. Channel registration remains atomic with the readiness check, and the second read check preserves queued-data precedence when closing races an empty read. The write path retains its close check without registering ready writes. No public API or wire changes.

Inspected the changed transport, surrounding close/stream lifecycle, kio waiter/notification semantics, and regression test, with an independent static review. Tests and benchmarks were not run; the reported performance gains were not independently reproduced.

(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

  • Memory transport read fast path — Chunks::poll_read_chunk now polls the channel first and only registers on the session close when the read parks, then re-polls the channel so a message queued before the close still drains.
  • Memory transport write fast path — SendStream::poll_write_buf peeks the close (closed(), a read lock) instead of registering, and only calls poll_close when the write parks on a full channel.
  • Channel::readable() helper — factors the read predicate (!messages.is_empty() || end.is_some()), replacing the inline closure.
  • New test only_parked_streams_wait_on_the_close — a counting waker verifies a ready read/write leaves no close registration behind while a parked one is woken by the close.
  • Quest cleanup — deletes quest/m1/ietf22-fetch-poll-cost.md and its quest/m1/README.md row.

I traced the read/write precedence against the previous close-first ordering. A session close only sets the session flag; it never ends stream channels, so dropping the ready-path registration changes nothing observable. Queued messages still drain before the close, a write after close still fails, and WaiterList::register is idempotent per list while the list holds the waiter, so the read path's two channel polls with one Waiter do not stack entries. No blocking concerns.

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

@coderabbitai

coderabbitai Bot commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
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: 83b1d8db-547a-4c63-a7bc-e8012ec43758
Commits

Reviewing files that changed from the base of the PR and between 026bc66 and 33dab59.

Files selected for processing: 3
  • quest/m1/README.md
  • quest/m1/ietf22-fetch-poll-cost.md
  • rs/moq-net/src/transport/memory.rs
Files with no reviewable changes: 2
  • quest/m1/README.md
  • quest/m1/ietf22-fetch-poll-cost.md

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 2 remain after this review.

Walkthrough

Memory transport reads and writes now check channel readiness before waiting for session closure. Reads can consume queued data after closure, and return the session error if the channel remains unreadable. Tests cover ready operations and operations that wait for closure. The m1 required list no longer includes the FETCH cost quest, and its investigation plan was deleted.

Merge Risk: Minimal · up to 33dab

The checked read and write behavior is preserved; no merge-blocking risk was identified.

Pre-merge checks: Passed 5
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: memory-transport reads and writes register for close notifications only when they wait.
Description check Passed The description directly explains the memory-transport fix, its performance impact, behavior guarantees, benchmark results, and validation. It is fully related to the changeset.
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 with cyclomatic complexity of at least 2. Documentat…
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.
Full details: Docstring Coverage

Explanation

Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 3 functions across 1 files.

Finishing Touches
Simplify code
  • Commit to this branch
  • Create a new PR
  • 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

@kixelated

Copy link
Copy Markdown
Collaborator Author

Landing summary: the memory transport now registers on the session close only when a read or write must park, so a ready read/write no longer allocates a waiter. This removes the draft-18/22 FETCH regression from moq-dev/moq#7042 (-12.5% instructions per 256-frame round vs main, below the pre-#7042 parent). Public API and wire format are unchanged.

  • Reviews: the online reviews (OpenAI, pullfrog) found nothing to fix, and the local Codex review of head 33dab59 found nothing.
  • Checks: CI is green, and quest check passes against origin/main.
  • Decisions: none are outstanding.
  • Follow-up: measure per-park waiter allocation on the production adapters (moq-tokio/moq-uring).

(Written by Claude Opus 5.5)

@kixelated
kixelated enabled auto-merge (squash) November 1, 2026 04:32
@moq-bot
moq-bot Bot merged commit 32391c4 into main Nov 1, 2026
1 check passed
@moq-bot
moq-bot Bot deleted the quest/m1/ietf22-fetch-poll-cost branch November 1, 2026 09:53
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