Skip to content

perf(net): reuse cache ticks for settlement - #3296

Merged
kixelated merged 6 commits into
devfrom
quest/m1/perf/cache-shard
Sep 2, 2026
Merged

perf(net): reuse cache ticks for settlement#3296
kixelated merged 6 commits into
devfrom
quest/m1/perf/cache-shard

Conversation

@kixelated

@kixelated kixelated commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Remove the duplicate model::clock::now() read on the frame-write path: the tick Charge::touch already sampled is threaded into Track::settle instead of being read a second time.
  • Add a parallel-writer benchmark for independent tracks sharing one relay-style cache pool.
  • Rescope the cache quest to the one thing the spike left open.

Spike decision

The proposed 16-way sharding of the process-wide pool counters was implemented and measured, then abandoned. Repeated results were mixed, and one repeat regressed the existing fanout benchmark by 15.6% at 64 readers and 10.5% at 512 readers. Sharding a counter that is already only relaxed fetch_adds trades one cheap contended line for several lines plus a read-side fold, and the fold is what showed up. None of that experimental code is retained.

What is retained

A frame write read the model clock twice for the same tick: once in Charge::touch via Pool::stamp, and again in Track::settle via expiry_due. Charge::add and record_write now return the tick they stamped, and Track::settle takes it as Option<u64>. Write paths hand theirs over; everyone else passes None.

Option rather than a bare tick, because 0 is a real tick (the pool epoch) and indistinguishable from the pool's first 100 ms. A 0 sentinel for a detached charge would make settle(0) wedge the expiry gate shut for the process lifetime, once next_expiry moved past it. It is unreachable today, since group::Producer::new always installs a real charge, but nothing in the signature said so.

The Option also keeps the clock read lazy. expiry_due resolves the None below its expiry == u64::MAX gate, so a pool with no expiry window still settles without reading the clock, and the two datagram call sites in track::Producer do not start paying for a tick they never use.

This does not coarsen the clock, shard counters, delay accounting, or change eviction and staleness bounds. There is no public API or wire-format change.

Benchmarks

Treat these as not establishing a win, and the change as justified by the removed duplicate read rather than by them.

just bench 3227c195ee2ad2110fd3e672f9a14c00af29e32e completed against a fixed dev base, reporting -11.3%/-14.7% at 1 shared-pool writer, -2.9% at 2, neutral at 4, and -12.5%/-6.46% at 8, plus 10.0% at one reader and 11.1% at eight on the existing track fanout with 64 and 512 neutral. That pattern is not monotone in either parameter, and a single Instant::now() is not 10-15% of a frame write, so it reads as machine noise rather than an effect. End-to-end was mixed in both directions: throughput and loss identical, fanout relay CPU -0.02% and RSS -0.41%, video relay CPU +3.57% and RSS +1.03%.

track_parallel_write also drops its Throughput::Elements(writers). One criterion iteration is one frame write wherever it landed, so declaring writers elements per iteration inflated the reported rate by that factor.

Quest

cache-shard.md is rewritten rather than deleted. The sharding spike is settled and the duplicate clock read is gone, but the counters themselves are still unbatched, which was the survey's actual proposal and is untouched here. The quest now carries that one open item plus the spike's verdict, so the next person measures batching on its own: batching reduces how often the shared line is touched, where sharding kept the frequency and spread the address.

Validation

  • just fix, just check, and just test pass locally (3429 tests, 0 failures).
  • expiry_gate_reuses_a_supplied_tick covers both directions: a supplied tick drives the gate, and a None falls back to the pool clock rather than inheriting the caller's. expiry_gate_stays_closed_without_a_window covers the short-circuit that keeps the clock read lazy.

(written by Claude Opus 5)

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-02T01:45:05.017022Z ccf463b New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d0842b88d7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-net/benches/track.rs Outdated
let pool = cache::Pool::new(config);
let mut group = c.benchmark_group("track_parallel_write");
for writers in WRITERS {
group.throughput(Throughput::Elements(writers as u64));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Report one element per aggregate write

For every writers > 1 case, parallel_write divides iterations among the threads, so the benchmark still writes exactly iterations groups in total. Declaring writers elements per iteration therefore inflates Criterion's reported throughput by that factor and makes scaling across writer counts appear better than it is. Use Throughput::Elements(1), or have every writer perform all iterations writes if each iteration is intended to represent one write per thread. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

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.

Agreed, and fixed in ccf463b — by dropping the throughput call entirely rather than setting Elements(1).

One criterion iteration here is one frame write wherever it landed, so Elements(1) would just restate what the per-iteration time already says. The bench's purpose is comparing that per-write cost across writer counts, and an elements/sec line on top of it is a second number that means the same thing.

Worth noting this is also why the PR's original benchmark table was uninterpretable across writer counts: at 8 writers the reported rate was 8x the real one.

(written by Claude Opus 5)

kixelated and others added 4 commits September 1, 2026 13:42
Co-Authored-By: OpenAI Codex <codex@openai.com>
Co-Authored-By: OpenAI Codex <codex@openai.com>
Co-Authored-By: OpenAI Codex <codex@openai.com>
Remove the completed quest and its performance index reference.

Co-Authored-By: GPT-5 <noreply@openai.com>
@kixelated
kixelated force-pushed the quest/m1/perf/cache-shard branch from d0842b8 to aaf4892 Compare September 1, 2026 20:42
kixelated and others added 2 commits September 1, 2026 18:42
Reshapes the previous commit rather than changing what it does.

`Charge::touch` returned a bare `u64` and `0` when the charge is detached,
but `0` is a real tick: it is the pool's own epoch, indistinguishable from
the first 100 ms of its life. `settle_at(0)` would then wedge the expiry
gate shut for the rest of the process, since `now < next` holds for every
deadline once `next_expiry` moves past zero. Unreachable today, because
`group::Producer::new` always installs a real charge, but nothing in the
signature says so.

`touch`, `add`, and `record_write` now return `Option<u64>` and `settle_at`
folds back into `settle(Option<u64>)`. That also restores the lazy clock
read the previous shape gave up: `settle` sampled `Pool::now()` eagerly for
every caller, where `expiry_due` used to return above it on
`expiry == u64::MAX`. The `None` is resolved below that gate again, so a
pool with no expiry window settles without reading the clock, and the two
datagram call sites in `track::Producer` stop paying for a tick they never
use. The two `match now { Some => settle_at, None => settle }` call sites
collapse into one call each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sharding spike is settled and the duplicate clock read is gone, but the
counters themselves are still unbatched, so the quest is not finished and
should not be deleted. Rewrite it around that one open item, and record why
sharding is not to be retried as specified, so the next person measures
batching on its own rather than inheriting the spike's verdict. Batching
reduces how often the shared line is touched; sharding kept the frequency
and spread the address. They are not the same experiment.

`track_parallel_write` drops its `Throughput::Elements(writers)`: one
criterion iteration is one frame write wherever it landed, so declaring
`writers` elements per iteration inflated the reported rate by that factor.

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ccf463b0c7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +122 to +123
barrier.wait();
let start = Instant::now();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Start timing before releasing the worker barrier

The main thread releases the barrier before recording start, so any worker scheduled first can execute frame writes outside the measured interval. This can omit a material share of short calibration samples and makes results depend on scheduler timing, especially with more writers. Record the timestamp before barrier.wait(), or add a second synchronization step that starts workers only after the timestamp is captured. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

@kixelated
kixelated merged commit c33b3b6 into dev Sep 2, 2026
3 checks passed
@kixelated
kixelated deleted the quest/m1/perf/cache-shard branch September 2, 2026 03:09
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