Skip to content

perf(rdp): skip the split-borrow move when the RDP needs no bus (1.157x) - #219

Merged
doublegate merged 16 commits into
mainfrom
perf/rdp-skip-idle-take
Jul 30, 2026
Merged

perf(rdp): skip the split-borrow move when the RDP needs no bus (1.157x)#219
doublegate merged 16 commits into
mainfrom
perf/rdp-skip-idle-take

Conversation

@doublegate

Copy link
Copy Markdown
Owner

Motivation

Bus::rdp_tick took the whole Rdp out of the Bus with core::mem::take on every RCP step — 344 bytes read and written, plus a fresh Default written into the vacated slot — purely so tick could borrow its owner. #218 measured that shuffle at ~1.07 GB a frame.

On most steps the RDP is frozen, stalling, or looking at an empty command FIFO and needs no bus at all, so it bought nothing.

What changes

Rdp::tick_without_bus answers those cases from the struct's own fields, and Bus::rdp_tick only takes when it returns false:

pub fn rdp_tick(&mut self) {
    if self.rdp.tick_without_bus() { return; }
    let mut rdp = core::mem::take(&mut self.rdp);
    rdp.tick_with_bus(self);
    self.rdp = rdp;
}

The predicate comes from Rdp::tick's own early-outs rather than from intuition, and lives beside them — it is a statement about this chip's state machine, so if they change it changes with them, in the same file. Rdp::tick calls the same helper, so there is one implementation and the two cannot drift apart.

The partial-command case (cmd_end - cmd_current < len_bytes) stays on the far side of the split: its length comes from an opcode in RDRAM, so it genuinely needs the bus.

Measured — Super Mario 64, --release, two runs each

before after
frame 125.32 / 125.16 ms 108.18 / 108.27 ms
FPS 7.98 9.24
scan-out 7.88 ms 7.98 / 8.04 ms (untouched)

1.157x.

This beats its own predicted ceiling, which means the model was wrong

docs/performance.md derives 1.056x as the ceiling for the entire split-borrow — RDP and audio. This is the RDP half alone and it measured 1.157x.

A result that exceeds its ceiling is a broken model, not a windfall. The cause: the profile attributed only the read_via_copy / write_via_move intrinsics inside mem::replace, and not the cost of constructing the Rdp::default() that take writes into the vacated slot — which is inlined into rdp_tick and charged elsewhere in the attribution. The document now records that correction rather than quietly banking the win.

Verification

cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, cargo test --workspace --features test-roms (the Angrylion .rvec and VI conformance vectors, byte-for-byte), RUSTDOCFLAGS="-D warnings" cargo doc, the no_std build, check_en_us.sh, and markdownlint. All green.

A new test pins the stall countdown through the skip path, mutation-checked: moving the empty-FIFO check ahead of the stall turns it red, because a stalled RDP with nothing queued would then never count down and the pipeline would hang — which no other test in the suite notices.

One correction made on the way

An earlier revision of my own comment claimed the bus-using half had to be a separate method or stall would decrement twice. Mutation-testing disproved it: rdp_tick only reaches that half when tick_without_bus returned false, which implies stall == 0, so a second pass finds nothing to decrement. The split is a redundant-work optimization, not a correctness requirement, and the comment now says which — the claim had been written from the shape of a hazard rather than from the code.

Bus::rdp_tick took the whole Rdp out of the Bus with core::mem::take on EVERY
RCP step -- 344 bytes read and written, plus a fresh Default written into the
vacated slot -- purely so tick could borrow its owner. On most steps the RDP is
frozen, stalling, or looking at an empty command FIFO and needs no bus at all,
so that shuffle bought nothing.

Rdp::tick_without_bus answers those cases from the struct's own fields and
Bus::rdp_tick only takes when it returns false. Rdp::tick calls the same helper,
so the early-outs have one implementation and cannot drift apart. The predicate
lives beside them in the RDP rather than in the Bus, because it is a statement
about this chip's state machine.

Measured, Super Mario 64, --release, two runs each:

  125.32 / 125.16 ms  ->  108.18 / 108.27 ms   1.157x, 7.98 -> 9.24 FPS

That EXCEEDS the 1.056x ceiling docs/performance.md derives for the whole
split-borrow, which means the attribution behind that ceiling was incomplete:
the profile counted the read_via_copy / write_via_move intrinsics inside
mem::replace, and not the cost of constructing the Rdp::default() that take
writes into the slot. A result beating its own ceiling is a broken model, not a
windfall, and the doc now says so.

Verified by the Angrylion .rvec and VI conformance vectors, which are
byte-for-byte. A new test pins the stall countdown through the skip path, and it
is mutation-checked: moving the empty-FIFO check ahead of the stall turns it
red, because a stalled RDP with nothing queued would then never count down.

One correction on the way: an earlier revision of this comment claimed the
bus-using half had to be a separate method or stall would decrement twice.
Mutation-testing disproved it -- rdp_tick only reaches that half when
tick_without_bus returned false, which implies stall == 0. The split is a
redundant-work optimisation, not a correctness requirement, and the comment now
says which.

Closes part of #61.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@doublegate, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 12 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0e9fcfc0-4eb6-407e-a549-1cf23256ddc0

📥 Commits

Reviewing files that changed from the base of the PR and between 0eec97b and 9453111.

📒 Files selected for processing (4)
  • crates/rustyn64-core/src/bus.rs
  • crates/rustyn64-rdp/src/lib.rs
  • docs/performance.md
  • docs/rdp.md
📝 Walkthrough

Summary by CodeRabbit

  • Performance
    • Improved rendering pipeline performance by avoiding unnecessary RDP state handling during idle, stalled, or frozen conditions.
    • Reduced overhead by only performing bus-backed RDP command processing when required.
  • Bug Fixes
    • Fixed RDP stall behaviour so the stall counter decrements exactly once per RCP step, preventing wraparound.
  • Documentation
    • Updated RDP and performance documentation with the revised stepping approach, measured results, and benchmarking guidance.

Walkthrough

RDP ticking now separates bus-free early-outs from bus-backed command decoding. Bus::rdp_tick only moves RDP state when bus access is required. Tests cover early-out conditions and stall countdown timing, while documentation records revised performance measurements.

Changes

RDP split ticking

Layer / File(s) Summary
RDP tick phases
crates/rustyn64-rdp/src/lib.rs
NeedsBus gates RDRAM-backed decoding; freeze, XBUS, stall, and empty-FIFO conditions are handled by tick_without_bus(), with dedicated branch coverage.
Bus integration and stall timing
crates/rustyn64-core/src/bus.rs
Bus::rdp_tick performs the bus-free phase before conditionally taking and restoring RDP state; a regression test verifies one decrement per stall tick.
Performance and implementation documentation
docs/performance.md, docs/rdp.md
Documentation records corrected modelling, measured timing, A-B-A inline results, and the updated RDP stepping flow.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Bus
  participant Rdp
  participant RDRAM
  Bus->>Rdp: tick_without_bus()
  alt bus access required
    Rdp-->>Bus: NeedsBus
    Bus->>Rdp: tick_with_bus(NeedsBus, Bus)
    Rdp->>RDRAM: read command words
    RDRAM-->>Rdp: command data
  else early-out
    Rdp-->>Bus: no bus access
  end
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 9 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Changelog Entry For User-Visible Changes ⚠️ Warning FAIL: this PR's user-visible RDP/API change touched no CHANGELOG.md; the [Unreleased] block has no entry for it. Add an [Unreleased] CHANGELOG entry for the public RDP bus-split/token change before merging.
✅ Passed checks (9 passed)
Check name Status Explanation
Title check ✅ Passed It follows the Conventional Commits pattern, stays within 72 characters, and matches the perf optimisation.
Description check ✅ Passed The description is clearly about the same RDP bus-splitting optimisation and performance validation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
Oracle Number Is Stated ✅ Passed PASS: docs/rdp.md says the split-borrow change’s oracle effect was not measured and could not change the n64-systemtest count.
Docs-As-Spec Sync ✅ Passed PASS: the RDP behaviour change in crates/rustyn64-rdp and bus.rs has matching docs/rdp.md and docs/performance.md updates, and the PR body explains the docs rationale.
Measured, Never Tuned ✅ Passed PASS: the new hardware timings (25/50/33 GCLK) are cited to N64brew, and the ms figures are host benchmarks documented in docs/performance.md, which explicitly excludes them from the accuracy ledger.
Unsafe Stays Out Of The Chip Crates ✅ Passed Core and every chip crate still have #![forbid(unsafe_code)], and the repo scan found no actual Rust unsafe constructs or frontend unsafe blocks.

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

The RDP half alone measured 1.157x against a 1.056x ceiling derived for the
whole split-borrow. A result that beats its own ceiling is a broken model, so
the hole is named rather than the win banked: the profile attributed the
read_via_copy / write_via_move intrinsics inside mem::replace and NOT the
construction of the Rdp::default() that take writes into the vacated slot, which
is inlined into rdp_tick and charged elsewhere. 5.32% was the memcpy half of the
cost.

The generalisation is the useful part: a share read off a profile bounds the
code the profiler NAMED, not the operation a reader has in mind. take is one
word and two distinct costs.

Which also means the latch split's 1.037x estimate should now be read with the
same suspicion -- it was built the same way from the same kind of share. Said so.

Cumulative: 155.13 -> 108.22 ms, 1.433x, and the gap to 60 FPS is 6.5x.
…ratives

Antigravity review of PR #219.

tick_with_bus is pub across the crate boundary and carries three unstated
invariants -- unfrozen, not stalled, non-empty FIFO. They are now debug_asserts,
so an out-of-order call says so instead of decoding a command out of an empty
FIFO in silence. Mutation-checked: removing the tick_without_bus guard from
Bus::rdp_tick makes nine tests fail on those messages.

The two post-mortem narratives are compressed to the sentence that documents
current behaviour -- that the split is an optimisation rather than a correctness
requirement, and that an ordering mistake would land on the stall countdown. The
reviewer is right that a doc comment describes what is, not what an earlier
draft got wrong; the full account stays in the commit that made the correction.
@doublegate

Copy link
Copy Markdown
Owner Author

All three adopted.

1. "tick_with_bus is pub and relies on unstated preconditions; add debug_assert!" — ADOPTED, and it is the right catch for a method that crossed a crate boundary carrying invariants only its comment mentioned.

Three asserts now — unfrozen, stall == 0, non-empty FIFO — each naming the fix (call tick_without_bus first). Without them, an out-of-order call would decode a command out of an empty FIFO in silence, which is the failure mode worth making loud.

Mutation-checked: removing the tick_without_bus guard from Bus::rdp_tick makes nine tests fail on those messages, so they are reachable rather than decorative.

2. "Remove the historical narrative about an earlier incorrect comment" — ADOPTED as compression.

You are right that a doc comment describes what is. What I kept is the sentence that documents current behavior — that the split is an optimization, not a correctness requirement, because reaching the bus half implies stall == 0 — since without it the next reader may "restore" a correctness constraint that does not exist. The account of how I got it wrong belongs in the commit that corrected it, and stays there.

3. Nitpick: same for the test docstring — ADOPTED. Down to the two facts a maintainer needs: the stall is the only thing the skip path mutates, and checking the FIFO first would leave a stalled RDP counting down forever — which no vector notices, because it changes when a command retires rather than whether it matches.

Gates re-run: clippy -D warnings, cargo test --workspace, --features test-roms (Angrylion .rvec + VI vectors), rustdoc. All green.

Antigravity round two on PR #219. debug_assert compiles out, and tick_with_bus
is pub across the crate boundary -- so in a shipped build an out-of-order call
would decode a command out of an empty FIFO in silence. The same three
conditions are now a release guard as well: three predictable branches on fields
already in cache, against a 344-byte move.

It deliberately does not decrement stall. That belongs to tick_without_bus, and
doing it in both is the one way this pair could burn two GCLK in a step -- the
hazard I wrongly claimed existed earlier, which would become real here.

Measured after the guard: 105.67 / 105.39 ms against 108.18 / 108.27 before it.
So it costs nothing -- but it should not make the frame FASTER, and I have no
mechanism for the 2.5%. Recorded in docs/performance.md as unexplained and not
counted; the conservative figure stays 108.22 ms.
@doublegate

Copy link
Copy Markdown
Owner Author

Round two. Two adopted, one rejected — and the first is the best comment on this PR.

1. "debug_assert! compiles out; in release an out-of-order call silently decodes from an empty FIFO" — ADOPTED.

Correct, and it is the difference between a diagnostic and a guarantee. The same three conditions are now a release guard as well: three predictable branches on fields already in cache, against a 344-byte move — not measurable, and not the redundant work this split removed.

It deliberately does not decrement stall. That belongs to tick_without_bus, and doing it in both is the one way this pair could burn two GCLK in a step — the hazard I wrongly claimed existed in an earlier revision, which putting a decrement here would have made real.

Measured after the guard: 105.67 / 105.39 ms against 108.18 / 108.27 before it. So it costs nothing — but it should not make the frame faster, and I have no mechanism for the 2.5%. That is recorded in docs/performance.md as unexplained and not counted; the conservative figure stays 108.22 ms. A speed-up without a mechanism is a coincidence waiting to be explained.

2. "Rename tick_without_bus to step_without_bus since it mutates" — REJECTED, on this codebase's vocabulary.

tick is the project's verb for advance this chip by one steprsp_tick, rdp_tick, audio_tick, pi_tick, Cpu::tick_at. A tick_* method mutating is the expectation, not a surprise; step_without_bus beside tick and tick_with_bus would suggest the three do different kinds of thing.

Your underlying point is fair — a -> bool does read like a query. What carries that is the doc's first line, which says it returns true when it finished the step, and the stall decrement is called out explicitly two paragraphs down.

3. Nitpick: conversational tone — ADOPTED. "is now history" → "is superseded". I kept "turned out to be wrong", which is a plain statement about a model that was.

…rtion

Antigravity round three on PR #219 is right that debug_assert and a release
guard disagree: the debug build panics on an out-of-order call, the release
build returns silently, and a caller who only ever calls tick_with_bus on a
stalled Rdp would hang because stall never counts down.

Rather than pick which of those two behaviours is correct, the precondition is
now carried by the type. tick_without_bus returns Option<NeedsBus>;
tick_with_bus requires the token; NeedsBus has a private field and no
constructor. Calling the halves out of order does not compile, so there is no
release-versus-debug behaviour to reconcile, and the redundant branch the same
round flagged is gone with it. The debug_asserts stay as executable
documentation of what the token means.

This also settles the unexplained 2.5%. Three builds -- no guard, release guard,
token -- differ in real work, and the last two both measure ~105.5-105.8 ms
against the first's 108.22. The delta tracks WHEN the measurement was taken, not
what was measured. The doc now says cross-session drift instead of "no mechanism
I can offer", and still quotes the conservative figure.
@doublegate

Copy link
Copy Markdown
Owner Author

Round three. Both adopted, and together they pointed at a better design than either patch.

1. "Debug and release disagree on a precondition failure; a stalled Rdp would hang in release" — ADOPTED, by removing the disagreement rather than choosing a side.

You are right on both counts: debug_assert panics, the release guard returns silently, and a caller who only ever calls tick_with_bus on a stalled RDP would never count stall down. My previous round created that inconsistency by adding the guard, so picking assert! or dropping the guard would have been choosing which of two wrong behaviours to keep.

The precondition is now carried by the type:

pub struct NeedsBus(());                                    // private field, no constructor
pub fn tick_without_bus(&mut self) -> Option<NeedsBus>;
pub fn tick_with_bus<B>(&mut self, _proof: NeedsBus, bus: &mut B);

Calling the halves out of order does not compile. There is no release-versus-debug behaviour left to reconcile, no silent hang, and no forged token — the field is private and the type has no constructor. The debug_asserts stay as executable documentation of what the token means; they are no longer the guarantee.

2. "Redundant branch evaluation in the hot path" — ADOPTED, and resolved by the same change: the release guard is gone, because the type now does its job.


This also settles the unexplained 2.5% from last round. Three builds now exist — no guard, release guard, token — which differ from each other in real work, and the last two both measure 105.5-105.8 ms against the first's 108.22. The delta tracks when the measurement was taken, not what was measured: it is cross-session drift, exactly what the method section warns about. docs/performance.md now says that instead of "no mechanism I can offer", and still quotes the conservative 108.22 ms.

Gates: clippy -D warnings, cargo test --workspace, --features test-roms (Angrylion .rvec + VI vectors), rustdoc, no_std, en-US, markdownlint. All green.

… cannot fire

Antigravity round four on PR #219, and the second item is a real defect I made
twice in the same file.

Inserting the new test before scanout_scaled_geometry_and_truncating_convert put
its doc comment BETWEEN that test's doc block and its #[test], so the older test
silently lost its documentation to the newer one. I hit exactly this when adding
the memoization test in #216, fixed it there, and then repeated it here --
inserting before an anchor whose doc comment precedes it is the mechanism, and
grepping for the anchor does not reveal it. Moved back.

The tick_without_bus doc still described a bool return after the signature became
Option<NeedsBus>. A doc comment that contradicts its own signature is worse than
none, since it is the thing a reader trusts before the type.

And the three debug_asserts in tick_with_bus are gone. With NeedsBus
unconstructible outside tick_without_bus they cannot fire, and an assertion that
cannot fire is dead code that reads like a safeguard -- the inert-API hazard
docs/engineering-lessons.md §3.2 describes. They were justified as "executable
documentation" when they were the guarantee; they stopped being either when the
token replaced them.
@doublegate

Copy link
Copy Markdown
Owner Author

Round four. All three adopted, and the second is a real defect that I have now made twice in the same file.

1. "The doc comments still say true/false but the signature returns Option<NeedsBus>" — ADOPTED. Now "returning None when it finished the step on its own and Some(NeedsBus) when work remains". A doc comment that contradicts its own signature is worse than none, because it is what a reader trusts before the type.

2. "The new test's doc comment attached itself to the preceding test's block" — ADOPTED, and thank you for catching it.

Exactly right: inserting before scanout_scaled_geometry_and_truncating_convert put my doc between that test's doc block and its #[test], so the older test silently lost its documentation to the newer one. Moved back.

Worth recording that I hit this same thing adding the memoization test in #216, fixed it there, and then reproduced it here. The mechanism is the trap: inserting before an anchor whose doc comment precedes it, where grepping for the anchor shows nothing wrong and every gate stays green. It is invisible to cargo test, to clippy, and to rustdoc, because both items still parse.

3. Nitpick: "the debug_assert!s are redundant now that NeedsBus cannot be constructed elsewhere" — ADOPTED, and it is more than redundancy.

They cannot fire. An assertion that cannot fire is dead code that reads like a safeguard — the inert-API hazard docs/engineering-lessons.md §3.2 describes, and the same shape as the ///-on-an-unnamed-item you caught on #218. I had justified them as "executable documentation" when they were the guarantee; the token made them neither. Removed, with the preconditions stated in prose where they belong.

Gates: clippy -D warnings, cargo test --workspace, --features test-roms, rustdoc, no_std. All green.

Antigravity round five on PR #219. Changing tick_without_bus from bool to
Option<NeedsBus> left three prose references behind; the previous round fixed one
and I did not grep for the rest, which is why there was a fifth round.

Both remaining now describe the token, and docs/rdp.md says the useful part: the
bus half cannot be called out of order at all, because NeedsBus has no public
constructor. That is a stronger statement than "only takes when it returns
false" and it is the reason the split is safe.
@doublegate

Copy link
Copy Markdown
Owner Author

Round five. Both adopted — and the reason there was a round five is worth naming.

Changing tick_without_bus from bool to Option<NeedsBus> left three prose references behind. Last round I fixed the one you pointed at and did not grep for the others, so the same defect came back in a different location. rg 'returns \false`'` would have found both in one pass; fixing the cited instance instead of the class is what cost the extra round.

1. tick_with_bus's docstring — now "once Rdp::tick_without_bus has handed back a NeedsBus".

2. docs/rdp.md — now says the stronger and more useful thing: Bus::rdp_tick takes when it gets a NeedsBus, and the bus half cannot be called out of order at all, because the token has no public constructor. "Only takes when it returns false" described a mechanism that no longer exists and understated the guarantee that replaced it.

Verified: zero remaining occurrences of a false return in either file. rustdoc -D warnings, clippy, en-US and markdownlint all green.

…ression

Antigravity round six on PR #219 suggested #[inline] on tick_without_bus, a
cross-crate call on the hot path. Measured in one sitting:

  A  without   105.84 / 105.64 ms
  B  with      107.50 / 107.57 ms
  A  without   107.35 / 107.45 ms

Two legs would have reported a 1.7%% regression. The third matches B, so the
machine got ~1.7%% slower partway through and stayed there: #[inline] is NEUTRAL,
which is the expected answer under lto = "fat" -- LLVM already has the callee's
body across the crate boundary.

Recorded as its own section, because the protocol is the transferable part:
back-to-back runs of one binary agree to 0.05-0.13%%, but a session drifts by
1-2%% over tens of minutes, which is the same size as a real optimisation. A
before/after pair cannot separate those; A-B-A can. This is also the third
independent confirmation of that drift in this document.

The inline entry under "ruled out" now carries both results, and the general
form: this workspace's release profile has already done the inlining.

Also fixes the tense in rdp_tick's doc comment -- it described the take as
happening on every RCP step, which is what this change stopped.
@doublegate

Copy link
Copy Markdown
Owner Author

Round six. One measured and rejected, one rejected structurally, one adopted.

1. "Consider #[inline] on tick_without_bus — cross-crate, hot path, depends on LTO" — MEASURED, and it is neutral. The measurement is more interesting than the answer.

leg frame
A without #[inline] 105.84 / 105.64 ms
B with #[inline] 107.50 / 107.57 ms
A without, again 107.35 / 107.45 ms

A before/after pair would have reported your suggestion as a 1.7% regression, and it is nothing of the kind — the third leg matches B, so the machine drifted ~1.7% slower partway through the sitting and stayed there. #[inline] is neutral, the expected answer under lto = "fat": LLVM already has the callee's body across the crate boundary, so the hint has nothing to add.

Not applied, per this repo's rule that anything measuring neutral is reverted rather than shipped. The A-B-A is now its own section in docs/performance.md, because the protocol is the transferable part: back-to-back runs agree to 0.05-0.13%, but a session drifts 1-2% over tens of minutes — the same size as a real optimization.

This is also the second independent "an inline hint does nothing here" result, after #[inline(always)] on the VI leaf readers made the scan-out 36% worse. The general form is now recorded: this workspace's release profile has already done the inlining.

2. "Evaluate whether the two-phase protocol should be pub or pub(crate)" — REJECTED; pub is structurally required.

rustyn64-core is a separate crate, and Rust has no cross-crate pub(crate). The chip crates exist as separate crates deliberately — the one-directional graph is what makes each independently fuzzable and benchmarkable — so anything the Bus calls has to be pub. Downstream consumers depend on rustyn64-core, which re-exports the chip types, rather than on rustyn64-rdp directly.

#[doc(hidden)] would hide the pair from rustdoc, but it hides rather than restricts, and NeedsBus is genuinely part of how a caller drives this chip — a reader of Rdp::tick_with_bus should be able to find out what the token means.

3. Nitpick: "rdp_tick's doc describes the take as happening on every RCP step — the pre-change behavior" — ADOPTED. It now says that is what it used to do and that the point of the code below is that it no longer does. A doc comment in the present tense describing behavior a change removed is the exact confusion worth avoiding.

…en by value

Antigravity round seven on PR #219.

The early-outs were only covered through Bus::rdp_tick, in another crate. Each
one is a reason a step can skip the 344-byte take, so a branch that stopped
firing would be a silent performance regression and one that fired when it should
not would be a skipped command -- and neither necessarily shows in the
conformance vectors, as long as the totals happen to work out. All five cases are
now pinned in rustyn64-rdp itself, including the frozen case not touching stall
and the queued-command case that does need the bus. Mutation-checked: deleting
the freeze/XBUS early-out turns it red.

The by-value token is now documented as deliberate. Relaxing NeedsBus to a
reference would let a caller hold one and re-enter after the state it attested to
had changed, which is the entire property it buys.

And a fourth stale bool reference -- "the answer here is `true`" -- which my grep
last round missed because I searched for one phrasing rather than the class. That
is the second time in this PR that fixing the cited instance rather than the
class cost another round; this time I grepped both `true` and `false` across all
three files.
@doublegate

Copy link
Copy Markdown
Owner Author

Round seven, all three adopted — and the middle one is the most valuable suggestion on this PR.

1. "Add direct unit tests for every early-out branch in rustyn64-rdp" — ADOPTED, and you are right that coverage through Bus::rdp_tick in another crate was not enough.

Each early-out is a reason a step can skip the 344-byte take, so:

  • a branch that stopped firing is a silent performance regression;
  • a branch that fires when it should not is a skipped command — a correctness regression.

Neither necessarily shows in the conformance vectors, as long as the totals happen to work out. All five cases are pinned in the RDP crate now: frozen (and not touching stall, since a frozen pipeline does not burn a GCLK), XBUS, stalling (one GCLK per step, twice), empty FIFO, and the queued-command case that genuinely needs the bus.

Mutation-checked: deleting the freeze/XBUS early-out turns it red.

2. Nitpick: "document that _proof is by value to enforce single-use" — ADOPTED, and it is the load-bearing detail. Relaxing NeedsBus to a reference would let a caller hold one and re-enter after the state it attested to had changed — which is the entire property the token buys. Now stated where a future refactor would read it.

3. "the answer here is true`` contradicts the Option return" — ADOPTED, the fourth such reference.

Worth naming why there was a fourth: last round I said the lesson was to fix the class rather than the cited instance, then grepped for returns `false` — one phrasing — and missed is `true`. So I repeated the exact mistake I had just written up, one round later. This time I grepped both `true` and `false` across all three files; the remaining hits are unrelated (Option-free booleans in the texture-mode decode).

Gates: clippy -D warnings, cargo test --workspace, --features test-roms, rustdoc, no_std, en-US. All green.

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/performance.md (1)

563-569: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Remove or relabel the superseded projections.

This table still presents 1.056x and the combined 1.098x/1.128x figures as current split-borrow projections, while the following section states that the model was wrong and the measured result is 1.157x. Mark these values explicitly as pre-measurement estimates or replace them with projections based on the measured result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/performance.md` around lines 563 - 569, Update the performance
projections table in the “Both are isolated, and they compose” section to
clearly label the 1.056x, 1.098x, and 1.128x figures as pre-measurement
estimates, or replace them with projections derived from the measured 1.157x
split-borrow result. Ensure the table no longer presents superseded values as
current.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/rustyn64-rdp/src/lib.rs`:
- Around line 1575-1577: Bind NeedsBus to the FIFO state it authorizes by
storing the validated position in the private token, and update the bus-dispatch
path around NeedsBus and tick_with_bus to reject tokens whose position no longer
matches the current runnable state before calculating command ranges. Ensure
stale proofs cannot dispatch after consuming a Sync Pipe or after DPC writes
invalidate cmd_current <= cmd_end, and add regression coverage for two proofs
followed by Sync Pipe then Sync Full.

In `@docs/performance.md`:
- Around line 624-628: Update the prose in the `#[inline]` discussion by adding
a comma after “Also”, so the sentence begins “Also, `#[inline]` on
`Rdp::tick_without_bus`”.

In `@docs/rdp.md`:
- Around line 851-862: Update the evidence paragraph describing the RDP stepping
optimization to cite the direct RDP early-out and queued-command tests plus the
Angrylion .rvec suite. Remove VI conformance vectors from evidence for the
scheduler split, and retain them only with scan-out verification.

---

Outside diff comments:
In `@docs/performance.md`:
- Around line 563-569: Update the performance projections table in the “Both are
isolated, and they compose” section to clearly label the 1.056x, 1.098x, and
1.128x figures as pre-measurement estimates, or replace them with projections
derived from the measured 1.157x split-borrow result. Ensure the table no longer
presents superseded values as current.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3a96d026-57c6-4bab-8024-1136f792602b

📥 Commits

Reviewing files that changed from the base of the PR and between b6916d5 and 143b58e.

📒 Files selected for processing (4)
  • crates/rustyn64-core/src/bus.rs
  • crates/rustyn64-rdp/src/lib.rs
  • docs/performance.md
  • docs/rdp.md

Comment thread crates/rustyn64-rdp/src/lib.rs
Comment thread docs/performance.md Outdated
Comment thread docs/rdp.md
Antigravity round eight. Either derive would let a caller keep a token past the
step it authorised and present it again after the state it attested to had
changed -- the same hole as taking it by reference, which the previous round
documented. A future refactor reaching for #[derive(Clone)] now reads why not.
Debug stays: it cannot duplicate the value.
@doublegate

Copy link
Copy Markdown
Owner Author

Round eight. One adopted, one rejected on idiom — and I checked the rejection rather than assuming it.

1. "Replace _proof with proof plus an explicit drop(proof)" — REJECTED, on idiom rather than feasibility.

I expected clippy::drop_non_drop to make this un-adoptable, since NeedsBus is a ZST with no Drop impl — so I tried it. It passes -D warnings cleanly, and the suggestion is available. Rejecting it is a judgment call, not a constraint:

  • _proof is the idiomatic Rust signal for a parameter that is consumed but unread, and every Rust reader parses it that way.
  • drop() of a type with no Drop impl is a no-op that reads as though something happens at that point. In a method whose whole subject is when work does and does not occur, that is the wrong kind of noise.
  • The semantics the suggestion wants made explicit are now stated in the doc, one line above the signature, including why it is by value and not by reference.

2. Nitpick: "note that Copy/Clone are intentionally omitted" — ADOPTED, and it is the natural companion to the by-value note.

Either derive reopens exactly the hole taking the token by reference would: a caller could keep it past the step it authorized and present it again after the state it attested to had changed. NeedsBus's doc now says so, so a future refactor reaching for #[derive(Clone)] reads the reason first. Debug stays — it cannot duplicate the value.

Gates green: clippy -D warnings, rustdoc, cargo test --workspace.

@coderabbitai coderabbitai 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.

♻️ Duplicate comments (1)
crates/rustyn64-rdp/src/lib.rs (1)

1575-1582: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Bind NeedsBus to the state it authorises.

This remains the previously reported issue: omitting Copy/Clone prevents duplication, but not retention or multiple proofs. A caller can obtain two tokens, consume one command with the first, then reuse the second after cmd_current, cmd_end, or stall has changed. Because tick_with_bus() ignores the token and repeats none of the early-out checks, it can dispatch while stalled or evaluate cmd_end - cmd_current after the FIFO state has become invalid.

Store the validated FIFO state in NeedsBus and reject stale tokens before reading RDRAM; add the two-proof regression described in the earlier review. This is required for the stated compile-time ordering contract.

Also applies to: 1844-1862, 1877-1887

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rustyn64-rdp/src/lib.rs` around lines 1575 - 1582, Bind NeedsBus to
the validated FIFO state by storing the relevant cmd_current, cmd_end, and stall
values when the token is created, then have tick_with_bus verify that state
before reading RDRAM or dispatching. Reject stale tokens, including a second
token obtained before the first command is consumed, while preserving the
existing early-out behavior for invalid FIFO or stalled states. Add the
two-proof regression covering reuse of the second token after the first token
changes the state; update the NeedsBus construction and tick_with_bus call sites
consistently.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@crates/rustyn64-rdp/src/lib.rs`:
- Around line 1575-1582: Bind NeedsBus to the validated FIFO state by storing
the relevant cmd_current, cmd_end, and stall values when the token is created,
then have tick_with_bus verify that state before reading RDRAM or dispatching.
Reject stale tokens, including a second token obtained before the first command
is consumed, while preserving the existing early-out behavior for invalid FIFO
or stalled states. Add the two-proof regression covering reuse of the second
token after the first token changes the state; update the NeedsBus construction
and tick_with_bus call sites consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: caf0b6da-40dd-416e-866f-ab24ea1a1283

📥 Commits

Reviewing files that changed from the base of the PR and between 143b58e and 0eec97b.

📒 Files selected for processing (1)
  • crates/rustyn64-rdp/src/lib.rs

Antigravity round nine asks for the invariant on an ignored token, and states it
as "self.stall has already been decremented without completing the step". That
is not the case, and the code says which: the token is handed out only on the
final path, where stall is already zero, so the decrement and the token are
mutually exclusive.

What dropping one really costs is the step's work -- the RDP does not advance and
the next rdp_tick starts the same step over. Nothing is corrupted; a step is
lost. #[must_use] is what makes ignoring one loud. Documented in those terms.

Also marks tick_without_bus as state-advancing in its summary, the second round
to stop on that name, and softens the hardcoded 344 bytes -- Rdp is
#[non_exhaustive] and its own doc says the render state grows every sprint, so
an exact figure in a neighbouring crate's comment is a stale number waiting to
happen.
@doublegate

Copy link
Copy Markdown
Owner Author

Round nine. All three adopted — with the second's premise corrected, because the code says otherwise.

1. "tick_without_bus sounds like an inspection method but mutates" — ADOPTED as an explicit note.

Second round to stop on this name, so the summary now says it outright: this advances state, burning one GCLK on the stalling path. Still tick_* rather than is_* or try_step_*, because tick is this codebase's verb for advance by one stepBus::rsp_tick, Cpu::tick_at, audio_tick — and renaming one of a trio (tick, tick_without_bus, tick_with_bus) would suggest the three do different kinds of thing.

2. "If a caller drops the token, self.stall has already been decremented without completing the step" — the concern is real; that specific invariant is not.

The token is handed out only on the final path, after stall > 0 has already returned None. So stall == 0 whenever a NeedsBus exists: the decrement and the token are mutually exclusive, and a dropped token cannot leave a half-applied stall.

What it does cost is the step's work — the RDP does not advance, and the next rdp_tick starts the same step over. Nothing is corrupted; a step is lost. That is now documented on NeedsBus, in those terms, with #[must_use] named as what makes ignoring one loud.

Worth having asked: the type carries a real operational invariant and it was undocumented. The version now written is the one the code supports.

3. Nitpick: "hardcoding Rdp is 344 bytes goes stale" — ADOPTED.

Especially here: Rdp is #[non_exhaustive] and its own doc says the render state grows every sprint, so an exact figure in a neighbouring crate's comment is a stale number waiting to happen. Now "was 344 bytes when this was measured and grows most sprints", with the measurement itself living in docs/performance.md where it is dated.

Note the contrast with Latch in #218, which is pinned by a const assert: that struct's size is load-bearing for a published breakdown and does not grow by design. Rdp's does.

… here

Antigravity round ten, and it caught a documentation claim of mine that was
simply false. NeedsBus carries #[must_use] and I wrote that this "makes ignoring
one loud". It does not: the attribute does not propagate through Option<T>, and
Option -- unlike Result -- is not #[must_use] itself, so discarding
rdp.tick_without_bus() produced NO warning at all.

Verified by probing it: a discarding call lints clean before the change and
reports "unused return value of Rdp::tick_without_bus that must be used" after
the attribute moved to the function. The doc now says which attribute does the
work and why the obvious one does not.

Also adds the positive-path test the same round asked for. The stall test proves
the skip fires, which on its own is satisfied by a predicate that always skips --
i.e. a dead RDP. The new test drives a Sync Pipe through Bus::rdp_tick end to
end: consumed, FIFO pointer advanced, documented stall applied.
Mutation-checked, by making the predicate always skip.
@doublegate

Copy link
Copy Markdown
Owner Author

Round ten. Your first item caught a claim of mine that was outright false, and the second was right that my test proved only half of what it needed to.

1. "#[must_use] on NeedsBus does not propagate through Option<NeedsBus>" — CORRECT, and I had documented the opposite.

I wrote on NeedsBus that "#[must_use] is what makes ignoring one loud". Probed it — a discarding call, rdp.tick_without_bus();:

  • before: cargo clippy --all-targets reports nothing. No lint at all.
  • after moving the attribute to the function: warning: unused return value of Rdp::tick_without_bus that must be used.

So the guarantee I documented did not exist. Two reasons, and I had the second wrong in my head: an attribute on T does not propagate through Option<T>, and Option — unlike Result — is not #[must_use] itself.

#[must_use] is now on the function, with a message naming the consequence, and the doc says which attribute does the work and why the obvious one does not.

2. "The test only exercises the early-out; add one for a complete command through tick_without_bus returning Some" — ADOPTED, and the gap was real.

The stall test proves the skip path fires. On its own that is satisfied by a predicate that always skips — a dead RDP that renders nothing. Only the conformance vectors would have caught that, and only if they happen to cover it.

New test drives a Sync Pipe (0x27) through Bus::rdp_tick end to end: command consumed (commands_processed incremented), cmd_current advanced past it, documented stall applied. Mutation-checked by making the predicate always skip — it turns red.

3. Nitpick: "remove the leading underscore from _proof" — held, third raising.

_proof is the idiomatic signal for consumed but unread. The stated risk — that it "weakens compiler verification if the function body is ever modified" — inverts: a future body that uses the token renames it as part of using it, and the unused-variable lint has nothing to catch in a body that correctly ignores a proof-of-precondition.

Gates: clippy -D warnings, cargo test --workspace, --features test-roms, rustdoc, no_std. All green.

Antigravity round eleven asks whether the comparison assumes monotonic pointer
increments. It does, and the reason is that these are RDRAM ADDRESSES rather
than ring indices: DPC_START/DPC_END are latched through DPC_ADDR_MASK in
dpc_write, cmd_current only advances by a decoded command length, and the
hardware has no wrap -- a driver restarts by writing DPC_START again. Noted
inline so the next reader does not have to re-derive it.
@doublegate

Copy link
Copy Markdown
Owner Author

Round eleven. One adopted, two rejected — the second on a workspace setting that removes the failure mode.

1. "Mark NeedsBus #[non_exhaustive] as well" — REJECTED, it would add nothing.

#[non_exhaustive] stops downstream crates constructing with a literal and matching exhaustively. A tuple struct with a private field already does both: outside rustyn64-rdp you can neither write NeedsBus(()) nor destructure it, because the field is not nameable. The attribute is for types whose fields are public and may grow; this one has exactly one field that will never be public, since its whole purpose is to be unforgeable.

2. "A panic inside tick_with_bus leaves self.rdp as Default permanently" — REJECTED for this workspace, and it predates the change.

Correct as a general Rust observation, and worth raising. Two reasons it is not live here:

  • panic = "abort" in the release profile (Cargo.toml), so there is no unwinding to observe the intermediate state through. The process ends.
  • In test/debug builds a panic in the RDP fails the test that provoked it, and the Bus is dropped with it.

It is also not introduced by this PR — rdp_tick has had the take/restore shape since the split-borrow was written; this change makes it run less often. A scope guard would be the fix if the profile ever unwinds, and that is a decision about the profile rather than about this method.

3. Nitpick: "note that pointer wrapping is handled before cmd_end is set" — ADOPTED, with the actual reason.

Checked rather than asserted: these are RDRAM addresses, not ring indices. DPC_START / DPC_END are latched through DPC_ADDR_MASK in dpc_write, cmd_current only ever advances by a decoded command's length, and the hardware has no wrap — a driver that wants to restart writes DPC_START again. So there is no wrapped case for a plain >= to get wrong, and the comment now says so instead of leaving the next reader to re-derive it.

…k inline

Antigravity round twelve. tick is the whole-step entry point and is NOT
deprecated -- it is what a caller uses when it already holds the bus and has
nothing to decide. Only Bus::rdp_tick benefits from the split, because only it
has to know whether the step needs a bus BEFORE arranging one, and arranging one
means taking the whole struct. Said so, since "why does this wrapper exist" is a
fair question to ask of it.

And rdp_tick's doc comment carried byte counts and GB-per-frame figures that
docs/performance.md already owns, dated and with their method. A doc comment
should carry the contract and point at the measurement, not copy it -- the same
reason ADR 0012's process note moved to engineering-lessons.
…t red

The previous commit referenced [\`Bus::rdp_tick\`] from inside rustyn64-rdp. Bus
lives in rustyn64-core, which this crate must not depend on (the one-directional
crate graph), so rustdoc -D warnings failed with an unresolved link -- and I
pushed anyway, because I ran the gates with `;` separators and echoed their
statuses instead of putting them in the conditional that guards the commit. That
is the exact hazard CLAUDE.md warns about, committed by me, in a session where I
had already cited it twice.

Plain text now. This is also why docs/architecture.md says to avoid intra-doc
links across the chip-crate boundary: the graph makes them unresolvable by
construction.
@doublegate

Copy link
Copy Markdown
Owner Author

Round twelve. Two adopted, one rejected — plus a red push of my own to report.

1. "Document whether Rdp::tick is deprecated or fallback-only" — ADOPTED. It is neither: it is the whole-step entry point, used by anything that already holds the bus and has nothing to decide. Only Bus::rdp_tick benefits from the split, because only it must know whether the step needs a bus before arranging one — and arranging one means taking the whole struct. Now stated, since "why does this wrapper still exist" is a fair question to ask of it.

2. Nitpick: "rdp_tick's doc duplicates benchmark detail from docs/performance.md" — ADOPTED. Trimmed to the contract plus a pointer. A doc comment should say what the method guarantees and where the measurement lives; copying dated figures into a second place is how the two disagree later.

3. "#[must_use] on NeedsBus is redundant" — held, and already explained in the type's own doc.

Correct that it does nothing today: the function's attribute is what fires, and the doc says exactly that, including why the obvious one does not. It stays because it is dormant rather than misleading — if the return shape ever changes to a bare NeedsBus, it activates and is correct. That is different from the inert debug_asserts I removed two rounds ago, which claimed to check something that could not happen.


Disclosure: I pushed a red commit. The doc I added in this round linked [Bus::rdp_tick] from inside rustyn64-rdpBus lives in rustyn64-core, which this crate must not depend on under the one-directional crate graph, so rustdoc -D warnings failed with an unresolved link.

It reached the branch because I ran the gates with ; separators and echoed their exit codes, then committed unconditionally — the exact failure CLAUDE.md describes, in a session where I had already cited that rule twice. Fixed in the follow-up, which ran every gate inside the conditional that guards the commit. It also illustrates why cross-crate intra-doc links are avoided here: the graph makes them unresolvable by construction.

…d token loses a cycle

Round thirteen of review on the RDP split-borrow skip. Four documentation
corrections; no functional code changes.

1. docs/rdp.md cited "the Angrylion .rvec and VI conformance vectors" as
   verification for the stepping split. The .vivec VI vectors feed the VI
   directly from RDRAM and never step the RDP, so they would pass just as
   convincingly with the stepping broken -- citing them overstates the oracle
   coverage of this change. The evidence is the .rvec suite, which does drive an
   RDP command list, plus the unit tests added here. The VI vectors stay
   attached to the scan-out claim, which is what they grade.

2. The NeedsBus doc said dropping a token "abandons that step's work". It does
   not: the Some path mutates nothing, so the command is still pending and the
   next tick retries the identical step. What is lost is a GCLK of RDP progress
   -- correct-but-late, with no wrong state anywhere, which is the failure mode
   worth naming because no state comparison can see it.

3. The rationale for the split now leads with the pattern (a caller that must
   move the struct before it can lend it) rather than with the name of the one
   downstream caller, which this crate must not depend on and whose name can go
   stale here silently.

4. Comma after a sentence-initial "Also" in docs/performance.md.

Gates: fmt, clippy -D warnings, test --workspace, rustdoc -D warnings,
check_en_us.sh, and markdownlint all run in one conditional; ALL-GATES-OK.
@doublegate

Copy link
Copy Markdown
Owner Author

Round thirteen. Four adopted — and the first two are both cases of a document claiming more than the code earns.

1. CodeRabbit: "Separate RDP evidence from VI evidence" — ADOPTED, and it was a real overstatement.

docs/rdp.md said the split was "verified by the Angrylion .rvec and VI conformance vectors". The .vivec vectors feed the VI directly from RDRAM and never step the RDP — they would pass exactly as convincingly with the stepping entirely broken. Citing them made the evidence base look twice as wide as it is, and the paragraph immediately below already attributes them to the scan-out, so the doc was crediting one oracle to two claims.

Now: the .rvec suite (the oracle that does drive an RDP command list) plus the unit tests added in this PR, and an explicit sentence saying why the VI vectors are not evidence here. This is the same class as the vacuous-pass lesson in docs/engineering-lessons.md — a green oracle that never reached the code under test.

2. Antigravity: "dropping a token defers the step, it does not lose it" — ADOPTED, and correct on the mechanism.

The Some path mutates nothing: it is reached only once stall is zero, so no decrement has occurred, and no FIFO pointer moves until the bus half runs. The command is still pending; the next rdp_tick retries the identical step. "Abandons that step's work" was wrong.

Adopted with one sharpening, because "deferred/retried" understates it in the other direction: the RDP made no progress during that GCLK and is one cycle late from then on. That is a timing divergence with no wrong state anywhere — correct-but-late, which no state comparison can detect. It is the same failure shape ADR 0011 names for the fast-scheduler bailout invariant, so it is worth saying precisely rather than softening.

3. Antigravity nitpick: "doc comments name an outer-crate caller, coupling rustyn64-rdp to rustyn64-core" — ADOPTED in part.

The load-bearing sentence now leads with the pattern — a caller that cannot lend this struct without first moving it, so it must know whether the step needs a bus before paying for the move — with the Bus named parenthetically as the instance. The concrete name stays in Rdp::tick's doc, where the question being answered ("why does this wrapper still exist") has no useful generic answer.

Worth noting the premise is stronger than a style preference here: a link across that edge does not compile, which is what I pushed red last round. Plain prose does compile, so it goes stale silently instead — which is the worse of the two failure modes.

4. CodeRabbit nitpick: comma after a sentence-initial "Also" — adopted.


No functional code changed; the implementation has been fixed since round three. Gates run in one conditional, no pipes: fmt, clippy -D warnings, test --workspace, rustdoc -D warnings, check_en_us.sh, markdownlint — ALL-GATES-OK.

@github-actions

Copy link
Copy Markdown

Antigravity review (Gemini via Ultra)

This PR optimizes RDP stepping by splitting Rdp::tick into a bus-free phase (tick_without_bus) and a bus-dependent execution phase (tick_with_bus), avoiding expensive core::mem::take struct moves when the RDP is idle, frozen, or stalling.

Blocking issues

None found.

Suggestions

  • crates/rustyn64-rdp/src/lib.rs (lines 1588–1592): The doc comment for NeedsBus claims that #[must_use] on a type does not propagate through Option<T>. In modern Rust compilers, type-level #[must_use] does propagate through standard library wrappers like Option and Result. Keeping #[must_use] on tick_without_bus is still good practice, but the comment rationale is inaccurate for current Rust.
  • crates/rustyn64-core/src/bus.rs (line 543): rdp_tick still performs core::mem::take(&mut self.rdp) on active steps, which constructs Rdp::default() in the vacated slot. If Rdp grows in size or acquires heap-allocated members in future iterations, this default instantiation on active cycles could become a secondary hotspot. Consider wrapping rdp in Option<Rdp> on Bus if Rdp::default() complexity increases.

Nitpicks

  • crates/rustyn64-rdp/src/lib.rs (line 1845): Rdp::tick retains its full bus: &mut B signature despite delegating to tick_without_bus() first. While reasonable for test convenience, consider adding #[inline] to tick so callers outside bus.rs don't pay redundant wrapper overhead.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

@doublegate
doublegate merged commit fc1e946 into main Jul 30, 2026
12 checks passed
@doublegate
doublegate deleted the perf/rdp-skip-idle-take branch July 30, 2026 19:39
doublegate added a commit that referenced this pull request Jul 30, 2026
…first tick did nothing

Review round one on #220. No change to the emulated code path; the tick body is
byte-identical to the build the 1.041x was measured on.

1. The test called `ai.tick(1, ..)` and asserted it "must anchor the next sample",
   but `write_reg` already sets `next_sample_tick` when a transfer is enqueued, so
   that call took the early-return branch and anchored nothing. The assertion
   described a path it never reached. The priming tick is removed and the schedule
   is attributed to the register write, which is where it comes from.

2. docs/audio.md said the period is computed "only when a sample is actually due".
   It is also computed when `next_sample_tick` is zero and the first sample has to
   be anchored. Reworded to what the code does: skipped when a schedule exists and
   its next sample is still ahead.

3. The 18-line comment in `tick` restated benchmark figures and ADR rationale that
   docs/audio.md now owns. Trimmed to the contract plus a pointer, the same call
   made for `rdp_tick` in #219.

4. The render-phase attribution table carried no remainder, so its columns summed
   to 97.7% and 83.5% with no way to audit the difference. Both now carry an
   explicit remainder and a total, plus a note that the old column is five coarse
   buckets and is not a like-for-like baseline.

5. The 60 FPS section stated a dynarec as required and implied unsafe_code forbids
   it. The measured 48 ms bound is arithmetic and stands; the dynarec is not a
   consequence of it. Rewritten as a proposed route with its costs, noting that
   ADR 0011 leaves the fast-path mechanism open and that unsafe is already
   permitted in the frontend and FFI -- so placement and an ADR, not impossibility.

Also records a null result from this session: adding [u64; 9] of padding to Latch
(120 -> 192 bytes, +60% bytes moved per emulated cycle) measured 1.1% FASTER, not
slower. The six pipeline.rs lines that read as 16.1% of a frame are perf charging
each stage's retired work to the store that ends it, not a transfer cost. That
retires the sizing behind the "split Latch" task. The caveat is stated in the doc:
unread padding can be narrowed by LLVM, so this is evidence against copy width
driving the cost, not proof.

Gates: fmt, clippy -D warnings, test --workspace, rustdoc -D warnings,
check_en_us.sh, markdownlint -- one conditional, no pipes; ALL-GATES-OK.
doublegate added a commit that referenced this pull request Jul 30, 2026
Audio::tick opened by computing period_ticks() -- MASTER_HZ / sample_rate, a
64-bit divide -- and only then asked whether a sample was due. The scheduler calls
it on every RCP step, about 1.04M times a frame, while at ~32 kHz the period is
~5,859 master ticks. So roughly 1,950 of every 1,951 calls divided and threw the
quotient away. That one line was 3.67% of a rendering frame, the largest source
line outside the CPU pipeline.

Ordering, not caching: return before the divide when a schedule already exists and
its next sample is still ahead. Precisely that -- the divide is still performed on
the two paths that need it, when a sample is due and when next_sample_tick is zero
and the first one must be anchored. "Only when due" would overstate it.

Behavior-identical rather than approximately so: on the skipped path the old code
either returned at the period == 0 guard or fell into a while whose condition is
exactly the negation of the new test, and neither route touches a field. A memo
field was rejected -- the quotient is derived from sample_rate, and caching derived
state in a serialized struct would change the save-state layout (ADR 0005) to buy
what the reordering buys for nothing.

A-B-A in one sitting, Super Mario 64, --release, frame_cost_probe:

  A  before        107.413 / 107.587 ms
  B  after         103.447 / 103.175 ms
  A  before again  107.652 ms

Three A legs within 0.22%, so the session did not drift. 107.55 -> 103.31 ms,
1.041x; the conservative pairing gives 1.038x. The profile predicted 3.67%.

The R-16 debug_assert is kept reachable on the new fast path, where it compiles out
of release entirely, rather than firing only on the ~0.05% of calls that emit. It
asserts the same invariant in the same situations the old guard did: period == 0 is
reachable only when sample_rate == 0, and AI_DACRATE is 14 bits so the divide
cannot floor to zero.

Mutation-checked, and the existing suite failed the check. Breaking the early-out
grossly is caught by seven tests, but the off-by-one that defers every sample by one
RCP step left the whole workspace green -- the other AI tests advance `now` in
strides of a full period and never land on the boundary. That case now has a test
which goes red under the mutation and green without it.

Two review rounds corrected claims that did not match the code: the new test called
tick() first and asserted it anchored the schedule, when write_reg had already set
next_sample_tick so that call took the early return; and docs/audio.md said the
period is computed "only when a sample is actually due".

Also in this change, documentation only:

- The render-phase attribution map, re-measured. The previous one was taken at
  138.7 ms/frame, before the scan-out memo (#216) and the RDP split-borrow skip
  (#219), so it was stale for exactly those buckets. Both columns now carry an
  explicit remainder and total, with a note that they are not like-for-like.

- The 60 FPS target, bounded by measurement. CPU buckets plus scheduler dispatch
  are 53.5% of a frame; setting both to zero caps the win at 2.15x (48.0 ms,
  20.8 FPS). ADR 0011 remains the largest single win left but is not a path to
  60 FPS, and the task list's claim that it was is retired. A dynarec is presented
  as a proposed route with its costs -- ADR 0011 leaves the mechanism open, and
  unsafe is already permitted in the frontend and FFI, so it is a placement and
  own-ADR question rather than an impossibility.

- A null result that retires the "split Latch" task without implementing it. Six
  pipeline.rs lines are all the same inter-stage latch copy and sum to 16.1% of a
  frame. Adding [u64; 9] of padding to Latch -- 120 to 192 bytes, per-cycle latch
  traffic 840 to 1344, +60% -- produced no sign of the roughly +10% that a
  transfer-bound cost predicts. perf is charging each stage's retired work to the
  store that ends it. Recorded with full provenance, and explicitly NOT quoting its
  ~1% delta: that probe was two-leg, not A-B-A, and 1% is inside session drift.
  Refuting a 10% prediction does not require resolving 1%.

Refs #55. Retires #60.
doublegate added a commit that referenced this pull request Jul 30, 2026
…83x)

Bus::audio_tick took the whole Audio out of the Bus with core::mem::take on EVERY
RCP step -- size_of::<Audio>() is 88 bytes, so a Default is written into the
vacated slot, 88 bytes move out, 88 move back, and the vacated value is dropped --
purely so tick could borrow its owner. The DAC emits nothing on ~1,949 of every
1,950 steps, so nearly all of it was waste.

Audio::tick_without_bus answers those steps from the struct's own fields and
Bus::audio_tick only takes when it hands back a NeedsBus. Audio::tick calls the
same helper, so the early-outs have one implementation and cannot drift apart.
Same shape as Bus::rdp_tick (#219).

The bus-free half still runs on every step and still mutates -- it stamps
last_tick and anchors the first sample -- so this skips the MOVE, never the step.

The token carries the DAC period as well as the proof, so the 64-bit divide that
produced it is not repeated in the second half. Its fields are private, it has no
constructor, and it derives neither Copy nor Clone, so it cannot be forged or
replayed against a schedule that has moved past it. clippy's
needless_pass_by_value is allowed at that signature with the reason stated: a
&NeedsBus would compile, and taking it by value is the entire property.

A-B-A, one sitting, Super Mario 64, --release, frame_cost_probe:

  A  take on every step        105.391 / 106.086 ms
  B  take only when due         97.854 /  97.309 ms
  A  take on every step, again 105.447 ms

Three A legs spanning 0.66% and bracketing B, so the session did not drift.
105.64 -> 97.58 ms, 1.083x; the conservative pairing, best A over worst B
(105.391 / 97.854), gives 1.077x.

IT BEAT ITS PREDICTED SIZE BY ~5x, which is a broken model and not a windfall --
and it is the same broken model as #219's. The profile attributed 2.68% to
core/src/mem/mod.rs (the mem::replace copy intrinsics, SHARED between the RSP,
RDP and AI takes) plus 1.01% to the audio_tick line, so ~1.5% was the
expectation. What that share does not name is the rest of a take-and-restore:
constructing the Default, writing it into the slot, writing the real value back,
and dropping the vacated one -- which for Audio is drop glue for the Vec sink, on
every step. "A profile share bounds the code the profiler named" now has a second
independent confirmation, so docs/performance.md records it as the rule and adds
the corollary: do not size a split-borrow from its mem::replace share. The RSP's
take is the last one and is a different case -- the RSP executes microcode on
essentially every step, so there is no idle majority to skip.

Tests: every bus-free early-out pinned to its own condition in rustyn64-audio
(schedule ahead, first-sample anchor, and the due case that does need the bus and
whose token is consumed), plus a -core test driving the AI one master tick at a
time across two full periods so thousands of skips are exercised, asserting the
emitted stream against the RDRAM the test wrote. Both mutation-checked: forcing
the take to be skipped unconditionally turns the -core test red on the sample
count, which is a clearer signal than the index-out-of-bounds panic that the only
previously-covering test produced.

Review corrected two things worth recording. The conservative ratio was reported
as 1.072x, which the stated extrema do not produce; it is 1.077x. And #[must_use]
on the NeedsBus type was proposed as catching a caller who binds the token and
drops it -- probed instead of assumed, and it does not: with
`if let Some(_proof) = ai.tick_without_bus(1) {}`, clippy --all-targets reports
zero warnings with or without it. The attribute is kept for consistency with the
RDP's token and documented as dormant. #[inline] was rejected on this workspace's
own measurements: lto = "fat" already crosses the crate boundary, and inline hints
have measured neutral (an A-B-A on the identical position in #219) or 36% worse
(the VI leaf readers) here.

Closes #61. Refs #55.
doublegate added a commit that referenced this pull request Jul 31, 2026
…moves the plan (#235)

* docs(perf): the RSP's idle steps are already free — measured, and it moves the plan

Answers the question the fast-exec profile ended on: how much of the RSP bucket
is a step that had nothing to do? That is what the deficit-counter scheduler has
to be sized against, since its value is removing VISITS and a visit is only worth
removing if it costs something.

HOW MANY. ~38% of render-phase steps are halted. The CUMULATIVE share is
misleading and is tabled per interval because of it: it starts above 80% and
falls throughout, since boot is mostly a halted RSP and the render phase is not.
The last four intervals sit at 37.7-41.0% and are flat, which is what says that
is steady state. Quoting the cumulative 56.9% would have overstated it by half;
quoting the first interval's 82.9% by more than double.

WHETHER REMOVING THEM HELPS: no.  already early-outs on halted; what a
caller could still skip is the wrapper — the call, the StepResult::default(), the
three Option tests, the counter. A-B-A over 900 frames:

  A 63.6 / 63.5   B 65.8 / 64.3   A 63.5 / 65.1

The legs OVERLAP — B's 64.3 sits inside A's 63.5-65.1 — so this is neutral and
B's apparent regression is drift. LLVM already elides the wrapper's work: su_step
returning a default StepResult inlines into rsp_tick and the dead stores fold
away. There was nothing there to remove.

WHAT IT DOES TO THE PLAN. The deficit-counter scheduler had two justifications
and both are now measured: scheduler.rs's dispatch arithmetic at 5.05% (1.05x if
eliminated entirely), and per-edge chip visits whose cost lands in the chip
buckets — measured here as ~nothing for the RSP, the largest of them. That is the
second justification failing on its strongest case. Not proof for the RDP, AI, PI
or VI, whose visits were not measured this way, and the doc says so.

So the RSP's 21.4% is REAL MICROCODE EXECUTION, not dispatch overhead, and the
lever that reaches it is a faster interpreter, not fewer visits.

Recorded under Ruled out: do not re-try the halted early-out. The analogous
change WAS a win for the RDP and AI (#219/#221) because those avoid a
core::mem::take of a large struct; the RSP's wrapper has no such payload, so the
pattern does not transfer. Matching the shape of a past win is not evidence.

Instrumentation was scratch-only: bus.rs snapshotted before, restored after, and
the tree verified clean against the snapshot between every leg.

Gates: check_en_us.sh, markdownlint. Docs-only.

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

* docs(perf): the reviewer was right — the null result was a noise artifact

Review pointed out that `gameplay_phase_probe` has a ~2.5% within-leg spread,
wider than the effect being looked for, and that `examples/frame_bench.rs` has a
~1% floor. Re-running there REVERSED the result.

The first measurement gave overlapping legs and was recorded as NEUTRAL. On
frame_bench, five readings per leg:

  A  64.231  64.392  64.262  64.401  65.407
  B  63.818  63.896  63.927  63.941  [71.507 excluded]

One reading is excluded and named: B's 71.507 is 11.8% above the rest of its own
leg, which is contamination rather than variance. Every other reading is kept,
including A's 65.407, which is only 1.8% high and has no such excuse.

On that data the legs DO NOT OVERLAP — A's minimum sits above B's maximum — so
the early-out is worth 64.231 -> 63.941 ms = 0.45%.

It is a real effect and it is small enough to change nothing, for two reasons
that are now stated as bounds rather than glossed:

- frame_bench's window is EARLY BOOT, where 80%+ of RSP steps are halted. The
  render phase runs at 38%, so expect roughly half of 0.45% there.
- It is within a factor of two of the harness's own noise floor, which is why
  five readings per leg were needed to see it at all.

Still not landed: 0.45% on the most favorable window does not justify a change
that also breaks `rcp_steps_for_test`'s count, and the reason to reach for it —
that idle visits are expensive — is exactly what it disproves. The value is the
bound, not the patch.

The conclusion about the deficit-counter scheduler is unchanged in substance:
its second justification still fails on its strongest case. What changed is that
the number is 0.45% rather than zero, and a document that says "neutral" when it
means "0.45%, measured on a harness that could not resolve it" is the kind of
claim this project keeps having to retract.

Also adopted: the step-to-frame ratio (~1.0 M RCP steps per frame) is stated so
the step-interval table connects to the 900-frame run, and the illustrative
snippet now shows the three Option tests it skips rather than eliding them.

Gates: check_en_us.sh, markdownlint. Docs-only.

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

* docs(perf): an upper bound is not a demonstrated win, and one claim was invented

Two review findings, both errors of mine.

"SO IT IS A REAL EFFECT" OVERSTATED WHAT FIVE READINGS A SIDE CAN SHOW. The legs
differ by 0.45% and do not overlap; that is an OBSERVATION, not a demonstrated
causal improvement, with an effect within a factor of two of the harness floor
and one excluded sample. What it establishes is an UPPER BOUND: whatever removing
idle visits is worth, it is not more than this. Reworded to say that.

The same over-reach ran into the conclusion. "The RSP's 21.4% is real microcode
execution, not dispatch overhead" generalises from ONE wrapper path. What the
experiment supports is narrower: the halted-visit overhead inside the bucket is
small, so the bucket is predominantly work done while the RSP is running. It does
NOT decompose the running 62% into dispatch versus arithmetic versus register
access, and it says nothing about the RDP, AI, PI or VI. The threaded interpreter
is now "likely" the lever rather than established as one, with the decomposition
named as the next measurement.

I CLAIMED THE CHANGE BREAKS `rcp_steps_for_test`. IT DOES NOT. The experimental
patch increments `rcp_steps` on the halted return, so the count is preserved and
no test is affected. That was a reason invented to reinforce a conclusion already
reached on other grounds — the worst kind of supporting argument, because it
reads as evidence. Removed, and the removal is recorded in the document rather
than quietly dropped, since a reader of the earlier text deserves to know it was
wrong.

The not-landed decision stands on the size of the effect alone, which was always
the real reason.

Gates: check_en_us.sh, markdownlint. Docs-only.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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