perf(rdp): skip the split-borrow move when the RDP needs no bus (1.157x) - #219
Conversation
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.
|
Warning Review limit reached
Next review available in: 12 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
📝 WalkthroughSummary by CodeRabbit
WalkthroughRDP ticking now separates bus-free early-outs from bus-backed command decoding. ChangesRDP split ticking
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
Possibly related PRs
🚥 Pre-merge checks | ✅ 9 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (9 passed)
Comment |
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.
|
All three adopted. 1. " Three asserts now — unfrozen, Mutation-checked: removing the 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 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 |
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.
|
Round two. Two adopted, one rejected — and the first is the best comment on this PR. 1. " 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 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 2. "Rename
Your underlying point is fair — a 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.
|
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 You are right on both counts: 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 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. Gates: clippy |
… 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.
|
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 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 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 3. Nitpick: "the They cannot fire. An assertion that cannot fire is dead code that reads like a safeguard — the inert-API hazard Gates: clippy |
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.
|
Round five. Both adopted — and the reason there was a round five is worth naming. Changing 1. 2. Verified: zero remaining occurrences of a |
…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.
|
Round six. One measured and rejected, one rejected structurally, one adopted. 1. "Consider
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. 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 This is also the second independent "an inline hint does nothing here" result, after 2. "Evaluate whether the two-phase protocol should be
3. Nitpick: " |
…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.
|
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 Each early-out is a reason a step can skip the 344-byte
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 Mutation-checked: deleting the freeze/XBUS early-out turns it red. 2. Nitpick: "document that 3. " 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 Gates: clippy |
There was a problem hiding this comment.
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 winRemove 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
📒 Files selected for processing (4)
crates/rustyn64-core/src/bus.rscrates/rustyn64-rdp/src/lib.rsdocs/performance.mddocs/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.
|
Round eight. One adopted, one rejected on idiom — and I checked the rejection rather than assuming it. 1. "Replace I expected
2. Nitpick: "note that 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. Gates green: clippy |
There was a problem hiding this comment.
♻️ Duplicate comments (1)
crates/rustyn64-rdp/src/lib.rs (1)
1575-1582: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winBind
NeedsBusto the state it authorises.This remains the previously reported issue: omitting
Copy/Cloneprevents duplication, but not retention or multiple proofs. A caller can obtain two tokens, consume one command with the first, then reuse the second aftercmd_current,cmd_end, orstallhas changed. Becausetick_with_bus()ignores the token and repeats none of the early-out checks, it can dispatch while stalled or evaluatecmd_end - cmd_currentafter the FIFO state has become invalid.Store the validated FIFO state in
NeedsBusand 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
📒 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.
|
Round nine. All three adopted — with the second's premise corrected, because the code says otherwise. 1. " 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 2. "If a caller drops the token, The token is handed out only on the final path, after What it does cost is the step's work — the RDP does not advance, and the next 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 Especially here: Note the contrast with |
… 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.
|
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. " I wrote on
So the guarantee I documented did not exist. Two reasons, and I had the second wrong in my head: an attribute on
2. "The test only exercises the early-out; add one for a complete command through 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 3. Nitpick: "remove the leading underscore from
Gates: clippy |
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.
|
Round eleven. One adopted, two rejected — the second on a workspace setting that removes the failure mode. 1. "Mark
2. "A panic inside Correct as a general Rust observation, and worth raising. Two reasons it is not live here:
It is also not introduced by this PR — 3. Nitpick: "note that pointer wrapping is handled before Checked rather than asserted: these are RDRAM addresses, not ring indices. |
…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.
|
Round twelve. Two adopted, one rejected — plus a red push of my own to report. 1. "Document whether 2. Nitpick: " 3. " 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 Disclosure: I pushed a red commit. The doc I added in this round linked It reached the branch because I ran the gates with |
…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.
|
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.
Now: the 2. Antigravity: "dropping a token defers the step, it does not lose it" — ADOPTED, and correct on the mechanism. The 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 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 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 |
Antigravity review (Gemini via Ultra)This PR optimizes RDP stepping by splitting Blocking issuesNone found. Suggestions
Nitpicks
Automated first-pass review by |
…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.
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.
…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.
…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>
Motivation
Bus::rdp_ticktook the wholeRdpout of the Bus withcore::mem::takeon every RCP step — 344 bytes read and written, plus a freshDefaultwritten into the vacated slot — purely sotickcould 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_busanswers those cases from the struct's own fields, andBus::rdp_tickonly takes when it returnsfalse: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::tickcalls 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 each1.157x.
This beats its own predicted ceiling, which means the model was wrong
docs/performance.mdderives 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_moveintrinsics insidemem::replace, and not the cost of constructing theRdp::default()thattakewrites into the vacated slot — which is inlined intordp_tickand 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.rvecand VI conformance vectors, byte-for-byte),RUSTDOCFLAGS="-D warnings" cargo doc, theno_stdbuild,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
stallwould decrement twice. Mutation-testing disproved it:rdp_tickonly reaches that half whentick_without_busreturnedfalse, which impliesstall == 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.