Skip to content

feat(cpu): the instruction-granular execution path (ADR 0013), default-off - #232

Merged
doublegate merged 2 commits into
mainfrom
perf/fast-exec-sequential-cpu
Jul 31, 2026
Merged

feat(cpu): the instruction-granular execution path (ADR 0013), default-off#232
doublegate merged 2 commits into
mainfrom
perf/fast-exec-sequential-cpu

Conversation

@doublegate

Copy link
Copy Markdown
Owner

Motivation

Step 2 of task #64, following #231's extraction. Pipeline::step_instruction runs one instruction to completion — fetch, gate, execute, memory, commit — and returns what it cost in PCycles, instead of advancing four inter-stage latches once per PClock. pipeline.rs is 36.1% of a rendering frame; this is the change that addresses it.

Behind the default-off fast-exec feature, authorized by ADR 0013.

It executes the same instruction stream

decode, exec::execute, alu, cop0, tlb, addr, softfloat, the caches, the TLB, and the exception model are all shared — and the commit runs through the accurate path's own wb_stage, by staging the instruction into dc_wb first. Nothing about what an instruction computes is reimplemented. Only the timing model is relaxed, which is exactly and only what ADR 0013 authorizes.

The cost model is the accurate path's own stall requests

This is the design decision the rest follows from. Every documented cost already exists in the tree, and the accurate path spends them as cycles:

cost source
MULT 5, DIV 37, DMULT 8, DDIV 69 alu::muldiv_stall_cycles (UM Table 3-12)
FPU add 3, mul 5/8, div/sqrt 29/58 fpu::stall_cycles (UM Table 7-14)
micro-ITLB miss 3 tlb::ITLB_MISS_PCYCLES (UM §4.6.2)
RCP register access 22 M_RCP_REGISTERmeasured
exception epilogue 2 exception::EPILOGUE_STALL (UM §4.7 p. 114)
I-cache fill 46, D-cache fill 40 M_ICACHE_FILL/M_DCACHE_FILLfitted, not measured (ledger C-1)

So the fast path drains Pipeline::stall after each phase and charges it, rather than carrying a table of its own. There is nothing to keep in step, and no constant is invented — a number wrong here is wrong in the accurate path too, which is the only arrangement under which the two are comparable at all. The last row is flagged in the module docs: inheriting fitted anchors is right, but a timing result from this mode is no more trustworthy than M(RDRAM) is.

No new state, deliberately

A branch and its delay slot execute in the same call. The alternative — a pending_redirect field on Pipeline — would be state the fast path owns, which makes ADR 0011 §4's save-state mode marker fall due and breaks the layout. Executing the pair together costs nothing and owes nothing. A delay slot that is itself a branch continues the loop rather than recursing; MIPS calls that UNPREDICTABLE and the loop reproduces the cascade's sequence.

Not modeled, each deliberately, and each a timing structure rather than a semantic one: the bypass network (sequential execution makes operands current), the load interlock (a load's value is always ready), the prev_was_run interrupt gate (an instruction boundary is always legal), the flush cascade (nothing younger has been fetched).

Also extracted apply_cop0_read from dc_stage — the fourth latch-independent primitive. #231 left it alone on the grounds that a seam guessed without a second caller is a guess; this is that caller.

Two findings from building the gate

The fast path sets the boundaries and the accurate path follows. One call can retire two instructions, so the oracle is advanced to meet the fast path — the reverse is structurally impossible.

Neither PC-like quantity is comparable across the modes. Both were tried, and each reported a divergence on the first boundary of a run that agrees on everything else:

  • Cpu::pc is the IC fetch pointer, up to four instructions ahead of the retiring one;
  • dc_wb.pc is off by exactly one, because the reverse cascade has already moved the next instruction in by the end of the tick that retired.

The architectural PC of a retiring instruction is simply not observable from outside the pipeline in the accurate mode. EPC is where it surfaces, and that is compared, as part of COP0. What carries the weight instead: every test program writes a distinct value to a distinct register per instruction, so a skipped, repeated, or reordered instruction shows in the GPRs within one boundary.

The cost assertion had to become an A/B

The first version asserted cost >= 8 + 5 + 37 on the multiply program, and passed with the charge deleted — eight cold I-cache fills cleared the bar on their own. That is the converging-test hazard, and mutation caught it, not review.

It is now the difference between two programs of the same length differing only in MULT/DIV versus NOP, which cancels every shared cost. Asserted as == (42) rather than >=, since anything else in the difference would be an operand-dependent cost this model does not have.

Mutation-checked, both restored afterwards: skipping the delay slot fails three tests; deleting the muldiv charge fails the A/B (54 with them, 54 without) where the threshold version had passed.

Disclosure: a fifth doc-comment theft, shipped in #231

Adding sample_interrupt_lines above fn dc_stage in the previous PR put it between dc_stage's doc comment and the function, so dc_stage has been undocumented on main since #231 and its /// \DC` — the data-cache access…block merged ontosample_interrupt_lines. The bot caught the ex_gateandfetch_wordinstances in that PR; this one slipped through. Fixed here, and I sweptpipeline.rsfor others — the only remaining undocumented items inimpl` blocks are two test helpers.

Verification

One guarded conditional, no pipes: cargo fmt --all --check; cargo clippy --workspace --all-targets -- -D warnings; cargo clippy -p rustyn64-cpu --features fast-exec --all-targets -- -D warnings; cargo test --workspace; cargo test -p rustyn64-cpu --features fast-exec; RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps; the no_std build for both crates; scripts/check_en_us.sh; pre-commit run markdownlint --all-files.

The oracle, on the accurate path, which this must not move:

Phase 1 categories: 0 failing (suite ran to xioctl(EXIT)). 90 failing suite-wide
across 950 tests started (the remainder are RSP/RCP -- Phase 2's criterion).

CI gains four entries for the new feature (test + clippy on the light leg, plus the no_std build), mirroring fast-scheduler — CI runs clippy exactly once and feature-gated code is invisible to every other job, so without them the gate would be a gate that never runs.

What this PR does not do

It does not wire fast-exec into System or the scheduler, so nothing in a shipped build reaches it and there is no throughput number yet — deliberately, so the execution path and its wiring are reviewable separately. Wiring, the core-level differential predicate on the ADR 0012 machinery, and the A-B-A measurement are the next slice.

🤖 Generated with Claude Code

…t-off

Step 2 of task #64. `Pipeline::step_instruction` runs ONE instruction to
completion -- fetch, gate, execute, memory, commit -- and returns what it cost
in PCycles, instead of advancing four latches once per PClock.

It executes the SAME instruction stream through the SAME semantics. decode,
exec::execute, alu, cop0, tlb, addr, softfloat, the caches and the exception
model are shared, and the commit runs through the accurate path's own
`wb_stage` by staging the instruction into `dc_wb` first. Nothing about what an
instruction computes is reimplemented; only the timing model is relaxed, which
is exactly and only what ADR 0013 authorizes.

THE COST MODEL IS THE ACCURATE PATH'S OWN STALL REQUESTS. This is the part
worth reading twice: `alu::muldiv_stall_cycles`, `fpu::stall_cycles`,
`tlb::ITLB_MISS_PCYCLES`, M_RCP_REGISTER, M_ICACHE_FILL/M_DCACHE_FILL and
`exception::EPILOGUE_STALL` already exist, and the accurate path SPENDS them as
cycles. The fast path drains `Pipeline::stall` after each phase and CHARGES
them. So there is no second table to keep in step and no constant is invented -
a number wrong here is wrong in the accurate path too, which is the only
arrangement under which the two can be compared at all. The cache fills are
fitted rather than measured (ledger C-1) and the module says so.

A branch and its delay slot execute in the SAME CALL, which is what lets this
add no field to `Pipeline`: the save-state layout is untouched and ADR 0011
section 4's mode marker is still not owed. A delay slot that is itself a branch
continues the loop rather than recursing.

Not modeled, each deliberately: the bypass network (sequential execution makes
operands current), the load interlock (a load's value is always ready), the
`prev_was_run` interrupt gate (an instruction boundary is always legal), and the
flush cascade (nothing younger has been fetched). Every one is a timing
structure -- the layer ADR 0013 relaxes.

Also extracted `apply_cop0_read` from `dc_stage`, the fourth latch-independent
primitive. PR #231 deliberately left it alone because it had one caller and a
seam guessed without a second caller is a guess; this is that caller.

THE GATE, and two findings from building it:

- The FAST path sets the boundaries and the accurate path follows. One call can
  retire two instructions, so the oracle is advanced to meet the fast path
  rather than the reverse, which it structurally could not do.
- NEITHER PC-LIKE QUANTITY IS COMPARABLE. `Cpu::pc` is the IC fetch pointer and
  sits up to four instructions ahead of the retiring one; `dc_wb.pc` is off by
  exactly one, because the reverse cascade has already moved the next
  instruction in by the end of the tick that retired. Both were tried and each
  reported a divergence on the first boundary of a run that agrees on
  everything else. The architectural PC of a retiring instruction is not
  observable from outside the pipeline in the accurate mode; EPC is where it
  surfaces, and that IS compared as part of COP0. What carries the weight
  instead: every program writes a distinct value to a distinct register per
  instruction, so a skipped, repeated or reordered instruction shows in the GPRs
  within one boundary.

The cost half is an A/B DIFFERENCE between two programs of the same length
differing only in MULT/DIV versus NOP. The first version used a threshold on a
single run and PASSED WITH THE CHARGE DELETED, because eight cold I-cache fills
cleared the bar on their own -- the converging-test hazard, caught by mutation
rather than by review.

Mutation-checked, both restored: skipping the delay slot fails three tests;
deleting the muldiv charge fails the A/B (54 with, 54 without) where the
threshold version had passed.

CI gains four entries for the new feature (test + clippy on the light leg,
no_std build), mirroring `fast-scheduler`, because CI runs clippy exactly once
and feature-gated code is invisible to every other job.

Gates: fmt, clippy (workspace + the feature), test --workspace, the new gate,
rustdoc, no_std for both crates, en-US, markdownlint, and the oracle -- 0
failing in the Phase 1 categories, 90 suite-wide, unchanged.

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

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added an optional, disabled-by-default fast execution mode for completing CPU instructions individually.
    • Preserves instruction costs, branching, delay slots, memory faults, interrupts and exception handling.
  • Documentation

    • Documented fast execution behaviour, timing considerations and usage.
  • Tests

    • Added comparisons with the accurate execution path across arithmetic, memory, branching, loops and multiply/divide operations.
    • Added automated builds and checks for supported targets.

Walkthrough

The CPU crate adds an opt-in fast-exec feature. It executes instructions through shared pipeline semantics, handles redirects and stalls, exposes per-instruction costs, and validates state against the accurate path.

Changes

Fast execution path

Layer / File(s) Summary
Feature and CPU entry point
crates/rustyn64-cpu/Cargo.toml, crates/rustyn64-cpu/src/lib.rs, crates/rustyn64-cpu/src/pipeline.rs
The default-off fast-exec feature adds Cpu::step_instruction_at and the feature-gated pipeline module.
Shared COP read handling
crates/rustyn64-cpu/src/pipeline.rs
COP0 and COP1 read handling uses the shared apply_cop0_read helper.
Instruction-granular pipeline execution
crates/rustyn64-cpu/src/pipeline/fastexec.rs
The fast path fetches, executes, commits, handles branches, delay slots, interrupts, exceptions, memory faults, FP traps, ERET, and stall costs.
Differential validation and integration
crates/rustyn64-cpu/tests/fast_exec_differential.rs, docs/cpu.md, .github/workflows/ci.yml
Tests compare architectural state and timing at retirement boundaries. Documentation and CI cover the feature-enabled path and no_std build.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Cpu
  participant Pipeline
  participant Bus
  Cpu->>Pipeline: step_instruction
  Pipeline->>Bus: Fetch instruction and access memory
  Pipeline-->>Cpu: Return instruction cost and updated state
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 7 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title identifies the feature but exceeds the 72-character limit and does not use an imperative subject. Shorten the title to 72 characters or fewer and rewrite the subject in imperative mood, without a trailing period.
Changelog Entry For User-Visible Changes ⚠️ Warning The PR adds the user-visible default-off fast-exec feature and public CPU entry point, but CHANGELOG.md has no corresponding entry under [Unreleased]. Add an ### Added entry under CHANGELOG.md [Unreleased] describing fast-exec and its instruction-granular execution API.
Measured, Never Tuned ⚠️ Warning fastexec.rs adds an uncited EXPECTED_CHAIN = 64 limit and selects a cascade for an explicitly UNPREDICTABLE nested branch; no manual/wiki citation or ledger entry exists. Record the chosen nested-branch behaviour and bound with authority or measurement in docs/accuracy-ledger.md, or remove the unsupported rule and defer the scheduler limit to wiring.
✅ Passed checks (7 passed)
Check name Status Explanation
Description check ✅ Passed The description directly explains the fast-exec feature, its scope, design decisions, tests, and verification.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 The PR states the measured oracle result: 0 Phase 1 failures and 90 suite-wide failures. docs/STATUS.md confirms the current figure of 90.
Docs-As-Spec Sync ✅ Passed The PR changes CPU behaviour and adds a matching docs/cpu.md section for fast-exec, including execution semantics, costs, delay slots, omissions, and differential testing.
Unsafe Stays Out Of The Chip Crates ✅ Passed PR range adds no unsafe Rust outside the frontend, and all six chip crates plus rustyn64-core retain #![forbid(unsafe_code)] as required by ADR 0013 §5.

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

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

🤖 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 `@docs/cpu.md`:
- Around line 711-716: Add the missing comma in the documentation sentence
following “MIPS calls that UNPREDICTABLE” so the two independent clauses are
correctly joined, without changing the surrounding explanation.
- Around line 732-747: Remove the commas before the essential trailing “because”
clauses in the documentation sentences describing “dc_wb.pc” and the deleted
charge passing the test. Preserve commas before trailing “because” clauses when
the main clause is negated, as in the surrounding “Neither” sentence.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 03dad8b6-49b7-4329-a4d9-ee54e109a8d6

📥 Commits

Reviewing files that changed from the base of the PR and between ff01921 and 038312f.

📒 Files selected for processing (7)
  • .github/workflows/ci.yml
  • crates/rustyn64-cpu/Cargo.toml
  • crates/rustyn64-cpu/src/lib.rs
  • crates/rustyn64-cpu/src/pipeline.rs
  • crates/rustyn64-cpu/src/pipeline/fastexec.rs
  • crates/rustyn64-cpu/tests/fast_exec_differential.rs
  • docs/cpu.md

Comment thread docs/cpu.md
Comment thread docs/cpu.md
Four review findings.

`Flow::Redirected` no longer carries a target. Everything that produces it has
already written `next_pc` -- that is what redirecting IS -- so the payload was a
second copy of the same fact and a place for the two to disagree. ERET wrote it
twice; now once.

The branch-in-delay-slot loop gains a documented `EXPECTED_CHAIN` bound with a
debug_assert. It is NOT a safety net against an infinite loop: the loop
terminates by construction, since every iteration retires an instruction. It
bounds how long one call runs without returning to the scheduler, which is a
SCHEDULING concern rather than a correctness one -- a chain of jumps each in the
previous one's delay slot starves the RCP for its duration. A release build
continues past it rather than truncating, because stopping mid-chain would
execute the wrong instructions, and a wrong answer is worse than a long call.
The real cap belongs in the wiring slice, where the scheduler is the thing that
cares.

The test budget's 400x multiplier gains its derivation: the accurate path spends
one tick_at per PCycle, and the worst single instruction here is roughly
DDIV 69 + an I-cache fill 46 + a D-cache fill 40 + the 5-stage fill and
epilogue -- under 200. Doubling leaves room for a stall nobody remembers to
account for here.

Three prose fixes in docs/cpu.md, one of them applying this repo's own
comma-before-because convention -- the one I cited to REJECT a suggestion on
#230 -- back at two sentences where the main clause is not negated.

Re-verified after the refactor: the delay-slot mutation still fails three tests.

Gates: fmt, clippy (workspace + feature), test --workspace, the fast-exec gate,
rustdoc, no_std, en-US, markdownlint.

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

Copy link
Copy Markdown
Owner Author

Adjudication of the Antigravity (Gemini) review — no blocking issues raised; three suggestions and one nitpick, answered in 53db8e2.

Suggestion 1 — redundant next_pc write in the ERET path. Adopted, and the fix is better than removing one assignment: Flow::Redirected now carries no target at all. Everything that produces that variant has already written next_pc — that is what redirecting is — so the payload was a second copy of the same fact and a place for the two to disagree. The variant now says only "control has moved; do not run a delay slot", which is the whole of what the caller needs.

Suggestion 2 — bound the delay-slot chain. Adopted, with the reasoning made explicit because the framing matters:

The loop terminates by construction — every iteration retires an instruction — so EXPECTED_CHAIN is not a safety net against an infinite loop. What it bounds is how long one call runs without returning to the scheduler, which is a scheduling concern rather than a correctness one: a chain of jumps each in the previous one's delay slot starves the RCP for its duration.

So it is a debug_assert, and a release build continues past it rather than truncating — stopping mid-chain would execute the wrong instructions, and a wrong answer is worse than a long call. The real cap belongs in the wiring slice, where the scheduler is the thing that cares; noted in the constant's doc comment so it is a scheduled decision rather than a forgotten one.

Suggestion 3 — derive the 400 budget multiplier. Adopted. The accurate path spends one tick_at per PCycle, and the worst single instruction in these programs is roughly DDIV 69 + an I-cache fill 46 + a D-cache fill 40 + the five-stage fill and epilogue — under 200. Doubling that leaves room for a stall nobody remembers to account for at this line. A bare multiplier ages badly; the derivation does not.

Nitpick — drop the similar_names comment if clippy rules change crate-wide. Rejected. Relaxing a workspace lint to accommodate one rename is disproportionate, and similar_names has caught real confusion in this codebase before. The comment stays because source/target diverges from the rs_val/rt_val naming the rest of the crate uses, and an unexplained local deviation is exactly what a reader stops on.

@github-actions

Copy link
Copy Markdown

Antigravity review (Gemini via Ultra)

This PR adds an instruction-granular execution path (fast-exec feature, default-off) under ADR 0013 that executes instructions to completion without 5-stage pipeline latches while reusing pipeline semantics and stall costs, along with CPU-level differential tests.

Blocking issues

None found.

Suggestions

  • crates/rustyn64-cpu/src/pipeline/fastexec.rs#L284-L296: Op::Dmtc2 is missing from the COP2 latch match block (Mtc2, Mfc2, Dmfc2). If Dmtc2 is emitted by decode, 64-bit GPR writes to COP2 will silently fail to update self.cop2_latch in fast-exec mode while Dmfc2 reads from it.
  • crates/rustyn64-cpu/src/pipeline/fastexec.rs#L189-L194: EXPECTED_CHAIN is only enforced via debug_assert!. In release builds, an infinite loop of branches in delay slots (e.g., self-referencing branch instructions) will hang host execution inside step_instruction indefinitely rather than returning control to the scheduler.

Nitpicks

  • crates/rustyn64-cpu/tests/fast_exec_differential.rs#L219: project() allocates new Vec instances for cop0 and ram on every instruction retirement boundary; reusing buffers or comparing directly would reduce allocation overhead during long test runs.

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

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

Caution

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

⚠️ Outside diff range comments (1)
crates/rustyn64-cpu/src/pipeline/fastexec.rs (1)

132-205: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Bound branch-delay chains before exposing instruction stepping.

A self-referential branch-delay chain can execute indefinitely inside one Pipeline::step_instruction call. The debug-only assertion does not provide a release-build bound, and the differential-test budget cannot run until that call returns.

  • crates/rustyn64-cpu/src/pipeline/fastexec.rs#L132-L205: retain resumable chain state or return at a caller-visible work quantum instead of executing unbounded branch-delay links.
  • crates/rustyn64-cpu/tests/fast_exec_differential.rs#L253-L280: add a self-referential branch-delay regression after the implementation can return control to the test harness.
🤖 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-cpu/src/pipeline/fastexec.rs` around lines 132 - 205, The
fast-execution loop in Pipeline::step_instruction can remain inside a
self-referential branch-delay chain indefinitely; add a caller-visible work
quantum by preserving resumable chain state or returning after a bounded number
of links, rather than relying only on the debug_assert! at
crates/rustyn64-cpu/src/pipeline/fastexec.rs:132-205. Add a regression covering
a self-referential branch-delay chain at
crates/rustyn64-cpu/tests/fast_exec_differential.rs:253-280, verifying control
returns to the test harness and execution can resume correctly.
🤖 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.

Outside diff comments:
In `@crates/rustyn64-cpu/src/pipeline/fastexec.rs`:
- Around line 132-205: The fast-execution loop in Pipeline::step_instruction can
remain inside a self-referential branch-delay chain indefinitely; add a
caller-visible work quantum by preserving resumable chain state or returning
after a bounded number of links, rather than relying only on the debug_assert!
at crates/rustyn64-cpu/src/pipeline/fastexec.rs:132-205. Add a regression
covering a self-referential branch-delay chain at
crates/rustyn64-cpu/tests/fast_exec_differential.rs:253-280, verifying control
returns to the test harness and execution can resume correctly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 69525817-d2af-44bc-849a-8d32d373c5ce

📥 Commits

Reviewing files that changed from the base of the PR and between 038312f and 53db8e2.

📒 Files selected for processing (3)
  • crates/rustyn64-cpu/src/pipeline/fastexec.rs
  • crates/rustyn64-cpu/tests/fast_exec_differential.rs
  • docs/cpu.md

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