Skip to content
Open
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
5 changes: 5 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ ci-test = "ci-nextest --profile ci"
ci-test-default = "nextest run --workspace --profile ci"
# The nightly instruction-driven clock tests, which need their own build.
ci-clock = "ci-nextest --profile ci-clock --features ci_clock,idle-pause"
# The wgpu raster unit, scan-out and presenter on a GPU, or on lavapipe where
# there is none. Run with VPRO_GPU=1 so the VPro test boards draw there; the
# ci-gpu profile picks the tests. --bins builds iris itself with the feature,
# which no other check does.
ci-gpu = "nextest run -p iris --lib --bins --features wgpu --profile ci-gpu"
# The loom concurrency models, built under --cfg loom. A flag, not RUSTFLAGS, so
# no environment is needed; cargo keeps the loom build beside the normal one in
# the same target directory.
Expand Down
16 changes: 16 additions & 0 deletions .config/nextest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ default-filter = 'test(/::ci_clock_/)'
fail-fast = false
slow-timeout = { period = "60s", terminate-after = 20 }

# The tests `cargo ci-gpu` runs on a GPU (.github/workflows/wgpu.yml): the
# shared wgpu layer and presenter, and the VPro display through the GPU
# scan-out and the offscreen wgpu renderer. The VPro golden scenes stay out:
# two of them differ from their goldens on the GPU unit by one colour step.
[profile.ci-gpu]
default-filter = 'test(/^wgpu::|^dev::odyssey::tests::display::/)'
# Each test opens its own device. lavapipe refuses some ("parent device is
# lost") when dozens open at once, as on a many-core host; four, a hosted
# runner's count, pass.
test-threads = 4
fail-fast = false
slow-timeout = { period = "60s", terminate-after = 20 }

[profile.ci-gpu.junit]
path = "junit.xml"

# The threaded device tests that the nightly ThreadSanitizer run covers
# (.github/workflows/tsan.yml). Add a test to a device module with one of these
# names and it joins the run; the workflow lists nothing.
Expand Down
74 changes: 74 additions & 0 deletions .github/workflows/wgpu.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: wgpu

# The wgpu path on lavapipe, Mesa's software Vulkan driver: hosted runners
# have no GPU. It runs the shared wgpu layer, the presenter drawing into an
# offscreen texture, and the VPro display composing on the GPU scan-out and
# presenting through the offscreen wgpu renderer, the window's path. The
# display tests assert the display's frame counts (composed, left unchanged,
# presented from the GPU), so a display that composes every frame again, or
# stops presenting from the GPU, fails here without a window or a screenshot.
# The rest of CI builds no wgpu feature, so pull requests run this only when
# they touch the code it covers.
on:
push:
branches: [ "main" ]
paths: &paths
- 'src/wgpu/**'
- 'src/dev/odyssey/**'
- 'src/ui.rs'
- 'src/main.rs'
- 'src/gfx_display.rs'
- 'Cargo.toml'
- 'Cargo.lock'
- '.cargo/config.toml'
- '.config/nextest.toml'
- '.github/workflows/wgpu.yml'
pull_request:
branches: [ "main" ]
types: [ opened, synchronize, reopened, ready_for_review ]
paths: *paths
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

env:
CARGO_TERM_COLOR: always
# libchdman-rs fetches a prebuilt archive at build time; compile from source
# when that download fails.
LIBCHDMAN_PREBUILT_FALLBACK: "1"

jobs:
lavapipe:
name: wgpu on lavapipe
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Install pinned toolchain
run: rustup show
# mesa-vulkan-drivers carries lavapipe; with no other driver installed it
# is the adapter wgpu opens.
- name: Install dependencies
run: sudo apt-get update && sudo apt-get install -y pkg-config libasound2-dev mesa-vulkan-drivers libvulkan1
# The wgpu feature changes the iris crate's build, so this job has its own
# key. Pull requests restore main's cache but never save one.
- uses: Swatinem/rust-cache@v2
with:
key: wgpu
save-if: ${{ github.ref == 'refs/heads/main' }}
- uses: taiki-e/install-action@cfdb7ed4ccc7c7dce37759eae89dc5674b469b39 # v2.90.3
with:
tool: nextest
# VPRO_GPU=1 puts the VPro test boards on the GPU raster unit. A board
# that cannot open the adapter fails its test, so a run with no usable
# lavapipe goes red even though the wgpu layer's own tests skip.
- name: Run the wgpu tests
run: VPRO_GPU=1 WGPU_BACKEND=vulkan cargo ci-gpu
- uses: mikepenz/action-junit-report@a16be3a69023e9bd7e748ede768b3282ea7b30ce # v6.7.1
if: ${{ !cancelled() }}
with:
report_paths: target/nextest/ci-gpu/junit.xml
annotate_only: true
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ hashes are given where a change is easiest to understand by reading the commit.

## November 2026

### Display counters

- `vpro frames [reset]` reports the VPro display thread's frames: how many it
composed, how many it skipped because nothing on screen changed, how many it
presented and from where, and how long a frame took.
- In `ci` mode with no window, `renderer = "wgpu"` now presents each frame into
an offscreen texture the way the window would, so the windowed display path
runs and can be measured on a machine with no display.

### Serial logs append on every machine

- New `serial_log_overwrite` (`--serial-log-overwrite`) starts `serial_log`, `serial_log_a` and `serial_log_b` over each run instead of adding to them.
Expand Down
2 changes: 1 addition & 1 deletion HELP.md
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ standalone `iris` window if you need them.

The window is freely resizable. At 1× and 2× scale the display is rendered with nearest-neighbour pixel-perfect sampling; at all other sizes trilinear filtering with mipmaps is used. `lock_aspect_ratio = false` in `iris.toml` allows free resizing with letterboxing. When the guest changes video mode (PROM, then IRIX during boot) the window snaps to the new resolution times the configured `scale` / `--2x`.

`renderer = "wgpu"` (or `--renderer wgpu`, in a build with the `wgpu` feature) draws the window through wgpu instead of OpenGL, with the same scaling rules. With a VPro or IMPACT whose raster unit is on the GPU (`raster = "wgpu"`) the window shares that GPU, and each frame goes from the board's memory to the screen without passing through main memory; screenshots and VNC still read frames back when they ask for them. Newport's display (Indy, Indigo2 XL) is composed on the GPU as well. Its legacy `IRIS_REX3_GPU` prototype shares the device in a `rex-gpu` build but still synchronizes through the CPU framebuffer. A separate GPU-resident Newport path is internal to tests and cannot be selected; Newport rejects `raster = "wgpu"` until M3 (complete REX3 command coverage). If wgpu cannot open the window, iris says so and draws with OpenGL.
`renderer = "wgpu"` (or `--renderer wgpu`, in a build with the `wgpu` feature) draws the window through wgpu instead of OpenGL, with the same scaling rules. With a VPro or IMPACT whose raster unit is on the GPU (`raster = "wgpu"`) the window shares that GPU, and each frame goes from the board's memory to the screen without passing through main memory; screenshots and VNC still read frames back when they ask for them. Newport's display (Indy, Indigo2 XL) is composed on the GPU as well. Its legacy `IRIS_REX3_GPU` prototype shares the device in a `rex-gpu` build but still synchronizes through the CPU framebuffer. A separate GPU-resident Newport path is internal to tests and cannot be selected; Newport rejects `raster = "wgpu"` until M3 (complete REX3 command coverage). If wgpu cannot open the window, iris says so and draws with OpenGL. In `ci` mode with no window (`ci_display` off), `renderer = "wgpu"` presents each frame into an offscreen texture the same way, so the window's path runs and can be measured (`vpro frames`) on a machine with no display; nothing is read back.

## VNC

Expand Down
1 change: 1 addition & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ That single-file run is not corpus coverage.
| `rust.yml` | every pull request and push to `main` | `cargo nextest run` on the workspace, a build of the release feature set, the interpreter's hot paths |
| `loom.yml` | pull requests that change a file holding loom models (`cfg(loom)`), nightly on `main` at 04:30 UTC, and by hand from Actions | the `loom_tests` concurrency models, e.g. `intx::loom_tests`; locally `cargo loom` |
| `tsan.yml` | nightly on `main` at 03:30 UTC, and by hand from Actions; gates nothing | the threaded device tests under ThreadSanitizer, to catch data races; the `tsan` nextest profile selects them; locally `RUSTFLAGS="-Zsanitizer=thread" CARGO_PROFILE_RELEASE_PANIC=unwind cargo nextest run -Zbuild-std --target x86_64-unknown-linux-gnu --release --lib --profile tsan` |
| `wgpu.yml` | pull requests and pushes to `main` that touch `src/wgpu/`, `src/dev/odyssey/`, the window or the test configuration, and by hand from Actions | `cargo ci-gpu` with `VPRO_GPU=1` on lavapipe (Mesa's software Vulkan): the wgpu layer and presenter, and the VPro display through the GPU scan-out and the offscreen wgpu renderer, checked by its frame counts (`vpro frames`) rather than by pictures |
| `suites.yml` | pull requests and pushes that touch the CPU, `cpu-tests/`, `bench/` or the test code | the two bare-metal suites on each engine |
| `nightly.yml` | every day at 05:00 UTC when `main` has moved, and by hand from Actions | the `build.yml` packages and IRIX boot, then publishes; a job beside them runs the default-feature tests and the `ci_clock` tests (`cargo ci-clock`) the pull-request run leaves out. It gates no publish |
| `build.yml` | nightly and release | packages for every OS, then boots every IRIX image to the login prompt on each CLI build |
Expand Down
1 change: 1 addition & 0 deletions debug.md
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ guest. `y = 0` is the top display line throughout.
| `vpro unimp` | Offsets and command words the board reported as not modelled |
| `vpro refusals [reset]` | Bus accesses answered busy, by reason, offset and width, with the longest time one access was kept retrying |
| `vpro gpu [trace on\|off]` | With `[graphics] raster = "wgpu"`: the GPU raster unit's counters (records and batches, how the batches were submitted, bytes uploaded and read back, waits, readbacks and copies the copy kernel took) and the GPU scan-out's (frames composed, bytes read back; with `renderer = "wgpu"` frames are presented from the GPU and only screenshots, frame requests and VNC read them back); `trace on` counts readbacks by the board function that caused them from then on, shown under the counters. The same lines appear in `vpro status`. On the GPU unit the commands that read board memory (`pix`, `raw`, `peek`, `shot`, `fbdump`, `hash`, `mem` and the state commands) first read the unit's memory back into the board's copy, when the board can be quiesced |
| `vpro frames [reset]` | The display thread's frames: how many ran, their average and longest time, how many were composed again (and of those, how many only to settle the frame after a store), how many were left as they were because nothing the scan-out reads had changed, and how many were presented (and of those, from a buffer on the GPU). An idle screen composes nothing and presents only every sixth frame. The frame time is the display thread's whole frame; with the offscreen wgpu renderer in `ci` mode it includes waiting for the GPU to finish the frame before last. `reset` zeroes them. |
| `vpro trace <file> [fifo,draw,cpu,dma\|all]` / `vpro trace flush\|off` / `vpro trace` | Annotated trace (`gputrace` builds; also `IRIS_ODSY_TRACE=<file>` from power-on): `FIFO` lines are command words as the parser completes them, named, with their operands as words and as floats where the word carries floats, plus copy rects, pattern and matrix loads, image boxes and bursts, queries and unknown words; `DRAW` lines each assembled primitive sent to the raster unit (`draw TRIANGLES n=3 ctx=1 textured depth queued cost 96`) and each clear; `CPU` lines every bus access that is not a command store, repeats collapsed; `DMA` lines the page-table descriptors. `flush` writes the buffer out (it is written at most twice a second otherwise); alone, the trace's state |
| `vpro rec <file>` / `vpro rec mark\|off` / `vpro rec` | Replayable recording (record.rs; also `IRIS_ODSY_REC=<file>` from power-on): every bus store and load and the DMA reads, gzip. A start, a `mark` and `off` wait for the board to go idle and write the memory hash (`vpro hash`) into the file, so a replay can check board memory at those points; a recording started mid-session cannot reproduce the live memory and says so when replayed, but still replays deterministically. Alone, the recording's state. Replay: `VPRO_REPLAY=<file> cargo test --release --lib odyssey::golden::replay -- --nocapture` (see `src/dev/odyssey/testdata/README.md`) |

Expand Down
24 changes: 24 additions & 0 deletions src/dev/odyssey/debug.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ pub(super) const HELP: &[&str] = &[
"vpro unimp offsets and command words reported as not modelled",
"vpro refusals [reset] bus refusals by reason and register",
"vpro gpu [trace on|off] the GPU raster unit's counters; trace counts readbacks by cause",
"vpro frames [reset] display frames: composed, unchanged, presented, frame time",
"vpro trace <file> [fifo,draw,cpu,dma|all] annotated trace (gputrace builds); alone: its state",
"vpro trace flush|off write the trace out / close it",
"vpro rec [<file>|mark|off] replayable recording: start, mark, stop; alone: its state",
Expand Down Expand Up @@ -280,6 +281,12 @@ impl Odyssey {
self.gpu_run(|g| g.trace_waits(on)).ok_or("the raster unit is the CPU's (gpu off)")?;
format!("gpu readback tracing {}\n", if on { "on" } else { "off" })
}
["frames"] => self.frames_text(),
["frames", "reset"] => {
self.display.reader.lock().counts = Default::default();
self.display.stats.reset();
String::new()
}
["refusals"] => {
let mut v = Vec::new();
self.state.refusals.report(&mut v).map_err(|e| e.to_string())?;
Expand Down Expand Up @@ -318,6 +325,23 @@ impl Odyssey {
Some(out)
}

fn frames_text(&self) -> String {
use std::sync::atomic::Ordering::Relaxed;
let c = self.display.reader.lock().counts;
let st = &self.display.stats;
let frames = st.frames.load(Relaxed);
format!(
"display: {frames} frames, {} us average, {} us longest\n composed {} ({} to settle), unchanged {}\n presented {} ({} from the GPU)\n",
st.busy_ns.load(Relaxed) / frames.max(1) / 1000,
st.worst_ns.load(Relaxed) / 1000,
c.composed,
c.settled,
c.unchanged,
st.presented.load(Relaxed) + st.presented_gpu.load(Relaxed),
st.presented_gpu.load(Relaxed),
)
}

fn status_text(&self) -> Result<String, String> {
let mut out = String::new();
let fifo = &self.state.fifo;
Expand Down
43 changes: 42 additions & 1 deletion src/dev/odyssey/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use super::consts::{
};
use super::fifo::display_fifo_pending;
use super::reader::{DisplayRegs, DisplayShared, ScanoutReader};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};

use parking_lot::Mutex;
Expand Down Expand Up @@ -47,6 +47,37 @@ pub(super) struct Display {
/// Consumers (VNC) that read `screen.rgba` every frame
/// (`GfxDisplay::set_capture`): while any does, frames are read back.
capture: AtomicU32,
/// What the display thread did, for the monitor (`vpro frames`).
pub stats: DisplayStats,
}

/// Frames the display thread ran and presented, and the time they took.
/// Relaxed counts: only the display thread adds to them, and the monitor
/// resets them (`vpro frames reset`).
#[derive(Default)]
pub(super) struct DisplayStats {
/// Frames presented from `screen`, and from a buffer on the GPU.
pub presented: AtomicU64,
pub presented_gpu: AtomicU64,
/// Frames run, and their time in all and the longest, in nanoseconds.
pub frames: AtomicU64,
pub busy_ns: AtomicU64,
pub worst_ns: AtomicU64,
}

impl DisplayStats {
/// A frame that took `ns`.
pub(super) fn frame_took(&self, ns: u64) {
self.frames.fetch_add(1, Ordering::Relaxed);
self.busy_ns.fetch_add(ns, Ordering::Relaxed);
self.worst_ns.fetch_max(ns, Ordering::Relaxed);
}

pub(super) fn reset(&self) {
for c in [&self.presented, &self.presented_gpu, &self.frames, &self.busy_ns, &self.worst_ns] {
c.store(0, Ordering::Relaxed);
}
}
}

struct Local {
Expand All @@ -72,6 +103,7 @@ impl Display {
status,
shared: OnceLock::new(),
capture: AtomicU32::new(0),
stats: DisplayStats::default(),
}
}

Expand Down Expand Up @@ -111,6 +143,7 @@ impl Display {
local.size = (w, h);
}
let stats = self.status.snapshot(std::time::Instant::now(), display_fifo_pending());
self.stats.presented_gpu.fetch_add(1, Ordering::Relaxed);
renderer.present_gpu_frame(frame, width, height, &mut screen, &mut local.overlay, &mut local.status, &mut local.texture, &stats);
}
}
Expand Down Expand Up @@ -166,6 +199,7 @@ impl Display {
}
}
*self.shown.lock() = (width, height);
self.stats.presented.fetch_add(1, Ordering::Relaxed);
screen.frame_seq = screen.frame_seq.wrapping_add(1);
// RCtrl+PrintScreen (ui.rs): save the screen as it is presented.
if self.screenshot.swap(false, Ordering::AcqRel) {
Expand Down Expand Up @@ -243,6 +277,13 @@ impl Board {
pub fn scanout_update(&mut self) -> bool {
self.test_reader().update()
}

/// As `scanout_update` on the GPU scan-out: None when it composes
/// nothing, else whether the frame was composed again.
#[cfg(feature = "wgpu")]
pub fn scanout_update_on_gpu(&mut self) -> Option<bool> {
self.test_reader().update_on_gpu().map(|(_, _, _, changed)| changed)
}
}

/// What one main drawable id shows, decided once per update instead of per
Expand Down
Loading
Loading