Skip to content

perf(moq-net): readers of a streaming frame share its byte owners - #7509

Merged
kixelated merged 7 commits into
mainfrom
quest/m2/perf/frame-read-owners
Nov 2, 2026
Merged

kixelated merged 7 commits into
mainfrom
quest/m2/perf/frame-read-owners

Conversation

@kixelated

@kixelated kixelated commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

Finishes quest/m2/perf/frame-read-owners.md.

Problem

Reading a streaming frame boxes a fresh Bytes owner per reader per read. A relay pays those allocations for every subscriber after every publication of new bytes.

Approach

  • Cache one owner of the published prefix beside each Fixed, Growing, or open Chain segment. Readers share it, and freezing reuses it when it covers the full frame.
  • Release the cached owner when growth replaces its segment. Old segments then live only as long as readers retain slices. Sealed chain segments keep sharing their existing owner.
  • Leave segment allocations uninitialized. The Release/Acquire publication boundary exposes only initialized bytes, including growth copies and overlapping deliveries.
  • Borrow the frame buffer during chunk reads. Box the rare Chain storage so every frame pays only 8 extra bytes of content metadata, rather than 32.
  • Keep both the streaming benchmark and main's paced-ingest benchmark when merging main. Streaming ownership and backpressure behavior are retained.

Impact

  • Public API and wire formats: unchanged.
  • Fuzz-only benchmark support: FrameRecv::with_readers adds readers; run returns the number of chunks read.
  • Fixed readers now take an owner-cache mutex. Fixed writers still take no storage lock. Single-reader and small threaded-reader costs are disclosed below.

Alternatives

The old per-reader owner keeps Fixed reads free of an owner-cache lock but allocates per reader, defeating the quest's goal. A lock-free cache would require safe reclamation machinery or retaining superseded owners. The selected design shares one owner per publication while preserving reclamation. Chain boxing limits the per-frame metadata cost; no public API or backpressure redesign is needed.

Measurements

Remeasured on 2026-11-02 against main 693a0ec229, with identical benchmark fixtures added to the base. Base and PR use the same compiler, optimized profile and controls.

FRAME_ALLOCS=1 passed all 48 combinations: upfront/grow, 16 KiB/2 MiB, 1/4 publishers, 1/16/128 readers, and 4/32 arrivals. Readers retain all old chunks across growth. Counts subtract identical no-reader ingest. At 128 readers and 32 arrivals, for both publisher counts:

storage base allocations / read PR allocations / read
16 KiB upfront 1.00 0.01
16 KiB grow 1.00 0.01
2 MiB upfront 0.94 0.01
2 MiB grow 0.94 0.01

That is about one owner allocation per publication, rather than per reader. Allocated owner bytes per read drop from 24.0 B (16 KiB) or 22.5 B (2 MiB) to 0.2 B.

Marginal user instructions/read with 128 readers, measured as (instructions at 32 arrivals - instructions at 4 arrivals) / (28 * publishers * readers). Three alternating samples per shape, CPU 0 pinned, existing Criterion --test controls; setup and process overhead are subtracted by the arrival difference:

storage base, 1 / 4 publishers PR, 1 / 4 publishers
16 KiB upfront 701 / 699 386 / 383
16 KiB grow 720 / 718 385 / 385
2048 KiB upfront 728 / 726 374 / 373
2048 KiB grow 731 / 728 376 / 375

The 128-reader cases use 44.9-48.6% fewer marginal instructions/read. The single-reader control costs 0.6-18.4% more, depending on shape. For example, 16 KiB upfront with 1 publisher is 1,406 to 1,664 instructions/read; with 4 publishers it is 1,567 to 1,638. This replaces the older, smaller single-reader estimate rather than claiming every workload improves.

group_stream_read passed all 12 smoke shapes, then measured three runs per side with --bench --quick: arrivals {4,16}, threads {1,4,16}, readers {16,256}. Two pairs alternated base/current then current/base. Point estimates in microseconds per round:

arrivals / threads / readers base, 3 runs current, 3 runs
arrivals_4/threads_1/16 2.114, 2.104, 2.050 1.749, 1.762, 1.756
arrivals_4/threads_1/256 41.712, 38.675, 41.040 15.501, 15.421, 15.463
arrivals_4/threads_4/16 4.117, 4.226, 6.260 5.037, 4.872, 4.932
arrivals_4/threads_4/256 53.230, 52.927, 53.587 48.003, 47.913, 52.715
arrivals_4/threads_16/16 15.711, 17.966, 16.486 33.479, 18.218, 15.680
arrivals_4/threads_16/256 219.870, 90.578, 73.094 78.585, 92.341, 90.506
arrivals_16/threads_1/16 1.511, 1.528, 1.514 1.240, 1.212, 1.241
arrivals_16/threads_1/256 34.029, 37.653, 17.130 11.185, 11.225, 11.123
arrivals_16/threads_4/16 3.347, 3.325, 3.102 4.347, 4.229, 4.279
arrivals_16/threads_4/256 45.393, 44.299, 42.533 40.080, 40.153, 39.517
arrivals_16/threads_16/16 202.260, 18.987, 7.314 13.532, 13.045, 13.438
arrivals_16/threads_16/256 264.600, 63.411, 57.309 73.679, 69.087, 76.295

This shared-host wall clock is mixed and noisy; no general wall-clock speedup is claimed. The 4-thread/16-reader/16-arrival case has a repeatable cost (3.10-3.35 us base, 4.23-4.35 us PR). The 1-thread/256-reader and 4-thread/256-reader cases improve. The 16-thread results vary substantially, including large base A/A spread. The intended allocation and high-fanout instruction improvements hold, with these measured contention tradeoffs.

Original implementation profile, retained as the zero-fill evidence: perf record on frame_recv/upfront/size=16384/streams=64 showed __memset_avx2_unaligned_erms at 3.0% of cycles, versus 5.8% for the payload copy. Since rep stosb counts as one instruction, the author also isolated the fill using GLIBC_TUNABLES=glibc.cpu.x86_rep_stosb_threshold=0x7fffffff:glibc.cpu.x86_rep_movsb_threshold=0x7fffffff:

one run zero-filled instructions uninitialized instructions
16 KiB upfront 2,491,809 2,483,770
16 KiB grow 2,582,039 2,564,864
2 MiB upfront 4,137,413 4,083,462
2 MiB grow 6,192,406 5,762,013

The recorded profile meets the quest's condition to keep the zero-fill optimization only when the profile shows its cost. Both benchmark targets run nightly through moq-net's --bench '*' leg.

Validation

  • Nix quest gate just ci origin/main: passed, 9,476 Rust tests passed, 109 skipped, plus selected formatting, documentation, quest and tooling checks. The initial inherited environment failed Cargo launch with E2BIG; a fresh nix develop --ignore-environment --keep HOME --keep PATH fixed the environment, without changing source or test settings.
  • backend::quic_client_close_drains_finished_track passed in the full gate. The shared failure addressed by moq-dev/moq#7634 did not reproduce. This PR neither incorporates nor duplicates that repair.
  • Both modified benchmark targets built for base and PR. All 48 allocation shapes and all 12 threaded smoke shapes completed; three instruction samples per selected control shape and three threaded measurement runs per side completed.
  • quest check --base origin/main --branch quest/m2/perf/frame-read-owners: passed.

Review

Claude Code, Claude Opus 5.5, medium effort, quest review --harness claude --base origin/main, session 677add45-64f9-4609-9d6a-fc30a34c4de6, round 2, head 57285e448a against 62ccb69bac: no findings.

Round 1 dispositions, accepted in round 2:

  • Contention evidence: refuted the missing-measurement premise with the original description and fresh complete threaded sweep. Reported the repeatable small-reader cost and noisy cross-core results instead of claiming universal speedup. Retained the selected per-publication ownership model.
  • Zero-fill evidence: refuted the missing-profile premise with the profile and isolated ablation table already in the original PR description. That evidence is retained above.
  • The online safety-comment finding was already fixed in 18588e5dd3: Fixed bytes rely on the Release/Acquire publication boundary, rather than its reader-only owner lock.

Follow-ups

None required. No new API or scope choices were made in this iteration.

(Written by GPT-6.1 Sol)

kixelated and others added 4 commits November 1, 2026 00:16
Readers slicing a frame still arriving shared nothing: every read boxed a
fresh owner over the segment. The first read past each publication now
builds one owner over every published byte, cached beside the segment,
and later readers slice it. Growth drops the cached owner with the old
segment, so it never outlives the slices cut from it.

Segments are left uninitialized, since only their published prefix is
read and every byte of it was written first. A frame chain is boxed so
the cached owner adds 8 bytes to each frame's content, not 32.

The frame bench sweeps publishers, readers, and arrival chunks, with
FRAME_ALLOCS=1 printing allocations per read; the group bench reads a
streaming frame from readers across threads.

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 Readers of a streaming frame share its byte owners perf(moq-net): readers of a streaming frame share its byte owners Nov 1, 2026
@kixelated
kixelated marked this pull request as ready for review November 1, 2026 09:12

@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: cb9a93a

No actionable bugs found in the changed areas. The direction is sound: caching the published-prefix owner addresses allocation fanout at its source, and dropping that cache on growth preserves old-slice ownership without retaining superseded segments. The small lock-based cache is preferable here to adding lock-free reclamation machinery; the fixed-reader locking and boxed-chain tradeoffs are explicit. No production public API or wire change found.

Checked publication ordering, initialized-prefix bounds, overlapping deliveries, growth/sealing/freezing, the borrowed consumer path, and benchmark scaffolding. Static GitHub review only; I did not run tests, Miri, or benchmarks or independently reproduce the reported performance measurements.

(Written by OpenAI)

@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: 6247160f-f585-4093-8b14-739802425e9d
Commits

Reviewing files that changed from the base of the PR and between 21112b3 and cb9a93a.

Files selected for processing: 7
  • quest/m2/perf/README.md
  • quest/m2/perf/frame-read-owners.md
  • quest/m2/quic-io-boundary.md
  • rs/moq-net/benches/frame.rs
  • rs/moq-net/benches/group.rs
  • rs/moq-net/src/fuzz.rs
  • rs/moq-net/src/model/frame.rs
Files with no reviewable changes: 2
  • quest/m2/perf/README.md
  • quest/m2/perf/frame-read-owners.md

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

Walkthrough

Frame storage now allocates uninitialized segments and shares Bytes owners for published data. Partial-frame reads borrow the source buffer. FrameRecv supports subscriber readers and returns the number of chunks read. New frame and group benchmarks measure chunked reads and allocation counts. Related planning documentation was updated or removed.

Merge Risk: Minimal · up to cb9a9

No merge-blocking issue was established; the change is mergeable after normal checks.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 72.22% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documenta… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title names the main change: readers of a streaming frame share one cached byte owner, which is the core of the diff in fuzz.rs, model/frame.rs, and the benches. It uses the conventional perf(moq-…
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.
Description check Passed The pull request description directly explains the shared byte-owner implementation, allocation and instruction improvements, benchmark changes, validation, and scope. These topics match the reported …
Full details: Title check

Explanation

The title names the main change: readers of a streaming frame share one cached byte owner, which is the core of the diff in fuzz.rs, model/frame.rs, and the benches. It uses the conventional perf(moq-net) prefix and is a single concise sentence. Nothing in the title is off-topic.

Full details: Description check

Explanation

The pull request description directly explains the shared byte-owner implementation, allocation and instruction improvements, benchmark changes, validation, and scope. These topics match the reported changeset.

Full details: Docstring Coverage

Explanation

Docstring coverage is 72.22% which is insufficient. 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 18 functions across 4 files. (1 skipped: 1 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

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

@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 clarifying note on a safety comment.

Reviewed changes

  • Shared byte owner. FrameContent::slice caches one Bytes owner per publication beside the storage (FrameStorage::Fixed { segment, shared } and Open { segment, shared }), rebuilt when written advances and dropped when Open::reserve grows the segment. Sealed chain segments keep one owner each and freeze reuses the cached owner.
  • Uninitialized segments. Segment::new now allocates with Box::<[u8]>::new_uninit_slice; only the published prefix is read. Verified there is no read path that observes a byte before it is written: every Filled::len is bounded by the content.written watermark, stored Release after the bytes and loaded Acquire by readers. The Fixed arm's shared mutex does not synchronize with writers, but the watermark pairing does and the concurrent write region is disjoint from [0, len).
  • Boxed chain and read-path borrow. FrameStorage::Chain is boxed and poll_read_chunk_once borrows &self.source instead of cloning its FrameBuf.
  • Benches and helper. New frame_read (with a counting-allocator FRAME_ALLOCS mode) and threaded group_stream_read benches; FrameRecv::with_readers added and run now returns the chunk count. Both benches are reached by nightly's criterion net ... --bench '*'.
  • Docs. Deletes quest/m2/perf/frame-read-owners.md and its index bullet, and updates the zero-fill sentence in quest/m2/quic-io-boundary.md.

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

Comment thread rs/moq-net/src/model/frame.rs Resolved
@kixelated
kixelated enabled auto-merge (squash) November 1, 2026 18:07
@moq-bot

moq-bot Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

codex / Codex land fixer note:

The Rust benchmark build and all 3247 Nextest tests passed, along with the full JavaScript net build and test suite and quest document check. This PR does not change the JavaScript implementation or test implicated in the batch failure, a peer close drains the overtaken group over moq-lite-02, which passed locally. No changes were made.

(written by codex / Codex)

@moq-bot
moq-bot Bot disabled auto-merge November 2, 2026 06:57
@moq-bot moq-bot Bot removed the quest:merge label Nov 2, 2026
@moq-bot

moq-bot Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

codex / Codex land fixer: This maintainer pick already used its mechanical fi·x attempt. Run /quest-iterate to diagnose the repeated failure, then pick it again. Recommendation: run /quest-iterate before picking it again.

	const published = new Map<string, string>();
	for (const dir of manifest(root).workspaces ?? []) {
		const pkg = manifest(join(root, dir));
		if (pkg.name.startsWith("@moq/") && pkg.scripts?.release) published.set(pkg.name, join(root, dir));
	}
	const declared = Object.keys(manifest(here).devDependencies ?? {}).filter((name) => name.startsWith("@moq/"));
	const missing = [...published.keys()].filter((name) => !declared.includes(name));
	const extra = declared.filter((name) => !published.has(name));
	if (missing.length || extra.length) {
		throw new Error(
			`${here}/package.json must declare exactly the published packages: missing [${missing}], extra [${extra}]`,
		);
	}
	const out = join(here, "../../lib/js/api");
	// `--cached` reuses the last reference while no input has changed since, unless
	// MOQ_API_REFERENCE=regenerate; the dev server passes it, a build never does.
	const stamp = join(out, ".inputs");
	const inputs = newest([...published.values(), here]);
	const cached = process.argv.includes("--cached") && process.env.MOQ_API_REFERENCE !== "regenerate";
	if (cached && existsSync(stamp) && Number(readFileSync(stamp, "utf8")) >= inputs) {
		console.log("API reference: unchanged, reusing it; MOQ_API_REFERENCE=regenerate forces it");
	} else {
		const started = performance.now();
		await generate({ root, packages: [...published.values()], out, docs: join(here, "../..") });
		writeFileSync(stamp, String(inputs));
		console.log(
			`API reference: ${published.size} packages in ${((performance.now() - started) / 1000).toFixed(1)}s`,
		);
	}
}

// The newest modification time of any file or directory under `dirs`, which a deleted file
// changes too, skipping installs and build output.
function newest(dirs: string[]): number {
	let found = 0;
	const walk = (path: string) => {
		const stat = statSync(path);
		found = Math.max(found, stat.mtimeMs);
		if (!stat.isDirectory()) return;
		for (const entry of readdirSync(path)) {
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
llmstxt »   vitepress-plugin-llms initialized (SSR build) with workDir: /tmp/quest-land-7530/doc
llmstxt »   Build started, file collection cleared
llmstxt »   Skipping LLMs docs generation in SSR build
�[32m✓�[0m building client + server bundles...
- rendering pages...
�[32m✓�[0m rendering pages...
build complete in 184.13s.
    ELAPSED %CPU   RSS COMMAND

codex
All checks passed, including #7530’s documentation build and 438 relay tests with io_uring enabled. I’ll requeue all three PRs unchanged, name the intermittent peer-close tests in the result, and leave blame unset because the evidence does not identify a responsible PR.

exec
/bin/bash -lc "python3 - <<'PY'
import json
from pathlib import Path
flake = 'intermittent landing tests: a peer close drains the overtaken group over moq-lite-02, moq-lite-03, and moq-lite-04'
results = [
    {
        'number': 7553,
        'action': 'requeue',
        'reason': 'Healthy with base; passed bun run --cwd js/net build, bun run --cwd js/net check, and bun run --cwd js/net test (2694 passed); ' + flake,
        'comment': 'The landing log reports peer-close drain failures in the existing MoQ Lite tests. This PR changes only IETF join tests and quest documents. The failing tests passed on base, the full base net suite passed (2689 tests), and the full net suite with this PR and base passed (2694 tests). Requeueing unchanged; please follow up on the intermittent peer-close tests for moq-lite-02, moq-lite-03, and moq-lite-04.'
    },
    {
        'number': 7509,
        'action': 'requeue',
        'reason': 'Healthy with base; passed cargo build --locked -p moq-net --features fuzz --bench frame --bench group and cargo nextest run --locked -p moq-net --tests --test-threads 4 (3232 passed); unchanged JS passed bun test --cwd js/net --only-failures on base; ' + flake,
        'comment': 'The Rust changes and both modified benchmarks build successfully. All 3232 moq-net tests passed with Nextest. An initial cargo test --locked -p moq-net --all-targets run encountered the existing setup::tests::credentials_stay_out_of_traces global tracing-subscriber conflict; the repository uses Nextest to isolate tests in separate processes. No source changes were needed. The original landing failure was in unchanged JavaScript peer-close tests, which passed in the full base net suite.'
    },
    {
        'number': 7530,
        'action': 'requeue',
        'reason': 'Healthy with base; passed cargo build --locked -p kio -p moq-uring -p moq-relay, cargo test --locked -p kio -p moq-uring -p moq-relay, cargo nextest run --locked -p moq-relay --features io-uring --tests --test-threads 4 (438 passed), and bun run --cwd doc check; unchanged JS passed bun test --cwd js/net --only-failures on base; ' + flake
    }
]
Path('quest-land-result.json').write_text(json.dumps(results, indent=2) + '\\n')
assert len(results) == 3 and {r['number'] for r in results} == {7553, 7509, 7530}
pinned = {7553: '378a07b7d0c390b6044c0802e57e1c80e7f56e56', 7509: '94a1d2be089a2738e8f7c412921d1833a87cf334', 7530: 'cbe4a2bc680f0dc568ebdc6e015cd7828d1e2c1b'}
import subprocess
for number, head in pinned.items():
    assert subprocess.check_output(['git', 'rev-parse', f'pr-{number}'], text=True).strip() == head
print('Result validated; all PR branch tips unchanged.')
PY
 git status --short && git -C /tmp/quest-land-7509 status --short --untracked-files=no && git -C /tmp/quest-land-7530 status --short --untracked-files=no && git -C /tmp/quest-land-7553 status --short --untracked-files=no" in /work
 succeeded in 9759ms:
Result validated; all PR branch tips unchanged.
?? quest-land-result.json

codex
Wrote [quest-land-result.json](/work/quest-land-result.json), requeueing all three PRs unchanged.

Builds and tests passed. The result names the intermittent MoQ Lite peer-close tests. No PR was blamed; all branch tips remain unchanged.
tokens used
85,072
Wrote [quest-land-result.json](/work/quest-land-result.json), requeueing all three PRs unchanged.

Builds and tests passed. The result names the intermittent MoQ Lite peer-close tests. No PR was blamed; all branch tips remain unchanged.
fixer result written

(written by codex / Codex)

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
@kixelated
kixelated added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 24b0463 Nov 2, 2026
9 of 10 checks passed
@kixelated
kixelated deleted the quest/m2/perf/frame-read-owners branch November 2, 2026 18:22
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