Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,16 @@ jobs:
# deliberately not, so they are graded by different predicates.
- run: cargo test -p rustyn64-cpu --features fast-exec
- run: cargo clippy -p rustyn64-cpu --all-targets --features fast-exec -- -D warnings
# And the core + frontend wiring. `run_frame` picks one of THREE entry
# points by `cfg`, and an arm that stops compiling is invisible to every job
# that does not enable its feature -- which is how a `#[cfg]` arm rots.
- run: cargo test -p rustyn64-core --features fast-exec
- run: cargo clippy -p rustyn64-core --all-targets --features fast-exec -- -D warnings
- run: cargo clippy -p rustyn64-frontend --all-targets --features fast-exec -- -D warnings
# Both features together is a DEFINED configuration (ADR 0013 section 1
# settles the precedence), so it is built rather than left to chance.
- run: cargo test -p rustyn64-frontend --features fast-exec
- run: cargo check -p rustyn64-frontend --features fast-exec,fast-scheduler
Comment thread
coderabbitai[bot] marked this conversation as resolved.

rustdoc:
name: rustdoc (-D warnings)
Expand Down Expand Up @@ -207,6 +217,7 @@ jobs:
# child module of `pipeline` and uses nothing beyond what the accurate path
# already does, but "uses nothing" is a claim, and this is what checks it.
- run: cargo build -p rustyn64-cpu --target thumbv7em-none-eabihf --no-default-features --features fast-exec
- run: cargo build -p rustyn64-core --target thumbv7em-none-eabihf --no-default-features --features fast-exec

no-commercial-roms:
name: no commercial ROMs
Expand Down
5 changes: 5 additions & 0 deletions crates/rustyn64-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ std = []
# not needed yet; that becomes due only when the fast path acquires state of its
# own.
fast-scheduler = []
# ADR 0013: the instruction-granular execution mode. INDEPENDENT of
# `fast-scheduler` — neither implies the other, because one is tick-identical and
# one deliberately is not, so they are graded by different predicates (ADR 0013
# §1). Where both are enabled, `fast-exec`'s scheduler is the one that runs.
fast-exec = ["rustyn64-cpu/fast-exec"]

[dependencies]
serde = { version = "1", default-features = false, features = ["derive", "alloc"] }
Expand Down
54 changes: 35 additions & 19 deletions crates/rustyn64-core/src/fastpath.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,46 +173,59 @@ impl BailOutSet {
/// *"the fast path never engaged"* and *"no bail-out boundary was reached"* — and
/// both would otherwise look exactly like success.
///
/// `blocks` is the engagement half: a fast path that quietly deferred everything to
/// the accurate scheduler would agree with it perfectly and prove nothing, which is
/// literally what #224 shipped on purpose while the gate was being built. It is a
/// count and not a flag because "engaged once over a whole suite" and "engaged
/// throughout" are worth telling apart in a failure message.
/// `work_units` is the engagement half: a fast path that quietly deferred
/// everything to the accurate scheduler would agree with it perfectly and prove
/// nothing, which is literally what #224 shipped on purpose while the gate was
/// being built. It is a count and not a flag because "engaged once over a whole
/// suite" and "engaged throughout" are worth telling apart in a failure message.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[must_use = "the run report is ADR 0012's completion witness; a caller that drops \
it should say so explicitly with `let _ =`"]
pub struct FastRunReport {
/// Whole edge periods the block executor ran itself.
pub blocks: u64,
/// How much work the fast path did itself — **in a unit the mode defines**.
///
/// | mode | one unit is |
/// | --- | --- |
/// | `fast-scheduler` (`System::run_until_fast`) | one whole edge period (`EDGE_PERIOD` ticks) |
/// | `fast-exec` (`System::run_until_exec`) | one CPU instruction |
///
/// **Do not compare it across modes**, and do not read it as a metric. It
/// answers ADR 0012 §2's question — *did the fast path engage* — for which any
/// positive count means the same thing either way, and a comparison of
/// magnitudes between two different units means nothing at all. Named
/// `work_units` rather than `blocks` precisely because "blocks" reads as a
/// single unit and it is not one; raised in review.
pub work_units: u64,
/// Reasons the accurate scheduler was handed a stretch.
pub bailed: BailOutSet,
}

impl FastRunReport {
/// Did the block executor run at all?
/// Did the fast path do any work itself?
#[must_use]
pub const fn engaged(self) -> bool {
self.blocks > 0
self.work_units > 0
}

/// Accumulate another call's report: blocks **add**, reasons **union**.
/// Accumulate another call's report: work units **add**, reasons **union**.
///
/// The rule lives here rather than at each call site because the two halves
/// fail differently. A caller that adds the counts and forgets the union still
/// compiles, still reports engagement, and **under-reports coverage** — which
/// is the one thing ADR 0012 §2's witness exists to catch, so a bug in the
/// accumulation would disable the check rather than trip it.
///
/// `saturating_add` because a block count is a diagnostic: a wrapped total
/// could read as zero and turn "engaged constantly" into "never engaged". It
/// cannot be reached in practice — `u64::MAX` blocks is 6 x 2^64 master
/// ticks — but saturating is the failure that stays interpretable.
/// `saturating_add` because the count is a diagnostic: a wrapped total could
/// read as zero and turn "engaged constantly" into "never engaged". It cannot
/// be reached in practice — `u64::MAX` units is more emulated time than the
/// tick counter itself can express — but saturating is the failure that stays
/// interpretable.
///
/// (No `#[must_use]` here: the returned type already carries one, and clippy's
/// `double_must_use` rejects a second without a distinct message.)
pub const fn merge(self, other: Self) -> Self {
Self {
blocks: self.blocks.saturating_add(other.blocks),
work_units: self.work_units.saturating_add(other.work_units),
bailed: self.bailed.union(other.bailed),
}
}
Expand Down Expand Up @@ -268,19 +281,22 @@ mod tests {
}
}

/// A report with no blocks has not engaged, whatever else it says.
/// A report with no work units has not engaged, whatever else it says.
#[test]
fn engagement_is_about_blocks_not_bailouts() {
fn engagement_is_about_work_units_not_bailouts() {
let mut bailed = BailOutSet::new();
bailed.insert(BailOut::PartialPeriodTail);
let deferred = FastRunReport { blocks: 0, bailed };
let deferred = FastRunReport {
work_units: 0,
bailed,
};
assert!(
!deferred.engaged(),
"a run that executed no block has not engaged, even having handed off"
);
assert!(
FastRunReport {
blocks: 1,
work_units: 1,
..deferred
}
.engaged()
Expand Down
14 changes: 9 additions & 5 deletions crates/rustyn64-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,15 @@ pub mod boot;
pub mod bus;
/// The fast path's hand-off enumeration and run report (ADR 0012 §2).
///
/// Present only with the default-off `fast-scheduler` feature, so the default
/// build gains nothing at all — ADR 0011 §1 by construction, the same way
/// [`scheduler::System::run_until_fast`] is a separate entry point rather than a
/// branch.
#[cfg(feature = "fast-scheduler")]
/// Present only with a fast path compiled in — `fast-scheduler` or `fast-exec` —
/// so a default build gains nothing at all. ADR 0011 §1 by construction, the same
/// way [`scheduler::System::run_until_fast`] is a separate entry point rather than
/// a branch.
///
/// Shared by both modes because [`fastpath::FastRunReport`] answers the same
/// question for each — did the fast path engage, and where did it hand back — even
/// though the two are graded by different predicates (ADR 0013 §1).
#[cfg(any(feature = "fast-scheduler", feature = "fast-exec"))]
pub mod fastpath;
pub mod scheduler;
pub mod vi;
Expand Down
127 changes: 126 additions & 1 deletion crates/rustyn64-core/src/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,132 @@ impl System {
}
self.run_until(target);

FastRunReport { blocks, bailed }
FastRunReport {
work_units: blocks,
bailed,
}
}

/// The **instruction-granular** counterpart to [`System::run_until`]
/// (ADR 0013), behind the default-off `fast-exec` feature.
///
/// # How time advances here
///
/// The accurate loop walks edge to edge and steps whichever domains are due.
/// This one lets the **CPU set the pace**: it executes one instruction, is told
/// what that cost in `PCycles`, advances `master_ticks` by that many CPU
/// periods, and runs the RCP over every one of its edges in the span that just
/// elapsed. The CPU therefore no longer lands on a derived edge at all — which
/// is precisely the relaxation ADR 0013 §2 authorizes and ADR 0011 §5 excludes.
///
/// **ADR 0006 still holds.** `master_ticks` remains the only counter that is
/// ever incremented, and every other position — the RCP's edges, COP0 `Count` —
/// is still derived from it. What changed is *how far* it moves per step, not
/// who owns it.
///
/// # It may land PAST `target`, and that is the divergence, not a defect
///
/// A cost is only known after the instruction has run, so the last instruction
/// of a call can carry `master_ticks` beyond `target` — by at most one
/// instruction's cost. Nothing drifts: the next call's `target` is absolute, so
/// an overshoot simply means less work next time. This is the timing divergence
/// ADR 0013 §4 requires to be *measured and bounded* rather than eliminated,
/// and it is the reason this returns a report rather than nothing.
///
/// # A halted CPU advances on RCP edges
///
/// A failed real-PIF boot checksum freezes the CPU via NMI while the RCP keeps
/// running (`PIF-NUS.md`). With no instruction to time the advance with, this
/// steps to the next RCP edge instead. It is deliberately **not** a bail-out to
/// the accurate scheduler: forcing an ADR 0012 bail-out reason here would need
/// a fixture that can reach a checksum failure, and ADR 0011 §6 only sanctions
/// a test-only seam where a boundary *genuinely* cannot be reached — which is
/// not the case when the fast path can simply handle it.
///
/// `fast-exec` consequently adds **no new [`BailOut`](crate::fastpath::BailOut)
/// variant**. Saying so plainly is better than inventing an exit to justify the
/// machinery; the enumeration exists so that a *real* one cannot be added
/// silently.
#[cfg(feature = "fast-exec")]
pub fn run_until_exec(&mut self, target: u64) -> crate::fastpath::FastRunReport {
use crate::fastpath::FastRunReport;

let mut report = FastRunReport::default();
while self.master_ticks < target {
if self.bus.boot_nmi_halt() {
let next = Self::next_edge_after(self.master_ticks, self.phases.rcp, RCP_DIVIDER);
if next > target {
break;
}
self.master_ticks = next;
self.step_rcp();
continue;
}

// `count_ticks` is derived from `master_ticks` and sampled BEFORE the
// instruction runs, which is the same instant the accurate path samples
// it at the CPU edge that would issue this instruction.
let count_now = self.count_ticks();
let cost = self.cpu.step_instruction_at(&mut self.bus, count_now);
report.work_units = report.work_units.saturating_add(1);

// One `PCycle` is `CPU_DIVIDER` master ticks (ADR 0006).
//
// **`checked`, not `saturating`.** Saturating was the first version and
// it is a real hazard here rather than a theoretical one: it can put
// `end` at `u64::MAX` *without the machine ever having run there*, and
// `next_edge_after` then evaluates `tick + 1` at `u64::MAX` — a panic in
// debug, and in release a wrap to a low tick that keeps `rcp <= end`
// true forever. That is the difference between this and the accurate
// loop, whose top-of-range is unreachable because reaching it means
// emulating three thousand years (the note in the fast-scheduler gate).
// Raised in review.
//
// Overflowing means the timeline is exhausted, so the run ends; the
// landing below carries `master_ticks` to `target` if it is not already
// past it.
// **This loop's termination depends on `cost > 0`, and nothing here
// enforced it.** `Pipeline::step_instruction` charges one `PCycle` to
// issue before adding anything, so zero is not reachable today — but
// that is an invariant held in another crate, and a reader of *this*
// loop cannot see it. A zero would leave `end == master_ticks`, step no
// RCP edges, and spin forever. Raised in review as blocking, correctly:
// the hazard is that the guarantee is remote, not that it is absent.
//
// The `debug_assert` is the real check; the `max(1)` is a floor so a
// release build makes progress rather than hanging. It is deliberately
// NOT a fix — a zero cost would be a defect either way — but running
// one `PCycle` fast is a far better way to report one than a hang.
debug_assert!(
cost > 0,
"an instruction cost 0 PCycles, which cannot happen"
);
let Some(end) = u64::from(cost.max(1))
.checked_mul(CPU_DIVIDER)
.and_then(|span| self.master_ticks.checked_add(span))
else {
break;
};

// The RCP catches up over the span the instruction consumed, on its own
// edges. CPU-before-RCP still holds: the instruction has already run.
let mut rcp = Self::next_edge_after(self.master_ticks, self.phases.rcp, RCP_DIVIDER);
while rcp <= end {
self.master_ticks = rcp;
self.step_rcp();
// `end` is a real tick because it came from `checked_add` above, so
// this cannot be asked for an edge past `u64::MAX`: the loop
// condition fails at or before it.
rcp = Self::next_edge_after(rcp, self.phases.rcp, RCP_DIVIDER);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
self.master_ticks = end;
}
// Only reachable through the halted branch's `break`; an executing CPU has
// already carried `master_ticks` to or past `target`.
if self.master_ticks < target {
self.master_ticks = target;
}
report
}

/// One RCP step: the RSP microcode unit, then the RDP rasterizer, then the
Expand Down
Loading