Skip to content

perf(net): copy small whole frames out of shared receive buffers - #7522

Merged
kixelated merged 7 commits into
mainfrom
quest/m2/performance-survey/cache-backing
Nov 2, 2026
Merged

kixelated merged 7 commits into
mainfrom
quest/m2/performance-survey/cache-backing

Conversation

@kixelated

@kixelated kixelated commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

Finishes quest/m2/performance-survey/cache-backing.md and deletes it.

Summary

The quest assumed fMP4 import puts slices of a fragment into the cache. It does not: every sample is copied into its own frame, both unwrapped and in passthrough. The amplification turned up somewhere else, in network ingest.

Reader::poll_read_frame cached a frame that arrives in one transport chunk as that chunk. The frame's own storage was used only when the frame spans chunks. Under QUIC a chunk is a slice of a datagram. moq-tokio's endpoint driver copies each receive into one reused BytesMut and splits datagrams off it (endpoint.rs). Once any slice of that buffer is still held, bytes reallocates 64 KiB (the most original capacity it keeps), and later datagrams fill that buffer back to back. A moq-uring GRO completion is one allocation as well. So every frame that fits in a datagram (audio, small deltas, catalogs) kept its whole receive allocation alive. That included the video bytes the cache had already copied out, and other connections' bytes. The pool billed only the frame.

  • Fix: poll_read_frame now copies a frame that arrives whole and is at most COPY_WHOLE (2 KiB) out of its chunk. Larger frames are still cached as the chunk, so a large frame on the in-memory transport still crosses as the publisher's allocation. A new unit test, a_small_whole_frame_is_copied_out_of_its_chunk, pins 1 B and 2 KiB frames as copies and a 2 KiB + 1 frame as the chunk, and checks the rest of the datagram stays leased. The zero_copy integration test (64 KiB frames) still passes, and its doc now notes the threshold.
  • backing bench (rs/moq-net/benches/backing.rs): receives 10 s of interleaved media through the session's frame reader into a shared pool, with tracks video and audio track pairs. Frames are packed back to back into 1150-byte packets. The sweep covers backing (exact control, datagram as moq-uring without GRO, recv64k as moq-tokio), video bitrate {150, 1000, 4000} kbps against 64 kbps audio, tracks {1, 4}, and selective expiry: keep {all, quarter, none} of the video groups while every audio group stays. BACKING_ALLOCS=1 prints the live heap the cache retains (each allocation counted once, after the group log's deferred frees) against Pool::used(), along with how many frames arrived whole and the allocations and bytes per frame. Timing covers the keep=all cases. The first run of each prints the same line for it and for its keep=quarter and keep=none cases, so the nightly rust-bench leg's -p moq-net --features fuzz --bench '*' logs every case's memory. The bench reports the ratio and doesn't assert it. The unit test above is the regression guard.
  • fuzz::GroupRecv: a fuzz-feature harness that feeds a frame's chunks, as given, through coding::Reader::poll_read_frame into a group, the way the lite subscriber does.

Public API: none (moq_net::fuzz::GroupRecv is fuzz-feature only). Wire: none.

Decisions

Settled by the maintainer on 2026-11-01:

  1. Should a small frame that arrives whole be copied out of its receive buffer?
    • ✅ A. Copy in Reader::poll_read_frame when a whole frame is at most a fixed threshold. (Implemented.)
    • B. Have moq-tokio allocate each datagram separately, as moq-uring does without GRO.
    • C. Leave sharing as it is, and document Pool::used() as the payload billed, not the heap held.
  2. If A, what threshold?
    • 16 KiB (first measured): covers jumbo datagrams, but in-memory frames up to 16 KiB get copied too.
    • ✅ 2 KiB. Enough for a QUIC datagram's stream payload (about 1.45 KB at moq-quic's default max_udp_payload_size of 1472). It is not tuned for jumbo paths. In-memory frames above 2 KiB cross without a copy.

Measurements

Every number here is deterministic. Two full BACKING_ALLOCS=1 runs printed byte-identical output. Instructions are user-mode perf stat -e instructions:u with the process pinned by taskset -c 7. Three runs per binary agreed within 0.003%, which serves as the A/A control. Base is c1a1412820. Machine: 24-core Linux 7.0, load average 10-15, Nix toolchain, --release bench profile, glibc malloc.

With the fix (2 KiB, on de08552b6c)

BACKING_ALLOCS=1 cargo bench -p moq-net --features fuzz --bench backing. Live heap retained, as a ratio to Pool::used():

case exact datagram recv64k
tracks=1/video=150k/keep=all 1.08x 1.08x 1.08x
tracks=1/video=150k/keep=none 1.19x 1.16x 1.16x
tracks=1/video=1000k/keep=all 1.03x 1.03x 1.03x
tracks=1/video=1000k/keep=none 1.19x 1.19x 1.19x
tracks=1/video=4000k/keep=all 1.01x 1.01x 1.01x
tracks=1/video=4000k/keep=quarter 1.02x 1.02x 1.02x
tracks=1/video=4000k/keep=none 1.16x 1.16x 1.19x
tracks=4/video=150k/keep=all 1.08x 1.08x 1.08x
tracks=4/video=1000k/keep=quarter 1.07x 1.07x 1.07x
tracks=4/video=4000k/keep=all 1.01x 1.01x 1.01x
tracks=4/video=4000k/keep=quarter 1.02x 1.02x 1.02x
tracks=4/video=4000k/keep=none 1.17x 1.17x 1.17x

All 54 cases match the exact control within 4.2 KB, about two of the 2,072-byte one-time allocations that also show up after teardown. 2 KiB holds the same result the 16 KiB candidate did, because a frame that arrives whole fits in one 1150-byte packet. For the same reason, the 2 KiB patch copies exactly the frames the 16 KiB one did in this bench, so the instruction cost below carries over unchanged.

In-memory transport, MEMORY_ALLOCS=1 cargo bench -p moq-net --features fuzz --bench memory, main bf4e726f66 → this PR: 64-byte frames go from 35.0 to 37.0 allocations per frame (4337 to 4425 bytes), and 16 KiB frames are unchanged at 34.3 allocations (4214 bytes), since they are no longer copied. With the 16 KiB candidate, 16 KiB frames had grown to 20,643 bytes per frame.

Before the fix: live heap retained (ratio to Pool::used())

Before this PR's fix. copied is the first measured candidate, which copied a frame that arrived whole and was at most 16 KiB.

case pool exact datagram recv64k datagram, copied recv64k, copied
tracks=1/video=150k/keep=all 0.37 MB 0.40 MB (1.08x) 0.48 MB (1.31x) 0.56 MB (1.52x) 0.40 MB (1.08x) 0.40 MB (1.08x)
tracks=1/video=150k/keep=none 0.14 MB 0.17 MB (1.19x) 0.34 MB (2.34x) 0.41 MB (2.89x) 0.17 MB (1.16x) 0.17 MB (1.16x)
tracks=1/video=1000k/keep=all 1.43 MB 1.46 MB (1.02x) 1.72 MB (1.21x) 2.76 MB (1.93x) 1.46 MB (1.02x) 1.46 MB (1.02x)
tracks=1/video=1000k/keep=none 0.14 MB 0.17 MB (1.19x) 0.43 MB (3.00x) 1.47 MB (10.22x) 0.17 MB (1.19x) 0.17 MB (1.19x)
tracks=1/video=4000k/keep=all 5.18 MB 5.21 MB (1.01x) 5.49 MB (1.06x) 9.86 MB (1.90x) 5.21 MB (1.01x) 5.21 MB (1.01x)
tracks=1/video=4000k/keep=quarter 1.15 MB 1.18 MB (1.02x) 1.45 MB (1.26x) 5.82 MB (5.06x) 1.18 MB (1.02x) 1.18 MB (1.02x)
tracks=1/video=4000k/keep=none 0.14 MB 0.17 MB (1.16x) 0.44 MB (3.05x) 4.81 MB (33.53x) 0.17 MB (1.16x) 0.17 MB (1.19x)
tracks=4/video=150k/keep=all 1.46 MB 1.59 MB (1.08x) 1.90 MB (1.30x) 2.03 MB (1.39x) 1.59 MB (1.08x) 1.59 MB (1.08x)
tracks=4/video=1000k/keep=quarter 1.60 MB 1.71 MB (1.07x) 1.97 MB (1.23x) 6.44 MB (4.02x) 1.71 MB (1.07x) 1.71 MB (1.07x)
tracks=4/video=4000k/keep=all 20.71 MB 20.86 MB (1.01x) 21.13 MB (1.02x) 38.56 MB (1.86x) 20.86 MB (1.01x) 20.86 MB (1.01x)
tracks=4/video=4000k/keep=quarter 4.60 MB 4.71 MB (1.02x) 4.99 MB (1.08x) 22.42 MB (4.87x) 4.71 MB (1.02x) 4.71 MB (1.02x)
tracks=4/video=4000k/keep=none 0.57 MB 0.67 MB (1.17x) 0.95 MB (1.66x) 18.38 MB (32.02x) 0.67 MB (1.17x) 0.67 MB (1.17x)

The other 24 cases follow the same pattern. The control's ratio above 1.0 is bookkeeping the pool's per-entry estimate does not cover, not payload. With nothing evicted, recv64k retains about 1.9x at 1-4 Mbps: the cache's copy of the video, plus the receive buffers that the audio keeps alive. Evicting video groups while audio stays drives it to 10-34x. Every case returned to its starting heap after teardown, within 2-4 KB of one-time lazy initialization.

The copy's cost

The copy adds 1.1-1.5 allocations and about 100-210 bytes allocated per frame received, averaged over all frames (BACKING_ALLOCS=1). User instructions for the whole process, median of 3:

case frames arriving whole base copied delta per frame copied
tracks=4/video=150k/keep=all/recv64k 2,351 of 3,200 15,762,724 17,819,791 +875
tracks=4/video=150k/keep=all/datagram 2,351 of 3,200 16,508,491 18,641,496 +907
tracks=4/video=4000k/keep=all/recv64k 1,763 of 3,200 31,385,973 33,353,966 +1,116

An empty run (no case matches the filter) costs 2,499,814 to 2,500,043 instructions. Net of that, the copy adds 16% to the 150 kbps ingest and 7% at 4 Mbps, about 0.3 µs per frame copied. The copy allocates the frame, then allocates again the first time the frame is cloned, because a Bytes built from a Vec promotes to shared storage on its first clone. Delivery latency moves only by that CPU, since the copy happens in the same poll that hands the frame over.

The in-memory transport's frames cross as the publisher's allocation today. With a 16 KiB threshold, MEMORY_ALLOCS=1 cargo bench -p moq-net --features fuzz --bench memory went from 35.9 to 37.9 allocations per frame (4358 to 4446 bytes) for 64-byte frames. For 16 KiB frames it went from 35.2 to 37.2 allocations (4235 to 20643 bytes), because those frames are now copied.

fMP4 import (scratch harness, not landed)

The harness imported 20 s of mixed H.264-shaped video and 160-byte AAC-shaped audio, built from bbb.mp4's moov. It swept fragments of 1 s and 4 s (two GOPs per fragment), video at {150, 1000, 4000} kbps, unbounded versus a pool capped at a quarter of the input (pool-driven eviction), and unwrapped versus passthrough. It reported the live heap after Import::finish against Pool::used().

  • Unwrapped: the retained heap beyond the bill was a constant 0.26-0.34 MB of importer and catalog state in every case (1.03x at 4 Mbps unbounded). The Legacy, LOC, and CMAF writers each build a fresh buffer per frame, so no frame shares the input.
  • Passthrough on main: up to 1.56x (4 Mbps, 1 s fragments). fragment() encodes the rebuilt mdat with mp4-atom's Vec<T> encoder one byte at a time. The Vec grows by doubling, and Bytes::from(Vec) keeps that capacity. With 4 s fragments, 2.0 MB happens to fit just under 2 MiB (1.07x).
  • Passthrough on moq-dev/moq#7232's head 5dbdb5a564: 1.02x. That PR streams the payload instead of encoding the mdat, so it also removes the per-byte loop. I left fragment() alone rather than conflict with it.

Choices made without asking

  • Scope moved from the importer to ingest. The Plan named the fMP4 importer, but source and measurement show it copies every sample. The landed bench covers the path that actually shares. The importer measurement was a one-off scratch harness, because fix(mux)!: stream fMP4 media payloads and bound held metadata #7232 is about to replace the code it measures. The method is described above, and the passthrough slack is fix(mux)!: stream fMP4 media payloads and bound held metadata #7232's to remove.
  • The bench models the driver's buffering, not the driver. It reproduces moq-tokio's reserve/extend_from_slice/split_to sequence and moq-uring's per-completion BytesMut::from against real bytes, then feeds the slices through the real reader and cache. A loopback QUIC run would batch nondeterministically. The packet model ignores QUIC frame headers and per-packet overhead.
  • Explicit eviction. Selective expiry aborts the oldest video groups with Error::Evicted, because the pool's debt evicts every track in proportion to what it wrote and so cannot keep audio while dropping video. keep sets the share of video groups that stay.
  • crossbeam-epoch as a dev-dependency. It is already in the lockfile through kio. The bench flushes the group log's epoch-deferred frees before reading the live heap; without that, frees landed in the next case.
  • Copy, not append. The fix copies with Bytes::copy_from_slice, the measured patch, rather than writing the slice into the frame's segment storage, so a lease is still never held across write_deferred.
  • Timed cases. Only keep=all is timed (18 cases), to keep the nightly rust-bench leg short. A timed run still prints every case's memory line, untimed, from inside the selected case's bench closure. That way the lines follow Criterion's filter, including bench/run.sh's --save-baseline and --exact runs. BACKING_ALLOCS=1 prints them without timing.

Follow-ups

  • MEMORY_ALLOCS=1 cargo bench -p moq-net --features fuzz --bench memory no longer hangs on main bf4e726f66. It now panics at streams=16/size=1048576 (memory.rs:194, unwrap() on Err(Evicted)), with and without this change, so that bench's allocation mode stays broken past streams=16/size=16384. On c1a1412820 it hung after streams=1/size=16384.
  • Once moq-dev/moq#7232 lands, the importer's passthrough slack is gone. A rerun of the importer harness would confirm it on main.
  • A transport hint (a lease saying whether its chunks share a larger allocation) would let jumbo-datagram paths copy too, without copying in-memory frames. Not needed at today's default datagram size.

Checks

On de08552b6c, after merging main bf4e726f66:

  • cargo nextest run --locked -p moq-net --all-targets --all-features passes: 3222 tests, 13 skipped.
  • cargo clippy --locked -p moq-net --all-targets --all-features -- -D warnings is clean, and cargo fmt -p moq-net --check passes.
  • The backing bench runs every case. quest check --base origin/main passes.
  • Plain cargo test --all-features fails setup::tests::credentials_stay_out_of_traces only because another test in the same process set the global tracing subscriber first. It passes alone and under nextest, which CI uses.

Before the fix, on fb1f340472, quest gate just check passed.

Review

Codex gpt-6.1-sol, medium effort (quest review --base origin/main, round 1), reviewed head de08552b6c and found nothing. That change got a local review because it changes ingest behavior.

Since then, 26f0edea76 changes only the backing bench, and the rest is a conflict-free merge of main. Under the simple-review rule (benchmarks only), the local review was skipped for the new head 6d50c88d60, which relies on the online reviewers. On that head, cargo clippy --locked -p moq-net --benches --features fuzz -- -D warnings, cargo fmt -p moq-net --check, and quest check --base origin/main pass. A filtered run of backing/tracks=4/video=4000k/keep=all/recv64k printed all three keep lines (1.01x, 1.02x, 1.17x) and timed only that case.

Online: Pullfrog's thread said the nightly run never logs the eviction cases. That is fixed in 26f0edea76. Its question on asserting the ratio is answered: the ratio is reported, and the reader's unit test is the guard. Pullfrog's check fails because of a billing outage, not this PR.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 2 commits November 1, 2026 01:24
A frame that arrives whole in one transport chunk is cached as that chunk,
a slice of the datagram's receive allocation. The new `backing` bench
receives mixed audio and video through the session's frame reader with
per-chunk, per-datagram and moq-tokio-style 64 KiB receive allocations,
evicts video groups while audio stays, and prints the live heap retained
against Pool::used(). With 64 KiB allocations it retains 1.9x with
nothing evicted and up to 33x once the video is gone.

The quest is rewritten to the remaining fix, which waits on choosing the
copy rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title Measure shared cache backing amplification bench(net): measure the receive buffers cached frames keep alive Nov 1, 2026
kixelated and others added 3 commits November 1, 2026 14:35
Reader::poll_read_frame cached a frame that arrived in one transport chunk
as that chunk, a slice of the QUIC receive allocation it shared with other
datagrams. A small audio frame kept up to 64 KiB alive while the pool billed
only the frame: 1.9x the bill with nothing evicted, 33x once the video
groups beside it were evicted.

A whole frame of at most COPY_WHOLE (2 KiB) is now copied out, which covers
a QUIC datagram's stream payload (about 1.45 KB). The backing bench returns
to the control in every case. Larger frames, including the in-memory
transport's, still cross as the publisher's allocation.

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 bench(net): measure the receive buffers cached frames keep alive perf(net): copy small whole frames out of shared receive buffers Nov 1, 2026
@kixelated
kixelated marked this pull request as ready for review November 1, 2026 22:47
@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 11 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: 703683da-30ba-4c0f-8ca0-bc11265b1a30
Commits

Reviewing files that changed from the base of the PR and between 88e6990 and 6d50c88.

Files ignored due to path filters: 1
  • Cargo.lock is excluded by !**/*.lock
Files selected for processing: 7
  • quest/m2/performance-survey/README.md
  • quest/m2/performance-survey/cache-backing.md
  • rs/moq-net/Cargo.toml
  • rs/moq-net/benches/backing.rs
  • rs/moq-net/src/coding/reader.rs
  • rs/moq-net/src/fuzz.rs
  • rs/moq-net/tests/integration/zero_copy.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

@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: de08552b6c47a39fd42a176971995fa24b34dc67.

No actionable bugs found. The whole-frame copy preserves lease consumption and receive-credit release, leaves fragmented-frame assembly unchanged, and retains sharing above 2 KiB. The threshold-boundary test and shared-backing benchmark target the ownership problem directly.

Overall direction is sound: copying at cache ingress is a small, focused alternative to changing every transport's allocation policy or expanding the lease API. The accepted threshold deliberately trades extra small in-memory copies for avoiding default-MTU receive-buffer retention; jumbo paths remain outside that guarantee. No production public API or wire change found; the new harness is fuzz-feature-only.

Verification limits: static GitHub inspection of the change and relevant reader, frame, lease, caller, and nightly-benchmark paths. I did not run tests or benchmarks or independently reproduce the reported performance numbers.

(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 critical issues — one note on the nightly guard's coverage.

Reviewed changes

  • Reader::poll_read_frame copies small whole frames: a frame that arrives whole in one chunk and is at most COPY_WHOLE (2 KiB) is copied out of it (Bytes::copy_from_slice + lease.advance); larger frames still cross as the chunk. One deliberate copy, since write_deferred stores a whole-frame chunk directly.
  • New test a_small_whole_frame_is_copied_out_of_its_chunk pins 1 B / 2 KiB as copies, 2 KiB + 1 as the chunk, and the rest of the datagram still leased.
  • New fuzz::GroupRecv harness feeds a frame's chunks, as given, through the real reader into a group (fuzz-only, #[doc(hidden)] module).
  • New backing bench sweeps tracks × video bitrate × backing (exact/datagram/recv64k) × selective expiry, printing live heap against Pool::used().
  • zero_copy doc notes the 2 KiB threshold; the cache-backing quest is finished and deleted with its README link removed.

Public API: none (fuzz::GroupRecv is fuzz-only). Wire: none.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Comment thread rs/moq-net/benches/backing.rs Resolved
kixelated and others added 2 commits November 1, 2026 15:28
The timed path printed memory only for the keep=all cases, so the nightly
run never showed the eviction cases where the shared receive buffers
amplified most. The first run of each timed case now prints the memory of
its keep=all, keep=quarter, and keep=none cases. Running inside the bench
closure follows Criterion's own filter, so a filtered or --exact run prints
only the selected case's lines.

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

@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: 6d50c88d60c89c4bd8478f0efb3431b19a010529, compared with my prior review at de08552b6c47a39fd42a176971995fa24b34dc67, excluding merged base changes.

No new actionable bugs found. The update fixes the selective-expiry logging gap identified in Pullfrog's finding: each selected timed case now measures all three retention policies once, outside iter_batched, and uses Criterion's filtering rather than duplicating its argument parsing. The existing runner captures stderr, so these measurements reach the retained logs.

Direction remains sound: reuse the existing measurement path without adding timing cases or an arbitrary memory threshold. The ratios remain diagnostic logs, not automatically compared Criterion metrics; the copy-boundary unit test remains the regression assertion. Production behavior/API/wire are unchanged by this update.

Static GitHub inspection only; tests and benchmarks were not run, and the reported ratios 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

Changes since the prior pullfrog review (de08552b6c): 26f0edea7 plus a conflict-free merge of main. Only rs/moq-net/benches/backing.rs changed in the delta, all benchmark-only.

  • Extracted case-name formatting into a shared name() helper, reused by cases() and the timed loop.
  • A timed keep=all case now measures and prints the memory line for all three keep values on its first run, so the nightly timed run (criterion net ... --bench '*', unfiltered) logs the selective-expiry cases' ratios too. This resolves the prior review's coverage note.

No production, public API, or wire change.

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
kixelated added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 4679619 Nov 2, 2026
7 checks passed
@kixelated
kixelated deleted the quest/m2/performance-survey/cache-backing branch November 2, 2026 09:09
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