From 498a5c01de855c73e5ad338a4c3bb05db9487f18 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 21 Aug 2026 10:40:04 -0400 Subject: [PATCH 01/15] testdev: a host clock and a retired-instruction counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Benchmarking the emulator needs a time base the emulator cannot distort. CP0 Count is a *virtual* clock — mips_core.rs materializes it from a wall-clock anchor at a `count_hz` inferred from the guest's own Compare writes — so timing a workload with it measures the timer model as much as the workload. Adds two 64-bit registers to the test device, plus a capability word: 0x10/0x14 HOST_NS host monotonic nanoseconds 0x18/0x1C ICOUNT guest instructions retired (MipsCore::hot.cycles) 0x20 CAPS feature bits Reading the LO half latches the whole 64-bit value and HI returns the high word of that same latch, so a guest doing LO-then-HI cannot see a torn count. ICOUNT is the interesting one: hot.cycles advances once per retired instruction in both the interpreter and jitv2 (emit_increment_cycles), so "guest instructions per host second" is directly comparable between engines — which is the single most useful number a benchmark can produce here. The register window widens from 16 to 64 bytes. It used to repeat every 16, and the signature test asserted that; it now repeats every REG_WINDOW. Consequence worth knowing for any guest built against the new header: on an emulator that predates these registers, 0x20 aliases back onto SIGNATURE — whose low bit is set — so a naive `caps & CAP_TIMEBASE` says yes and every timing comes back from a frozen clock. A guest must probe, and must never *write* an unprobed offset (0x1C aliases onto EXIT). Also lists r5k/jitv2/mips4/opcodefusion/idle-pause in print_build_features. They were missing, so an R5000 build announced itself as "build features: tlbvmap" and a result recorded from it was indistinguishable from an R4400 one — the same confusion cpu-tests/run/matrix.sh already guards against. Co-Authored-By: Claude Opus 5 (1M context) --- cpu-tests/harness/iris.h | 12 ++++ src/main.rs | 14 ++++ src/testdev.rs | 149 +++++++++++++++++++++++++++++++++++---- 3 files changed, 161 insertions(+), 14 deletions(-) diff --git a/cpu-tests/harness/iris.h b/cpu-tests/harness/iris.h index b610c0e..d2065fc 100644 --- a/cpu-tests/harness/iris.h +++ b/cpu-tests/harness/iris.h @@ -20,6 +20,18 @@ #define TESTDEV_PUTC (TESTDEV_BASE + 0x04) #define TESTDEV_DUMP (TESTDEV_BASE + 0x08) #define TESTDEV_EXIT (TESTDEV_BASE + 0x0C) +/* Host monotonic nanoseconds and retired-guest-instruction count. Reading the + * LO half latches the whole 64-bit value; the HI half then reads that same + * latch, so LO-then-HI cannot tear. Present only when TESTDEV_CAPS has + * TESTDEV_CAP_TIMEBASE — older emulator builds decode only 16 bytes here and + * alias these back onto SIGNATURE/PUTC/DUMP/EXIT, so probe before you use it, + * and never *write* an unprobed offset (0x0C aliases EXIT). */ +#define TESTDEV_HOST_NS_LO (TESTDEV_BASE + 0x10) +#define TESTDEV_HOST_NS_HI (TESTDEV_BASE + 0x14) +#define TESTDEV_ICOUNT_LO (TESTDEV_BASE + 0x18) +#define TESTDEV_ICOUNT_HI (TESTDEV_BASE + 0x1C) +#define TESTDEV_CAPS (TESTDEV_BASE + 0x20) +#define TESTDEV_CAP_TIMEBASE 0x00000001u #define TESTDEV_MAGIC 0x49524953u /* 'I','R','I','S' */ /* ── CPU identity (src/mips_core.rs:348-364) ──────────────────────────────── */ diff --git a/src/main.rs b/src/main.rs index b26068a..8abe782 100644 --- a/src/main.rs +++ b/src/main.rs @@ -145,6 +145,20 @@ fn main() { /// the interpreter's idle-park path, so an idle guest spins the host CPU). fn print_build_features() { const FEATURES: &[(&str, bool)] = &[ + // CPU model and execution engine first: these change what the guest + // sees, not just how fast it sees it, and a benchmark result is + // meaningless without them. `r5k` in particular was missing here long + // enough that an R5000 build could report "build features: tlbvmap" + // and be taken for an R4400 — cpu-tests/run/matrix.sh has a whole + // guard against exactly that confusion. + ("r5k", cfg!(feature = "r5k")), + ("r5ksc", cfg!(feature = "r5ksc")), + ("r5ksc_triton", cfg!(feature = "r5ksc_triton")), + ("mips4", cfg!(feature = "mips4")), + ("jitv2", cfg!(feature = "jitv2")), + ("jitv2_opcodefusion", cfg!(feature = "jitv2_opcodefusion")), + ("opcodefusion", cfg!(feature = "opcodefusion")), + ("idle-pause", cfg!(feature = "idle-pause")), ("rex-jit", cfg!(feature = "rex-jit")), ("lightning", cfg!(feature = "lightning")), ("tlbvmap", cfg!(feature = "tlbvmap")), diff --git a/src/testdev.rs b/src/testdev.rs index bdbc025..a116d59 100644 --- a/src/testdev.rs +++ b/src/testdev.rs @@ -39,10 +39,34 @@ pub const REG_SIGNATURE: u32 = 0x00; pub const REG_PUTC: u32 = 0x04; pub const REG_DUMP: u32 = 0x08; pub const REG_EXIT: u32 = 0x0C; +/// Host monotonic nanoseconds since the device was created. Reading LO latches +/// the whole 64-bit value; HI returns the high word of that same latch, so a +/// guest doing LO-then-HI never sees a torn count. Benchmarks need a time base +/// the emulator cannot distort: CP0 Count is a *virtual* clock derived from a +/// calibrated `count_hz` (mips_core.rs), so timing anything with it measures +/// the timer model as much as the workload. +pub const REG_HOST_NS_LO: u32 = 0x10; +pub const REG_HOST_NS_HI: u32 = 0x14; +/// Guest instructions retired (`MipsCore::hot.cycles`), same latch protocol. +/// Advanced once per retired instruction by both the interpreter and jitv2 +/// (`emit_increment_cycles`), so it is directly comparable across engines — +/// which is what makes "guest MIPS" a meaningful per-kernel number. +pub const REG_ICOUNT_LO: u32 = 0x18; +pub const REG_ICOUNT_HI: u32 = 0x1C; +/// Capability bitmask, so a guest built against a newer header can still run +/// on an older emulator: read it, and only use what it advertises. +pub const REG_CAPS: u32 = 0x20; + +/// Registers decode within this many bytes and the window repeats across the +/// whole 64 KB the device claims. Was 16 before the clock/icount registers. +pub const REG_WINDOW: u32 = 0x40; /// Reads back at `REG_SIGNATURE` — "IRIS" in ASCII. pub const SIGNATURE: u32 = 0x4952_4953; +/// `REG_CAPS` bit 0: `REG_HOST_NS_*` and `REG_ICOUNT_*` are present. +pub const CAP_TIMEBASE: u32 = 1 << 0; + /// Where `REG_DUMP` writes go when no path is configured. pub const DEFAULT_DUMP_PATH: &str = "iris-testdev-dump.json"; @@ -59,6 +83,12 @@ pub struct TestDevice { last_tag: AtomicU64, chars: AtomicU64, running: AtomicBool, + /// Origin for `REG_HOST_NS_*`. Only deltas are meaningful to the guest, so + /// where zero lands does not matter — only that it never moves. + epoch: std::time::Instant, + /// Latched 64-bit values, published by a read of the LO half. + host_ns_latch: AtomicU64, + icount_latch: AtomicU64, } /// Raw pointer to the executor's `MipsCore`, valid for the process lifetime. @@ -77,6 +107,9 @@ impl TestDevice { last_tag: AtomicU64::new(0), chars: AtomicU64::new(0), running: AtomicBool::new(false), + epoch: std::time::Instant::now(), + host_ns_latch: AtomicU64::new(0), + icount_latch: AtomicU64::new(0), } } @@ -194,16 +227,50 @@ pub fn dump_json(core: &MipsCore, tag: u32) -> String { s } +impl TestDevice { + /// Nanoseconds since `epoch`, latched so the HI read that follows sees the + /// same sample. + fn latch_host_ns(&self) -> u64 { + let ns = self.epoch.elapsed().as_nanos() as u64; + self.host_ns_latch.store(ns, Ordering::Relaxed); + ns + } + + /// Retired guest instructions, latched the same way. Zero when no core is + /// attached (a guest reading a flat zero learns the counter is unusable + /// rather than getting a plausible-looking wrong number). + fn latch_icount(&self) -> u64 { + let guard = self.core.lock(); + let n = match guard.as_ref() { + // SAFETY: see CorePtr — the CPU thread is the one issuing this load. + Some(CorePtr(ptr)) => unsafe { (**ptr).hot.cycles }, + None => 0, + }; + drop(guard); + self.icount_latch.store(n, Ordering::Relaxed); + n + } + + fn read_reg(&self, off: u32) -> u32 { + match off { + REG_SIGNATURE => SIGNATURE, + REG_HOST_NS_LO => self.latch_host_ns() as u32, + REG_HOST_NS_HI => (self.host_ns_latch.load(Ordering::Relaxed) >> 32) as u32, + REG_ICOUNT_LO => self.latch_icount() as u32, + REG_ICOUNT_HI => (self.icount_latch.load(Ordering::Relaxed) >> 32) as u32, + REG_CAPS => CAP_TIMEBASE, + _ => 0, + } + } +} + impl BusDevice for TestDevice { fn read32(&self, addr: u32) -> BusRead32 { - match (addr - TEST_DEV_BASE) & 0xF { - REG_SIGNATURE => BusRead32::ok(SIGNATURE), - _ => BusRead32::ok(0), - } + BusRead32::ok(self.read_reg((addr - TEST_DEV_BASE) & (REG_WINDOW - 1) & !3)) } fn write32(&self, addr: u32, val: u32) -> u32 { - match (addr - TEST_DEV_BASE) & 0xF { + match (addr - TEST_DEV_BASE) & (REG_WINDOW - 1) & !3 { REG_PUTC => self.putc(val as u8), REG_DUMP => self.dump(val), REG_EXIT => self.exit(val), @@ -213,18 +280,18 @@ impl BusDevice for TestDevice { } fn read8(&self, addr: u32) -> BusRead8 { - // Byte reads of SIGNATURE, big-endian: byte 0 is the high byte. - let off = (addr - TEST_DEV_BASE) & 0xF; - if off < 4 { - return BusRead8::ok((SIGNATURE >> (8 * (3 - off))) as u8); - } - BusRead8::ok(0) + // Big-endian lane select within the containing word: byte 0 is the high + // byte. Reading the low byte of a latching register still latches, since + // the whole word is materialized to pick the lane out of. + let off = (addr - TEST_DEV_BASE) & (REG_WINDOW - 1); + let word = self.read_reg(off & !3); + BusRead8::ok((word >> (8 * (3 - (off & 3)))) as u8) } fn write8(&self, addr: u32, val: u8) -> u32 { // A byte store to any lane of a register acts on that register, so // `sb` to PUTC works without the guest building a whole word. - match (addr - TEST_DEV_BASE) & 0xC { + match (addr - TEST_DEV_BASE) & (REG_WINDOW - 1) & !3 { REG_PUTC => self.putc(val), REG_DUMP => self.dump(val as u32), REG_EXIT => self.exit(val as u32), @@ -263,6 +330,8 @@ impl Resettable for TestDevice { self.dumps.store(0, Ordering::Relaxed); self.last_tag.store(0, Ordering::Relaxed); self.chars.store(0, Ordering::Relaxed); + self.host_ns_latch.store(0, Ordering::Relaxed); + self.icount_latch.store(0, Ordering::Relaxed); } } @@ -292,12 +361,64 @@ mod tests { fn signature_reads_back_word_and_byte_wise() { let d = TestDevice::new("unused"); assert_eq!(d.read32(TEST_DEV_BASE).data, SIGNATURE); - // Repeats every 16 bytes across the decoded window. - assert_eq!(d.read32(TEST_DEV_BASE + 0x10).data, SIGNATURE); + // Repeats every REG_WINDOW bytes across the decoded window. (It was + // every 16 until the clock/icount registers claimed 0x10..0x20.) + assert_eq!(d.read32(TEST_DEV_BASE + REG_WINDOW).data, SIGNATURE); let bytes: Vec = (0..4).map(|i| d.read8(TEST_DEV_BASE + i).data).collect(); assert_eq!(&bytes, b"IRIS", "signature is big-endian ASCII"); } + #[test] + fn caps_advertises_the_timebase() { + let d = TestDevice::new("unused"); + assert_eq!(d.read32(TEST_DEV_BASE + REG_CAPS).data & CAP_TIMEBASE, CAP_TIMEBASE); + } + + #[test] + fn host_ns_latches_so_lo_then_hi_cannot_tear() { + let d = TestDevice::new("unused"); + let lo = d.read32(TEST_DEV_BASE + REG_HOST_NS_LO).data; + let hi = d.read32(TEST_DEV_BASE + REG_HOST_NS_HI).data; + let first = ((hi as u64) << 32) | lo as u64; + // HI on its own never re-samples: read it again and it is the same half + // of the same latch, however much time has passed in between. + assert_eq!(d.read32(TEST_DEV_BASE + REG_HOST_NS_HI).data, hi); + + // A fresh LO read advances (the clock is monotonic and this is not + // instantaneous, but do not assume it ticked — assert non-regression). + let lo2 = d.read32(TEST_DEV_BASE + REG_HOST_NS_LO).data; + let hi2 = d.read32(TEST_DEV_BASE + REG_HOST_NS_HI).data; + let second = ((hi2 as u64) << 32) | lo2 as u64; + assert!(second >= first, "host clock went backwards: {} -> {}", first, second); + } + + #[test] + fn icount_reports_retired_instructions_and_zero_with_no_core() { + let d = TestDevice::new("unused"); + // No core attached: a flat zero, not a plausible-looking wrong number. + assert_eq!(d.read32(TEST_DEV_BASE + REG_ICOUNT_LO).data, 0); + assert_eq!(d.read32(TEST_DEV_BASE + REG_ICOUNT_HI).data, 0); + + let mut core = MipsCore::new(); + core.hot.cycles = 0x1_2345_6789; + d.attach_core(&core as *const MipsCore); + let lo = d.read32(TEST_DEV_BASE + REG_ICOUNT_LO).data; + let hi = d.read32(TEST_DEV_BASE + REG_ICOUNT_HI).data; + assert_eq!(((hi as u64) << 32) | lo as u64, 0x1_2345_6789); + } + + #[test] + fn byte_reads_pick_the_big_endian_lane_of_any_register() { + let d = TestDevice::new("unused"); + let mut core = MipsCore::new(); + core.hot.cycles = 0x0000_0000_AABB_CCDD; + d.attach_core(&core as *const MipsCore); + let bytes: Vec = (0..4) + .map(|i| d.read8(TEST_DEV_BASE + REG_ICOUNT_LO + i).data) + .collect(); + assert_eq!(&bytes, &[0xAA, 0xBB, 0xCC, 0xDD]); + } + #[test] fn dump_json_covers_the_whole_architectural_state() { let mut core = MipsCore::new(); From 3a848ee09e9b5fb5eb8e59459f50f9b8eeeb3546 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 21 Aug 2026 10:40:23 -0400 Subject: [PATCH 02/15] cpu-tests: split console.h out of testlib.h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical: the base types, the SCC console API and the test-device API move from testlib.h into a new console.h, which testlib.h then includes. console.c and cp0.h include console.h instead. No behaviour change — `make -C cpu-tests` produces the same binary and the same results. The point is that bench/ (next commit) can compile this same console.c against its own harness without dragging in the CHECK macros and the exception-record plumbing, which belong to a *test* suite and mean nothing to a benchmark. One SCC driver, one test-device probe, one copy. Co-Authored-By: Claude Opus 5 (1M context) --- cpu-tests/harness/console.c | 2 +- cpu-tests/harness/console.h | 59 +++++++++++++++++++++++++++++++++++++ cpu-tests/harness/cp0.h | 2 +- cpu-tests/harness/testlib.h | 47 +---------------------------- 4 files changed, 62 insertions(+), 48 deletions(-) create mode 100644 cpu-tests/harness/console.h diff --git a/cpu-tests/harness/console.c b/cpu-tests/harness/console.c index 2d93c87..ba705e9 100644 --- a/cpu-tests/harness/console.c +++ b/cpu-tests/harness/console.c @@ -8,7 +8,7 @@ * empty GIO slot times out), which is why we probe rather than assume. */ -#include "testlib.h" +#include "console.h" #define RD8(a) (*(volatile u8 *)(unsigned long)(a)) #define WR8(a, v) (*(volatile u8 *)(unsigned long)(a) = (u8)(v)) diff --git a/cpu-tests/harness/console.h b/cpu-tests/harness/console.h new file mode 100644 index 0000000..03873ca --- /dev/null +++ b/cpu-tests/harness/console.h @@ -0,0 +1,59 @@ +/* console.h — base types, serial console, and the IRIS test device. + * + * Split out of testlib.h so that code which is not a *test* can use the + * console without dragging in the CHECK macros and the exception-record + * plumbing: bench/ compiles this same console.c against its own harness. + * testlib.h includes this, so every existing test file is unaffected. + */ +#ifndef CONSOLE_H +#define CONSOLE_H + +#include "iris.h" + +typedef unsigned char u8; +typedef unsigned short u16; +typedef unsigned int u32; +typedef unsigned long long u64; +typedef signed char s8; +typedef short s16; +typedef int s32; +typedef long long s64; + +/* ── CPU selection ────────────────────────────────────────────────────────── */ +/* Bitmask: a test declares which CPUs it is valid on. */ +#define CPU_R4400 0x1 +#define CPU_R5000 0x2 +#define CPU_ALL (CPU_R4400 | CPU_R5000) + +extern u32 cpu_kind; /* CPU_R4400 or CPU_R5000, set at startup */ +extern u32 cpu_prid; +extern u32 cpu_fir; +extern u32 cpu_config; +static inline int is_r5000(void) { return cpu_kind == CPU_R5000; } +static inline int is_r4400(void) { return cpu_kind == CPU_R4400; } + +/* ── Console ──────────────────────────────────────────────────────────────── */ +void con_init(void); +void con_putc(int c); +void con_puts(const char *s); +void con_hex32(u32 v); +void con_hex64(u64 v); +void con_dec(long long v); +void con_udec(unsigned long long v); +/* Minimal printf: %s %c %d %u %x (32-bit), %X/%lx (64-bit), %% */ +void con_printf(const char *fmt, ...); +/* Wait for the serial transmitter to drain. Call before anything that stops + * the machine, or the tail of the last line is lost. */ +void con_flush(void); + +/* ── Test device (absent on real hardware; probed at startup) ─────────────── */ +extern int have_testdev; +void testdev_probe(void); +void testdev_dump(u32 tag); +void testdev_exit(u32 code); /* no return when present */ + + +/* Halt with a message — unrecoverable harness failure. */ +void panic(const char *msg) __attribute__((noreturn)); + +#endif /* CONSOLE_H */ diff --git a/cpu-tests/harness/cp0.h b/cpu-tests/harness/cp0.h index 905880f..7956cac 100644 --- a/cpu-tests/harness/cp0.h +++ b/cpu-tests/harness/cp0.h @@ -7,7 +7,7 @@ #ifndef CP0_H #define CP0_H -#include "testlib.h" +#include "console.h" /* 32-bit CP0 read/write (mfc0/mtc0). `sel` is not used on R4400/R5000 — the * select field arrived with MIPS32r1 — so these take the register number diff --git a/cpu-tests/harness/testlib.h b/cpu-tests/harness/testlib.h index fd6af92..c39739d 100644 --- a/cpu-tests/harness/testlib.h +++ b/cpu-tests/harness/testlib.h @@ -8,49 +8,7 @@ #ifndef TESTLIB_H #define TESTLIB_H -#include "iris.h" - -typedef unsigned char u8; -typedef unsigned short u16; -typedef unsigned int u32; -typedef unsigned long long u64; -typedef signed char s8; -typedef short s16; -typedef int s32; -typedef long long s64; - -/* ── CPU selection ────────────────────────────────────────────────────────── */ -/* Bitmask: a test declares which CPUs it is valid on. */ -#define CPU_R4400 0x1 -#define CPU_R5000 0x2 -#define CPU_ALL (CPU_R4400 | CPU_R5000) - -extern u32 cpu_kind; /* CPU_R4400 or CPU_R5000, set at startup */ -extern u32 cpu_prid; -extern u32 cpu_fir; -extern u32 cpu_config; -static inline int is_r5000(void) { return cpu_kind == CPU_R5000; } -static inline int is_r4400(void) { return cpu_kind == CPU_R4400; } - -/* ── Console ──────────────────────────────────────────────────────────────── */ -void con_init(void); -void con_putc(int c); -void con_puts(const char *s); -void con_hex32(u32 v); -void con_hex64(u64 v); -void con_dec(long long v); -void con_udec(unsigned long long v); -/* Minimal printf: %s %c %d %u %x (32-bit), %X/%lx (64-bit), %% */ -void con_printf(const char *fmt, ...); -/* Wait for the serial transmitter to drain. Call before anything that stops - * the machine, or the tail of the last line is lost. */ -void con_flush(void); - -/* ── Test device (absent on real hardware; probed at startup) ─────────────── */ -extern int have_testdev; -void testdev_probe(void); -void testdev_dump(u32 tag); -void testdev_exit(u32 code); /* no return when present */ +#include "console.h" /* ── Result accounting ────────────────────────────────────────────────────── */ extern u32 n_pass, n_fail, n_skip, n_tests_run; @@ -199,7 +157,4 @@ void icache_invalidate_range(volatile void *addr, u32 len); void dcache_wb_invalidate_range(volatile void *addr, u32 len); void dcache_invalidate_range(volatile void *addr, u32 len); -/* Halt with a message — unrecoverable harness failure. */ -void panic(const char *msg) __attribute__((noreturn)); - #endif /* TESTLIB_H */ From c78a06a27e95b5002c3723e7d9efec9317938cee Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 21 Aug 2026 10:41:20 -0400 Subject: [PATCH 03/15] bench: a bare-metal benchmark suite for the emulated CPU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling to cpu-tests/, sharing its toolchain probe, SCC console and exception dispatcher. Where cpu-tests asks "is this instruction correct", one instruction at a time with clean state, this asks "is it still correct after ten million of them, and how long did that take". 46 kernels in six groups, all deterministic and autoscaled to ~250 ms per timed run so the suite finishes in a couple of minutes: int alu, alu_ilp, alu64, muldiv, branch, bitops, Dhrystone 2.1 fpu scalar s/d, divsqrt, transcendentals, Whetstone, LINPACK 100x100, matmul mem L1/L2/DRAM pointer-chase latency, copy, fill, stream x3, unaligned, random img rgb2ycbcr, convolve3x3, sharpen5x5, dct8x8, resize, rotate90, composite, dither, histogram vid motion_est, yuv2rgb codec crc32, adler32, rle, lz, huffman sys tlb_hit, tlb_miss, exception, cache_flush, uncached, llsc The imaging group is the point: an Indy shipped with a camera on the monitor and Photoshop in the catalogue, and a 3x3 convolution stresses the cache model in ways an ALU chain never will. The sys group is the other point — TLB refills, exception round trips and uncached device reads are where an emulator can be catastrophically slower than the hardware it replaces, and they are invisible to every conventional benchmark. Every kernel checksums its result against a golden value computed by building the same C natively (gen/golden.c), so each run reports an accuracy percentage next to its throughput. The oracle is an independent IEEE-754 implementation rather than a recording of a previous emulator run, which would agree with the emulator by construction including everywhere the emulator is wrong. The same sources also build for the host (`make hostbench`) using the same runner, so the native comparison is the identical kernels rather than two benchmarks pretending to be comparable. Two things the harness had to grow after being bitten: - exception counting per timed run. The shared dispatcher steps over a faulting instruction and carries on, so a kernel that faults still reports a throughput — for doing something other than what it claims. mem/unaligned scored a plausible 871k accesses/s while taking an address error on three loads in four, and only the checksum gave it away. - a real timebase probe. An emulator without the new test-device registers aliases CAPS onto SIGNATURE, whose low bit is set, so a naive capability check passes and every timing comes back frozen. Whetstone is reported in passes per second, not MWIPS: converting needs the "Whetstone instructions per loop" constant from a reference implementation and that is not something this suite can verify. Dhrystone/1757 and LINPACK's 2/3n^3+2n^2 are unambiguous, so those two are in their standard units. CI gates accuracy only. A shared runner's throughput varies by more than most real regressions; whether the emulator computed the right answer does not. rules/testing/benchmark-suite-gotchas.md collects what the accuracy check caught during development — endianness, uninitialised memory, an out-of-bounds read, a bump allocator called inside an iteration loop. Read it before adding a kernel. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/bench.yml | 220 +++++++ .gitignore | 6 + bench/Makefile | 176 ++++++ bench/README.md | 324 +++++++++++ bench/gen/golden.c | 59 ++ bench/gen/hostplat.c | 114 ++++ bench/golden/golden.h | 56 ++ bench/harness/benchlib.c | 254 ++++++++ bench/harness/benchlib.h | 230 ++++++++ bench/harness/bmath.h | 166 ++++++ bench/harness/groups.c | 31 + bench/harness/hostshim.h | 51 ++ bench/harness/link.ld | 68 +++ bench/harness/main.c | 460 +++++++++++++++ bench/harness/string.c | 77 +++ bench/harness/tlbasm.S | 59 ++ bench/irix/steps.toml | 175 ++++++ bench/kernels/codec.c | 390 +++++++++++++ bench/kernels/fpu.c | 472 +++++++++++++++ bench/kernels/imaging.c | 712 +++++++++++++++++++++++ bench/kernels/integer.c | 461 +++++++++++++++ bench/kernels/memory.c | 395 +++++++++++++ bench/kernels/sys.c | 270 +++++++++ bench/run/bare.toml | 19 + bench/run/run-local.sh | 43 ++ bench/run/run-prom.sh | 88 +++ rules/testing/benchmark-suite-gotchas.md | 96 +++ 27 files changed, 5472 insertions(+) create mode 100644 .github/workflows/bench.yml create mode 100644 bench/Makefile create mode 100644 bench/README.md create mode 100644 bench/gen/golden.c create mode 100644 bench/gen/hostplat.c create mode 100644 bench/golden/golden.h create mode 100644 bench/harness/benchlib.c create mode 100644 bench/harness/benchlib.h create mode 100644 bench/harness/bmath.h create mode 100644 bench/harness/groups.c create mode 100644 bench/harness/hostshim.h create mode 100644 bench/harness/link.ld create mode 100644 bench/harness/main.c create mode 100644 bench/harness/string.c create mode 100644 bench/harness/tlbasm.S create mode 100644 bench/irix/steps.toml create mode 100644 bench/kernels/codec.c create mode 100644 bench/kernels/fpu.c create mode 100644 bench/kernels/imaging.c create mode 100644 bench/kernels/integer.c create mode 100644 bench/kernels/memory.c create mode 100644 bench/kernels/sys.c create mode 100644 bench/run/bare.toml create mode 100755 bench/run/run-local.sh create mode 100755 bench/run/run-prom.sh create mode 100644 rules/testing/benchmark-suite-gotchas.md diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml new file mode 100644 index 0000000..65818d4 --- /dev/null +++ b/.github/workflows/bench.yml @@ -0,0 +1,220 @@ +# Bare-metal benchmark suite (bench/). +# +# CI gates the ACCURACY score, not the performance numbers. A shared runner's +# throughput varies by more than most real regressions, so a perf threshold +# here would either be so loose it catches nothing or so tight it fires every +# other week. What does not vary is whether the emulator computed the right +# answer: every kernel checksums its result against a golden value produced by +# building the same C natively, and the suite's exit code is the number of +# mismatches. That is a genuine regression net, and it covers ground cpu-tests +# cannot — instruction-level tests run one operation at a time with clean +# state, while these run millions with whatever state the last million left. +# +# The performance figures are still collected and uploaded, so a run can be +# read after the fact or compared by hand across commits. + +name: Benchmark + +on: + push: + branches: [ "main" ] + paths: + - 'bench/**' + - 'cpu-tests/harness/**' + - 'src/mips_*.rs' + - 'src/jitv2/**' + - 'src/testdev.rs' + - 'src/bin/iris_bench.rs' + - '.github/workflows/bench.yml' + pull_request: + branches: [ "main" ] + paths: + - 'bench/**' + - 'cpu-tests/harness/**' + - 'src/mips_*.rs' + - 'src/jitv2/**' + - 'src/testdev.rs' + - 'src/bin/iris_bench.rs' + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + build-guest: + name: Build the guest binary and the oracle + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install the MIPS cross toolchain + run: | + sudo apt-get update + sudo apt-get install -y gcc-mips-linux-gnu binutils-mips-linux-gnu + + # golden.h is generated from the kernels themselves and checked in, so + # building the suite needs nothing but the cross toolchain. Regenerating + # here and failing on a diff is what stops the kernels and their expected + # values from drifting apart — and doubles as a determinism check on the + # suite itself, since a kernel whose result depends on uninitialised + # memory or on the host's byte order produces a different table on a + # different runner. Every one of those has already happened once; see + # rules/testing/benchmark-suite-gotchas.md. + - name: Verify the golden checksums are up to date + run: | + make -C bench golden + git diff --exit-code bench/golden/golden.h + + - name: Build irisbench.elf + run: make -C bench + + # One binary for every cell below — same as cpu-tests, and for the same + # reason: a differential comparison needs the guest side held constant. + - uses: actions/upload-artifact@v4 + with: + name: irisbench-elf + path: bench/build/irisbench.elf + if-no-files-found: error + + host-baseline: + name: Host baseline + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # The same kernels, natively. Its accuracy score must be 100% by + # construction — it is the source of the golden values — so a failure + # here means a kernel is not deterministic even against itself. + - name: Build and run + run: | + make -C bench hostbench + ./bench/build/irisbench-host | tee bench/build/host.log + - name: The oracle must agree with itself + run: grep -q 'IRIS-BENCH-DONE rc=0' bench/build/host.log + - uses: actions/upload-artifact@v4 + if: always() + with: + name: bench-host-log + path: bench/build/host.log + if-no-files-found: ignore + + run: + name: ${{ matrix.cpu }} / ${{ matrix.engine }} + needs: build-guest + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + cpu: [r4400, r5000] + engine: [interp, jitv2] + steps: + - uses: actions/checkout@v4 + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libasound2-dev + + - uses: actions/download-artifact@v4 + with: + name: irisbench-elf + path: bench/build + + - uses: Swatinem/rust-cache@v2 + + # The CPU model and the JIT are compile-time cargo features, not runtime + # switches — see rules/perf/hardware-profiles.md. Plain r5k only, for the + # reasons cpu-tests.yml sets out at length. + - name: Build IRIS + run: | + FEATURES="" + [ "${{ matrix.cpu }}" = "r5000" ] && FEATURES="r5k" + [ "${{ matrix.engine }}" = "jitv2" ] && FEATURES="${FEATURES:+$FEATURES,}jitv2" + if [ -n "$FEATURES" ]; then + cargo build --release --bin iris --features "$FEATURES" + else + cargo build --release --bin iris + fi + cargo build --release --bin iris-bench + + - name: Run the suite + run: | + chmod +x bench/build/irisbench.elf + ./target/release/iris-bench run \ + --label "${{ matrix.cpu }}-${{ matrix.engine }}" \ + --timeout 2400 \ + 2>&1 | tee bench/build/run.log + + # Three ways this can fail quietly, so all three are checked. A build + # whose features did not take runs the wrong CPU and every result is + # mislabelled; the guest's banner is authoritative because it reads PRId. + # A kernel that faults is stepped over by the exception dispatcher and + # still reports a throughput. And a mismatch is the actual regression. + - name: Check the run + run: | + python3 - <<'PY' + import json, sys, glob + paths = glob.glob("bench/build/results/*.json") + if not paths: + sys.exit("::error::no result file was written") + run = json.load(open(paths[0])) + want = "${{ matrix.cpu }}".upper() + if run["machine"]["cpu"] != want: + sys.exit(f"::error::guest reports {run['machine']['cpu']}, expected {want}") + if not run["machine"]["timebase"]: + sys.exit("::error::no host time base — every timing is a guess") + + expect_exc = {"sys/exception", "sys/tlb_miss"} + bad = [r for r in run["rows"] if r["status"] == "MISMATCH"] + exc = [r for r in run["rows"] if r["exc"] and r["name"] not in expect_exc] + for r in bad: + print(f"::error::{r['name']} checksum {r['checksum']} != {r['golden']}") + for r in exc: + print(f"::error::{r['name']} took {r['exc']} unexpected exceptions") + + mips = run["total_icount"] * 1e3 / max(run["total_ns"], 1) + print(f"{run['cell']}: {run['matched']}/{run['checked']} matched, " + f"{mips:.1f} guest MIPS, {run['total_ns']/1e9:.1f} s timed") + sys.exit(1 if bad or exc else 0) + PY + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: bench-${{ matrix.cpu }}-${{ matrix.engine }} + path: | + bench/build/run.log + bench/build/results/*.json + if-no-files-found: ignore + + report: + name: Comparison report + needs: run + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libasound2-dev + - uses: actions/download-artifact@v4 + with: + pattern: bench-* + path: artifacts + merge-multiple: true + - name: Assemble + run: | + mkdir -p bench/build/results + find artifacts -name '*.json' -exec cp {} bench/build/results/ \; || true + if [ -z "$(ls -A bench/build/results 2>/dev/null)" ]; then + echo "no results to report"; exit 0 + fi + cargo build --release --bin iris-bench + ./target/release/iris-bench report --format md > bench/build/report.md + cat bench/build/report.md >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@v4 + with: + name: bench-report + path: bench/build/report.md + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index e02864c..8372cc7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,9 @@ screenshot_*.png IRIS.app/ *.log .DS_Store + +# Benchmark build products: the guest ELF, the native oracle and baseline, the +# per-cell emulator copies, and the JSON results. golden/golden.h is generated +# but deliberately checked in — building the suite must need nothing but the +# MIPS cross toolchain. +bench/build/ diff --git a/bench/Makefile b/bench/Makefile new file mode 100644 index 0000000..5550168 --- /dev/null +++ b/bench/Makefile @@ -0,0 +1,176 @@ +# bench — the IRIS benchmark suite, bare metal. +# +# make build build/irisbench.elf +# make run build, then run it under IRIS via --load-elf +# make golden regenerate golden/golden.h from a native host build +# make image bootable SGI disk image (volume header + ELF) +# make matrix run every CPU x engine cell and write a comparison +# make clean +# +# The toolchain lookup, the console and the startup/exception code are shared +# with cpu-tests rather than duplicated: one MIPS cross-compiler probe, one SCC +# driver, one exception dispatcher. What is not shared is the float ABI (this +# suite wants the FPU; cpu-tests drives it by hand under -msoft-float), the +# link layout (this one needs megabytes of working set) and the runner. + +.DEFAULT_GOAL := all + +CPUTESTS := ../cpu-tests +include $(CPUTESTS)/toolchain.mk + +BUILD := build +TARGET := $(BUILD)/irisbench.elf + +# -march=mips3 an R4400 must be able to run it; the R5000 cells run the +# same binary, and anything MIPS IV would be a silent R4400 +# Reserved Instruction rather than a loud build error. +# -mabi=n32 64-bit values in single registers. Still ELF32 MSB, so +# --load-elf and the PROM both accept it. +# -mhard-float the opposite of cpu-tests, and the point: these kernels are +# meant to hammer the FPU through ordinary compiled code. +# -ffp-contract=off no multiply-add fusion. MIPS III has no FMA, so the guest +# could not fuse anyway — but the golden generator runs on a +# host that can, and a contracted result differs in the last +# ulp from an uncontracted one. +ARCHFLAGS := -march=mips3 -mabi=n32 -EB -mno-abicalls -fno-pic -G0 -mhard-float +CFLAGS := $(ARCHFLAGS) -ffreestanding -nostdlib -nostdinc \ + -fno-builtin -fno-stack-protector -fno-strict-aliasing \ + -fno-delete-null-pointer-checks -fomit-frame-pointer \ + -ffp-contract=off -fexcess-precision=standard \ + -O2 -g -std=gnu11 \ + -Wall -Wextra -Werror -Wno-unused-parameter \ + -Iharness -Igolden -I$(CPUTESTS)/harness +ASFLAGS := $(ARCHFLAGS) -ffreestanding -nostdlib -I$(CPUTESTS)/harness -Iharness \ + -g -Wa,--fatal-warnings +LDFLAGS := -EB -T harness/link.ld -nostdlib --no-warn-mismatch + +HARNESS_C := harness/benchlib.c harness/string.c harness/groups.c harness/main.c \ + $(CPUTESTS)/harness/console.c +HARNESS_S := $(CPUTESTS)/harness/start.S harness/tlbasm.S +KERNEL_C := $(sort $(wildcard kernels/*.c)) + +OBJS := $(patsubst %.c,$(BUILD)/%.o,$(notdir $(HARNESS_C))) \ + $(patsubst %.S,$(BUILD)/%.o,$(notdir $(HARNESS_S))) \ + $(patsubst kernels/%.c,$(BUILD)/kernels/%.o,$(KERNEL_C)) +DEPS := $(OBJS:.o=.d) + +# No libgcc, for the same reason cpu-tests has none: the Debian cross package +# ships only an o32 copy, and linking that into an n32 image is silently wrong. +# Under n32 with hard float every operation these kernels perform is a native +# instruction. If a helper reference ever appears the link fails loudly. +LIBGCC := + +.PHONY: all clean run dis syms image golden hostbench matrix bench report check-size +all: $(TARGET) + +$(BUILD)/%.o: harness/%.c + @mkdir -p $(dir $@) + $(CC) $(CFLAGS) -MMD -MP -c -o $@ $< + +$(BUILD)/%.o: $(CPUTESTS)/harness/%.c + @mkdir -p $(dir $@) + $(CC) $(CFLAGS) -MMD -MP -c -o $@ $< + +$(BUILD)/kernels/%.o: kernels/%.c + @mkdir -p $(dir $@) + $(CC) $(CFLAGS) -MMD -MP -c -o $@ $< + +$(BUILD)/%.o: harness/%.S + @mkdir -p $(dir $@) + $(CC) $(ASFLAGS) -MMD -MP -c -o $@ $< + +$(BUILD)/%.o: $(CPUTESTS)/harness/%.S + @mkdir -p $(dir $@) + $(CC) $(ASFLAGS) -MMD -MP -c -o $@ $< + +$(TARGET): $(OBJS) harness/link.ld + @mkdir -p $(dir $@) + $(LD) $(LDFLAGS) -o $@ $(OBJS) $(LIBGCC) + @$(MAKE) --no-print-directory check-size + +check-size: $(TARGET) + @$(READELF) -S $(TARGET) | grep -qE '\.got|\.dynamic' && { \ + echo "error: $(TARGET) has a GOT/dynamic section — not -mno-abicalls clean"; \ + exit 1; } || true + @echo "built $(TARGET):" + @$(READELF) -h $(TARGET) | grep -E 'Class|Data|Machine|Entry|Flags' + @$(NM) $(TARGET) | grep -E ' (_ftext|_etext|_fbss|_end|_work_start)$$' | sort + +dis: $(TARGET) + $(OBJDUMP) -d $(TARGET) | less + +syms: $(TARGET) + $(NM) -n $(TARGET) + +clean: + rm -rf $(BUILD) + +# ── the oracle ─────────────────────────────────────────────────────────────── +# Native build of the same kernels. -O1 and no vectoriser: see gen/golden.c. +HOSTCC ?= cc +HOSTCFLAGS := -O1 -std=gnu11 -DBENCH_HOST -Iharness -Igolden \ + -fno-tree-vectorize -ffp-contract=off -fexcess-precision=standard \ + -Wall -Wextra -Wno-unused-parameter +# sys.c is excluded: a TLB refill and a cache writeback have no host analogue, +# and those kernels carry no golden checksum for exactly that reason. +GOLDEN_SRC := gen/golden.c gen/hostplat.c harness/groups.c $(filter-out kernels/sys.c,$(KERNEL_C)) + +golden: $(BUILD)/golden-gen + @$(BUILD)/golden-gen > golden/golden.h.new && mv golden/golden.h.new golden/golden.h + @echo "wrote golden/golden.h" + +$(BUILD)/golden-gen: $(GOLDEN_SRC) harness/benchlib.h harness/hostshim.h harness/bmath.h golden/golden.h + @mkdir -p $(BUILD) + $(HOSTCC) $(HOSTCFLAGS) -o $@ $(GOLDEN_SRC) -lm + +# ── the host baseline ──────────────────────────────────────────────────────── +# The same kernels and the same runner, built for the machine the emulator runs +# on. Emits the identical machine block, so iris-bench can put a native column +# next to the emulated ones and the ratio between them means something. +# +# -O2 here, not the golden generator's -O1: this one is measuring the host, and +# hobbling the compiler would understate it. Checksums are unaffected — they +# are integers and IEEE-754 arithmetic, neither of which -O touches without +# -ffast-math. +HOSTBENCHFLAGS := -O2 -std=gnu11 -DBENCH_HOST -Iharness -Igolden \ + -ffp-contract=off -fexcess-precision=standard \ + -Wall -Wextra -Wno-unused-parameter +HOSTBENCH_SRC := harness/main.c gen/hostplat.c harness/groups.c \ + $(filter-out kernels/sys.c,$(KERNEL_C)) + +hostbench: $(BUILD)/irisbench-host +$(BUILD)/irisbench-host: $(HOSTBENCH_SRC) harness/benchlib.h harness/hostshim.h harness/bmath.h golden/golden.h + @mkdir -p $(BUILD) + $(HOSTCC) $(HOSTBENCHFLAGS) -o $@ $(HOSTBENCH_SRC) -lm + +# ── running ────────────────────────────────────────────────────────────────── +IRIS ?= ../target/release/iris + +run: $(TARGET) + @run/run-local.sh $(TARGET) + +# The matrix lives in iris-bench, not in a shell script: it has to build a +# separate emulator per cell (the CPU model and the JIT are cargo features), +# check that each one really is the CPU it claims, parse the results and write +# a comparison. That is a program, and it needs tests. +matrix: $(TARGET) + cd .. && cargo build --release --bin iris-bench + cd .. && ./target/release/iris-bench matrix + +# One cell, whatever ../target/release/iris happens to be. +bench: $(TARGET) + cd .. && cargo build --release --bin iris-bench + cd .. && ./target/release/iris-bench run --label local + +report: + cd .. && ./target/release/iris-bench report + +# ── bootable image ─────────────────────────────────────────────────────────── +MKVH ?= ../target/release/mkvh + +image: $(BUILD)/irisbench.img +$(BUILD)/irisbench.img: $(TARGET) + $(MKVH) build $@ --bootfile irisbench irisbench=$(TARGET) + $(MKVH) dump $@ + +-include $(DEPS) diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..1c5c6f6 --- /dev/null +++ b/bench/README.md @@ -0,0 +1,324 @@ +# bench — the IRIS benchmark suite + +Two questions, one suite: + +* **How fast is this build of IRIS, and where does the time go?** Per kernel, + in guest instructions per host second, so a change to the emulator can be + measured rather than felt. +* **Is it still computing the right answers at speed?** Every kernel produces a + deterministic checksum compared against a value computed independently on the + host. The share that match is the accuracy score. + +It runs bare metal — no IRIX, no PROM required — as a static MIPS III binary +loaded straight into RAM. The same C is compiled a second time for the host, so +the machine you are running the emulator on is measured by *the same kernels* +and the comparison between them is real rather than rhetorical. + +``` +make -C bench # build the guest binary +make -C bench golden # (re)compute the expected checksums natively +make -C bench hostbench # build the host baseline +make -C bench bench # run once against ../target/release/iris +make -C bench matrix # build and run every CPU x engine cell +make -C bench report # turn the saved results into markdown +``` + +--- + +## What it measures + +46 kernels in six groups. Every one is deterministic, self-checking where a +checksum is meaningful, and autoscaled by the harness to about 250 ms per timed +run so the whole suite finishes in a couple of minutes on the interpreter and +less on a JIT. + +| group | kernels | what it is really testing | +|---|---|---| +| `int/` | alu, alu_ilp, alu64, muldiv, branch, bitops, **dhrystone** | dispatch cost, 64-bit paths, unpredictable branches | +| `fpu/` | scalar_s, scalar_d, divsqrt, transcend, **whetstone**, **linpack**, matmul | the FPU under sustained load, not one instruction at a time | +| `mem/` | latency L1/L2/DRAM, copy, fill, stream copy/scale/triad, unaligned, random | the cache hierarchy, as a curve rather than a number | +| `img/` | rgb2ycbcr, convolve3x3, sharpen5x5, dct8x8, resize, rotate90, composite, dither, histogram | what the machine was bought to do | +| `vid/` | motion_est, yuv2rgb | the MPEG inner loop, and playback | +| `codec/` | crc32, adler32, rle, lz, huffman | table lookups, hash chains, bit packing | +| `sys/` | tlb_hit, tlb_miss, exception, cache_flush, uncached, llsc | paths that exist only because this is an emulator | + +**The imaging group is the point of the whole thing.** An Indy shipped with a +camera on the monitor and Photoshop in the catalogue; the workloads people ran +were images and video. A 3x3 convolution is three strided reads and a +multiply-accumulate per pixel and lives or dies on the cache model. A DCT is a +register-pressure problem. Motion estimation is a branch-free absolute +difference storm. Floyd-Steinberg is a strictly serial dependency across a +whole frame that nothing can reorder. Between them they exercise translation, +memory and dispatch in the proportions real software uses, which no ALU chain +does. + +**The `sys` group is the other point.** TLB refills, exception round trips, +cache maintenance and uncached device reads are where an emulator can be +catastrophically slower than the hardware it stands in for, and they are +invisible to every conventional benchmark. `rules/perf/` and `rules/jitv2/` are +full of work whose payoff shows up here and nowhere else. + +### Industry-standard figures + +Three kernels report in units with four decades of published numbers behind +them, so an emulated Indy can be put next to a real one: + +| kernel | unit | derived as | +|---|---|---| +| `int/dhrystone` | DMIPS | Dhrystone 2.1, rate / 1757 | +| `fpu/linpack` | MFLOPS | LINPACK 100x100 (dgefa + dgesl), rate / 1e6 | + +`iris-bench report` computes both. One caveat and one deliberate omission: + +* **LINPACK** generates the system once and restores it with a copy between + solves, since the harness times whatever `run()` does while the reference + implementation times only the factor and solve. That is ~80 KB of copy + against ~690 k flops, and it is identical on every cell being compared. +* **Whetstone** is here for its instruction mix — the classic module structure + and weights — but is reported in **passes per second, not MWIPS**. Converting + needs the "Whetstone instructions per loop" constant from a reference + implementation, and that is not something this suite can verify; a derived + MWIPS resting on an unchecked factor of a thousand would look authoritative + without being so. It also uses the suite's own transcendentals + (`harness/bmath.h`) rather than a libm, because a libm difference between host + and guest would be scored as an emulator fault. + +The `mem/stream_*` kernels are STREAM-shaped and deliberately not STREAM: same +four loops, arrays sized to miss every cache, but an independent +implementation. Do not quote them as STREAM results. + +--- + +## How a benchmark is put together + +```c +static u64 v_dct(void); /* one fixed reference run -> checksum */ +static u64 r_dct(u32 iters); /* iters passes -> work units performed */ + +BENCH("img/dct8x8", "blk", v_dct, r_dct, 1, BG_IMG), +``` + +`verify()` and `run()` are separate on purpose. The harness picks the iteration +count for `run()` by measurement, so it differs between a fast host and a slow +one — a checksum taken from the timed loop would depend on how fast the machine +is and could never be compared to anything. + +The rules a kernel has to follow: + +* **Call `work_alloc()` once per `run()`, outside the iteration loop.** It is a + bump allocator with no free. A kernel that allocates per iteration exhausts + 24 MB in a handful of passes — and only at whatever iteration count the + autoscaler happened to pick, which makes it look like a data-dependent + failure rather than the plain bug it is. +* **Never checksum anything wider than a byte as raw bytes.** The golden values + come from a little-endian host and the guest is big-endian; `cksum_bytes` over + a `short[]` compares byte order, not results, and reports the difference as an + emulator fault. Use `cksum_u64`/`cksum_f64`, which are defined on values. +* **Initialise everything you checksum.** A buffer whose tail is never written + folds in whatever the allocator last held, which is not the same on the host + as on the guest and not even the same between two host builds. +* **Say so if you mean to take exceptions.** `BENCH_EXC` marks a kernel that + faults on purpose. Everywhere else a nonzero count means the shared dispatcher + stepped over a faulting instruction and the kernel produced a number for doing + something other than what it claims. + +That last one is not hypothetical. `mem/unaligned` scored a plausible 871 k +accesses/s while taking an address error on three loads in four, because +`*(const u32 *)p` promises an alignment the pointer does not have and GCC +emitted a plain `lw`. The checksum caught it; the exception counter now catches +the whole class. + +--- + +## The time base + +Timing anything with CP0 Count would be measuring the timer model as much as the +workload — under IRIS, Count is virtual, materialised from a wall-clock anchor +at a `count_hz` inferred from the guest's own Compare writes. So the test device +(`--test-device`) carries two extra registers: + +| register | offset | what it is | +|---|---|---| +| `TESTDEV_HOST_NS_LO/HI` | `0x10` / `0x14` | host monotonic nanoseconds | +| `TESTDEV_ICOUNT_LO/HI` | `0x18` / `0x1C` | guest instructions retired | +| `TESTDEV_CAPS` | `0x20` | capability bits | + +Reading the LO half latches the whole 64-bit value; HI returns the high word of +that same latch, so LO-then-HI cannot tear. + +The instruction counter is `MipsCore::hot.cycles`, advanced once per retired +instruction by **both** the interpreter and jitv2 (`emit_increment_cycles`), +which is what makes "guest MIPS" comparable across engines and the single most +useful number the suite produces. + +The suite still measures the CP0 Count rate against the host clock at startup +and prints it. That is not decoration — it is a report on the emulator's timer +model, and it is what makes the fallback path honest when there is no host clock +at all. + +**Probing matters.** An emulator built before these registers existed decodes +only 16 bytes and repeats, so `0x20` aliases back onto `SIGNATURE` — whose low +bit is set, so a naive `caps & CAP_TIMEBASE` says yes and every timing comes +back frozen. That happened on the first run. The suite now rejects a CAPS word +equal to the signature and then requires the clock to actually advance. And it +never *writes* an unprobed offset: on an old device `0x1C` aliases onto `EXIT`. + +Without a host clock — real hardware, or an older build — everything falls back +to CP0 Count at an assumed 100 MHz (a 200 MHz R4x00, Count = clock/2), and the +header says so in as many words. + +--- + +## Output + +A human table, streamed a line at a time because the run takes minutes and +something that prints nothing until it is over is indistinguishable from a hang: + +``` +benchmark unit rate/s guest-MIPS time% acc +------------------------------------------------------------------ +int/alu ops 38479208 64.93 ok +fpu/linpack flop 5874487 34.25 ok +img/dct8x8 blk 3766 49.21 ok +sys/tlb_miss miss 1948786 24.94 - +------------------------------------------------------------------ + wall clock 15.48 s + guest work 756.28 M instructions + emulator speed 48.85 MIPS (guest instructions per host second) + accuracy 97.5 % (39 of 40 checksums matched) +``` + +then the two rankings that answer "what is taking a while" — which are +different lists, because a kernel can dominate the wall clock simply by being +long, and a kernel can be terrible per instruction while barely registering in +the total: + +``` + Where the time went (largest share of wall clock) + Where the emulator works hardest (fewest guest MIPS) +``` + +then a machine-readable block between `IRIS-BENCH-BEGIN` and `IRIS-BENCH-END` +that `iris-bench` parses. Everything in it is an integer — a freestanding `%f` +would need its own float formatter, and nanoseconds, instructions, work units +and checksums are all exact as integers. Rates are derived by the host, which +has a real printf. + +--- + +## iris-bench + +``` +iris-bench run [--iris PATH] [--elf PATH] [--label NAME] +iris-bench host # measure this machine with the same kernels +iris-bench matrix [--cells r4400-interp,r5000-jitv2] [--force-build] +iris-bench report [--baseline CELL] [--format md|json|text] +iris-bench cells # what matrix knows how to build +``` + +`matrix` builds a **separate emulator per cell**, because the CPU model and the +JIT are compile-time cargo features and there is no runtime switch to flip: + +| cell | features | +|---|---| +| `r4400-interp` | (default) | +| `r5000-interp` | `r5k` | +| `r4400-jitv2` | `jitv2` | +| `r5000-jitv2` | `r5k,jitv2` | +| `r4400-lightning` | `lightning` | +| `r4400-jitv2-lightning` | `jitv2,lightning` | + +Each build is copied to `bench/build/iris-` before the next one starts — +the next `cargo build` overwrites `target/release/iris`, and a matrix that races +its own artefacts produces results labelled with the wrong build. After the run, +the guest's own `#machine cpu=` line (read from PRId, so it is authoritative) is +checked against what the cell claims. cpu-tests has the same guard for the same +reason: an `--features r5k` build once overwrote the binary between the copy and +the run, and an "R4400" cell silently exercised an R5000. + +The report gives per-cell summaries with DMIPS/MWIPS/MFLOPS, per-kernel +throughput with speedups against a baseline cell and against native, any +checksum mismatches, any unexpected exceptions, and per-cell time-share and +efficiency rankings. + +--- + +## The other half: `bench/irix/` + +Everything above runs with no operating system, which is the right way to +measure a CPU and the wrong way to answer "is this usable". `bench/irix/` +measures the machine as a user meets it — a filesystem on an emulated SCSI +disk, IRIX's buffer cache and syscall path, the tools that shipped in the box, +and the X server driving REX3: + +```sh +# with an emulator already running --ci and IRIX sitting at a shell prompt +iris-bench irix --socket /tmp/iris.sock +``` + +Steps live in `bench/irix/steps.toml` and are ordinary shell one-liners: `dd` +through the filesystem cold and warm, 500 small files created/stat'd/removed, +`sum`/`compress`/`gzip`/`tar`, 256 MB through the read/write syscall pair with +no disk in the path, loopback ping, and `xwd` reading the root window back out +of the framebuffer. A step naming a `requires` program is **skipped** when that +program is not installed rather than failed — IRIX installs vary enormously and +a missing `x11perf` is not a benchmark result. + +Timing is done on the host around one `iris-ci run`, with the measured no-op +round trip subtracted, so nothing depends on the guest having a usable clock. +Commands run as `sh -c ''` with an IRIX-shaped PATH whatever the login +shell is — so a step command may not contain a single quote. + +Nothing here is checksummed. These are IRIX's own tools against IRIX's own +filesystem and their output is not ours to predict; the accuracy score belongs +to the bare-metal half. + +> **Status: written, not yet run.** There is no IRIX disk image in this working +> tree, so the step list has been reasoned about rather than executed. The +> per-step `requires` gate is what makes that safe to ship — an install that +> lacks a tool skips it — but expect to adjust paths on first contact. + +## Running it elsewhere + +**Through the PROM**, which is also how you would run it on real hardware: + +```sh +make -C bench image # volume-header image via mkvh +bench/run/run-prom.sh # boot -f dksc(0,2,8)irisbench +``` + +Slower than `--load-elf`, but it exercises the real path: the PROM reads the +volume header, loads the ELF and jumps to it. An image built this way can be +burned to a CD and booted on an actual Indy — which is the only way to get a +reference number that is not an emulator's opinion of itself. There is no test +device on real hardware, so results come back over serial with CP0 Count as the +time base; the header says so. + +**Filtering.** There is no runtime selector — a bare-metal binary loaded with +`--load-elf` has nowhere to take arguments from. Comment out a group in +`harness/groups.c` and rebuild, or filter in the report. + +--- + +## Layout + +``` +harness/ benchlib (time base, work area, checksums), main (the runner), + bmath (a libm that is the same everywhere), link.ld, string.c, + tlbasm.S (the TLB refill handler for sys/tlb_miss) +kernels/ integer fpu memory imaging codec sys +gen/ golden.c (the oracle) + hostplat.c (the host platform layer) +golden/ golden.h — generated, checked in +run/ bare.toml, run-local.sh, run-prom.sh +``` + +The toolchain probe, the SCC console and the startup/exception code are shared +with `cpu-tests/` rather than duplicated. What is *not* shared is the float ABI +(this suite wants the FPU; cpu-tests drives it by hand under `-msoft-float`), +the link layout (this one needs megabytes of working set above `_end`), and the +runner. + +The two suites answer different questions and neither replaces the other. +cpu-tests asks "is this instruction correct", one instruction at a time with +clean state. This asks "is it still correct after ten million of them, and how +long did that take". diff --git a/bench/gen/golden.c b/bench/gen/golden.c new file mode 100644 index 0000000..3ee9e80 --- /dev/null +++ b/bench/gen/golden.c @@ -0,0 +1,59 @@ +/* + * golden.c — the oracle. + * + * Runs every checked kernel natively and prints golden/golden.h. Compiled from + * the same kernel sources the guest runs, so the two differ only in the + * machine underneath — which is the whole point. + * + * Built with -O1 -fno-tree-vectorize -ffp-contract=off. Not superstition: + * reassociating a floating-point reduction or fusing a multiply-add changes + * results in the last ulp, and the checksums fold in raw bit patterns. GCC + * will not reassociate FP without -ffast-math, but x86-64 will happily + * contract into an FMA if it is allowed to, and MIPS III has no FMA to match + * it with. + */ + +#include "benchlib.h" + +extern const struct bench_group *const all_bgroups[]; +extern const unsigned n_bgroups; + +void bench_init(void); + +int main(void) +{ + unsigned gi, bi, total = 0; + + bench_init(); + + printf("/* golden.h — generated by bench/gen/golden.c; do not edit.\n"); + printf(" *\n"); + printf(" * Expected checksums, computed natively. Regenerate with:\n"); + printf(" * make -C bench golden\n"); + printf(" */\n"); + printf("#ifndef GOLDEN_H\n#define GOLDEN_H\n\n"); + + for (gi = 0; gi < n_bgroups; gi++) total += all_bgroups[gi]->count; + printf("#define BENCH_MAX_RESULTS %u\n\n", total + 16); + + printf("struct golden_entry { const char *name; u64 sum; };\n"); + printf("static const struct golden_entry goldens[] = {\n"); + + for (gi = 0; gi < n_bgroups; gi++) { + const struct bench_group *g = all_bgroups[gi]; + for (bi = 0; bi < g->count; bi++) { + const struct bench *b = &g->benches[bi]; + u64 sum; + if (!b->verify) continue; + work_reset(); + sum = b->verify(); + printf(" { \"%s\", 0x%016llxull },\n", b->name, (unsigned long long)sum); + fprintf(stderr, " %-24s %016llx\n", b->name, (unsigned long long)sum); + } + } + + printf("};\n"); + printf("static const unsigned n_goldens = sizeof(goldens) / sizeof(goldens[0]);\n\n"); + printf("#endif /* GOLDEN_H */\n"); + return 0; +} diff --git a/bench/gen/hostplat.c b/bench/gen/hostplat.c new file mode 100644 index 0000000..3fa3406 --- /dev/null +++ b/bench/gen/hostplat.c @@ -0,0 +1,114 @@ +/* + * hostplat.c — the platform layer for the host build. + * + * Everything harness/benchlib.c and cpu-tests' console.c provide on the + * machine, provided here by a libc: a bump allocator over malloc, a monotonic + * clock, and a console. Deliberately the same shapes, so harness/main.c + * compiles against either without a single #if of its own beyond the two that + * name genuinely machine-only things. + * + * There is no instruction counter. The host's retired-instruction count would + * need perf counters and root, and the number it is compared against — guest + * instructions per host second — has no host analogue anyway. It reports 0, + * and the runner prints "n/a" rather than a fiction. + */ + +#include "benchlib.h" + +#include +#include + +unsigned char *work; +u32 work_bytes; +static unsigned char *pool; +static u32 pool_used; + +u32 cpu_kind, cpu_prid, cpu_fir, cpu_config; +int have_l2; +int have_timebase = 1; +int have_testdev; +u64 count_hz_measured = 1000000000ull; /* the host clock is the time base */ + +void work_reset(void) { pool_used = 0; } + +void *work_alloc(u32 n, u32 align) +{ + u32 off = (pool_used + (align - 1)) & ~(align - 1); + if (off + n > work_bytes) { + con_printf("\nwork_alloc(%u, %u) at offset %u of %u\n", n, align, off, work_bytes); + panic("work area exhausted"); + } + pool_used = off + n; + return pool + off; +} + +u64 bench_host_ns(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (u64)ts.tv_sec * 1000000000ull + (u64)ts.tv_nsec; +} + +u64 bench_icount(void) { return 0; } +u32 bench_cp0_count(void) { return (u32)bench_host_ns(); } + +void testdev_probe(void) { have_testdev = 0; } + +void bench_exc_reset(void) { } +u32 bench_exc_count(void) { return 0; } + +void testdev_exit(u32 code) { fflush(stdout); exit((int)code); } + +void panic(const char *msg) +{ + fflush(stdout); + fprintf(stderr, "\nPANIC: %s\n", msg); + exit(127); +} + +void con_hex32(u32 v) { printf("0x%08x", v); } +void con_hex64(u64 v) { printf("0x%016llx", (unsigned long long)v); } +void con_udec(unsigned long long v) { printf("%llu", v); } +void con_dec(long long v) { printf("%lld", v); } + +/* Only the conversions harness/main.c actually uses. */ +void con_printf(const char *fmt, ...) +{ + va_list ap; + va_start(ap, fmt); + for (; *fmt; fmt++) { + if (*fmt != '%') { con_putc(*fmt); continue; } + fmt++; + switch (*fmt) { + case 's': con_puts(va_arg(ap, const char *)); break; + case 'c': con_putc(va_arg(ap, int)); break; + case 'd': con_dec(va_arg(ap, int)); break; + case 'u': con_udec((unsigned)va_arg(ap, unsigned int)); break; + case 'x': con_hex32(va_arg(ap, u32)); break; + case 'X': con_hex64(va_arg(ap, u64)); break; + case '%': con_putc('%'); break; + case '\0': con_putc('%'); va_end(ap); return; + default: con_putc('%'); con_putc(*fmt); break; + } + } + va_end(ap); +} + +/* + * Stand-in for benchlib.c's bench_init. Claims to be both CPUs so nothing is + * skipped, and takes the whole working set up front so a kernel never measures + * a page fault that the guest — running in physical RAM with no demand paging + * — would never take. + */ +void bench_init(void) +{ + u32 i; + cpu_kind = BCPU_R4400 | BCPU_R5000; + work_bytes = WORK_WANT_BYTES; + pool = (unsigned char *)malloc(work_bytes + 8192); + if (!pool) panic("host: out of memory for the work area"); + pool = (unsigned char *)(((unsigned long)pool + 4095) & ~4095ul); + for (i = 0; i < work_bytes; i += 4096) pool[i] = 0; + work = pool; + pool_used = 0; +} diff --git a/bench/golden/golden.h b/bench/golden/golden.h new file mode 100644 index 0000000..6178e05 --- /dev/null +++ b/bench/golden/golden.h @@ -0,0 +1,56 @@ +/* golden.h — generated by bench/gen/golden.c; do not edit. + * + * Expected checksums, computed natively. Regenerate with: + * make -C bench golden + */ +#ifndef GOLDEN_H +#define GOLDEN_H + +#define BENCH_MAX_RESULTS 56 + +struct golden_entry { const char *name; u64 sum; }; +static const struct golden_entry goldens[] = { + { "int/alu", 0x152c986014248fb5ull }, + { "int/alu_ilp", 0xb80f49e42eb46bb6ull }, + { "int/alu64", 0x828057639138a309ull }, + { "int/muldiv", 0x661d902f3a9cff3cull }, + { "int/branch", 0x2c1cfe10c4708a79ull }, + { "int/bitops", 0x840547bc99a1a019ull }, + { "int/dhrystone", 0xe5c6550cf608d8caull }, + { "fpu/scalar_d", 0x9d5a929882c8d4e5ull }, + { "fpu/scalar_s", 0x5d9ce14e39dcfffdull }, + { "fpu/divsqrt", 0x741a8d17f8eddcb3ull }, + { "fpu/transcend", 0x87dd7704315ab146ull }, + { "fpu/whetstone", 0xce0b48fcfaf1a302ull }, + { "fpu/linpack", 0x1fdc5081a1a8bd98ull }, + { "fpu/matmul", 0xa67a87a912b0b821ull }, + { "mem/latency_l1", 0xf7d3cc3409bcd464ull }, + { "mem/latency_l2", 0x0e8e2d9ebf4bb54cull }, + { "mem/latency_dram", 0x15b86e243bdc125dull }, + { "mem/copy", 0xb603341656905fe2ull }, + { "mem/fill", 0x3714cb7062b034f0ull }, + { "mem/stream_copy", 0x01ab715dd28a1c33ull }, + { "mem/stream_scale", 0x3d9d9f8dcc5f249cull }, + { "mem/stream_triad", 0xee91d33a7644d508ull }, + { "mem/unaligned", 0x70a06eac048fbfe3ull }, + { "mem/random", 0x563c9578cc9b25bfull }, + { "img/rgb2ycbcr", 0x2da00fe31ab538fbull }, + { "img/convolve3x3", 0xcda201c186a71be1ull }, + { "img/sharpen5x5", 0x31ea19fdf98f8655ull }, + { "img/dct8x8", 0x7ee518e56ab19702ull }, + { "img/resize", 0xe26bc1b11f3b4ae5ull }, + { "img/rotate90", 0x9e0c2f28ac68fbf4ull }, + { "img/composite", 0xaf2ee2ecadac6f8cull }, + { "img/dither", 0xb79d5d36274fa4e5ull }, + { "img/histogram", 0x2382af06fbf2887aull }, + { "vid/motion_est", 0x3cffc53002d7bfceull }, + { "vid/yuv2rgb", 0x539219c94e480ca9ull }, + { "codec/crc32", 0x9c366318b484228cull }, + { "codec/adler32", 0x37dc273596909791ull }, + { "codec/rle", 0xb2546ae87656cf32ull }, + { "codec/lz", 0x7d1497e7349e5230ull }, + { "codec/huffman", 0x7fb2cfb4b1a1c603ull }, +}; +static const unsigned n_goldens = sizeof(goldens) / sizeof(goldens[0]); + +#endif /* GOLDEN_H */ diff --git a/bench/harness/benchlib.c b/bench/harness/benchlib.c new file mode 100644 index 0000000..0cb43e2 --- /dev/null +++ b/bench/harness/benchlib.c @@ -0,0 +1,254 @@ +/* benchlib.c — time base, working memory, and machine identity. */ + +#include "benchlib.h" +#include "cp0.h" +#include "excoff.h" + +u32 cpu_kind, cpu_prid, cpu_fir, cpu_config; +int have_l2; +int have_timebase; +u64 count_hz_measured = BENCH_COUNT_HZ_ASSUMED; + +unsigned char *work; +u32 work_bytes; +static u32 work_used; + +/* start.S's exception dispatcher stores through these. The benchmark suite + * only needs the TLB-refill path (sys/tlb_miss) and a trap round trip + * (sys/exception), but the dispatcher is shared verbatim with cpu-tests, so + * the whole record has to exist. */ +volatile struct exc_record exc; +volatile u32 exc_resume_mode = EXC_RESUME_SKIP; +volatile u32 exc_user_handler = 0; + +_Static_assert(__builtin_offsetof(struct exc_record, count) == EXC_O_COUNT, "exc.count"); +_Static_assert(__builtin_offsetof(struct exc_record, status) == EXC_O_STATUS, "exc.status"); +_Static_assert(__builtin_offsetof(struct exc_record, cause) == EXC_O_CAUSE, "exc.cause"); +_Static_assert(__builtin_offsetof(struct exc_record, vector) == EXC_O_VECTOR, "exc.vector"); +_Static_assert(__builtin_offsetof(struct exc_record, fcsr) == EXC_O_FCSR, "exc.fcsr"); +_Static_assert(__builtin_offsetof(struct exc_record, epc) == EXC_O_EPC, "exc.epc"); +_Static_assert(__builtin_offsetof(struct exc_record, badvaddr) == EXC_O_BADVADDR, "exc.badvaddr"); +_Static_assert(__builtin_offsetof(struct exc_record, errorepc) == EXC_O_ERROREPC, "exc.errorepc"); +_Static_assert(__builtin_offsetof(struct exc_record, entryhi) == EXC_O_ENTRYHI, "exc.entryhi"); +_Static_assert(__builtin_offsetof(struct exc_record, context) == EXC_O_CONTEXT, "exc.context"); +_Static_assert(__builtin_offsetof(struct exc_record, xcontext) == EXC_O_XCONTEXT, "exc.xcontext"); +_Static_assert(sizeof(struct exc_record) == EXC_SIZEOF, "exc_record size"); + +extern u32 tramp_tlb[], tramp_tlb_end[]; +extern u32 tramp_xtlb[], tramp_xtlb_end[]; +extern u32 tramp_general[], tramp_general_end[]; + +#define RD32(a) (*(volatile u32 *)(unsigned long)(a)) + +/* ── time base ────────────────────────────────────────────────────────────── */ + +u64 bench_host_ns(void) +{ + u32 lo, hi; + if (!have_timebase) return 0; + /* LO first: that read latches the whole 64-bit sample, and HI then returns + * the high half of the same one. The other order reads two samples. */ + lo = RD32(TESTDEV_HOST_NS_LO); + hi = RD32(TESTDEV_HOST_NS_HI); + return ((u64)hi << 32) | lo; +} + +u64 bench_icount(void) +{ + u32 lo, hi; + if (!have_timebase) return 0; + lo = RD32(TESTDEV_ICOUNT_LO); + hi = RD32(TESTDEV_ICOUNT_HI); + return ((u64)hi << 32) | lo; +} + +u32 bench_cp0_count(void) { return cp0_count(); } + +/* + * Decide whether the host time base is really there. + * + * Reading TESTDEV_CAPS and believing the answer is not enough. An emulator + * build from before these registers existed decodes only 16 bytes and repeats, + * so offset 0x20 aliases back onto SIGNATURE and 0x10 onto SIGNATURE as well — + * and SIGNATURE ('IRIS') has bit 0 set, so a naive `caps & CAP_TIMEBASE` test + * says yes, then every timing comes back as a frozen clock. Which is exactly + * what happened the first time this ran. + * + * So: reject a CAPS word that is the signature, and then require the clock to + * actually move. Nothing else about the suite is safe if this is wrong, and + * falling back to CP0 Count is a perfectly good second choice. + */ +static int probe_timebase(void) +{ + u32 caps = RD32(TESTDEV_CAPS); + u64 a, b; + volatile u32 spin; + + if (caps == TESTDEV_MAGIC) return 0; /* aliased SIGNATURE */ + if (!(caps & TESTDEV_CAP_TIMEBASE)) return 0; + + have_timebase = 1; /* bench_host_ns gates on it */ + a = bench_host_ns(); + for (spin = 0; spin < 100000u; spin++) { } + b = bench_host_ns(); + have_timebase = 0; + + return b > a; +} + +/* + * Measure the CP0 Count rate against the host clock. + * + * Under IRIS, Count is virtual: mips_core.rs materializes it from a wall-clock + * anchor at a `count_hz` that is *inferred* from the guest's own Compare + * writes, and a bare-metal binary never writes a plausible one — so it sits at + * the 33 MHz default rather than at the ~100 MHz a real 200 MHz R4400 would + * show. Assuming either number would silently scale every Count-derived + * figure. Measuring it turns that into data: the ratio between what Count + * claims and what the host clock says is itself a report on the emulator's + * timer model, and it is what makes the fallback path (real hardware, no test + * device) honest about being an assumption. + */ +static void calibrate_count(void) +{ + u64 t0, t1, dns; + u32 c0, c1, dc; + volatile u32 spin; + + if (!have_timebase) { count_hz_measured = BENCH_COUNT_HZ_ASSUMED; return; } + + /* Long enough that Count's ~30 ns granularity and the device-read overhead + * are both noise, short enough not to matter to total suite runtime. */ + t0 = bench_host_ns(); + c0 = bench_cp0_count(); + for (spin = 0; spin < 2000000u; spin++) { } + c1 = bench_cp0_count(); + t1 = bench_host_ns(); + + dns = t1 - t0; + dc = c1 - c0; /* 32-bit, wraps correctly */ + count_hz_measured = dns ? ((u64)dc * 1000000000ull) / dns : BENCH_COUNT_HZ_ASSUMED; +} + +/* ── working memory ───────────────────────────────────────────────────────── */ + +extern unsigned char _work_start[]; + +/* + * Find how much RAM there is above the image by writing a signature to the top + * of each candidate size and reading it back through KSEG1, so a cached write + * that never reached DRAM cannot be mistaken for real memory. Banks that are + * not populated alias or swallow, and both show up as a mismatch. + */ +static void probe_work(void) +{ + u32 want = WORK_WANT_BYTES; + work = _work_start; + while (want >= 1024u * 1024u) { + volatile u32 *k0 = (volatile u32 *)SEXT_PTR((u32)(unsigned long)work + want - 4); + volatile u32 *k1 = (volatile u32 *)K1_PTR((u32)(unsigned long)work + want - 4); + *k0 = 0xA5A5F00Du; + dcache_wb_range((volatile void *)k0, 4); + if (*k1 == 0xA5A5F00Du) break; + want >>= 1; + } + work_bytes = want >= 1024u * 1024u ? want : 0; + work_used = 0; + if (work_bytes == 0) panic("no usable work RAM above the image"); +} + +void work_reset(void) { work_used = 0; } + +void *work_alloc(u32 n, u32 align) +{ + u32 off = (work_used + (align - 1)) & ~(align - 1); + if (off + n > work_bytes) { + con_printf("\nwork_alloc(%u, %u) at offset %u of %u\n", n, align, off, work_bytes); + panic("work area exhausted"); + } + work_used = off + n; + return work + off; +} + +/* ── cache maintenance ────────────────────────────────────────────────────── */ + +/* Step 16 — the R4400 line size, and correct-if-redundant for the R5000's 32. */ +#define RANGE_LOOP(op, addr, len) \ + do { \ + char *__p = (char *)((unsigned long)(addr) & ~15ul); \ + char *__e = (char *)(((unsigned long)(addr) + (len) + 15) & ~15ul); \ + for (; __p != __e; __p += 16) CACHE_OP(op, __p); \ + } while (0) + +void dcache_wb_range(volatile void *addr, u32 len) +{ + RANGE_LOOP(CACHE_D | CACHE_OP_HIT_WB_INV, addr, len); + if (have_l2) RANGE_LOOP(CACHE_SD | CACHE_OP_HIT_WB_INV, addr, len); +} + +void dcache_inv_range(volatile void *addr, u32 len) +{ + RANGE_LOOP(CACHE_D | CACHE_OP_HIT_INV, addr, len); + if (have_l2) RANGE_LOOP(CACHE_SD | CACHE_OP_HIT_INV, addr, len); +} + +void icache_inv_range(volatile void *addr, u32 len) +{ + RANGE_LOOP(CACHE_I | CACHE_OP_HIT_INV, addr, len); + if (have_l2) RANGE_LOOP(CACHE_SD | CACHE_OP_HIT_WB_INV, addr, len); +} + +/* ── exception vectors ────────────────────────────────────────────────────── */ + +static void install_one(u32 vec, const u32 *src, const u32 *end) +{ + volatile u32 *dst = (volatile u32 *)SEXT_PTR(vec); + unsigned n = (unsigned)(end - src), i; + if (n > 32) panic("vector trampoline too long"); + for (i = 0; i < n; i++) dst[i] = src[i]; +} + +void exc_install(void) +{ + install_one(VEC_TLB_REFILL, tramp_tlb, tramp_tlb_end); + install_one(VEC_XTLB_REFILL, tramp_xtlb, tramp_xtlb_end); + install_one(VEC_GENERAL, tramp_general, tramp_general_end); + dcache_wb_range(SEXT_PTR(VEC_TLB_REFILL), 0x200); + icache_inv_range(SEXT_PTR(VEC_TLB_REFILL), 0x200); + SYNC(); +} + +void bench_exc_reset(void) { exc.count = 0; } +u32 bench_exc_count(void) { return exc.count; } + +void exc_clear(void) +{ + exc.count = 0; exc.status = 0; exc.cause = 0; exc.vector = 0; exc.fcsr = 0; + exc.epc = 0; exc.badvaddr = 0; exc.errorepc = 0; exc.entryhi = 0; + exc.context = 0; exc.xcontext = 0; + exc_resume_mode = EXC_RESUME_SKIP; + exc_user_handler = 0; +} + +/* ── startup ──────────────────────────────────────────────────────────────── */ + +void bench_init(void) +{ + cpu_prid = cp0_prid(); + cpu_config = cp0_config(); + cpu_fir = fir(); + switch (PRID_IMP(cpu_prid)) { + case IMP_R4400: cpu_kind = BCPU_R4400; break; + case IMP_R5000: cpu_kind = BCPU_R5000; break; + default: cpu_kind = 0; break; + } + have_l2 = (cpu_config & CFG_SC) == 0; + + testdev_probe(); + have_timebase = have_testdev && probe_timebase(); + + exc_clear(); + exc_install(); + probe_work(); + calibrate_count(); +} diff --git a/bench/harness/benchlib.h b/bench/harness/benchlib.h new file mode 100644 index 0000000..4ae7e9c --- /dev/null +++ b/bench/harness/benchlib.h @@ -0,0 +1,230 @@ +/* benchlib.h — the benchmark harness API. + * + * A benchmark is a pair of functions and a table entry. `verify()` runs one + * fixed reference workload and returns a checksum, which the runner compares + * against a golden value computed by building the same source for the host — + * that is the accuracy score. `run(n)` performs n iterations of the same kind + * of work and returns how many work units that was, and the runner calls it + * with whatever n makes the measurement long enough to mean something — that + * is the performance score. The two are separate on purpose: an + * autoscaled iteration count would make the checksum depend on how fast the + * host is. + */ +#ifndef BENCHLIB_H +#define BENCHLIB_H + +#if defined(BENCH_HOST) +# include "hostshim.h" +#else +# include "console.h" +#endif + +/* ── time base ────────────────────────────────────────────────────────────── */ + +/* True when the IRIS test device is present and advertises TESTDEV_CAP_TIMEBASE. + * Without it (real hardware, or an older emulator build) the suite falls back + * to CP0 Count and says so in its output, because everything derived from a + * guessed Count frequency is a guess. */ +extern int have_timebase; + +/* Host monotonic nanoseconds. 0 when !have_timebase. */ +u64 bench_host_ns(void); +/* Guest instructions retired. 0 when !have_timebase. */ +u64 bench_icount(void); +/* CP0 Count, which on real hardware is the CPU clock / 2 and under IRIS is a + * virtual clock derived from `count_hz` — hence measured, not assumed. */ +u32 bench_cp0_count(void); + +/* CP0 Count ticks per real second, measured against the host clock at startup. + * BENCH_COUNT_HZ_ASSUMED when there is no host clock to measure against. */ +extern u64 count_hz_measured; +#define BENCH_COUNT_HZ_ASSUMED 100000000ull /* a 200 MHz R4x00: Count = clk/2 */ + +/* ── working memory ───────────────────────────────────────────────────────── */ + +/* Scratch RAM immediately above the image, sized by probing at startup. Every + * kernel that needs a buffer carves it out of here rather than declaring its + * own, so the total footprint is known and the cache-hierarchy sweeps can rely + * on it being physically contiguous KSEG0. */ +extern unsigned char *work; +extern u32 work_bytes; +#define WORK_WANT_BYTES (24u * 1024u * 1024u) + +/* + * Carve `n` bytes off the work area, aligned to `align`. Panics if the area is + * exhausted — a kernel silently getting a smaller buffer than it asked for + * would make its score meaningless rather than obviously wrong. + * + * This is a bump allocator with no free: call it once per run(), OUTSIDE the + * iteration loop. A kernel that allocates per iteration exhausts 24 MB in a + * handful of passes, and because the runner resets between benchmarks it does + * so only at whatever iteration count the autoscaler happened to pick — which + * makes it look like a data-dependent failure rather than the plain bug it is. + */ +void *work_alloc(u32 n, u32 align); +/* Reset the bump allocator. The runner does this before each benchmark, so + * every kernel sees the same addresses and the same cache alignment. */ +void work_reset(void); + +/* ── checksums ────────────────────────────────────────────────────────────── */ + +/* FNV-1a over a stream of 64-bit values. Order-sensitive, which is what we + * want: a kernel that produces the right values in the wrong order is broken. */ +#define CKSUM_INIT 0xcbf29ce484222325ull +static inline u64 cksum_u64(u64 h, u64 v) +{ + int i; + for (i = 0; i < 8; i++) { + h ^= (v >> (i * 8)) & 0xFF; + h *= 0x100000001b3ull; + } + return h; +} +static inline u64 cksum_bytes(u64 h, const void *p, u32 n) +{ + const unsigned char *b = (const unsigned char *)p; + u32 i; + for (i = 0; i < n; i++) { h ^= b[i]; h *= 0x100000001b3ull; } + return h; +} +/* Fold a double in by its bit pattern, so the comparison is exact rather than + * "close enough" — a one-ulp difference is exactly the kind of FPU fault this + * suite exists to find. */ +static inline u64 cksum_f64(u64 h, double d) +{ + union { double d; u64 u; } cv; + cv.d = d; + return cksum_u64(h, cv.u); +} +static inline u64 cksum_f32(u64 h, float f) +{ + union { float f; u32 u; } cv; + cv.f = f; + return cksum_u64(h, (u64)cv.u); +} + +/* ── deterministic input data ─────────────────────────────────────────────── */ + +/* xorshift64*: every kernel seeds its own copy, so kernels never depend on the + * order they ran in. */ +static inline u64 rng_next(u64 *s) +{ + u64 x = *s; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + *s = x; + return x * 0x2545F4914F6CDD1Dull; +} + +/* Keep the optimiser from folding a value away or hoisting the work that + * produced it out of a loop. */ +#define OPAQUE(x) ({ __typeof__(x) __o = (x); __asm__ __volatile__("" : "+r"(__o)); __o; }) +#define SINK(x) __asm__ __volatile__("" :: "r"(x) : "memory") + +/* ── the three libc functions the compiler emits calls to ─────────────────── */ +/* Defined in harness/string.c for the MIPS build; hostshim.h pulls the real + * ones in for the golden generator. */ +#if !defined(BENCH_HOST) +void *memset(void *dst, int c, unsigned long n); +void *memcpy(void *dst, const void *src, unsigned long n); +void *memmove(void *dst, const void *src, unsigned long n); +int memcmp(const void *a, const void *b, unsigned long n); +#endif + +/* ── benchmark registration ───────────────────────────────────────────────── */ + +#define BG_INT 0x01 +#define BG_FPU 0x02 +#define BG_MEM 0x04 +#define BG_IMG 0x08 +#define BG_CODEC 0x10 +#define BG_SYS 0x20 +#define BG_ALL 0x3F + +/* CPU applicability, same convention as cpu-tests. */ +#define BCPU_R4400 0x1 +#define BCPU_R5000 0x2 +#define BCPU_ALL (BCPU_R4400 | BCPU_R5000) + +/* A kernel that is *supposed* to take exceptions. Everything else taking one + * is a bug: the shared dispatcher records and steps over the faulting + * instruction, so a kernel that faults does not crash — it quietly produces a + * number for doing something other than what it claims. mem/unaligned scored a + * plausible-looking 871 k accesses/s that way, taking an address error three + * loads in four, and only the checksum gave it away. */ +#define BF_TAKES_EXC 0x1 + +struct bench { + const char *name; /* "img/dct8x8" — group/kernel */ + const char *unit; /* work unit: "ops", "B", "px", "blk", "flop" */ + u64 (*verify)(void); /* fixed reference run -> checksum; 0 if unchecked */ + u64 (*run)(u32 iters); /* timed loop -> work units performed */ + u32 base_iters; /* first guess for the autoscaler */ + u32 group; /* BG_* */ + u32 cpus; /* BCPU_* */ + u32 flags; /* BF_* */ +}; + +struct bench_group { + const char *name; + const struct bench *benches; + unsigned count; +}; + +#define BENCH(n, u, v, r, it, g) { n, u, v, r, it, g, BCPU_ALL, 0 } +#define BENCH_EXC(n, u, v, r, it, g) { n, u, v, r, it, g, BCPU_ALL, BF_TAKES_EXC } +#define BENCH_CPU(n, u, v, r, it, g, c) { n, u, v, r, it, g, c, 0 } + +#define DECLARE_BGROUP(g) extern const struct bench_group g + +/* ── CPU identity ─────────────────────────────────────────────────────────── */ +extern u32 cpu_kind, cpu_prid, cpu_fir, cpu_config; +extern int have_l2; + +/* Bring up the time base, the work area and the machine identity. benchlib.c + * on MIPS, gen/hostplat.c on the host. */ +void bench_init(void); + +/* Exceptions taken since the last bench_exc_reset(). Always 0 on the host, + * which has no exception vector to count through. */ +void bench_exc_reset(void); +u32 bench_exc_count(void); + +#if !defined(BENCH_HOST) + +/* ── machine plumbing (MIPS build only) ───────────────────────────────────── */ + +void dcache_wb_range(volatile void *addr, u32 len); +void dcache_inv_range(volatile void *addr, u32 len); +void icache_inv_range(volatile void *addr, u32 len); + +/* Field order and padding are fixed by excoff.h, which start.S's shared + * dispatcher stores through; benchlib.c static-asserts every offset. */ +struct exc_record { + u32 count; + u32 status; + u32 cause; + u32 vector; + u32 fcsr; + u32 pad; + u64 epc; + u64 badvaddr; + u64 errorepc; + u64 entryhi; + u64 context; + u64 xcontext; +}; +extern volatile struct exc_record exc; + +#define EXC_RESUME_SKIP 0 +#define EXC_RESUME_RETRY 1 +extern volatile u32 exc_resume_mode; +extern volatile u32 exc_user_handler; + +void exc_clear(void); +void exc_install(void); + +#endif /* !BENCH_HOST */ + +#endif /* BENCHLIB_H */ diff --git a/bench/harness/bmath.h b/bench/harness/bmath.h new file mode 100644 index 0000000..5c97ac8 --- /dev/null +++ b/bench/harness/bmath.h @@ -0,0 +1,166 @@ +/* bmath.h — the libm the benchmark suite carries with it. + * + * Every transcendental here is a polynomial in +, -, * and / (plus the + * hardware square root), evaluated in a fixed order. That is not a stylistic + * choice: the suite's accuracy score compares the guest's results against + * golden checksums computed by building these same sources for the host, so + * every operation on the path has to be one IEEE-754 defines exactly. A real + * libm call would not be — two libms disagree in the last ulp, and the + * checksum would then report an emulator fault that is really a libm + * difference. + * + * Accuracy of the approximations themselves does not matter for the same + * reason. They are good to roughly single-precision over the reduced range, + * which is plenty for a workload, and whatever they compute they compute + * identically everywhere. + */ +#ifndef BMATH_H +#define BMATH_H + +#define B_PI 3.14159265358979323846 +#define B_TWO_PI 6.28318530717958647692 +#define B_PI_2 1.57079632679489661923 +#define B_LN2 0.69314718055994530942 + +/* Hardware square root. MIPS III has sqrt.d/sqrt.s and x86-64 has sqrtsd/ + * sqrtss; IEEE-754 requires both to be correctly rounded, so they agree bit + * for bit. Anything else here would not. */ +#if defined(BENCH_HOST) +static inline double b_sqrt(double x) { return __builtin_sqrt(x); } +static inline float b_sqrtf(float x) { return __builtin_sqrtf(x); } +#else +static inline double b_sqrt(double x) +{ + double r; + __asm__(".set push; .set mips3; .set hardfloat\n\t" + "sqrt.d %0, %1\n\t" + ".set pop" : "=f"(r) : "f"(x)); + return r; +} +static inline float b_sqrtf(float x) +{ + float r; + __asm__(".set push; .set mips3; .set hardfloat\n\t" + "sqrt.s %0, %1\n\t" + ".set pop" : "=f"(r) : "f"(x)); + return r; +} +#endif + +static inline double b_fabs(double x) { return x < 0.0 ? -x : x; } + +/* Round-to-nearest via the "add and subtract a big constant" trick — exact in + * round-to-nearest-even for |x| < 2^52, and free of any conversion + * instruction, so it cannot pick up a flush-to-zero or an invalid-operation + * difference on the way through an integer register. */ +static inline double b_round(double x) +{ + const double big = 6755399441055744.0; /* 3 * 2^51 */ + if (b_fabs(x) >= 4503599627370496.0) return x; + return x >= 0.0 ? (x + big) - big : (x - big) + big; +} + +/* sin over the full line: reduce by 2*pi, fold into [-pi/2, pi/2], then a + * degree-13 odd minimax polynomial. */ +static inline double b_sin(double x) +{ + double y, y2, r; + int neg = 0; + x = x - B_TWO_PI * b_round(x / B_TWO_PI); /* -> [-pi, pi] */ + if (x > B_PI_2) { x = B_PI - x; } + else if (x < -B_PI_2) { x = -B_PI - x; } + y = x; + if (y < 0.0) { y = -y; neg = 1; } + y2 = y * y; + r = -2.5052108385441718e-8; + r = r * y2 + 2.7557319223985893e-6; + r = r * y2 - 1.9841269841269841e-4; + r = r * y2 + 8.3333333333333333e-3; + r = r * y2 - 1.6666666666666666e-1; + r = r * y2 * y + y; + return neg ? -r : r; +} + +static inline double b_cos(double x) { return b_sin(x + B_PI_2); } + +/* exp: split into 2^k * exp(f) with |f| <= ln2/2, then a degree-7 series. + * The 2^k scaling is done by repeated multiplication rather than by building + * an exponent field through a union, so it stays pure arithmetic. */ +static inline double b_exp(double x) +{ + double k, f, r, p; + int i, n; + if (x > 700.0) x = 700.0; + if (x < -700.0) x = -700.0; + k = b_round(x / B_LN2); + f = x - k * B_LN2; + r = 1.0 / 5040.0; + r = r * f + 1.0 / 720.0; + r = r * f + 1.0 / 120.0; + r = r * f + 1.0 / 24.0; + r = r * f + 1.0 / 6.0; + r = r * f + 0.5; + r = r * f + 1.0; + r = r * f + 1.0; + n = (int)k; + p = 1.0; + if (n >= 0) { for (i = 0; i < n; i++) p *= 2.0; } + else { for (i = 0; i < -n; i++) p *= 0.5; } + return r * p; +} + +/* log for x > 0: scale into [2/3, 4/3] by halving/doubling, then atanh series + * on (x-1)/(x+1). */ +static inline double b_log(double x) +{ + double s, s2, r; + int k = 0; + if (x <= 0.0) return -1.0e300; + while (x > 1.3333333333333333) { x *= 0.5; k++; } + while (x < 0.6666666666666666) { x *= 2.0; k--; } + s = (x - 1.0) / (x + 1.0); + s2 = s * s; + r = 2.0 / 13.0; + r = r * s2 + 2.0 / 11.0; + r = r * s2 + 2.0 / 9.0; + r = r * s2 + 2.0 / 7.0; + r = r * s2 + 2.0 / 5.0; + r = r * s2 + 2.0 / 3.0; + r = r * s2 + 2.0; + return r * s + (double)k * B_LN2; +} + +/* atan over the full line: fold |x| > 1 through the reciprocal identity, then + * a degree-19 odd polynomial on [-1, 1]. */ +static inline double b_atan(double x) +{ + double y, y2, r; + int neg = 0, inv = 0; + if (x < 0.0) { x = -x; neg = 1; } + if (x > 1.0) { x = 1.0 / x; inv = 1; } + y = x; y2 = y * y; + r = 1.0 / 19.0; + r = -1.0 / 17.0 + r * y2; + r = 1.0 / 15.0 + r * y2; + r = -1.0 / 13.0 + r * y2; + r = 1.0 / 11.0 + r * y2; + r = -1.0 / 9.0 + r * y2; + r = 1.0 / 7.0 + r * y2; + r = -1.0 / 5.0 + r * y2; + r = 1.0 / 3.0 + r * y2; + r = -1.0 + r * y2; + r = r * y2 * y + y; + if (inv) r = B_PI_2 - r; + return neg ? -r : r; +} + +static inline double b_pow_i(double x, int n) +{ + double r = 1.0; + int i; + if (n < 0) { x = 1.0 / x; n = -n; } + for (i = 0; i < n; i++) r *= x; + return r; +} + +#endif /* BMATH_H */ diff --git a/bench/harness/groups.c b/bench/harness/groups.c new file mode 100644 index 0000000..541ca7b --- /dev/null +++ b/bench/harness/groups.c @@ -0,0 +1,31 @@ +/* groups.c — the registry. + * + * Order matters only for reading the output; each benchmark is independent and + * the harness resets the work area between them. `sys` is last because it is + * the only group that leaves machine state behind (TLB entries, exception + * vectors) even though it puts it all back. + */ + +#include "benchlib.h" + +DECLARE_BGROUP(group_integer); +DECLARE_BGROUP(group_fpu); +DECLARE_BGROUP(group_memory); +DECLARE_BGROUP(group_imaging); +DECLARE_BGROUP(group_codec); +#if !defined(BENCH_HOST) +DECLARE_BGROUP(group_sys); +#endif + +const struct bench_group *const all_bgroups[] = { + &group_integer, + &group_fpu, + &group_memory, + &group_imaging, + &group_codec, +#if !defined(BENCH_HOST) + &group_sys, +#endif +}; + +const unsigned n_bgroups = sizeof(all_bgroups) / sizeof(all_bgroups[0]); diff --git a/bench/harness/hostshim.h b/bench/harness/hostshim.h new file mode 100644 index 0000000..be66361 --- /dev/null +++ b/bench/harness/hostshim.h @@ -0,0 +1,51 @@ +/* hostshim.h — what the host build substitutes for the machine. + * + * The kernels and the runner are compiled a second time, natively, and produce + * the same output block from the same code. Two things come out of that: + * + * - the golden checksums the guest is scored against, computed by an + * independent IEEE-754 implementation rather than by a previous run of the + * emulator (a checksum recorded from IRIS would agree with IRIS by + * construction, including everywhere IRIS is wrong); + * - a baseline for the machine the emulator is running on, measured by + * literally the same kernels with the same autoscaler and the same + * best-of-two, so "IRIS delivers 1/85th of native on this kernel" is a + * real ratio and not two benchmarks pretending to be comparable. + * + * Only the `sys` group is left out: a TLB refill has no host analogue. + */ +#ifndef HOSTSHIM_H +#define HOSTSHIM_H + +#include +#include +#include + +typedef unsigned char u8; +typedef unsigned short u16; +typedef unsigned int u32; +typedef unsigned long long u64; +typedef signed char s8; +typedef short s16; +typedef int s32; +typedef long long s64; + +/* The guest console API, on stdout. Buffered rather than per-character: the + * SCC costs the guest a bus transaction per byte and the host should not + * pretend to pay it. */ +static inline void con_putc(int c) { putchar(c); if (c == '\n') fflush(stdout); } +static inline void con_puts(const char *s) { fputs(s, stdout); } +void con_printf(const char *fmt, ...); +void con_hex32(u32 v); +void con_hex64(u64 v); +void con_udec(unsigned long long v); +void con_dec(long long v); +static inline void con_init(void) { } +static inline void con_flush(void) { fflush(stdout); } + +extern int have_testdev; +void testdev_probe(void); +__attribute__((noreturn)) void testdev_exit(u32 code); +__attribute__((noreturn)) void panic(const char *msg); + +#endif /* HOSTSHIM_H */ diff --git a/bench/harness/link.ld b/bench/harness/link.ld new file mode 100644 index 0000000..9cdcb13 --- /dev/null +++ b/bench/harness/link.ld @@ -0,0 +1,68 @@ +/* + * link.ld — same fixed KSEG0 home as cpu-tests (0x88200000), for the same + * reasons: physical RAM on IP22/IP24 starts at 0x08000000, so 0x80200000 is a + * hole that swallows writes, and KSEG0 is unmapped so the sys/tlb kernels can + * rewrite the TLB without unmapping the code doing it. + * + * The one structural difference from cpu-tests: the multi-megabyte working set + * lives ABOVE _end rather than inside .bss. start.S relocates and zeroes + * [_ftext, _end), and putting 24 MB in there would make it copy 24 MB from + * beyond the loaded image on the PROM boot path and then clear it for nothing. + * Every kernel initialises its own buffer anyway. + */ + +OUTPUT_FORMAT("elf32-ntradbigmips") /* n32 */ +OUTPUT_ARCH(mips) +ENTRY(_start) + +SECTIONS +{ + . = 0x88200000; + + _ftext = .; + .init : { *(.init) } + .text : { *(.text) *(.text.*) } + _etext = .; + + . = ALIGN(16); + _fdata = .; + .rodata : { *(.rodata) *(.rodata.*) } + .data : { *(.data) *(.data.*) } + + _gp = ALIGN(16) + 0x7ff0; + .lit8 : { *(.lit8) } + .lit4 : { *(.lit4) } + .sdata : { *(.sdata) *(.sdata.*) } + _edata = .; + + . = ALIGN(16); + _fbss = .; + .sbss : { *(.sbss) *(.sbss.*) *(.scommon) } + .bss : { + *(.bss) + *(.bss.*) + *(COMMON) + . = ALIGN(16); + _stack_bottom = .; + . += 0x20000; /* 128 KB — kernels recurse a little */ + _stack_top = .; + } + _end = .; + + /* Working set. 1 MB aligned so a cache-hierarchy sweep starts on a clean + * boundary in every cache the machine has, and so a 1 MB or 4 MB TLB page + * can map it in the sys/tlb kernels. Sized by probing at run time + * (probe_work); nothing is reserved here beyond the name. */ + . = ALIGN(0x100000); + _work_start = .; + + /DISCARD/ : { + *(.MIPS.abiflags) + *(.reginfo) + *(.comment) + *(.note*) + *(.gnu.attributes) + *(.pdr) + *(.eh_frame) + } +} diff --git a/bench/harness/main.c b/bench/harness/main.c new file mode 100644 index 0000000..e427b40 --- /dev/null +++ b/bench/harness/main.c @@ -0,0 +1,460 @@ +/* + * main.c — the benchmark runner. + * + * Two outputs, in this order: + * 1. a human table, because someone is usually watching a serial console; + * 2. a machine block between IRIS-BENCH-BEGIN and IRIS-BENCH-END, which is + * what bench/run and iris-bench parse. + * + * Everything printed is an integer. Not a stylistic constraint — a + * freestanding %f would need its own float formatter, and every number worth + * having here (nanoseconds, instructions, work units, checksums) is exact as + * an integer. Rates are derived by the host, which has a real printf. + */ + +#include "benchlib.h" +#include "golden.h" + +/* How long one timed run should take, in host nanoseconds. Long enough that + * the ~30 ns Count granularity and the two uncached device reads on each side + * are noise; short enough that ~50 kernels x REPEATS still finishes in about a + * minute even on the interpreter. */ +#define TARGET_NS 250000000ull +#define MIN_NS (TARGET_NS / 2) +#define REPEATS 2 +#define MAX_CAL 4 +#define MAX_ITERS 0x40000000u + +struct result { + const struct bench *b; + u32 iters; + u64 work; + u64 ns; + u64 icount; + u32 count_ticks; + u32 exc; /* exceptions taken during the timed run */ + u64 sum; + u64 gold; + int status; /* R_* */ +}; + +#define R_OK 0 +#define R_MISMATCH 1 +#define R_UNCHECKED 2 +#define R_SKIP 3 + +static const char *const status_name[] = { "OK", "MISMATCH", "UNCHECKED", "SKIP" }; + +struct tstamp { u64 ns; u64 ic; u32 cnt; }; + +static void tstamp(struct tstamp *t) +{ + /* Count last on the way in and first on the way out would be tidier, but + * the ordering that matters is that the host clock brackets the work as + * tightly as possible, since it is the one the score is computed from. */ + t->cnt = bench_cp0_count(); + t->ic = bench_icount(); + t->ns = bench_host_ns(); +} + +static u64 elapsed_ns(const struct tstamp *a, const struct tstamp *b) +{ + if (have_timebase) return b->ns - a->ns; + /* No host clock: fall back to CP0 Count at whatever rate calibrate_count + * settled on. 32-bit subtraction wraps correctly for any interval under + * a Count period (~43 s at 100 MHz), and no timed run comes close. */ + return ((u64)(u32)(b->cnt - a->cnt) * 1000000000ull) / count_hz_measured; +} + +/* ── integer formatting ───────────────────────────────────────────────────── */ + +/* num/den to `dec` decimal places, right-aligned in `width`. */ +static void con_fixed(u64 num, u64 den, int dec, int width) +{ + char buf[40]; + int n = 0, i, d; + u64 scale = 1, v; + + for (i = 0; i < dec; i++) scale *= 10; + if (den == 0) { for (i = 0; i < width - 1; i++) con_putc(' '); con_putc('-'); return; } + /* Round half up, guarding the multiply against overflow on big numerators + * by pre-dividing when it cannot fit. */ + if (num > 0xFFFFFFFFFFFFFFFFull / scale) v = (num / den) * scale; + else v = (num * scale + den / 2) / den; + + do { buf[n++] = (char)('0' + (int)(v % 10)); v /= 10; } while (v); + while (n < dec + 1) buf[n++] = '0'; + d = n + (dec ? 1 : 0); + for (i = 0; i < width - d; i++) con_putc(' '); + for (i = n - 1; i >= 0; i--) { + con_putc(buf[i]); + if (dec && i == dec) con_putc('.'); + } +} + +static void con_pad(const char *s, int width) +{ + int n = 0; + while (*s) { con_putc(*s++); n++; } + while (n < width) { con_putc(' '); n++; } +} + +/* ── the runner ───────────────────────────────────────────────────────────── */ + +extern const struct bench_group *const all_bgroups[]; +extern const unsigned n_bgroups; + +static struct result results[BENCH_MAX_RESULTS]; +static unsigned n_results; + +/* NULL when the kernel has no golden value — which is a different thing from a + * golden value of zero, and the difference is UNCHECKED versus MISMATCH. */ +static const struct golden_entry *find_golden(const char *name) +{ + unsigned i; + for (i = 0; i < n_goldens; i++) { + const char *a = name, *b = goldens[i].name; + while (*a && *a == *b) { a++; b++; } + if (*a == 0 && *b == 0) return &goldens[i]; + } + return 0; +} + +/* Grow the iteration count until one run lands near TARGET_NS. Deliberately + * conservative: a x64 cap per step keeps a first run that happened to be + * absurdly quick (a kernel the JIT compiled instantly) from jumping straight + * to an iteration count that then takes a minute. */ +static u32 calibrate(const struct bench *b) +{ + u32 iters = b->base_iters ? b->base_iters : 1; + int i; + + for (i = 0; i < MAX_CAL; i++) { + struct tstamp t0, t1; + u64 ns, scaled; + + work_reset(); + tstamp(&t0); + (void)b->run(iters); + tstamp(&t1); + ns = elapsed_ns(&t0, &t1); + + if (ns >= MIN_NS) return iters; + if (ns == 0) ns = 1; + + scaled = (u64)iters * TARGET_NS / ns; + if (scaled > (u64)iters * 64) scaled = (u64)iters * 64; + if (scaled <= iters) scaled = (u64)iters * 2; + if (scaled > MAX_ITERS) { return MAX_ITERS; } + iters = (u32)scaled; + } + return iters; +} + +static void run_one(const struct bench *b) +{ + struct result *r = &results[n_results++]; + int rep; + + r->b = b; + r->iters = 0; r->work = 0; r->ns = 0; r->icount = 0; r->count_ticks = 0; + r->exc = 0; r->sum = 0; r->gold = 0; r->status = R_UNCHECKED; + + if (!(b->cpus & cpu_kind)) { r->status = R_SKIP; return; } + + /* Accuracy first, and from its own fixed workload — the timed loop runs a + * host-dependent number of iterations, so a checksum taken from it would + * differ between a fast host and a slow one and mean nothing. */ + if (b->verify) { + const struct golden_entry *g; + work_reset(); + r->sum = b->verify(); + g = find_golden(b->name); + if (g) { + r->gold = g->sum; + r->status = (r->sum == r->gold) ? R_OK : R_MISMATCH; + } + } + + r->iters = calibrate(b); + + for (rep = 0; rep < REPEATS; rep++) { + struct tstamp t0, t1; + u64 work, ns; + + u32 exc_taken; + + work_reset(); + bench_exc_reset(); + tstamp(&t0); + work = b->run(r->iters); + tstamp(&t1); + exc_taken = bench_exc_count(); + ns = elapsed_ns(&t0, &t1); + if (ns == 0) ns = 1; + + /* An exception in ANY repeat is a defect, so keep the worst count + * rather than the one belonging to the repeat that happened to be + * fastest. */ + if (exc_taken > r->exc) r->exc = exc_taken; + + /* Best of REPEATS. The slow samples are host scheduling noise, not the + * emulator: the guest is a fixed amount of work either way. */ + if (rep == 0 || ns < r->ns) { + r->ns = ns; + r->work = work; + r->icount = t1.ic - t0.ic; + r->count_ticks = (u32)(t1.cnt - t0.cnt); + } + } +} + +/* ── reporting ────────────────────────────────────────────────────────────── */ + +static const char *cpu_name(void) +{ +#if defined(BENCH_HOST) + return "host"; +#else + if (cpu_kind == BCPU_R4400) return "R4400"; + if (cpu_kind == BCPU_R5000) return "R5000"; + return "unknown"; +#endif +} + +static void print_header(void) +{ + con_puts("\n"); + con_puts("============================================================\n"); + con_printf(" IRIS benchmark suite cpu=%s\n", cpu_name()); + con_printf(" PRId %x FIR %x Config %x L2 %s\n", + cpu_prid, cpu_fir, cpu_config, have_l2 ? "yes" : "no"); + con_printf(" test device %s host time base %s\n", + have_testdev ? "yes" : "no", have_timebase ? "yes" : "NO (CP0 Count)"); + con_printf(" work area %x", (u32)(unsigned long)work); + con_printf(" .. %x (", (u32)(unsigned long)work + work_bytes); + con_udec(work_bytes >> 20); con_puts(" MB)\n"); + con_puts(" CP0 Count "); + con_fixed(count_hz_measured, 1000000ull, 3, 1); + con_puts(" MHz "); + con_puts(have_timebase ? "(measured against the host clock)\n" + : "(ASSUMED — no host clock, timings are relative)\n"); + con_puts("============================================================\n\n"); + con_pad("benchmark", 26); + con_pad("unit", 6); + con_puts(" rate/s guest-MIPS time% acc\n"); + con_puts("------------------------------------------------------------------\n"); +} + +/* One line per benchmark, printed the moment it finishes rather than in a + * table at the end: this takes minutes, and a run that shows nothing until it + * is over is indistinguishable from a hang. `total_ns` is zero while the run + * is still going, which suppresses the share-of-total column. */ +static void print_row(const struct result *r, u64 total_ns) +{ + con_pad(r->b->name, 26); + con_pad(r->b->unit, 6); + if (r->status == R_SKIP) { con_puts(" (not on this CPU)\n"); return; } + + /* work units per second = work * 1e9 / ns, computed so a big work count + * cannot overflow the multiply. */ + if (r->work > 0xFFFFFFFFFFFFFFFFull / 1000000000ull) + con_fixed(r->work / (r->ns ? r->ns : 1) * 1000000000ull, 1, 0, 11); + else + con_fixed(r->work * 1000000000ull, r->ns, 0, 11); + + con_puts(" "); + /* Guest instructions retired per second, in millions. Zero means there is + * no instruction counter to read — the host build, or an emulator without + * the timebase registers — not a kernel that retired nothing. */ + if (r->icount) con_fixed(r->icount * 1000ull, r->ns, 2, 10); + else con_pad(" n/a", 10); + + con_puts(" "); + if (total_ns) con_fixed(r->ns * 100ull, total_ns, 1, 6); + else con_pad(" ", 6); + con_puts(" "); + con_puts(r->status == R_OK ? "ok" + : r->status == R_MISMATCH ? "FAIL" + : "-"); + /* An unexpected exception means the kernel measured something other than + * what it claims to; say so on the line rather than only in the block. */ + if (r->exc && !(r->b->flags & BF_TAKES_EXC)) { + con_puts(" exc:"); con_udec(r->exc); + } + con_putc('\n'); +} + +/* The two questions a benchmark run is actually asked: where did the wall + * clock go, and where is the emulator least efficient. They are different + * lists — a kernel can dominate the run simply by being long, and a kernel can + * be terrible per instruction while barely registering in the total. */ +static unsigned char taken[BENCH_MAX_RESULTS]; + +static void clear_taken(void) +{ + unsigned i; + for (i = 0; i < BENCH_MAX_RESULTS; i++) taken[i] = 0; +} + +static void print_hotspots(u64 total_ns) +{ + unsigned i, shown; + + clear_taken(); + con_puts("\n Where the time went (largest share of wall clock)\n"); + for (shown = 0; shown < 6; shown++) { + u64 best = 0; + unsigned bi = (unsigned)-1; + for (i = 0; i < n_results; i++) { + if (taken[i] || results[i].status == R_SKIP) continue; + if (bi == (unsigned)-1 || results[i].ns > best) { best = results[i].ns; bi = i; } + } + if (bi == (unsigned)-1) break; + taken[bi] = 1; + con_puts(" "); + con_pad(results[bi].b->name, 26); + con_fixed(results[bi].ns, 1000000ull, 1, 9); + con_puts(" ms "); + con_fixed(results[bi].ns * 100ull, total_ns, 1, 5); + con_puts("%\n"); + } + + /* No instruction counter, no efficiency ranking — only the time-share list + * above means anything then. */ + { + unsigned any = 0; + for (i = 0; i < n_results; i++) if (results[i].icount) any = 1; + if (!any) return; + } + + clear_taken(); + con_puts("\n Where the emulator works hardest (fewest guest MIPS)\n"); + for (shown = 0; shown < 6; shown++) { + u64 best = 0; + unsigned bi = (unsigned)-1; + for (i = 0; i < n_results; i++) { + u64 mips_x1000; + if (taken[i] || results[i].status == R_SKIP || results[i].ns == 0) continue; + mips_x1000 = results[i].icount * 1000ull / results[i].ns; + if (bi == (unsigned)-1 || mips_x1000 < best) { best = mips_x1000; bi = i; } + } + if (bi == (unsigned)-1) break; + taken[bi] = 1; + con_puts(" "); + con_pad(results[bi].b->name, 26); + con_fixed(results[bi].icount * 1000ull, results[bi].ns, 2, 9); + con_puts(" MIPS\n"); + } +} + +static void print_machine_block(u64 total_ns, u64 total_ic, + unsigned checked, unsigned matched) +{ + unsigned i; + + con_puts("\nIRIS-BENCH-BEGIN v1\n"); + con_printf("#machine cpu=%s prid=%x", cpu_name(), cpu_prid); + con_printf(" fir=%x config=%x", cpu_fir, cpu_config); + con_printf(" l2=%d testdev=%d", have_l2, have_testdev); + con_printf(" timebase=%d\n", have_timebase); + con_puts("#timebase count_hz="); con_udec(count_hz_measured); + con_puts(" measured="); con_udec((u64)(have_timebase ? 1 : 0)); + con_puts("\n"); + con_puts("#work base="); con_hex32((u32)(unsigned long)work); + con_puts(" bytes="); con_udec(work_bytes); + con_puts("\n"); + con_puts("#cols name unit iters work ns icount count exc checksum golden status\n"); + + for (i = 0; i < n_results; i++) { + const struct result *r = &results[i]; + con_puts(r->b->name); con_putc(' '); + con_puts(r->b->unit); con_putc(' '); + con_udec(r->iters); con_putc(' '); + con_udec(r->work); con_putc(' '); + con_udec(r->ns); con_putc(' '); + con_udec(r->icount); con_putc(' '); + con_udec(r->count_ticks); con_putc(' '); + con_udec(r->exc); con_putc(' '); + con_hex64(r->sum); con_putc(' '); + con_hex64(r->gold); con_putc(' '); + con_puts(status_name[r->status]); + con_putc('\n'); + } + + con_puts("#totals benches="); con_udec(n_results); + con_puts(" checked="); con_udec(checked); + con_puts(" matched="); con_udec(matched); + con_puts(" ns="); con_udec(total_ns); + con_puts(" icount="); con_udec(total_ic); + con_puts("\n"); + con_puts("IRIS-BENCH-END\n"); +} + +int main(void) +{ + unsigned gi, bi, i; + u64 total_ns = 0, total_ic = 0; + unsigned checked = 0, matched = 0, rc; + + con_init(); + bench_init(); + + if (cpu_kind == 0) { + con_puts("\nUNKNOWN CPU — refusing to run: the golden checksums are\n" + "selected by PRId, so the accuracy score would be meaningless.\n"); + con_puts("\nIRIS-BENCH-DONE rc=127\n"); + con_flush(); + testdev_exit(127); + } + + print_header(); + + for (gi = 0; gi < n_bgroups; gi++) { + const struct bench_group *g = all_bgroups[gi]; + for (bi = 0; bi < g->count; bi++) { + if (n_results >= BENCH_MAX_RESULTS) panic("too many benchmarks"); + run_one(&g->benches[bi]); + print_row(&results[n_results - 1], 0); + } + } + + for (i = 0; i < n_results; i++) { + if (results[i].status == R_SKIP) continue; + total_ns += results[i].ns; + total_ic += results[i].icount; + if (results[i].status == R_OK || results[i].status == R_MISMATCH) { + checked++; + if (results[i].status == R_OK) matched++; + } + } + if (total_ns == 0) total_ns = 1; + + con_puts("------------------------------------------------------------------\n"); + con_puts(" wall clock "); + con_fixed(total_ns, 1000000000ull, 2, 8); + con_puts(" s\n"); + if (total_ic) { + con_puts(" guest work "); + con_fixed(total_ic, 1000000ull, 2, 8); + con_puts(" M instructions\n"); + con_puts(" emulator speed "); + con_fixed(total_ic * 1000ull, total_ns, 2, 8); + con_puts(" MIPS (guest instructions per host second)\n"); + } + con_puts(" accuracy "); + con_fixed((u64)matched * 100ull, checked ? checked : 1, 1, 8); + con_puts(" % ("); + con_udec(matched); con_puts(" of "); con_udec(checked); + con_puts(" checksums matched)\n"); + + print_hotspots(total_ns); + print_machine_block(total_ns, total_ic, checked, matched); + + rc = checked - matched; + if (rc > 100) rc = 100; + con_puts("\nIRIS-BENCH-DONE rc="); con_udec(rc); con_puts("\n"); + con_flush(); + testdev_exit(rc); + return 0; +} diff --git a/bench/harness/string.c b/bench/harness/string.c new file mode 100644 index 0000000..88a03db --- /dev/null +++ b/bench/harness/string.c @@ -0,0 +1,77 @@ +/* string.c — the three functions GCC emits calls to on its own. + * + * -ffreestanding stops the compiler *assuming* a libc, but it is still free to + * turn a struct assignment or an array initialiser into a memcpy/memset call, + * and at -O2 over kernels this size it does. Without these the link fails with + * an undefined reference, which is at least loud — but the kernels want them + * anyway, and a benchmark should not measure a byte-at-a-time memcpy when the + * thing it is really testing is the cache. + * + * Word-at-a-time when both ends are aligned. Deliberately plain: this is + * infrastructure, and mem/bandwidth_copy is where copy throughput is actually + * measured. + */ + +#include "benchlib.h" + +void *memset(void *dst, int c, unsigned long n) +{ + unsigned char *d = (unsigned char *)dst; + unsigned long i = 0; + u32 w = (u32)(unsigned char)c; + w |= w << 8; w |= w << 16; + + while (i < n && (((unsigned long)(d + i)) & 3)) { d[i] = (unsigned char)c; i++; } + while (i + 16 <= n) { + *(u32 *)(void *)(d + i) = w; + *(u32 *)(void *)(d + i + 4) = w; + *(u32 *)(void *)(d + i + 8) = w; + *(u32 *)(void *)(d + i + 12) = w; + i += 16; + } + while (i + 4 <= n) { *(u32 *)(void *)(d + i) = w; i += 4; } + while (i < n) { d[i] = (unsigned char)c; i++; } + return dst; +} + +void *memcpy(void *dst, const void *src, unsigned long n) +{ + unsigned char *d = (unsigned char *)dst; + const unsigned char *s = (const unsigned char *)src; + unsigned long i = 0; + + if (((((unsigned long)d) ^ ((unsigned long)s)) & 3) == 0) { + while (i < n && (((unsigned long)(d + i)) & 3)) { d[i] = s[i]; i++; } + while (i + 16 <= n) { + *(u32 *)(void *)(d + i) = *(const u32 *)(const void *)(s + i); + *(u32 *)(void *)(d + i + 4) = *(const u32 *)(const void *)(s + i + 4); + *(u32 *)(void *)(d + i + 8) = *(const u32 *)(const void *)(s + i + 8); + *(u32 *)(void *)(d + i + 12) = *(const u32 *)(const void *)(s + i + 12); + i += 16; + } + while (i + 4 <= n) { + *(u32 *)(void *)(d + i) = *(const u32 *)(const void *)(s + i); + i += 4; + } + } + while (i < n) { d[i] = s[i]; i++; } + return dst; +} + +void *memmove(void *dst, const void *src, unsigned long n) +{ + unsigned char *d = (unsigned char *)dst; + const unsigned char *s = (const unsigned char *)src; + if (d == s || n == 0) return dst; + if (d < s) return memcpy(dst, src, n); + while (n--) d[n] = s[n]; + return dst; +} + +int memcmp(const void *a, const void *b, unsigned long n) +{ + const unsigned char *x = (const unsigned char *)a, *y = (const unsigned char *)b; + unsigned long i; + for (i = 0; i < n; i++) if (x[i] != y[i]) return (int)x[i] - (int)y[i]; + return 0; +} diff --git a/bench/harness/tlbasm.S b/bench/harness/tlbasm.S new file mode 100644 index 0000000..bc1839f --- /dev/null +++ b/bench/harness/tlbasm.S @@ -0,0 +1,59 @@ +/* + * tlbasm.S — a minimal TLB refill handler, for sys/tlb_miss. + * + * The shared dispatcher in cpu-tests' start.S saves registers and records + * eleven CP0 registers before it hands over, which is right for a test that + * wants to inspect an exception and wrong for a benchmark that wants to + * measure one: the measurement would be mostly the recording. IRIX's own + * utlbmiss handler is a handful of instructions, so this is a handful of + * instructions. + * + * The mapping is arithmetic rather than a page table walk. A real handler + * loads two PTEs through Context, which needs an 8 MB-aligned table; the + * region this maps is contiguous, so PFN = VPN + delta covers it with no + * memory reference at all. That makes the number a clean read on the + * emulator's refill path — the TLB write and the exception entry/exit — with + * as little of our own code mixed in as possible. + */ + +#include "iris.h" + + .set noreorder + .set noat + + .text + .globl bench_tlb_refill + .globl bench_tlb_refill_end + .ent bench_tlb_refill +bench_tlb_refill: + dmfc0 $k0, $8 /* BadVAddr */ + lui $k1, %hi(bench_pfn_delta) + lw $k1, %lo(bench_pfn_delta)($k1) + dsrl $k0, $k0, 13 /* VPN2 */ + dsll $k0, $k0, 1 /* even VPN */ + daddu $k0, $k0, $k1 /* even PFN */ + dsll $k0, $k0, 6 /* PFN into EntryLo bits 31:6 */ + ori $k0, $k0, 0x1F /* C=3 (cacheable), D, V, G */ + dmtc0 $k0, $2 /* EntryLo0 */ + daddiu $k0, $k0, 0x40 /* the odd page is the next PFN */ + dmtc0 $k0, $3 /* EntryLo1 */ + nop + nop + tlbwr + nop + nop + eret + nop +bench_tlb_refill_end: + .end bench_tlb_refill + + .section .bss + .align 2 + .globl bench_pfn_delta + /* A word, not a doubleword. The handler reads it with `lw`, and on a + * big-endian machine `lw` from the base of a 64-bit object returns the + * HIGH half — which is zero, which makes the refill an identity map onto + * unmapped physical memory. The whole tlb_miss kernel came back as + * "MC: CPU Error at 02000000" until this was four bytes wide. */ +bench_pfn_delta: + .space 4 diff --git a/bench/irix/steps.toml b/bench/irix/steps.toml new file mode 100644 index 0000000..be6bb06 --- /dev/null +++ b/bench/irix/steps.toml @@ -0,0 +1,175 @@ +# Guest-level benchmark steps — the other half of the suite. +# +# bench/ measures the emulated CPU with no operating system in the way. This +# measures the machine as a *user* meets it: a filesystem on an emulated SCSI +# disk, IRIX's own buffer cache and syscall path, the tools that shipped in the +# box, and the X server driving REX3. None of that is visible to a bare-metal +# kernel, and all of it is what "is the emulator fast enough to use" actually +# means. +# +# Every step is timed by the HOST, around one `iris-ci run`, with the measured +# no-op round trip subtracted — so nothing here depends on the guest having a +# usable clock. Keep each step above a second of work or the subtraction is +# doing more than the measurement. +# +# Commands run as `sh -c ''` regardless of the login shell, so behaviour +# does not depend on whether the account uses csh. Consequence: **no single +# quotes in a command**, and no unescaped newlines. +# +# `requires` names a program that must be on PATH; the step is skipped (not +# failed) when it is absent, because IRIX installs vary enormously and a +# missing x11perf is not a benchmark result. + +# ── storage ───────────────────────────────────────────────────────────────── +# 16 MB is comfortably larger than the buffer cache will hold onto across a +# sync, so the write really does reach the emulated disk. + +[[step]] +name = "disk/write" +unit = "B" +work = 16777216 +setup = "rm -f /tmp/irisbench.dat" +cmd = "dd if=/dev/zero of=/tmp/irisbench.dat bs=64k count=256 >/dev/null 2>&1; sync" +requires = "dd" + +[[step]] +name = "disk/read_cold" +unit = "B" +work = 16777216 +# umount/mount would be cleaner but needs the filesystem idle; reading a second +# 16 MB file first is enough to push the first one out of a small buffer cache. +setup = "dd if=/dev/zero of=/tmp/irisbench2.dat bs=64k count=256 >/dev/null 2>&1; sync" +cmd = "dd if=/tmp/irisbench.dat of=/dev/null bs=64k >/dev/null 2>&1" +requires = "dd" + +[[step]] +name = "disk/read_warm" +unit = "B" +work = 16777216 +cmd = "dd if=/tmp/irisbench.dat of=/dev/null bs=64k >/dev/null 2>&1" +requires = "dd" + +[[step]] +name = "disk/copy" +unit = "B" +work = 33554432 +cmd = "cp /tmp/irisbench.dat /tmp/irisbench3.dat; sync; rm -f /tmp/irisbench3.dat" +requires = "cp" + +# ── filesystem metadata ───────────────────────────────────────────────────── +# Thousands of small inode operations: the path an unpack, a build or a backup +# actually spends its time on, and nothing like a streaming read. + +[[step]] +name = "fs/create_files" +unit = "file" +work = 500 +setup = "rm -rf /tmp/irisbench.d; mkdir /tmp/irisbench.d" +cmd = "i=0; while [ $i -lt 500 ]; do echo x > /tmp/irisbench.d/f$i; i=`expr $i + 1`; done; sync" + +[[step]] +name = "fs/stat_files" +unit = "file" +work = 500 +cmd = "ls -l /tmp/irisbench.d > /dev/null" +requires = "ls" + +[[step]] +name = "fs/remove_files" +unit = "file" +work = 500 +cmd = "rm -f /tmp/irisbench.d/f*; sync" + +[[step]] +name = "fs/walk_usr" +unit = "file" +# Not a fixed count — /usr differs per install — so the rate is only comparable +# between runs on the SAME disk image. iris-bench flags work=0 as unscored. +work = 0 +cmd = "find /usr/bin -type f -print | wc -l" +requires = "find" + +# ── CPU, through IRIX's own tools ─────────────────────────────────────────── + +[[step]] +name = "cpu/checksum" +unit = "B" +work = 16777216 +cmd = "sum /tmp/irisbench.dat > /dev/null" +requires = "sum" + +[[step]] +name = "cpu/compress" +unit = "B" +work = 16777216 +cmd = "compress -c /tmp/irisbench.dat > /dev/null" +requires = "compress" + +[[step]] +name = "cpu/gzip" +unit = "B" +work = 16777216 +cmd = "gzip -c -6 /tmp/irisbench.dat > /dev/null" +requires = "gzip" + +[[step]] +name = "cpu/tar" +unit = "B" +work = 0 +cmd = "tar cf /dev/null /usr/bin" +requires = "tar" + +# ── memory and syscalls ───────────────────────────────────────────────────── +# No disk in the path at all: 256 MB through the read/write syscall pair, which +# is IRIX's own copy loop plus the emulator's memory model. + +[[step]] +name = "mem/syscall_copy" +unit = "B" +work = 268435456 +cmd = "dd if=/dev/zero of=/dev/null bs=1m count=256 >/dev/null 2>&1" +requires = "dd" + +# ── networking ────────────────────────────────────────────────────────────── + +[[step]] +name = "net/loopback_ping" +unit = "pkt" +work = 100 +cmd = "ping -c 100 -i 0 127.0.0.1 > /dev/null 2>&1" +requires = "ping" + +# ── graphics ──────────────────────────────────────────────────────────────── +# Only meaningful with X running and DISPLAY set. xwd reads the whole root +# window back out of the framebuffer, which is a real REX3 read path rather +# than a synthetic one; x11perf, when the install has it, is the standard. + +[[step]] +name = "gfx/xwd_root" +unit = "grab" +work = 20 +cmd = "i=0; while [ $i -lt 20 ]; do xwd -root -silent > /dev/null; i=`expr $i + 1`; done" +requires = "xwd" + +[[step]] +name = "gfx/x11perf_rects" +unit = "op" +work = 0 +cmd = "x11perf -reps 3 -rect500 2>&1 | tail -2" +requires = "x11perf" + +[[step]] +name = "gfx/x11perf_scroll" +unit = "op" +work = 0 +cmd = "x11perf -reps 3 -scroll500 2>&1 | tail -2" +requires = "x11perf" + +# ── cleanup ───────────────────────────────────────────────────────────────── + +[[step]] +name = "cleanup" +unit = "op" +work = 0 +skip_timing = true +cmd = "rm -rf /tmp/irisbench.dat /tmp/irisbench2.dat /tmp/irisbench3.dat /tmp/irisbench.d" diff --git a/bench/kernels/codec.c b/bench/kernels/codec.c new file mode 100644 index 0000000..5b8c091 --- /dev/null +++ b/bench/kernels/codec.c @@ -0,0 +1,390 @@ +/* + * codec.c — compression and checksums. + * + * Everything a file manager, an archiver or a network stack spends its time + * on, and a very different instruction mix from the imaging kernels: table + * lookups with unpredictable indices, byte-at-a-time state machines, hash + * chains that chase pointers into a window, and bit-level packing. This is the + * workload most likely to expose a translator that is good at loops over + * arrays and bad at everything else. + */ + +#include "benchlib.h" + +#define SRC_BYTES (1u << 20) /* 1 MB of realistically compressible input */ + +static unsigned char *src, *dst, *rt; +static int src_ready; + +/* + * Compressible input, not random bytes. Random data is the one case where + * every compressor short-circuits, and it would make the LZ match search find + * nothing and run at a speed no real file produces. This mixes long runs, + * repeated phrases and a low-entropy tail — roughly the statistics of the + * mixed text and binary a real archive holds. + */ +static void src_build(void) +{ + unsigned char *s = (unsigned char *)work_alloc(SRC_BYTES, 4096); + unsigned char *d = (unsigned char *)work_alloc(SRC_BYTES * 2u, 4096); + unsigned char *r = (unsigned char *)work_alloc(SRC_BYTES + 64u, 4096); + u64 rs = 0xFEEDFACECAFEBEEFull; + u32 i = 0; + + if (src_ready && s == src) { dst = d; rt = r; return; } + + while (i < SRC_BYTES) { + u64 v = rng_next(&rs); + switch (v & 3) { + case 0: { /* a run */ + u32 n = (u32)((v >> 8) & 63) + 4, k; + unsigned char c = (unsigned char)(v >> 16); + for (k = 0; k < n && i < SRC_BYTES; k++) s[i++] = c; + break; + } + case 1: { /* a back-reference */ + u32 n = (u32)((v >> 8) & 127) + 8, k; + u32 from = i > 4096 ? i - 4096 + (u32)((v >> 20) & 4095) : 0; + for (k = 0; k < n && i < SRC_BYTES; k++) { s[i] = i ? s[from + k >= i ? from : from + k] : 0; i++; } + break; + } + case 2: { /* low-entropy bytes */ + u32 n = (u32)((v >> 8) & 31) + 1, k; + for (k = 0; k < n && i < SRC_BYTES; k++) + s[i++] = (unsigned char)('a' + ((v >> (k & 31)) & 15)); + break; + } + default: /* incompressible */ + s[i++] = (unsigned char)(v >> 32); + break; + } + } + src = s; dst = d; rt = r; src_ready = 1; +} + +/* ── codec/crc32 — table driven, the classic ──────────────────────────────── */ + +static u32 crc_tab[256]; +static int crc_tab_ready; + +static void crc_init(void) +{ + u32 i, j, c; + if (crc_tab_ready) return; + for (i = 0; i < 256; i++) { + c = i; + for (j = 0; j < 8; j++) c = (c & 1) ? 0xEDB88320u ^ (c >> 1) : c >> 1; + crc_tab[i] = c; + } + crc_tab_ready = 1; +} + +static u32 crc32(const unsigned char *p, u32 n) +{ + u32 c = 0xFFFFFFFFu, i; + for (i = 0; i < n; i++) c = crc_tab[(c ^ p[i]) & 0xFF] ^ (c >> 8); + return c ^ 0xFFFFFFFFu; +} + +static u64 v_crc32(void) { src_build(); crc_init(); return cksum_u64(CKSUM_INIT, crc32(src, SRC_BYTES)); } +static u64 r_crc32(u32 n) { u32 i, c = 0; src_build(); crc_init(); for (i = 0; i < n; i++) c ^= crc32(src, SRC_BYTES); SINK(c); return (u64)n * SRC_BYTES; } + +/* ── codec/adler32 — two accumulators, no table ───────────────────────────── */ + +static u32 adler32(const unsigned char *p, u32 n) +{ + u32 a = 1, b = 0, i = 0; + while (i < n) { + u32 blk = n - i > 5552 ? 5552 : n - i, k; + for (k = 0; k < blk; k++) { a += p[i + k]; b += a; } + a %= 65521; b %= 65521; + i += blk; + } + return (b << 16) | a; +} + +static u64 v_adler32(void) { src_build(); return cksum_u64(CKSUM_INIT, adler32(src, SRC_BYTES)); } +static u64 r_adler32(u32 n) { u32 i, c = 0; src_build(); for (i = 0; i < n; i++) c ^= adler32(src, SRC_BYTES); SINK(c); return (u64)n * SRC_BYTES; } + +/* ── codec/rle — encode and decode ────────────────────────────────────────── */ + +/* PackBits, the run-length scheme in TIFF and in the SGI RGB image format — + * so this is literally the codec an .rgb file on this machine used. */ +static u32 rle_encode(const unsigned char *in, u32 n, unsigned char *out) +{ + u32 i = 0, o = 0; + while (i < n) { + u32 run = 1; + while (i + run < n && run < 127 && in[i + run] == in[i]) run++; + if (run >= 2) { + out[o++] = (unsigned char)(257 - run); /* -(run-1) as a byte */ + out[o++] = in[i]; + i += run; + } else { + u32 lit = 0, start = i; + while (i + lit < n && lit < 127 && + (i + lit + 1 >= n || in[i + lit + 1] != in[i + lit])) lit++; + if (lit == 0) lit = 1; + out[o++] = (unsigned char)(lit - 1); + { + u32 k; + for (k = 0; k < lit; k++) out[o++] = in[start + k]; + } + i += lit; + } + } + return o; +} + +static u32 rle_decode(const unsigned char *in, u32 n, unsigned char *out, u32 cap) +{ + u32 i = 0, o = 0; + while (i < n && o < cap) { + int c = in[i++]; + if (c < 128) { + u32 k, lit = (u32)c + 1; + for (k = 0; k < lit && i < n && o < cap; k++) out[o++] = in[i++]; + } else { + u32 k, run = 257u - (u32)c; + unsigned char v = in[i++]; + for (k = 0; k < run && o < cap; k++) out[o++] = v; + } + } + return o; +} + +/* No src_build() here: r_rle drives this in a loop, and work_alloc bumps on + * every call whether or not the buffers are already built. Setup belongs + * outside the iteration loop — see work_alloc's contract in benchlib.h. */ +static u64 rle_roundtrip(void) +{ + u32 enc, dec; + enc = rle_encode(src, SRC_BYTES, dst); + dec = rle_decode(dst, enc, rt, SRC_BYTES); + return ((u64)enc << 32) | dec; +} + +static u64 v_rle(void) +{ + u64 h = CKSUM_INIT; + u64 sizes; + src_build(); + sizes = rle_roundtrip(); + h = cksum_u64(h, sizes); + /* Round-tripping to something other than the input is a correctness + * failure, and it should show up in the accuracy column rather than as a + * silently faster run. */ + h = cksum_u64(h, (u64)(memcmp(src, rt, SRC_BYTES) == 0)); + h = cksum_bytes(h, dst, 4096); + return h; +} + +static u64 r_rle(u32 n) { u32 i; src_build(); for (i = 0; i < n; i++) SINK((u32)rle_roundtrip()); return (u64)n * SRC_BYTES * 2ull; } + +/* ── codec/lz — LZ77 match search with a hash chain ───────────────────────── */ + +/* + * A deflate-shaped compressor: a 3-byte rolling hash into a 4096-entry head + * table, per-position prev links, and a bounded chain walk looking for the + * longest match in a 32 KB window. The pointer chasing through prev[] is the + * whole point — it is unpredictable, it misses cache, and it is where every + * real compressor spends its time. + */ +#define LZ_WBITS 15 +#define LZ_WSIZE (1u << LZ_WBITS) +#define LZ_HBITS 12 +#define LZ_HSIZE (1u << LZ_HBITS) +#define LZ_MAXCHAIN 32 +#define LZ_MINMATCH 3 +#define LZ_MAXMATCH 258 + +static int *lz_head, *lz_prev; +static int lz_ready; + +static void lz_alloc(void) +{ + int *h = (int *)work_alloc(LZ_HSIZE * (u32)sizeof(int), 64); + int *p = (int *)work_alloc(LZ_WSIZE * (u32)sizeof(int), 64); + src_build(); + lz_head = h; lz_prev = p; lz_ready = 1; +} + +static u32 lz_hash(const unsigned char *p) +{ + return (((u32)p[0] << 10) ^ ((u32)p[1] << 5) ^ (u32)p[2]) & (LZ_HSIZE - 1); +} + +/* Returns the encoded size; the encoded stream itself goes to dst so the + * checksum has something to look at. */ +static u32 lz_compress(const unsigned char *in, u32 n, unsigned char *out) +{ + u32 i, o = 0, k; + for (k = 0; k < LZ_HSIZE; k++) lz_head[k] = -1; + for (k = 0; k < LZ_WSIZE; k++) lz_prev[k] = -1; + + i = 0; + while (i + LZ_MINMATCH < n) { + u32 h = lz_hash(in + i); + int cand = lz_head[h]; + u32 best_len = 0, best_dist = 0, chain = 0; + + while (cand >= 0 && chain < LZ_MAXCHAIN) { + u32 dist = i - (u32)cand; + u32 len = 0, max; + if (dist == 0 || dist >= LZ_WSIZE) break; + max = n - i; + if (max > LZ_MAXMATCH) max = LZ_MAXMATCH; + while (len < max && in[cand + len] == in[i + len]) len++; + if (len > best_len) { best_len = len; best_dist = dist; } + if (best_len >= LZ_MAXMATCH) break; + cand = lz_prev[(u32)cand & (LZ_WSIZE - 1)]; + chain++; + } + + lz_prev[i & (LZ_WSIZE - 1)] = lz_head[h]; + lz_head[h] = (int)i; + + if (best_len >= LZ_MINMATCH) { + out[o++] = 0x80 | (unsigned char)(best_len > 127 ? 127 : best_len); + out[o++] = (unsigned char)(best_dist >> 8); + out[o++] = (unsigned char)best_dist; + /* Insert the skipped positions so the chains stay correct — the + * expensive, honest thing to do, and what deflate does. */ + for (k = 1; k < best_len && i + k + LZ_MINMATCH < n; k++) { + u32 hh = lz_hash(in + i + k); + lz_prev[(i + k) & (LZ_WSIZE - 1)] = lz_head[hh]; + lz_head[hh] = (int)(i + k); + } + i += best_len; + } else { + out[o++] = in[i] & 0x7F; + out[o++] = in[i]; + i++; + } + } + while (i < n) { out[o++] = in[i] & 0x7F; out[o++] = in[i]; i++; } + return o; +} + +static u64 v_lz(void) +{ + u64 h = CKSUM_INIT; + u32 o; + lz_alloc(); + o = lz_compress(src, SRC_BYTES, dst); + h = cksum_u64(h, o); + h = cksum_bytes(h, dst, o < 65536 ? o : 65536); + return h; +} + +static u64 r_lz(u32 n) { u32 i; lz_alloc(); for (i = 0; i < n; i++) SINK(lz_compress(src, SRC_BYTES, dst)); return (u64)n * SRC_BYTES; } + +/* ── codec/huffman — build a canonical code and pack the bits ─────────────── */ + +static u32 *hf_freq; +static unsigned char *hf_len; +static u32 *hf_code; +static int hf_ready; + +static void hf_alloc(void) +{ + u32 *f = (u32 *)work_alloc(256u * (u32)sizeof(u32), 64); + unsigned char *l = (unsigned char *)work_alloc(256, 64); + u32 *c = (u32 *)work_alloc(256u * (u32)sizeof(u32), 64); + src_build(); + hf_freq = f; hf_len = l; hf_code = c; hf_ready = 1; +} + +/* + * Package-merge is overkill here; this is the simple two-array Huffman tree + * over 256 symbols followed by canonical code assignment and a bit packer. + * The tree build is small and branchy, the packing pass is a shift-and-mask + * loop over a megabyte, and between them they cover both halves of what an + * entropy coder costs. + */ +static u32 huffman_pack(const unsigned char *in, u32 n, unsigned char *out) +{ + /* Only the parent links are needed: code lengths come from walking up to + * the root, and canonical assignment does the rest. A real encoder keeps + * left/right to emit the tree; this one does not emit it. */ + int parent[512]; + u32 weight[512]; + int nodes = 0, i, j; + u32 bitbuf = 0, o = 0; + int bitcnt = 0; + + for (i = 0; i < 256; i++) hf_freq[i] = 0; + for (i = 0; i < (int)n; i++) hf_freq[in[i]]++; + + for (i = 0; i < 256; i++) { + weight[nodes] = hf_freq[i] + 1; /* +1 so every symbol gets a code */ + parent[nodes] = -1; + nodes++; + } + while (1) { + int a = -1, b = -1; + for (i = 0; i < nodes; i++) { + if (parent[i] != -1) continue; + if (a < 0 || weight[i] < weight[a]) { b = a; a = i; } + else if (b < 0 || weight[i] < weight[b]) { b = i; } + } + if (b < 0) break; + weight[nodes] = weight[a] + weight[b]; + parent[nodes] = -1; + parent[a] = parent[b] = nodes; + nodes++; + } + + for (i = 0; i < 256; i++) { + int depth = 0, k = i; + while (parent[k] != -1) { k = parent[k]; depth++; } + hf_len[i] = (unsigned char)(depth > 24 ? 24 : depth); + } + /* Canonical assignment: sort by (length, symbol) and hand out codes in + * order — no tree walk needed at decode time. */ + { + u32 code = 0; + int len; + for (len = 1; len <= 24; len++) { + for (j = 0; j < 256; j++) if (hf_len[j] == len) hf_code[j] = code++; + code <<= 1; + } + } + + for (i = 0; i < (int)n; i++) { + int sym = in[i], len = hf_len[sym]; + bitbuf = (bitbuf << len) | (hf_code[sym] & ((1u << len) - 1u)); + bitcnt += len; + while (bitcnt >= 8) { out[o++] = (unsigned char)(bitbuf >> (bitcnt - 8)); bitcnt -= 8; } + } + if (bitcnt) out[o++] = (unsigned char)(bitbuf << (8 - bitcnt)); + return o; +} + +static u64 v_huffman(void) +{ + u64 h = CKSUM_INIT; + u32 o; + hf_alloc(); + o = huffman_pack(src, SRC_BYTES, dst); + h = cksum_u64(h, o); + h = cksum_bytes(h, hf_len, 256); + h = cksum_bytes(h, dst, o < 65536 ? o : 65536); + return h; +} + +static u64 r_huffman(u32 n) { u32 i; hf_alloc(); for (i = 0; i < n; i++) SINK(huffman_pack(src, SRC_BYTES, dst)); return (u64)n * SRC_BYTES; } + +/* ── registration ─────────────────────────────────────────────────────────── */ + +static const struct bench benches[] = { + BENCH("codec/crc32", "B", v_crc32, r_crc32, 1, BG_CODEC), + BENCH("codec/adler32", "B", v_adler32, r_adler32, 1, BG_CODEC), + BENCH("codec/rle", "B", v_rle, r_rle, 1, BG_CODEC), + BENCH("codec/lz", "B", v_lz, r_lz, 1, BG_CODEC), + BENCH("codec/huffman", "B", v_huffman, r_huffman, 1, BG_CODEC), +}; + +const struct bench_group group_codec = { + "codec", benches, sizeof(benches) / sizeof(benches[0]) +}; diff --git a/bench/kernels/fpu.c b/bench/kernels/fpu.c new file mode 100644 index 0000000..f56cc3f --- /dev/null +++ b/bench/kernels/fpu.c @@ -0,0 +1,472 @@ +/* + * fpu.c — floating-point kernels. + * + * All checksums fold in the raw bit patterns of the results, so a one-ulp + * disagreement with the host reference fails rather than passing quietly. That + * is the point: rules/testing/fpu-exception-model-vs-r4400.md records that the + * FPU flag model is the part of this emulator known to be imperfect, and an + * instruction-level test suite exercises operations one at a time, in + * isolation, with clean state. These kernels run millions of operations with + * whatever state the previous million left behind, which is where a rounding + * or a denormal path that is subtly wrong actually shows up. + */ + +#include "benchlib.h" +#include "bmath.h" + +/* ── fpu/scalar_d — dependent double-precision add/mul chain ──────────────── */ + +static double dchain(u32 rounds, double seed) +{ + double a = seed, b = seed * 0.5 + 1.0, c = 1.0 / 3.0, d = 0.7071067811865476; + u32 i; + for (i = 0; i < rounds; i++) { + a = a * c + d; + b = b * d - c; + a = a - b * 0.25; + b = b + a * 0.125; + c = c * 1.0000001 - 1.0e-9; + d = d * 0.9999999 + 1.0e-9; + if (a > 1.0e12 || a < -1.0e12) a *= 1.0e-12; + if (b > 1.0e12 || b < -1.0e12) b *= 1.0e-12; + } + return a + b + c + d; +} + +static u64 v_scalar_d(void) { return cksum_f64(CKSUM_INIT, dchain(4096, 1.0)); } +static u64 r_scalar_d(u32 n) { double r = dchain(n, 1.0); SINK((int)(r != 0.0)); return (u64)n * 8; } + +/* ── fpu/scalar_s — the same shape in single precision ────────────────────── */ + +static float fchain(u32 rounds, float seed) +{ + float a = seed, b = seed * 0.5f + 1.0f, c = 1.0f / 3.0f, d = 0.70710678f; + u32 i; + for (i = 0; i < rounds; i++) { + a = a * c + d; + b = b * d - c; + a = a - b * 0.25f; + b = b + a * 0.125f; + c = c * 1.0000001f - 1.0e-9f; + d = d * 0.9999999f + 1.0e-9f; + if (a > 1.0e12f || a < -1.0e12f) a *= 1.0e-12f; + if (b > 1.0e12f || b < -1.0e12f) b *= 1.0e-12f; + } + return a + b + c + d; +} + +static u64 v_scalar_s(void) { return cksum_f32(CKSUM_INIT, fchain(4096, 1.0f)); } +static u64 r_scalar_s(u32 n) { float r = fchain(n, 1.0f); SINK((int)(r != 0.0f)); return (u64)n * 8; } + +/* ── fpu/divsqrt — the long-latency FP units ──────────────────────────────── */ + +static double divsqrt(u32 rounds, double seed) +{ + double a = seed + 1.0, acc = 0.0; + u32 i; + for (i = 0; i < rounds; i++) { + double r = b_sqrt(a); + acc += 1.0 / r; + a = a * 1.0000003 + 0.5; + acc += r / (a + 1.0); + if (a > 1.0e18) a = seed + 1.0; + } + return acc + a; +} + +static u64 v_divsqrt(void) { return cksum_f64(CKSUM_INIT, divsqrt(2048, 2.0)); } +static u64 r_divsqrt(u32 n) { double r = divsqrt(n, 2.0); SINK((int)(r != 0.0)); return (u64)n * 5; } + +/* ── fpu/transcend — sin/cos/exp/log/atan over the suite's own libm ───────── */ + +static double transcend(u32 rounds, double seed) +{ + double x = seed, acc = 0.0; + u32 i; + for (i = 0; i < rounds; i++) { + acc += b_sin(x) * b_cos(x * 0.5); + acc += b_exp(x * 0.001); + acc += b_log(x + 2.0); + acc += b_atan(x * 0.25); + x += 0.0009765625; /* 2^-10: exact, so no drift */ + if (x > 100.0) x -= 100.0; + } + return acc; +} + +static u64 v_transcend(void) { return cksum_f64(CKSUM_INIT, transcend(512, 0.5)); } +static u64 r_transcend(u32 n) { double r = transcend(n, 0.5); SINK((int)(r != 0.0)); return (u64)n * 5; } + +/* ══ fpu/whetstone ═══════════════════════════════════════════════════════════ + * + * The Curnow/Wichmann module structure with the conventional weights: modules + * 1-4, 6-9 and 11 in order, and the same balance of scalar arithmetic, array + * traffic, integer work, procedure calls and transcendentals that has made it + * a useful floating-point mix for four decades. + * + * Reported in **loops per second, not MWIPS**. Converting needs the "Whetstone + * instructions per loop" constant from a reference implementation, and that + * constant is not something this suite can verify — a derived MWIPS resting on + * an unchecked factor of a thousand would look authoritative without being so. + * Loops per second is exact, and it is what comparisons between cells use. + * + * Two departures from a reference implementation either way. The + * transcendentals in modules 7 and 11 are bmath.h's rather than a libm's, + * because a libm difference between host and guest would be scored as an + * emulator fault. And there is no self-timing loop; the harness times it. + */ + +static double whet_t = 0.499975; +static double whet_t1 = 0.50025; +static double whet_t2 = 2.0; + +static void whet_pa(double e[4]) +{ + int j = 0; + do { + e[0] = (e[0] + e[1] + e[2] - e[3]) * whet_t; + e[1] = (e[0] + e[1] - e[2] + e[3]) * whet_t; + e[2] = (e[0] - e[1] + e[2] + e[3]) * whet_t; + e[3] = (-e[0] + e[1] + e[2] + e[3]) / whet_t2; + j += 1; + } while (j < 6); +} + +static void whet_p3(double *x, double *y, double *z) +{ + double x1 = *x, y1 = *y; + x1 = whet_t * (x1 + y1); + y1 = whet_t * (x1 + y1); + *z = (x1 + y1) / whet_t2; +} + +static void whet_p0(double e1[5], int j, int k, int l) +{ + e1[j] = e1[k]; + e1[k] = e1[l]; + e1[l] = e1[j]; +} + +/* The classic weights, per loop. */ +#define WN1 0 +#define WN2 12 +#define WN3 14 +#define WN4 345 +#define WN6 210 +#define WN7 32 +#define WN8 899 +#define WN9 616 +#define WN11 93 + +static double whetstone(u32 loops) +{ + double x1, x2, x3, x4, x, y, z; + double e1[5]; + int j, k, l, n; + u32 loop; + double acc = 0.0; + + for (loop = 0; loop < loops; loop++) { + /* Module 1: simple identifiers */ + x1 = 1.0; x2 = -1.0; x3 = -1.0; x4 = -1.0; + for (n = 0; n < WN1; n++) { + x1 = (x1 + x2 + x3 - x4) * whet_t; + x2 = (x1 + x2 - x3 + x4) * whet_t; + x3 = (x1 - x2 + x3 + x4) * whet_t; + x4 = (-x1 + x2 + x3 + x4) * whet_t; + } + + /* Module 2: array elements */ + e1[0] = 1.0; e1[1] = -1.0; e1[2] = -1.0; e1[3] = -1.0; + for (n = 0; n < WN2; n++) { + e1[0] = (e1[0] + e1[1] + e1[2] - e1[3]) * whet_t; + e1[1] = (e1[0] + e1[1] - e1[2] + e1[3]) * whet_t; + e1[2] = (e1[0] - e1[1] + e1[2] + e1[3]) * whet_t; + e1[3] = (-e1[0] + e1[1] + e1[2] + e1[3]) * whet_t; + } + + /* Module 3: array as parameter */ + for (n = 0; n < WN3; n++) whet_pa(e1); + + /* Module 4: conditional jumps */ + j = 1; + for (n = 0; n < WN4; n++) { + j = (j == 1) ? 2 : 3; + j = (j > 2) ? 0 : 1; + j = (j < 1) ? 1 : 0; + } + + /* Module 6: integer arithmetic */ + j = 1; k = 2; l = 3; + for (n = 0; n < WN6; n++) { + j = j * (k - j) * (l - k); + k = l * k - (l - j) * k; + l = (l - k) * (k + j); + e1[l - 2] = (double)(j + k + l); + e1[k - 2] = (j * k * l) < 0 ? -(double)(j * k * l) : (double)(j * k * l); + } + + /* Module 7: trigonometric functions */ + x = 0.5; y = 0.5; + for (n = 0; n < WN7; n++) { + x = whet_t * b_atan(whet_t2 * b_sin(x) * b_cos(x) / + (b_cos(x + y) + b_cos(x - y) - 1.0)); + y = whet_t * b_atan(whet_t2 * b_sin(y) * b_cos(y) / + (b_cos(x + y) + b_cos(x - y) - 1.0)); + } + + /* Module 8: procedure calls */ + x = 1.0; y = 1.0; z = 1.0; + for (n = 0; n < WN8; n++) whet_p3(&x, &y, &z); + + /* Module 9: array references */ + j = 0; k = 1; l = 2; + e1[0] = 1.0; e1[1] = 2.0; e1[2] = 3.0; + for (n = 0; n < WN9; n++) whet_p0(e1, j, k, l); + + /* Module 11: standard functions */ + x = 0.75; + for (n = 0; n < WN11; n++) x = b_sqrt(b_exp(b_log(x) / whet_t1)); + + acc += x1 + x2 + x3 + x4 + e1[0] + e1[1] + e1[2] + e1[3] + x + y + z; + } + return acc; +} + +static u64 v_whetstone(void) { return cksum_f64(CKSUM_INIT, whetstone(8)); } +/* One work unit is one pass over the whole module set — see the note above. */ +static u64 r_whetstone(u32 n) { double r = whetstone(n); SINK((int)(r != 0.0)); return (u64)n; } + +/* ══ fpu/linpack — LINPACK 100x100, dgefa + dgesl ═════════════════════════════ + * + * The netlib benchmark: LU factorisation with partial pivoting, then a solve, + * on a 100x100 double matrix. Reported as flops, so rate/s is MFLOPS x 1e6 in + * the same units every published LINPACK figure uses. + * + * Column-major with lda = 101, exactly as the original, because that odd + * leading dimension is doing real work — it staggers each column across cache + * sets instead of aliasing them all onto the same one, and a benchmark that + * "tidied" it to 100 would be measuring conflict misses. + */ + +#define LP_N 100 +#define LP_LDA 101 + +static double *lp_a, *lp_b; +static double *lp_a0, *lp_b0; /* pristine system, generated once */ +static int *lp_ipvt; +static int lp_ready; + +static void lp_matgen(void); + +/* + * The reference LINPACK generates the system outside the timed region and + * times only dgefa+dgesl. The harness times whatever run() does, so the + * pristine system is built once and restored with a copy — 80 KB against + * ~690k flops of solve, and identical on every cell being compared. work_reset + * hands back the same addresses on every call, so "already built" is a safe + * thing to remember. + */ +static void lp_alloc(void) +{ + double *a = (double *)work_alloc(LP_LDA * LP_N * (u32)sizeof(double), 64); + double *b = (double *)work_alloc(LP_N * (u32)sizeof(double), 64); + double *a0 = (double *)work_alloc(LP_LDA * LP_N * (u32)sizeof(double), 64); + double *b0 = (double *)work_alloc(LP_N * (u32)sizeof(double), 64); + int *pv = (int *)work_alloc(LP_N * (u32)sizeof(int), 64); + + if (lp_ready && a == lp_a && a0 == lp_a0) { lp_b = b; lp_ipvt = pv; return; } + + lp_a = a; lp_b = b; lp_a0 = a0; lp_b0 = b0; lp_ipvt = pv; + lp_matgen(); + memcpy(lp_a0, lp_a, LP_LDA * LP_N * sizeof(double)); + memcpy(lp_b0, lp_b, LP_N * sizeof(double)); + lp_ready = 1; +} + +static void lp_restore(void) +{ + memcpy(lp_a, lp_a0, LP_LDA * LP_N * sizeof(double)); + memcpy(lp_b, lp_b0, LP_N * sizeof(double)); +} + +/* The original matgen: a 3125-multiplier LCG mod 65536, mapped to [-2, 2). */ +static void lp_matgen(void) +{ + int i, j, init = 1325; + for (j = 0; j < LP_N; j++) { + for (i = 0; i < LP_N; i++) { + init = 3125 * init % 65536; + lp_a[i + j * LP_LDA] = ((double)(init - 32768)) / 16384.0; + } + } + for (i = 0; i < LP_N; i++) lp_b[i] = 0.0; + for (j = 0; j < LP_N; j++) + for (i = 0; i < LP_N; i++) lp_b[i] += lp_a[i + j * LP_LDA]; +} + +static void lp_daxpy(int n, double da, const double *dx, double *dy) +{ + int i; + if (da == 0.0) return; + for (i = 0; i < n; i++) dy[i] += da * dx[i]; +} + +static void lp_dscal(int n, double da, double *dx) +{ + int i; + for (i = 0; i < n; i++) dx[i] *= da; +} + +static double lp_ddot(int n, const double *dx, const double *dy) +{ + double s = 0.0; + int i; + for (i = 0; i < n; i++) s += dx[i] * dy[i]; + return s; +} + +static int lp_idamax(int n, const double *dx) +{ + double dmax; + int i, best = 0; + if (n < 1) return -1; + dmax = dx[0] < 0.0 ? -dx[0] : dx[0]; + for (i = 1; i < n; i++) { + double v = dx[i] < 0.0 ? -dx[i] : dx[i]; + if (v > dmax) { dmax = v; best = i; } + } + return best; +} + +static void lp_dgefa(void) +{ + int j, k, l; + for (k = 0; k < LP_N - 1; k++) { + double *ak = &lp_a[k + k * LP_LDA]; + double t; + l = lp_idamax(LP_N - k, ak) + k; + lp_ipvt[k] = l; + if (lp_a[l + k * LP_LDA] == 0.0) continue; + if (l != k) { + t = lp_a[l + k * LP_LDA]; + lp_a[l + k * LP_LDA] = lp_a[k + k * LP_LDA]; + lp_a[k + k * LP_LDA] = t; + } + t = -1.0 / lp_a[k + k * LP_LDA]; + lp_dscal(LP_N - k - 1, t, &lp_a[k + 1 + k * LP_LDA]); + for (j = k + 1; j < LP_N; j++) { + t = lp_a[l + j * LP_LDA]; + if (l != k) { + lp_a[l + j * LP_LDA] = lp_a[k + j * LP_LDA]; + lp_a[k + j * LP_LDA] = t; + } + lp_daxpy(LP_N - k - 1, t, &lp_a[k + 1 + k * LP_LDA], &lp_a[k + 1 + j * LP_LDA]); + } + } + lp_ipvt[LP_N - 1] = LP_N - 1; +} + +static void lp_dgesl(void) +{ + int k, l; + double t; + for (k = 0; k < LP_N - 1; k++) { + l = lp_ipvt[k]; + t = lp_b[l]; + if (l != k) { lp_b[l] = lp_b[k]; lp_b[k] = t; } + lp_daxpy(LP_N - k - 1, t, &lp_a[k + 1 + k * LP_LDA], &lp_b[k + 1]); + } + for (k = LP_N - 1; k >= 0; k--) { + lp_b[k] /= lp_a[k + k * LP_LDA]; + t = -lp_b[k]; + lp_daxpy(k, t, &lp_a[k * LP_LDA], lp_b); + } +} + +static double lp_solve_once(void) +{ + lp_restore(); + lp_dgefa(); + lp_dgesl(); + return lp_ddot(LP_N, lp_b, lp_b); +} + +static u64 v_linpack(void) +{ + u64 h = CKSUM_INIT; + int i; + lp_alloc(); + (void)lp_solve_once(); + /* The solution should be all ones; fold in every element so a single wrong + * pivot cannot hide behind a norm. */ + for (i = 0; i < LP_N; i++) h = cksum_f64(h, lp_b[i]); + return h; +} + +/* 2/3 n^3 + 2 n^2 flops per factor-and-solve, the standard LINPACK count. */ +#define LP_FLOPS ((2ull * LP_N * LP_N * LP_N) / 3ull + 2ull * LP_N * LP_N) + +static u64 r_linpack(u32 n) +{ + u32 i; + lp_alloc(); + for (i = 0; i < n; i++) SINK((int)(lp_solve_once() != 0.0)); + return (u64)n * LP_FLOPS; +} + +/* ── fpu/matmul — 64x64 double matrix multiply ────────────────────────────── */ + +#define MM_N 64 + +static u64 mm_go(u32 iters, u64 *out_sum) +{ + double *a = (double *)work_alloc(MM_N * MM_N * (u32)sizeof(double), 64); + double *b = (double *)work_alloc(MM_N * MM_N * (u32)sizeof(double), 64); + double *c = (double *)work_alloc(MM_N * MM_N * (u32)sizeof(double), 64); + u64 s = 0x1234567ull; + u32 it; + int i, j, k; + + for (i = 0; i < MM_N * MM_N; i++) { + a[i] = (double)(s32)(rng_next(&s) >> 40) * (1.0 / 8388608.0); + b[i] = (double)(s32)(rng_next(&s) >> 40) * (1.0 / 8388608.0); + } + + for (it = 0; it < iters; it++) { + for (i = 0; i < MM_N; i++) { + for (j = 0; j < MM_N; j++) c[i * MM_N + j] = 0.0; + for (k = 0; k < MM_N; k++) { + double aik = a[i * MM_N + k]; + const double *brow = &b[k * MM_N]; + double *crow = &c[i * MM_N]; + for (j = 0; j < MM_N; j++) crow[j] += aik * brow[j]; + } + } + } + if (out_sum) { + u64 h = CKSUM_INIT; + for (i = 0; i < MM_N * MM_N; i++) h = cksum_f64(h, c[i]); + *out_sum = h; + } + return (u64)iters * 2ull * MM_N * MM_N * MM_N; +} + +static u64 v_matmul(void) { u64 h = 0; (void)mm_go(1, &h); return h; } +static u64 r_matmul(u32 n) { return mm_go(n, 0); } + +/* ── registration ─────────────────────────────────────────────────────────── */ + +static const struct bench benches[] = { + BENCH("fpu/scalar_d", "ops", v_scalar_d, r_scalar_d, 1u << 13, BG_FPU), + BENCH("fpu/scalar_s", "ops", v_scalar_s, r_scalar_s, 1u << 13, BG_FPU), + BENCH("fpu/divsqrt", "ops", v_divsqrt, r_divsqrt, 1u << 12, BG_FPU), + BENCH("fpu/transcend", "ops", v_transcend, r_transcend, 1u << 10, BG_FPU), + BENCH("fpu/whetstone", "loop", v_whetstone, r_whetstone, 1u << 6, BG_FPU), + BENCH("fpu/linpack", "flop", v_linpack, r_linpack, 4, BG_FPU), + BENCH("fpu/matmul", "flop", v_matmul, r_matmul, 4, BG_FPU), +}; + +const struct bench_group group_fpu = { + "fpu", benches, sizeof(benches) / sizeof(benches[0]) +}; diff --git a/bench/kernels/imaging.c b/bench/kernels/imaging.c new file mode 100644 index 0000000..87ed06e --- /dev/null +++ b/bench/kernels/imaging.c @@ -0,0 +1,712 @@ +/* + * imaging.c — what the machine was bought to do. + * + * An Indy shipped with a camera on top of the monitor and Adobe Photoshop and + * MovieMaker in the software catalogue, and the workloads people actually ran + * on it were images and video: convert a colour space, filter, scale, DCT, + * quantise, search for motion vectors, composite. Every kernel here is one of + * those inner loops, at a size an Indy would plausibly have been given. + * + * They are here because they stress the machine in combinations the + * synthetic kernels do not: a 3x3 convolution is three strided reads and a + * multiply-accumulate per pixel and lives or dies on the cache model, a DCT is + * a register-pressure problem, motion estimation is a branch-free absolute + * difference storm, and Floyd-Steinberg is a strictly serial dependency across + * a whole frame that nothing can reorder. Between them they cover the + * emulator's translation, memory and dispatch paths in the proportions real + * software uses, which no ALU chain does. + */ + +#include "benchlib.h" + +#define IMG_W 512 +#define IMG_H 384 +#define IMG_PX (IMG_W * IMG_H) + +/* ── the source image ─────────────────────────────────────────────────────── */ + +static unsigned char *img_rgb; /* IMG_PX * 3, interleaved */ +static unsigned char *img_y; /* IMG_PX luma */ +static int img_ready; + +/* + * A synthetic photograph: two smooth gradients, a disc, a hard-edged + * rectangle and a little dither noise. Not art — but real photographic + * statistics matter to these kernels. Uniform noise would make the DCT's + * coefficients dense and the RLE incompressible, and a flat field would make + * both trivially fast; a smooth image with a few edges puts the energy where + * a real one does. + */ +static void img_build(void) +{ + unsigned char *rgb = (unsigned char *)work_alloc(IMG_PX * 3u, 4096); + unsigned char *y = (unsigned char *)work_alloc(IMG_PX, 4096); + u64 s = 0x5EED1A6E0F0Dull; + int px, py; + + if (img_ready && rgb == img_rgb) return; + + for (py = 0; py < IMG_H; py++) { + for (px = 0; px < IMG_W; px++) { + int i = py * IMG_W + px; + int dx = px - IMG_W / 3, dy = py - IMG_H / 2; + int d2 = dx * dx + dy * dy; + int r, g, b, n; + + r = (px * 255) / IMG_W; + g = (py * 255) / IMG_H; + b = 128 + ((px + py) * 64) / (IMG_W + IMG_H); + + if (d2 < 90 * 90) { /* a soft disc */ + int k = (90 * 90 - d2) / 180; + r += k; g -= k / 2; b += k / 3; + } + if (px > 320 && px < 460 && py > 60 && py < 200) { /* a hard edge */ + r = 240; g = 30; b = 30; + } + n = (int)(rng_next(&s) & 7) - 4; /* film-grain-ish */ + r += n; g += n; b += n; + rgb[i * 3 + 0] = (unsigned char)(r < 0 ? 0 : r > 255 ? 255 : r); + rgb[i * 3 + 1] = (unsigned char)(g < 0 ? 0 : g > 255 ? 255 : g); + rgb[i * 3 + 2] = (unsigned char)(b < 0 ? 0 : b > 255 ? 255 : b); + /* BT.601 luma, the integer form everything below uses */ + y[i] = (unsigned char)((77 * rgb[i * 3] + 150 * rgb[i * 3 + 1] + + 29 * rgb[i * 3 + 2]) >> 8); + } + } + img_rgb = rgb; img_y = y; img_ready = 1; +} + +/* ── img/rgb2ycbcr — the first thing any codec does ───────────────────────── */ + +static unsigned char *cs_y, *cs_cb, *cs_cr; +static void cs_alloc(void) +{ + img_build(); + cs_y = (unsigned char *)work_alloc(IMG_PX, 64); + cs_cb = (unsigned char *)work_alloc(IMG_PX, 64); + cs_cr = (unsigned char *)work_alloc(IMG_PX, 64); +} + +/* ITU-R BT.601 in 16-bit fixed point, the coefficients libjpeg uses. */ +static void rgb2ycbcr(void) +{ + const unsigned char *p = img_rgb; + int i; + for (i = 0; i < IMG_PX; i++) { + int r = p[0], g = p[1], b = p[2]; + cs_y[i] = (unsigned char)((19595 * r + 38470 * g + 7471 * b + 32768) >> 16); + cs_cb[i] = (unsigned char)(((-11056 * r - 21712 * g + 32768 * b + 8388608) >> 16)); + cs_cr[i] = (unsigned char)(((32768 * r - 27440 * g - 5328 * b + 8388608) >> 16)); + p += 3; + } +} + +static u64 v_rgb2ycbcr(void) +{ + u64 h = CKSUM_INIT; + cs_alloc(); + rgb2ycbcr(); + h = cksum_bytes(h, cs_y, IMG_PX); + h = cksum_bytes(h, cs_cb, IMG_PX); + h = cksum_bytes(h, cs_cr, IMG_PX); + return h; +} + +static u64 r_rgb2ycbcr(u32 n) +{ + u32 i; + cs_alloc(); + for (i = 0; i < n; i++) rgb2ycbcr(); + SINK(cs_y[0]); + return (u64)n * IMG_PX; +} + +/* ── img/convolve3x3 — separable Gaussian blur ────────────────────────────── */ + +static unsigned char *cv_tmp, *cv_out; +static void cv_alloc(void) +{ + img_build(); + cv_tmp = (unsigned char *)work_alloc(IMG_PX, 64); + cv_out = (unsigned char *)work_alloc(IMG_PX, 64); +} + +/* [1 2 1] horizontally then vertically — the same 3x3 Gaussian every image + * editor's "blur" starts from, done separably as one would in practice. */ +static void convolve3(void) +{ + int x, y; + for (y = 0; y < IMG_H; y++) { + const unsigned char *src = img_y + y * IMG_W; + unsigned char *dst = cv_tmp + y * IMG_W; + dst[0] = src[0]; + for (x = 1; x < IMG_W - 1; x++) + dst[x] = (unsigned char)((src[x - 1] + 2 * src[x] + src[x + 1]) >> 2); + dst[IMG_W - 1] = src[IMG_W - 1]; + } + for (x = 0; x < IMG_W; x++) cv_out[x] = cv_tmp[x]; + for (y = 1; y < IMG_H - 1; y++) { + const unsigned char *a = cv_tmp + (y - 1) * IMG_W; + const unsigned char *b = cv_tmp + y * IMG_W; + const unsigned char *c = cv_tmp + (y + 1) * IMG_W; + unsigned char *dst = cv_out + y * IMG_W; + for (x = 0; x < IMG_W; x++) dst[x] = (unsigned char)((a[x] + 2 * b[x] + c[x]) >> 2); + } + for (x = 0; x < IMG_W; x++) cv_out[(IMG_H - 1) * IMG_W + x] = cv_tmp[(IMG_H - 1) * IMG_W + x]; +} + +static u64 v_convolve3(void) { cv_alloc(); convolve3(); return cksum_bytes(CKSUM_INIT, cv_out, IMG_PX); } +static u64 r_convolve3(u32 n) { u32 i; cv_alloc(); for (i = 0; i < n; i++) convolve3(); SINK(cv_out[0]); return (u64)n * IMG_PX; } + +/* ── img/sharpen5x5 — unsharp mask, non-separable ─────────────────────────── */ + +static unsigned char *sh_out; +static void sh_alloc(void) { img_build(); sh_out = (unsigned char *)work_alloc(IMG_PX, 64); } + +static const signed char sharpen_k[25] = { + 0, 0, -1, 0, 0, + 0, -1, -2, -1, 0, + -1, -2, 25, -2, -1, + 0, -1, -2, -1, 0, + 0, 0, -1, 0, 0 +}; + +static void sharpen5(void) +{ + int x, y, ky, kx; + for (y = 0; y < IMG_H; y++) { + for (x = 0; x < IMG_W; x++) { + int acc = 0; + if (x < 2 || y < 2 || x >= IMG_W - 2 || y >= IMG_H - 2) { + sh_out[y * IMG_W + x] = img_y[y * IMG_W + x]; + continue; + } + for (ky = -2; ky <= 2; ky++) { + const unsigned char *row = img_y + (y + ky) * IMG_W + x; + const signed char *k = sharpen_k + (ky + 2) * 5; + for (kx = -2; kx <= 2; kx++) acc += k[kx + 2] * row[kx]; + } + acc >>= 4; + sh_out[y * IMG_W + x] = (unsigned char)(acc < 0 ? 0 : acc > 255 ? 255 : acc); + } + } +} + +static u64 v_sharpen5(void) { sh_alloc(); sharpen5(); return cksum_bytes(CKSUM_INIT, sh_out, IMG_PX); } +static u64 r_sharpen5(u32 n) { u32 i; sh_alloc(); for (i = 0; i < n; i++) sharpen5(); SINK(sh_out[0]); return (u64)n * IMG_PX; } + +/* ── img/dct8x8 — forward and inverse integer DCT ─────────────────────────── */ + +/* + * A JPEG-style integer DCT: the standard even/odd decomposition with 13-bit + * fixed-point rotation constants, forward and inverse over every 8x8 block of + * the luma plane. It is not libjpeg's islow — an independent implementation of + * the same algorithm — so its coefficients are its own, which does not matter + * because both sides of the accuracy comparison run this code. + */ +#define DCT_C1 4017 /* cos(1*pi/16) * 4096 */ +#define DCT_C2 3784 +#define DCT_C3 3406 +#define DCT_C4 2896 +#define DCT_C5 2276 +#define DCT_C6 1567 +#define DCT_C7 799 + +static void fdct8(const int *in, int *out, int stride_in, int stride_out) +{ + int s07 = in[0 * stride_in] + in[7 * stride_in]; + int s16 = in[1 * stride_in] + in[6 * stride_in]; + int s25 = in[2 * stride_in] + in[5 * stride_in]; + int s34 = in[3 * stride_in] + in[4 * stride_in]; + int d07 = in[0 * stride_in] - in[7 * stride_in]; + int d16 = in[1 * stride_in] - in[6 * stride_in]; + int d25 = in[2 * stride_in] - in[5 * stride_in]; + int d34 = in[3 * stride_in] - in[4 * stride_in]; + + int a0 = s07 + s34, a1 = s16 + s25, a2 = s16 - s25, a3 = s07 - s34; + + out[0 * stride_out] = (DCT_C4 * (a0 + a1)) >> 12; + out[4 * stride_out] = (DCT_C4 * (a0 - a1)) >> 12; + out[2 * stride_out] = (DCT_C2 * a3 + DCT_C6 * a2) >> 12; + out[6 * stride_out] = (DCT_C6 * a3 - DCT_C2 * a2) >> 12; + + out[1 * stride_out] = (DCT_C1 * d07 + DCT_C3 * d16 + DCT_C5 * d25 + DCT_C7 * d34) >> 12; + out[3 * stride_out] = (DCT_C3 * d07 - DCT_C7 * d16 - DCT_C1 * d25 - DCT_C5 * d34) >> 12; + out[5 * stride_out] = (DCT_C5 * d07 - DCT_C1 * d16 + DCT_C7 * d25 + DCT_C3 * d34) >> 12; + out[7 * stride_out] = (DCT_C7 * d07 - DCT_C5 * d16 + DCT_C3 * d25 - DCT_C1 * d34) >> 12; +} + +static void idct8(const int *in, int *out, int stride_in, int stride_out) +{ + int e0 = (DCT_C4 * (in[0 * stride_in] + in[4 * stride_in])) >> 12; + int e1 = (DCT_C4 * (in[0 * stride_in] - in[4 * stride_in])) >> 12; + int e2 = (DCT_C2 * in[2 * stride_in] + DCT_C6 * in[6 * stride_in]) >> 12; + int e3 = (DCT_C6 * in[2 * stride_in] - DCT_C2 * in[6 * stride_in]) >> 12; + + int a0 = e0 + e2, a3 = e0 - e2, a1 = e1 + e3, a2 = e1 - e3; + + int o0 = (DCT_C1 * in[1 * stride_in] + DCT_C3 * in[3 * stride_in] + + DCT_C5 * in[5 * stride_in] + DCT_C7 * in[7 * stride_in]) >> 12; + int o1 = (DCT_C3 * in[1 * stride_in] - DCT_C7 * in[3 * stride_in] + - DCT_C1 * in[5 * stride_in] - DCT_C5 * in[7 * stride_in]) >> 12; + int o2 = (DCT_C5 * in[1 * stride_in] - DCT_C1 * in[3 * stride_in] + + DCT_C7 * in[5 * stride_in] + DCT_C3 * in[7 * stride_in]) >> 12; + int o3 = (DCT_C7 * in[1 * stride_in] - DCT_C5 * in[3 * stride_in] + + DCT_C3 * in[5 * stride_in] - DCT_C1 * in[7 * stride_in]) >> 12; + + out[0 * stride_out] = a0 + o0; + out[7 * stride_out] = a0 - o0; + out[1 * stride_out] = a1 + o1; + out[6 * stride_out] = a1 - o1; + out[2 * stride_out] = a2 + o2; + out[5 * stride_out] = a2 - o2; + out[3 * stride_out] = a3 + o3; + out[4 * stride_out] = a3 - o3; +} + +/* Quality-50 luminance quantisation table, the JPEG Annex K one. */ +static const short jpeg_q50[64] = { + 16, 11, 10, 16, 24, 40, 51, 61, + 12, 12, 14, 19, 26, 58, 60, 55, + 14, 13, 16, 24, 40, 57, 69, 56, + 14, 17, 22, 29, 51, 87, 80, 62, + 18, 22, 37, 56, 68,109,103, 77, + 24, 35, 55, 64, 81,104,113, 92, + 49, 64, 78, 87,103,121,120,101, + 72, 92, 95, 98,112,100,103, 99 +}; + +static short *dct_coef; +static unsigned char *dct_out; +static void dct_alloc(void) +{ + img_build(); + dct_coef = (short *)work_alloc(IMG_PX * (u32)sizeof(short), 64); + dct_out = (unsigned char *)work_alloc(IMG_PX, 64); +} + +/* Encode-then-decode one frame: forward DCT, quantise, dequantise, inverse + * DCT. The round trip is what a codec does, and keeping both halves means the + * output is an image again and can be checksummed as one. */ +static void dct_frame(void) +{ + int bx, by, i; + int blk[64], tmp[64]; + + for (by = 0; by < IMG_H; by += 8) { + for (bx = 0; bx < IMG_W; bx += 8) { + const unsigned char *src = img_y + by * IMG_W + bx; + short *co = dct_coef + by * IMG_W + bx; + unsigned char *dst = dct_out + by * IMG_W + bx; + + for (i = 0; i < 8; i++) { + int j; + for (j = 0; j < 8; j++) blk[i * 8 + j] = (int)src[i * IMG_W + j] - 128; + } + for (i = 0; i < 8; i++) fdct8(&blk[i * 8], &tmp[i * 8], 1, 1); /* rows */ + for (i = 0; i < 8; i++) fdct8(&tmp[i], &blk[i], 8, 8); /* cols */ + + for (i = 0; i < 64; i++) { + int q = jpeg_q50[i]; + int v = blk[i] / q; + co[(i >> 3) * IMG_W + (i & 7)] = (short)v; + blk[i] = v * q; + } + + for (i = 0; i < 8; i++) idct8(&blk[i], &tmp[i], 8, 8); + for (i = 0; i < 8; i++) idct8(&tmp[i * 8], &blk[i * 8], 1, 1); + + for (i = 0; i < 8; i++) { + int j; + for (j = 0; j < 8; j++) { + int v = (blk[i * 8 + j] >> 3) + 128; + dst[i * IMG_W + j] = (unsigned char)(v < 0 ? 0 : v > 255 ? 255 : v); + } + } + } + } +} + +static u64 v_dct(void) +{ + u64 h = CKSUM_INIT; + dct_alloc(); + int i; + dct_frame(); + h = cksum_bytes(h, dct_out, IMG_PX); + /* Element-wise, not cksum_bytes over the array. The golden values come + * from a little-endian host and the guest is big-endian, so folding in the + * raw bytes of a 16-bit array compares byte order rather than + * coefficients — and reports a byte-order difference as an emulator fault. + * Anything wider than a byte gets checksummed by value. */ + for (i = 0; i < IMG_PX; i++) h = cksum_u64(h, (u64)(u16)dct_coef[i]); + return h; +} + +/* Work unit: 8x8 blocks through a full encode/decode round trip. */ +static u64 r_dct(u32 n) { u32 i; dct_alloc(); for (i = 0; i < n; i++) dct_frame(); SINK(dct_out[0]); return (u64)n * (IMG_PX / 64); } + +/* ── img/resize — bilinear downscale to half size ─────────────────────────── */ + +#define RS_W (IMG_W / 2) +#define RS_H (IMG_H / 2) + +static unsigned char *rs_out; +static void rs_alloc(void) { img_build(); rs_out = (unsigned char *)work_alloc(RS_W * RS_H * 3u, 64); } + +/* 16.16 fixed point, sampling at pixel centres — the arithmetic any image + * viewer's zoom does. */ +static void resize_bilinear(void) +{ + const int sx_step = (IMG_W << 16) / RS_W; + const int sy_step = (IMG_H << 16) / RS_H; + int dy, dx; + + for (dy = 0; dy < RS_H; dy++) { + int sy = dy * sy_step + (sy_step >> 1) - 32768; + int y0, fy; + if (sy < 0) sy = 0; + y0 = sy >> 16; fy = sy & 0xFFFF; + if (y0 >= IMG_H - 1) { y0 = IMG_H - 2; fy = 0xFFFF; } + for (dx = 0; dx < RS_W; dx++) { + int sx = dx * sx_step + (sx_step >> 1) - 32768; + int x0, fx, c; + if (sx < 0) sx = 0; + x0 = sx >> 16; fx = sx & 0xFFFF; + if (x0 >= IMG_W - 1) { x0 = IMG_W - 2; fx = 0xFFFF; } + for (c = 0; c < 3; c++) { + const unsigned char *p = img_rgb + (y0 * IMG_W + x0) * 3 + c; + int p00 = p[0], p01 = p[3]; + int p10 = p[IMG_W * 3], p11 = p[IMG_W * 3 + 3]; + int top = p00 + (((p01 - p00) * fx) >> 16); + int bot = p10 + (((p11 - p10) * fx) >> 16); + rs_out[(dy * RS_W + dx) * 3 + c] = (unsigned char)(top + (((bot - top) * fy) >> 16)); + } + } + } +} + +static u64 v_resize(void) { rs_alloc(); resize_bilinear(); return cksum_bytes(CKSUM_INIT, rs_out, RS_W * RS_H * 3u); } +static u64 r_resize(u32 n) { u32 i; rs_alloc(); for (i = 0; i < n; i++) resize_bilinear(); SINK(rs_out[0]); return (u64)n * (RS_W * RS_H); } + +/* ── img/rotate90 — transpose, all stride and no arithmetic ───────────────── */ + +static unsigned char *rot_out; +static void rot_alloc(void) { img_build(); rot_out = (unsigned char *)work_alloc(IMG_PX, 64); } + +/* Blocked 16x16 so it is a realistic implementation rather than a worst case; + * the interesting part is that every write is a cache line away from the last. */ +static void rotate90(void) +{ + int by, bx, y, x; + for (by = 0; by < IMG_H; by += 16) { + for (bx = 0; bx < IMG_W; bx += 16) { + for (y = by; y < by + 16 && y < IMG_H; y++) + for (x = bx; x < bx + 16 && x < IMG_W; x++) + rot_out[x * IMG_H + (IMG_H - 1 - y)] = img_y[y * IMG_W + x]; + } + } +} + +static u64 v_rotate(void) { rot_alloc(); rotate90(); return cksum_bytes(CKSUM_INIT, rot_out, IMG_PX); } +static u64 r_rotate(u32 n) { u32 i; rot_alloc(); for (i = 0; i < n; i++) rotate90(); SINK(rot_out[0]); return (u64)n * IMG_PX; } + +/* ── img/composite — 8-bit alpha blend of two layers ──────────────────────── */ + +static unsigned char *comp_top, *comp_alpha, *comp_out; +static int comp_ready; + +static void comp_alloc(void) +{ + unsigned char *t, *a, *o; + img_build(); + t = (unsigned char *)work_alloc(IMG_PX * 3u, 64); + a = (unsigned char *)work_alloc(IMG_PX, 64); + o = (unsigned char *)work_alloc(IMG_PX * 3u, 64); + if (!comp_ready || t != comp_top) { + u64 s = 0xC0FFEE5EEDull; + int i; + for (i = 0; i < IMG_PX; i++) { + t[i * 3 + 0] = (unsigned char)(i & 0xFF); + t[i * 3 + 1] = (unsigned char)((i >> 8) & 0xFF); + t[i * 3 + 2] = (unsigned char)(rng_next(&s)); + /* A soft radial mask — the shape a feathered selection has. */ + { + int px = i % IMG_W, py = i / IMG_W; + int dx = px - IMG_W / 2, dy = py - IMG_H / 2; + int d2 = dx * dx + dy * dy; + int v = 255 - d2 / 400; + a[i] = (unsigned char)(v < 0 ? 0 : v); + } + } + comp_top = t; comp_alpha = a; comp_out = o; comp_ready = 1; + } else { + comp_out = o; + } +} + +/* out = top*alpha + bottom*(255-alpha), the 8-bit "+ 128, + >>8" rounding + * every compositor uses to avoid a divide. */ +static void composite(void) +{ + int i; + for (i = 0; i < IMG_PX; i++) { + int al = comp_alpha[i], ia = 255 - al, c; + for (c = 0; c < 3; c++) { + int t = comp_top[i * 3 + c] * al + img_rgb[i * 3 + c] * ia; + comp_out[i * 3 + c] = (unsigned char)((t + 128 + ((t + 128) >> 8)) >> 8); + } + } +} + +static u64 v_composite(void) { comp_alloc(); composite(); return cksum_bytes(CKSUM_INIT, comp_out, IMG_PX * 3u); } +static u64 r_composite(u32 n) { u32 i; comp_alloc(); for (i = 0; i < n; i++) composite(); SINK(comp_out[0]); return (u64)n * IMG_PX; } + +/* ── img/dither — Floyd-Steinberg to 4 bits, strictly serial ──────────────── */ + +static short *dt_err; +static unsigned char *dt_out; +static void dt_alloc(void) +{ + img_build(); + dt_err = (short *)work_alloc((IMG_W + 2) * 2u * (u32)sizeof(short), 64); + dt_out = (unsigned char *)work_alloc(IMG_PX, 64); +} + +/* + * Error diffusion, and therefore a dependency chain the length of the whole + * frame: pixel (x, y) cannot be decided until (x-1, y) has, and its error + * reaches three pixels on the next row. Nothing vectorises, nothing reorders, + * and a translator gets no help from anything except raw dispatch speed. + */ +static void dither(void) +{ + short *cur = dt_err, *next = dt_err + (IMG_W + 2); + int x, y; + for (x = 0; x < IMG_W + 2; x++) { cur[x] = 0; next[x] = 0; } + for (y = 0; y < IMG_H; y++) { + for (x = 0; x < IMG_W + 2; x++) { cur[x] = next[x]; next[x] = 0; } + for (x = 0; x < IMG_W; x++) { + int old = img_y[y * IMG_W + x] + cur[x + 1]; + int nv = old & 0xF0; + int err; + if (nv > 255) nv = 240; + if (nv < 0) nv = 0; + err = old - nv; + dt_out[y * IMG_W + x] = (unsigned char)nv; + cur[x + 2] = (short)(cur[x + 2] + (err * 7) / 16); + next[x] = (short)(next[x] + (err * 3) / 16); + next[x + 1] = (short)(next[x + 1] + (err * 5) / 16); + next[x + 2] = (short)(next[x + 2] + (err * 1) / 16); + } + } +} + +static u64 v_dither(void) { dt_alloc(); dither(); return cksum_bytes(CKSUM_INIT, dt_out, IMG_PX); } +static u64 r_dither(u32 n) { u32 i; dt_alloc(); for (i = 0; i < n; i++) dither(); SINK(dt_out[0]); return (u64)n * IMG_PX; } + +/* ── img/histogram — histogram plus a LUT contrast stretch ────────────────── */ + +static u32 *hs_hist; +static unsigned char *hs_lut, *hs_out; +static void hs_alloc(void) +{ + img_build(); + hs_hist = (u32 *)work_alloc(256u * (u32)sizeof(u32), 64); + hs_lut = (unsigned char *)work_alloc(256, 64); + hs_out = (unsigned char *)work_alloc(IMG_PX, 64); +} + +/* The scattered increment into a 1 KB table is the point: 256 buckets is a + * pathological read-modify-write pattern for a store buffer, and it is exactly + * what "auto levels" does before it can do anything else. */ +static void histogram(void) +{ + int i, sum = 0, cum = 0; + for (i = 0; i < 256; i++) hs_hist[i] = 0; + for (i = 0; i < IMG_PX; i++) hs_hist[img_y[i]]++; + for (i = 0; i < 256; i++) sum += (int)hs_hist[i]; + for (i = 0; i < 256; i++) { + cum += (int)hs_hist[i]; + hs_lut[i] = (unsigned char)((cum * 255) / (sum ? sum : 1)); + } + for (i = 0; i < IMG_PX; i++) hs_out[i] = hs_lut[img_y[i]]; +} + +static u64 v_histogram(void) +{ + u64 h = CKSUM_INIT; + hs_alloc(); + histogram(); + h = cksum_bytes(h, hs_lut, 256); + h = cksum_bytes(h, hs_out, IMG_PX); + return h; +} + +static u64 r_histogram(u32 n) { u32 i; hs_alloc(); for (i = 0; i < n; i++) histogram(); SINK(hs_out[0]); return (u64)n * IMG_PX; } + +/* ══ video ═══════════════════════════════════════════════════════════════════ */ + +#define ME_W 256 +#define ME_H 192 +#define ME_BLK 16 +#define ME_RANGE 4 +#define ME_BX (ME_W / ME_BLK) +#define ME_BY (ME_H / ME_BLK) + +static unsigned char *me_ref, *me_cur; +static short *me_mv; +static int me_ready; + +static void me_alloc(void) +{ + unsigned char *r = (unsigned char *)work_alloc(ME_W * ME_H, 4096); + unsigned char *c = (unsigned char *)work_alloc(ME_W * ME_H, 4096); + short *mv = (short *)work_alloc(ME_BX * ME_BY * 2u * (u32)sizeof(short), 64); + img_build(); + if (!me_ready || r != me_ref) { + int y, x; + /* Two frames of the same scene, the second panned by (3, -2) — a real + * motion vector for the search to find rather than noise. */ + for (y = 0; y < ME_H; y++) + for (x = 0; x < ME_W; x++) { + r[y * ME_W + x] = img_y[(y + 40) * IMG_W + (x + 60)]; + c[y * ME_W + x] = img_y[(y + 38) * IMG_W + (x + 63)]; + } + me_ref = r; me_cur = c; me_ready = 1; + } + me_mv = mv; +} + +/* + * Full-search block matching, +/-4 pixels, sum of absolute differences. The + * inner loop is 256 abs-diffs with no multiply and no branch worth predicting, + * repeated 81 times per macroblock — the single hottest loop in any MPEG + * encoder, and the reason video encoding on a workstation of this era was an + * overnight job. + */ +static void motion_estimate(void) +{ + int bx, by; + for (by = 0; by < ME_BY; by++) { + for (bx = 0; bx < ME_BX; bx++) { + int best = 0x7FFFFFFF, bmx = 0, bmy = 0, dy, dx; + int ox = bx * ME_BLK, oy = by * ME_BLK; + for (dy = -ME_RANGE; dy <= ME_RANGE; dy++) { + int ry = oy + dy; + if (ry < 0 || ry + ME_BLK > ME_H) continue; + for (dx = -ME_RANGE; dx <= ME_RANGE; dx++) { + int rx = ox + dx, sad = 0, y; + if (rx < 0 || rx + ME_BLK > ME_W) continue; + for (y = 0; y < ME_BLK; y++) { + const unsigned char *a = me_cur + (oy + y) * ME_W + ox; + const unsigned char *b = me_ref + (ry + y) * ME_W + rx; + int x; + for (x = 0; x < ME_BLK; x++) { + int d = a[x] - b[x]; + sad += d < 0 ? -d : d; + } + } + if (sad < best) { best = sad; bmx = dx; bmy = dy; } + } + } + me_mv[(by * ME_BX + bx) * 2 + 0] = (short)bmx; + me_mv[(by * ME_BX + bx) * 2 + 1] = (short)bmy; + } + } +} + +static u64 v_motion(void) +{ + u64 h = CKSUM_INIT; + int i; + me_alloc(); + motion_estimate(); + /* By value, not by bytes — see v_dct. */ + for (i = 0; i < ME_BX * ME_BY * 2; i++) h = cksum_u64(h, (u64)(u16)me_mv[i]); + return h; +} + +/* Work unit: one 16x16 SAD evaluation. */ +static u64 r_motion(u32 n) +{ + u32 i; + me_alloc(); + for (i = 0; i < n; i++) motion_estimate(); + SINK(me_mv[0]); + return (u64)n * ME_BX * ME_BY * (2 * ME_RANGE + 1) * (2 * ME_RANGE + 1); +} + +/* ── vid/yuv2rgb — 4:2:0 playback ─────────────────────────────────────────── */ + +static unsigned char *yv_y, *yv_u, *yv_v, *yv_rgb; +static int yv_ready; + +static void yv_alloc(void) +{ + unsigned char *yy = (unsigned char *)work_alloc(IMG_PX, 4096); + unsigned char *uu = (unsigned char *)work_alloc(IMG_PX / 4u, 64); + unsigned char *vv = (unsigned char *)work_alloc(IMG_PX / 4u, 64); + unsigned char *rr = (unsigned char *)work_alloc(IMG_PX * 3u, 4096); + img_build(); + if (!yv_ready || yy != yv_y) { + int y, x; + for (y = 0; y < IMG_H; y++) + for (x = 0; x < IMG_W; x++) yy[y * IMG_W + x] = img_y[y * IMG_W + x]; + for (y = 0; y < IMG_H / 2; y++) + for (x = 0; x < IMG_W / 2; x++) { + const unsigned char *p = img_rgb + ((y * 2) * IMG_W + x * 2) * 3; + int r = p[0], g = p[1], b = p[2]; + uu[y * (IMG_W / 2) + x] = (unsigned char)(((-38 * r - 74 * g + 112 * b) >> 8) + 128); + vv[y * (IMG_W / 2) + x] = (unsigned char)(((112 * r - 94 * g - 18 * b) >> 8) + 128); + } + yv_y = yy; yv_u = uu; yv_v = vv; yv_ready = 1; + } + yv_rgb = rr; +} + +/* Chroma upsampled by replication — what a software player of the period did, + * and what makes this two strided reads per pixel instead of one. */ +static void yuv2rgb(void) +{ + int y, x; + for (y = 0; y < IMG_H; y++) { + const unsigned char *yp = yv_y + y * IMG_W; + const unsigned char *up = yv_u + (y / 2) * (IMG_W / 2); + const unsigned char *vp = yv_v + (y / 2) * (IMG_W / 2); + unsigned char *out = yv_rgb + y * IMG_W * 3; + for (x = 0; x < IMG_W; x++) { + int Y = yp[x] - 16, U = up[x / 2] - 128, V = vp[x / 2] - 128; + int r = (298 * Y + 409 * V + 128) >> 8; + int g = (298 * Y - 100 * U - 208 * V + 128) >> 8; + int b = (298 * Y + 516 * U + 128) >> 8; + out[x * 3 + 0] = (unsigned char)(r < 0 ? 0 : r > 255 ? 255 : r); + out[x * 3 + 1] = (unsigned char)(g < 0 ? 0 : g > 255 ? 255 : g); + out[x * 3 + 2] = (unsigned char)(b < 0 ? 0 : b > 255 ? 255 : b); + } + } +} + +static u64 v_yuv2rgb(void) { yv_alloc(); yuv2rgb(); return cksum_bytes(CKSUM_INIT, yv_rgb, IMG_PX * 3u); } +static u64 r_yuv2rgb(u32 n) { u32 i; yv_alloc(); for (i = 0; i < n; i++) yuv2rgb(); SINK(yv_rgb[0]); return (u64)n * IMG_PX; } + +/* ── registration ─────────────────────────────────────────────────────────── */ + +static const struct bench benches[] = { + BENCH("img/rgb2ycbcr", "px", v_rgb2ycbcr, r_rgb2ycbcr, 1, BG_IMG), + BENCH("img/convolve3x3","px", v_convolve3, r_convolve3, 1, BG_IMG), + BENCH("img/sharpen5x5", "px", v_sharpen5, r_sharpen5, 1, BG_IMG), + BENCH("img/dct8x8", "blk", v_dct, r_dct, 1, BG_IMG), + BENCH("img/resize", "px", v_resize, r_resize, 1, BG_IMG), + BENCH("img/rotate90", "px", v_rotate, r_rotate, 1, BG_IMG), + BENCH("img/composite", "px", v_composite, r_composite, 1, BG_IMG), + BENCH("img/dither", "px", v_dither, r_dither, 1, BG_IMG), + BENCH("img/histogram", "px", v_histogram, r_histogram, 1, BG_IMG), + BENCH("vid/motion_est", "sad", v_motion, r_motion, 1, BG_IMG), + BENCH("vid/yuv2rgb", "px", v_yuv2rgb, r_yuv2rgb, 1, BG_IMG), +}; + +const struct bench_group group_imaging = { + "imaging", benches, sizeof(benches) / sizeof(benches[0]) +}; diff --git a/bench/kernels/integer.c b/bench/kernels/integer.c new file mode 100644 index 0000000..4b9fa1f --- /dev/null +++ b/bench/kernels/integer.c @@ -0,0 +1,461 @@ +/* + * integer.c — integer CPU kernels. + * + * The first two are a deliberate pair. `alu` is one dependency chain, so every + * operation waits for the one before it and the score is a latency; `alu_ilp` + * is eight independent chains over the same operation mix, so the score is a + * throughput. On real silicon the ratio between them is the machine's + * superscalar width. Under an emulator it is something more useful: an + * interpreter dispatches one instruction at a time whatever the dependencies, + * so the two scores converge, while a JIT that has scheduled the block apart + * pulls them back open. The gap is a direct read on how much real work the + * translation layer is doing. + */ + +#include "benchlib.h" + +/* ── int/alu — dependent 32-bit ALU chain ─────────────────────────────────── */ + +#define ALU_OPS_PER_ROUND 16 + +static u32 alu_chain(u32 rounds, u32 seed) +{ + u32 x = seed, i; + for (i = 0; i < rounds; i++) { + x += 0x9E3779B9u; x ^= x >> 15; + x *= 0x85EBCA6Bu; x ^= x >> 13; + x += x << 3; x ^= x >> 7; + x |= 0x00000001u; x -= 0x27D4EB2Fu; + x ^= x << 5; x *= 0xC2B2AE35u; + x ^= x >> 16; x += 0x165667B1u; + x = (x << 11) | (x >> 21); + x &= 0xFFFFFFFEu; x |= 0x00000003u; + x ^= 0x5BF03635u; + } + return x; +} + +static u64 v_alu(void) { return cksum_u64(CKSUM_INIT, alu_chain(4096, 0x12345678u)); } +static u64 r_alu(u32 n) { SINK(alu_chain(n, 0x12345678u)); return (u64)n * ALU_OPS_PER_ROUND; } + +/* ── int/alu_ilp — the same mix with eight independent chains ─────────────── */ + +static u32 alu_ilp(u32 rounds, u32 seed) +{ + u32 a = seed, b = seed ^ 0x11111111u, c = seed ^ 0x22222222u, d = seed ^ 0x33333333u; + u32 e = seed ^ 0x44444444u, f = seed ^ 0x55555555u, g = seed ^ 0x66666666u, h = seed ^ 0x77777777u; + u32 i; + for (i = 0; i < rounds; i++) { + a += 0x9E3779B9u; b += 0x9E3779B9u; c += 0x9E3779B9u; d += 0x9E3779B9u; + e += 0x9E3779B9u; f += 0x9E3779B9u; g += 0x9E3779B9u; h += 0x9E3779B9u; + a ^= a >> 15; b ^= b >> 15; c ^= c >> 15; d ^= d >> 15; + e ^= e >> 15; f ^= f >> 15; g ^= g >> 15; h ^= h >> 15; + a *= 0x85EBCA6Bu; b *= 0x85EBCA6Bu; c *= 0x85EBCA6Bu; d *= 0x85EBCA6Bu; + e *= 0x85EBCA6Bu; f *= 0x85EBCA6Bu; g *= 0x85EBCA6Bu; h *= 0x85EBCA6Bu; + a ^= a >> 13; b ^= b >> 13; c ^= c >> 13; d ^= d >> 13; + e ^= e >> 13; f ^= f >> 13; g ^= g >> 13; h ^= h >> 13; + } + return a ^ b ^ c ^ d ^ e ^ f ^ g ^ h; +} + +static u64 v_alu_ilp(void) { return cksum_u64(CKSUM_INIT, alu_ilp(4096, 0x12345678u)); } +static u64 r_alu_ilp(u32 n) { SINK(alu_ilp(n, 0x12345678u)); return (u64)n * 32; } + +/* ── int/alu64 — 64-bit ALU, the half of MIPS III a 32-bit guest never uses ─ */ + +static u64 alu64_chain(u32 rounds, u64 seed) +{ + u64 x = seed; + u32 i; + for (i = 0; i < rounds; i++) { + x += 0x9E3779B97F4A7C15ull; x ^= x >> 30; + x *= 0xBF58476D1CE4E5B9ull; x ^= x >> 27; + x *= 0x94D049BB133111EBull; x ^= x >> 31; + x = (x << 17) | (x >> 47); + x -= 0xD6E8FEB86659FD93ull; + x &= 0xFFFFFFFFFFFFFFFEull; + x |= 0x0000000000000003ull; + } + return x; +} + +static u64 v_alu64(void) { return cksum_u64(CKSUM_INIT, alu64_chain(4096, 0x0123456789ABCDEFull)); } +static u64 r_alu64(u32 n) { SINK(alu64_chain(n, 0x0123456789ABCDEFull)); return (u64)n * 13; } + +/* ── int/muldiv — mult/div, the long-latency integer units ────────────────── */ + +static u64 muldiv_chain(u32 rounds, u32 seed) +{ + u32 a = seed | 1u, i; + u64 acc = 0; + for (i = 0; i < rounds; i++) { + u32 b = a * 2654435761u + 1u; + u32 q, r; + if (b == 0) b = 1; + q = a / b; + r = a % b; + acc += (u64)q * 31u + r; + { + /* 64-bit divide too: dmultu/ddivu are a different unit again, and + * an emulator that special-cases the 32-bit case will show it. */ + u64 wide = ((u64)a << 20) ^ 0x5DEECE66Dull; + u64 den = (u64)b | 1ull; + acc ^= wide / den; + acc += wide % den; + } + a = a * 1103515245u + 12345u; + a |= 1u; + } + return acc ^ a; +} + +static u64 v_muldiv(void) { return cksum_u64(CKSUM_INIT, muldiv_chain(2048, 0xC0FFEE11u)); } +static u64 r_muldiv(u32 n) { SINK(muldiv_chain(n, 0xC0FFEE11u)); return (u64)n * 6; } + +/* ── int/branch — data-dependent, unpredictable branches ──────────────────── */ + +/* The branch direction comes out of a hash, so no predictor helps and no + * translator can turn it into straight-line code. This is the shape that hurts + * an emulator most: a taken branch is a dispatch, and a region-compiling JIT + * has to leave its compiled region at each one it cannot prove. */ +static u32 branch_maze(u32 rounds, u32 seed) +{ + u32 x = seed, acc = 0, i; + for (i = 0; i < rounds; i++) { + x ^= x << 13; x ^= x >> 17; x ^= x << 5; + if (x & 1u) acc += 3; + else acc ^= 0x1234u; + if ((x & 6u) == 4u) acc -= 7; + else if (x & 8u) acc += acc >> 3; + else acc ^= x; + switch ((x >> 4) & 7u) { + case 0: acc += 11; break; + case 1: acc -= 13; break; + case 2: acc ^= 17; break; + case 3: acc += acc << 2; break; + case 4: acc = ~acc; break; + case 5: acc ^= x >> 8; break; + case 6: acc += 19; break; + default: acc -= 23; break; + } + if ((s32)acc < 0) acc = (u32)(-(s32)acc); + } + return acc ^ x; +} + +static u64 v_branch(void) { return cksum_u64(CKSUM_INIT, branch_maze(8192, 0xDEADBEEFu)); } +static u64 r_branch(u32 n) { SINK(branch_maze(n, 0xDEADBEEFu)); return (u64)n * 4; } + +/* ── int/bitops — bit manipulation, no multiply, no memory ────────────────── */ + +static u32 popcnt32(u32 v) +{ + v = v - ((v >> 1) & 0x55555555u); + v = (v & 0x33333333u) + ((v >> 2) & 0x33333333u); + v = (v + (v >> 4)) & 0x0F0F0F0Fu; + return (v * 0x01010101u) >> 24; +} + +static u32 bitops_chain(u32 rounds, u32 seed) +{ + u32 x = seed, acc = 0, i; + for (i = 0; i < rounds; i++) { + u32 rev = 0, k; + x ^= x << 13; x ^= x >> 17; x ^= x << 5; + for (k = 0; k < 32; k++) rev = (rev << 1) | ((x >> k) & 1u); + acc += popcnt32(x) + popcnt32(rev); + acc ^= rev; + acc = (acc << 7) | (acc >> 25); + } + return acc; +} + +static u64 v_bitops(void) { return cksum_u64(CKSUM_INIT, bitops_chain(1024, 0x0BADC0DEu)); } +static u64 r_bitops(u32 n) { SINK(bitops_chain(n, 0x0BADC0DEu)); return (u64)n * 80; } + +/* ══ int/dhrystone — Dhrystone 2.1 ════════════════════════════════════════════ + * + * Weicker's benchmark, in the shape the 1988 C version fixed: same procedure + * and function decomposition, same string and record traffic, same globals. It + * is here because DMIPS (runs per second / 1757) is a number that can be + * compared against forty years of published figures for real hardware, + * including SGI's own, which is exactly what "how fast is the emulated Indy" + * needs and what a bespoke kernel can never provide. + * + * Two departures, both forced by running with no operating system, and neither + * touching the measured work: strcpy/strcmp are the local d_* versions below + * (there is no libc), and the timing loop belongs to the harness rather than + * to Proc_0 (there is no clock() and the harness has a better one). + */ + +typedef enum { Ident_1, Ident_2, Ident_3, Ident_4, Ident_5 } Enumeration; +typedef int One_Thirty; +typedef int One_Fifty; +typedef char Capital_Letter; +typedef int Boolean; +typedef char Str_30[31]; +typedef int Arr_1_Dim[50]; +typedef int Arr_2_Dim[50][50]; + +typedef struct record { + struct record *Ptr_Comp; + Enumeration Discr; + union { + struct { Enumeration Enum_Comp; One_Thirty Int_Comp; char Str_Comp[31]; } var_1; + struct { Enumeration E_Comp_2; char Str_2_Comp[31]; } var_2; + struct { char Ch_1_Comp; char Ch_2_Comp; } var_3; + } variant; +} Rec_Type, *Rec_Pointer; + +static Rec_Pointer Ptr_Glob, Next_Ptr_Glob; +static int Int_Glob; +static Boolean Bool_Glob; +static char Ch_1_Glob, Ch_2_Glob; +static int Arr_1_Glob[50]; +static int Arr_2_Glob[50][50]; +static Rec_Type Rec_Glob_1, Rec_Glob_2; + +static void d_strcpy(char *d, const char *s) { while ((*d++ = *s++) != 0) { } } +static int d_strcmp(const char *a, const char *b) +{ + while (*a && *a == *b) { a++; b++; } + return (int)(unsigned char)*a - (int)(unsigned char)*b; +} + +static Boolean Func_3(Enumeration Enum_Par_Val) +{ + Enumeration Enum_Loc = Enum_Par_Val; + return Enum_Loc == Ident_3; +} + +static void Proc_7(One_Fifty Int_1_Par_Val, One_Fifty Int_2_Par_Val, One_Fifty *Int_Par_Ref) +{ + One_Fifty Int_Loc = Int_1_Par_Val + 2; + *Int_Par_Ref = Int_2_Par_Val + Int_Loc; +} + +static void Proc_6(Enumeration Enum_Val_Par, Enumeration *Enum_Ref_Par) +{ + *Enum_Ref_Par = Enum_Val_Par; + if (!Func_3(Enum_Val_Par)) *Enum_Ref_Par = Ident_4; + switch (Enum_Val_Par) { + case Ident_1: *Enum_Ref_Par = Ident_1; break; + case Ident_2: *Enum_Ref_Par = (Int_Glob > 100) ? Ident_1 : Ident_4; break; + case Ident_3: *Enum_Ref_Par = Ident_2; break; + case Ident_4: break; + case Ident_5: *Enum_Ref_Par = Ident_3; break; + } +} + +static void Proc_8(Arr_1_Dim Arr_1_Par_Ref, Arr_2_Dim Arr_2_Par_Ref, + int Int_1_Par_Val, int Int_2_Par_Val) +{ + One_Fifty Int_Index, Int_Loc = Int_1_Par_Val + 5; + Arr_1_Par_Ref[Int_Loc] = Int_2_Par_Val; + Arr_1_Par_Ref[Int_Loc + 1] = Arr_1_Par_Ref[Int_Loc]; + Arr_1_Par_Ref[Int_Loc + 30] = Int_Loc; + for (Int_Index = Int_Loc; Int_Index <= Int_Loc + 1; ++Int_Index) + Arr_2_Par_Ref[Int_Loc][Int_Index] = Int_Loc; + Arr_2_Par_Ref[Int_Loc][Int_Loc - 1] += 1; + Arr_2_Par_Ref[Int_Loc + 20][Int_Loc] = Arr_1_Par_Ref[Int_Loc]; + Int_Glob = 5; +} + +static Enumeration Func_1(Capital_Letter Ch_1_Par_Val, Capital_Letter Ch_2_Par_Val) +{ + Capital_Letter Ch_1_Loc = Ch_1_Par_Val; + Capital_Letter Ch_2_Loc = Ch_1_Loc; + if (Ch_2_Loc != Ch_2_Par_Val) return Ident_1; + Ch_1_Glob = Ch_1_Loc; + return Ident_2; +} + +static Boolean Func_2(Str_30 Str_1_Par_Ref, Str_30 Str_2_Par_Ref) +{ + One_Thirty Int_Loc = 2; + Capital_Letter Ch_Loc = 'A'; + while (Int_Loc <= 2) { + if (Func_1(Str_1_Par_Ref[Int_Loc], Str_2_Par_Ref[Int_Loc + 1]) == Ident_1) { + Ch_Loc = 'A'; + Int_Loc += 1; + } + } + if (Ch_Loc >= 'W' && Ch_Loc < 'Z') Int_Loc = 7; + if (Ch_Loc == 'R') return 1; + if (d_strcmp(Str_1_Par_Ref, Str_2_Par_Ref) > 0) { Int_Loc += 7; Int_Glob = Int_Loc; return 1; } + return 0; +} + +static void Proc_3(Rec_Pointer *Ptr_Ref_Par) +{ + if (Ptr_Glob != 0) *Ptr_Ref_Par = Ptr_Glob->Ptr_Comp; + Proc_7(10, Int_Glob, &Ptr_Glob->variant.var_1.Int_Comp); +} + +static void Proc_1(Rec_Pointer Ptr_Val_Par) +{ + Rec_Pointer Next_Record = Ptr_Val_Par->Ptr_Comp; + /* structassign in the original — a whole-record copy, which is exactly the + * memcpy the compiler emits here. */ + *Ptr_Val_Par->Ptr_Comp = *Ptr_Glob; + Ptr_Val_Par->variant.var_1.Int_Comp = 5; + Next_Record->variant.var_1.Int_Comp = Ptr_Val_Par->variant.var_1.Int_Comp; + Next_Record->Ptr_Comp = Ptr_Val_Par->Ptr_Comp; + Proc_3(&Next_Record->Ptr_Comp); + if (Next_Record->Discr == Ident_1) { + Next_Record->variant.var_1.Int_Comp = 6; + Proc_6(Ptr_Val_Par->variant.var_1.Enum_Comp, &Next_Record->variant.var_1.Enum_Comp); + Next_Record->Ptr_Comp = Ptr_Glob->Ptr_Comp; + Proc_7(Next_Record->variant.var_1.Int_Comp, 10, &Next_Record->variant.var_1.Int_Comp); + } else { + *Ptr_Val_Par = *Ptr_Val_Par->Ptr_Comp; + } +} + +static void Proc_2(One_Fifty *Int_Par_Ref) +{ + One_Fifty Int_Loc = *Int_Par_Ref + 10; + Enumeration Enum_Loc = Ident_1; /* the original leaves this uninitialised; + * seeding it keeps the checksum stable + * without changing any executed path, + * since Int_Glob is 5 here every time */ + for (;;) { + if (Ch_1_Glob == 'A') { + Int_Loc -= 1; + *Int_Par_Ref = Int_Loc - Int_Glob; + Enum_Loc = Ident_1; + } + if (Enum_Loc == Ident_1) break; + } +} + +static void Proc_4(void) +{ + Boolean Bool_Loc = Ch_1_Glob == 'A'; + Bool_Glob = Bool_Loc | Bool_Glob; + Ch_2_Glob = 'B'; +} + +static void Proc_5(void) +{ + Ch_1_Glob = 'A'; + Bool_Glob = 0; +} + +static void dhry_setup(void) +{ + Next_Ptr_Glob = &Rec_Glob_1; + Ptr_Glob = &Rec_Glob_2; + + Ptr_Glob->Ptr_Comp = Next_Ptr_Glob; + Ptr_Glob->Discr = Ident_1; + Ptr_Glob->variant.var_1.Enum_Comp = Ident_3; + Ptr_Glob->variant.var_1.Int_Comp = 40; + d_strcpy(Ptr_Glob->variant.var_1.Str_Comp, "DHRYSTONE PROGRAM, SOME STRING"); + + Next_Ptr_Glob->Ptr_Comp = 0; + Next_Ptr_Glob->Discr = Ident_1; + Next_Ptr_Glob->variant.var_1.Enum_Comp = Ident_3; + Next_Ptr_Glob->variant.var_1.Int_Comp = 0; + Next_Ptr_Glob->variant.var_1.Str_Comp[0] = 0; + + Arr_2_Glob[8][7] = 10; + Int_Glob = 0; + Bool_Glob = 0; + Ch_1_Glob = 0; + Ch_2_Glob = 0; + { + int i, j; + for (i = 0; i < 50; i++) { Arr_1_Glob[i] = 0; for (j = 0; j < 50; j++) Arr_2_Glob[i][j] = 0; } + Arr_2_Glob[8][7] = 10; + } +} + +static void dhry_runs(u32 runs) +{ + One_Fifty Int_1_Loc, Int_2_Loc, Int_3_Loc; + char Ch_Index; + Enumeration Enum_Loc; + Str_30 Str_1_Loc, Str_2_Loc; + u32 Run_Index; + + d_strcpy(Str_1_Loc, "DHRYSTONE PROGRAM, 1'ST STRING"); + d_strcpy(Str_2_Loc, "DHRYSTONE PROGRAM, 2'ND STRING"); + /* All three are assigned on every iteration of the loop below; seeding + * them keeps a runs==0 call (which the autoscaler never makes, but the + * compiler cannot know that) from reading an uninitialised local. */ + Int_1_Loc = 0; + Int_2_Loc = 0; + Int_3_Loc = 0; + Enum_Loc = Ident_1; + + for (Run_Index = 1; Run_Index <= runs; ++Run_Index) { + Proc_5(); + Proc_4(); + Int_1_Loc = 2; + Int_2_Loc = 3; + d_strcpy(Str_2_Loc, "DHRYSTONE PROGRAM, 2'ND STRING"); + Enum_Loc = Ident_2; + Bool_Glob = !Func_2(Str_1_Loc, Str_2_Loc); + while (Int_1_Loc < Int_2_Loc) { + Int_3_Loc = 5 * Int_1_Loc - Int_2_Loc; + Proc_7(Int_1_Loc, Int_2_Loc, &Int_3_Loc); + Int_1_Loc += 1; + } + Proc_8(Arr_1_Glob, Arr_2_Glob, Int_1_Loc, Int_3_Loc); + Proc_1(Ptr_Glob); + for (Ch_Index = 'A'; Ch_Index <= Ch_2_Glob; ++Ch_Index) { + if (Enum_Loc == Func_1(Ch_Index, 'C')) { + Proc_6(Ident_1, &Enum_Loc); + d_strcpy(Str_2_Loc, "DHRYSTONE PROGRAM, 3'RD STRING"); + Int_2_Loc = (int)Run_Index; + Int_Glob = (int)Run_Index; + } + } + Int_2_Loc = Int_2_Loc * Int_1_Loc; + Int_1_Loc = Int_2_Loc / Int_3_Loc; + Int_2_Loc = 7 * (Int_2_Loc - Int_3_Loc) - Int_1_Loc; + Proc_2(&Int_1_Loc); + } + + /* Feed the loop-carried locals somewhere the optimiser cannot see through, + * so the last iteration is not dead code. */ + SINK(Int_1_Loc); SINK(Int_2_Loc); SINK(Int_3_Loc); + SINK((int)Enum_Loc); SINK(Str_2_Loc[0]); +} + +static u64 v_dhry(void) +{ + u64 h = CKSUM_INIT; + int i, j; + dhry_setup(); + dhry_runs(1000); + h = cksum_u64(h, (u64)(u32)Int_Glob); + h = cksum_u64(h, (u64)(u32)Bool_Glob); + h = cksum_u64(h, (u64)(u8)Ch_1_Glob); + h = cksum_u64(h, (u64)(u8)Ch_2_Glob); + for (i = 0; i < 50; i++) h = cksum_u64(h, (u64)(u32)Arr_1_Glob[i]); + for (i = 0; i < 50; i++) for (j = 0; j < 50; j++) h = cksum_u64(h, (u64)(u32)Arr_2_Glob[i][j]); + h = cksum_u64(h, (u64)(u32)Ptr_Glob->variant.var_1.Int_Comp); + h = cksum_u64(h, (u64)(u32)Next_Ptr_Glob->variant.var_1.Int_Comp); + h = cksum_bytes(h, Ptr_Glob->variant.var_1.Str_Comp, 31); + return h; +} + +static u64 r_dhry(u32 n) { dhry_setup(); dhry_runs(n); return (u64)n; } + +/* ── registration ─────────────────────────────────────────────────────────── */ + +static const struct bench benches[] = { + BENCH("int/alu", "ops", v_alu, r_alu, 1u << 14, BG_INT), + BENCH("int/alu_ilp", "ops", v_alu_ilp, r_alu_ilp, 1u << 14, BG_INT), + BENCH("int/alu64", "ops", v_alu64, r_alu64, 1u << 14, BG_INT), + BENCH("int/muldiv", "ops", v_muldiv, r_muldiv, 1u << 13, BG_INT), + BENCH("int/branch", "ops", v_branch, r_branch, 1u << 13, BG_INT), + BENCH("int/bitops", "ops", v_bitops, r_bitops, 1u << 10, BG_INT), + BENCH("int/dhrystone", "dhry", v_dhry, r_dhry, 1u << 12, BG_INT), +}; + +const struct bench_group group_integer = { + "integer", benches, sizeof(benches) / sizeof(benches[0]) +}; diff --git a/bench/kernels/memory.c b/bench/kernels/memory.c new file mode 100644 index 0000000..1b931f8 --- /dev/null +++ b/bench/kernels/memory.c @@ -0,0 +1,395 @@ +/* + * memory.c — cache hierarchy and memory system. + * + * The three latency kernels are one kernel at three working-set sizes, chosen + * to land in a different level of an Indy's hierarchy each: 8 KB fits the + * R4400's 16 KB L1D and the R5000's 32 KB, 256 KB misses both L1s but fits a + * 1 MB L2, and 8 MB misses everything. Read as a set they trace the hierarchy; + * the shape of that curve is a much better test of the cache model than any + * single number, because an emulator that models L2 as "L1 that missed" gets + * the middle point wrong while both ends look fine. + * + * Each chase is a single dependent load chain — the next address comes out of + * the current load — so nothing can overlap the misses and the score really is + * latency rather than throughput. + */ + +#include "benchlib.h" + +#define L1_BYTES (8u * 1024u) +#define L2_BYTES (256u * 1024u) +#define DRAM_BYTES (8u * 1024u * 1024u) +/* A stride of 128 clears the 16-byte R4400 line and the 32-byte R5000 line + * with room to spare, so consecutive chase steps never share a line. */ +#define CHASE_STRIDE 128u + +struct chase { + u32 **ptrs; + u32 bytes; + u32 steps; + int built; +}; + +static struct chase chase_l1, chase_l2, chase_dram; + +/* + * Build one Sattolo cycle over the buffer: every slot is visited exactly once + * before returning to the start, and the order is a random permutation, so no + * prefetcher (real or emulated) can predict the next address. Rebuilt only + * when the allocation moves, which it does not — work_reset hands back the + * same base every call, and rebuilding a 65 536-step permutation inside the + * timed region would be most of what got measured. + */ +static void chase_build(struct chase *c, u32 bytes) +{ + u32 steps = bytes / CHASE_STRIDE; + unsigned char *buf = (unsigned char *)work_alloc(bytes, 4096); + u64 s = 0x9E3779B97F4A7C15ull ^ bytes; + u32 i; + u32 *order; + + if (c->built && (unsigned char *)c->ptrs == buf) return; + + order = (u32 *)work_alloc(steps * (u32)sizeof(u32), 64); + for (i = 0; i < steps; i++) order[i] = i; + for (i = steps - 1; i > 0; i--) { + u32 j = (u32)(rng_next(&s) % i); /* Sattolo: j < i, never j == i */ + u32 t = order[i]; order[i] = order[j]; order[j] = t; + } + /* order[] is now a single cycle; lay it down as next-pointers. */ + for (i = 0; i < steps; i++) { + u32 from = order[i]; + u32 to = order[(i + 1) % steps]; + *(u32 **)(void *)(buf + (unsigned long)from * CHASE_STRIDE) = + (u32 *)(void *)(buf + (unsigned long)to * CHASE_STRIDE); + } + + c->ptrs = (u32 **)(void *)buf; + c->bytes = bytes; + c->steps = steps; + c->built = 1; +} + +static u64 chase_run(struct chase *c, u32 iters) +{ + u32 **p = c->ptrs; + u64 total = 0; + u32 it, i; + for (it = 0; it < iters; it++) { + for (i = 0; i < c->steps; i++) p = (u32 **)*p; + total += c->steps; + } + SINK((unsigned long)p); + return total; +} + +static u64 chase_verify(struct chase *c, u32 bytes) +{ + /* One full lap must return to where it started — a cycle, not a rho. That + * is the only thing worth checking here, and it makes a broken build + * loudly wrong rather than quietly short. */ + u32 **p, **start; + u64 h = CKSUM_INIT; + u32 i; + chase_build(c, bytes); + start = c->ptrs; + p = start; + for (i = 0; i < c->steps; i++) p = (u32 **)*p; + h = cksum_u64(h, (u64)(p == start)); + h = cksum_u64(h, c->steps); + return h; +} + +static u64 v_lat_l1(void) { return chase_verify(&chase_l1, L1_BYTES); } +static u64 v_lat_l2(void) { return chase_verify(&chase_l2, L2_BYTES); } +static u64 v_lat_dram(void) { return chase_verify(&chase_dram, DRAM_BYTES); } + +static u64 r_lat_l1(u32 n) { chase_build(&chase_l1, L1_BYTES); return chase_run(&chase_l1, n); } +static u64 r_lat_l2(u32 n) { chase_build(&chase_l2, L2_BYTES); return chase_run(&chase_l2, n); } +static u64 r_lat_dram(u32 n) { chase_build(&chase_dram, DRAM_BYTES); return chase_run(&chase_dram, n); } + +/* ── streaming bandwidth ──────────────────────────────────────────────────── */ + +/* STREAM-style, and deliberately not STREAM: the arrays are sized to miss + * every cache on the machine, the loops are the same four shapes, but this is + * an independent implementation and its numbers should not be quoted as STREAM + * results. */ +#define STREAM_N (512u * 1024u) /* 512K doubles = 4 MB per array */ + +static double *st_a, *st_b, *st_c; +static int st_ready; + +static void stream_alloc(void) +{ + double *a = (double *)work_alloc(STREAM_N * (u32)sizeof(double), 4096); + double *b = (double *)work_alloc(STREAM_N * (u32)sizeof(double), 4096); + double *c = (double *)work_alloc(STREAM_N * (u32)sizeof(double), 4096); + u32 i; + if (st_ready && a == st_a) return; + st_a = a; st_b = b; st_c = c; + for (i = 0; i < STREAM_N; i++) { a[i] = 1.0; b[i] = 2.0; c[i] = 0.0; } + st_ready = 1; +} + +/* Cheap correctness checks for the streaming kernels: run the same loop over a + * small prefix from known inputs and fold in the result. Without these three, + * a third of the memory group contributes nothing to the accuracy score. */ +static u64 v_st_copy(void) +{ + u64 h = CKSUM_INIT; + u32 i; + stream_alloc(); + for (i = 0; i < 4096; i++) { st_a[i] = (double)(int)(i * 3 + 1); st_c[i] = 0.0; } + for (i = 0; i < 4096; i++) st_c[i] = st_a[i]; + for (i = 0; i < 4096; i += 97) h = cksum_f64(h, st_c[i]); + for (i = 0; i < 4096; i++) { st_a[i] = 1.0; st_c[i] = 0.0; } + return h; +} + +static u64 v_st_scale(void) +{ + u64 h = CKSUM_INIT; + u32 i; + stream_alloc(); + for (i = 0; i < 4096; i++) { st_c[i] = (double)(int)(i & 255) * 0.125; st_b[i] = 0.0; } + for (i = 0; i < 4096; i++) st_b[i] = 3.0 * st_c[i]; + for (i = 0; i < 4096; i += 97) h = cksum_f64(h, st_b[i]); + for (i = 0; i < 4096; i++) { st_b[i] = 2.0; st_c[i] = 0.0; } + return h; +} + +static u64 r_st_copy(u32 n) +{ + u32 it, i; + stream_alloc(); + for (it = 0; it < n; it++) for (i = 0; i < STREAM_N; i++) st_c[i] = st_a[i]; + SINK((int)(st_c[0] != 0.0)); + return (u64)n * STREAM_N * 2ull * sizeof(double); /* one read + one write */ +} + +static u64 r_st_scale(u32 n) +{ + u32 it, i; + stream_alloc(); + for (it = 0; it < n; it++) for (i = 0; i < STREAM_N; i++) st_b[i] = 3.0 * st_c[i]; + SINK((int)(st_b[0] != 0.0)); + return (u64)n * STREAM_N * 2ull * sizeof(double); +} + +static u64 r_st_triad(u32 n) +{ + u32 it, i; + stream_alloc(); + for (it = 0; it < n; it++) for (i = 0; i < STREAM_N; i++) st_a[i] = st_b[i] + 3.0 * st_c[i]; + SINK((int)(st_a[0] != 0.0)); + return (u64)n * STREAM_N * 3ull * sizeof(double); /* two reads + one write */ +} + +static u64 v_st_triad(void) +{ + u64 h = CKSUM_INIT; + u32 i; + stream_alloc(); + for (i = 0; i < STREAM_N; i++) { st_b[i] = (double)(int)(i & 1023); st_c[i] = 0.5; } + for (i = 0; i < STREAM_N; i++) st_a[i] = st_b[i] + 3.0 * st_c[i]; + for (i = 0; i < STREAM_N; i += 4093) h = cksum_f64(h, st_a[i]); + /* Leave the arrays as stream_alloc set them up, so a later run() is not + * measuring a different denormal/zero mix than the first one did. */ + for (i = 0; i < STREAM_N; i++) { st_a[i] = 1.0; st_b[i] = 2.0; st_c[i] = 0.0; } + return h; +} + +/* ── mem/fill — write-only bandwidth ──────────────────────────────────────── */ + +#define FILL_BYTES (4u * 1024u * 1024u) + +static u64 r_fill(u32 n) +{ + unsigned char *p = (unsigned char *)work_alloc(FILL_BYTES, 4096); + u32 it; + for (it = 0; it < n; it++) memset(p, (int)(it & 0xFF), FILL_BYTES); + SINK(p[0]); + return (u64)n * FILL_BYTES; +} + +static u64 v_fill(void) +{ + unsigned char *p = (unsigned char *)work_alloc(FILL_BYTES, 4096); + memset(p, 0, 4096); + memset(p + 1, 0xA5, 1023); + memset(p + 2048, 0x5A, 1000); + return cksum_bytes(CKSUM_INIT, p, 4096); +} + +/* ── mem/copy — byte-copy bandwidth through the harness memcpy ────────────── */ + +#define COPY_BYTES (2u * 1024u * 1024u) + +static u64 v_copy(void) +{ + unsigned char *a = (unsigned char *)work_alloc(COPY_BYTES, 4096); + unsigned char *b = (unsigned char *)work_alloc(COPY_BYTES, 4096); + u64 s = 0x1B1C1D1E1F202122ull; + u32 i; + for (i = 0; i < 8192; i++) a[i] = (unsigned char)rng_next(&s); + /* Clear first: the tail of the region is never written by the copies + * below, and checksumming uninitialised RAM makes the result depend on + * whatever the allocator last held — which is not the same on the host as + * on the guest, and not even the same between two host builds. */ + memset(b, 0, 8192); + /* All four misalignments of dst against src: memcpy's word fast path is + * only taken when the two agree modulo four. */ + for (i = 0; i < 4; i++) memcpy(b + 4096 + i * 1024, a + i, 1000); + memcpy(b, a, 4096); + return cksum_bytes(CKSUM_INIT, b, 8192); +} + +static u64 r_copy(u32 n) +{ + unsigned char *a = (unsigned char *)work_alloc(COPY_BYTES, 4096); + unsigned char *b = (unsigned char *)work_alloc(COPY_BYTES, 4096); + u32 it; + for (it = 0; it < n; it++) memcpy(b, a, COPY_BYTES); + SINK(b[0]); + return (u64)n * COPY_BYTES * 2ull; +} + +/* ── mem/unaligned — lwl/lwr, the path a naive byte-stream reader takes ───── */ + +/* + * One unaligned big-endian 32-bit load. + * + * On MIPS this is lwl+lwr, written out rather than left to the compiler. + * `*(const u32 *)(const void *)p` looks like it would do the job, and it does + * on the host — but on MIPS the cast promises alignment the pointer does not + * have, so GCC emits a plain `lw`, three loads in four take an address error, + * the harness's exception handler skips them, and the kernel reports a + * throughput for taking exceptions. It scored 871 k/s and a wrong checksum + * before this was explicit. + * + * On the host the word is assembled from bytes instead: a native unaligned + * load there produces a little-endian value, and the golden comparison would + * then be reporting a byte order difference as an emulator fault. + */ +static inline u32 ua_load(const unsigned char *p) +{ +#if defined(BENCH_HOST) + return ((u32)p[0] << 24) | ((u32)p[1] << 16) | ((u32)p[2] << 8) | (u32)p[3]; +#else + u32 v; + __asm__(".set push; .set mips3; .set noreorder; .set noat\n\t" + "lwl %0, 0(%1)\n\t" + "lwr %0, 3(%1)\n\t" + ".set pop" : "=&r"(v) : "r"(p) : "memory"); + return v; +#endif +} + +/* `bytes` is what is readable AT p, not the size of the underlying buffer — + * the caller starts one byte in. Passing the buffer size read four bytes + * beginning at the last valid one, and the byte past the end is leftover from + * whichever kernel ran before on the guest and fresh malloc on the host, so + * the two checksums could never agree. The accuracy score is what found it. */ +static u32 unaligned_sum(const unsigned char *p, u32 bytes, u32 off) +{ + u32 acc = 0, i; + for (i = off; i + 4 <= bytes; i += 7) acc += ua_load(p + i); + return acc; +} + +#define UA_BYTES (1u << 20) + +static unsigned char *ua_buf; +static int ua_ready; + +static void ua_alloc(void) +{ + unsigned char *p = (unsigned char *)work_alloc(UA_BYTES, 4096); + u64 s = 0xABCDEF0123456789ull; + u32 i; + if (ua_ready && p == ua_buf) return; + for (i = 0; i < UA_BYTES; i++) p[i] = (unsigned char)rng_next(&s); + ua_buf = p; ua_ready = 1; +} + +static u64 r_unaligned(u32 n) +{ + u32 it, acc = 0; + ua_alloc(); + /* Offset 1 guarantees every load crosses a word boundary. GCC will not + * emit lwl/lwr for a plain aligned-typed load, so the address is made + * opaque and the compiler has to assume the worst. */ + for (it = 0; it < n; it++) acc += unaligned_sum(OPAQUE(ua_buf) + 1, UA_BYTES - 1, 0); + SINK(acc); + return (u64)n * ((UA_BYTES - 1u) / 7u); +} + +static u64 v_unaligned(void) +{ + ua_alloc(); + return cksum_u64(CKSUM_INIT, unaligned_sum(OPAQUE(ua_buf) + 1, UA_BYTES - 1, 0)); +} + +/* ── mem/random — scattered 64-bit read-modify-write ──────────────────────── */ + +/* One update per random address over an 8 MB table: no locality at any level, + * which is where a TLB-and-cache model earns or loses its keep. */ +#define RAND_WORDS (1u << 20) /* 8 MB of u64 */ + +static u64 *rnd_tab; +static int rnd_ready; + +static void rnd_alloc(void) +{ + u64 *t = (u64 *)work_alloc(RAND_WORDS * (u32)sizeof(u64), 4096); + u32 i; + if (rnd_ready && t == rnd_tab) return; + for (i = 0; i < RAND_WORDS; i++) t[i] = i; + rnd_tab = t; rnd_ready = 1; +} + +/* Caller allocates; see rle_roundtrip for why. */ +static u64 rnd_go(u32 updates, u64 seed) +{ + u64 s = seed; + u32 i; + for (i = 0; i < updates; i++) { + u64 r = rng_next(&s); + u32 idx = (u32)(r & (RAND_WORDS - 1)); + rnd_tab[idx] ^= r; + } + return (u64)updates; +} + +static u64 r_random(u32 n) { rnd_alloc(); return rnd_go(n, 0x123456789ABCDEFull); } + +static u64 v_random(void) +{ + u64 h = CKSUM_INIT; + u32 i; + rnd_alloc(); + for (i = 0; i < RAND_WORDS; i++) rnd_tab[i] = i; + (void)rnd_go(65536, 0x123456789ABCDEFull); + for (i = 0; i < RAND_WORDS; i += 8191) h = cksum_u64(h, rnd_tab[i]); + for (i = 0; i < RAND_WORDS; i++) rnd_tab[i] = i; + return h; +} + +/* ── registration ─────────────────────────────────────────────────────────── */ + +static const struct bench benches[] = { + BENCH("mem/latency_l1", "acc", v_lat_l1, r_lat_l1, 1u << 5, BG_MEM), + BENCH("mem/latency_l2", "acc", v_lat_l2, r_lat_l2, 1u << 2, BG_MEM), + BENCH("mem/latency_dram", "acc", v_lat_dram, r_lat_dram, 1, BG_MEM), + BENCH("mem/copy", "B", v_copy, r_copy, 1, BG_MEM), + BENCH("mem/fill", "B", v_fill, r_fill, 1, BG_MEM), + BENCH("mem/stream_copy", "B", v_st_copy, r_st_copy, 1, BG_MEM), + BENCH("mem/stream_scale", "B", v_st_scale, r_st_scale, 1, BG_MEM), + BENCH("mem/stream_triad", "B", v_st_triad, r_st_triad, 1, BG_MEM), + BENCH("mem/unaligned", "acc", v_unaligned, r_unaligned, 1, BG_MEM), + BENCH("mem/random", "upd", v_random, r_random, 1u << 16, BG_MEM), +}; + +const struct bench_group group_memory = { + "memory", benches, sizeof(benches) / sizeof(benches[0]) +}; diff --git a/bench/kernels/sys.c b/bench/kernels/sys.c new file mode 100644 index 0000000..25afd4f --- /dev/null +++ b/bench/kernels/sys.c @@ -0,0 +1,270 @@ +/* + * sys.c — the machine underneath the machine. + * + * These four have no meaning on a host and no golden checksum: they measure + * paths that exist only because this is a MIPS running under an emulator — + * address translation, exception entry and exit, cache maintenance, and + * uncached device access. They are the kernels most likely to move when the + * emulator's own internals change, and the ones an ALU benchmark can never + * see. rules/perf and rules/jitv2 are full of work whose payoff shows up here + * and nowhere else. + */ + +#include "benchlib.h" + +#if !defined(BENCH_HOST) + +#include "cp0.h" + +/* ── sys/tlb_hit — translation on the fast path ───────────────────────────── */ + +/* + * Fill all 48 entries, then walk exactly those 48 pages. Every access + * translates and every translation hits, so this is the cost of the lookup + * itself — which is precisely what the tlbvmap fast path in mips_tlb.rs + * exists to make cheap, and the only kernel here that can show it working. + */ +#define TLB_ENTRIES_USED 48 +#define PAGE_BYTES 4096u +#define TLB_HIT_VA 0x00800000u + +static unsigned char *tlb_phys; +static int tlb_mapped; + +static u32 va_to_pfn(unsigned char *p) { return ((u32)(unsigned long)p & 0x1FFFFFFFu) >> 12; } + +/* Park every entry on a distinct invalid VPN before writing real ones. Leaving + * the power-on state in place risks two entries claiming the same VPN2, which + * on real silicon is undefined and in IRIS trips the duplicate-entry checks + * that rules/testing exists because of. */ +static void tlb_reset_all(void) +{ + int i; + cp0_pagemask_set(PM_4K); + for (i = 0; i < 48; i++) { + cp0_entryhi_set((u64)(s64)(s32)(0x40000000u + (u32)i * 0x2000u)); + cp0_entrylo0_set(0); + cp0_entrylo1_set(0); + cp0_index_set((u32)i); + __asm__ __volatile__(".set push; .set mips3; .set noreorder\n\t" + "tlbwi\n\tnop\n\tnop\n\t.set pop" ::: "memory"); + } +} + +static void tlb_hit_setup(void) +{ + int i; + unsigned char *phys = (unsigned char *)work_alloc(TLB_ENTRIES_USED * 2u * PAGE_BYTES, 16384); + u32 pfn0 = va_to_pfn(phys); + + tlb_phys = phys; + tlb_reset_all(); + cp0_pagemask_set(PM_4K); + /* One entry maps a pair of pages, so 48 entries cover 96 pages; the walk + * below touches every one of them. */ + for (i = 0; i < TLB_ENTRIES_USED; i++) { + u32 va = TLB_HIT_VA + (u32)i * 2u * PAGE_BYTES; + u32 pfn = pfn0 + (u32)i * 2u; + cp0_entryhi_set((u64)(s64)(s32)va); + cp0_entrylo0_set(((u64)pfn << ELO_PFN_SHIFT) | ((u64)CA_CACHEABLE_NC << ELO_C_SHIFT) | ELO_D | ELO_V | ELO_G); + cp0_entrylo1_set(((u64)(pfn + 1) << ELO_PFN_SHIFT) | ((u64)CA_CACHEABLE_NC << ELO_C_SHIFT) | ELO_D | ELO_V | ELO_G); + cp0_index_set((u32)i); + __asm__ __volatile__(".set push; .set mips3; .set noreorder\n\t" + "tlbwi\n\tnop\n\tnop\n\t.set pop" ::: "memory"); + } + tlb_mapped = 1; + + /* Seed through the mapping so the pages are real and the first timed pass + * is not paying for cold cache lines on top of the translation. */ + for (i = 0; i < TLB_ENTRIES_USED * 2; i++) { + volatile u32 *p = (volatile u32 *)SEXT_PTR(TLB_HIT_VA + (u32)i * PAGE_BYTES); + *p = (u32)i; + } +} + +static u64 tlb_hit_walk(u32 iters) +{ + u32 it, i, acc = 0; + for (it = 0; it < iters; it++) { + for (i = 0; i < TLB_ENTRIES_USED * 2; i++) { + volatile u32 *p = (volatile u32 *)SEXT_PTR(TLB_HIT_VA + i * PAGE_BYTES); + acc += *p; + } + } + SINK(acc); + return (u64)iters * TLB_ENTRIES_USED * 2u; +} + +static u64 r_tlb_hit(u32 n) +{ + u64 w; + tlb_hit_setup(); + w = tlb_hit_walk(n); + tlb_reset_all(); + return w; +} + +/* ── sys/tlb_miss — translation on the slow path ──────────────────────────── */ + +#define MISS_VA 0x02000000u +#define MISS_PAGES 2048u /* 8 MB: 42x the TLB, so every + * touch refills */ + +extern u32 bench_tlb_refill[], bench_tlb_refill_end[]; +extern u32 bench_pfn_delta; + +static void install_refill(void) +{ + volatile u32 *dst; + unsigned n = (unsigned)(bench_tlb_refill_end - bench_tlb_refill), i; + if (n > 32) panic("refill handler too long for a vector slot"); + /* Both vectors: start.S runs with KX/SX/UX set, so a kuseg address refills + * through the 64-bit XTLB vector, not the 32-bit one — and which of the + * two fires is exactly the sort of thing worth being immune to. */ + dst = (volatile u32 *)SEXT_PTR(VEC_TLB_REFILL); + for (i = 0; i < n; i++) dst[i] = bench_tlb_refill[i]; + dst = (volatile u32 *)SEXT_PTR(VEC_XTLB_REFILL); + for (i = 0; i < n; i++) dst[i] = bench_tlb_refill[i]; + dcache_wb_range(SEXT_PTR(VEC_TLB_REFILL), 0x200); + icache_inv_range(SEXT_PTR(VEC_TLB_REFILL), 0x200); + SYNC(); +} + +static u64 r_tlb_miss(u32 n) +{ + unsigned char *phys = (unsigned char *)work_alloc(MISS_PAGES * PAGE_BYTES, 16384); + u32 it, i, acc = 0; + + bench_pfn_delta = va_to_pfn(phys) - (MISS_VA >> 12); + dcache_wb_range((volatile void *)&bench_pfn_delta, 4); + + tlb_reset_all(); + cp0_pagemask_set(PM_4K); + install_refill(); + + /* Prove the mapping before measuring it. A refill handler that computes + * the wrong PFN does not fail — it faults on unmapped physical memory, + * the shared dispatcher records and skips, and the kernel happily reports + * a throughput figure for taking bus errors. Check that a word written + * through KSEG0 reads back through the mapping, and stop if it does not. */ + { + volatile u32 *k0 = (volatile u32 *)SEXT_PTR((u32)(unsigned long)phys); + volatile u32 *va = (volatile u32 *)SEXT_PTR(MISS_VA); + *k0 = 0x7EB1AB1Eu; + dcache_wb_range((volatile void *)k0, 4); + if (*va != 0x7EB1AB1Eu) { + tlb_reset_all(); + exc_install(); + panic("sys/tlb_miss: refill handler does not map the region"); + } + } + + /* Stride a whole page so no two consecutive touches share an entry, and + * walk far more pages than the TLB holds so nothing survives to be reused. */ + for (it = 0; it < n; it++) { + for (i = 0; i < MISS_PAGES; i++) { + volatile u32 *p = (volatile u32 *)SEXT_PTR(MISS_VA + i * PAGE_BYTES); + acc += *p; + } + } + SINK(acc); + + tlb_reset_all(); + exc_install(); /* hand the vectors back to the harness */ + return (u64)n * MISS_PAGES; +} + +/* ── sys/exception — a full trap round trip ───────────────────────────────── */ + +/* + * A `break` per iteration through the shared dispatcher: vector entry, eleven + * CP0 reads, the EPC fixup and an eret. That is a heavier handler than a + * syscall would meet in practice, but it is the same handler on every cell, + * and an exception is the one operation where an emulator can be + * catastrophically slower than the hardware it stands in for. + */ +static u64 r_exception(u32 n) +{ + u32 i; + exc_clear(); + exc_resume_mode = EXC_RESUME_SKIP; + for (i = 0; i < n; i++) + __asm__ __volatile__(".set push; .set mips3; .set noreorder\n\t" + "break 0\n\tnop\n\t.set pop" ::: "memory"); + SINK((u32)exc.count); + exc_clear(); + return (u64)n; +} + +/* ── sys/cache_flush — cache maintenance over a range ─────────────────────── */ + +#define FLUSH_BYTES (256u * 1024u) + +static u64 r_cache_flush(u32 n) +{ + unsigned char *p = (unsigned char *)work_alloc(FLUSH_BYTES, 4096); + u32 it, i; + for (it = 0; it < n; it++) { + /* Dirty it first, or the writeback has nothing to do and the number is + * a measurement of the "already clean" early-out instead. */ + for (i = 0; i < FLUSH_BYTES; i += 64) p[i] = (unsigned char)(it + i); + dcache_wb_range(p, FLUSH_BYTES); + } + SINK(p[0]); + return (u64)n * (FLUSH_BYTES / 16u); /* cache ops issued */ +} + +/* ── sys/uncached — KSEG1, straight down the MC bus ───────────────────────── */ + +#define UNCACHED_BYTES (64u * 1024u) + +static u64 r_uncached(u32 n) +{ + unsigned char *p = (unsigned char *)work_alloc(UNCACHED_BYTES, 4096); + volatile u32 *k1 = (volatile u32 *)K1_PTR(p); + u32 it, i, acc = 0; + dcache_wb_range(p, UNCACHED_BYTES); + for (it = 0; it < n; it++) + for (i = 0; i < UNCACHED_BYTES / 4u; i++) acc += k1[i]; + SINK(acc); + return (u64)n * UNCACHED_BYTES; +} + +/* ── sys/llsc — load-linked / store-conditional, IRIX's lock primitive ────── */ + +static u64 r_llsc(u32 n) +{ + volatile u32 *p = (volatile u32 *)work_alloc(64, 64); + u32 i, ok = 0; + *p = 0; + for (i = 0; i < n; i++) { + u32 v, res; + __asm__ __volatile__(".set push; .set mips3; .set noreorder\n\t" + "ll %0, 0(%2)\n\t" + "addiu %0, %0, 1\n\t" + "move %1, %0\n\t" + "sc %1, 0(%2)\n\t" + ".set pop" + : "=&r"(v), "=&r"(res) : "r"(p) : "memory"); + ok += res; + } + SINK(ok); + return (u64)n; +} + +/* ── registration ─────────────────────────────────────────────────────────── */ + +static const struct bench benches[] = { + BENCH("sys/tlb_hit", "xlat", 0, r_tlb_hit, 1u << 8, BG_SYS), + BENCH_EXC("sys/tlb_miss", "miss", 0, r_tlb_miss, 1u << 2, BG_SYS), + BENCH_EXC("sys/exception", "exc", 0, r_exception, 1u << 12, BG_SYS), + BENCH("sys/cache_flush", "op", 0, r_cache_flush, 1u << 3, BG_SYS), + BENCH("sys/uncached", "B", 0, r_uncached, 1u << 2, BG_SYS), + BENCH("sys/llsc", "op", 0, r_llsc, 1u << 14, BG_SYS), +}; + +const struct bench_group group_sys = { + "sys", benches, sizeof(benches) / sizeof(benches[0]) +}; + +#endif /* !BENCH_HOST */ diff --git a/bench/run/bare.toml b/bench/run/bare.toml new file mode 100644 index 0000000..b55908a --- /dev/null +++ b/bench/run/bare.toml @@ -0,0 +1,19 @@ +# Bare-metal machine for the benchmark suite. +# +# 256 MB in two banks: the suite probes for up to 24 MB of working set above +# its image, and the DRAM-latency and stream kernels are only measuring DRAM if +# the buffers genuinely do not fit in any cache. +# +# No SCSI, no graphics, no audio. The `scsi` key must be present-but-empty: +# when it is missing entirely serde falls back to default_scsi(), which +# attaches scsi1.raw and makes startup fatal when that file is absent — which +# it always is here. + +banks = [128, 128, 0, 0] +headless = true +no_audio = true + +[machine] +profile = "indy_ip24" + +[scsi] diff --git a/bench/run/run-local.sh b/bench/run/run-local.sh new file mode 100755 index 0000000..98f7880 --- /dev/null +++ b/bench/run/run-local.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# run-local.sh — run the benchmark under IRIS and keep the result. +# +# Loads the ELF straight into RAM (--load-elf): no PROM, no disk, no IRIX, so +# nothing between the kernels and the emulator. The machine block the guest +# prints is the actual output; the human table above it is for watching. +# +# usage: run-local.sh [build/irisbench.elf] [extra iris args...] +set -uo pipefail + +ELF="${1:-build/irisbench.elf}"; shift || true +IRIS="${IRIS:-../target/release/iris}" +# A hang detector, not a performance budget. The suite targets ~250 ms per +# timed run over ~45 kernels with 2 repeats plus calibration, so a couple of +# minutes is normal and ten is not. +TIMEOUT="${TIMEOUT:-900}" +LOG="${LOG:-build/bench.log}" + +[[ -f "$ELF" ]] || { echo "run-local: no such binary: $ELF" >&2; exit 2; } +[[ -x "$IRIS" ]] || { echo "run-local: no iris at $IRIS (cargo build --release)" >&2; exit 2; } + +mkdir -p "$(dirname "$LOG")" +rm -f "$LOG" + +# --test-device is not optional here: without it there is no host clock and no +# retired-instruction count, and every score falls back to CP0 Count at an +# assumed frequency. The suite says so in its header when that happens. +timeout "$TIMEOUT" "$IRIS" \ + --config run/bare.toml \ + --load-elf "$ELF" \ + --test-device \ + --headless --noaudio \ + "$@" 2>&1 | tee "$LOG" +rc=${PIPESTATUS[0]} + +if [[ $rc -eq 124 ]]; then + echo "run-local: TIMED OUT after ${TIMEOUT}s" >&2 + tail -30 "$LOG" >&2 + exit 124 +fi + +echo "run-local: iris exited rc=$rc (log: $LOG)" +exit $rc diff --git a/bench/run/run-prom.sh b/bench/run/run-prom.sh new file mode 100755 index 0000000..53b78bd --- /dev/null +++ b/bench/run/run-prom.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# run-prom.sh — boot the benchmark the way real hardware would: through the +# PROM, off a disk image whose volume header names the binary. +# +# --load-elf is faster and is what day-to-day work uses, but only this proves +# the image is genuinely bootable — the volume-header layout, the ELF the PROM +# will accept, and the load address. It is also the path a real Indy takes, so +# an image built this way can be burned to a CD and run on the hardware the +# emulator is imitating, which is the only way to get a reference number that +# is not itself an emulator's opinion. +# +# usage: run/run-prom.sh [scsi-id] [bootfile] +set -uo pipefail + +cd "$(dirname "$0")/.." + +ID="${1:-2}" +NAME="${2:-irisbench}" +IRIS="${IRIS:-../target/release/iris}" +CI="${CI:-../target/release/iris-ci}" +SOCK="/tmp/iris-bench-prom.sock" +LOG="build/prom.log" + +[[ -x "$IRIS" ]] || { echo "run-prom: no iris at $IRIS" >&2; exit 2; } +[[ -x "$CI" ]] || { echo "run-prom: no iris-ci at $CI" >&2; exit 2; } +[[ -f build/irisbench.img ]] || { echo "run-prom: make image first" >&2; exit 2; } + +cat > build/prom.toml <<'TOML' +# The image goes on SCSI ID 2 so nothing mistakes it for a system disk and so +# `boot -f dksc(0,2,8)irisbench` reads naturally. +banks = [128, 128, 0, 0] +headless = true +no_audio = true + +[machine] +profile = "indy_ip24" + +[scsi] + +[scsi.2] +path = "build/irisbench.img" +cdrom = false +TOML + +rm -f "$SOCK" "$LOG" +"$IRIS" --config build/prom.toml --ci --ci-socket "$SOCK" \ + --test-device --headless --noaudio --serial-log "$LOG" \ + > build/prom-stdout.log 2>&1 & +IRIS_PID=$! +trap 'kill $IRIS_PID 2>/dev/null' EXIT + +for _ in $(seq 1 60); do [[ -S "$SOCK" ]] && break; sleep 1; done +[[ -S "$SOCK" ]] || { echo "run-prom: socket never appeared" >&2; exit 2; } + +ci() { "$CI" --socket "$SOCK" "$@"; } +ci start >/dev/null 2>&1 || true + +# The PROM counts down before auto-booting; ESC interrupts it and lands in the +# maintenance menu, where 5 is "Enter Command Monitor". +echo "run-prom: waiting for the PROM banner" +ci serial-wait "System Maintenance" --timeout 120 >/dev/null 2>&1 \ + || ci serial-send --no-cr $'\e' >/dev/null 2>&1 +sleep 2 +ci serial-send --no-cr $'\e' >/dev/null 2>&1 +ci serial-wait "Option" --timeout 60 >/dev/null 2>&1 || true +ci serial-send "5" >/dev/null 2>&1 +ci serial-wait ">>" --timeout 60 >/dev/null 2>&1 || true + +echo "run-prom: boot -f dksc(0,$ID,8)$NAME" +ci serial-send "boot -f dksc(0,$ID,8)$NAME" >/dev/null 2>&1 + +if ci serial-wait "IRIS-BENCH-DONE" --timeout "${TIMEOUT:-2400}" >/dev/null 2>&1; then + # serial-wait returns on the token, several characters short of the end of + # the line — at this baud rate the rc digits are still in flight. Wait for + # the whole line before deciding anything, the same race cpu-tests' + # run-prom.sh documents. + for _ in $(seq 1 50); do + grep -qE "IRIS-BENCH-DONE rc=[0-9]+" "$LOG" && break + sleep 0.2 + done + grep -E "accuracy|emulator speed|IRIS-BENCH-DONE" "$LOG" | tail -4 + grep -q "IRIS-BENCH-DONE rc=0" "$LOG" && { echo "run-prom: PASS"; exit 0; } + echo "run-prom: suite reported checksum mismatches"; exit 1 +fi + +echo "run-prom: never reached the DONE token; last serial output:" >&2 +tail -40 "$LOG" >&2 +exit 1 diff --git a/rules/testing/benchmark-suite-gotchas.md b/rules/testing/benchmark-suite-gotchas.md new file mode 100644 index 0000000..183e3c6 --- /dev/null +++ b/rules/testing/benchmark-suite-gotchas.md @@ -0,0 +1,96 @@ +# Writing benchmark kernels for `bench/` — things that bit + +The suite scores itself on accuracy: every kernel's result checksum is compared +against a golden value computed by building the same C natively. That comparison +turned out to be a much better bug detector than intended, and everything below +is something it caught. Read this before adding a kernel. + +## A checksum must depend on nothing but the arithmetic + +**Never fold a multi-byte array in as raw bytes.** `cksum_bytes(h, (const +unsigned char *)coeffs, n * sizeof(short))` compares *byte order*, not results. +The golden values come from a little-endian host; the guest is big-endian; the +mismatch reads as an emulator fault. `img/dct8x8` and `vid/motion_est` both did +this. Use `cksum_u64` / `cksum_f64`, which are defined on values. + +**Initialise everything you checksum.** `mem/copy`'s verify wrote 5 KB of an +8 KB region and checksummed all of it. The tail was whatever the allocator last +held — different on the host, different between two host builds, different +between a first and second run. + +**Do not read past the end of a buffer.** `mem/unaligned` passed the buffer size +as the readable length while starting one byte in, so the last load reached one +byte beyond. On the guest that byte was a previous kernel's leftovers; on the +host it was fresh malloc. There is no value either side could agree on. + +**Anything endian-sensitive by nature needs a host-side equivalent, not a +host-side copy.** `mem/unaligned` genuinely wants an unaligned big-endian load; +the host assembles the word from bytes under `#if BENCH_HOST` so the *value* +matches while the guest still executes the instruction being measured. + +## `work_alloc` is a bump allocator with no free + +Call it **once per `run()`, outside the iteration loop**. The runner resets the +allocator between benchmarks, so a kernel that allocates per iteration only +exhausts the 24 MB work area at whatever iteration count the autoscaler picked — +which makes a plain bug look like a data-dependent failure. `codec/rle` called +`src_build()` inside its loop and died on the sixth pass. + +The flip side is useful: because the allocator resets to the same base every +time, a kernel can cache expensive setup behind `if (built && ptr == cached)` +and it will hit every time. + +## A faulting kernel does not crash — it reports a number + +The shared exception dispatcher records the fault and steps over the +instruction, so a kernel that faults keeps running and produces a throughput +figure for taking exceptions. `mem/unaligned` scored a plausible 871 k +accesses/s while taking an address error on three loads in four. + +`*(const u32 *)(const void *)p` on an unaligned `p` is the trap: the cast +promises an alignment the pointer does not have, and GCC emits a plain `lw`. If +you want an unaligned load on MIPS, write `lwl`/`lwr` yourself. + +The harness now counts exceptions across every timed run and prints them on the +line (`exc:N`); mark a kernel that faults on purpose with `BENCH_EXC` so it is +not flagged. Add that flag rather than ignoring the count. + +## The guest must probe the test device, not trust it + +An emulator built before `TESTDEV_HOST_NS`/`ICOUNT`/`CAPS` existed decodes only +16 bytes and repeats, so `0x20` (CAPS) aliases onto `SIGNATURE` — and `'IRIS'` +has bit 0 set, so a naive `caps & CAP_TIMEBASE` says yes. Every timing then +comes back from a frozen clock, and the suite reported the assumed 100 MHz Count +rate as if it had measured it. + +`probe_timebase()` rejects a CAPS word equal to the signature *and* requires the +clock to advance before trusting it. Also: never **write** an unprobed offset in +that window — on an old device `0x1C` aliases onto `EXIT`. + +## CP0 Count is not a stopwatch + +Under IRIS, Count is virtual: materialised from a wall-clock anchor at a +`count_hz` that `infer_count_hz` learns from the guest's own Compare writes. A +bare-metal binary never writes a plausible one — `start.S` sets Compare to +`0xFFFFFFFF`, whose delta is outside the plausible-tick bounds and is ignored — +so it sits at `DEFAULT_COUNT_HZ`, 33 MHz, not the ~100 MHz a real 200 MHz R4400 +would show. Time with the test device's host clock and *measure* the Count rate; +the ratio between them is a report on the timer model, not a nuisance. + +## Assembly that shares a word with C + +`bench_pfn_delta` was `.space 8` in the assembler and `u32` in C, read back with +`lw`. On a big-endian machine `lw` from the base of a 64-bit object returns the +**high** half — zero — so the TLB refill handler built an identity map onto +unmapped physical memory and `sys/tlb_miss` measured 780 000 bus errors instead. +It is `.space 4` now. Keep the width the same on both sides, and make a kernel +that depends on a mapping *prove* the mapping before timing it. + +## The feature banner has to name the CPU + +`print_build_features()` in `src/main.rs` did not list `r5k`, `jitv2`, or +`mips4`, so an R5000 build announced itself as `build features: tlbvmap` and a +benchmark result recorded from it was indistinguishable from an R4400 one. It +lists them now. `iris-bench matrix` still cross-checks the guest's own +`#machine cpu=` line, read from PRId — same guard, and for the same reason, as +`cpu-tests/run/matrix.sh`. From 3c95fc374e774c284a9e478a71fd9742ec8ee61b Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 21 Aug 2026 10:41:37 -0400 Subject: [PATCH 04/15] iris-bench: run the suite across builds and report on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CPU model and the JIT are compile-time cargo features, so comparing them means comparing binaries. `matrix` builds a separate emulator per cell, copies each aside before the next build overwrites target/release/iris, runs the same guest binary on all of them, and cross-checks each result against the guest's own PRId-derived banner — the same guard cpu-tests/run/matrix.sh has, for the same reason. iris-bench run one cell against a given emulator iris-bench host the same kernels natively, for the ratio iris-bench matrix every CPU x engine cell, then a comparison report iris-bench report markdown / json / text iris-bench irix the guest-OS suite over iris-ci (written, not yet run) iris-bench reference a row for data/bench_reference.json The report answers the two questions a benchmark is actually asked, and keeps them as two lists: where the wall clock went, and where the emulator is least efficient per guest instruction. A kernel can dominate a run simply by being long, and a kernel can be terrible per instruction while barely registering. data/bench_reference.json is what the GUI will compare a user's result against. It ships empty and empty is a normal state — a machine with no row gets "reference statistics not gathered for this platform" rather than an invented comparison. Deliberately a static file updated by hand: no upload, no download, no user-writable override. Rows arrive as pull requests. Every result now carries a suite_id (blake3 of the guest binary). Reference figures mean something only against the exact suite that produced them: add or change a kernel and every stored number silently becomes a comparison between two different workloads. So the merge refuses on a mismatch, and a consumer can treat a mismatch exactly like an empty table. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.toml | 6 + data/bench_reference.README.md | 45 + data/bench_reference.json | 5 + src/bin/iris_bench.rs | 1401 ++++++++++++++++++++++++++++++++ 4 files changed, 1457 insertions(+) create mode 100644 data/bench_reference.README.md create mode 100644 data/bench_reference.json create mode 100644 src/bin/iris_bench.rs diff --git a/Cargo.toml b/Cargo.toml index b68b34e..435a634 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -248,6 +248,12 @@ path = "src/coffdump.rs" name = "iris-ci" path = "src/iris_ci_main.rs" +# Benchmark driver. Named with a hyphen to match `iris-ci`, so the path has to +# be spelled out — cargo's own bin autodiscovery would call it `iris_bench`. +[[bin]] +name = "iris-bench" +path = "src/bin/iris_bench.rs" + [[bin]] name = "chd_extract" path = "src/bin/chd_extract.rs" diff --git a/data/bench_reference.README.md b/data/bench_reference.README.md new file mode 100644 index 0000000..0272b89 --- /dev/null +++ b/data/bench_reference.README.md @@ -0,0 +1,45 @@ +# data/bench_reference.json + +What a benchmark result gets compared against in the GUI. **Starts empty, and +empty is a normal state** — a machine with no row here simply gets "reference +statistics not gathered for this platform" instead of a comparison. Nothing is +uploaded, nothing is downloaded, and there is no user-writable override: it is a +static file updated by hand when someone measures a machine worth recording. + +## Adding a row + +```sh +make -C bench # build the guest binary +cargo build --release --bin iris-bench +./target/release/iris-bench run --label my-machine + +./target/release/iris-bench reference \ + --id m1-max-interp \ + --label "MacBook Pro (M1 Max) — interpreter" \ + --into data/bench_reference.json +``` + +`reference` with no `--from` uses the newest result in `bench/build/results/`. +Without `--into` it prints the row to stdout for pasting. + +## `suite_id` + +The blake3 of the guest binary the numbers were measured against. Reference +figures only mean something against the exact suite that produced them: add or +change a kernel and every stored number silently becomes a comparison between +two different workloads. + +So the merge refuses when a result's suite disagrees with a populated table, and +the GUI treats a mismatch exactly like an empty table. **If the suite changes, +every row has to be re-measured** — which is the honest cost of having reference +numbers at all, and the reason the file is small on purpose. + +An empty table adopts the suite of the first row merged into it. + +## `cpu` and `engine` + +Both are recorded because the two engines differ by roughly 4x, so a row without +them cannot be compared with anything. Note the Mac App Store build forces +`IRIS_NO_JIT=1` (`iris-gui/src/main.rs`) — the sandbox only permits `MAP_JIT` +pages and Cranelift does not use them — so rows meant for comparison against a +store build must be `"engine": "interp"`. diff --git a/data/bench_reference.json b/data/bench_reference.json new file mode 100644 index 0000000..9217fb3 --- /dev/null +++ b/data/bench_reference.json @@ -0,0 +1,5 @@ +{ + "schema": 1, + "suite_id": "", + "entries": [] +} diff --git a/src/bin/iris_bench.rs b/src/bin/iris_bench.rs new file mode 100644 index 0000000..ca046af --- /dev/null +++ b/src/bin/iris_bench.rs @@ -0,0 +1,1401 @@ +//! `iris-bench` — drive the benchmark suite and turn its output into a report. +//! +//! The suite itself is `bench/`: a bare-metal MIPS binary that runs under IRIS +//! with no operating system, and the same C compiled natively for the host. +//! Both print the same machine-readable block. This program runs them, parses +//! it, and answers the three questions worth asking: +//! +//! - **how fast** is a given build of IRIS, per kernel and overall, in guest +//! instructions per host second; +//! - **how correct** is it, as the share of kernels whose result checksum +//! matched an independently computed golden value; +//! - **where does the time go**, both as share of wall clock and as +//! emulation efficiency, which are different lists. +//! +//! `matrix` builds each CPU x engine combination and runs all of them, because +//! the CPU model and the JIT are compile-time cargo features — comparing them +//! means comparing binaries, not flags. + +use std::collections::BTreeMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Instant; + +use clap::{Parser, Subcommand}; +use serde::{Deserialize, Serialize}; + +const BEGIN: &str = "IRIS-BENCH-BEGIN"; +const END: &str = "IRIS-BENCH-END"; + +/// Kernels whose whole point is to take exceptions. Everywhere else a nonzero +/// count is a defect — see BF_TAKES_EXC in bench/harness/benchlib.h. +const EXPECT_EXC: &[&str] = &["sys/exception", "sys/tlb_miss"]; + +/// Dhrystones per second per DMIPS, by the VAX 11/780 convention every +/// published Dhrystone figure since 1988 uses. +const DHRY_PER_DMIPS: f64 = 1757.0; + +// ─── data model ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Row { + pub name: String, + pub unit: String, + pub iters: u64, + pub work: u64, + pub ns: u64, + pub icount: u64, + pub count: u64, + /// Exceptions taken during the timed run. Nonzero for a kernel that is not + /// meant to take any means it measured something other than what it + /// claims — the harness flags those on the line, and the report repeats it. + pub exc: u64, + pub checksum: String, + pub golden: String, + pub status: String, +} + +impl Row { + /// Work units per second. The unit is the kernel's own, so this is only + /// comparable across cells for the same kernel — which is exactly how the + /// report uses it. + pub fn rate(&self) -> f64 { + if self.ns == 0 { 0.0 } else { self.work as f64 * 1e9 / self.ns as f64 } + } + /// Guest instructions retired per host second. Zero when there is no + /// instruction counter (host runs, or an emulator without the test + /// device's timebase registers). + pub fn mips(&self) -> f64 { + if self.ns == 0 || self.icount == 0 { 0.0 } else { self.icount as f64 * 1e3 / self.ns as f64 } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Machine { + pub cpu: String, + pub prid: String, + pub fir: String, + pub config: String, + pub l2: bool, + pub testdev: bool, + pub timebase: bool, + pub count_hz: u64, + pub count_hz_measured: bool, + pub work_bytes: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct HostInfo { + pub os: String, + pub arch: String, + pub cpu_model: String, + pub cores: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Run { + /// Cell label: "r4400-interp", "r5000-jitv2", "host", … + pub cell: String, + /// Cargo features the emulator was built with, from its own banner. + pub features: Vec, + pub machine: Machine, + pub host: HostInfo, + pub rows: Vec, + pub checked: usize, + pub matched: usize, + pub total_ns: u64, + pub total_icount: u64, + /// Wall clock for the whole process, including emulator startup — always + /// larger than total_ns, which counts only timed regions. + pub wall_s: f64, + /// Hash of the guest binary this result came from. Reference numbers only + /// mean anything against the exact suite that produced them: add or change + /// a kernel and every stored figure silently becomes a comparison between + /// two different workloads. Empty for a host run (no guest binary). + /// `default` so results recorded before this field existed still load. + #[serde(default)] + pub suite_id: String, +} + +impl Run { + pub fn accuracy(&self) -> f64 { + if self.checked == 0 { 0.0 } else { self.matched as f64 * 100.0 / self.checked as f64 } + } + pub fn mips(&self) -> f64 { + if self.total_ns == 0 || self.total_icount == 0 { 0.0 } + else { self.total_icount as f64 * 1e3 / self.total_ns as f64 } + } + pub fn row(&self, name: &str) -> Option<&Row> { + self.rows.iter().find(|r| r.name == name) + } + /// Dhrystone 2.1 DMIPS. + pub fn dmips(&self) -> Option { + self.row("int/dhrystone").map(|r| r.rate() / DHRY_PER_DMIPS) + } + /// Whetstone passes per second. Deliberately not converted to MWIPS: that + /// needs a "Whetstone instructions per loop" constant taken from a + /// reference implementation, and a figure resting on an unverified factor + /// of a thousand would look authoritative without being so. Passes per + /// second is exact and is what cell-to-cell comparison uses. + pub fn whet_loops(&self) -> Option { + self.row("fpu/whetstone").map(|r| r.rate()) + } + /// LINPACK 100x100 MFLOPS. + pub fn linpack_mflops(&self) -> Option { + self.row("fpu/linpack").map(|r| r.rate() / 1e6) + } +} + +// ─── parsing the suite's machine block ─────────────────────────────────────── + +fn parse_block(text: &str) -> Result<(Machine, Vec, usize, usize, u64, u64), String> { + let begin = text.find(BEGIN).ok_or_else(|| { + "no IRIS-BENCH-BEGIN in the output — the suite did not reach its report".to_string() + })?; + let end = text[begin..].find(END).ok_or_else(|| { + "output ends before IRIS-BENCH-END — the suite died partway through".to_string() + })? + begin; + + let mut machine = Machine::default(); + let mut rows = Vec::new(); + let (mut checked, mut matched, mut total_ns, mut total_ic) = (0usize, 0usize, 0u64, 0u64); + + for line in text[begin..end].lines().skip(1) { + let line = line.trim(); + if line.is_empty() { continue; } + if let Some(rest) = line.strip_prefix('#') { + let mut it = rest.split_whitespace(); + let kind = it.next().unwrap_or(""); + let kv: BTreeMap<&str, &str> = it + .filter_map(|tok| tok.split_once('=')) + .collect(); + let num = |k: &str| -> u64 { + kv.get(k).and_then(|v| parse_u64(v)).unwrap_or(0) + }; + match kind { + "machine" => { + machine.cpu = kv.get("cpu").unwrap_or(&"unknown").to_string(); + machine.prid = kv.get("prid").unwrap_or(&"").to_string(); + machine.fir = kv.get("fir").unwrap_or(&"").to_string(); + machine.config = kv.get("config").unwrap_or(&"").to_string(); + machine.l2 = num("l2") != 0; + machine.testdev = num("testdev") != 0; + machine.timebase = num("timebase") != 0; + } + "timebase" => { + machine.count_hz = num("count_hz"); + machine.count_hz_measured = num("measured") != 0; + } + "work" => machine.work_bytes = num("bytes"), + "totals" => { + checked = num("checked") as usize; + matched = num("matched") as usize; + total_ns = num("ns"); + total_ic = num("icount"); + } + _ => {} + } + continue; + } + + let f: Vec<&str> = line.split_whitespace().collect(); + if f.len() != 11 { continue; } + rows.push(Row { + name: f[0].to_string(), + unit: f[1].to_string(), + iters: parse_u64(f[2]).unwrap_or(0), + work: parse_u64(f[3]).unwrap_or(0), + ns: parse_u64(f[4]).unwrap_or(0), + icount: parse_u64(f[5]).unwrap_or(0), + count: parse_u64(f[6]).unwrap_or(0), + exc: parse_u64(f[7]).unwrap_or(0), + checksum: f[8].to_string(), + golden: f[9].to_string(), + status: f[10].to_string(), + }); + } + + if rows.is_empty() { + return Err("the report block held no benchmark rows".to_string()); + } + Ok((machine, rows, checked, matched, total_ns, total_ic)) +} + +fn parse_u64(s: &str) -> Option { + let s = s.trim(); + if let Some(hex) = s.strip_prefix("0x") { + u64::from_str_radix(hex, 16).ok() + } else { + s.parse().ok() + } +} + +/// Pull the feature list out of the emulator's own startup banner, so a saved +/// result records what produced it rather than what the caller believed. +fn parse_features(stderr: &str) -> Vec { + for line in stderr.lines() { + if let Some(rest) = line.strip_prefix("iris: build features: ") { + let rest = rest.trim(); + if rest == "(none)" { return Vec::new(); } + return rest.split_whitespace().map(str::to_string).collect(); + } + } + Vec::new() +} + +// ─── host identification ───────────────────────────────────────────────────── + +fn host_info() -> HostInfo { + let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0); + HostInfo { + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + cpu_model: cpu_model(), + cores, + } +} + +fn cpu_model() -> String { + #[cfg(target_os = "linux")] + { + if let Ok(s) = std::fs::read_to_string("/proc/cpuinfo") { + for line in s.lines() { + if let Some((k, v)) = line.split_once(':') { + if k.trim() == "model name" || k.trim() == "Model" { + return v.trim().to_string(); + } + } + } + } + } + #[cfg(target_os = "macos")] + { + if let Ok(out) = Command::new("sysctl").args(["-n", "machdep.cpu.brand_string"]).output() { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !s.is_empty() { return s; } + } + } + #[cfg(target_os = "windows")] + { + if let Ok(s) = std::env::var("PROCESSOR_IDENTIFIER") { return s; } + } + "unknown".to_string() +} + +// ─── running ───────────────────────────────────────────────────────────────── + +/// Everything `bench/` produces lives under one directory so a report can be +/// assembled from whatever happens to be there. +fn default_out() -> PathBuf { PathBuf::from("bench/build/results") } + +fn repo_relative(p: &str) -> PathBuf { + // Run from the repo root by preference, but tolerate being run from + // bench/ — every path in this program is repo-relative. + let direct = PathBuf::from(p); + if direct.exists() { return direct; } + let up = PathBuf::from("..").join(p); + if up.exists() { return up; } + direct +} + +fn run_guest( + iris: &Path, + elf: &Path, + config: &Path, + label: &str, + timeout_s: u64, + extra: &[String], +) -> Result { + if !iris.exists() { return Err(format!("no emulator at {}", iris.display())); } + if !elf.exists() { + return Err(format!("no suite binary at {} — run `make -C bench`", elf.display())); + } + + // Run from bench/, where the suite's own relative paths resolve and where + // the emulator's stray output files belong. Everything handed to the + // emulator is absolute, so it does not matter what those paths looked like + // on the way in — a --elf pointing somewhere else entirely still works. + let abs = |p: &Path| -> Result { + std::fs::canonicalize(p).map_err(|e| format!("{}: {}", p.display(), e)) + }; + let (iris, elf, config) = (abs(iris)?, abs(elf)?, abs(config)?); + let suite_id = suite_id_of(&elf)?; + let cwd = config.parent().and_then(|p| p.parent()).map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + + let mut cmd = Command::new(&iris); + cmd.current_dir(&cwd) + .arg("--config").arg(&config) + .arg("--load-elf").arg(&elf) + .arg("--test-device") + .arg("--headless") + .arg("--noaudio"); + for e in extra { cmd.arg(e); } + + let started = Instant::now(); + let out = run_with_timeout(cmd, timeout_s)?; + let wall_s = started.elapsed().as_secs_f64(); + + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let (machine, rows, checked, matched, total_ns, total_icount) = + parse_block(&stdout).map_err(|e| { + format!("{}\n--- last 20 lines of emulator output ---\n{}", e, tail(&stdout, 20)) + })?; + + Ok(Run { + cell: label.to_string(), + features: parse_features(&stderr), + machine, + host: host_info(), + rows, + checked, + matched, + total_ns, + total_icount, + wall_s, + suite_id, + }) +} + +/// Short blake3 of the guest binary. Short because it is an identity tag a +/// human pastes into a JSON file, not a security digest. +fn suite_id_of(elf: &Path) -> Result { + let bytes = std::fs::read(elf).map_err(|e| format!("{}: {}", elf.display(), e))?; + Ok(format!("blake3:{}", &blake3::hash(&bytes).to_hex()[..16])) +} + +fn run_host(exe: &Path, timeout_s: u64) -> Result { + if !exe.exists() { + return Err(format!("no host build at {} — run `make -C bench hostbench`", exe.display())); + } + let started = Instant::now(); + let out = run_with_timeout(Command::new(exe), timeout_s)?; + let wall_s = started.elapsed().as_secs_f64(); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let (mut machine, rows, checked, matched, total_ns, total_icount) = parse_block(&stdout)?; + machine.cpu = "host".to_string(); + Ok(Run { + cell: "host".to_string(), + features: Vec::new(), + machine, + host: host_info(), + rows, + checked, + matched, + total_ns, + total_icount, + wall_s, + suite_id: String::new(), + }) +} + +/// Wait for a child, killing it after `timeout_s`. A benchmark that hangs is a +/// finding, not a reason to block a matrix run forever. +fn run_with_timeout(mut cmd: Command, timeout_s: u64) -> Result { + use std::process::Stdio; + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = cmd.spawn().map_err(|e| format!("spawn: {}", e))?; + + // Drain both pipes on their own threads: a child that fills a pipe buffer + // while we sleep would deadlock instead of timing out. + let mut so = child.stdout.take().unwrap(); + let mut se = child.stderr.take().unwrap(); + let t_out = std::thread::spawn(move || { let mut v = Vec::new(); let _ = std::io::copy(&mut so, &mut v); v }); + let t_err = std::thread::spawn(move || { let mut v = Vec::new(); let _ = std::io::copy(&mut se, &mut v); v }); + + let deadline = Instant::now() + std::time::Duration::from_secs(timeout_s); + let status = loop { + match child.try_wait().map_err(|e| e.to_string())? { + Some(s) => break s, + None => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("timed out after {}s", timeout_s)); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + } + }; + let stdout = t_out.join().unwrap_or_default(); + let stderr = t_err.join().unwrap_or_default(); + Ok(std::process::Output { status, stdout, stderr }) +} + +fn tail(s: &str, n: usize) -> String { + let lines: Vec<&str> = s.lines().collect(); + lines[lines.len().saturating_sub(n)..].join("\n") +} + +fn save(run: &Run, dir: &Path) -> Result { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {}", dir.display(), e))?; + let path = dir.join(format!("{}.json", run.cell)); + let json = serde_json::to_string_pretty(run).map_err(|e| e.to_string())?; + std::fs::write(&path, json).map_err(|e| format!("{}: {}", path.display(), e))?; + Ok(path) +} + +/// Most recently modified `*.json` in `dir`, skipping the host run (it has no +/// suite_id and is not a machine anyone compares against). +fn newest_result(dir: &Path) -> Result { + let rd = std::fs::read_dir(dir).map_err(|e| format!("{}: {}", dir.display(), e))?; + let mut best: Option<(std::time::SystemTime, PathBuf)> = None; + for e in rd.flatten() { + let p = e.path(); + if p.extension().and_then(|s| s.to_str()) != Some("json") { continue; } + if p.file_stem().and_then(|s| s.to_str()) == Some("host") { continue; } + let Ok(m) = e.metadata().and_then(|m| m.modified()) else { continue }; + if best.as_ref().map_or(true, |(t, _)| m > *t) { best = Some((m, p)); } + } + best.map(|(_, p)| p) + .ok_or_else(|| format!("no result files in {} — run `iris-bench run` first", dir.display())) +} + +fn load_all(dir: &Path) -> Result, String> { + let rd = std::fs::read_dir(dir).map_err(|e| format!("{}: {}", dir.display(), e))?; + let mut runs = Vec::new(); + for e in rd.flatten() { + let p = e.path(); + if p.extension().and_then(|s| s.to_str()) != Some("json") { continue; } + let text = std::fs::read_to_string(&p).map_err(|e| format!("{}: {}", p.display(), e))?; + match serde_json::from_str::(&text) { + Ok(r) => runs.push(r), + Err(e) => eprintln!("iris-bench: skipping {}: {}", p.display(), e), + } + } + if runs.is_empty() { return Err(format!("no result files in {}", dir.display())); } + // Emulated cells first, host last: the host is the reference, not a peer. + runs.sort_by(|a, b| (a.cell == "host", &a.cell).cmp(&(b.cell == "host", &b.cell))); + Ok(runs) +} + +// ─── the matrix ────────────────────────────────────────────────────────────── + +/// A cell is a cargo feature set plus the CPU the guest must report. The CPU +/// model and the JIT are compile-time features, so each cell is a separate +/// build of the emulator — there is no runtime switch to flip. +struct Cell { + name: &'static str, + features: &'static str, + /// What the guest must print in `#machine cpu=`. Checked, because an + /// overwritten target/release/iris silently turning an "R4400" cell into + /// an R5000 run is a mistake this repo has made before — see + /// cpu-tests/run/matrix.sh. + expect_cpu: &'static str, +} + +const CELLS: &[Cell] = &[ + Cell { name: "r4400-interp", features: "", expect_cpu: "R4400" }, + Cell { name: "r5000-interp", features: "r5k", expect_cpu: "R5000" }, + Cell { name: "r4400-jitv2", features: "jitv2", expect_cpu: "R4400" }, + Cell { name: "r5000-jitv2", features: "r5k,jitv2", expect_cpu: "R5000" }, + Cell { name: "r4400-lightning", features: "lightning", expect_cpu: "R4400" }, + Cell { name: "r4400-jitv2-lightning", features: "jitv2,lightning", expect_cpu: "R4400" }, +]; + +fn build_cell(cell: &Cell, root: &Path, force: bool) -> Result { + let dest = root.join("bench/build").join(format!("iris-{}", cell.name)); + if dest.exists() && !force { + println!(" reusing {}", dest.display()); + return Ok(dest); + } + println!(" building {} {}", cell.name, + if cell.features.is_empty() { "(default features)".to_string() } + else { format!("--features {}", cell.features) }); + + let mut cmd = Command::new("cargo"); + cmd.current_dir(root).args(["build", "--release", "--bin", "iris"]); + if !cell.features.is_empty() { cmd.args(["--features", cell.features]); } + let st = cmd.status().map_err(|e| format!("cargo: {}", e))?; + if !st.success() { return Err(format!("cargo build failed for {}", cell.name)); } + + let src = root.join("target/release/iris"); + std::fs::create_dir_all(dest.parent().unwrap()).map_err(|e| e.to_string())?; + // Copy rather than run in place: the next cell's build overwrites + // target/release/iris, and a matrix that races its own artefacts produces + // results labelled with the wrong build. + std::fs::copy(&src, &dest).map_err(|e| format!("copy {}: {}", src.display(), e))?; + Ok(dest) +} + + +// ─── the guest-OS level suite ──────────────────────────────────────────────── +// +// bench/ measures the emulated CPU with no operating system in the way. This +// measures the machine as a user meets it: a filesystem on an emulated SCSI +// disk, IRIX's buffer cache and syscall path, the tools that shipped in the +// box, and the X server driving REX3. None of that is visible to a bare-metal +// kernel, and all of it is what "is the emulator fast enough to use" means. +// +// Every step is timed on the host around one `iris-ci run`, with the measured +// no-op round trip subtracted, so nothing depends on the guest having a usable +// clock or a working `time`. + +/// Directories an IRIX 6.5 install actually puts programs in. `which` is a csh +/// script there and `command -v` is not in its Bourne shell, so a program is +/// probed by testing for it directly. +const IRIX_PATH: &str = + "/bin:/usr/bin:/usr/sbin:/usr/bsd:/usr/etc:/usr/bin/X11:/usr/local/bin:/sbin:/usr/gfx"; + +#[derive(Debug, Deserialize)] +struct StepFile { + step: Vec, +} + +#[derive(Debug, Deserialize)] +struct Step { + name: String, + unit: String, + /// Work units the command performs. 0 means "not a fixed quantity" (a + /// `find` over /usr, an x11perf run) — the duration is still comparable + /// between runs on the same disk image, the rate is not. + #[serde(default)] + work: u64, + /// Untimed preparation. + #[serde(default)] + setup: Option, + cmd: String, + /// Program that must exist, or the step is skipped rather than failed. + #[serde(default)] + requires: Option, + /// Run it, but do not record a row (cleanup). + #[serde(default)] + skip_timing: bool, +} + +/// Wrap a guest command so it runs under a Bourne shell with a sane PATH, +/// whatever the login shell is. Consequence, documented in steps.toml: a step +/// command may not contain a single quote. +fn wrap(cmd: &str) -> String { + format!("sh -c 'PATH={}; export PATH; {}'", IRIX_PATH, cmd) +} + +struct Ci { + bin: PathBuf, + socket: Option, + shell: String, + timeout: u64, +} + +impl Ci { + /// Returns the guest's stdout. An iris-ci failure is an error; a nonzero + /// exit status inside the guest is not — several steps end on a command + /// that legitimately returns nonzero. + fn run(&self, guest_cmd: &str) -> Result<(String, f64), String> { + let mut cmd = Command::new(&self.bin); + if let Some(s) = &self.socket { cmd.arg("--socket").arg(s); } + cmd.arg("run") + .arg(guest_cmd) + .arg("--shell").arg(&self.shell) + .arg("--timeout").arg(self.timeout.to_string()); + let started = Instant::now(); + let out = run_with_timeout(cmd, self.timeout + 30)?; + let secs = started.elapsed().as_secs_f64(); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + if !out.status.success() && stdout.trim().is_empty() { + return Err(format!( + "iris-ci run failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok((stdout, secs)) + } + + fn has(&self, prog: &str) -> Result { + let probe = format!( + "for d in `echo {} | tr : \\\" \\\"`; do test -x $d/{} && echo IRISBENCH-HAVE; done", + IRIX_PATH, prog + ); + let (out, _) = self.run(&wrap(&probe))?; + Ok(out.contains("IRISBENCH-HAVE")) + } + + /// Smallest round trip we can measure: everything a step's timing has in + /// common with doing nothing at all — serial latency, prompt matching, + /// process spawn. Subtracted from every step, so a one-second step is a + /// second of work rather than a second of work plus the harness. + fn baseline(&self) -> Result { + let mut best = f64::MAX; + for _ in 0..3 { + let (_, s) = self.run(&wrap(":"))?; + if s < best { best = s; } + } + Ok(best) + } +} + +fn run_irix(ci: &Ci, steps_path: &Path, label: &str) -> Result { + let text = std::fs::read_to_string(steps_path) + .map_err(|e| format!("{}: {}", steps_path.display(), e))?; + let file: StepFile = toml::from_str(&text) + .map_err(|e| format!("{}: {}", steps_path.display(), e))?; + + // Identify the guest before anything else: a result recorded against the + // wrong disk image is worse than no result. + let (uname, _) = ci.run(&wrap("uname -a"))?; + let cpu = uname.lines().find(|l| l.contains("IRIX")).unwrap_or("IRIX").trim().to_string(); + println!(" guest: {}", cpu); + + let baseline = ci.baseline()?; + println!(" round-trip floor: {:.0} ms", baseline * 1e3); + + let mut rows = Vec::new(); + let mut total_ns = 0u64; + + for step in &file.step { + if let Some(prog) = &step.requires { + if !ci.has(prog)? { + println!(" {:<22} skipped ({} not installed)", step.name, prog); + continue; + } + } + if let Some(setup) = &step.setup { ci.run(&wrap(setup))?; } + + let (_, secs) = ci.run(&wrap(&step.cmd))?; + if step.skip_timing { continue; } + + let ns = ((secs - baseline).max(0.0) * 1e9) as u64; + total_ns += ns; + println!( + " {:<22} {:>8.2} s{}", + step.name, + ns as f64 / 1e9, + if step.work > 0 { + format!(" {}/s", fmt_rate(step.work as f64 * 1e9 / ns.max(1) as f64)) + } else { + String::new() + } + ); + rows.push(Row { + name: step.name.clone(), + unit: step.unit.clone(), + iters: 1, + work: step.work, + ns, + icount: 0, + count: 0, + exc: 0, + checksum: "0x0000000000000000".into(), + golden: "0x0000000000000000".into(), + // Nothing here is checksummed: these are IRIX's own tools against + // IRIX's own filesystem, and their output is not ours to predict. + status: "UNCHECKED".into(), + }); + } + + Ok(Run { + cell: label.to_string(), + features: Vec::new(), + machine: Machine { cpu, ..Default::default() }, + host: host_info(), + rows, + checked: 0, + matched: 0, + total_ns, + total_icount: 0, + wall_s: total_ns as f64 / 1e9, + // The guest-OS suite runs IRIX's own tools, not the bare-metal binary, + // so there is no suite hash and its numbers never join the reference + // table — they are only comparable against runs on the same disk image. + suite_id: String::new(), + }) +} + +// ─── reports ───────────────────────────────────────────────────────────────── + +fn fmt_rate(v: f64) -> String { + if v <= 0.0 { return "-".into(); } + if v >= 1e9 { format!("{:.2} G", v / 1e9) } + else if v >= 1e6 { format!("{:.2} M", v / 1e6) } + else if v >= 1e3 { format!("{:.2} k", v / 1e3) } + else { format!("{:.1}", v) } +} + +fn fmt_ratio(a: f64, b: f64) -> String { + if b <= 0.0 || a <= 0.0 { return "-".into(); } + let r = a / b; + if r >= 100.0 { format!("{:.0}x", r) } else { format!("{:.2}x", r) } +} + +fn markdown(runs: &[Run], baseline: Option<&str>) -> String { + let mut o = String::new(); + let emulated: Vec<&Run> = runs.iter().filter(|r| r.cell != "host").collect(); + let host = runs.iter().find(|r| r.cell == "host"); + let base = baseline + .and_then(|b| runs.iter().find(|r| r.cell == b)) + .or_else(|| emulated.first().copied()); + + o.push_str("# IRIS benchmark report\n\n"); + if let Some(h) = runs.first() { + o.push_str(&format!( + "Host: {} {} / {} / {} cores\n\n", + h.host.os, h.host.arch, h.host.cpu_model, h.host.cores + )); + } + + // ── per-cell summary ──────────────────────────────────────────────────── + o.push_str("## Cells\n\n"); + o.push_str("| cell | features | CPU | accuracy | guest MIPS | DMIPS | whet/s | LINPACK MFLOPS | timed | wall |\n"); + o.push_str("|---|---|---|---:|---:|---:|---:|---:|---:|---:|\n"); + for r in runs { + let feats = if r.features.is_empty() { "-".to_string() } else { r.features.join(" ") }; + o.push_str(&format!( + "| {} | {} | {} | {:.1}% ({}/{}) | {} | {} | {} | {} | {:.1} s | {:.1} s |\n", + r.cell, feats, r.machine.cpu, + r.accuracy(), r.matched, r.checked, + if r.mips() > 0.0 { format!("{:.1}", r.mips()) } else { "n/a".into() }, + r.dmips().map(|v| format!("{:.1}", v)).unwrap_or_else(|| "-".into()), + r.whet_loops().map(|v| format!("{:.0}", v)).unwrap_or_else(|| "-".into()), + r.linpack_mflops().map(|v| format!("{:.2}", v)).unwrap_or_else(|| "-".into()), + r.total_ns as f64 / 1e9, r.wall_s, + )); + } + o.push('\n'); + + // ── accuracy detail ───────────────────────────────────────────────────── + let mut any_bad = false; + for r in runs { + for row in &r.rows { + if row.status == "MISMATCH" { + if !any_bad { + o.push_str("## Checksum mismatches\n\n"); + o.push_str("A kernel whose result differs from the independently computed \ + golden value. Either the emulator computed something wrong, or \ + the kernel is not as deterministic as it claims — both are worth \ + chasing.\n\n"); + o.push_str("| cell | benchmark | got | want |\n|---|---|---|---|\n"); + any_bad = true; + } + o.push_str(&format!("| {} | {} | `{}` | `{}` |\n", + r.cell, row.name, row.checksum, row.golden)); + } + } + } + if any_bad { o.push('\n'); } + + let mut any_exc = false; + for r in runs { + for row in &r.rows { + if row.exc > 0 && !EXPECT_EXC.contains(&row.name.as_str()) { + if !any_exc { + o.push_str("## Unexpected exceptions\n\n"); + o.push_str("The harness steps over a faulting instruction and carries on, so a \ + kernel that faults still reports a throughput — for doing something \ + other than what it claims. Anything listed here is measuring the \ + exception path.\n\n"); + o.push_str("| cell | benchmark | exceptions |\n|---|---|---:|\n"); + any_exc = true; + } + o.push_str(&format!("| {} | {} | {} |\n", r.cell, row.name, row.exc)); + } + } + } + if any_exc { o.push('\n'); } + + // ── per-kernel rates ──────────────────────────────────────────────────── + o.push_str("## Per-kernel throughput\n\n"); + o.push_str("Work units per second, in each kernel's own unit. `vs base` is \ + relative to **"); + o.push_str(base.map(|b| b.cell.as_str()).unwrap_or("-")); + o.push_str("**"); + if host.is_some() { + o.push_str("; `native` is the fraction of the host's own rate on the identical kernel"); + } + o.push_str(".\n\n"); + + o.push_str("| benchmark | unit |"); + for r in &emulated { o.push_str(&format!(" {} |", r.cell)); } + if base.is_some() && emulated.len() > 1 { o.push_str(" vs base |"); } + if host.is_some() { o.push_str(" host | native |"); } + o.push('\n'); + o.push_str("|---|---|"); + for _ in &emulated { o.push_str("---:|"); } + if base.is_some() && emulated.len() > 1 { o.push_str("---:|"); } + if host.is_some() { o.push_str("---:|---:|"); } + o.push('\n'); + + let names = ordered_names(runs); + for name in &names { + let unit = runs.iter().find_map(|r| r.row(name).map(|x| x.unit.clone())) + .unwrap_or_default(); + o.push_str(&format!("| {} | {} |", name, unit)); + for r in &emulated { + o.push_str(&format!(" {} |", r.row(name).map(|x| fmt_rate(x.rate())) + .unwrap_or_else(|| "-".into()))); + } + if base.is_some() && emulated.len() > 1 { + let b = base.unwrap().row(name).map(|x| x.rate()).unwrap_or(0.0); + let best = emulated.iter().filter_map(|r| r.row(name)).map(|x| x.rate()) + .fold(0.0f64, f64::max); + o.push_str(&format!(" {} |", fmt_ratio(best, b))); + } + if let Some(h) = host { + let hv = h.row(name).map(|x| x.rate()).unwrap_or(0.0); + let best = emulated.iter().filter_map(|r| r.row(name)).map(|x| x.rate()) + .fold(0.0f64, f64::max); + o.push_str(&format!(" {} |", fmt_rate(hv))); + o.push_str(&format!(" {} |", if hv > 0.0 && best > 0.0 { + format!("1/{:.0}", hv / best) + } else { "-".into() })); + } + o.push('\n'); + } + o.push('\n'); + + // ── efficiency ────────────────────────────────────────────────────────── + for r in &emulated { + if r.mips() <= 0.0 { continue; } + o.push_str(&format!("## Where {} spends its time\n\n", r.cell)); + + let mut by_time: Vec<&Row> = r.rows.iter().filter(|x| x.ns > 0).collect(); + by_time.sort_by(|a, b| b.ns.cmp(&a.ns)); + o.push_str("| benchmark | share of wall clock | guest MIPS |\n|---|---:|---:|\n"); + for row in by_time.iter().take(8) { + o.push_str(&format!("| {} | {:.1}% | {:.1} |\n", + row.name, row.ns as f64 * 100.0 / r.total_ns.max(1) as f64, row.mips())); + } + o.push('\n'); + + let mut by_mips: Vec<&Row> = r.rows.iter().filter(|x| x.icount > 0).collect(); + by_mips.sort_by(|a, b| a.mips().partial_cmp(&b.mips()).unwrap()); + o.push_str("Least efficient — the kernels where the emulator does the most host work \ + per guest instruction:\n\n"); + o.push_str("| benchmark | guest MIPS | vs this cell's average |\n|---|---:|---:|\n"); + let avg = r.mips(); + for row in by_mips.iter().take(8) { + o.push_str(&format!("| {} | {:.1} | {:.2}x |\n", + row.name, row.mips(), if avg > 0.0 { row.mips() / avg } else { 0.0 })); + } + o.push('\n'); + } + + o +} + +/// Every kernel name that appears anywhere, in the order the first run lists +/// them — the suite's own order, which groups related kernels together. +fn ordered_names(runs: &[Run]) -> Vec { + let mut names: Vec = Vec::new(); + for r in runs { + for row in &r.rows { + if !names.iter().any(|n| n == &row.name) { names.push(row.name.clone()); } + } + } + names +} + +fn text_summary(runs: &[Run]) -> String { + let mut o = String::new(); + for r in runs { + o.push_str(&format!( + "{:<24} {:>6.1}% accuracy {:>8} {:>7} DMIPS {:>7.1} s timed\n", + r.cell, r.accuracy(), + if r.mips() > 0.0 { format!("{:.1} MIPS", r.mips()) } else { "n/a".into() }, + r.dmips().map(|v| format!("{:.1}", v)).unwrap_or_else(|| "-".into()), + r.total_ns as f64 / 1e9, + )); + } + o +} + + +// ─── reference rows ────────────────────────────────────────────────────────── +// +// `data/bench_reference.json` is the table the GUI compares a user's result +// against. It ships checked in and **starts empty** — a machine with no row is +// the normal case, not an error, and the GUI says "reference statistics not +// gathered for this platform" rather than inventing one. Rows are added by +// running the suite on a machine and pasting what this subcommand prints. +// +// Deliberately a static file updated by hand: the alternative (a user-writable +// override, an import/export pair, a fetch) is a lot of machinery for a table +// that changes when someone gets a new Mac. + +/// One machine's numbers, as they appear in `data/bench_reference.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReferenceEntry { + pub id: String, + pub label: String, + /// Emulated CPU (`R4400`/`R5000`) and execution engine (`interp`/`jitv2`). + /// Both matter: the two engines differ by about 4x, so a row without them + /// cannot be compared with anything. + pub cpu: String, + pub engine: String, + pub host: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub measured: Option, + pub guest_mips: f64, + pub dmips: f64, + pub accuracy: f64, + /// Work units per second, per kernel. + pub kernels: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReferenceTable { + pub schema: u32, + /// The guest binary these numbers were measured against. A result whose own + /// `suite_id` differs is not comparable — treat the table as empty rather + /// than comparing across two different workloads. + pub suite_id: String, + pub entries: Vec, +} + +/// `interp` unless the emulator's own feature banner says otherwise. Read from +/// the banner rather than inferred, so a mislabelled row is impossible. +fn engine_of(run: &Run) -> &'static str { + if run.features.iter().any(|f| f == "jitv2") { "jitv2" } else { "interp" } +} + +/// Today as `YYYY-MM-DD`, from the system clock. Hinnant's civil-from-days. +fn today() -> String { + let days = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + Ok(d) => (d.as_secs() / 86_400) as i64, + Err(_) => return "unknown".into(), + }; + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + format!("{:04}-{:02}-{:02}", if m <= 2 { y + 1 } else { y }, m, d) +} + +fn reference_entry(run: &Run, id: &str, label: Option<&str>, measured: Option<&str>) -> ReferenceEntry { + ReferenceEntry { + id: id.to_string(), + label: label.map(str::to_string).unwrap_or_else(|| { + format!("{} — {}", run.host.cpu_model, engine_of(run)) + }), + cpu: run.machine.cpu.clone(), + engine: engine_of(run).to_string(), + host: run.host.cpu_model.clone(), + measured: Some(measured.map(str::to_string).unwrap_or_else(today)), + guest_mips: (run.mips() * 10.0).round() / 10.0, + dmips: run.dmips().map(|v| (v * 10.0).round() / 10.0).unwrap_or(0.0), + accuracy: (run.accuracy() * 10.0).round() / 10.0, + kernels: run.rows.iter() + .filter(|r| r.status != "SKIP" && r.work > 0) + .map(|r| (r.name.clone(), (r.rate() * 100.0).round() / 100.0)) + .collect(), + } +} + +// ─── CLI ───────────────────────────────────────────────────────────────────── + +#[derive(Parser, Debug)] +#[command( + name = "iris-bench", + about = "Run the IRIS benchmark suite and report on it.", + version +)] +struct Cli { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + /// Run the suite once under one emulator binary. + Run { + /// Emulator to run. Defaults to target/release/iris. + #[arg(long)] + iris: Option, + /// Suite binary. Defaults to bench/build/irisbench.elf. + #[arg(long)] + elf: Option, + /// Machine config. Defaults to bench/run/bare.toml. + #[arg(long)] + config: Option, + /// Name this result. Defaults to "local". + #[arg(long, default_value = "local")] + label: String, + #[arg(long)] + out: Option, + #[arg(long, default_value_t = 1800)] + timeout: u64, + /// Extra arguments passed through to the emulator. + #[arg(last = true)] + extra: Vec, + }, + + /// Measure the machine the emulator runs on, with the same kernels. + Host { + /// Host build of the suite. Defaults to bench/build/irisbench-host. + #[arg(long)] + exe: Option, + #[arg(long)] + out: Option, + #[arg(long, default_value_t = 600)] + timeout: u64, + }, + + /// Build every CPU x engine cell and run all of them. + Matrix { + /// Comma-separated cell names. Default: the four core cells. + #[arg(long)] + cells: Option, + #[arg(long)] + out: Option, + /// Rebuild even when a cached emulator binary exists. + #[arg(long)] + force_build: bool, + /// Skip the host baseline run. + #[arg(long)] + no_host: bool, + #[arg(long, default_value_t = 1800)] + timeout: u64, + }, + + /// Turn saved results into a report. + Report { + #[arg(long)] + dir: Option, + /// Cell to express speedups against. Defaults to the first emulated cell. + #[arg(long)] + baseline: Option, + #[arg(long, value_parser = ["md", "json", "text"], default_value = "md")] + format: String, + /// Write here instead of stdout. + #[arg(long)] + out: Option, + }, + + /// Run the guest-OS level suite against a booted, logged-in IRIX. + /// + /// Needs an emulator started with `--ci`, a guest sitting at a shell + /// prompt, and `iris-ci` on hand. Unlike every other subcommand this one + /// measures the machine as a user meets it — filesystem, buffer cache, + /// syscalls, IRIX's own tools, and X on REX3. + Irix { + /// CI socket. Defaults to iris-ci's own default. + #[arg(long)] + socket: Option, + /// Step definitions. Defaults to bench/irix/steps.toml. + #[arg(long)] + steps: Option, + /// iris-ci binary. Defaults to target/release/iris-ci. + #[arg(long)] + ci: Option, + /// Guest login shell, passed through to `iris-ci run`. + #[arg(long, default_value = "csh")] + shell: String, + #[arg(long, default_value = "irix")] + label: String, + #[arg(long)] + out: Option, + /// Per-command timeout inside the guest. + #[arg(long, default_value_t = 300)] + timeout: u64, + }, + + /// Print a `data/bench_reference.json` row for a saved result. + /// + /// The reference table is a static file updated by hand — run the suite on + /// a machine, run this, paste the row in, commit. There is no upload and no + /// user-writable override; a machine with no row simply has no comparison. + Reference { + /// Result JSON to convert. Defaults to the newest in the results dir. + #[arg(long)] + from: Option, + /// Short stable identifier, e.g. `m1-max-interp`. + #[arg(long)] + id: String, + /// Human label. Defaults to "". + #[arg(long)] + label: Option, + /// Measurement date, `YYYY-MM-DD`. Defaults to today. + #[arg(long)] + measured: Option, + /// Merge into this table in place instead of printing the row. + #[arg(long)] + into: Option, + }, + + /// List the cells `matrix` knows about. + Cells, +} + +fn main() { + let cli = Cli::parse(); + if let Err(e) = dispatch(cli.cmd) { + eprintln!("iris-bench: {}", e); + std::process::exit(1); + } +} + +fn dispatch(cmd: Cmd) -> Result<(), String> { + match cmd { + Cmd::Cells => { + println!("{:<24} {}", "cell", "cargo features"); + for c in CELLS { + println!("{:<24} {}", c.name, + if c.features.is_empty() { "(default)" } else { c.features }); + } + Ok(()) + } + + Cmd::Run { iris, elf, config, label, out, timeout, extra } => { + let iris = iris.unwrap_or_else(|| repo_relative("target/release/iris")); + let elf = elf.unwrap_or_else(|| repo_relative("bench/build/irisbench.elf")); + let config = config.unwrap_or_else(|| repo_relative("bench/run/bare.toml")); + let out = out.unwrap_or_else(default_out); + let run = run_guest(&iris, &elf, &config, &label, timeout, &extra)?; + let path = save(&run, &out)?; + print!("{}", text_summary(std::slice::from_ref(&run))); + println!("wrote {}", path.display()); + Ok(()) + } + + Cmd::Host { exe, out, timeout } => { + let exe = exe.unwrap_or_else(|| repo_relative("bench/build/irisbench-host")); + let out = out.unwrap_or_else(default_out); + let run = run_host(&exe, timeout)?; + let path = save(&run, &out)?; + print!("{}", text_summary(std::slice::from_ref(&run))); + println!("wrote {}", path.display()); + Ok(()) + } + + Cmd::Matrix { cells, out, force_build, no_host, timeout } => { + let root = if PathBuf::from("Cargo.toml").exists() { PathBuf::from(".") } + else { PathBuf::from("..") }; + let out = out.unwrap_or_else(default_out); + let wanted: Vec<&Cell> = match &cells { + Some(list) => { + let names: Vec<&str> = list.split(',').map(str::trim).collect(); + let sel: Vec<&Cell> = CELLS.iter().filter(|c| names.contains(&c.name)).collect(); + for n in &names { + if !CELLS.iter().any(|c| &c.name == n) { + return Err(format!("unknown cell '{}' (try `iris-bench cells`)", n)); + } + } + sel + } + // The four that answer "which CPU, which engine". The + // lightning cells are opt-in: they trade away breakpoints and + // the traceback buffer, so they are a release-build question + // rather than a default comparison. + None => CELLS.iter().take(4).collect(), + }; + + let elf = root.join("bench/build/irisbench.elf"); + if !elf.exists() { + return Err(format!("no suite binary at {} — run `make -C bench` first", elf.display())); + } + let config = root.join("bench/run/bare.toml"); + + let mut failures = Vec::new(); + for cell in &wanted { + println!("== {} ==", cell.name); + let iris = match build_cell(cell, &root, force_build) { + Ok(p) => p, + Err(e) => { eprintln!(" {}", e); failures.push(cell.name); continue; } + }; + match run_guest(&iris, &elf, &config, cell.name, timeout, &[]) { + Ok(run) => { + // The guest reads PRId, so its banner is the authority + // on which CPU actually ran. + if run.machine.cpu != cell.expect_cpu { + eprintln!(" FAIL {} — guest reports cpu={}, expected {}", + cell.name, run.machine.cpu, cell.expect_cpu); + failures.push(cell.name); + continue; + } + let path = save(&run, &out)?; + print!(" "); + print!("{}", text_summary(std::slice::from_ref(&run))); + println!(" wrote {}", path.display()); + } + Err(e) => { eprintln!(" FAIL {} — {}", cell.name, e); failures.push(cell.name); } + } + } + + if !no_host { + println!("== host =="); + let exe = root.join("bench/build/irisbench-host"); + match run_host(&exe, timeout) { + Ok(run) => { let p = save(&run, &out)?; println!(" wrote {}", p.display()); } + Err(e) => eprintln!(" host baseline skipped: {}", e), + } + } + + println!(); + if failures.is_empty() { println!("matrix: every cell completed"); } + else { println!("matrix: {} cell(s) failed: {}", failures.len(), failures.join(" ")); } + + let runs = load_all(&out)?; + let md = markdown(&runs, None); + let report = out.join("report.md"); + std::fs::write(&report, &md).map_err(|e| format!("{}: {}", report.display(), e))?; + println!("report: {}", report.display()); + if !failures.is_empty() { return Err("some cells failed".into()); } + Ok(()) + } + + Cmd::Irix { socket, steps, ci, shell, label, out, timeout } => { + let ci_bin = ci.unwrap_or_else(|| repo_relative( + if cfg!(windows) { "target/release/iris-ci.exe" } else { "target/release/iris-ci" })); + if !ci_bin.exists() { + return Err(format!("no iris-ci at {} — cargo build --release --bin iris-ci", + ci_bin.display())); + } + let steps = steps.unwrap_or_else(|| repo_relative("bench/irix/steps.toml")); + let out = out.unwrap_or_else(default_out); + let ci = Ci { bin: ci_bin, socket, shell, timeout }; + let run = run_irix(&ci, &steps, &label)?; + let path = save(&run, &out)?; + println!("wrote {}", path.display()); + Ok(()) + } + + Cmd::Reference { from, id, label, measured, into } => { + let path = match from { + Some(p) => p, + None => newest_result(&default_out())?, + }; + let text = std::fs::read_to_string(&path) + .map_err(|e| format!("{}: {}", path.display(), e))?; + let run: Run = serde_json::from_str(&text) + .map_err(|e| format!("{}: {}", path.display(), e))?; + if run.suite_id.is_empty() { + return Err(format!( + "{} has no suite_id — it predates the field, or it is a host run. \ + Re-run the suite to record one.", path.display())); + } + let entry = reference_entry(&run, &id, label.as_deref(), measured.as_deref()); + + let Some(table_path) = into else { + println!("{}", serde_json::to_string_pretty(&entry).map_err(|e| e.to_string())?); + eprintln!("\n// paste into data/bench_reference.json \"entries\" \ + (suite_id {})", run.suite_id); + return Ok(()); + }; + + let mut table: ReferenceTable = match std::fs::read_to_string(&table_path) { + Ok(t) => serde_json::from_str(&t).map_err(|e| format!("{}: {}", table_path.display(), e))?, + Err(_) => ReferenceTable { schema: 1, suite_id: run.suite_id.clone(), entries: Vec::new() }, + }; + // An empty table adopts the incoming suite; a populated one must + // agree, or the merged file would hold two different workloads' + // numbers under one name. + if table.entries.is_empty() { + table.suite_id = run.suite_id.clone(); + } else if table.suite_id != run.suite_id { + return Err(format!( + "suite mismatch: {} holds {} but this result is {}. \ + The suite changed — regenerate every row, or start a new table.", + table_path.display(), table.suite_id, run.suite_id)); + } + table.entries.retain(|e| e.id != entry.id); + table.entries.push(entry); + table.entries.sort_by(|a, b| a.id.cmp(&b.id)); + std::fs::write(&table_path, serde_json::to_string_pretty(&table).map_err(|e| e.to_string())? + "\n") + .map_err(|e| format!("{}: {}", table_path.display(), e))?; + println!("{}: {} entries (suite {})", table_path.display(), table.entries.len(), table.suite_id); + Ok(()) + } + + Cmd::Report { dir, baseline, format, out } => { + let dir = dir.unwrap_or_else(default_out); + let runs = load_all(&dir)?; + let text = match format.as_str() { + "md" => markdown(&runs, baseline.as_deref()), + "text" => text_summary(&runs), + "json" => serde_json::to_string_pretty(&runs).map_err(|e| e.to_string())?, + _ => unreachable!(), + }; + match out { + Some(p) => { + std::fs::write(&p, &text).map_err(|e| format!("{}: {}", p.display(), e))?; + println!("wrote {}", p.display()); + } + None => { let mut so = std::io::stdout(); let _ = so.write_all(text.as_bytes()); } + } + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "\ +noise before the block +IRIS-BENCH-BEGIN v1 +#machine cpu=R4400 prid=0x00000440 fir=0x00000500 config=0x00c08483 l2=1 testdev=1 timebase=1 +#timebase count_hz=33000000 measured=1 +#work base=0x88300000 bytes=25165824 +#cols name unit iters work ns icount count exc checksum golden status +int/alu ops 16384 262144 250000000 12500000 8250 0 0x1111 0x1111 OK +int/dhrystone dhry 1000 1757000 1000000000 50000000 33000 0 0x2222 0x3333 MISMATCH +sys/tlb_hit xlat 256 24576 250000000 5000000 8250 4096 0x0000 0x0000 UNCHECKED +#totals benches=3 checked=2 matched=1 ns=1500000000 icount=67500000 +IRIS-BENCH-END +"; + + #[test] + fn parses_a_report_block_out_of_surrounding_noise() { + let (m, rows, checked, matched, ns, ic) = parse_block(SAMPLE).unwrap(); + assert_eq!(m.cpu, "R4400"); + assert_eq!(m.count_hz, 33_000_000); + assert!(m.timebase && m.testdev && m.l2); + assert_eq!(m.work_bytes, 25_165_824); + assert_eq!(rows.len(), 3); + assert_eq!((checked, matched), (2, 1)); + assert_eq!((ns, ic), (1_500_000_000, 67_500_000)); + } + + #[test] + fn rates_and_derived_units() { + let (_, rows, checked, matched, ns, ic) = parse_block(SAMPLE).unwrap(); + let run = Run { + cell: "t".into(), features: vec![], machine: Machine::default(), + host: HostInfo::default(), rows, checked, matched, + total_ns: ns, total_icount: ic, wall_s: 2.0, + suite_id: "blake3:0000000000000000".into(), + }; + // 262144 work units in 0.25 s + assert!((run.row("int/alu").unwrap().rate() - 1_048_576.0).abs() < 1.0); + // 12.5M instructions in 0.25 s = 50 MIPS + assert!((run.row("int/alu").unwrap().mips() - 50.0).abs() < 0.01); + // 1,757,000 dhrystones in 1 s / 1757 = 1000 DMIPS + assert!((run.dmips().unwrap() - 1000.0).abs() < 0.01); + assert!(run.whet_loops().is_none(), "the sample block has no whetstone row"); + assert!((run.accuracy() - 50.0).abs() < 0.01); + assert!((run.mips() - 45.0).abs() < 0.01); + } + + #[test] + fn a_truncated_run_is_an_error_not_an_empty_report() { + let cut = SAMPLE.split(END).next().unwrap(); + assert!(parse_block(cut).unwrap_err().contains("IRIS-BENCH-END")); + assert!(parse_block("nothing here").unwrap_err().contains("IRIS-BENCH-BEGIN")); + } + + #[test] + fn features_come_from_the_emulator_banner() { + assert_eq!(parse_features("iris: build features: r5k jitv2 tlbvmap\n"), + vec!["r5k", "jitv2", "tlbvmap"]); + assert!(parse_features("iris: build features: (none)\n").is_empty()); + assert!(parse_features("no banner at all").is_empty()); + } + + #[test] + fn every_cell_name_is_unique_and_maps_to_a_cpu() { + for (i, a) in CELLS.iter().enumerate() { + assert!(a.expect_cpu == "R4400" || a.expect_cpu == "R5000"); + // An r5k cell must actually ask for the r5k feature, or the guard + // in `matrix` would reject its own build. + assert_eq!(a.expect_cpu == "R5000", a.features.contains("r5k"), "{}", a.name); + for b in CELLS.iter().skip(i + 1) { assert_ne!(a.name, b.name); } + } + } +} From 10cbf4b9facffe431ced5f477871a282a6177141 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 21 Aug 2026 10:41:51 -0400 Subject: [PATCH 05/15] iris-gui: a Benchmark tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs iris-bench from the GUI — one run, this host's baseline, or the full matrix — with the child's output streamed line by line into the panel. A matrix takes tens of minutes and rebuilds the emulator once per cell, so a progress-free spinner would be useless. Nothing here touches a running machine: iris-bench spawns its own headless emulator with its own bare-metal config, so this is safe to use while an IRIX session is up. Hidden under `appstore` alongside the CI tab, for the same reason: it spawns cargo and a second process, and a sandboxed build can do neither. Making this work for App Store users is a different design — see docs/gui-benchmark-plan.md, which maps out the in-process path (iris-gui already links the emulator as a library, so there is no subprocess to sandbox once the suite is embedded). Co-Authored-By: Claude Opus 5 (1M context) --- iris-gui/src/bench_ui.rs | 298 ++++++++++++++++++++++++++++++++++++++ iris-gui/src/config_ui.rs | 10 +- iris-gui/src/main.rs | 7 + 3 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 iris-gui/src/bench_ui.rs diff --git a/iris-gui/src/bench_ui.rs b/iris-gui/src/bench_ui.rs new file mode 100644 index 0000000..b2db9c2 --- /dev/null +++ b/iris-gui/src/bench_ui.rs @@ -0,0 +1,298 @@ +//! Benchmark tab — run `iris-bench` from the GUI and watch it. +//! +//! The suite is a developer tool and the command line is its natural home; this +//! exists so that "how fast is this build, and is it still right" is one click +//! away rather than a remembered incantation, and so the answer is legible +//! while it is still running. A full matrix takes tens of minutes and rebuilds +//! the emulator once per cell — a progress-free spinner would be useless, so +//! the child's output is streamed line by line into the panel. +//! +//! Nothing here talks to a running machine. `iris-bench` spawns its own +//! headless emulator with its own bare-metal config, so this is safe to use +//! while a normal IRIX session is up. + +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use eframe::egui::{self, Color32, RichText, ScrollArea, Ui}; + +/// How many output lines to keep. A matrix run prints a cargo build per cell; +/// the interesting part is always the tail. +const MAX_LINES: usize = 4000; + +#[derive(Default)] +pub struct BenchState { + lines: Arc>>, + running: Arc, + child: Arc>>, + /// What finished last, and how — kept after the run so the panel still says + /// something once the thread is gone. + last: Option, + what: String, +} + +struct Outcome { + label: String, + ok: bool, + detail: String, +} + +/// Where the pieces are, relative to wherever the GUI was launched from. The +/// dev workflow runs it from the repo root; an installed layout puts the +/// binaries next to the executable. +fn locate(rel: &str) -> Option { + let exe_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(PathBuf::from)); + let name = Path::new(rel).file_name()?.to_owned(); + let mut candidates = vec![PathBuf::from(rel), PathBuf::from("..").join(rel)]; + if let Some(d) = exe_dir { + candidates.push(d.join(&name)); + candidates.push(d.join(rel)); + } + candidates.into_iter().find(|p| p.exists()) +} + +fn iris_bench_bin() -> Option { + let exe = if cfg!(windows) { "iris-bench.exe" } else { "iris-bench" }; + locate(&format!("target/release/{}", exe)) +} + +fn suite_elf() -> Option { locate("bench/build/irisbench.elf") } +fn host_bin() -> Option { + let exe = if cfg!(windows) { "irisbench-host.exe" } else { "irisbench-host" }; + locate(&format!("bench/build/{}", exe)) +} +fn results_dir() -> Option { locate("bench/build/results") } + +impl BenchState { + pub fn is_running(&self) -> bool { self.running.load(Ordering::Relaxed) } + + fn start(&mut self, label: &str, args: &[&str]) { + if self.is_running() { return; } + let Some(bin) = iris_bench_bin() else { + self.last = Some(Outcome { + label: label.to_string(), + ok: false, + detail: "iris-bench not built — run `cargo build --release --bin iris-bench`" + .to_string(), + }); + return; + }; + + self.lines.lock().unwrap().clear(); + self.what = label.to_string(); + self.last = None; + self.running.store(true, Ordering::Relaxed); + + let lines = Arc::clone(&self.lines); + let running = Arc::clone(&self.running); + let child_slot = Arc::clone(&self.child); + let owned: Vec = args.iter().map(|s| s.to_string()).collect(); + // The repo root, so bench/ and target/ resolve the way iris-bench + // expects — it takes every path relative to there. + let cwd = bin.parent().and_then(|p| p.parent()).and_then(|p| p.parent()) + .map(PathBuf::from).unwrap_or_else(|| PathBuf::from(".")); + let label = label.to_string(); + + std::thread::spawn(move || { + let mut cmd = Command::new(&bin); + cmd.current_dir(&cwd) + .args(&owned) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + lines.lock().unwrap().push(format!("failed to start {}: {}", bin.display(), e)); + running.store(false, Ordering::Relaxed); + return; + } + }; + + let so = child.stdout.take(); + let se = child.stderr.take(); + *child_slot.lock().unwrap() = Some(child); + + // Both pipes on their own threads: a child that fills one while we + // read the other would stall instead of finishing. + let pump = |r: Option>, sink: Arc>>| { + std::thread::spawn(move || { + if let Some(r) = r { + for line in BufReader::new(r).lines().map_while(Result::ok) { + let mut v = sink.lock().unwrap(); + v.push(line); + if v.len() > MAX_LINES { let drop_n = v.len() - MAX_LINES; v.drain(..drop_n); } + } + } + }) + }; + let t1 = pump(so.map(|s| Box::new(s) as Box), Arc::clone(&lines)); + let t2 = pump(se.map(|s| Box::new(s) as Box), Arc::clone(&lines)); + + let status = child_slot.lock().unwrap().as_mut().map(|c| c.wait()); + let _ = t1.join(); + let _ = t2.join(); + *child_slot.lock().unwrap() = None; + + let ok = matches!(status, Some(Ok(s)) if s.success()); + lines.lock().unwrap().push(if ok { + format!("--- {} finished ---", label) + } else { + format!("--- {} failed ---", label) + }); + running.store(false, Ordering::Relaxed); + }); + } + + fn stop(&mut self) { + if let Some(c) = self.child.lock().unwrap().as_mut() { let _ = c.kill(); } + } + + /// Pull the headline numbers back out of the streamed output. iris-bench + /// prints one summary line per cell in a fixed shape, so this is a read of + /// what already scrolled past rather than a second source of truth. + fn summarize(&self) -> Vec { + self.lines + .lock() + .unwrap() + .iter() + .filter(|l| l.contains("accuracy") || l.starts_with("report:") || l.starts_with("matrix:")) + .cloned() + .collect() + } +} + +pub fn show(ui: &mut Ui, st: &mut BenchState) { + ui.heading("Benchmark"); + ui.label( + "Runs bench/ — a bare-metal MIPS suite that measures this build of IRIS and \ + checks that it is still computing the right answers. No IRIX and no disk \ + image needed; it starts its own headless emulator, so it is safe to use \ + while a machine is running.", + ); + ui.add_space(6.0); + + // ── prerequisites ─────────────────────────────────────────────────────── + let bench_bin = iris_bench_bin(); + let elf = suite_elf(); + let hostb = host_bin(); + + egui::Grid::new("bench_paths").num_columns(2).striped(true).show(ui, |ui| { + let row = |ui: &mut Ui, name: &str, p: &Option, hint: &str| { + ui.label(name); + match p { + Some(p) => { ui.label(RichText::new(p.display().to_string()).monospace()); } + None => { ui.label(RichText::new(hint).color(Color32::from_rgb(220, 170, 90))); } + } + ui.end_row(); + }; + row(ui, "iris-bench", &bench_bin, "not built — cargo build --release --bin iris-bench"); + row(ui, "suite binary", &elf, "not built — make -C bench (needs a MIPS cross toolchain)"); + row(ui, "host baseline", &hostb, "not built — make -C bench hostbench"); + }); + + ui.add_space(8.0); + let busy = st.is_running(); + + ui.horizontal(|ui| { + ui.add_enabled_ui(!busy && bench_bin.is_some() && elf.is_some(), |ui| { + if ui + .button("Run once") + .on_hover_text( + "Run the suite against target/release/iris as it is built right now. \ + A couple of minutes on the interpreter.", + ) + .clicked() + { + st.start("run", &["run", "--label", "gui"]); + } + }); + + ui.add_enabled_ui(!busy && bench_bin.is_some() && hostb.is_some(), |ui| { + if ui + .button("Measure this host") + .on_hover_text( + "Run the identical kernels natively, for the ratio between \ + emulated and native. About ten seconds.", + ) + .clicked() + { + st.start("host", &["host"]); + } + }); + + ui.add_enabled_ui(!busy && bench_bin.is_some() && elf.is_some(), |ui| { + if ui + .button("Full matrix") + .on_hover_text( + "R4400 and R5000, interpreter and jitv2. Builds a separate emulator \ + for each — the CPU model and the JIT are compile-time cargo features. \ + Tens of minutes, and it needs cargo on PATH.", + ) + .clicked() + { + st.start("matrix", &["matrix"]); + } + }); + + if busy && ui.button("Stop").clicked() { st.stop(); } + + if let Some(dir) = results_dir() { + if !busy && ui.button("Open results").clicked() { open_folder(&dir); } + } + }); + + if busy { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.spinner(); + ui.label(format!("{} running…", st.what)); + }); + // A background thread is writing lines; without this the panel only + // updates when the pointer moves over it. + ui.ctx().request_repaint_after(std::time::Duration::from_millis(200)); + } + + let summary = st.summarize(); + if !summary.is_empty() { + ui.add_space(6.0); + ui.separator(); + for line in &summary { + ui.label(RichText::new(line).monospace().strong()); + } + } + if let Some(out) = &st.last { + ui.label( + RichText::new(format!("{}: {}", out.label, out.detail)) + .color(if out.ok { Color32::LIGHT_GREEN } else { Color32::from_rgb(220, 170, 90) }), + ); + } + + ui.add_space(6.0); + ui.separator(); + ui.label("Output"); + ScrollArea::vertical() + .max_height(320.0) + .stick_to_bottom(true) + .auto_shrink([false, false]) + .show(ui, |ui| { + let lines = st.lines.lock().unwrap(); + if lines.is_empty() { + ui.label(RichText::new("(nothing yet)").weak()); + } + for l in lines.iter() { + ui.label(RichText::new(l).monospace().size(11.0)); + } + }); +} + +fn open_folder(dir: &Path) { + #[cfg(target_os = "windows")] + let _ = Command::new("explorer").arg(dir).spawn(); + #[cfg(target_os = "macos")] + let _ = Command::new("open").arg(dir).spawn(); + #[cfg(all(unix, not(target_os = "macos")))] + let _ = Command::new("xdg-open").arg(dir).spawn(); +} diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index cb43af3..de28b36 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -93,6 +93,7 @@ pub enum Tab { VideoIn, Debug, Ci, + Bench, } impl Tab { @@ -111,6 +112,10 @@ impl Tab { } if !cfg!(feature = "appstore") { tabs.push(Tab::Ci); + // Benchmarking spawns cargo and a second emulator; that is a + // developer workflow, and a sandboxed App Store build can do + // neither. Same reasoning as the CI tab it sits next to. + tabs.push(Tab::Bench); } tabs } @@ -124,6 +129,7 @@ impl Tab { Tab::VideoIn => "Video-In", Tab::Debug => "Debug", Tab::Ci => "CI / Automation", + Tab::Bench => "Benchmark", } } } @@ -178,6 +184,7 @@ pub fn show_tab( disk_folders: &[String], pcap_ifaces: &Option, String>>, mem_ctx: MemoryUiContext, + bench: &mut crate::bench_ui::BenchState, ) -> TabOutcome { ScrollArea::vertical().show(ui, |ui| match tab { Tab::General => TabOutcome { action: show_general(ui, cfg), ..Default::default() }, @@ -190,7 +197,8 @@ pub fn show_tab( Tab::Display => { show_display(ui, cfg, mem_ctx.running); TabOutcome::default() } Tab::VideoIn => TabOutcome { action: show_vino(ui, cfg), ..Default::default() }, Tab::Debug => TabOutcome { action: show_debug(ui, cfg), ..Default::default() }, - Tab::Ci => TabOutcome { action: show_ci(ui, cfg), ..Default::default() } + Tab::Ci => TabOutcome { action: show_ci(ui, cfg), ..Default::default() }, + Tab::Bench => { crate::bench_ui::show(ui, bench); TabOutcome::default() } }).inner } diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index 473687b..044da96 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -1,5 +1,6 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod bench_ui; mod camera_test; mod capture_access; mod config_ui; @@ -158,6 +159,10 @@ struct App { /// Banks passed to the last `Cmd::Start` (guest-visible RAM is fixed until Stop). started_banks: Option<[u32; 4]>, tab: Tab, + /// Benchmark tab state: the child process, its streamed output, and the + /// last outcome. Lives here rather than in the tab function because a run + /// outlives many frames. + bench: bench_ui::BenchState, emu: EmulatorHandle, toast: Option<(String, std::time::Instant)>, fullscreen: bool, @@ -460,6 +465,7 @@ impl App { cfg_dirty_since: None, started_banks: None, tab: Tab::General, + bench: bench_ui::BenchState::default(), emu: EmulatorHandle::spawn(), toast: None, stop_modal: None, @@ -2205,6 +2211,7 @@ impl App { running: self.emu.is_running(), started_banks: self.started_banks, }, + &mut self.bench, ); match out.action { ConfigAction::RequestEmbeddedProm => self.confirm_embedded_prom = true, From 86cad46ea9c858099bc7f5a43c2fb656cf1c3621 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 21 Aug 2026 10:42:08 -0400 Subject: [PATCH 06/15] docs: first benchmark numbers, jitv2 recommendations, GUI plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rules/perf/bench-first-numbers.md — the first complete matrix run, kept as a baseline. 100% accuracy on every cell; 51.0 guest MIPS / 70.9 DMIPS on the R4400 interpreter, 202.9 / 213.0 with jitv2. Run-to-run variation is under 1.1%, so a 5% change between commits is real and anything under 2% is noise. docs/jitv2_performance_analysis.md — where to spend effort next, each recommendation corroborated against the code and carrying a confidence figure. The load-bearing measurement is jitv2-vs-interpreter with build flags held constant (both cells `lightning`), which isolates what the JIT itself contributes: 5.2-6.7x on integer ALU, 2.6-5.1x on imaging and codec, but 0.98-1.01x on FP arithmetic and 0.67-0.86x on memory streaming and the region-boundary kernels — two classes where turning the JIT on makes the guest slower. Ends with a resume prompt naming the exact functions and line numbers, the order to attack them in, and the three guardrails. docs/gui-benchmark-plan.md — mapping the benchmark behind one button for App Store users. The feature is smaller than it looks because iris-gui already runs the emulator in-process; four things in the iris crate stand in the way, and TestDevice::exit calling std::process::exit is the delicate one. Also settles a question the analysis left open: the store build forces IRIS_NO_JIT=1 because the sandbox only permits MAP_JIT pages, so it has no JIT at all — not jitv2, not the REX3 draw-shader one — and its reference numbers must be interpreter runs. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 16 +- README.md | 44 +++- docs/gui-benchmark-plan.md | 362 +++++++++++++++++++++++++++++ docs/jitv2_performance_analysis.md | 284 ++++++++++++++++++++++ rules/perf/bench-first-numbers.md | 127 ++++++++++ 5 files changed, 831 insertions(+), 2 deletions(-) create mode 100644 docs/gui-benchmark-plan.md create mode 100644 docs/jitv2_performance_analysis.md create mode 100644 rules/perf/bench-first-numbers.md diff --git a/CLAUDE.md b/CLAUDE.md index ee39ba1..ab4c3e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,21 @@ cargo run --release --features jitv2,rex-jit # enable MIPS JIT v2 ( ``` Binaries: `iris` (the emulator), `iris-ci` (CI/automation socket client), -`coffdump`, `chd_extract`. Feature flags are documented in `README.md`. +`iris-bench` (benchmark driver), `coffdump`, `chd_extract`. Feature flags are +documented in `README.md`. + +## Testing and benchmarking + +- `cpu-tests/` — bare-metal MIPS III/IV correctness suite. "Is this instruction + right", one instruction at a time. `make -C cpu-tests run`. +- `bench/` — bare-metal benchmark suite. "How fast is this build, and is it + still right after ten million of them." Reports throughput, guest MIPS and an + accuracy score per kernel; `iris-bench matrix` sweeps R4400/R5000 x + interpreter/jitv2. Read `rules/testing/benchmark-suite-gotchas.md` before + adding a kernel — the accuracy check catches endianness, uninitialised + memory and out-of-bounds reads, and every one of those has already happened. +- Both share `cpu-tests/harness` (toolchain probe, SCC console, startup and + exception dispatch). Changing those files affects both suites. ## Hard invariants (from HACKING.md) diff --git a/README.md b/README.md index a8bf47a..d80caab 100644 --- a/README.md +++ b/README.md @@ -416,6 +416,48 @@ terminal apps. Use `telnet 127.0.0.1 2323` (with port forwarding configured) for a clean terminal instead. +## Testing and benchmarking + +Two bare-metal MIPS suites run on the emulated CPU with no operating system in +the way. They answer different questions and neither replaces the other. + +**`cpu-tests/`** — is this instruction correct? ~240 self-checking tests over +ALU, FPU, TLB, caches, exceptions and the MIPS IV additions, one instruction at +a time with clean state. + +```sh +sudo apt-get install gcc-mips-linux-gnu binutils-mips-linux-gnu # or: make -C cpu-tests toolchain-local +make -C cpu-tests && make -C cpu-tests run +cpu-tests/run/matrix.sh # R4400/R5000 x interp/jitv2 +``` + +**`bench/`** — how fast is this build, and is it still right after ten million +of them? 46 kernels covering integer, FPU, the cache hierarchy, image and video +editing inner loops, compression, and the emulator-only paths (TLB refill, +exception round trip, cache maintenance, uncached I/O). Every kernel checksums +its result against a golden value computed by building the same C natively, so +each run reports an accuracy percentage next to its throughput — and per-kernel +**guest instructions per host second**, which is directly comparable between the +interpreter and jitv2. + +```sh +make -C bench && make -C bench hostbench +cargo build --release --bin iris-bench +./target/release/iris-bench matrix # builds and runs every CPU x engine cell +./target/release/iris-bench host # the same kernels, natively, for the ratio +``` + +Includes Dhrystone 2.1 (DMIPS) and LINPACK 100x100 (MFLOPS), so an emulated +Indy can be put next to published figures for a real one, plus a Whetstone mix +(reported in passes/s — see bench/README.md for why not MWIPS). See +[bench/README.md](bench/README.md) — and +[rules/testing/benchmark-suite-gotchas.md](rules/testing/benchmark-suite-gotchas.md) +before adding a kernel. + +`bench/irix/` is the other half: real workloads under a booted IRIX (filesystem, +buffer cache, IRIX's own tools, X on REX3), driven over `iris-ci`. + + ## Rules The `rules/` directory contains hard-won lessons from debugging the JIT and @@ -424,7 +466,7 @@ on the codebase. - `rules/jitv2/` - jitv2 region compiler design, codegen gotchas, fusion hazards - `rules/irix/` - networking config, keyboard quirks, csh + scratch raw-device gotchas -- `rules/testing/` - disk image handling, avoiding filesystem corruption +- `rules/testing/` - disk image handling, avoiding filesystem corruption, benchmark-kernel gotchas - `rules/snapshot/` - snapshot binary format, scratch-volume conventions, round-trip tests, CI overlay paths, **iris-ci as the canonical CI interface** If you're about to touch the jitv2 compiler, read `rules/jitv2/jit-v2-design.md` diff --git a/docs/gui-benchmark-plan.md b/docs/gui-benchmark-plan.md new file mode 100644 index 0000000..08dc6a5 --- /dev/null +++ b/docs/gui-benchmark-plan.md @@ -0,0 +1,362 @@ +# Benchmark in iris-gui — feature map + +Goal: an App Store user presses one button and gets a meaningful score for their +machine, on every platform, with no toolchain, no subprocess, and no files +written outside the sandbox. + +Status: **plan only.** Nothing below is built. The developer-facing path +(`iris-bench`, `bench/`, the Benchmark tab hidden under `!appstore`) already +works and is what this reuses. + +--- + +## The fact that makes this tractable + +`iris-gui` depends on `iris` as a **library** and already runs the emulator +**in-process** on a worker thread (`handle.rs:382`, `Machine::new(cfg_owned)` +inside `catch_unwind`). There is no `iris` subprocess to sandbox, no `cargo`, no +`iris-bench` binary to ship. + +So the App Store benchmark is not "drive the developer tool from a GUI". It is: +build a `MachineConfig`, load an ELF that is already inside the app, run it, read +the report the guest prints. Most of the work is making the emulator *embeddable* +for that, not building UI. + +Four things stand in the way, all in `iris`, none large: + +| Blocker | Where | Why it matters in-process | +|---|---|---| +| `TestDevice::exit()` calls `std::process::exit` | `testdev.rs:164` | Guest finishing the suite would **quit the app** | +| `TestDevice::putc()` writes to `stdout` | `testdev.rs:129` | GUI never sees the report | +| `Machine::load_elf` takes a path | `machine.rs:1064` | Suite has to be a file on disk | +| `Machine::new` calls `process::exit(1)` on the ultra64/test-device slot clash | `machine.rs:565` | Config mistake would quit the app | + +`load_elf` is a five-line refactor — it is already `fs::read` → `elf::parse` → +load segments (`mips_exec.rs:8441`). The other three are the real work, and +`TestDevice::exit` is the one to be careful with: the guest is mid-store on the +CPU thread when it fires. + +--- + +## What the user sees + +**One primary action.** "Benchmark this Mac" (or PC). No cell picker, no engine +picker, no mention of R4400 vs R5000 — the App Store build is one binary with one +CPU and one engine, so there is nothing to choose. + +**While it runs:** a progress bar, the current kernel name, elapsed and estimated +remaining. Not a log tail — the current tab streams subprocess stdout, which is +right for a developer and wrong for everyone else. + +**When it finishes** — this is the shipping state, with an empty reference +table. Design for it first; it is what every user sees until someone measures +their machine: + +``` + Emulated Indy 71 DMIPS ← interpreter: the store build has no JIT + Emulator throughput 51 MIPS + Accuracy 100% (40/40) ← correctness, not speed + + Integer 38.5 M ops/s Imaging 1.0 M px/s + Floating 7.3 M ops/s Codec 7.6 MB/s + Memory 30.7 MB/s + + Reference statistics not gathered for this platform. + + [ Details ] [ Copy ] [ Save report… ] +``` + +Once a matching row exists in `data/bench_reference.json`, the same block gains +a comparison column and the sentence is replaced: + +``` + Integer 38.5 M ops/s ████████████░░ 1.4× vs MacBook Air (M1) + Floating 7.3 M ops/s ███░░░░░░░░░░░ 0.7× + … +``` + +- **The absolute numbers carry the screen.** DMIPS has forty years of published + figures behind it, guest MIPS is meaningful on its own, and accuracy needs no + baseline at all — so an empty reference table costs the user very little. + Comparison is an enhancement, never a dependency. +- **Accuracy is shown as prominently as speed.** It is the differentiator: no + other emulator reports whether it computed the right answer. A user seeing + 100% learns something real, and a user seeing 97% has found a bug worth + reporting. +- **Nothing is uploaded.** Results live in the app container; export is an + explicit save panel. (See `PRIVACY.md`.) + +Numbers above are the interpreter's, because that is what ships — see the JIT +note under Risks. A source build with `jitv2` scores ~203 MIPS / ~213 DMIPS on +the same host, which is why every stored result carries its engine. + +**Quick vs full.** The full suite is ~160 s interpreted, ~80 s with jitv2. That +is too long for a consumer button as the default. Ship a **quick mode** (~20 s) +as the default and full as an option. + +**Progress, not a log tail.** The suite already streams its table a line at a +time; the runner parses those lines anyway to know which kernel is running, so +the progress display and the raw console are the same data at two levels of +detail. Progress bar on top, `Show details ▸` for the console underneath. + +--- + +## Architecture + +**Today (developer path):** + +``` +iris-gui ──spawn──> iris-bench ──spawn──> iris (process) + │ │ --load-elf bench/build/irisbench.elf + │ │ --test-device + │ stdout ──> IRIS-BENCH-BEGIN…END + └── parses the block, writes results/*.json +``` +Needs: a built ELF on disk, two binaries, subprocess spawn, filesystem writes. +None of that survives the sandbox. + +**Proposed (embedded path):** + +``` +iris-gui + └── iris::bench_runner::run(opts, progress_cb) + ├── MachineConfig { headless, no_audio, no scsi, test_device, banks } + ├── Machine::new(cfg) (in-process, worker thread) + ├── machine.load_elf_bytes(BENCH_ELF) (include_bytes!, ~285 KB) + ├── TestDevice sink ──> Vec ──> progress_cb per line + └── on TESTDEV_EXIT ──> stop ──> parse ──> Report +``` +Needs: nothing outside the process. Works identically on macOS, Windows, Linux. + +The same `bench_runner` backs `iris-bench run` too, so there is one +implementation of "run the suite and parse the answer" rather than two. + +--- + +## Work items, in dependency order + +### P0 — make the emulator embeddable *(small, `iris` crate only)* + +1. `Machine::load_elf_bytes(&self, bytes: &[u8]) -> Result`. + Refactor `load_elf` to call it. `MipsCpu::load_elf` splits the same way. +2. `TestDevice` gains an output mode: + - `TestDevice::new_embedded(sink: Arc>>, on_exit: Box)` + alongside today's `new(dump_path)`. + - `putc` writes to the sink when embedded, `stdout` otherwise. + - `exit(code)` calls `on_exit(code)` and then **parks the CPU** instead of + `process::exit`. Getting this right is the one genuinely delicate piece: + the store that triggers it is executing on the CPU thread, so the handler + must not block on anything the CPU thread owns. Signal an `AtomicBool` + + `Condvar` and let the *runner* thread do the stopping. + - `dump()` becomes a no-op when no path is configured. +3. `Machine::new`'s `process::exit(1)` (`machine.rs:565`) becomes an error or a + panic. `Machine::new` already panics on bad input and the GUI already catches + that (`handle.rs:381`), so a full `Result` refactor is optional — converting + the one `exit` call to a panic is enough and is smaller. + +**Test**: a `#[test]` in `iris` that runs the embedded suite in quick mode and +asserts the report parses and accuracy is 100%. That single test covers most of +P0 and P1 and is the regression net for the whole feature. + +### P1 — ship the suite as an asset *(small)* + +4. Check in `bench/prebuilt/irisbench.elf` (~285 KB) plus the `golden.h` hash it + was built against. `include_bytes!` it from a new `src/benchsuite.rs`. + Precedent: the 512 KB PROM is already embedded (`src/prombin.rs`) — though as + a 3.2 MB Rust hex array, which is *not* the pattern to copy. `include_bytes!` + on a real file is smaller, faster to compile, and diffable as a binary. +5. Extend `.github/workflows/bench.yml`: rebuild the ELF and fail if it differs + from the checked-in one. Same discipline as `golden.h` and `fpvectors.c` + already have — a checked-in build product that can silently drift is worse + than no build product. +6. Move the report parser out of `src/bin/iris_bench.rs` into `iris` so the + library, the CLI and the GUI share one copy. + +### P2 — the runner *(medium)* + +7. `iris::bench_runner`: + ```rust + pub struct BenchOptions { pub quick: bool, pub groups: u32, pub banks: [u32; 4] } + pub enum Progress { Started { total: usize }, Kernel { name: String, index: usize }, Line(String) } + pub fn run(opts: BenchOptions, progress: impl FnMut(Progress) + Send) -> Result; + ``` +8. Rewire `iris-bench run` onto it (drops the subprocess for the local case; + `matrix` keeps spawning, because comparing builds inherently means comparing + binaries). + +### P3 — the GUI screen *(medium — this is where the design effort goes)* + +9. Replace `bench_ui.rs`'s subprocess + log tail with the runner + a results + view. The state machine is small (Idle → Running → Done/Failed); the work is + the results presentation, not the plumbing. +10. **No IRIS Index in v1** — see the reference-table section. Headline the + numbers that stand alone. +11. **Progress primary, console secondary.** The suite already streams its + human table a line at a time — deliberately, so a run that prints nothing + for two minutes is not mistaken for a hang. The runner has to consume that + stream anyway to know which kernel is running, so the progress events *are* + the parsed console lines and showing the raw text underneath costs nothing + extra. Progress bar + current kernel on top; `Show details ▸` reveals the + console. + - Do **not** route it through `serial_console.rs`: that is a TCP client to + `127.0.0.1:8881`, so it would require standing up the loopback serial + server for a benchmark that has no other use for it. Take the test-device + sink directly and reuse only the *view* half of that widget (scrollback + cap, autoscroll, monospace). Splitting `SerialConsole` into transport and + view is worth doing for its own sake. + - Raw console as the *primary* surface reads as "something went wrong" to a + non-technical user. It belongs one click down. +12. Keep matrix/cells/host-baseline buttons behind `!appstore`. + +### P4 — host baseline in-process *(medium, one build-system decision)* + +12. The kernels already compile natively — that is how `golden.h` is generated. + Compile `bench/kernels/*.c` + `bench/gen/hostplat.c` into `iris` with the + `cc` crate so the native comparison runs in-process. That preserves the + property the whole suite is built on: **the same C on both sides**, so the + native ratio is a real number rather than two benchmarks pretending to be + comparable. +13. **Decision needed**: `cc` means a C compiler becomes a build requirement for + the `iris` crate. Either (a) gate it behind a `bench-host` feature that + release builds turn on — contributors' builds then differ from shipped ones; + or (b) always on, with build.rs degrading to a stub when no compiler is + found. Recommend (b): the failure mode is a missing feature, not a broken + build, and shipped and local builds stay identical. + +### P5 — quick mode *(small, guest-side)* + +14. The suite deliberately has no runtime selector — a bare-metal binary loaded + with `--load-elf` has nowhere to take arguments from. Cleanest fix: **a new + test-device register** the guest reads at startup (`TESTDEV_CONFIG`, next to + `TESTDEV_CAPS`), carrying a group bitmask and a target-time scale. ~20 lines + in `harness/main.c`, one register in `testdev.rs`, and it composes with the + capability probe already there. + Alternatives considered: a second smaller ELF (doubles the asset and the + golden discipline), or poking a word into RAM after `load_elf_bytes` + (works, but invents a second ABI nobody documents). + +### P6 — sandbox and store *(small, mostly audit)* + +15. Unhide the tab for `appstore`. Audit: no path outside the container, no + subprocess, no `process::exit`, export only via `rfd` save panel. +16. Confirm what the App Store workflow actually builds (`appstore.yml` is not in + this branch). See the open question below. + +--- + +## The reference table + +`data/bench_reference.json` — checked in, `include_str!`'d, updated by hand when +someone measures a machine worth recording. **It ships empty, and empty is a +normal state**: a machine with no row gets *"reference statistics not gathered +for this platform"* rather than a comparison. No upload, no download, no +user-writable override, no import/export. It is a static file and a pull +request. + +Deliberately cut from an earlier draft of this plan: the layered +bundled/override/import design, and the IRIS Index. The index needs a frozen +normalization vector to mean anything, and there is nothing to freeze until the +table has entries — so v1 shows the numbers that stand on their own (guest MIPS, +DMIPS, accuracy) and an index can arrive later if it earns its place. + +Note this is a different file from the golden checksums, which are the +correctness oracle and are already compiled *into the MIPS ELF*. Those must +never be user-editable — editing them would let someone "fix" an accuracy +failure. Externalising the performance table therefore carries no correctness +risk at all. + +### Adding a row *(built — works today)* + +```sh +./target/release/iris-bench run --label my-machine +./target/release/iris-bench reference \ + --id m1-max-interp --label "MacBook Pro (M1 Max) — interpreter" \ + --into data/bench_reference.json +``` + +`reference` with no `--from` takes the newest result in `bench/build/results/`; +without `--into` it prints the row for pasting. See +`data/bench_reference.README.md`. + +### Two fields that keep it honest + +**`suite_id`** — blake3 of the guest binary the numbers came from, recorded on +every result. Reference figures only mean something against the exact suite that +produced them: add or change a kernel and every stored number silently becomes a +comparison between two different workloads. So the merge refuses when a result +disagrees with a populated table, and the GUI treats a mismatch exactly like an +empty table — one fallback path covers both. An empty table adopts the suite of +the first row merged into it. + +**`cpu` and `engine`** — the two engines differ by roughly 4x, so a row without +them cannot be compared with anything. This matters concretely: the App Store +build forces `IRIS_NO_JIT=1`, so rows meant for comparison against it must be +`"engine": "interp"`. + +### Multiple contributors + +Not designed for. It is a file in the repo; rows arrive as pull requests. If +that ever becomes unwieldy the problem will announce itself, and the schema +already carries everything a merge would need. + +--- + +## Risks and open questions + +| | Risk | Mitigation | +|---|---|---| +| **1** | `TestDevice::exit` firing on the CPU thread mid-store | Signal + park; let the runner thread stop the machine. Cover with the P0 test. | +| **2** | Two `Machine`s at once (benchmark while IRIX is running) | Refuse. `handle.rs:358` already has the one-machine guard. It also gives a clean measurement, so this is a feature. | +| **3** | Laptop measurement validity — thermal throttling, efficiency cores, other apps | Already best-of-two per kernel. Surface the spread; warn when the repeats disagree by >5%; say plainly that a laptop on battery will score lower. | +| **4** | Embedded ELF drifting from `golden.h` | CI check (item 5). Non-negotiable — a stale ELF against fresh goldens reports false accuracy failures to users. | +| **5** | A user reading a low score as "IRIS is broken" | Lead with the reference comparison, not the raw number. | +| **6** | App Store review reading it as a hardware-diagnostic utility | It benchmarks *the app's own emulator*, reports nothing about the host beyond a CPU model string, and uploads nothing. Frame the UI that way. | + +**Settled — the App Store build has no JIT at all.** `main.rs:116` forces +`IRIS_NO_JIT=1` under `feature = "appstore"`, and the comment there explains +why: Cranelift allocates executable memory with `mmap`+`mprotect`, not +`MAP_JIT`, and the sandbox only permits `MAP_JIT` pages +(`com.apple.security.cs.allow-jit` is the only code-signing entitlement the +store accepts; `allow-unsigned-executable-memory` and +`disable-executable-page-protection` are rejected by review). The first JITed +REX3 draw gets SIGKILL'd. So it is not just jitv2 — the REX3 draw-shader JIT is +off too. + +Consequences, all of which the design has to absorb rather than work around: + +- The App Store headline is **~51 MIPS / ~70 DMIPS**, not 203 / 213. +- **The shipped reference table must be interpreter numbers.** A jitv2 row next + to an App Store result is a 4x apples-to-oranges comparison. +- The benchmark is still worth shipping: "how fast is your Mac at emulating an + Indy" is the user-facing question, and the interpreter is what they have. +- If Cranelift is ever made `MAP_JIT`-aware, this reverses — which is another + reason entries carry an explicit `engine` field rather than an implied one. + +--- + +## What stays developer-only + +`iris-bench matrix` — building a separate emulator per cell is inherently a +source-checkout activity. Same for `--force-build`, the cell picker, and the +`bench/irix/` guest-OS suite (needs an IRIX image and a CI socket). + +The split is clean: **one embedded run** is a product feature, **comparing +builds** is a developer tool. + +--- + +## Rough shape of the effort + +| Phase | Crates touched | Size | Risk | +|---|---|---|---| +| P0 embeddability | `iris` | small | **the exit path is the one delicate piece** | +| P1 asset + CI | `iris`, workflows | small | low | +| P2 runner | `iris`, `iris-bench` | medium | low | +| P3 GUI screen | `iris-gui` | medium | low (design effort, not technical) | +| P4 host baseline | `iris` + build.rs | medium | build-system decision | +| P5 quick mode | `bench/`, `iris` | small | low | +| P6 sandbox/store | `iris-gui`, workflows | small | gated on the jitv2 question | + +P0 + P1 + P2 is the spine: at the end of it `iris-bench run` works with no +subprocess and no ELF on disk, and the GUI is a view over something already +proven. P3 onwards is additive. diff --git a/docs/jitv2_performance_analysis.md b/docs/jitv2_performance_analysis.md new file mode 100644 index 0000000..a3d6909 --- /dev/null +++ b/docs/jitv2_performance_analysis.md @@ -0,0 +1,284 @@ +# jitv2 performance — where to spend effort next + +Measured with `bench/` (`iris-bench matrix`) on 2026-08-21, Core i5-9500T, Linux +x86_64. Every number below is guest instructions retired per host second, from +the test device's `ICOUNT` register — the same counter in both engines, so the +ratios are real. + +The key table is **jitv2 vs interpreter with build flags held constant** (both +cells `lightning`, so opcodefusion and breakpoint removal cancel out). That +isolates what the JIT itself contributes: + +| kernel class | JIT contribution | +|---|---| +| integer ALU (`int/alu`, `muldiv`, `bitops`, `alu64`) | **5.2 – 6.7×** | +| imaging / codec | 2.6 – 5.1× | +| FP arithmetic (`fpu/scalar_d`, `scalar_s`, `divsqrt`) | **0.98 – 1.01× — nothing** | +| memory streaming (`mem/copy`, `stream_*`) | **0.75 – 0.86× — the JIT loses** | +| region-boundary kernels (`sys/cache_flush`, `sys/llsc`) | **0.67 – 0.71× — the JIT loses** | + +Headline cells for reference: + +| cell | guest MIPS | DMIPS | +|---|---:|---:| +| r4400-interp | 51.0 | 70.9 | +| r4400-lightning | 75.3 | 119.7 | +| r4400-jitv2 | 202.9 | 213.0 | +| r4400-jitv2-lightning | 234.1 | 299.6 | + +--- + +## Recommendations + +Confidence = probability the change produces a measurable net win on the kernels +named, given the code as it stands today. + +| # | Do this | Confidence | +|---|---|---:| +| **R0** | Add a JIT-coverage counter (retired instructions executed in compiled code / total) before touching anything else | **95%** | +| **R1** | Inline the memory fast path instead of `call_indirect` per access | **85%** | +| **R2a** | Guard regions on `FCSR enables == 0` and hoist the MXCSR round-trip out of per-instruction FP | **75%** | +| **R4** | Check the JIT gate *before* `fetch_instr`/decode in non-lightning builds | **80%** | +| **R3** | Enable interpreter-fallback region admission so `CACHE`/`LL`/`SC` stop ending regions | **70%** | +| **R5** | Coarsen the per-instruction preamble (masked-region elision; batch the cycle counter) | **60%** | +| **R2b** | Replace MXCSR entirely with analytic IEEE flag computation in IR | **55%** | + +Ruled out after checking: `opt_level` is already `speed` in non-`developer` +builds (`codegen.rs:277`), so this is not a "turn the optimizer on" problem. + +--- + +## High-level findings + +**FP gets exactly nothing from the JIT, and the reason is not dispatch.** Every +FP arithmetic emitter calls `emit_fpu_clear_status` (STMXCSR + LDMXCSR) before +the operation and `emit_fpu_update_fcsr` (STMXCSR, then another clear) after — +both as `call_indirect` through function pointers on `MipsCore`. Two LDMXCSR per +emulated FP instruction, each of which serializes the host FP pipeline. The +interpreter pays the same cost, which is why the ratio is 1.00×. FP kernels run +at 19–25 MIPS against 630 for integer ALU. Not an x86 quirk: the aarch64 path +(`platform.rs:160`, `177`) is the same shape with `mrs`/`msr fpsr`, so an Apple +Silicon host has the same problem. + +**On memory-streaming loops the JIT is a net loss.** `emit_mem_read` / +`emit_mem_write` load `jit_ctx`, load a function pointer, `call_indirect`, then +load and branch on `core.jit_mem_exc` — per access. The interpreter inlines the +same work: `read_data`, `write_data` and both `*_impl` bodies all carry +`#[inline]` (`mips_exec.rs:3215`, `3384`), so an interpreted `lw` has no call at +all where a compiled one has an indirect call it cannot inline through. When a +loop is more than half memory operations, that overhead exceeds the dispatch the +JIT saves. +`jit-v2-design.md` §3.3 already sanctions the fix — "compiled loads/stores call +**or inline the fast path of** the shared memory-access core" — the second half +was never built. + +**Region boundaries are expensive and more common than they need to be.** +`JR`/`JALR` always exit; `J`/`JAL` are treated as page-leaving and always exit; +`CACHE`, `LL`, `SC`, `SYSCALL`, `BREAK` are `Excluded`, and +`analyzer.rs:34 FALLBACK_ENABLED` defaults **off**, so an excluded word ends the +region rather than being admitted as an interpreter-fallback head — even though +`emit_interp_fallback_head` is written and working. A loop containing one LL/SC +pair therefore pays a full exit + re-entry every iteration. + +**Region re-entry is more expensive than it looks in non-lightning builds.** The +JIT gate in `exec_decoded` runs *after* `step()` has already done +`fetch_instr` — nanotlb translate, I-cache probe, and decode. Only `lightning` +builds get `jitv2_try_dispatch_without_decode`, which checks the gate first. +Measured cost of that difference: +15% overall, **+41% on Dhrystone**, up to ++58% on the most exit-heavy kernels. + +**The per-instruction preamble is a compiler barrier, not just three µops.** +`emit_pending_interrupt_preamble` emits a **sequentially-consistent +`atomic_load`** before every instruction, plus `emit_increment_cycles` +(load/add/store of `hot.cycles`). §5 of the design doc assumes "within-block +value forwarding comes largely free from Cranelift GVN/alias analysis" for the +memory-resident register file — a SeqCst load between every pair of instructions +is exactly what stops that. The design doc's own §3.2 already prices the +preamble at 30–50% of the per-unit budget and lists the coarsening roadmap; +item 1 (masked-region elision) is described there as unobservable and +strict-validate-safe. + +**Caveat on R5's measured upside.** `bench/` runs bare metal with `Status.IE` +clear throughout, so masked-region elision would apply to the *entire* suite. +Real IRIX userland runs unmasked. Expect the benchmark to overstate that +specific change; use `sys/*` and a real IRIX boot to sanity-check it. + +**What is not a problem.** R5000 vs R4400 under jitv2 is within a few percent +(202.9 vs 211.6 MIPS), so the 2-way L1 model is not a JIT-side cost. Accuracy is +100% (40/40 checksums) on every cell measured, including both lightning cells — +none of this is a correctness tradeoff being paid for speed. + +--- + +## Resume prompt + +Paste the block below into a fresh session to pick this up. It is written to be +self-contained. + +--- + +> You are continuing a performance investigation into `jitv2`, the Cranelift +> region compiler in this repo (`src/jitv2/`). Read `docs/jitv2_performance_analysis.md` +> (this file) and `rules/jitv2/jit-v2-design.md` first — especially §3.2 +> (interrupt sampling contract and its coarsening roadmap), §3.3 (exit stubs and +> the memory-helper ABI), §4.4 (excluded instructions) and §5 (memory-resident +> register state). +> +> **What is already established** (measured, not assumed — reproduce with +> `iris-bench matrix --cells r4400-lightning,r4400-jitv2-lightning`): +> with build flags held constant, jitv2 gives 5–6.7× on integer ALU kernels, +> 2.6–5.1× on imaging/codec, **1.00× on FP arithmetic**, **0.75–0.86× on memory +> streaming**, and **0.67–0.71× on `sys/cache_flush` and `sys/llsc`**. The last +> two classes are cases where turning the JIT on makes the guest slower. +> +> **Your job**, in this order: +> +> **Step 0 — instrument (R0).** There is no way today to tell "compiled but +> slow" from "never compiled": `j2 stats` (`src/mips_exec.rs:10459`) reports +> pages, functions compiled, arena bytes and mega-flushes, but no coverage. +> Add a counter of retired instructions executed inside compiled code — the +> natural place is alongside `emit_increment_cycles` (`codegen.rs:2387`), +> incrementing a second `MipsCore` field, with the ratio against `hot.cycles` +> reported by `j2 stats`. Then re-run the suite and record per-kernel JIT +> coverage. Everything below is much cheaper to evaluate once this exists, and +> several of the hypotheses may resolve immediately. +> +> **Step 1 — memory helper ABI (R1, highest expected value).** Read +> `emit_mem_read`/`emit_mem_write`/`emit_check_mem_exc` (`codegen.rs:2552`, +> `2592`, `2676`) and the wrappers they call (`jit_read32`/`jit_write32`, +> `mips_exec.rs:1189`, `1229`). Each access is: load `jit_ctx`, load a fn +> pointer, `call_indirect`, load `core.jit_mem_exc`, compare, branch. The +> interpreter inlines the same `read_data`/`write_data`. Emit the hit path +> inline in IR — the nanotlb probe and the cached-RAM hit — and keep the +> `call_indirect` only as the miss/MMIO/fault tail. §3.3 already sanctions this +> shape. Target kernels: `mem/copy`, `mem/stream_copy`, `mem/stream_scale`, +> `mem/stream_triad`; success is getting them above 1.0× against the +> interpreter, and the imaging/codec kernels should move too since they are +> load/store dense. +> +> **Step 2 — FP status handling (R2).** Read `emit_fpu_clear_status` +> (`codegen.rs:2754`), `emit_fpu_update_fcsr_with_inexact_override` +> (`codegen.rs:2996`), `emit_fbinop_d` (`codegen.rs:4854`), and +> `platform::x86_64::{get_fpu_status, clear_fpu_status}` (`src/platform.rs:82`, +> `99`). Two LDMXCSR per emulated FP instruction is the cost. +> Do **R2a first**: extend the existing region-wide FPU guard +> (`emit_fpu_entry_guard`, `codegen.rs:1712` — it already guards CU1 and FR the +> same way) with an "FCSR enable bits are all zero" condition. In that mode no +> FP instruction can trap, so Cause/Flag only need to be correct at the next +> *observation* point — a CFC1, an FP branch, or a region exit. Clear MXCSR once +> at region entry, accumulate, and write FCSR at the exit stub. R2b (compute +> V/Z/O/U analytically in IR and drop MXCSR entirely) is the bigger win but +> Inexact is genuinely hard; note that the emitters already compute subnormal +> and NaN predicates in IR (`emit_is_subnormal_or_qnan_d`, `codegen.rs:3251`), +> and `emit_round_and_convert` already overrides Inexact analytically, so the +> precedent exists. +> +> **Step 3 — dispatch shortcut (R4).** `jitv2_try_dispatch_without_decode` +> (`mips_exec.rs:2413`) is `cfg(all(jitv2, lightning, …))`. In non-lightning +> builds the gate in `exec_decoded` (`mips_exec.rs:6560`) only runs after +> `fetch_instr` has translated, probed the I-cache and decoded. Work out what +> actually blocks using the shortcut without `lightning` — the PC-breakpoint +> check is the obvious one; the I-cache probe is already sanctioned as skippable +> by §8.1 — and gate it on "no breakpoints armed" rather than on the feature. +> Measure with `--cells r4400-jitv2,r4400-jitv2-lightning`; the remaining gap +> after the change is the part that was really opcodefusion. +> +> **Step 4 — region admission (R3).** `FALLBACK_ENABLED` (`analyzer.rs:34`) +> defaults false, so an `Excluded` word ends the region. `j2 fallback on` flips +> it at runtime and `emit_interp_fallback_head` already implements the admitted +> path. This is nearly free to evaluate: turn it on, re-run, look at +> `sys/cache_flush` and `sys/llsc`. If it holds up under a real IRIX boot, +> consider defaulting it on. +> +> **Step 5 — preamble coarsening (R5, do last).** `emit_pending_interrupt_preamble` +> (`codegen.rs:1631`) emits a SeqCst `atomic_load` per instruction; +> `emit_increment_cycles` (`codegen.rs:2387`) emits a load/add/store per +> instruction. Two separable questions: (a) does the SeqCst load defeat +> Cranelift's alias analysis and block the store-to-load forwarding §5 assumes — +> test by switching to a plain `load` and measuring `int/alu`, which is the +> cleanest ALU-density signal; (b) can `hot.cycles` be batched per straight-line +> run — outside `ci_clock`, Count is virtual and wall-clock anchored, so +> `hot.cycles` is not the timer source, and an exit stub can add a +> statically-known constant. Then attempt roadmap item 1, masked-region elision. +> **Remember the benchmark runs with IE clear throughout**, so it will overstate +> that last one; cross-check against a real IRIX boot. +> +> **Guardrails — run all three before believing any result:** +> - `make -C bench && iris-bench matrix` — accuracy must stay 100% (40/40) on +> every cell. A checksum mismatch means the change altered guest-visible +> results. +> - `make -C cpu-tests && make -C cpu-tests run` — expect 2160 checks passed, 2 +> failed. The two failures are the known FPU-flag findings +> (`fpu/cvt_s_d_rounds`, `fpu/cvt_out_of_range`); anything else is new. This +> matters most for step 2 — the FP flag model is exactly what those tests +> cover. +> - `cargo test --release` — 387 lib tests, plus `src/jitv2/equiv_test.rs`, which +> is the JIT-vs-interpreter differential net. +> +> **Reporting.** Every claim should come with a number from `iris-bench`, not an +> expectation. Run-to-run variation on an idle machine is under 1.1%, so a 5% +> change is real and anything under 2% is noise. Write findings into +> `rules/jitv2/` as short notes, and update the table at the top of +> `docs/jitv2_performance_analysis.md` with what actually happened — including +> the recommendations that turned out to be wrong. + +--- + +## Appendix — full per-kernel JIT contribution + +`r4400-lightning` → `r4400-jitv2-lightning`, guest MIPS. Both cells are +`lightning`, so this is the JIT's own contribution with everything else equal. + +| kernel | interp | jitv2 | ratio | +|---|---:|---:|---:| +| sys/cache_flush | 73.0 | 48.7 | 0.67× | +| sys/llsc | 95.4 | 67.7 | 0.71× | +| mem/stream_copy | 12.0 | 9.0 | 0.75× | +| mem/stream_scale | 13.5 | 10.4 | 0.77× | +| sys/exception | 74.8 | 60.8 | 0.81× | +| mem/copy | 12.2 | 10.3 | 0.85× | +| mem/stream_triad | 15.5 | 13.2 | 0.86× | +| sys/tlb_miss | 27.5 | 24.2 | 0.88× | +| mem/random | 31.3 | 29.7 | 0.95× | +| fpu/scalar_s | 25.7 | 25.1 | 0.98× | +| fpu/divsqrt | 18.8 | 18.8 | 1.00× | +| fpu/scalar_d | 25.2 | 25.4 | 1.01× | +| mem/latency_dram | 9.2 | 9.4 | 1.02× | +| fpu/transcend | 26.9 | 31.3 | 1.17× | +| fpu/whetstone | 29.3 | 37.7 | 1.29× | +| fpu/linpack | 42.7 | 59.0 | 1.38× | +| mem/fill | 68.6 | 95.8 | 1.40× | +| sys/tlb_hit | 81.1 | 140.2 | 1.73× | +| mem/latency_l2 | 87.5 | 156.4 | 1.79× | +| fpu/matmul | 59.4 | 110.1 | 1.85× | +| int/dhrystone | 89.2 | 223.2 | 2.50× | +| img/convolve3x3 | 85.1 | 220.1 | 2.59× | +| mem/unaligned | 88.8 | 235.2 | 2.65× | +| img/histogram | 88.9 | 237.9 | 2.68× | +| mem/latency_l1 | 92.4 | 262.9 | 2.84× | +| img/composite | 94.8 | 279.1 | 2.94× | +| img/sharpen5x5 | 104.5 | 315.8 | 3.02× | +| img/rotate90 | 102.8 | 318.0 | 3.09× | +| codec/adler32 | 117.6 | 384.2 | 3.27× | +| vid/motion_est | 105.6 | 351.0 | 3.32× | +| codec/crc32 | 105.1 | 353.7 | 3.36× | +| img/resize | 94.6 | 324.0 | 3.42× | +| codec/lz | 96.3 | 336.7 | 3.50× | +| codec/huffman | 104.3 | 371.1 | 3.56× | +| sys/uncached | 102.9 | 370.7 | 3.60× | +| codec/rle | 95.2 | 349.5 | 3.67× | +| img/dither | 90.9 | 348.5 | 3.83× | +| img/rgb2ycbcr | 89.4 | 347.9 | 3.89× | +| int/branch | 99.3 | 460.5 | 4.64× | +| img/dct8x8 | 66.6 | 322.1 | 4.84× | +| vid/yuv2rgb | 92.2 | 471.6 | 5.11× | +| int/bitops | 125.3 | 656.0 | 5.23× | +| int/alu64 | 119.1 | 628.4 | 5.28× | +| int/alu_ilp | 116.2 | 655.3 | 5.64× | +| int/alu | 105.7 | 625.9 | 5.92× | +| int/muldiv | 101.0 | 673.2 | 6.66× | + +Relevant tunables, for the record: `MIN_CALLS_BEFORE_COMPILE = 4` +(`jitv2.rs:54`), `MAX_INSTRS_PER_COMPILE = 128` (`comp.rs:57`), +`FALLBACK_ENABLED = false` (`analyzer.rs:34`), `opt_level = speed` +(`codegen.rs:277`). diff --git a/rules/perf/bench-first-numbers.md b/rules/perf/bench-first-numbers.md new file mode 100644 index 0000000..e984f6f --- /dev/null +++ b/rules/perf/bench-first-numbers.md @@ -0,0 +1,127 @@ +# First benchmark matrix — 2026-08-21 + +The first complete run of `bench/`, kept as a baseline and for the four +findings it produced. Reproduce with `iris-bench matrix`; compare with +`iris-bench report --baseline r4400-interp`. + +Host: Intel Core i5-9500T @ 2.20 GHz, 6 cores, Linux x86_64. A modest desktop +CPU — the ratios below are what a laptop-class machine gives, not a workstation. + +| cell | accuracy | guest MIPS | DMIPS | LINPACK MFLOPS | +|---|---:|---:|---:|---:| +| r4400-interp | 100% (40/40) | 50.8 | 71.1 | 5.86 | +| r5000-interp | 100% (40/40) | 44.2 | 58.9 | 5.04 | +| r4400-jitv2 | 100% (40/40) | **201.8** | **214.1** | 10.08 | +| r5000-jitv2 | 100% (40/40) | 209.6 | 197.4 | 9.33 | + +**214 DMIPS with jitv2** puts an emulated Indy in the same range as the real +200 MHz R4400 it stands in for, on this host. The interpreter is about a third +of that. + +**Run-to-run variation is under 1%.** Two independent full matrix runs on an +otherwise idle machine agreed to within 1.1% on every headline figure (50.8 vs +51.0 MIPS, 201.8 vs 202.9, 214.1 vs 213.0 DMIPS). So a 5% change between +commits is real and worth chasing; anything under 2% is noise. That is what the +best-of-two-at-250 ms design buys, and it is why the CI job gates on accuracy +rather than on throughput — a shared runner has nothing like this floor. + +--- + +## 1. jitv2 buys nothing on floating point + +Guest MIPS, interpreter → jitv2 on R4400: + +| kernel | interp | jitv2 | | +|---|---:|---:|---| +| `int/alu` | 65.0 | 631.6 | **9.7x** | +| `int/muldiv` | 60.6 | 671.9 | 11.1x | +| `fpu/scalar_d` | 23.6 | 23.5 | **1.00x** | +| `fpu/scalar_s` | 24.0 | 24.3 | 1.01x | +| `fpu/divsqrt` | 18.3 | 18.6 | 1.02x | +| `fpu/linpack` | 34.2 | 58.8 | 1.7x | +| `fpu/matmul` | 43.2 | 95.4 | 2.2x | + +The three kernels that are *nothing but* FP arithmetic land within 2% of the +interpreter. The mixed ones (linpack, matmul) gain, and gain roughly in +proportion to how much integer and address arithmetic they carry. So the +compiled FP path costs the same as the interpreted one: dispatch is not what an +FP instruction spends its time on here, the FP semantics are, and jitv2 emits +the same semantics. + +FP emitters exist (`opcode_support.rs` asserts `Fadd_s` has one and the +per-kind toggle defaults on), so this is not a coverage gap. It is where the +next real FP speedup has to come from, and it is worth roughly 4x on +`fpu/scalar_*` before it would stop being the limiter. + +## 2. The emulated L2 costs 3-4x on memory-bound code + +Plain `r5k` reports `Config.SC = 1` (no secondary cache), so the L2 model is +bypassed entirely. That makes the R5000 cells a natural controlled experiment, +and the result is large: + +| kernel | r4400-interp | r5000-interp | | +|---|---:|---:|---| +| `mem/copy` | 10.8 MIPS | 41.9 MIPS | **3.9x** | +| `mem/stream_copy` | 9.6 | 34.3 | 3.6x | +| `mem/stream_scale` | 11.0 | 34.7 | 3.2x | +| `mem/latency_dram` | 10.0 | 24.7 | 2.5x | + +Everything else goes the other way — R5000 is ~20% *slower* on pure ALU work +(65.0 → 51.8 MIPS on `int/alu`), which is the price of the 2-way L1 probe on +every fetch. So the R5000 cells are not "faster"; they are the same machine +with the L2 model switched off, and switching it off is worth 3-4x on anything +that streams memory. + +HACKING.md already says "fully emulated L2 was a mistake in hindsight". This is +that sentence with a number on it. + +## 3. jitv2 makes memory-bound loops *slower* on R4400 + +On the L2-enabled configuration only: + +| kernel | interp | jitv2 | +|---|---:|---:| +| `mem/copy` | 10.8 | **9.1** | +| `mem/stream_copy` | 9.6 | **7.9** | +| `mem/stream_scale` | 11.0 | **9.2** | +| `mem/stream_triad` | 12.8 | **11.7** | + +A 10-15% regression, and it does not appear on R5000 (where the same kernels go +41.9 → 128.3), so it is an interaction with the L2 path rather than with the +loops themselves. Worth a look: these are the only kernels in the whole suite +where turning the JIT on costs throughput. + +## 4. Region boundaries cost what the design says they cost + +`CACHE` and `LL`/`SC` are architecturally excluded from jitv2 — deliberate +region boundaries, per `rules/jitv2/unsupported-instructions.md`. The suite +prices them: + +| kernel | interp | jitv2 | +|---|---:|---:| +| `sys/cache_flush` | 55.3 | 38.7 | +| `sys/llsc` | 57.5 | 42.8 | +| `sys/tlb_miss` | 24.8 | 21.8 | + +A 25-30% loss on code dominated by a boundary instruction. That is the expected +shape of the tradeoff rather than a bug, but it is the first time it has had a +number, and it says what a lock-heavy or cache-management-heavy guest workload +actually pays. + +--- + +## Two more things worth knowing + +**`sys/uncached` is 4x faster under jitv2** (69.6 → 289.2 MB/s) — uncached +KSEG1 reads go straight down the MC bus and the win there is all dispatch. + +**`img/rgb2ycbcr` and `img/composite` are ~2.5x slower on R5000-jitv2 than on +R4400-jitv2** (309 → 130 MIPS, 214 → 100 MIPS) while every other imaging kernel +is within 20%. Both are the byte-store-heaviest kernels in the group. Not +chased down. + +**Native ratio.** With jitv2 the emulator runs integer ALU work at about 1/6 of +this host's native rate, imaging kernels at 1/30 to 1/100, and FP at 1/150 or +worse. `vid/motion_est` reads as 1/499, but the host build almost certainly +vectorises that SAD loop into `psadbw`; treat the extreme end of that column as +a compiler comparison, not an emulator one. From ce6417684e72b9f78aeaecfbe5207c758f4a9b6e Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 21 Aug 2026 14:54:44 -0400 Subject: [PATCH 07/15] cpu-tests: stop burning most of a bare-metal run on a dead serial port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SCC transmitter is enabled by WR5, which the PROM programs — so an image loaded with --load-elf, which never runs the PROM, leaves it disabled. The four-byte holding queue fills, RR0.TX_BUFFER_EMPTY goes low and never comes back, and every character after the fourth burns the whole 100,000-iteration spin limit in scc_putc. Both suites print a line per test or per kernel, so this was not a rounding error. A full bench run, r4400 lightning: before 117 s wall, 12.5 s in timed regions, 40/40 accuracy after 46 s wall, 12.4 s in timed regions, 40/40 accuracy The entire 71-second difference was the guest waiting on a port with nothing on the other end. cpu-tests was paying the same tax; its results are unchanged (2160 checks passed, 2 failed, the same two pre-existing fpu/cvt_* findings). Latch the port off the first time the spin limit is exceeded, but only when a test device is present: that is the question that actually matters — is anything else reading this? With a test device the host reads that instead and serial is redundant; without one serial is the only sink there is, and a slow port beats no output. A PROM-booted run has a working SCC, never trips the limit, and is unaffected either way, which matters because run-prom.sh decides pass or fail by grepping the serial log. Co-Authored-By: Claude Opus 5 (1M context) --- cpu-tests/harness/console.c | 31 +++++++++++++++- .../scc-serial-output-from-bare-metal-code.md | 36 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/cpu-tests/harness/console.c b/cpu-tests/harness/console.c index ba705e9..d61fbdd 100644 --- a/cpu-tests/harness/console.c +++ b/cpu-tests/harness/console.c @@ -20,6 +20,9 @@ int have_testdev = 0; /* Bounded so a wedged SCC can't hang the whole suite. */ #define TX_SPIN_LIMIT 100000 +/* Latched once the SCC has proved it is not transmitting — see scc_putc. */ +static int scc_dead = 0; + void con_init(void) { /* The PROM leaves the console configured. Nothing to do; kept as a hook @@ -27,11 +30,34 @@ void con_init(void) * /12/13/14/3/5 itself. */ } +/* + * Write one byte to the serial console, giving up on the port for good once it + * has demonstrated that it is not going to transmit. + * + * The transmitter is enabled by WR5, which the PROM programs — so booting an + * image with `--load-elf` (no PROM, straight into RAM) leaves it disabled. The + * SCC's four-byte holding queue then fills, TX_BUFFER_EMPTY goes low and never + * comes back, and every subsequent character burns the whole spin limit. That + * is not a small cost: it was about 78 seconds of a 117-second bare-metal + * benchmark run, spent waiting on a port with nothing on the other end. + * + * Latching only when `have_testdev` is what keeps this from silently dropping + * output: with a test device the host is reading that instead and serial is + * redundant, and without one serial is the only sink there is, so a slow port + * still beats no output. A PROM-booted run has a working SCC, never trips the + * limit, and is unaffected either way — which matters, because run-prom.sh + * decides pass or fail by grepping the serial log. + */ static void scc_putc(int c) { int spins = 0; + + if (scc_dead) return; while (!(RD8(SCC_CHB_CMD) & SCC_RR0_TX_EMPTY)) { - if (++spins > TX_SPIN_LIMIT) return; + if (++spins > TX_SPIN_LIMIT) { + if (have_testdev) scc_dead = 1; + return; + } } WR8(SCC_CHB_DATA, (u8)c); } @@ -67,6 +93,9 @@ void con_flush(void) { int spins = 0; + /* Nothing was ever handed to the SCC, so there is nothing in flight. */ + if (scc_dead) return; + while (!(RD8(SCC_CHB_CMD) & SCC_RR0_TX_EMPTY)) { if (++spins > TX_SPIN_LIMIT) break; } diff --git a/rules/testing/scc-serial-output-from-bare-metal-code.md b/rules/testing/scc-serial-output-from-bare-metal-code.md index a332557..c723cf7 100644 --- a/rules/testing/scc-serial-output-from-bare-metal-code.md +++ b/rules/testing/scc-serial-output-from-bare-metal-code.md @@ -25,3 +25,39 @@ test-suite support. In order of how long each one costs you: output. Treat "no handshake within a second" as "reconnect", or use `--serial-log FILE`, which tees ttyd1 to a file and avoids the socket entirely — much the better option for a CI harness. + +## The same gate costs enormous *time*, not just output + +Point 2 above has a consequence nobody noticed for a long time: a bare-metal +image loaded with `--load-elf` never runs the PROM, so WR5.TX_ENABLE is never +set, so the SCC's four-byte holding queue fills and `RR0.TX_BUFFER_EMPTY` goes +low and never comes back. `cpu-tests/harness/console.c`'s `scc_putc` spins on +that bit with a `TX_SPIN_LIMIT` of 100,000 before giving up — **per character**. + +At interpreter speed that is roughly 5 ms of emulated spinning per byte. The +benchmark suite prints its table a row at a time and then a full machine-readable +block, several thousand characters in all: + +| | full `bench/` run, r4400 lightning | +|---|---| +| before | **117 s** | +| after | **46 s** | + +Same 40/40 accuracy, same timed totals (12.5 s vs 12.4 s) — the entire +difference was the guest waiting on a serial port with nothing on the other end. +`cpu-tests` was paying the same tax. + +The fix is in `scc_putc`: latch a `scc_dead` flag the first time the spin limit +is exceeded and stop trying. It is deliberately conditional on `have_testdev`, +because that is the question that actually matters — *is anything else reading +this?* With a test device the host reads that instead and serial is redundant; +without one serial is the only sink there is, and a slow port beats no output. A +PROM-booted run has a working SCC, never trips the limit, and is unaffected +either way — which matters, because `run-prom.sh` decides pass or fail by +grepping the serial log. + +**Watch for this shape generally.** Any bounded spin on a device bit that a +bare-metal image never enabled is a per-operation cost that looks like slow +emulation. If a workload's wall clock is far larger than the sum of what it +claims to have timed, suspect polling before suspecting the CPU: here the +benchmark's own `#totals ns=` said 12.5 s while the process took 117. From d9d5c4420b56cd4fa828525d89a5696c712a80c4 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 21 Aug 2026 14:55:07 -0400 Subject: [PATCH 08/15] bench: quick mode, a machine inventory, and any MIPS CPU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to the guest, plus the checked-in binary that lets the emulator run it without a cross toolchain. **A run configuration the host can set.** A bare-metal image loaded with --load-elf has no argv and no environment, so a new test-device register (TESTDEV_RUN_CONFIG) carries a group mask, a target-time percentage and a repeat count. Every field means "unrestricted" when zero — which is what an emulator predating the register returns — so the guest reads it unconditionally. Verified both ways: this image runs correctly on an emulator built before the register existed. Quick mode is time_pct=30, repeats=1: about half the wall clock for the same figures to within a couple of percent. It deliberately runs no fewer kernels — accuracy is what this suite exists to report, and a short run that quietly checked less would read the same 100% over less ground. The effective configuration is echoed back on a #run line so a shortened result can never be mistaken for a full one. **The machine, read out of the machine.** CPU identity and revision and the L1/L2 geometry from CP0 Config, the RAM banks from the memory controller's MEMCFG registers — which are valid with no PROM and no POST, since post_map_banks programs them exactly as POST would. Printed as a header and as #cache / #memory lines. This is provenance that matters: the mem/ kernels are a direct readout of the cache hierarchy, so two results with different L1 sizes are not measuring the same thing, and nothing in a stored result used to say so. The L2 *size* is reported as unknown rather than guessed — only a Triton encodes it, nothing distinguishes a Triton from a plain R5000 at runtime, and on an R4400 those bits mean something else entirely. **Any MIPS CPU is named, and runs.** The suite named only the R4400 and R5000 and refused everything else, on the stated grounds that "the golden checksums are selected by PRId". That was not true — golden.h is one flat CPU-independent table and no kernel is CPU-gated; the real mechanism was cpu_kind == 0 matching no kernel's CPU mask. It now names R4000/R4400 (split on revision, the standard rule), R4600, R4700, R4650, R4300, R5000, R8000, R10000, R12000, R14000, RM5200 and RM7000 from PRId, prints anything else as MIPS-imp-0xNN, and runs either way. Verified by presenting an R10000 and an unknown implementation to the guest: both identified correctly and scored 40/40. cpu-tests keeps its two-value CPU_* and its refusal, correctly — its tests are CPU-specific by construction. Also: an IRIS-BENCH-PLAN line announcing how many kernels will run, so a host driving the suite can size a progress bar without growing its own denominator; `make -C bench prebuilt` and the checked-in guest binary the emulator links in; and the platform assumptions of the shared harness written up once, marking which claims were tested and which are reasoning. Co-Authored-By: Claude Opus 5 (1M context) --- bench/Makefile | 22 +- bench/README.md | 132 +++++++++- bench/gen/hostplat.c | 13 + bench/harness/benchlib.c | 106 +++++++- bench/harness/benchlib.h | 86 +++++- bench/harness/main.c | 245 ++++++++++++++++-- bench/prebuilt/PROVENANCE | 3 + bench/prebuilt/README.md | 21 ++ bench/prebuilt/irisbench.elf | Bin 0 -> 299168 bytes cpu-tests/README.md | 22 ++ cpu-tests/harness/iris.h | 74 ++++++ ...bare-metal-harness-platform-assumptions.md | 120 +++++++++ 12 files changed, 808 insertions(+), 36 deletions(-) create mode 100644 bench/prebuilt/PROVENANCE create mode 100644 bench/prebuilt/README.md create mode 100755 bench/prebuilt/irisbench.elf create mode 100644 rules/testing/bare-metal-harness-platform-assumptions.md diff --git a/bench/Makefile b/bench/Makefile index 5550168..5793e5e 100644 --- a/bench/Makefile +++ b/bench/Makefile @@ -60,9 +60,29 @@ DEPS := $(OBJS:.o=.d) # instruction. If a helper reference ever appears the link fails loudly. LIBGCC := -.PHONY: all clean run dis syms image golden hostbench matrix bench report check-size +PREBUILT := prebuilt/irisbench.elf + +.PHONY: all clean run dis syms image golden hostbench matrix bench report check-size prebuilt all: $(TARGET) +# Refresh the copy of the guest binary that `iris` links in with include_bytes! +# (src/benchsuite.rs), so the benchmark runs on a machine with no MIPS cross +# toolchain — a released app, a sandboxed one, or anyone who just wants the +# number. Run this after changing anything the guest is built from and commit +# the result alongside the source change: CI rebuilds it and fails on a diff, +# because a stale image against fresh goldens reports accuracy failures that +# are not real. +prebuilt: $(TARGET) + @mkdir -p prebuilt + cp $(TARGET) $(PREBUILT) + @python3 -c "import hashlib,os; \ + h=lambda p: hashlib.sha256(open(p,'rb').read()).hexdigest(); \ + open('prebuilt/PROVENANCE','w').write( \ + '# What the checked-in guest binary hashes to. See README.md.\n' + \ + 'irisbench.elf sha256 %s %d bytes\n' % (h('$(PREBUILT)'), os.path.getsize('$(PREBUILT)')) + \ + 'golden/golden.h sha256 %s %d bytes\n' % (h('golden/golden.h'), os.path.getsize('golden/golden.h')))" + @echo "prebuilt: $(PREBUILT) refreshed — commit it with the change that caused it" + $(BUILD)/%.o: harness/%.c @mkdir -p $(dir $@) $(CC) $(CFLAGS) -MMD -MP -c -o $@ $< diff --git a/bench/README.md b/bench/README.md index 1c5c6f6..c532db3 100644 --- a/bench/README.md +++ b/bench/README.md @@ -21,8 +21,15 @@ make -C bench hostbench # build the host baseline make -C bench bench # run once against ../target/release/iris make -C bench matrix # build and run every CPU x engine cell make -C bench report # turn the saved results into markdown +make -C bench prebuilt # refresh the guest binary that `iris` links in ``` +**You do not need any of this to run the suite.** A known-good guest binary is +checked in at `prebuilt/` and linked into `iris` with `include_bytes!`, so +`iris-bench run` and the GUI's Benchmark tab work on a machine with no MIPS +cross toolchain at all. The Makefile is for changing the suite, not for using +it — see [The checked-in guest binary](#the-checked-in-guest-binary). + --- ## What it measures @@ -206,16 +213,107 @@ has a real printf. --- +## What the machine says it is + +Before it measures anything, the suite reads the machine out of the machine and +prints it: + +``` + IRIS benchmark suite + CPU R4400 rev 4.0 (PRId 0x00000440) + FPU R4010 rev 0.0 (FIR 0x00000500) + L1 cache 16 KB I (16 B lines) / 16 KB D (16 B lines) + L2 cache present, 128 B lines, size not reported by the architecture + Memory 256 MB bank0 128 MB @ 0x08000000 bank1 128 MB @ 0x10000000 + Board SYSID 0x00000013 Config 0x00c08483 + Devices test device yes, host time base yes + Work area 0x88300000 .. 0x89b00000 (24 MB) + CP0 Count 33.000 MHz (measured against the host clock) +``` + +Every line comes from the hardware, not from what the runner was told: CP0 +Config for the CPU identity and the cache geometry, the memory controller's +MEMCFG registers for the bank layout. That works with no PROM and no POST, +because `--load-elf` programs MEMCFG exactly as POST would before the image +starts — so this is as true of a bare-metal run as of a PROM-booted one. + +It is provenance, not decoration. The `mem/` kernels are a direct readout of +the cache hierarchy, so two results whose L1 sizes differ are not measuring the +same thing however close their rates look — and until this existed, nothing in +a saved result said so. The same fields go into the machine block as `#cache` +and `#memory`, so every stored result carries them. + +**The L2 size is reported as unknown, deliberately.** Only a Triton R5000 +encodes it (Config TR_SS), there is no runtime way to tell a Triton from a plain +R5000, and on an R4400 those same bits mean something else entirely — so +decoding them would invent a 512 KB cache out of two zero bits. The PROM knows +the real size because it reads the EEPROM; the architecture does not expose it. + +--- + +## The checked-in guest binary + +`prebuilt/irisbench.elf` is a copy of a known-good `build/irisbench.elf`, +checked in and linked into `iris` with `include_bytes!` (`src/benchsuite.rs`). +It is what makes the benchmark a product feature rather than a developer tool: +a released application has no cross toolchain, and a sandboxed one has no +writable path to unpack an image to either. + +**Refresh it with `make -C bench prebuilt` whenever you change anything the +guest is built from** — the kernels, `harness/`, `cpu-tests/harness/`, the link +script, the compiler flags — and commit it alongside the source change. +`.github/workflows/bench.yml` rebuilds it and fails on any difference, because +this is a build product that drifts dangerously: accuracy is scored against +golden checksums compiled *into* the image, so a stale image against fresh +goldens reports failures to users that are not real. + +--- + +## Asking for a shorter run + +A bare-metal image loaded with `--load-elf` has no argv and no environment, so +the host leaves its request in a test-device register the guest reads at +startup (`TESTDEV_RUN_CONFIG`, `src/testdev.rs`): + +``` + 31 16 15 12 11 0 + +---------------+-------+---------------+ + | groups |repeats| time_pct | + +---------------+-------+---------------+ +``` + +Every field means "unrestricted" when zero — which is what an emulator +predating the register returns — so the guest reads it unconditionally and an +older emulator simply runs everything. `iris-bench run --quick` sets +`time_pct=30, repeats=1`: about half the wall clock, the same numbers to within +a couple of percent. + +**Quick mode never runs fewer kernels.** Accuracy is the number this suite +exists to report, and a short run that quietly checked less would report the +same 100% over less ground. It gives up measurement precision and nothing else. +The effective configuration is echoed back on the `#run` line and stored on +every result, so a shortened run cannot be mistaken for a full one — +`iris-bench reference` refuses to put one in the reference table. + +--- + ## iris-bench ``` -iris-bench run [--iris PATH] [--elf PATH] [--label NAME] +iris-bench run [--quick] [--label NAME] [--iris PATH [--elf PATH]] iris-bench host # measure this machine with the same kernels iris-bench matrix [--cells r4400-interp,r5000-jitv2] [--force-build] iris-bench report [--baseline CELL] [--format md|json|text] +iris-bench reference --id ID [--into data/bench_reference.json] iris-bench cells # what matrix knows how to build ``` +`run` is **in-process by default**: `iris-bench` is itself an emulator and the +guest image is linked into it, so there is no subprocess, no ELF on disk and +nothing platform-specific. Pass `--iris` to measure a *different* emulator +binary in a subprocess instead — which is what `matrix` does, and what CI does +for each cell, because a cell's features live in its binary. + `matrix` builds a **separate emulator per cell**, because the CPU model and the JIT are compile-time cargo features and there is no runtime switch to flip: @@ -294,9 +392,34 @@ reference number that is not an emulator's opinion of itself. There is no test device on real hardware, so results come back over serial with CP0 Count as the time base; the header says so. -**Filtering.** There is no runtime selector — a bare-metal binary loaded with -`--load-elf` has nowhere to take arguments from. Comment out a group in -`harness/groups.c` and rebuild, or filter in the report. +**On which SGI machines?** The suite identifies any MIPS CPU by name from PRId +— R4000, R4400, R4600, R4700, R5000, R8000, R10000, R12000, R14000, RM5200, +RM7000 — and an implementation it has no name for prints as `MIPS-imp-0xNN` +rather than refusing. That is safe to do because **nothing here is +CPU-specific**: the kernels are ordinary compiled MIPS III, and `golden.h` is +one flat table computed natively rather than a per-CPU one, so an unfamiliar +CPU is a labelling problem and not a correctness one. (cpu-tests is the +opposite, and keeps its two-value `CPU_*` for exactly that reason.) + +What is *not* portable off an Indy or Indigo2 is the machine around the CPU: +the image links and self-relocates to a fixed address chosen because IP22/IP24 +RAM begins at `0x08000000`, the console is the SCC via IOC2 at `0x1FBD9800`, +and the memory inventory comes from the MC at `0x1FA00000`. The load address is +what stops it first, not the console. + +So: **any CPU an Indy or Indigo2 will take, on real hardware or emulated.** +Another SGI family needs a small platform layer, and the sane way to write one +is against ARCS firmware rather than per-machine drivers — that is one path for +the whole family, and it can be developed under emulation because IRIS runs a +real PROM. + +Full detail, including which parts of that were tested and which are reasoning: +[`rules/testing/bare-metal-harness-platform-assumptions.md`](../rules/testing/bare-metal-harness-platform-assumptions.md). +None of it touches the measurement, which is already platform-independent. + +**Filtering.** `--quick` aside, there is no runtime kernel selector beyond the +group mask in `TESTDEV_RUN_CONFIG`, and that needs a host to set it. Comment +out a group in `harness/groups.c` and rebuild, or filter in the report. --- @@ -309,6 +432,7 @@ harness/ benchlib (time base, work area, checksums), main (the runner), kernels/ integer fpu memory imaging codec sys gen/ golden.c (the oracle) + hostplat.c (the host platform layer) golden/ golden.h — generated, checked in +prebuilt/ irisbench.elf — generated, checked in, linked into `iris` run/ bare.toml, run-local.sh, run-prom.sh ``` diff --git a/bench/gen/hostplat.c b/bench/gen/hostplat.c index 3fa3406..00808aa 100644 --- a/bench/gen/hostplat.c +++ b/bench/gen/hostplat.c @@ -27,6 +27,19 @@ u32 cpu_kind, cpu_prid, cpu_fir, cpu_config; int have_l2; int have_timebase = 1; int have_testdev; +/* + * No CP0 and no memory controller to read an inventory out of, and no portable + * way to get one: /proc/cpuinfo, sysctl and GetSystemInfo are three different + * answers on three platforms and none of them is what this suite is for. Left + * zeroed, and print_inventory says so rather than showing a machine of zeroes. + */ +struct hwinv hw; +void bench_probe_hw(void) { } +/* No test device to read a configuration from: the golden generator and the + * native baseline always run everything, full length. */ +u32 bench_groups = BG_ALL; +u32 bench_time_pct = 100; +u32 bench_repeats = BENCH_REPEATS_DEFAULT; u64 count_hz_measured = 1000000000ull; /* the host clock is the time base */ void work_reset(void) { pool_used = 0; } diff --git a/bench/harness/benchlib.c b/bench/harness/benchlib.c index 0cb43e2..df02250 100644 --- a/bench/harness/benchlib.c +++ b/bench/harness/benchlib.c @@ -7,6 +7,10 @@ u32 cpu_kind, cpu_prid, cpu_fir, cpu_config; int have_l2; int have_timebase; +struct hwinv hw; +u32 bench_groups = BG_ALL; +u32 bench_time_pct = 100; +u32 bench_repeats = BENCH_REPEATS_DEFAULT; u64 count_hz_measured = BENCH_COUNT_HZ_ASSUMED; unsigned char *work; @@ -96,6 +100,101 @@ static int probe_timebase(void) return b > a; } +/* + * Read the machine out of the machine. + * + * CP0 Config carries the L1 geometry architecturally: IC/DC are log2 sizes + * biased by 12, IB/DB pick a 16- or 32-byte line, SC says whether a secondary + * cache exists (0 = present, inverted from what you would expect) and SB gives + * its line size. What Config does *not* carry on an R4400 or a non-Triton + * R5000 is the L2 *size* — the PROM reads that from the EEPROM — so it is + * reported as unknown rather than guessed at. + * + * The RAM layout comes from the memory controller's MEMCFG registers, which + * are populated whether or not the PROM ran: POST programs them on a real + * boot, and IRIS's post_map_banks does the same before an --load-elf image + * starts. Reads go through KSEG1 so they bypass the caches, like every other + * device access in this suite. + */ +void bench_probe_hw(void) +{ + unsigned i; + u32 cfg; + + hw.prid = cpu_prid; + hw.fir = cpu_fir; + hw.config = cfg = cpu_config; + hw.sysid = RD32(MC_SYSID); + + hw.cpu_rev_major = (hw.prid >> 4) & 0xF; + hw.cpu_rev_minor = hw.prid & 0xF; + hw.fpu_imp = (hw.fir >> 8) & 0xFF; + hw.fpu_rev_major = (hw.fir >> 4) & 0xF; + hw.fpu_rev_minor = hw.fir & 0xF; + + /* size = 2^(12 + field) */ + hw.l1i_bytes = 1u << (12 + ((cfg >> CFG_IC_SHIFT) & 0x7)); + hw.l1d_bytes = 1u << (12 + ((cfg >> CFG_DC_SHIFT) & 0x7)); + hw.l1i_line = (cfg & CFG_IB) ? 32 : 16; + hw.l1d_line = (cfg & CFG_DB) ? 32 : 16; + + hw.l2_present = (cfg & CFG_SC) == 0; + hw.l2_line = hw.l2_present ? (4u << ((cfg >> CFG_SB_SHIFT) & 0x3)) * 4u : 0; + /* + * Deliberately not decoded from Config, and deliberately not guessed. + * + * Only Triton encodes an L2 size, in TR_SS (bits 21:20) — and there is no + * runtime way to tell a Triton from a plain R5000, both of which report + * PRId imp 0x23. On an R4400 those same bits are SS (split-cache mode) and + * SW (port width), so reading them as a size invents a 512 KB L2 out of two + * zero bits. The PROM knows the real figure because it reads the EEPROM; + * the architecture does not expose it, so this stays 0 and the report says + * "size not reported" instead of lying with a plausible number. + */ + hw.l2_bytes = 0; + + hw.ram_mb = 0; + hw.banks = 0; + for (i = 0; i < 4; i++) { + u32 word = RD32(i < 2 ? MC_MEMCFG0 : MC_MEMCFG1); + u32 half = (i & 1) ? (word & 0xFFFFu) : ((word >> 16) & 0xFFFFu); + hw.bank_mb[i] = 0; + hw.bank_base[i] = 0; + if (!MEMCFG_VALID(half)) continue; + hw.bank_mb[i] = MEMCFG_MB(half); + hw.bank_base[i] = MEMCFG_BASE(half); + hw.ram_mb += hw.bank_mb[i]; + hw.banks++; + } +} + +/* + * Take the host's run configuration, if it left one. + * + * Guarded the same way probe_timebase() is: an emulator that decodes only the + * first 16 bytes of the device aliases every register back to SIGNATURE, so a + * CAPS read that returns the magic means "no capabilities register", not "every + * capability". Without CAP_RUN_CONFIG the defaults stand. + */ +static void read_run_config(void) +{ + u32 caps, w, pct; + + if (!have_testdev) return; + caps = RD32(TESTDEV_CAPS); + if (caps == TESTDEV_MAGIC) return; + if (!(caps & TESTDEV_CAP_RUN_CONFIG)) return; + + w = RD32(TESTDEV_RUN_CONFIG); + if (TESTDEV_RC_GROUPS(w)) bench_groups = TESTDEV_RC_GROUPS(w) & BG_ALL; + if (TESTDEV_RC_REPEATS(w)) bench_repeats = TESTDEV_RC_REPEATS(w); + + /* A target of zero would make every kernel run its base iteration count and + * measure nothing but timer granularity, so clamp rather than trust. */ + pct = TESTDEV_RC_TIME_PCT(w); + if (pct) bench_time_pct = pct < BENCH_TIME_PCT_MIN ? BENCH_TIME_PCT_MIN : pct; +} + /* * Measure the CP0 Count rate against the host clock. * @@ -237,15 +336,20 @@ void bench_init(void) cpu_prid = cp0_prid(); cpu_config = cp0_config(); cpu_fir = fir(); + /* Only two CPUs get their own bit, because only two of them are ever asked + * a CPU-specific question. Everything else is MIPS III and runs the same + * kernels against the same goldens — see BCPU_OTHER. */ switch (PRID_IMP(cpu_prid)) { case IMP_R4400: cpu_kind = BCPU_R4400; break; case IMP_R5000: cpu_kind = BCPU_R5000; break; - default: cpu_kind = 0; break; + default: cpu_kind = BCPU_OTHER; break; } have_l2 = (cpu_config & CFG_SC) == 0; testdev_probe(); have_timebase = have_testdev && probe_timebase(); + read_run_config(); + bench_probe_hw(); exc_clear(); exc_install(); diff --git a/bench/harness/benchlib.h b/bench/harness/benchlib.h index 4ae7e9c..17d0166 100644 --- a/bench/harness/benchlib.h +++ b/bench/harness/benchlib.h @@ -132,6 +132,72 @@ void *memmove(void *dst, const void *src, unsigned long n); int memcmp(const void *a, const void *b, unsigned long n); #endif +/* ── machine inventory ────────────────────────────────────────────────────── */ + +/* + * What the machine actually is, at the moment the suite runs. + * + * Every field comes from the hardware itself — CP0 Config for the cache + * geometry, the memory controller's MEMCFG registers for the bank layout — so + * this is as true under `--load-elf` (no PROM, no POST) as it is on a + * PROM-booted or real machine. Nothing here is passed in by the host or taken + * from a build-time constant, which is the point: a result that recorded what + * the *runner* believed rather than what the guest found would be worth + * nothing when the two disagreed, and the disagreements are the interesting + * cases. + * + * It matters for comparison, not just for display. The `mem/` kernels are a + * direct readout of the cache hierarchy, so two results from machines with + * different L1 sizes are not measuring the same thing — and until this existed, + * nothing in a saved result said so. + */ +struct hwinv { + u32 prid, fir, config, sysid; + u32 cpu_rev_major, cpu_rev_minor; /* PRId 7:4 / 3:0 */ + u32 fpu_imp, fpu_rev_major, fpu_rev_minor; + u32 l1i_bytes, l1i_line; + u32 l1d_bytes, l1d_line; + int l2_present; + u32 l2_line; + /* 0 when the architecture does not report it — true on the R4400 and on a + * non-Triton R5000, where the PROM reads the size out of the EEPROM + * instead. Reported as unknown rather than guessed. */ + u32 l2_bytes; + u32 ram_mb; /* total across valid banks */ + u32 bank_mb[4], bank_base[4]; + unsigned banks; /* count of valid banks */ +}; + +extern struct hwinv hw; + +/* Fill `hw`. Called by bench_init(); safe to call before the work area exists. */ +void bench_probe_hw(void); + +/* ── run configuration ────────────────────────────────────────────────────── */ + +/* + * What the host asked for, read once at startup from TESTDEV_RUN_CONFIG — the + * only channel there is, since a bare-metal image loaded with --load-elf has no + * argv and no environment. All three are set to their defaults when the host + * asked for nothing, which is also what an emulator without the register gives + * us, so nothing downstream needs to know whether it was there. + * + * Note what is *not* configurable: whether a kernel verifies itself. Accuracy + * is scored against golden checksums compiled into this binary, and a shorter + * run that quietly checked less would report the same 100% while covering less + * ground. Only the timed measurement gets cheaper. + */ +extern u32 bench_groups; /* BG_* mask of groups to run; BG_ALL by default */ +extern u32 bench_time_pct; /* per-kernel target time, percent of default */ +extern u32 bench_repeats; /* timed passes per kernel */ + +/* Best of two. The slow sample is host scheduling noise, not the emulator: the + * guest performs a fixed amount of work either way. */ +#define BENCH_REPEATS_DEFAULT 2 +/* Below this the ~30 ns Count granularity and the two uncached device reads on + * each side of a timed region stop being noise and start being the measurement. */ +#define BENCH_TIME_PCT_MIN 10 + /* ── benchmark registration ───────────────────────────────────────────────── */ #define BG_INT 0x01 @@ -142,10 +208,26 @@ int memcmp(const void *a, const void *b, unsigned long n); #define BG_SYS 0x20 #define BG_ALL 0x3F -/* CPU applicability, same convention as cpu-tests. */ +/* + * CPU applicability. Same first two bits as cpu-tests, plus one that suite + * deliberately does not have. + * + * BCPU_OTHER covers every MIPS III-or-later CPU that is not one of the two the + * emulator models — an R4000, R4600, R8000, R10000, a real machine's RM7000. + * Those can run this suite and get a *meaningful* score, because unlike + * cpu-tests nothing here is CPU-specific: the kernels are ordinary compiled + * MIPS III, and golden.h is one flat table computed natively rather than a + * per-CPU one. So an unrecognised CPU is a labelling problem, not a + * correctness problem, and the suite runs and says what it found. + * + * cpu-tests is the opposite and keeps its own two-value CPU_* — its tests + * check R4400-versus-R5000 behaviour by construction, so "some other CPU" + * genuinely has no expected answer there. + */ #define BCPU_R4400 0x1 #define BCPU_R5000 0x2 -#define BCPU_ALL (BCPU_R4400 | BCPU_R5000) +#define BCPU_OTHER 0x4 +#define BCPU_ALL (BCPU_R4400 | BCPU_R5000 | BCPU_OTHER) /* A kernel that is *supposed* to take exceptions. Everything else taking one * is a bug: the shared dispatcher records and steps over the faulting diff --git a/bench/harness/main.c b/bench/harness/main.c index e427b40..75adb4b 100644 --- a/bench/harness/main.c +++ b/bench/harness/main.c @@ -17,14 +17,20 @@ /* How long one timed run should take, in host nanoseconds. Long enough that * the ~30 ns Count granularity and the two uncached device reads on each side - * are noise; short enough that ~50 kernels x REPEATS still finishes in about a - * minute even on the interpreter. */ + * are noise; short enough that ~50 kernels x repeats still finishes in about a + * minute even on the interpreter. + * + * The host can scale this down (bench_time_pct) for a run that wants an answer + * sooner than it wants a precise one — see TESTDEV_RUN_CONFIG. Everything below + * derives from target_ns() rather than the constant, so there is one place the + * scaling applies. */ #define TARGET_NS 250000000ull -#define MIN_NS (TARGET_NS / 2) -#define REPEATS 2 #define MAX_CAL 4 #define MAX_ITERS 0x40000000u +static u64 target_ns(void) { return TARGET_NS * (u64)bench_time_pct / 100ull; } +static u64 min_ns(void) { return target_ns() / 2; } + struct result { const struct bench *b; u32 iters; @@ -120,7 +126,7 @@ static const struct golden_entry *find_golden(const char *name) return 0; } -/* Grow the iteration count until one run lands near TARGET_NS. Deliberately +/* Grow the iteration count until one run lands near target_ns(). Deliberately * conservative: a x64 cap per step keeps a first run that happened to be * absurdly quick (a kernel the JIT compiled instantly) from jumping straight * to an iteration count that then takes a minute. */ @@ -139,10 +145,10 @@ static u32 calibrate(const struct bench *b) tstamp(&t1); ns = elapsed_ns(&t0, &t1); - if (ns >= MIN_NS) return iters; + if (ns >= min_ns()) return iters; if (ns == 0) ns = 1; - scaled = (u64)iters * TARGET_NS / ns; + scaled = (u64)iters * target_ns() / ns; if (scaled > (u64)iters * 64) scaled = (u64)iters * 64; if (scaled <= iters) scaled = (u64)iters * 2; if (scaled > MAX_ITERS) { return MAX_ITERS; } @@ -161,6 +167,7 @@ static void run_one(const struct bench *b) r->exc = 0; r->sum = 0; r->gold = 0; r->status = R_UNCHECKED; if (!(b->cpus & cpu_kind)) { r->status = R_SKIP; return; } + if (!(b->group & bench_groups)) { r->status = R_SKIP; return; } /* Accuracy first, and from its own fixed workload — the timed loop runs a * host-dependent number of iterations, so a checksum taken from it would @@ -178,7 +185,7 @@ static void run_one(const struct bench *b) r->iters = calibrate(b); - for (rep = 0; rep < REPEATS; rep++) { + for (rep = 0; rep < (int)bench_repeats; rep++) { struct tstamp t0, t1; u64 work, ns; @@ -198,8 +205,8 @@ static void run_one(const struct bench *b) * fastest. */ if (exc_taken > r->exc) r->exc = exc_taken; - /* Best of REPEATS. The slow samples are host scheduling noise, not the - * emulator: the guest is a fixed amount of work either way. */ + /* Best of bench_repeats. The slow samples are host scheduling noise, + * not the emulator: the guest is a fixed amount of work either way. */ if (rep == 0 || ns < r->ns) { r->ns = ns; r->work = work; @@ -211,14 +218,161 @@ static void run_one(const struct bench *b) /* ── reporting ────────────────────────────────────────────────────────────── */ +/* + * The CPU by name, from PRId alone — so this is right on any MIPS machine, + * not only on the two the emulator models. The revision split on imp 0x04 is + * the standard one: an R4000 and an R4400 report the same implementation and + * are distinguished by major revision. + * + * An imp we do not have a name for still runs; it just prints as "MIPS imp + * 0xNN", which is more use to whoever is holding that machine than "unknown". + */ static const char *cpu_name(void) { #if defined(BENCH_HOST) return "host"; #else - if (cpu_kind == BCPU_R4400) return "R4400"; - if (cpu_kind == BCPU_R5000) return "R5000"; - return "unknown"; + static char other[20]; + + switch (PRID_IMP(cpu_prid)) { + case IMP_R4000: return PRID_REV_MAJOR(cpu_prid) >= 4 ? "R4400" : "R4000"; + case IMP_R10000: return "R10000"; + case IMP_R4300: return "R4300"; + case IMP_R12000: return "R12000"; + case IMP_R14000: return "R14000"; + case IMP_R8000: return "R8000"; + case IMP_R4600: return "R4600"; + case IMP_R4700: return "R4700"; + case IMP_R4650: return "R4650"; + case IMP_R5000: return "R5000"; + case IMP_RM7000: return "RM7000"; + case IMP_RM5200: return "RM5200"; + default: { + /* No snprintf here — freestanding. Build "MIPS-imp-0xNN" by hand. + * + * No spaces, deliberately: this string is emitted as the value of + * `cpu=` in the machine block, which is parsed by splitting on + * whitespace and then on '='. A name with a space in it silently + * truncates to its first word — "MIPS imp 0xab" parsed as cpu="MIPS" + * — so every name this function can return must be a single token. */ + static const char hexd[] = "0123456789abcdef"; + u32 imp = PRID_IMP(cpu_prid); + const char *p = "MIPS-imp-0x"; + int i = 0; + while (*p) other[i++] = *p++; + other[i++] = hexd[(imp >> 4) & 0xF]; + other[i++] = hexd[imp & 0xF]; + other[i] = 0; + return other; + } + } +#endif +} + +/* Kernels that will actually run, after the CPU and group filters. */ +static unsigned planned_benches(void) +{ + unsigned gi, bi, n = 0; + + for (gi = 0; gi < n_bgroups; gi++) { + const struct bench_group *g = all_bgroups[gi]; + for (bi = 0; bi < g->count; bi++) { + const struct bench *b = &g->benches[bi]; + if ((b->cpus & cpu_kind) && (b->group & bench_groups)) n++; + } + } + return n; +} + +/* The FPU by name where the implementation number is one we know. An R4400 + * carries an R4010-generation FPU reporting imp 0x05; on an R5000 the FPU is + * on-chip and reports the CPU's own 0x23. */ +static const char *fpu_name(void) +{ + switch (hw.fpu_imp) { + case 0x05: return "R4010"; + case 0x23: return "R5000 (on-chip)"; + default: return "unknown"; + } +} + +/* Print one size the way a person reads it: KB under a megabyte, MB above. */ +static void print_bytes(u32 n) +{ + if (n == 0) { con_puts("none"); return; } + if (n >= 1024u * 1024u) { con_udec(n >> 20); con_puts(" MB"); return; } + if (n >= 1024u) { con_udec(n >> 10); con_puts(" KB"); return; } + con_udec(n); con_puts(" B"); +} + +/* + * What this machine is, read out of the machine — see `struct hwinv`. + * + * Printed before anything is measured, because it is the context every number + * below is only meaningful in: the mem/ kernels are a direct readout of the + * cache hierarchy, and two runs whose L1 sizes differ are not measuring the + * same thing however similar the rates look. + */ +static void print_inventory(void) +{ + unsigned i; + +#if defined(BENCH_HOST) + /* The host build has no CP0 and no memory controller, and there is no + * portable substitute worth pretending with — see hostplat.c. */ + con_puts(" CPU the host, natively (no MIPS inventory)\n"); + return; +#else + con_printf(" CPU %s rev %u.%u (PRId %x)\n", + cpu_name(), hw.cpu_rev_major, hw.cpu_rev_minor, hw.prid); + con_printf(" FPU %s rev %u.%u (FIR %x)\n", + fpu_name(), hw.fpu_rev_major, hw.fpu_rev_minor, hw.fir); + + con_puts(" L1 cache "); + print_bytes(hw.l1i_bytes); + con_printf(" I (%u B lines) / ", hw.l1i_line); + print_bytes(hw.l1d_bytes); + con_printf(" D (%u B lines)\n", hw.l1d_line); + + con_puts(" L2 cache "); + if (!hw.l2_present) { + con_puts("absent\n"); + } else { + con_printf("present, %u B lines, ", hw.l2_line); + /* See bench_probe_hw: the architecture does not expose this. */ + con_puts("size not reported by the architecture\n"); + } + + con_puts(" Memory "); + if (hw.banks == 0) { + /* Only reachable if the MC was never programmed — neither POST nor + * post_map_banks ran — which would mean the image is executing out of + * unmapped RAM and has bigger problems than its report. */ + con_puts("no valid banks in MEMCFG\n"); + } else { + print_bytes(hw.ram_mb << 20); + con_puts(" "); + for (i = 0; i < 4; i++) { + if (hw.bank_mb[i] == 0) continue; + con_printf(" bank%u ", i); + print_bytes(hw.bank_mb[i] << 20); + con_printf(" @ %x", hw.bank_base[i]); + } + con_puts("\n"); + } + + con_printf(" Board SYSID %x", hw.sysid); + con_printf(" Config %x\n", hw.config); + + /* Said once, here, rather than left for a reader to infer from a name they + * do not recognise. The score is still meaningful — no kernel is + * CPU-specific and the goldens are computed natively — so this is a note, + * not a warning. */ + if (cpu_kind == BCPU_OTHER) { + con_puts(" (not a CPU this build models; the kernels and the\n" + " golden checksums are CPU-independent, so the score\n" + " still means what it says)\n"); + } #endif } @@ -226,20 +380,30 @@ static void print_header(void) { con_puts("\n"); con_puts("============================================================\n"); - con_printf(" IRIS benchmark suite cpu=%s\n", cpu_name()); - con_printf(" PRId %x FIR %x Config %x L2 %s\n", - cpu_prid, cpu_fir, cpu_config, have_l2 ? "yes" : "no"); - con_printf(" test device %s host time base %s\n", + con_puts(" IRIS benchmark suite\n"); + print_inventory(); + con_printf(" Devices test device %s, host time base %s\n", have_testdev ? "yes" : "no", have_timebase ? "yes" : "NO (CP0 Count)"); - con_printf(" work area %x", (u32)(unsigned long)work); + con_printf(" Work area %x", (u32)(unsigned long)work); con_printf(" .. %x (", (u32)(unsigned long)work + work_bytes); con_udec(work_bytes >> 20); con_puts(" MB)\n"); - con_puts(" CP0 Count "); + con_puts(" CP0 Count "); con_fixed(count_hz_measured, 1000000ull, 3, 1); con_puts(" MHz "); con_puts(have_timebase ? "(measured against the host clock)\n" : "(ASSUMED — no host clock, timings are relative)\n"); con_puts("============================================================\n\n"); + /* How many rows are coming. A host driving this suite has no other way to + * know: the count depends on the CPU (some kernels are R5000-only) and on + * the group mask it just asked for, both of which only the guest can + * resolve. Printed before the table so a progress bar is right from the + * first row rather than growing its own denominator. */ + con_puts("IRIS-BENCH-PLAN benches="); + con_udec(planned_benches()); + con_puts(" groups="); con_hex32(bench_groups); + con_puts(" time_pct="); con_udec(bench_time_pct); + con_puts(" repeats="); con_udec(bench_repeats); + con_puts("\n\n"); con_pad("benchmark", 26); con_pad("unit", 6); con_puts(" rate/s guest-MIPS time% acc\n"); @@ -357,13 +521,46 @@ static void print_machine_block(u64 total_ns, u64 total_ic, con_printf("#machine cpu=%s prid=%x", cpu_name(), cpu_prid); con_printf(" fir=%x config=%x", cpu_fir, cpu_config); con_printf(" l2=%d testdev=%d", have_l2, have_testdev); - con_printf(" timebase=%d\n", have_timebase); + con_printf(" timebase=%d", have_timebase); + con_printf(" sysid=%x", hw.sysid); + con_printf(" rev=%u.", hw.cpu_rev_major); con_udec(hw.cpu_rev_minor); + con_puts("\n"); + /* The hierarchy the mem/ kernels are measuring. Recorded on every result + * because two runs with different cache geometry are not comparable, and + * nothing else in the block would say so. */ + con_puts("#cache l1i="); con_udec(hw.l1i_bytes); + con_puts(" l1i_line="); con_udec(hw.l1i_line); + con_puts(" l1d="); con_udec(hw.l1d_bytes); + con_puts(" l1d_line="); con_udec(hw.l1d_line); + con_printf(" l2=%d", hw.l2_present); + con_puts(" l2_line="); con_udec(hw.l2_line); + con_puts(" l2_bytes="); con_udec(hw.l2_bytes); /* 0 = not reported */ + con_puts("\n"); + con_puts("#memory total_mb="); con_udec(hw.ram_mb); + con_puts(" banks="); con_udec(hw.banks); + { + unsigned i; + for (i = 0; i < 4; i++) { + if (hw.bank_mb[i] == 0) continue; + con_printf(" bank%u_mb=", i); con_udec(hw.bank_mb[i]); + con_printf(" bank%u_base=", i); con_hex32(hw.bank_base[i]); + } + } + con_puts("\n"); con_puts("#timebase count_hz="); con_udec(count_hz_measured); con_puts(" measured="); con_udec((u64)(have_timebase ? 1 : 0)); con_puts("\n"); con_puts("#work base="); con_hex32((u32)(unsigned long)work); con_puts(" bytes="); con_udec(work_bytes); con_puts("\n"); + /* What was actually measured. A shortened run is still accurate — every + * kernel that ran verified against its golden checksum — but its rates are + * noisier, so a saved result has to say so rather than let a reader assume + * a full one. */ + con_puts("#run groups="); con_hex32(bench_groups); + con_puts(" time_pct="); con_udec(bench_time_pct); + con_puts(" repeats="); con_udec(bench_repeats); + con_puts("\n"); con_puts("#cols name unit iters work ns icount count exc checksum golden status\n"); for (i = 0; i < n_results; i++) { @@ -400,14 +597,6 @@ int main(void) con_init(); bench_init(); - if (cpu_kind == 0) { - con_puts("\nUNKNOWN CPU — refusing to run: the golden checksums are\n" - "selected by PRId, so the accuracy score would be meaningless.\n"); - con_puts("\nIRIS-BENCH-DONE rc=127\n"); - con_flush(); - testdev_exit(127); - } - print_header(); for (gi = 0; gi < n_bgroups; gi++) { diff --git a/bench/prebuilt/PROVENANCE b/bench/prebuilt/PROVENANCE new file mode 100644 index 0000000..910eb72 --- /dev/null +++ b/bench/prebuilt/PROVENANCE @@ -0,0 +1,3 @@ +# What the checked-in guest binary hashes to. See README.md. +irisbench.elf sha256 6b49cf9fc97e82b2ac305a8fbf4e8b8e84a64ab68c2863e6d169431b1d1b7209 299168 bytes +golden/golden.h sha256 c26eda9c1d331b5925b1339132a63320b4663315739ba103794e6304662144cb 2289 bytes diff --git a/bench/prebuilt/README.md b/bench/prebuilt/README.md new file mode 100644 index 0000000..089d61a --- /dev/null +++ b/bench/prebuilt/README.md @@ -0,0 +1,21 @@ +# bench/prebuilt — the guest binary, checked in + +`iris` links `irisbench.elf` in with `include_bytes!` (`src/benchsuite.rs`) so +the benchmark runs on a machine with no MIPS cross toolchain and no build step: +a released app, a sandboxed one, or anyone who just wants the number. +`make -C bench` still builds `build/irisbench.elf` for development; this is a +copy of a known-good one. + +A checked-in build product that can drift is worse than no build product, and +this one drifts dangerously: accuracy is scored against golden checksums +compiled *into* the image, so a stale image against fresh goldens reports +failures that are not real. `.github/workflows/bench.yml` rebuilds it and fails +on any difference. + +Refresh it with `make -C bench prebuilt` after changing anything the guest is +built from — the kernels, the harness, `cpu-tests/harness/`, the link script, +the compiler flags — and commit the result alongside the source change. + +`PROVENANCE` records what the checked-in bytes hash to. It is written by the +`prebuilt` target; nothing reads it, but a reviewer can check it by hand and a +`git log` on it shows every time the image moved. diff --git a/bench/prebuilt/irisbench.elf b/bench/prebuilt/irisbench.elf new file mode 100755 index 0000000000000000000000000000000000000000..610ec45ec06560fe05af5f5ee65fc1e1bb5544da GIT binary patch literal 299168 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 F{U1m`Uqb)@ literal 0 HcmV?d00001 diff --git a/cpu-tests/README.md b/cpu-tests/README.md index 8418629..16c461c 100644 --- a/cpu-tests/README.md +++ b/cpu-tests/README.md @@ -71,6 +71,28 @@ run/run-prom.sh # PROM: boot -f dksc(0,2,8)cputest Slower, but it exercises the real path: the PROM reads the volume header, loads the ELF, and jumps to it. This is what the bootable CD will use. +### Which machines this runs on + +**An SGI Indy or Indigo2 (IP22/IP24), and nothing else** — on real hardware or +emulated. The harness is written for that machine: it links and self-relocates +to a fixed KSEG0 address chosen because IP22/IP24 RAM begins at `0x08000000` +(`harness/link.ld` explains why at length), it drives the console through the +Z85C30 SCC via IOC2 at `0x1FBD9800`, and the exception vectors and TLB work +assume that map. Under IRIS none of that costs anything, because IP22/IP24 is +what IRIS emulates. + +Unlike `bench/`, this suite also **legitimately refuses an unrecognised CPU** +(exit 127). That is not the same limitation and should not be "fixed" the same +way: these tests check R4400-versus-R5000 behaviour by construction — cache +geometry, MIPS IV availability, FPU quirks — so a third CPU genuinely has no +expected answer here. `bench/` has no CPU-specific anything and does run on any +MIPS III-or-later part. + +The assumptions, which of them were tested, and what a port to another SGI +family would actually involve, are written up once in +[`rules/testing/bare-metal-harness-platform-assumptions.md`](../rules/testing/bare-metal-harness-platform-assumptions.md) +— the harness is shared with `bench/`, so the limitations are shared too. + ## Expected results | | pass | fail | failing tests | diff --git a/cpu-tests/harness/iris.h b/cpu-tests/harness/iris.h index d2065fc..5e8ddad 100644 --- a/cpu-tests/harness/iris.h +++ b/cpu-tests/harness/iris.h @@ -14,6 +14,30 @@ #define SCC_CHA_DATA (IOC_BASE + 0x3C) /* IOC_SERIAL2_DATA */ #define SCC_RR0_TX_EMPTY 0x04u /* RR0 bit 2: Tx buffer empty */ +/* ── Memory controller (src/mc.rs: MC_BASE 0x1FA00000) ────────────────────── */ +#define MC_BASE 0xBFA00000u +#define MC_SYSID (MC_BASE + 0x0018) /* board revision / system id */ +#define MC_MEMCFG0 (MC_BASE + 0x00C0) /* bank 0 in 31:16, bank 1 in 15:0 */ +#define MC_MEMCFG1 (MC_BASE + 0x00C8) /* bank 2 in 31:16, bank 3 in 15:0 */ + +/* + * One MEMCFG half-word. Valid whether or not the PROM ran: POST programs these + * on a real boot, and `--load-elf` gets the same values from + * MemoryController::post_map_banks, which maps the banks exactly as POST would + * before the image is loaded. + * + * 15 14 13 12 8 7 0 + * +----+-----+----+-----------+-------------+ + * | |rank | VLD| size | base >> 22 | + * +----+-----+----+-----------+-------------+ + * + * A dual-rank SIMM stores the size of *one* rank, so the installed total is + * doubled — see MemoryController::encode_memcfg_half for the table this mirrors. + */ +#define MEMCFG_VALID(h) (((h) >> 13) & 1u) +#define MEMCFG_BASE(h) (((h) & 0xFFu) << 22) +#define MEMCFG_MB(h) (((((h) >> 8) & 0x1Fu) + 1u) * 4u << (((h) >> 14) & 1u)) + /* ── IRIS test device, GIO64 expansion slot 0 (src/testdev.rs) ────────────── */ #define TESTDEV_BASE 0xBF400000u #define TESTDEV_SIGNATURE (TESTDEV_BASE + 0x00) /* reads "IRIS" */ @@ -32,6 +56,22 @@ #define TESTDEV_ICOUNT_HI (TESTDEV_BASE + 0x1C) #define TESTDEV_CAPS (TESTDEV_BASE + 0x20) #define TESTDEV_CAP_TIMEBASE 0x00000001u +/* Run configuration, set by the host before the guest starts. A bare-metal + * image loaded with --load-elf has no argv and no environment, so this register + * is the only way to ask it for a shorter run. Every field is "unrestricted" + * when zero, which is what an emulator without TESTDEV_CAP_RUN_CONFIG returns, + * so it can be read unconditionally. See src/testdev.rs's RunConfig. + * + * 31 16 15 12 11 0 + * +---------------+-------+---------------+ + * | groups |repeats| time_pct | + * +---------------+-------+---------------+ + */ +#define TESTDEV_RUN_CONFIG (TESTDEV_BASE + 0x24) +#define TESTDEV_CAP_RUN_CONFIG 0x00000002u +#define TESTDEV_RC_GROUPS(w) (((w) >> 16) & 0xFFFFu) +#define TESTDEV_RC_REPEATS(w) (((w) >> 12) & 0xFu) +#define TESTDEV_RC_TIME_PCT(w) ((w) & 0xFFFu) #define TESTDEV_MAGIC 0x49524953u /* 'I','R','I','S' */ /* ── CPU identity (src/mips_core.rs:348-364) ──────────────────────────────── */ @@ -40,8 +80,38 @@ #define FIR_R4000 0x00000500u #define FIR_R5000 0x00002300u #define PRID_IMP(p) (((p) >> 8) & 0xFF) +#define PRID_REV_MAJOR(p) (((p) >> 4) & 0xF) +#define PRID_REV_MINOR(p) ((p) & 0xF) + +/* + * PRId implementation numbers for the MIPS CPUs SGI shipped. The architectural + * ones (imp) are stable across vendors; which machine took which is the SGI + * part: + * + * IP20 Indigo R4000 + * IP22/IP24 Indy R4000, R4400, R4600, R5000 + * IP22 Indigo2 R4400, R4600, R8000, R10000 + * IP32 O2 R5000, RM5200, RM7000, R10000, R12000 + * IP30 Octane R10000, R12000, R14000 + * IP27/IP35 Origin R10000, R12000, R14000 + * + * R4000 and R4400 share imp 0x04 and are told apart by revision — major >= 4 + * is an R4400, which is the same rule IRIX and Linux use, and is why IRIS + * reports PRId 0x0440. + */ +#define IMP_R4000 0x04 /* R4400 too — see PRID_REV_MAJOR */ #define IMP_R4400 0x04 +#define IMP_R10000 0x09 +#define IMP_R4300 0x0B +#define IMP_R12000 0x0E +#define IMP_R14000 0x0F +#define IMP_R8000 0x10 +#define IMP_R4600 0x20 +#define IMP_R4700 0x21 +#define IMP_R4650 0x22 #define IMP_R5000 0x23 +#define IMP_RM7000 0x27 +#define IMP_RM5200 0x28 /* ── CP0 Status ───────────────────────────────────────────────────────────── */ #define ST_IE 0x00000001u @@ -103,7 +173,11 @@ #define CFG_IB 0x00000020u /* icache line: 0=16B 1=32B */ #define CFG_DC_SHIFT 6 #define CFG_IC_SHIFT 9 +#define CFG_SE 0x00001000u /* R5K/Triton: L2 enable */ #define CFG_SC 0x00020000u /* 1 = no secondary cache */ +#define CFG_SB_SHIFT 22 /* L2 line: 4< physical RAM on IP22/IP24 begins at LOMEM_BASE = `0x08000000`, not at 0 […] +> everything from `0x00080000` up to `0x08000000` is unmapped. + +On a machine whose RAM is based at 0, physical `0x08200000` is 130 MB up — not +RAM at all on a smaller machine, and the wrong place regardless. The relocating +copy goes into the void and the jump after it lands in nothing. `bench`'s work +area (`_work_start` at `0x88300000`, probing upward) inherits the same +assumption. + +**2. The console.** `IOC_BASE 0xBFBD9800` — Z85C30 SCC channel B via IOC2. +Note that `scc_putc` *reads* RR0 before writing, so on a machine where nothing +decodes that address it is a bus error per character, not a quiet no-op. + +**3. The memory inventory** (`bench` only). Bank layout comes from the memory +controller at `MC_BASE 0xBFA00000`. Cache geometry does *not* — that is read +from CP0 Config and is portable. + +Also `TESTDEV_BASE 0xBF400000`, but that is an IRIS device by definition. Its +absence is expected, detected, and reported: without it the suites fall back to +CP0 Count at a measured-if-possible frequency and say so in the header. + +## What was actually tested + +Verified by experiment in the emulator: + +- **CPU identification is genuinely generic.** The emulator's PRId was + temporarily patched to `0x0926` and to `0xab37`; the guest named them + `R10000 rev 2.6` and `MIPS-imp-0xab rev 3.7` and ran all 46 kernels at 40/40 + both times. (It used to *refuse* anything but an R4400 or R5000, on the + stated grounds that "the golden checksums are selected by PRId" — which was + false; see the note under **Gotchas** below.) +- **No `bench` kernel is CPU-gated** — every one is `BENCH(...)`, i.e. + `BCPU_ALL`. `grep -c BENCH_CPU bench/kernels/*.c` is 0 across the board. +- **cpu-tests is the opposite** and deliberately so: its tests check + R4400-versus-R5000 behaviour by construction, so it keeps its two-value + `CPU_*` and genuinely has no expected answer for a third CPU. + +Reasoning, **not** tested — treat as a starting point, not as fact: + +- That IP32 (O2) bases RAM at physical 0 and uses CRIME/MACE rather than + HPC3/IOC2. This is the premise the whole "it will not run on an O2" + conclusion rests on. +- Anything about Octane or Origin. +- The exact ARCS vector layout (below). + +No run on real SGI hardware is recorded anywhere in this repo. `run-prom.sh` +exists and is built for it, but every number we have is the emulator's opinion +of itself. + +## If someone ports it: use ARCS, not per-machine drivers + +The obvious approach — write a MACE UART driver for the O2, another for +Octane — is the wrong one, because it is per-machine and it has a chicken-and-egg +problem (you cannot debug a console with no console). + +Every SGI machine from the Indigo onwards boots **ARCS** firmware, which +defines a standard callable console interface *and* a memory-map query. That is +one path for the whole family, and it happens to solve both of the hard +assumptions above: console, and where RAM actually is (so the relocation target +can be discovered rather than hardcoded). The board SYSID line would stay +per-machine, and it is cosmetic. + +Two things to know before starting: + +- ARCS is a **PROM-booted** path only. IRIS's `--load-elf` never runs the PROM, + so the direct SCC console has to stay as the emulator's fast path — two + console paths selected at runtime on whether a firmware vector exists, not + one. +- **It can be developed without real hardware.** IRIS executes a real 512 KB + SGI PROM image as guest code (`src/prombin.rs`, `Prom::from_file_or_embedded`), + so the PROM's own ARCS implementation runs inside the emulator, and + `bench/run/run-prom.sh` already boots the suite through it. Real hardware then + confirms it generalises rather than being the development loop. + `src/debug.md` has notes on the PROM's `arcs_printf`. + +## Gotchas already paid for + +- **A CPU name must be a single token.** It is emitted as the value of `cpu=` + in the machine block, which is parsed by splitting on whitespace and then on + `=`. `MIPS imp 0xab` silently truncated to `cpu="MIPS"`; it is now + `MIPS-imp-0xab`, with a test asserting no whitespace. +- **A wrong reason in a comment outlives the code it explains.** The refusal to + run on an unknown CPU claimed the goldens were selected by PRId. They never + were — `golden.h` is one flat table. The real mechanism was that + `cpu_kind == 0` matched no kernel's CPU mask, which is a very different + problem with a very different fix. Check the mechanism, not the comment. From b10ba3a1ecae6e354a774060fbd38a00c78ae4ba Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 21 Aug 2026 14:55:31 -0400 Subject: [PATCH 09/15] iris: run the benchmark suite in-process, on any platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The developer path spawns two processes — iris-bench spawns iris, which loads a guest binary off disk and prints to a pipe. None of that survives an application sandbox, and none of it was necessary: iris is a library, the guest image is now linked into it, and the test device can deliver the guest's console and exit code to a caller instead of to stdout and process::exit. The whole run becomes a Machine on a worker thread, identical on macOS, Windows and Linux because nothing platform-specific is left in it. Making the emulator embeddable: - TestDevice::new_embedded — console into a Vec sink, EXIT into a hook, no dump file. The standalone --test-device path is unchanged. - Machine::load_elf_bytes / MipsCpu::load_elf_bytes alongside the path versions, so a bare-metal image can be loaded from memory. - Machine::new_with_testdev, and the ultra64 slot-clash process::exit(1) becomes a panic, which iris-gui already catches. Killing the application over a configuration mistake is not a choice a library gets to make. EXIT returning is safe, and that is the whole trick. The store lands on the CPU thread mid-instruction, which is why this looked like the delicate part. It is not: every guest reaches EXIT through testdev_exit(), which spins forever afterwards because a bare-metal image has nowhere to return to. So the hook fires, the store completes, the CPU thread loops harmlessly in guest code, and the runner stops the machine from its own thread. A hook that blocked would deadlock against Machine::stop's join. That and the three other things that bite are in rules/testing/embedding-the-emulator-in-process.md. New modules: - benchsuite — the guest image via include_bytes!, and its blake3 suite id. - bench_report — the report parser, data model and reference table, moved out of src/bin/iris_bench.rs. Three callers need them and only one is that binary; "accuracy" should have one definition. - bench_runner — the runner itself, with progress events, a cancel flag and a timeout. Also backs `iris-bench run`, so there is one implementation of "run the suite and parse the answer" rather than two. iris-bench run is now in-process by default; --iris measures a different binary in a subprocess, which is what matrix needs and what CI now passes explicitly. Flags that only apply to the subprocess path are refused rather than silently ignored, and `reference` refuses a --quick result: a shortened run is accurate but imprecise, and the table is what every other machine is compared against. cpu_model() on macOS uses sysctlbyname rather than spawning sysctl — that was the last subprocess anywhere in the benchmark path. Not compiled for macOS here; type-checked against the libc signature. CI gains a prebuilt-drift check (a stale guest image against fresh goldens would report accuracy failures to users that are not real) and a run of the embedded path. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/bench.yml | 35 + data/bench_reference.README.md | 10 +- .../embedding-the-emulator-in-process.md | 64 ++ src/bench_report.rs | 921 ++++++++++++++++++ src/bench_runner.rs | 399 ++++++++ src/benchsuite.rs | 59 ++ src/bin/iris_bench.rs | 497 +++------- src/lib.rs | 48 + src/machine.rs | 58 +- src/main.rs | 36 +- src/mips_exec.rs | 11 +- src/testdev.rs | 234 ++++- 12 files changed, 1922 insertions(+), 450 deletions(-) create mode 100644 rules/testing/embedding-the-emulator-in-process.md create mode 100644 src/bench_report.rs create mode 100644 src/bench_runner.rs create mode 100644 src/benchsuite.rs diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 65818d4..0f1c607 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -24,6 +24,9 @@ on: - 'src/mips_*.rs' - 'src/jitv2/**' - 'src/testdev.rs' + - 'src/bench_report.rs' + - 'src/bench_runner.rs' + - 'src/benchsuite.rs' - 'src/bin/iris_bench.rs' - '.github/workflows/bench.yml' pull_request: @@ -34,6 +37,9 @@ on: - 'src/mips_*.rs' - 'src/jitv2/**' - 'src/testdev.rs' + - 'src/bench_report.rs' + - 'src/bench_runner.rs' + - 'src/benchsuite.rs' - 'src/bin/iris_bench.rs' workflow_dispatch: @@ -68,6 +74,22 @@ jobs: - name: Build irisbench.elf run: make -C bench + # bench/prebuilt/irisbench.elf is linked into `iris` with include_bytes! + # so the benchmark runs on a machine with no cross toolchain — a released + # app, a sandboxed one, or anyone who just wants the number. A checked-in + # build product that can drift is worse than no build product, and this + # one drifts dangerously: accuracy is scored against golden checksums + # compiled *into* the image, so a stale image against fresh goldens + # reports failures to users that are not real. + - name: The checked-in guest binary must match what we just built + run: | + make -C bench prebuilt + if ! git diff --exit-code bench/prebuilt/; then + echo "::error::bench/prebuilt/irisbench.elf is stale." + echo "::error::Run 'make -C bench prebuilt' and commit the result." + exit 1 + fi + # One binary for every cell below — same as cpu-tests, and for the same # reason: a differential comparison needs the guest side held constant. - uses: actions/upload-artifact@v4 @@ -136,10 +158,14 @@ jobs: fi cargo build --release --bin iris-bench + # --iris is required, not decorative: without it `run` measures this + # process, and iris-bench is built with default features rather than the + # cell's. The cell is the binary built in the step above, so name it. - name: Run the suite run: | chmod +x bench/build/irisbench.elf ./target/release/iris-bench run \ + --iris ./target/release/iris \ --label "${{ matrix.cpu }}-${{ matrix.engine }}" \ --timeout 2400 \ 2>&1 | tee bench/build/run.log @@ -177,6 +203,15 @@ jobs: sys.exit(1 if bad or exc else 0) PY + # The path a released application actually takes: no subprocess, no ELF on + # disk, the guest image read straight out of the binary. Quick mode so it + # costs about half a minute on top of the full run above. Only on the + # default cell — the embedded runner is the same code in every cell, and + # what it exercises is the plumbing, not the CPU model. + - name: The embedded runner must work too + if: matrix.cpu == 'r4400' && matrix.engine == 'interp' + run: cargo test --release --lib bench_runner -- --ignored --nocapture + - uses: actions/upload-artifact@v4 if: always() with: diff --git a/data/bench_reference.README.md b/data/bench_reference.README.md index 0272b89..ad56f59 100644 --- a/data/bench_reference.README.md +++ b/data/bench_reference.README.md @@ -9,9 +9,8 @@ static file updated by hand when someone measures a machine worth recording. ## Adding a row ```sh -make -C bench # build the guest binary cargo build --release --bin iris-bench -./target/release/iris-bench run --label my-machine +./target/release/iris-bench run --label my-machine # NOT --quick ./target/release/iris-bench reference \ --id m1-max-interp \ @@ -43,3 +42,10 @@ them cannot be compared with anything. Note the Mac App Store build forces `IRIS_NO_JIT=1` (`iris-gui/src/main.rs`) — the sandbox only permits `MAP_JIT` pages and Cranelift does not use them — so rows meant for comparison against a store build must be `"engine": "interp"`. + +## Full runs only + +`reference` refuses a result recorded with `--quick`. A shortened run is still +accurate — every kernel ran and verified — but its rates come from a single +timed pass at 30% of the usual target time, and this table is what every other +machine gets compared against. Re-run without `--quick`. diff --git a/rules/testing/embedding-the-emulator-in-process.md b/rules/testing/embedding-the-emulator-in-process.md new file mode 100644 index 0000000..1841bf2 --- /dev/null +++ b/rules/testing/embedding-the-emulator-in-process.md @@ -0,0 +1,64 @@ +# Running a bare-metal image inside the host process + +`iris` is a library, and `iris-gui` already runs a `Machine` on a worker thread. +So a bare-metal suite (`bench/`, and in principle `cpu-tests/`) can be driven +entirely in-process — no `iris` subprocess, no ELF on disk, no stdout to parse. +That is what makes the benchmark shippable: a sandboxed application has no +toolchain and no writable path to unpack an image to. + +`crate::bench_runner` is the working example. What had to change, and why. + +## `TestDevice::exit` can just return — no parking, no signalling + +This looked like the delicate part and turned out to be the easy one. The +`EXIT` store lands on the **CPU thread**, inside the guest's own instruction, so +the obvious worry is what that thread does next. + +It does not matter, because **every guest that has a test device reaches `EXIT` +through `testdev_exit()`, which spins forever afterwards** +(`cpu-tests/harness/console.c`) — a bare-metal image has nowhere to return to. +So the handler can fire a callback, return normally, and let the CPU thread go +back to looping in guest code that does nothing while the *runner* thread stops +the machine at its leisure. + +Do **not** block in the handler instead. `Machine::stop` joins the CPU thread, +so a handler that waits for the runner deadlocks against the runner waiting for +the join. + +## Do not call `register_system_controller` + +It hands a raw pointer to the `Machine` to a thread that outlives the call. That +is fine for `main.rs`, where the machine lives as long as the process, and wrong +for a runner whose machine is dropped when the run finishes. Skip it: nothing an +embedded run needs (`reset`, `save`, `load`, guest-initiated power-off) applies. + +## Spawn a thread with a big stack + +`Machine::new` puts a >1 MB device map on the stack. Windows gives a thread 1 MB +by default, so construct it on a `Builder::new().stack_size(16 << 20)` thread — +`main.rs` does the same thing for the same reason. Doing it inside the runner +rather than asking callers to remember is what keeps the API a plain function. + +## `Machine::start` does not always start the CPU + +`self.cpu.start()` there is behind `#[cfg(not(any(debug_assertions, feature = +"developer")))]`, so a debug build — which is what `cargo test` produces — +starts every device *except* the CPU and then sits there. Call +`Machine::cpu_start()` explicitly afterwards; it is a no-op if the CPU is +already running. + +## Give the guest its configuration through a register + +A bare-metal image loaded with `--load-elf` has no argv and no environment. +`TESTDEV_RUN_CONFIG` (`src/testdev.rs`, `RunConfig`) is the channel. Encode it so +that **every field means "unrestricted" when zero**: that is what an emulator +predating the register returns from an undecoded address, so the guest can read +it unconditionally with no capability check of its own beyond the one that +already exists. Verified both ways — the current guest image runs correctly on +an emulator built before the register existed. + +## `MachineConfig::default()` attaches a disk + +`default_scsi()` puts `scsi1.raw` on ID 1, and startup is fatal when the file is +absent — which it always is for a bare-metal run. Clear `cfg.scsi` (this is the +same reason `bench/run/bare.toml` carries a present-but-empty `[scsi]`). diff --git a/src/bench_report.rs b/src/bench_report.rs new file mode 100644 index 0000000..75a10bd --- /dev/null +++ b/src/bench_report.rs @@ -0,0 +1,921 @@ +//! The benchmark suite's report: its data model, its parser, and the reference +//! table a result is compared against. +//! +//! The suite (`bench/`) is a bare-metal MIPS binary that prints a human table +//! and then a machine-readable block between `IRIS-BENCH-BEGIN` and +//! `IRIS-BENCH-END`. Everything here is about turning that block into numbers. +//! +//! This lives in the library rather than in `iris-bench` because three callers +//! need it and only one of them is that binary: the CLI, the in-process runner +//! (`crate::bench_runner`), and the GUI reading a saved result or the reference +//! table. One parser, one schema, one definition of what "accuracy" means. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +pub const BEGIN: &str = "IRIS-BENCH-BEGIN"; +pub const END: &str = "IRIS-BENCH-END"; + + +/// Kernels whose whole point is to take exceptions. Everywhere else a nonzero +/// count is a defect — see BF_TAKES_EXC in bench/harness/benchlib.h. +pub const EXPECT_EXC: &[&str] = &["sys/exception", "sys/tlb_miss"]; + +/// Dhrystones per second per DMIPS, by the VAX 11/780 convention every +/// published Dhrystone figure since 1988 uses. +pub const DHRY_PER_DMIPS: f64 = 1757.0; + +// ─── data model ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Row { + pub name: String, + pub unit: String, + pub iters: u64, + pub work: u64, + pub ns: u64, + pub icount: u64, + pub count: u64, + /// Exceptions taken during the timed run. Nonzero for a kernel that is not + /// meant to take any means it measured something other than what it + /// claims — the harness flags those on the line, and the report repeats it. + pub exc: u64, + pub checksum: String, + pub golden: String, + pub status: String, +} + +impl Row { + /// Work units per second. The unit is the kernel's own, so this is only + /// comparable across cells for the same kernel — which is exactly how the + /// report uses it. + pub fn rate(&self) -> f64 { + if self.ns == 0 { 0.0 } else { self.work as f64 * 1e9 / self.ns as f64 } + } + /// Guest instructions retired per host second. Zero when there is no + /// instruction counter (host runs, or an emulator without the test + /// device's timebase registers). + pub fn mips(&self) -> f64 { + if self.ns == 0 || self.icount == 0 { 0.0 } else { self.icount as f64 * 1e3 / self.ns as f64 } + } +} + +/// The cache hierarchy, read out of CP0 Config by the guest. +/// +/// Worth recording on every result rather than inferring from the CPU name: +/// the `mem/` kernels are a direct readout of this hierarchy, so two results +/// with different L1 sizes are not measuring the same thing however similar +/// their rates look. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CacheInfo { + pub l1i_bytes: u64, + pub l1i_line: u64, + pub l1d_bytes: u64, + pub l1d_line: u64, + pub l2_present: bool, + pub l2_line: u64, + /// 0 when the architecture does not report it — which is every CPU but a + /// Triton R5000. The PROM knows the real figure from the EEPROM; CP0 does + /// not expose it, so this is left unknown rather than guessed. + pub l2_bytes: u64, +} + +impl CacheInfo { + /// True when the guest reported nothing — a host run, or a result recorded + /// before the inventory existed. + pub fn is_empty(&self) -> bool { + self.l1i_bytes == 0 && self.l1d_bytes == 0 + } +} + +/// One RAM bank, as the memory controller has it programmed. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Bank { + pub index: u8, + pub mb: u64, + pub base: u64, +} + +/// Installed memory, from the MC's MEMCFG registers — valid whether or not the +/// PROM ran, since `--load-elf` programs them exactly as POST would. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct MemoryInfo { + pub total_mb: u64, + pub banks: Vec, +} + +impl MemoryInfo { + pub fn is_empty(&self) -> bool { + self.total_mb == 0 + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MachineInfo { + pub cpu: String, + pub prid: String, + pub fir: String, + pub config: String, + pub l2: bool, + pub testdev: bool, + pub timebase: bool, + pub count_hz: u64, + pub count_hz_measured: bool, + pub work_bytes: u64, + /// CPU revision as `major.minor`, from PRId. Empty on an older result. + #[serde(default)] + pub rev: String, + /// Board/system revision, from the MC's SYSID register. + #[serde(default)] + pub sysid: String, + /// `default` on all three so results recorded before the guest reported an + /// inventory still load — as an empty one, which is what they had. + #[serde(default)] + pub cache: CacheInfo, + #[serde(default)] + pub memory: MemoryInfo, +} + +/// The run configuration the guest actually used, from the `#run` line. +/// +/// A shortened run is still *accurate* — every kernel that ran verified against +/// its golden checksum — but its rates are noisier, so a result has to carry +/// this rather than let a reader assume a full one. `Default` is what the suite +/// does when nobody asks for anything, which is also what a result recorded +/// before the `#run` line existed must be read as. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunSettings { + /// The suite's own `BG_*` group mask. `BG_ALL` is every group. + pub groups: u32, + /// Per-kernel target time, as a percentage of the suite's default. + pub time_pct: u32, + /// Timed passes per kernel. + pub repeats: u32, +} + +/// `BG_ALL` from `bench/harness/benchlib.h`: integer, fpu, memory, imaging, +/// codec, sys. +pub const BG_ALL: u32 = 0x3F; + +impl Default for RunSettings { + fn default() -> Self { + Self { groups: BG_ALL, time_pct: 100, repeats: 2 } + } +} + +impl RunSettings { + /// Every group, full-length timed runs, best-of-two. Only a full run's + /// numbers belong in the reference table. + pub fn is_full(&self) -> bool { + *self == Self::default() + } + /// Why this run is not comparable with a full one, for a human. + pub fn shortened_because(&self) -> Option { + if self.is_full() { return None; } + let mut why = Vec::new(); + if self.groups != BG_ALL { + why.push(format!("only groups {:#04x} ran", self.groups)); + } + if self.time_pct != 100 { + why.push(format!("timed runs were {}% of full length", self.time_pct)); + } + if self.repeats != 2 { + why.push(format!("{} timed pass(es) per kernel instead of 2", self.repeats)); + } + Some(why.join("; ")) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct HostInfo { + pub os: String, + pub arch: String, + pub cpu_model: String, + pub cores: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Run { + /// Cell label: "r4400-interp", "r5000-jitv2", "host", … + pub cell: String, + /// Cargo features the emulator was built with, from its own banner. + pub features: Vec, + pub machine: MachineInfo, + pub host: HostInfo, + pub rows: Vec, + pub checked: usize, + pub matched: usize, + pub total_ns: u64, + pub total_icount: u64, + /// Wall clock for the whole process, including emulator startup — always + /// larger than total_ns, which counts only timed regions. + pub wall_s: f64, + /// Hash of the guest binary this result came from. Reference numbers only + /// mean anything against the exact suite that produced them: add or change + /// a kernel and every stored figure silently becomes a comparison between + /// two different workloads. Empty for a host run (no guest binary). + /// `default` so results recorded before this field existed still load. + #[serde(default)] + pub suite_id: String, + /// How the suite was configured for this run. `default` so results + /// recorded before the `#run` line existed still load, as full runs — + /// which is what they were. + #[serde(default)] + pub settings: RunSettings, +} + +impl Run { + pub fn accuracy(&self) -> f64 { + if self.checked == 0 { 0.0 } else { self.matched as f64 * 100.0 / self.checked as f64 } + } + pub fn mips(&self) -> f64 { + if self.total_ns == 0 || self.total_icount == 0 { 0.0 } + else { self.total_icount as f64 * 1e3 / self.total_ns as f64 } + } + pub fn row(&self, name: &str) -> Option<&Row> { + self.rows.iter().find(|r| r.name == name) + } + /// Dhrystone 2.1 DMIPS. + pub fn dmips(&self) -> Option { + self.row("int/dhrystone").map(|r| r.rate() / DHRY_PER_DMIPS) + } + /// Whetstone passes per second. Deliberately not converted to MWIPS: that + /// needs a "Whetstone instructions per loop" constant taken from a + /// reference implementation, and a figure resting on an unverified factor + /// of a thousand would look authoritative without being so. Passes per + /// second is exact and is what cell-to-cell comparison uses. + pub fn whet_loops(&self) -> Option { + self.row("fpu/whetstone").map(|r| r.rate()) + } + /// LINPACK 100x100 MFLOPS. + pub fn linpack_mflops(&self) -> Option { + self.row("fpu/linpack").map(|r| r.rate() / 1e6) + } +} + +// ─── parsing the suite's machine block ─────────────────────────────────────── + +/// What one parse of the suite's machine block yielded. +#[derive(Debug, Clone)] +pub struct Parsed { + pub machine: MachineInfo, + pub rows: Vec, + pub checked: usize, + pub matched: usize, + pub total_ns: u64, + pub total_icount: u64, + pub settings: RunSettings, +} + +pub fn parse_block(text: &str) -> Result { + let begin = text.find(BEGIN).ok_or_else(|| { + "no IRIS-BENCH-BEGIN in the output — the suite did not reach its report".to_string() + })?; + let end = text[begin..].find(END).ok_or_else(|| { + "output ends before IRIS-BENCH-END — the suite died partway through".to_string() + })? + begin; + + let mut machine = MachineInfo::default(); + let mut rows = Vec::new(); + let mut settings = RunSettings::default(); + let (mut checked, mut matched, mut total_ns, mut total_ic) = (0usize, 0usize, 0u64, 0u64); + + for line in text[begin..end].lines().skip(1) { + let line = line.trim(); + if line.is_empty() { continue; } + if let Some(rest) = line.strip_prefix('#') { + let mut it = rest.split_whitespace(); + let kind = it.next().unwrap_or(""); + let kv: BTreeMap<&str, &str> = it + .filter_map(|tok| tok.split_once('=')) + .collect(); + let num = |k: &str| -> u64 { + kv.get(k).and_then(|v| parse_u64(v)).unwrap_or(0) + }; + match kind { + "machine" => { + machine.cpu = kv.get("cpu").unwrap_or(&"unknown").to_string(); + machine.prid = kv.get("prid").unwrap_or(&"").to_string(); + machine.fir = kv.get("fir").unwrap_or(&"").to_string(); + machine.config = kv.get("config").unwrap_or(&"").to_string(); + machine.l2 = num("l2") != 0; + machine.testdev = num("testdev") != 0; + machine.timebase = num("timebase") != 0; + machine.rev = kv.get("rev").unwrap_or(&"").to_string(); + machine.sysid = kv.get("sysid").unwrap_or(&"").to_string(); + } + "timebase" => { + machine.count_hz = num("count_hz"); + machine.count_hz_measured = num("measured") != 0; + } + "work" => machine.work_bytes = num("bytes"), + "cache" => { + machine.cache = CacheInfo { + l1i_bytes: num("l1i"), + l1i_line: num("l1i_line"), + l1d_bytes: num("l1d"), + l1d_line: num("l1d_line"), + l2_present: num("l2") != 0, + l2_line: num("l2_line"), + l2_bytes: num("l2_bytes"), + }; + } + "memory" => { + machine.memory.total_mb = num("total_mb"); + machine.memory.banks = (0..4) + .filter_map(|i| { + let mb = num(&format!("bank{}_mb", i)); + (mb > 0).then(|| Bank { + index: i as u8, + mb, + base: num(&format!("bank{}_base", i)), + }) + }) + .collect(); + } + "run" => { + settings = RunSettings { + groups: num("groups") as u32, + time_pct: num("time_pct") as u32, + repeats: num("repeats") as u32, + }; + } + "totals" => { + checked = num("checked") as usize; + matched = num("matched") as usize; + total_ns = num("ns"); + total_ic = num("icount"); + } + _ => {} + } + continue; + } + + let f: Vec<&str> = line.split_whitespace().collect(); + if f.len() != 11 { continue; } + rows.push(Row { + name: f[0].to_string(), + unit: f[1].to_string(), + iters: parse_u64(f[2]).unwrap_or(0), + work: parse_u64(f[3]).unwrap_or(0), + ns: parse_u64(f[4]).unwrap_or(0), + icount: parse_u64(f[5]).unwrap_or(0), + count: parse_u64(f[6]).unwrap_or(0), + exc: parse_u64(f[7]).unwrap_or(0), + checksum: f[8].to_string(), + golden: f[9].to_string(), + status: f[10].to_string(), + }); + } + + if rows.is_empty() { + return Err("the report block held no benchmark rows".to_string()); + } + Ok(Parsed { machine, rows, checked, matched, total_ns, total_icount: total_ic, settings }) +} + +pub fn parse_u64(s: &str) -> Option { + let s = s.trim(); + if let Some(hex) = s.strip_prefix("0x") { + u64::from_str_radix(hex, 16).ok() + } else { + s.parse().ok() + } +} + +/// Pull the feature list out of the emulator's own startup banner, so a saved +/// result records what produced it rather than what the caller believed. +pub fn parse_features(stderr: &str) -> Vec { + for line in stderr.lines() { + if let Some(rest) = line.strip_prefix("iris: build features: ") { + let rest = rest.trim(); + if rest == "(none)" { return Vec::new(); } + return rest.split_whitespace().map(str::to_string).collect(); + } + } + Vec::new() +} + +// ─── headline categories ───────────────────────────────────────────────────── + +/// One line of the summary a person actually reads: a benchmark family reduced +/// to a single throughput. +/// +/// A group's kernels do not all measure the same thing — `int/` reports both +/// `ops` and `dhry`, `mem/` reports `B`, `acc` and `upd` — so a category takes +/// only the kernels carrying the group's dominant unit and aggregates those. +/// Summing work and time separately (rather than averaging per-kernel rates) +/// weights each kernel by how long it ran, which is what makes the result a +/// throughput rather than an average of incomparable numbers. +pub struct Category { + pub label: &'static str, + /// Kernel-name prefixes that belong to this family. + pub prefixes: &'static [&'static str], + /// The work unit to aggregate. Kernels reporting anything else are left + /// out — they are measuring something this figure does not claim to cover. + pub unit: &'static str, + /// How to render the aggregate, after the SI prefix. + pub suffix: &'static str, +} + +pub const CATEGORIES: &[Category] = &[ + Category { label: "Integer", prefixes: &["int/"], unit: "ops", suffix: "ops/s" }, + Category { label: "Floating", prefixes: &["fpu/"], unit: "ops", suffix: "ops/s" }, + Category { label: "Memory", prefixes: &["mem/"], unit: "B", suffix: "B/s" }, + Category { label: "Imaging", prefixes: &["img/", "vid/"], unit: "px", suffix: "px/s" }, + Category { label: "Codec", prefixes: &["codec/"], unit: "B", suffix: "B/s" }, +]; + +impl Run { + /// This run's throughput for one category, or `None` when no kernel in it + /// produced a usable measurement (skipped on this CPU, or the group was not + /// selected). + pub fn category_rate(&self, c: &Category) -> Option { + let (mut work, mut ns) = (0u128, 0u128); + for r in &self.rows { + if r.status == "SKIP" || r.unit != c.unit || r.ns == 0 { continue; } + if !c.prefixes.iter().any(|p| r.name.starts_with(p)) { continue; } + work += r.work as u128; + ns += r.ns as u128; + } + if ns == 0 || work == 0 { return None; } + Some(work as f64 * 1e9 / ns as f64) + } +} + +/// A rate with an SI prefix: `38.5 M`, `7.32 k`. The caller appends the unit. +pub fn fmt_rate(v: f64) -> String { + if v <= 0.0 { return "-".into(); } + if v >= 1e9 { format!("{:.2} G", v / 1e9) } + else if v >= 1e6 { format!("{:.2} M", v / 1e6) } + else if v >= 1e3 { format!("{:.2} k", v / 1e3) } + else { format!("{:.1}", v) } +} + +// ─── host identification ───────────────────────────────────────────────────── + +pub fn host_info() -> HostInfo { + let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0); + HostInfo { + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + cpu_model: cpu_model(), + cores, + } +} + +/// A NUL-terminated `sysctl` name's value as a string, or `None` if the key is +/// absent or does not hold one. +#[cfg(target_os = "macos")] +fn sysctl_string(name: &[u8]) -> Option { + let mut len: libc::size_t = 0; + let name = name.as_ptr() as *const libc::c_char; + + // SAFETY: `name` is NUL-terminated by the caller's byte-string literal. A + // null `oldp` asks only for the length, which is what `len` receives. + let rc = unsafe { + libc::sysctlbyname(name, std::ptr::null_mut(), &mut len, std::ptr::null_mut(), 0) + }; + if rc != 0 || len <= 1 { + return None; + } + + let mut buf = vec![0u8; len]; + // SAFETY: `buf` holds exactly the `len` bytes the call above asked for, and + // `len` is passed by pointer so the kernel can shorten it. + let rc = unsafe { + libc::sysctlbyname(name, buf.as_mut_ptr().cast(), &mut len, std::ptr::null_mut(), 0) + }; + if rc != 0 { + return None; + } + + buf.truncate(len); + if let Some(nul) = buf.iter().position(|b| *b == 0) { + buf.truncate(nul); + } + let s = String::from_utf8(buf).ok()?; + let s = s.trim().to_string(); + if s.is_empty() { None } else { Some(s) } +} + +pub fn cpu_model() -> String { + #[cfg(target_os = "linux")] + { + if let Ok(s) = std::fs::read_to_string("/proc/cpuinfo") { + for line in s.lines() { + if let Some((k, v)) = line.split_once(':') { + if k.trim() == "model name" || k.trim() == "Model" { + return v.trim().to_string(); + } + } + } + } + } + #[cfg(target_os = "macos")] + { + // `sysctlbyname`, not a spawned `sysctl`. This would otherwise be the + // only subprocess left anywhere in the benchmark path, and the whole + // point of running the suite in-process is that a sandboxed + // application should need none. Returns "Apple M1"-style names on + // Apple silicon and the Intel brand string on Intel. + if let Some(s) = sysctl_string(b"machdep.cpu.brand_string\0") { + return s; + } + } + #[cfg(target_os = "windows")] + { + if let Ok(s) = std::env::var("PROCESSOR_IDENTIFIER") { return s; } + } + "unknown".to_string() +} + + +// ─── reference rows ────────────────────────────────────────────────────────── +// +// `data/bench_reference.json` is the table the GUI compares a user's result +// against. It ships checked in and **starts empty** — a machine with no row is +// the normal case, not an error, and the GUI says "reference statistics not +// gathered for this platform" rather than inventing one. Rows are added by +// running the suite on a machine and pasting what this subcommand prints. +// +// Deliberately a static file updated by hand: the alternative (a user-writable +// override, an import/export pair, a fetch) is a lot of machinery for a table +// that changes when someone gets a new Mac. + +/// One machine's numbers, as they appear in `data/bench_reference.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReferenceEntry { + pub id: String, + pub label: String, + /// Emulated CPU (`R4400`/`R5000`) and execution engine (`interp`/`jitv2`). + /// Both matter: the two engines differ by about 4x, so a row without them + /// cannot be compared with anything. + pub cpu: String, + pub engine: String, + pub host: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub measured: Option, + pub guest_mips: f64, + pub dmips: f64, + pub accuracy: f64, + /// Work units per second, per kernel. + pub kernels: BTreeMap, +} + +/// The reference table that ships with the application. +/// +/// Checked in, compiled in, and **normally empty** — a machine with no row gets +/// "reference statistics not gathered for this platform" rather than an +/// invented comparison. There is no upload, no download and no user-writable +/// override: it is a static file and a pull request. See +/// `data/bench_reference.README.md`. +/// +/// Note this is *not* the correctness oracle. The golden checksums accuracy is +/// scored against are compiled into the guest image, where nobody can edit them +/// to "fix" a failure. Externalising the performance table therefore carries no +/// correctness risk at all. +pub fn bundled_reference() -> ReferenceTable { + const JSON: &str = include_str!("../data/bench_reference.json"); + serde_json::from_str(JSON).unwrap_or_else(|e| { + // A malformed table must not take the application down over a + // comparison it can perfectly well do without. + log::warn!("bench_reference.json is not readable ({e}); treating it as empty"); + ReferenceTable { schema: 1, suite_id: String::new(), entries: Vec::new() } + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReferenceTable { + pub schema: u32, + /// The guest binary these numbers were measured against. A result whose own + /// `suite_id` differs is not comparable — treat the table as empty rather + /// than comparing across two different workloads. + pub suite_id: String, + pub entries: Vec, +} + +impl ReferenceTable { + /// The row to compare `run` against, if there is one. + /// + /// Three things have to agree before a comparison means anything: the + /// **suite**, because two different workloads under one name is not a + /// comparison; the **emulated CPU**, because the R4400 and R5000 cache + /// models differ deeply; and the **engine**, because the interpreter and + /// jitv2 are about 4x apart. A mismatch on any of them is treated exactly + /// like an empty table, so callers need one fallback path rather than four. + pub fn matching(&self, run: &Run) -> Option<&ReferenceEntry> { + if self.entries.is_empty() || self.suite_id != run.suite_id { + return None; + } + let engine = engine_of(run); + let candidates = || { + self.entries.iter() + .filter(|e| e.cpu == run.machine.cpu && e.engine == engine) + }; + // Prefer the same host CPU — that is a like-for-like number rather than + // a cross-machine one — and otherwise take any row for this cell. + candidates() + .find(|e| e.host == run.host.cpu_model) + .or_else(|| candidates().next()) + } +} + +/// `interp` unless the emulator's own feature banner says otherwise. Read from +/// the banner rather than inferred, so a mislabelled row is impossible. +pub fn engine_of(run: &Run) -> &'static str { + if run.features.iter().any(|f| f == "jitv2") { "jitv2" } else { "interp" } +} + +/// Today as `YYYY-MM-DD`, from the system clock. Hinnant's civil-from-days. +pub fn today() -> String { + let days = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + Ok(d) => (d.as_secs() / 86_400) as i64, + Err(_) => return "unknown".into(), + }; + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + format!("{:04}-{:02}-{:02}", if m <= 2 { y + 1 } else { y }, m, d) +} + +pub fn reference_entry(run: &Run, id: &str, label: Option<&str>, measured: Option<&str>) -> ReferenceEntry { + ReferenceEntry { + id: id.to_string(), + label: label.map(str::to_string).unwrap_or_else(|| { + format!("{} — {}", run.host.cpu_model, engine_of(run)) + }), + cpu: run.machine.cpu.clone(), + engine: engine_of(run).to_string(), + host: run.host.cpu_model.clone(), + measured: Some(measured.map(str::to_string).unwrap_or_else(today)), + guest_mips: (run.mips() * 10.0).round() / 10.0, + dmips: run.dmips().map(|v| (v * 10.0).round() / 10.0).unwrap_or(0.0), + accuracy: (run.accuracy() * 10.0).round() / 10.0, + kernels: run.rows.iter() + .filter(|r| r.status != "SKIP" && r.work > 0) + .map(|r| (r.name.clone(), (r.rate() * 100.0).round() / 100.0)) + .collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A real report block, trimmed to four rows. Kept verbatim rather than + /// generated: the point of the test is that the parser still agrees with + /// what `bench/harness/main.c` actually prints. + const BLOCK: &str = "\ +benchmark unit rate/s guest-MIPS time% acc +------------------------------------------------------------------ +int/alu ops 61247926 103.36 ok +------------------------------------------------------------------ + +IRIS-BENCH-BEGIN v1 +#machine cpu=R4400 prid=0x00000440 fir=0x00000500 config=0x00c08483 l2=1 testdev=1 timebase=1 sysid=0x00000013 rev=4.0 +#cache l1i=16384 l1i_line=16 l1d=16384 l1d_line=16 l2=1 l2_line=128 l2_bytes=0 +#memory total_mb=256 banks=2 bank0_mb=128 bank0_base=0x08000000 bank1_mb=128 bank1_base=0x10000000 +#timebase count_hz=32999459 measured=1 +#work base=0x88300000 bytes=25165824 +#run groups=0x0000003f time_pct=30 repeats=1 +#cols name unit iters work ns icount count exc checksum golden status +int/alu ops 975094 15601504 254727060 26327595 8405943 0 0x152c986014248fb5 0x152c986014248fb5 OK +int/dhrystone dhry 51941 51941 246911934 22031467 8148070 0 0xe5c6550cf608d8ca 0xe5c6550cf608d8ca OK +mem/copy B 1 4194304 213900417 2621565 7058653 0 0xb603341656905fe2 0xb603341656905fe2 OK +sys/tlb_miss miss 256 524288 232062918 6710007 7658053 3 0x0000000000000000 0x0000000000000000 UNCHECKED +#totals benches=4 checked=3 matched=3 ns=947602329 icount=57690634 +IRIS-BENCH-END +"; + + fn parsed() -> Parsed { + parse_block(BLOCK).expect("the block the guest prints must parse") + } + + #[test] + fn the_machine_block_parses() { + let p = parsed(); + assert_eq!(p.machine.cpu, "R4400"); + assert!(p.machine.l2 && p.machine.testdev && p.machine.timebase); + assert_eq!(p.machine.count_hz, 32_999_459); + assert_eq!(p.machine.work_bytes, 25_165_824); + assert_eq!(p.rows.len(), 4, "the human table above the block is not a row"); + assert_eq!((p.checked, p.matched), (3, 3)); + assert_eq!(p.total_ns, 947_602_329); + + let dhry = p.rows.iter().find(|r| r.name == "int/dhrystone").unwrap(); + assert_eq!(dhry.work, 51_941); + assert_eq!(dhry.status, "OK"); + // Exceptions are parsed from their own column, not inferred. + assert_eq!(p.rows.iter().find(|r| r.name == "sys/tlb_miss").unwrap().exc, 3); + } + + #[test] + fn the_machine_inventory_parses() { + let m = parsed().machine; + assert_eq!(m.rev, "4.0"); + assert_eq!(m.sysid, "0x00000013"); + + assert_eq!(m.cache, CacheInfo { + l1i_bytes: 16384, l1i_line: 16, + l1d_bytes: 16384, l1d_line: 16, + l2_present: true, l2_line: 128, + // Not architecturally reported on anything but a Triton — 0 means + // "unknown", and must never be read as "no L2". + l2_bytes: 0, + }); + assert!(!m.cache.is_empty()); + assert!(m.cache.l2_present && m.cache.l2_bytes == 0, + "an L2 of unknown size is still an L2"); + + assert_eq!(m.memory.total_mb, 256); + assert_eq!(m.memory.banks, vec![ + Bank { index: 0, mb: 128, base: 0x0800_0000 }, + Bank { index: 1, mb: 128, base: 0x1000_0000 }, + ]); + assert_eq!(m.memory.banks.iter().map(|b| b.mb).sum::(), m.memory.total_mb, + "the banks must add up to the total the guest reported"); + } + + /// The guest names any MIPS CPU from PRId, including ones this emulator + /// does not model. Every name it can produce has to be a single token, or + /// the whitespace-then-`=` parse of the machine block truncates it — + /// "MIPS imp 0xab" once parsed as cpu="MIPS". + #[test] + fn a_cpu_the_emulator_does_not_model_survives_the_round_trip() { + let exotic = BLOCK + .replace("cpu=R4400", "cpu=MIPS-imp-0xab") + .replace("prid=0x00000440", "prid=0x0000ab37") + .replace("rev=4.0", "rev=3.7"); + let m = parse_block(&exotic).unwrap().machine; + assert_eq!(m.cpu, "MIPS-imp-0xab"); + assert_eq!(m.prid, "0x0000ab37"); + assert_eq!(m.rev, "3.7"); + assert!(!m.cpu.contains(char::is_whitespace), + "a CPU name with a space in it truncates on parse"); + // The keys *after* cpu= must still be found, which is what a spaced + // name would have broken. + assert_eq!(m.fir, "0x00000500"); + assert!(m.timebase); + } + + /// A host run reports no inventory, and a result recorded before the + /// inventory existed has none either. Both must load as "empty", never as + /// a machine with no cache and no RAM. + #[test] + fn a_result_with_no_inventory_loads_as_empty_not_as_zero() { + let older = BLOCK + .lines() + .filter(|l| !l.starts_with("#cache") && !l.starts_with("#memory")) + .collect::>() + .join("\n"); + let m = parse_block(&older).unwrap().machine; + assert!(m.cache.is_empty()); + assert!(m.memory.is_empty()); + assert!(m.memory.banks.is_empty()); + } + + #[test] + fn the_run_configuration_comes_from_the_block_not_from_the_caller() { + let p = parsed(); + assert_eq!(p.settings, RunSettings { groups: BG_ALL, time_pct: 30, repeats: 1 }); + assert!(!p.settings.is_full()); + assert!(p.settings.shortened_because().is_some()); + } + + /// A result recorded before `#run` existed was a full run, and has to load + /// as one — otherwise every stored result would suddenly be "shortened" + /// and refused by the reference merge. + #[test] + fn a_block_without_a_run_line_is_a_full_run() { + let older = BLOCK.replace("#run groups=0x0000003f time_pct=30 repeats=1\n", ""); + let p = parse_block(&older).unwrap(); + assert!(p.settings.is_full()); + assert!(p.settings.shortened_because().is_none()); + } + + #[test] + fn a_truncated_report_is_an_error_rather_than_an_empty_result() { + let cut = BLOCK.split("#totals").next().unwrap(); + assert!(parse_block(cut).is_err(), "a run that died mid-report must not parse"); + assert!(parse_block("nothing here").is_err()); + } + + fn a_run() -> Run { + let p = parsed(); + Run { + cell: "test".into(), + features: vec!["tlbvmap".into()], + machine: p.machine, + host: HostInfo { cpu_model: "Test CPU".into(), ..Default::default() }, + rows: p.rows, + checked: p.checked, + matched: p.matched, + total_ns: p.total_ns, + total_icount: p.total_icount, + wall_s: 30.0, + suite_id: "blake3:0123456789abcdef".into(), + settings: p.settings, + } + } + + #[test] + fn derived_figures_use_the_conventions_they_claim_to() { + let run = a_run(); + assert_eq!(run.accuracy(), 100.0); + // DMIPS is dhrystones/s over the VAX 11/780 constant, not a raw rate. + let dhry = run.row("int/dhrystone").unwrap(); + let want = dhry.rate() / DHRY_PER_DMIPS; + assert!((run.dmips().unwrap() - want).abs() < 1e-9); + assert!(run.dmips().unwrap() > 0.0); + } + + #[test] + fn a_category_aggregates_only_its_own_kernels_and_unit() { + let run = a_run(); + let int = CATEGORIES.iter().find(|c| c.label == "Integer").unwrap(); + // int/alu is "ops"; int/dhrystone is "dhry" and must not be folded in, + // since adding dhrystones to ALU operations is not a throughput. + let alu = run.row("int/alu").unwrap(); + let want = alu.work as f64 * 1e9 / alu.ns as f64; + assert!((run.category_rate(int).unwrap() - want).abs() < 1e-6); + + // A category with no kernels present is absent, not zero. + let img = CATEGORIES.iter().find(|c| c.label == "Imaging").unwrap(); + assert!(run.category_rate(img).is_none()); + } + + #[test] + fn the_reference_table_refuses_every_mismatch() { + let run = a_run(); + let row = ReferenceEntry { + id: "ref".into(), label: "Reference".into(), + cpu: "R4400".into(), engine: "interp".into(), host: "Test CPU".into(), + measured: None, guest_mips: 50.0, dmips: 70.0, accuracy: 100.0, + kernels: BTreeMap::new(), + }; + let table = |suite: &str, e: ReferenceEntry| ReferenceTable { + schema: 1, suite_id: suite.into(), entries: vec![e], + }; + + assert!(table(&run.suite_id, row.clone()).matching(&run).is_some()); + + // A different suite is two different workloads under one name. + assert!(table("blake3:ffffffffffffffff", row.clone()).matching(&run).is_none()); + // A different emulated CPU: the cache models differ deeply. + assert!(table(&run.suite_id, ReferenceEntry { cpu: "R5000".into(), ..row.clone() }) + .matching(&run).is_none()); + // A different engine: the two are about 4x apart. + assert!(table(&run.suite_id, ReferenceEntry { engine: "jitv2".into(), ..row.clone() }) + .matching(&run).is_none()); + // An empty table is the normal shipping state, not an error. + assert!(ReferenceTable { schema: 1, suite_id: String::new(), entries: Vec::new() } + .matching(&run).is_none()); + } + + #[test] + fn the_same_host_cpu_wins_over_a_merely_compatible_row() { + let run = a_run(); + let base = ReferenceEntry { + id: "other".into(), label: "Other".into(), + cpu: "R4400".into(), engine: "interp".into(), host: "Some Other CPU".into(), + measured: None, guest_mips: 1.0, dmips: 1.0, accuracy: 100.0, + kernels: BTreeMap::new(), + }; + let mine = ReferenceEntry { id: "mine".into(), host: "Test CPU".into(), ..base.clone() }; + let table = ReferenceTable { + schema: 1, suite_id: run.suite_id.clone(), entries: vec![base, mine], + }; + assert_eq!(table.matching(&run).unwrap().id, "mine"); + } + + /// The table that actually ships has to parse, whatever is in it. + #[test] + fn the_bundled_reference_table_loads() { + let t = bundled_reference(); + assert_eq!(t.schema, 1); + if !t.entries.is_empty() { + assert!(!t.suite_id.is_empty(), + "a populated table must name the suite its rows were measured against"); + for e in &t.entries { + assert!(!e.cpu.is_empty() && !e.engine.is_empty(), + "row {} has no cpu/engine, so nothing can be compared to it", e.id); + } + } + } + + #[test] + fn rates_are_formatted_with_an_si_prefix() { + assert_eq!(fmt_rate(38_500_000.0), "38.50 M"); + assert_eq!(fmt_rate(7_320.0), "7.32 k"); + assert_eq!(fmt_rate(0.0), "-"); + assert_eq!(fmt_rate(-1.0), "-"); + } +} diff --git a/src/bench_runner.rs b/src/bench_runner.rs new file mode 100644 index 0000000..d57c84c --- /dev/null +++ b/src/bench_runner.rs @@ -0,0 +1,399 @@ +//! Run the benchmark suite in-process and hand back a parsed report. +//! +//! The developer path spawns two processes — `iris-bench` spawns `iris`, which +//! loads a guest binary off disk and prints to a pipe. None of that survives an +//! application sandbox, and none of it is necessary: `iris` is a library, the +//! guest image is linked into it (`crate::benchsuite`), and the test device can +//! deliver the guest's console and exit code to a caller instead of to stdout +//! and `process::exit` (`TestDevice::new_embedded`). So the whole run is a +//! `Machine` on a worker thread, and it behaves identically on macOS, Windows +//! and Linux because there is nothing platform-specific left in it. +//! +//! `iris-bench run` uses this too, so "run the suite and parse the answer" has +//! one implementation rather than two. What still spawns processes is +//! `iris-bench matrix`, and necessarily: the CPU model and the JIT are +//! compile-time cargo features, so comparing them means comparing binaries. + +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use parking_lot::Mutex; + +use crate::bench_report::{host_info, parse_block, Run}; +use crate::benchsuite; +use crate::config::MachineConfig; +use crate::machine::Machine; +use crate::testdev::{RunConfig, TestDevice}; + +/// RAM for the bare-metal machine, in MB per bank — the same 256 MB in two +/// banks as `bench/run/bare.toml`. The suite probes for up to 24 MB of working +/// set above its image, and the DRAM-latency and stream kernels are only +/// measuring DRAM if the buffers genuinely do not fit in any cache. +pub const BENCH_BANKS: [u32; 4] = [128, 128, 0, 0]; + +/// Quick mode: shorter timed runs and one pass instead of best-of-two. +/// +/// It deliberately does **not** drop groups. Every kernel still runs and still +/// verifies against its golden checksum, so a quick run's accuracy score means +/// exactly what a full run's does — which matters, because accuracy is the +/// number the shipping build leads with. What it gives up is measurement +/// precision, and only that. +/// +/// The floor is each kernel's verification pass: one exact workload, because +/// that is what the golden checksum was computed against, so it cannot be +/// scaled. That is about 11 s of a full 45 s interpreted run, and it is why +/// quick mode lands near 20 s rather than near zero. +const QUICK: RunConfig = RunConfig { groups: 0, time_pct: 30, repeats: 1 }; + +#[derive(Debug, Clone)] +pub struct BenchOptions { + /// Fewer groups and shorter timed runs. Accuracy is unaffected: every + /// kernel that runs still verifies against its golden checksum. + pub quick: bool, + /// Name for the result. `iris-bench` uses the cell name; the GUI uses the + /// host CPU. + pub label: String, + pub banks: [u32; 4], + /// A hang detector, not a performance budget. + pub timeout: Duration, + /// Set this to abort. The machine is stopped and `run` returns an error — + /// there is no partial report, because the suite prints its block only at + /// the end. Exists so an interactive caller's Stop button does something + /// other than wait out the timeout. + pub cancel: Option>, +} + +impl Default for BenchOptions { + fn default() -> Self { + Self { + quick: false, + label: "local".to_string(), + banks: BENCH_BANKS, + timeout: Duration::from_secs(1800), + cancel: None, + } + } +} + +/// What the runner reports while the guest is working. +/// +/// The suite streams its human table a line at a time — deliberately, so a run +/// that prints nothing for a minute is not mistaken for a hang. The runner has +/// to read that stream anyway to know where it has got to, so the progress +/// events *are* the parsed console lines: a caller showing a progress bar and a +/// caller showing a console are looking at the same data at two levels of +/// detail, not at two separate channels. +#[derive(Debug, Clone)] +pub enum Progress { + /// The guest is up and has announced how many kernels it intends to run. + /// `total` is 0 if it did not say (an older guest image). + Started { total: usize }, + /// A kernel finished and printed its row. + Kernel { name: String, index: usize }, + /// One line of guest console output, verbatim. + Line(String), +} + +/// Run the suite and return the parsed report. +/// +/// Blocks until the guest exits, the timeout expires, or startup fails. Safe to +/// call from any thread; it does its own work on a worker with a large stack, +/// because `Machine::new` puts a >1 MB device map on the stack and Windows +/// gives a thread 1 MB by default. +pub fn run( + opts: &BenchOptions, + progress: impl FnMut(Progress) + Send + 'static, +) -> Result { + let opts = opts.clone(); + std::thread::Builder::new() + .name("bench-runner".to_string()) + .stack_size(16 * 1024 * 1024) + .spawn(move || run_inner(&opts, progress)) + .map_err(|e| format!("bench runner thread: {}", e))? + .join() + .map_err(|_| "the benchmark runner panicked".to_string())? +} + +fn run_inner( + opts: &BenchOptions, + mut progress: impl FnMut(Progress), +) -> Result { + let sink: Arc>> = Arc::new(Mutex::new(Vec::with_capacity(64 * 1024))); + let done = Arc::new(AtomicBool::new(false)); + let code = Arc::new(AtomicU32::new(0)); + + let cfg = if opts.quick { QUICK } else { RunConfig::ALL }; + + let testdev = { + let (done, code) = (done.clone(), code.clone()); + // Runs on the CPU thread inside the guest's store. Two relaxed atomic + // stores and a return — see `TestDevice::exit` for why returning is + // both safe and required. + let on_exit = Box::new(move |c: u32| { + code.store(c, Ordering::Relaxed); + done.store(true, Ordering::Release); + }); + Arc::new(TestDevice::new_embedded(sink.clone(), on_exit, cfg)) + }; + + let started = Instant::now(); + let mut machine = Box::new(Machine::new_with_testdev( + bench_config(opts.banks), + Some(testdev), + )); + // Deliberately no `register_system_controller`: it hands a raw pointer to + // this Machine to a thread that outlives the call, and this one is dropped + // when the run finishes. Nothing here needs `reset`/`save`/`load` anyway. + + machine.load_elf_bytes(benchsuite::SUITE_ELF, "irisbench.elf") + .map_err(|e| format!("loading the embedded suite: {}", e))?; + + machine.start(); + // `Machine::start` autostarts the CPU only in a non-developer release + // build; ask for it explicitly so a debug build (`cargo test`) runs too. + // `MipsCpu::start` is a no-op when it is already running. + machine.cpu_start(); + + let outcome = pump(&sink, &done, opts, started, &mut progress); + machine.stop(); + // Anything the guest printed between the last poll and the stop. + let text = String::from_utf8_lossy(&sink.lock()).into_owned(); + outcome?; + + let wall_s = started.elapsed().as_secs_f64(); + let p = parse_block(&text).map_err(|e| { + format!("{}\n--- last 20 lines of guest output ---\n{}", e, tail(&text, 20)) + })?; + + Ok(Run { + cell: opts.label.clone(), + features: crate::build_features::enabled().iter().map(|s| s.to_string()).collect(), + machine: p.machine, + host: host_info(), + rows: p.rows, + checked: p.checked, + matched: p.matched, + total_ns: p.total_ns, + total_icount: p.total_icount, + wall_s, + suite_id: benchsuite::suite_id(), + settings: p.settings, + }) +} + +/// Drain the guest console until it exits, reporting progress a line at a time. +fn pump( + sink: &Arc>>, + done: &Arc, + opts: &BenchOptions, + started: Instant, + progress: &mut impl FnMut(Progress), +) -> Result<(), String> { + let mut read = 0usize; // bytes of the sink already turned into lines + let mut partial = String::new(); + let mut state = Table::default(); + + loop { + // Read the flag *before* the buffer, not after. The guest's last act is + // to print its DONE line and then store to EXIT, so a flag read after + // the drain could go true in between and lose those lines from the + // progress stream. This way the final drain is guaranteed to be later + // than the store. (The parsed report never depended on this — the whole + // sink is re-read once the machine has stopped — but a console that + // drops the last line looks like a crash.) + let finished = done.load(Ordering::Acquire); + + // Take whatever is new. The lock is held only for the copy, never + // across the callback: `progress` is caller code and the CPU thread + // writes into this buffer one byte per guest `putc`. + let chunk = { + let buf = sink.lock(); + let chunk = buf[read.min(buf.len())..].to_vec(); + read = buf.len(); + chunk + }; + + partial.push_str(&String::from_utf8_lossy(&chunk)); + while let Some(nl) = partial.find('\n') { + let line: String = partial.drain(..=nl).collect(); + let line = line.trim_end_matches(['\n', '\r']).to_string(); + state.classify(&line, progress); + progress(Progress::Line(line)); + } + + if finished { + return Ok(()); + } + if opts.cancel.as_ref().is_some_and(|c| c.load(Ordering::Relaxed)) { + return Err("stopped".to_string()); + } + if started.elapsed() >= opts.timeout { + return Err(format!("the guest never finished — gave up after {}s", + opts.timeout.as_secs())); + } + std::thread::sleep(POLL); + } +} + +/// 50 ms is well under the ~250 ms a kernel's timed run takes, so no row is +/// ever more than a poll behind, and it costs one uncontended lock per poll. +const POLL: Duration = Duration::from_millis(50); + +/// Where the guest's output has got to. +/// +/// The human table is bracketed by two horizontal rules, and *only* rows +/// between them are kernels. Bracketing rather than pattern-matching a row is +/// what keeps the count honest: the "where the time went" list further down +/// also leads with `codec/lz`-style names, and counting those too ran the +/// progress bar six past its own total. +#[derive(Default)] +struct Table { + rules: usize, + kernels: usize, +} + +impl Table { + fn classify(&mut self, line: &str, progress: &mut impl FnMut(Progress)) { + if let Some(rest) = line.trim().strip_prefix("IRIS-BENCH-PLAN") { + let total = rest + .split_whitespace() + .filter_map(|t| t.strip_prefix("benches=")) + .filter_map(|v| v.parse().ok()) + .next() + .unwrap_or(0); + progress(Progress::Started { total }); + return; + } + + let trimmed = line.trim(); + if trimmed.len() >= 8 && trimmed.chars().all(|c| c == '-') { + self.rules += 1; + return; + } + if self.rules != 1 { + return; + } + + // "int/alu ops 61247926 103.36 ok" — group/kernel, then unit, + // then numbers. A row for a kernel skipped on this CPU has fewer. + let mut fields = line.split_whitespace(); + if let Some(name) = fields.next() { + if name.contains('/') && fields.next().is_some() { + self.kernels += 1; + progress(Progress::Kernel { name: name.to_string(), index: self.kernels }); + } + } + } +} + +/// The bare-metal machine the suite runs on: RAM, a test device, and nothing +/// else. No SCSI (there is no disk image and no filesystem to find one on), no +/// graphics, no audio. +pub fn bench_config(banks: [u32; 4]) -> MachineConfig { + let mut cfg = MachineConfig { + banks, + headless: true, + no_audio: true, + ..Default::default() + }; + // `MachineConfig::default` attaches scsi1.raw, which makes startup fatal + // when the file is absent — and here it always is. Same reason + // bench/run/bare.toml carries a present-but-empty `[scsi]`. + cfg.scsi.clear(); + cfg +} + +fn tail(s: &str, n: usize) -> String { + let lines: Vec<&str> = s.lines().collect(); + lines[lines.len().saturating_sub(n)..].join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The whole embedded path end to end: build a machine, load the suite out + /// of the binary, run it, parse what it printed. Quick mode so it is a test + /// rather than a coffee break. + /// + /// Accuracy is asserted at 100%, which makes this a correctness net for the + /// emulator and not just for the plumbing: every kernel checksums its + /// result against a golden value compiled into the guest image. + #[test] + #[ignore = "runs the emulator for ~30s; run with --ignored"] + fn the_embedded_suite_runs_and_scores_100_percent() { + use std::sync::atomic::AtomicUsize; + + let (lines, kernels, planned) = ( + Arc::new(AtomicUsize::new(0)), + Arc::new(AtomicUsize::new(0)), + Arc::new(AtomicUsize::new(0)), + ); + let run = { + let (l, k, p) = (lines.clone(), kernels.clone(), planned.clone()); + run( + &BenchOptions { quick: true, label: "test".into(), ..Default::default() }, + move |ev| { + match ev { + Progress::Line(_) => &l, + Progress::Kernel { .. } => &k, + Progress::Started { total } => { + p.store(total, Ordering::Relaxed); + return; + } + } + .fetch_add(1, Ordering::Relaxed); + }, + ) + } + .expect("the embedded suite must run"); + + eprintln!( + "quick run: {:.1}s wall, {:.1}s timed, {:.1} guest MIPS, {}/{} matched, {} rows", + run.wall_s, run.total_ns as f64 / 1e9, run.mips(), + run.matched, run.checked, run.rows.len() + ); + + assert!(!run.rows.is_empty(), "no kernels reported"); + assert!(run.checked > 0, "nothing was checked against a golden value"); + assert_eq!( + run.matched, run.checked, + "{} of {} checksums matched — the emulator computed a wrong answer", + run.matched, run.checked + ); + assert!(run.total_icount > 0, "no retired-instruction count — no test device?"); + assert!(run.machine.timebase, "no host time base: every timing would be a guess"); + assert_eq!(run.suite_id, benchsuite::suite_id()); + + // Quick mode narrows precision, not coverage: it must still have run + // every group, and the result must say it was shortened. + assert_eq!(run.settings.groups, crate::bench_report::BG_ALL); + assert!(!run.settings.is_full(), "a quick run must record that it was one"); + + // Progress must be usable for a progress bar: an up-front total that + // the rows actually reach. + let (planned, kernels) = (planned.load(Ordering::Relaxed), kernels.load(Ordering::Relaxed)); + assert!(planned > 0, "the guest never announced how many kernels it would run"); + assert_eq!(planned, kernels, "progress reported {} of a planned {}", kernels, planned); + assert_eq!(planned, run.rows.len(), "planned count disagrees with the rows reported"); + assert!(lines.load(Ordering::Relaxed) > kernels, "no console lines beyond the rows"); + } + + #[test] + fn the_bench_machine_has_no_disks() { + let cfg = bench_config(BENCH_BANKS); + assert!(cfg.scsi.is_empty(), "a bench machine must not try to open a disk image"); + assert!(cfg.headless && cfg.no_audio); + } + + #[test] + fn quick_mode_narrows_precision_and_nothing_else() { + // Accuracy is what the shipping build leads with, so quick mode must + // not quietly reduce what the score covers: every group still runs and + // every kernel still verifies. + assert_eq!(QUICK.groups, 0, "quick mode must run every group"); + assert!(QUICK.time_pct > 0 && QUICK.time_pct < 100); + assert_eq!(QUICK.repeats, 1); + } +} diff --git a/src/benchsuite.rs b/src/benchsuite.rs new file mode 100644 index 0000000..12140b8 --- /dev/null +++ b/src/benchsuite.rs @@ -0,0 +1,59 @@ +//! The benchmark suite's guest binary, carried inside the emulator. +//! +//! `bench/` builds a bare-metal MIPS image with a cross toolchain and drops it +//! in `bench/build/`. That is right for development and useless everywhere +//! else: a released application has no toolchain, and a sandboxed one has no +//! writable path to unpack an image to either. So a known-good build is checked +//! in at `bench/prebuilt/` and linked into the binary, and +//! `Machine::load_elf_bytes` loads it straight out of `.rodata` — no file, no +//! subprocess, no build step, identical on macOS, Windows and Linux. +//! +//! Precedent: the 512 KB PROM is already embedded (`crate::prombin`), though as +//! a generated Rust array rather than a real file. `include_bytes!` is smaller, +//! compiles faster, and leaves the artifact diffable as the binary it is. +//! +//! **The copy must not drift.** Accuracy is scored against golden checksums +//! compiled *into* this image, so a stale image against fresh goldens reports +//! failures that are not real. `.github/workflows/bench.yml` rebuilds it and +//! fails on any difference; `make -C bench prebuilt` refreshes it. + +/// The guest image, ELF32 MSB, ~285 KB. +pub static SUITE_ELF: &[u8] = include_bytes!("../bench/prebuilt/irisbench.elf"); + +/// Short blake3 of the guest binary, in the form stored on every result and in +/// `data/bench_reference.json`. +/// +/// Reference figures only mean something against the exact suite that produced +/// them — add or change a kernel and every stored number silently becomes a +/// comparison between two different workloads. Short because it is an identity +/// tag a human pastes into a JSON file, not a security digest. +pub fn suite_id() -> String { + suite_id_of(SUITE_ELF) +} + +pub fn suite_id_of(bytes: &[u8]) -> String { + format!("blake3:{}", &blake3::hash(bytes).to_hex()[..16]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_embedded_image_is_a_loadable_elf() { + let elf = crate::elf::parse(SUITE_ELF).expect("embedded suite must parse as ELF"); + assert!(!elf.segments.is_empty(), "no PT_LOAD segments"); + assert!(elf.entry != 0, "no entry point"); + // KSEG0. The suite links to a fixed address above the PROM's load area; + // anything else means the link script changed underneath us. + assert!(elf.entry >= 0xFFFF_FFFF_8800_0000, "entry {:#x} is not in KSEG0", elf.entry); + } + + #[test] + fn the_suite_id_is_stable_and_short() { + let id = suite_id(); + assert!(id.starts_with("blake3:")); + assert_eq!(id.len(), "blake3:".len() + 16); + assert_eq!(id, suite_id(), "suite_id must not depend on anything but the bytes"); + } +} diff --git a/src/bin/iris_bench.rs b/src/bin/iris_bench.rs index ca046af..717b181 100644 --- a/src/bin/iris_bench.rs +++ b/src/bin/iris_bench.rs @@ -16,272 +16,22 @@ //! the CPU model and the JIT are compile-time cargo features — comparing them //! means comparing binaries, not flags. -use std::collections::BTreeMap; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; -use std::time::Instant; +use std::time::{Duration, Instant}; use clap::{Parser, Subcommand}; -use serde::{Deserialize, Serialize}; - -const BEGIN: &str = "IRIS-BENCH-BEGIN"; -const END: &str = "IRIS-BENCH-END"; - -/// Kernels whose whole point is to take exceptions. Everywhere else a nonzero -/// count is a defect — see BF_TAKES_EXC in bench/harness/benchlib.h. -const EXPECT_EXC: &[&str] = &["sys/exception", "sys/tlb_miss"]; - -/// Dhrystones per second per DMIPS, by the VAX 11/780 convention every -/// published Dhrystone figure since 1988 uses. -const DHRY_PER_DMIPS: f64 = 1757.0; - -// ─── data model ────────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Row { - pub name: String, - pub unit: String, - pub iters: u64, - pub work: u64, - pub ns: u64, - pub icount: u64, - pub count: u64, - /// Exceptions taken during the timed run. Nonzero for a kernel that is not - /// meant to take any means it measured something other than what it - /// claims — the harness flags those on the line, and the report repeats it. - pub exc: u64, - pub checksum: String, - pub golden: String, - pub status: String, -} - -impl Row { - /// Work units per second. The unit is the kernel's own, so this is only - /// comparable across cells for the same kernel — which is exactly how the - /// report uses it. - pub fn rate(&self) -> f64 { - if self.ns == 0 { 0.0 } else { self.work as f64 * 1e9 / self.ns as f64 } - } - /// Guest instructions retired per host second. Zero when there is no - /// instruction counter (host runs, or an emulator without the test - /// device's timebase registers). - pub fn mips(&self) -> f64 { - if self.ns == 0 || self.icount == 0 { 0.0 } else { self.icount as f64 * 1e3 / self.ns as f64 } - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct Machine { - pub cpu: String, - pub prid: String, - pub fir: String, - pub config: String, - pub l2: bool, - pub testdev: bool, - pub timebase: bool, - pub count_hz: u64, - pub count_hz_measured: bool, - pub work_bytes: u64, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct HostInfo { - pub os: String, - pub arch: String, - pub cpu_model: String, - pub cores: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Run { - /// Cell label: "r4400-interp", "r5000-jitv2", "host", … - pub cell: String, - /// Cargo features the emulator was built with, from its own banner. - pub features: Vec, - pub machine: Machine, - pub host: HostInfo, - pub rows: Vec, - pub checked: usize, - pub matched: usize, - pub total_ns: u64, - pub total_icount: u64, - /// Wall clock for the whole process, including emulator startup — always - /// larger than total_ns, which counts only timed regions. - pub wall_s: f64, - /// Hash of the guest binary this result came from. Reference numbers only - /// mean anything against the exact suite that produced them: add or change - /// a kernel and every stored figure silently becomes a comparison between - /// two different workloads. Empty for a host run (no guest binary). - /// `default` so results recorded before this field existed still load. - #[serde(default)] - pub suite_id: String, -} - -impl Run { - pub fn accuracy(&self) -> f64 { - if self.checked == 0 { 0.0 } else { self.matched as f64 * 100.0 / self.checked as f64 } - } - pub fn mips(&self) -> f64 { - if self.total_ns == 0 || self.total_icount == 0 { 0.0 } - else { self.total_icount as f64 * 1e3 / self.total_ns as f64 } - } - pub fn row(&self, name: &str) -> Option<&Row> { - self.rows.iter().find(|r| r.name == name) - } - /// Dhrystone 2.1 DMIPS. - pub fn dmips(&self) -> Option { - self.row("int/dhrystone").map(|r| r.rate() / DHRY_PER_DMIPS) - } - /// Whetstone passes per second. Deliberately not converted to MWIPS: that - /// needs a "Whetstone instructions per loop" constant taken from a - /// reference implementation, and a figure resting on an unverified factor - /// of a thousand would look authoritative without being so. Passes per - /// second is exact and is what cell-to-cell comparison uses. - pub fn whet_loops(&self) -> Option { - self.row("fpu/whetstone").map(|r| r.rate()) - } - /// LINPACK 100x100 MFLOPS. - pub fn linpack_mflops(&self) -> Option { - self.row("fpu/linpack").map(|r| r.rate() / 1e6) - } -} - -// ─── parsing the suite's machine block ─────────────────────────────────────── - -fn parse_block(text: &str) -> Result<(Machine, Vec, usize, usize, u64, u64), String> { - let begin = text.find(BEGIN).ok_or_else(|| { - "no IRIS-BENCH-BEGIN in the output — the suite did not reach its report".to_string() - })?; - let end = text[begin..].find(END).ok_or_else(|| { - "output ends before IRIS-BENCH-END — the suite died partway through".to_string() - })? + begin; - - let mut machine = Machine::default(); - let mut rows = Vec::new(); - let (mut checked, mut matched, mut total_ns, mut total_ic) = (0usize, 0usize, 0u64, 0u64); - - for line in text[begin..end].lines().skip(1) { - let line = line.trim(); - if line.is_empty() { continue; } - if let Some(rest) = line.strip_prefix('#') { - let mut it = rest.split_whitespace(); - let kind = it.next().unwrap_or(""); - let kv: BTreeMap<&str, &str> = it - .filter_map(|tok| tok.split_once('=')) - .collect(); - let num = |k: &str| -> u64 { - kv.get(k).and_then(|v| parse_u64(v)).unwrap_or(0) - }; - match kind { - "machine" => { - machine.cpu = kv.get("cpu").unwrap_or(&"unknown").to_string(); - machine.prid = kv.get("prid").unwrap_or(&"").to_string(); - machine.fir = kv.get("fir").unwrap_or(&"").to_string(); - machine.config = kv.get("config").unwrap_or(&"").to_string(); - machine.l2 = num("l2") != 0; - machine.testdev = num("testdev") != 0; - machine.timebase = num("timebase") != 0; - } - "timebase" => { - machine.count_hz = num("count_hz"); - machine.count_hz_measured = num("measured") != 0; - } - "work" => machine.work_bytes = num("bytes"), - "totals" => { - checked = num("checked") as usize; - matched = num("matched") as usize; - total_ns = num("ns"); - total_ic = num("icount"); - } - _ => {} - } - continue; - } - - let f: Vec<&str> = line.split_whitespace().collect(); - if f.len() != 11 { continue; } - rows.push(Row { - name: f[0].to_string(), - unit: f[1].to_string(), - iters: parse_u64(f[2]).unwrap_or(0), - work: parse_u64(f[3]).unwrap_or(0), - ns: parse_u64(f[4]).unwrap_or(0), - icount: parse_u64(f[5]).unwrap_or(0), - count: parse_u64(f[6]).unwrap_or(0), - exc: parse_u64(f[7]).unwrap_or(0), - checksum: f[8].to_string(), - golden: f[9].to_string(), - status: f[10].to_string(), - }); - } - - if rows.is_empty() { - return Err("the report block held no benchmark rows".to_string()); - } - Ok((machine, rows, checked, matched, total_ns, total_ic)) -} - -fn parse_u64(s: &str) -> Option { - let s = s.trim(); - if let Some(hex) = s.strip_prefix("0x") { - u64::from_str_radix(hex, 16).ok() - } else { - s.parse().ok() - } -} - -/// Pull the feature list out of the emulator's own startup banner, so a saved -/// result records what produced it rather than what the caller believed. -fn parse_features(stderr: &str) -> Vec { - for line in stderr.lines() { - if let Some(rest) = line.strip_prefix("iris: build features: ") { - let rest = rest.trim(); - if rest == "(none)" { return Vec::new(); } - return rest.split_whitespace().map(str::to_string).collect(); - } - } - Vec::new() -} - -// ─── host identification ───────────────────────────────────────────────────── +use serde::Deserialize; -fn host_info() -> HostInfo { - let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0); - HostInfo { - os: std::env::consts::OS.to_string(), - arch: std::env::consts::ARCH.to_string(), - cpu_model: cpu_model(), - cores, - } -} - -fn cpu_model() -> String { - #[cfg(target_os = "linux")] - { - if let Ok(s) = std::fs::read_to_string("/proc/cpuinfo") { - for line in s.lines() { - if let Some((k, v)) = line.split_once(':') { - if k.trim() == "model name" || k.trim() == "Model" { - return v.trim().to_string(); - } - } - } - } - } - #[cfg(target_os = "macos")] - { - if let Ok(out) = Command::new("sysctl").args(["-n", "machdep.cpu.brand_string"]).output() { - let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if !s.is_empty() { return s; } - } - } - #[cfg(target_os = "windows")] - { - if let Ok(s) = std::env::var("PROCESSOR_IDENTIFIER") { return s; } - } - "unknown".to_string() -} +// The report's data model, its parser and the reference table live in the +// library: this binary, the in-process runner and the GUI all need them, and +// "what accuracy means" should have exactly one definition. +use iris::bench_report::{ + fmt_rate, host_info, parse_block, reference_entry, MachineInfo, ReferenceTable, Row, Run, + RunSettings, EXPECT_EXC, +}; +use iris::bench_runner::{self, BenchOptions}; // ─── running ───────────────────────────────────────────────────────────────── @@ -339,31 +89,29 @@ fn run_guest( let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); - let (machine, rows, checked, matched, total_ns, total_icount) = - parse_block(&stdout).map_err(|e| { - format!("{}\n--- last 20 lines of emulator output ---\n{}", e, tail(&stdout, 20)) - })?; + let p = parse_block(&stdout).map_err(|e| { + format!("{}\n--- last 20 lines of emulator output ---\n{}", e, tail(&stdout, 20)) + })?; Ok(Run { cell: label.to_string(), - features: parse_features(&stderr), - machine, + features: iris::bench_report::parse_features(&stderr), + machine: p.machine, host: host_info(), - rows, - checked, - matched, - total_ns, - total_icount, + rows: p.rows, + checked: p.checked, + matched: p.matched, + total_ns: p.total_ns, + total_icount: p.total_icount, wall_s, suite_id, + settings: p.settings, }) } -/// Short blake3 of the guest binary. Short because it is an identity tag a -/// human pastes into a JSON file, not a security digest. fn suite_id_of(elf: &Path) -> Result { let bytes = std::fs::read(elf).map_err(|e| format!("{}: {}", elf.display(), e))?; - Ok(format!("blake3:{}", &blake3::hash(&bytes).to_hex()[..16])) + Ok(iris::benchsuite::suite_id_of(&bytes)) } fn run_host(exe: &Path, timeout_s: u64) -> Result { @@ -374,20 +122,42 @@ fn run_host(exe: &Path, timeout_s: u64) -> Result { let out = run_with_timeout(Command::new(exe), timeout_s)?; let wall_s = started.elapsed().as_secs_f64(); let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); - let (mut machine, rows, checked, matched, total_ns, total_icount) = parse_block(&stdout)?; - machine.cpu = "host".to_string(); + let mut p = parse_block(&stdout)?; + p.machine.cpu = "host".to_string(); Ok(Run { cell: "host".to_string(), features: Vec::new(), - machine, + machine: p.machine, host: host_info(), - rows, - checked, - matched, - total_ns, - total_icount, + rows: p.rows, + checked: p.checked, + matched: p.matched, + total_ns: p.total_ns, + total_icount: p.total_icount, wall_s, suite_id: String::new(), + settings: p.settings, + }) +} + +/// Run the suite inside this process, streaming the guest's console as it +/// arrives. +/// +/// The guest prints its table a row at a time so that a run which shows nothing +/// for a minute is not mistaken for a hang, and that property is worth keeping +/// at the command line — so this echoes every line rather than waiting for the +/// end and printing the parsed summary. +fn run_embedded(label: &str, timeout_s: u64, quick: bool) -> Result { + let opts = BenchOptions { + quick, + label: label.to_string(), + timeout: Duration::from_secs(timeout_s), + ..Default::default() + }; + bench_runner::run(&opts, |p| { + if let bench_runner::Progress::Line(l) = p { + println!("{}", l); + } }) } @@ -688,7 +458,7 @@ fn run_irix(ci: &Ci, steps_path: &Path, label: &str) -> Result { Ok(Run { cell: label.to_string(), features: Vec::new(), - machine: Machine { cpu, ..Default::default() }, + machine: MachineInfo { cpu, ..Default::default() }, host: host_info(), rows, checked: 0, @@ -700,19 +470,13 @@ fn run_irix(ci: &Ci, steps_path: &Path, label: &str) -> Result { // so there is no suite hash and its numbers never join the reference // table — they are only comparable against runs on the same disk image. suite_id: String::new(), + // Not the bare-metal harness, so its run configuration does not apply. + settings: RunSettings::default(), }) } // ─── reports ───────────────────────────────────────────────────────────────── -fn fmt_rate(v: f64) -> String { - if v <= 0.0 { return "-".into(); } - if v >= 1e9 { format!("{:.2} G", v / 1e9) } - else if v >= 1e6 { format!("{:.2} M", v / 1e6) } - else if v >= 1e3 { format!("{:.2} k", v / 1e3) } - else { format!("{:.1}", v) } -} - fn fmt_ratio(a: f64, b: f64) -> String { if b <= 0.0 || a <= 0.0 { return "-".into(); } let r = a / b; @@ -901,91 +665,6 @@ fn text_summary(runs: &[Run]) -> String { } -// ─── reference rows ────────────────────────────────────────────────────────── -// -// `data/bench_reference.json` is the table the GUI compares a user's result -// against. It ships checked in and **starts empty** — a machine with no row is -// the normal case, not an error, and the GUI says "reference statistics not -// gathered for this platform" rather than inventing one. Rows are added by -// running the suite on a machine and pasting what this subcommand prints. -// -// Deliberately a static file updated by hand: the alternative (a user-writable -// override, an import/export pair, a fetch) is a lot of machinery for a table -// that changes when someone gets a new Mac. - -/// One machine's numbers, as they appear in `data/bench_reference.json`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReferenceEntry { - pub id: String, - pub label: String, - /// Emulated CPU (`R4400`/`R5000`) and execution engine (`interp`/`jitv2`). - /// Both matter: the two engines differ by about 4x, so a row without them - /// cannot be compared with anything. - pub cpu: String, - pub engine: String, - pub host: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub measured: Option, - pub guest_mips: f64, - pub dmips: f64, - pub accuracy: f64, - /// Work units per second, per kernel. - pub kernels: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReferenceTable { - pub schema: u32, - /// The guest binary these numbers were measured against. A result whose own - /// `suite_id` differs is not comparable — treat the table as empty rather - /// than comparing across two different workloads. - pub suite_id: String, - pub entries: Vec, -} - -/// `interp` unless the emulator's own feature banner says otherwise. Read from -/// the banner rather than inferred, so a mislabelled row is impossible. -fn engine_of(run: &Run) -> &'static str { - if run.features.iter().any(|f| f == "jitv2") { "jitv2" } else { "interp" } -} - -/// Today as `YYYY-MM-DD`, from the system clock. Hinnant's civil-from-days. -fn today() -> String { - let days = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { - Ok(d) => (d.as_secs() / 86_400) as i64, - Err(_) => return "unknown".into(), - }; - let z = days + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - format!("{:04}-{:02}-{:02}", if m <= 2 { y + 1 } else { y }, m, d) -} - -fn reference_entry(run: &Run, id: &str, label: Option<&str>, measured: Option<&str>) -> ReferenceEntry { - ReferenceEntry { - id: id.to_string(), - label: label.map(str::to_string).unwrap_or_else(|| { - format!("{} — {}", run.host.cpu_model, engine_of(run)) - }), - cpu: run.machine.cpu.clone(), - engine: engine_of(run).to_string(), - host: run.host.cpu_model.clone(), - measured: Some(measured.map(str::to_string).unwrap_or_else(today)), - guest_mips: (run.mips() * 10.0).round() / 10.0, - dmips: run.dmips().map(|v| (v * 10.0).round() / 10.0).unwrap_or(0.0), - accuracy: (run.accuracy() * 10.0).round() / 10.0, - kernels: run.rows.iter() - .filter(|r| r.status != "SKIP" && r.work > 0) - .map(|r| (r.name.clone(), (r.rate() * 100.0).round() / 100.0)) - .collect(), - } -} // ─── CLI ───────────────────────────────────────────────────────────────────── @@ -1002,15 +681,21 @@ struct Cli { #[derive(Subcommand, Debug)] enum Cmd { - /// Run the suite once under one emulator binary. + /// Run the suite once. + /// + /// In-process by default: this binary *is* an emulator, and the guest image + /// is linked into it, so there is no subprocess, no ELF on disk and nothing + /// platform-specific. Pass `--iris` to measure a different emulator binary + /// instead, which is what `matrix` does. Run { - /// Emulator to run. Defaults to target/release/iris. + /// Measure this emulator binary in a subprocess instead of this one + /// in-process. Needs --elf too, or a built bench/build/irisbench.elf. #[arg(long)] iris: Option, - /// Suite binary. Defaults to bench/build/irisbench.elf. + /// Suite binary for --iris. Defaults to bench/build/irisbench.elf. #[arg(long)] elf: Option, - /// Machine config. Defaults to bench/run/bare.toml. + /// Machine config for --iris. Defaults to bench/run/bare.toml. #[arg(long)] config: Option, /// Name this result. Defaults to "local". @@ -1020,7 +705,13 @@ enum Cmd { out: Option, #[arg(long, default_value_t = 1800)] timeout: u64, - /// Extra arguments passed through to the emulator. + /// Shorter timed runs and one pass per kernel instead of best of two. + /// Every kernel still runs and still verifies, so accuracy means the + /// same thing; only the rates are noisier. Not accepted with --iris — + /// the register that carries it is set at machine construction. + #[arg(long)] + quick: bool, + /// Extra arguments passed through to --iris. #[arg(last = true)] extra: Vec, }, @@ -1141,12 +832,38 @@ fn dispatch(cmd: Cmd) -> Result<(), String> { Ok(()) } - Cmd::Run { iris, elf, config, label, out, timeout, extra } => { - let iris = iris.unwrap_or_else(|| repo_relative("target/release/iris")); - let elf = elf.unwrap_or_else(|| repo_relative("bench/build/irisbench.elf")); - let config = config.unwrap_or_else(|| repo_relative("bench/run/bare.toml")); + Cmd::Run { iris, elf, config, label, out, timeout, quick, extra } => { let out = out.unwrap_or_else(default_out); - let run = run_guest(&iris, &elf, &config, &label, timeout, &extra)?; + let run = match iris { + Some(iris) => { + if quick { + return Err("--quick needs the in-process runner; drop --iris".into()); + } + let elf = elf.unwrap_or_else(|| repo_relative("bench/build/irisbench.elf")); + let config = config.unwrap_or_else(|| repo_relative("bench/run/bare.toml")); + run_guest(&iris, &elf, &config, &label, timeout, &extra)? + } + None => { + // Silently ignoring these would be worse than refusing: + // they all describe a subprocess that is not being started, + // and a run that quietly measured the wrong thing is the + // failure mode this whole suite exists to avoid. + let stray = [ + elf.is_some().then_some("--elf"), + config.is_some().then_some("--config"), + (!extra.is_empty()).then_some("trailing emulator arguments"), + ]; + let stray: Vec<&str> = stray.into_iter().flatten().collect(); + if !stray.is_empty() { + let (verb, obj) = if stray.len() == 1 { ("applies", "it") } + else { ("apply", "them") }; + return Err(format!( + "{} only {} to --iris, and `run` is in-process by default. \ + Add --iris PATH, or drop {}.", stray.join(" and "), verb, obj)); + } + run_embedded(&label, timeout, quick)? + } + }; let path = save(&run, &out)?; print!("{}", text_summary(std::slice::from_ref(&run))); println!("wrote {}", path.display()); @@ -1269,6 +986,14 @@ fn dispatch(cmd: Cmd) -> Result<(), String> { "{} has no suite_id — it predates the field, or it is a host run. \ Re-run the suite to record one.", path.display())); } + // A shortened run is accurate but imprecise, and the table is what + // every other machine gets compared against. Refuse rather than + // quietly enshrine a noisy row. + if let Some(why) = run.settings.shortened_because() { + return Err(format!( + "{} was not a full run ({}). Reference rows must be full runs — \ + re-run without --quick.", path.display(), why)); + } let entry = reference_entry(&run, &id, label.as_deref(), measured.as_deref()); let Some(table_path) = into else { diff --git a/src/lib.rs b/src/lib.rs index 217a002..bc380ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -69,6 +69,51 @@ pub mod build_features { } else { "MIPS R4400" }; + + /// Every compile-time flag this binary was built with, in a fixed order. + /// + /// CPU model and execution engine come first: these change what the guest + /// sees, not just how fast it sees it, and a benchmark result is meaningless + /// without them. `r5k` in particular was missing from this list long enough + /// that an R5000 build could report "build features: tlbvmap" and be taken + /// for an R4400 — cpu-tests/run/matrix.sh has a whole guard against exactly + /// that confusion. + /// + /// In the library rather than in `main.rs` because a saved benchmark result + /// records this list, and the in-process runner has no startup banner to + /// read it back out of. + pub fn enabled() -> Vec<&'static str> { + const FEATURES: &[(&str, bool)] = &[ + ("r5k", cfg!(feature = "r5k")), + ("r5ksc", cfg!(feature = "r5ksc")), + ("r5ksc_triton", cfg!(feature = "r5ksc_triton")), + ("mips4", cfg!(feature = "mips4")), + ("jitv2", cfg!(feature = "jitv2")), + ("jitv2_opcodefusion", cfg!(feature = "jitv2_opcodefusion")), + ("opcodefusion", cfg!(feature = "opcodefusion")), + ("idle-pause", cfg!(feature = "idle-pause")), + ("rex-jit", cfg!(feature = "rex-jit")), + ("lightning", cfg!(feature = "lightning")), + ("tlbvmap", cfg!(feature = "tlbvmap")), + ("tlbstats", cfg!(feature = "tlbstats")), + ("tlbcheck", cfg!(feature = "tlbcheck")), + ("instr_stats", cfg!(feature = "instr_stats")), + ("chd", cfg!(feature = "chd")), + ("camera", cfg!(feature = "camera")), + ("pcap", cfg!(feature = "pcap")), + ("ci_clock", cfg!(feature = "ci_clock")), + ("developer", cfg!(feature = "developer")), + ("developer_ip7", cfg!(feature = "developer_ip7")), + ("debug_cache", cfg!(feature = "debug_cache")), + ]; + FEATURES.iter().filter(|(_, e)| *e).map(|(n, _)| *n).collect() + } + + /// `enabled()` as the emulator prints it at startup. + pub fn banner() -> String { + let on = enabled(); + if on.is_empty() { "(none)".to_string() } else { on.join(" ") } + } } pub mod config; @@ -106,6 +151,9 @@ pub mod net; pub mod nfsudp; pub mod tftp; pub mod testdev; +pub mod bench_report; +pub mod benchsuite; +pub mod bench_runner; pub mod xdmcp; #[cfg(feature = "pcap")] pub mod net_pcap; diff --git a/src/machine.rs b/src/machine.rs index 4b0f78d..a182d1e 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -210,8 +210,35 @@ pub(crate) struct LiveCheckpoint { rex3_head1: Option, } +/// The test device and the ultra64 dev board both decode GIO expansion slot 0, +/// so only one of them can exist. Panics rather than exiting: `Machine::new` +/// already panics on bad input, and a host that embeds IRIS catches that +/// (`iris-gui`'s worker wraps construction in `catch_unwind`) — killing the +/// application over a configuration mistake is not a choice a library gets to +/// make for its caller. +fn check_testdev_slot_free(#[cfg(feature = "ultra64")] ultra64_present: bool) { + #[cfg(feature = "ultra64")] + if ultra64_present { + panic!("--test-device and the ultra64 dev board both claim GIO slot 0"); + } +} + impl Machine { pub fn new(cfg: MachineConfig) -> Self { + Self::new_with_testdev(cfg, None) + } + + /// `new`, with the bare-metal test device supplied by the caller rather + /// than built from `cfg.test_device_dump`. Passing one enables it — the + /// device *is* the request, so `cfg.test_device` need not also be set. + /// + /// Exists for hosts that link IRIS as a library and need the guest's + /// console and exit code delivered in-process instead of to stdout and + /// `process::exit` (`crate::bench_runner`, `TestDevice::new_embedded`). + pub fn new_with_testdev( + cfg: MachineConfig, + testdev_override: Option>, + ) -> Self { // Capture config flags that are needed after the local `cfg` binding // is shadowed later in this function. let ci_enabled = cfg.ci; @@ -558,12 +585,17 @@ impl Machine { // Bare-metal test device (--test-device): default off, and refused // alongside the ultra64 dev board, which claims the same GIO slot. - let testdev = if cfg.test_device { - #[cfg(feature = "ultra64")] - if ultra64.is_some() { - eprintln!("iris: fatal: --test-device and the ultra64 dev board both claim GIO slot 0"); - std::process::exit(1); - } + let testdev = if let Some(dev) = testdev_override { + check_testdev_slot_free( + #[cfg(feature = "ultra64")] + ultra64.is_some(), + ); + Some(dev) + } else if cfg.test_device { + check_testdev_slot_free( + #[cfg(feature = "ultra64")] + ultra64.is_some(), + ); let path = cfg.test_device_dump.clone() .unwrap_or_else(|| crate::testdev::DEFAULT_DUMP_PATH.to_string()); eprintln!("iris: test device enabled at {:#010x}, dumps to {}", @@ -1066,7 +1098,19 @@ impl Machine { // addresses go to UnmappedRam, so map the banks as POST would first. let mapped = self.mc.post_map_banks(); let out = self.cpu.load_elf(path)?; - Ok(if mapped { format!(" (mapped RAM banks; POST has not run)\n{}", out) } else { out }) + Ok(Self::note_banks(mapped, out)) + } + + /// `load_elf` for an image already in memory — see + /// `MipsCpu::load_elf_bytes`. `name` only labels errors. + pub fn load_elf_bytes(&self, bytes: &[u8], name: &str) -> Result { + let mapped = self.mc.post_map_banks(); + let out = self.cpu.load_elf_bytes(bytes, name)?; + Ok(Self::note_banks(mapped, out)) + } + + fn note_banks(mapped: bool, out: String) -> String { + if mapped { format!(" (mapped RAM banks; POST has not run)\n{}", out) } else { out } } /// The in-process serial backend used by `--ci` mode. `None` in diff --git a/src/main.rs b/src/main.rs index 8abe782..c10a5f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -143,40 +143,8 @@ fn main() { /// Print which compile-time feature flags this binary was built with. Handy /// when diagnosing behaviour that depends on the build (e.g. MIPS `jit` bypasses /// the interpreter's idle-park path, so an idle guest spins the host CPU). +/// The list itself lives in the library — see `iris::build_features::enabled`. fn print_build_features() { - const FEATURES: &[(&str, bool)] = &[ - // CPU model and execution engine first: these change what the guest - // sees, not just how fast it sees it, and a benchmark result is - // meaningless without them. `r5k` in particular was missing here long - // enough that an R5000 build could report "build features: tlbvmap" - // and be taken for an R4400 — cpu-tests/run/matrix.sh has a whole - // guard against exactly that confusion. - ("r5k", cfg!(feature = "r5k")), - ("r5ksc", cfg!(feature = "r5ksc")), - ("r5ksc_triton", cfg!(feature = "r5ksc_triton")), - ("mips4", cfg!(feature = "mips4")), - ("jitv2", cfg!(feature = "jitv2")), - ("jitv2_opcodefusion", cfg!(feature = "jitv2_opcodefusion")), - ("opcodefusion", cfg!(feature = "opcodefusion")), - ("idle-pause", cfg!(feature = "idle-pause")), - ("rex-jit", cfg!(feature = "rex-jit")), - ("lightning", cfg!(feature = "lightning")), - ("tlbvmap", cfg!(feature = "tlbvmap")), - ("tlbstats", cfg!(feature = "tlbstats")), - ("tlbcheck", cfg!(feature = "tlbcheck")), - ("instr_stats", cfg!(feature = "instr_stats")), - ("chd", cfg!(feature = "chd")), - ("camera", cfg!(feature = "camera")), - ("pcap", cfg!(feature = "pcap")), - ("ci_clock", cfg!(feature = "ci_clock")), - ("developer", cfg!(feature = "developer")), - ("developer_ip7", cfg!(feature = "developer_ip7")), - ("debug_cache", cfg!(feature = "debug_cache")), - ]; - let on: Vec<&str> = FEATURES.iter().filter(|(_, e)| *e).map(|(n, _)| *n).collect(); - eprintln!( - "iris: build features: {}", - if on.is_empty() { "(none)".to_string() } else { on.join(" ") } - ); + eprintln!("iris: build features: {}", iris::build_features::banner()); } diff --git a/src/mips_exec.rs b/src/mips_exec.rs index 372e5a5..8f5de00 100644 --- a/src/mips_exec.rs +++ b/src/mips_exec.rs @@ -8440,7 +8440,16 @@ impl MipsCpu { /// zero-fill to `p_memsz`, set PC to `e_entry`. Returns a segment summary. pub fn load_elf(&self, path: &str) -> Result { let bytes = std::fs::read(path).map_err(|e| format!("{}: {}", path, e))?; - let elf = crate::elf::parse(&bytes).map_err(|e| format!("{}: {}", path, e))?; + self.load_elf_bytes(&bytes, path) + } + + /// `load_elf` on an image already in memory. `name` only labels errors — + /// nothing here touches the filesystem, which is what lets a host embedding + /// IRIS as a library run a bare-metal image it carries in its own binary + /// (`crate::benchsuite`) without writing a temporary file. A sandboxed app + /// has nowhere to write one. + pub fn load_elf_bytes(&self, bytes: &[u8], name: &str) -> Result { + let elf = crate::elf::parse(bytes).map_err(|e| format!("{}: {}", name, e))?; self.check_stopped()?; let mut exec = self.try_lock_executor()?; let mut out = String::new(); diff --git a/src/testdev.rs b/src/testdev.rs index a116d59..5450347 100644 --- a/src/testdev.rs +++ b/src/testdev.rs @@ -22,6 +22,13 @@ //! //! The guest detects the device by reading `SIGNATURE`; on real hardware the //! empty slot times out, so a suite falls back to SCC-only output. +//! +//! **Two output modes.** `new()` is the `iris --test-device` process: bytes to +//! stdout, dumps to a file, `EXIT` ends the process. `new_embedded()` is for a +//! host that links IRIS as a library and runs a bare-metal image in-process +//! (`crate::bench_runner`, and the GUI behind it): bytes to a buffer the +//! embedder drains, no dump file, and `EXIT` calls a hook instead of killing +//! the host application. use std::io::Write; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; @@ -56,6 +63,12 @@ pub const REG_ICOUNT_HI: u32 = 0x1C; /// Capability bitmask, so a guest built against a newer header can still run /// on an older emulator: read it, and only use what it advertises. pub const REG_CAPS: u32 = 0x20; +/// Run configuration, read once at guest startup. A bare-metal image loaded +/// with `--load-elf` has no argv and no environment, so this register is how +/// the host asks for a shorter run: see `RunConfig`. Zero — the value an +/// emulator without the capability returns — means "everything, full length", +/// so a guest that reads it unconditionally still behaves. +pub const REG_RUN_CONFIG: u32 = 0x24; /// Registers decode within this many bytes and the window repeats across the /// whole 64 KB the device claims. Was 16 before the clock/icount registers. @@ -66,15 +79,84 @@ pub const SIGNATURE: u32 = 0x4952_4953; /// `REG_CAPS` bit 0: `REG_HOST_NS_*` and `REG_ICOUNT_*` are present. pub const CAP_TIMEBASE: u32 = 1 << 0; +/// `REG_CAPS` bit 1: `REG_RUN_CONFIG` is present and meaningful. +pub const CAP_RUN_CONFIG: u32 = 1 << 1; + +/// What `REG_RUN_CONFIG` carries, packed into one word: +/// +/// ```text +/// 31 16 15 12 11 0 +/// +---------------+-------+---------------+ +/// | groups |repeats| time_pct | +/// +---------------+-------+---------------+ +/// ``` +/// +/// **Every field means "unrestricted" when zero**, so the word reading back as +/// 0 — on an emulator that predates the register, or on a run that never set +/// it — is exactly the behaviour the suite had before it existed. That is the +/// whole reason for the encoding: a guest can read it unconditionally. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RunConfig { + /// The suite's own group mask — `BG_INT`, `BG_FPU`, … from + /// `bench/harness/benchlib.h`, which the guest ANDs against each kernel's + /// declared group. 0 selects all of them. + pub groups: u16, + /// Per-kernel target time as a percentage of the harness default. 0 means + /// unchanged; the guest clamps against its own floor. Capped at 12 bits. + pub time_pct: u16, + /// Timed passes per kernel. 0 means the harness default (best of two — + /// the slow sample is host scheduling noise, since the guest does a fixed + /// amount of work either way). Capped at 4 bits. + pub repeats: u8, +} + +impl RunConfig { + pub const ALL: Self = Self { groups: 0, time_pct: 0, repeats: 0 }; + + pub const fn to_word(self) -> u32 { + ((self.groups as u32) << 16) + | (((self.repeats as u32) & 0xF) << 12) + | (self.time_pct as u32 & 0xFFF) + } + + pub const fn from_word(w: u32) -> Self { + Self { + groups: (w >> 16) as u16, + repeats: ((w >> 12) & 0xF) as u8, + time_pct: (w & 0xFFF) as u16, + } + } +} /// Where `REG_DUMP` writes go when no path is configured. pub const DEFAULT_DUMP_PATH: &str = "iris-testdev-dump.json"; +/// Called on `REG_EXIT` in embedded mode, on the CPU thread, from inside the +/// guest's store. It must not block on anything that thread owns — signal and +/// return; see `TestDevice::exit`. +pub type ExitHook = Box; + +/// Where `PUTC` bytes go. +enum Console { + /// The process's own stdout, flushed per line. + Stdout, + /// A buffer the embedder drains. Unbounded: a bare-metal image prints + /// kilobytes, and truncating the guest's own report to save memory would + /// lose the part the host is there to parse. + Buffer(std::sync::Arc>>), +} + pub struct TestDevice { - /// Dump file path. Never defaults to a bare CWD-relative name in a - /// sandboxed app, where the CWD is `/` and unwritable — the caller passes - /// an absolute path there. - dump_path: std::path::PathBuf, + /// Dump file path, or `None` in embedded mode, where `DUMP` is a counted + /// no-op — a sandboxed host has nowhere to put the file and the embedded + /// users (benchmarks) never ask for one. Never defaults to a bare + /// CWD-relative name in a sandboxed app, where the CWD is `/` and + /// unwritable — the caller passes an absolute path there. + dump_path: Option, + out: Console, + on_exit: Option, + /// Published to the guest at `REG_RUN_CONFIG`. + run_config: AtomicU32, /// Set by `attach_core`. Only read from the CPU thread, which is the thread /// that issues the store that lands here, so there is no race — the same /// process-lifetime argument as `MipsCpu`'s `cycles_ptr`/`interrupts_ptr`. @@ -100,8 +182,30 @@ unsafe impl Sync for CorePtr {} impl TestDevice { pub fn new(dump_path: impl Into) -> Self { + Self::build(Some(dump_path.into()), Console::Stdout, None, RunConfig::ALL) + } + + /// In-process mode: guest console into `sink`, `EXIT` into `on_exit`, no + /// dump file, and `cfg` published at `REG_RUN_CONFIG`. + pub fn new_embedded( + sink: std::sync::Arc>>, + on_exit: ExitHook, + cfg: RunConfig, + ) -> Self { + Self::build(None, Console::Buffer(sink), Some(on_exit), cfg) + } + + fn build( + dump_path: Option, + out: Console, + on_exit: Option, + cfg: RunConfig, + ) -> Self { Self { - dump_path: dump_path.into(), + dump_path, + out, + on_exit, + run_config: AtomicU32::new(cfg.to_word()), core: Mutex::new(None), dumps: AtomicU32::new(0), last_tag: AtomicU64::new(0), @@ -118,8 +222,8 @@ impl TestDevice { *self.core.lock() = Some(CorePtr(core)); } - pub fn dump_path(&self) -> &std::path::Path { - &self.dump_path + pub fn dump_path(&self) -> Option<&std::path::Path> { + self.dump_path.as_deref() } pub fn dumps_written(&self) -> u32 { @@ -127,11 +231,18 @@ impl TestDevice { } fn putc(&self, byte: u8) { - let mut out = std::io::stdout().lock(); - let _ = out.write_all(&[byte]); - // Flush per line so a hung test still shows everything it printed. - if byte == b'\n' { - let _ = out.flush(); + match &self.out { + Console::Stdout => { + let mut out = std::io::stdout().lock(); + let _ = out.write_all(&[byte]); + // Flush per line so a hung test still shows everything it printed. + if byte == b'\n' { + let _ = out.flush(); + } + } + // No per-line flush to do: the embedder reads the buffer whenever + // it likes, and a partial line is visible the moment it lands. + Console::Buffer(buf) => buf.lock().push(byte), } self.chars.fetch_add(1, Ordering::Relaxed); } @@ -140,6 +251,12 @@ impl TestDevice { /// a test can dump more than once; the file is overwritten each time. fn dump(&self, tag: u32) { self.last_tag.store(tag as u64, Ordering::Relaxed); + // Embedded: nothing to write to. Still counted, so `testdev` and the + // save state report what the guest asked for. + let Some(path) = self.dump_path.clone() else { + self.dumps.fetch_add(1, Ordering::Relaxed); + return; + }; let guard = self.core.lock(); let Some(CorePtr(ptr)) = guard.as_ref() else { eprintln!("test device: DUMP with no CPU attached"); @@ -150,18 +267,31 @@ impl TestDevice { let json = dump_json(core, tag); drop(guard); - match std::fs::write(&self.dump_path, json) { + match std::fs::write(&path, json) { Ok(()) => { self.dumps.fetch_add(1, Ordering::Relaxed); - eprintln!("test device: dump {} → {}", tag, self.dump_path.display()); + eprintln!("test device: dump {} → {}", tag, path.display()); } - Err(e) => eprintln!("test device: dump {} to {}: {}", tag, self.dump_path.display(), e), + Err(e) => eprintln!("test device: dump {} to {}: {}", tag, path.display(), e), } } - /// Terminate the emulator with the guest's exit code. Stdout is flushed - /// first so buffered `PUTC` output is never lost. - fn exit(&self, code: u32) -> ! { + /// The guest is done. Standalone, that means ending the process with its + /// exit code; embedded, it means telling the host and *returning*. + /// + /// Returning is safe, and is the reason this needs no parking or signalling + /// on the CPU thread: every guest that has a test device reaches this + /// through `testdev_exit()`, which spins forever afterwards + /// (`cpu-tests/harness/console.c`) precisely because a bare-metal image has + /// nowhere to return to. So the store completes, the CPU thread carries on + /// looping in guest code that does nothing, and the host stops the machine + /// from its own thread whenever it gets to it. A hook that blocked here + /// instead would deadlock `Machine::stop`, which joins this thread. + fn exit(&self, code: u32) { + if let Some(hook) = &self.on_exit { + hook(code); + return; + } let _ = std::io::stdout().flush(); eprintln!("test device: guest requested exit({})", code as u8); std::process::exit(code as u8 as i32) @@ -258,7 +388,8 @@ impl TestDevice { REG_HOST_NS_HI => (self.host_ns_latch.load(Ordering::Relaxed) >> 32) as u32, REG_ICOUNT_LO => self.latch_icount() as u32, REG_ICOUNT_HI => (self.icount_latch.load(Ordering::Relaxed) >> 32) as u32, - REG_CAPS => CAP_TIMEBASE, + REG_CAPS => CAP_TIMEBASE | CAP_RUN_CONFIG, + REG_RUN_CONFIG => self.run_config.load(Ordering::Relaxed), _ => 0, } } @@ -317,7 +448,10 @@ impl Device for TestDevice { return Err("Command not found".to_string()); } writeln!(writer, "test device @ {:#010x} signature {:#010x}", TEST_DEV_BASE, SIGNATURE).unwrap(); - writeln!(writer, " dump file : {}", self.dump_path.display()).unwrap(); + match &self.dump_path { + Some(p) => writeln!(writer, " dump file : {}", p.display()).unwrap(), + None => writeln!(writer, " dump file : (embedded — DUMP is a no-op)").unwrap(), + } writeln!(writer, " dumps : {} (last tag {})", self.dumps.load(Ordering::Relaxed), self.last_tag.load(Ordering::Relaxed)).unwrap(); writeln!(writer, " chars out : {}", self.chars.load(Ordering::Relaxed)).unwrap(); @@ -374,6 +508,66 @@ mod tests { assert_eq!(d.read32(TEST_DEV_BASE + REG_CAPS).data & CAP_TIMEBASE, CAP_TIMEBASE); } + #[test] + fn run_config_round_trips_and_defaults_to_unrestricted() { + // The property the guest depends on: a zero word is "run everything, + // full length", so reading the register unconditionally is safe. + assert_eq!(RunConfig::from_word(0), RunConfig::ALL); + assert_eq!(RunConfig::ALL.to_word(), 0); + + for c in [ + RunConfig { groups: 0xBEEF, time_pct: 30, repeats: 1 }, + RunConfig { groups: 1, time_pct: 4095, repeats: 15 }, + RunConfig { groups: 0, time_pct: 100, repeats: 2 }, + ] { + assert_eq!(RunConfig::from_word(c.to_word()), c, "{:?} did not round-trip", c); + } + + // Fields must not bleed into each other. + let c = RunConfig { groups: 0xFFFF, time_pct: 0, repeats: 0 }; + assert_eq!(c.to_word(), 0xFFFF_0000); + } + + #[test] + fn the_run_config_register_reads_back_what_it_was_built_with() { + let want = RunConfig { groups: 0b101, time_pct: 30, repeats: 1 }; + let d = TestDevice::new_embedded( + std::sync::Arc::new(Mutex::new(Vec::new())), + Box::new(|_| {}), + want, + ); + assert_eq!(d.read32(TEST_DEV_BASE + REG_CAPS).data & CAP_RUN_CONFIG, CAP_RUN_CONFIG); + assert_eq!(RunConfig::from_word(d.read32(TEST_DEV_BASE + REG_RUN_CONFIG).data), want); + } + + #[test] + fn embedded_mode_captures_output_and_reports_exit_without_killing_the_process() { + let sink = std::sync::Arc::new(Mutex::new(Vec::new())); + let seen = std::sync::Arc::new(AtomicU32::new(u32::MAX)); + let d = { + let seen = seen.clone(); + TestDevice::new_embedded( + sink.clone(), + Box::new(move |c| seen.store(c, Ordering::Relaxed)), + RunConfig::ALL, + ) + }; + + for b in b"hi\n" { d.write32(TEST_DEV_BASE + REG_PUTC, *b as u32); } + assert_eq!(&*sink.lock(), b"hi\n"); + + // DUMP has nowhere to go and must not try: still counted, no file, and + // above all no panic on a device with no dump path. + d.write32(TEST_DEV_BASE + REG_DUMP, 3); + assert_eq!(d.dumps_written(), 1); + assert!(d.dump_path().is_none()); + + // The point of the whole exercise: EXIT returns instead of ending the + // host process, so this test can observe it at all. + d.write32(TEST_DEV_BASE + REG_EXIT, 7); + assert_eq!(seen.load(Ordering::Relaxed), 7); + } + #[test] fn host_ns_latches_so_lo_then_hi_cannot_tear() { let d = TestDevice::new("unused"); From aa0ec687edd91c184c4eb1be38fcb399450b9b06 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 21 Aug 2026 14:55:52 -0400 Subject: [PATCH 10/15] iris-gui: a Benchmark tab that ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One button, one number, on every platform including inside the App Store sandbox. The tab used to spawn iris-bench, which spawned iris, which read an ELF off disk — three steps, none of which survive a sandbox, which is why it was hidden from distributed builds. It now runs iris::bench_runner in-process and is visible everywhere; its developer half (the matrix runner, which builds one emulator per cell because the CPU and the JIT are cargo features) folds into a disclosure that is compiled out under feature = "appstore". The screen leads with the three figures that stand on their own — DMIPS, guest MIPS, and accuracy — because that is what makes an empty reference table cost the reader almost nothing. Comparison is an enhancement, never a dependency. Accuracy is as prominent as speed: no other emulator reports whether it computed the right answer, so 100% tells a user something real and 97% has found a bug worth reporting. Caveats are stated rather than left to be discovered. A quick run says it was one. An interpreter build says a JIT build scores roughly four times higher and is not comparable — which matters, because the App Store build forces IRIS_NO_JIT=1 and Cranelift's mmap+mprotect is not MAP_JIT. A kernel that took an unexpected exception is named, because the harness steps over faults and the kernel still reports a throughput for doing something other than what it claims. Progress is honest: the guest announces how many kernels it will run, and the time estimate refuses to extrapolate before four rows have finished — the first rows are the cheap integer kernels and the last are the expensive codec ones, so an estimate from row two is confidently wrong. The console is one click down; a wall of monospace as the primary surface reads as "something went wrong" to a reader who did not ask for a log. A run is refused while a machine is running: two emulators sharing the host would measure whatever IRIX happened to be doing, and refusing is simpler than explaining the result afterwards. Export is an explicit rfd save panel; nothing is uploaded. Quick is the default. On a plain release interpreter it is 33 s against 58 s for a full run and reports the same figures to within a couple of percent, while giving up no accuracy at all. docs/gui-benchmark-plan.md becomes a design-and-status record rather than a plan, including the two things deliberately not built: the in-process host baseline, which needs a build-system decision about making a C compiler a requirement of the iris crate, and a measurement-spread warning for thermally throttled laptops. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 54 ++ README.md | 11 +- docs/gui-benchmark-plan.md | 546 ++++++++---------- iris-gui/src/bench_ui.rs | 1099 ++++++++++++++++++++++++++++-------- iris-gui/src/config_ui.rs | 12 +- 5 files changed, 1196 insertions(+), 526 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3812ec1..c1c8f12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,60 @@ produces deterministic results. ### Added +#### Benchmark, for everyone + +- **The benchmark runs in-process, on every platform.** `iris-bench run` and + the GUI's Benchmark tab no longer spawn anything: the guest binary is linked + into `iris` (`bench/prebuilt/`, `src/benchsuite.rs`) and runs on a headless + machine the emulator builds for itself (`iris::bench_runner`). No MIPS cross + toolchain, no ELF on disk, no subprocess, and nothing written outside the + application container — so the Benchmark tab now ships in App Store builds + instead of being hidden. `iris-bench run --iris PATH` still measures a + separate binary in a subprocess, which is what `matrix` needs. +- **The bare-metal suite got 2.5x faster** (117 s → 46 s for a full r4400 + lightning run, same 40/40 accuracy). `--load-elf` skips the PROM, so the + SCC's transmitter is never enabled and every character the guest printed + burned a 100,000-iteration spin waiting for a TX-empty bit that would never + come. `cpu-tests` was paying the same tax and gets the same speedup. See + `rules/testing/scc-serial-output-from-bare-metal-code.md`. +- **Quick mode** (`iris-bench run --quick`, and the GUI's default): about half + the wall clock for the same numbers to within a couple of percent. It never + runs fewer kernels — accuracy would then mean less while still reading 100% — + it only shortens the timed passes. Requested through a new test-device + register (`TESTDEV_RUN_CONFIG`), since a bare-metal image has no argv; every + field means "unrestricted" when zero, so older emulators are unaffected. + Recorded on every result, and refused by `iris-bench reference`. +- **Every result records the machine it measured.** The suite now reads the + hardware out of the hardware before it starts — CPU identity and revision and + the L1/L2 geometry from CP0 Config, the RAM banks from the memory + controller's MEMCFG registers — and prints it as a header and as `#cache` / + `#memory` lines in the machine block. Works with no PROM and no POST, since + `--load-elf` programs MEMCFG exactly as POST would. This is provenance that + matters: the `mem/` kernels are a direct readout of the cache hierarchy, and + nothing in a saved result previously said whether two results even had the + same one. The GUI shows it under "Machine measured"; the exported report + carries it. +- **Any MIPS CPU is identified and runs.** The suite named only the R4400 and + R5000 and *refused to run* on anything else, on the stated grounds that "the + golden checksums are selected by PRId" — which was not true: `golden.h` is one + flat, CPU-independent table and no kernel is CPU-gated. It now names R4000, + R4400, R4600, R4700, R5000, R8000, R10000, R12000, R14000, RM5200 and RM7000 + from PRId (R4000 and R4400 split on revision, the standard rule), prints an + unrecognised implementation as `MIPS-imp-0xNN`, and runs either way. Verified + by presenting an R10000 and an unknown implementation to the guest: both + identify correctly and score 40/40. +- **Platform limits documented.** The bare-metal harness both suites share is + written for an Indy or Indigo2 (IP22/IP24): the load address, the console and + the memory inventory all assume that machine, and the load address is what + stops a port first, not the console. Recorded once in + `rules/testing/bare-metal-harness-platform-assumptions.md` — including which + claims were tested and which are reasoning, and why an ARCS-based port would + be one path for the whole SGI family rather than one per machine. +- `iris::bench_report` — the report parser, data model and reference table + moved out of `src/bin/iris_bench.rs` so the CLI, the runner and the GUI share + one definition of what "accuracy" means. + + #### Snapshot system - **Save/restore/rollback** (`save_snapshot` / `load_snapshot` / diff --git a/README.md b/README.md index d80caab..72c2f94 100644 --- a/README.md +++ b/README.md @@ -441,12 +441,21 @@ each run reports an accuracy percentage next to its throughput — and per-kerne interpreter and jitv2. ```sh -make -C bench && make -C bench hostbench cargo build --release --bin iris-bench +./target/release/iris-bench run # ~60 s; --quick for about half that +./target/release/iris-bench run --quick + +make -C bench && make -C bench hostbench # only to change the suite ./target/release/iris-bench matrix # builds and runs every CPU x engine cell ./target/release/iris-bench host # the same kernels, natively, for the ratio ``` +`run` needs **no MIPS toolchain and no build step**: a known-good guest binary +is checked in at `bench/prebuilt/` and linked into `iris`, and the run happens +in-process on a headless machine the emulator builds for itself. That is also +what the GUI's **Benchmark tab** does, on every platform and inside the App +Store sandbox — one button, and the accuracy score sits next to the speed. + Includes Dhrystone 2.1 (DMIPS) and LINPACK 100x100 (MFLOPS), so an emulated Indy can be put next to published figures for a real one, plus a Whetstone mix (reported in passes/s — see bench/README.md for why not MWIPS). See diff --git a/docs/gui-benchmark-plan.md b/docs/gui-benchmark-plan.md index 08dc6a5..e5bd096 100644 --- a/docs/gui-benchmark-plan.md +++ b/docs/gui-benchmark-plan.md @@ -1,362 +1,310 @@ -# Benchmark in iris-gui — feature map +# Benchmark in iris-gui -Goal: an App Store user presses one button and gets a meaningful score for their -machine, on every platform, with no toolchain, no subprocess, and no files -written outside the sandbox. +Goal: a user presses one button and gets a meaningful score for their machine, +on every platform, with no toolchain, no subprocess, and no files written +outside the sandbox. -Status: **plan only.** Nothing below is built. The developer-facing path -(`iris-bench`, `bench/`, the Benchmark tab hidden under `!appstore`) already -works and is what this reuses. +**Status: built.** The spine (an embeddable emulator, the suite as a linked-in +asset, an in-process runner, the GUI screen over it) is implemented and tested. +What is left is listed under [Not built](#not-built) at the end — one of the two +items is a build-system decision rather than work. --- -## The fact that makes this tractable +## What it does now -`iris-gui` depends on `iris` as a **library** and already runs the emulator -**in-process** on a worker thread (`handle.rs:382`, `Machine::new(cfg_owned)` -inside `catch_unwind`). There is no `iris` subprocess to sandbox, no `cargo`, no -`iris-bench` binary to ship. +``` +iris-gui Benchmark tab + └── iris::bench_runner::run(opts, progress_cb) + ├── MachineConfig { headless, no_audio, no scsi, banks: 128+128 } + ├── Machine::new_with_testdev(cfg, TestDevice::new_embedded(…)) + ├── machine.load_elf_bytes(benchsuite::SUITE_ELF) include_bytes!, 285 KB + ├── TestDevice sink ──> Vec ──> Progress events per line + └── on TESTDEV_EXIT ──> stop ──> parse ──> Run +``` -So the App Store benchmark is not "drive the developer tool from a GUI". It is: -build a `MachineConfig`, load an ELF that is already inside the app, run it, read -the report the guest prints. Most of the work is making the emulator *embeddable* -for that, not building UI. +Nothing outside the process; identical on macOS, Windows and Linux. `iris-bench +run` is the same code path, so "run the suite and parse the answer" has one +implementation rather than two. -Four things stand in the way, all in `iris`, none large: +The tab now ships in every build, App Store included. Its developer half — the +matrix runner and the native host baseline, which need a source checkout and +build a separate emulator per cell — is folded into a "Developer tools" +disclosure that is compiled out under `feature = "appstore"`. -| Blocker | Where | Why it matters in-process | -|---|---|---| -| `TestDevice::exit()` calls `std::process::exit` | `testdev.rs:164` | Guest finishing the suite would **quit the app** | -| `TestDevice::putc()` writes to `stdout` | `testdev.rs:129` | GUI never sees the report | -| `Machine::load_elf` takes a path | `machine.rs:1064` | Suite has to be a file on disk | -| `Machine::new` calls `process::exit(1)` on the ultra64/test-device slot clash | `machine.rs:565` | Config mistake would quit the app | +--- + +## The measurement got 2.5x faster, and that changed the design + +The plan this document replaces was written against a 160-second interpreted +run and built a "quick mode" around it. Measuring where that time actually went +found something better. + +The suite prints its table one row at a time, over the SCC. `scc_putc` spins on +`RR0.TX_BUFFER_EMPTY` with a 100,000-iteration bound before giving up. But +WR5.TX_ENABLE is programmed by the PROM, and a `--load-elf` image never runs the +PROM — so the transmitter is never enabled, the four-byte holding queue fills, +`TX_BUFFER_EMPTY` never comes back, and **every character costs the full spin**. + +| r4400 lightning, full suite | wall | timed regions | accuracy | +|---|---|---|---| +| before | 117 s | 12.5 s | 40/40 | +| after | **46 s** | 12.4 s | 40/40 | + +The fix is six lines in `cpu-tests/harness/console.c` — latch the port off once +it has proved it will not transmit, but only when a test device is present, so +that a run with no other sink still gets its (slow) serial output and a +PROM-booted run is untouched. `cpu-tests` was paying the same tax and gets the +same speedup. Written up in +`rules/testing/scc-serial-output-from-bare-metal-code.md`. -`load_elf` is a five-line refactor — it is already `fs::read` → `elf::parse` → -load segments (`mips_exec.rs:8441`). The other three are the real work, and -`TestDevice::exit` is the one to be careful with: the guest is mid-store on the -CPU thread when it fires. +Measured on the reference host, plain release interpreter — which is what the +App Store build is: + +| | wall | guest MIPS | DMIPS | accuracy | +|---|---|---|---|---| +| full | 58 s | 51.7 | 74.4 | 100% | +| quick | 33 s | 51.9 | 72.6 | 100% | + +Quick mode therefore reports the same numbers to within a couple of percent. +What it gives up is precision, and only that. + +### Why quick mode does not drop kernels + +The original plan's quick mode ran fewer groups. It no longer needs to, and +should not: **accuracy is the number the shipping build leads with**, and a +short run that quietly checked less would report the same 100% while covering +less ground. So quick mode scales the per-kernel target time and drops from +best-of-two to a single pass, and every kernel still runs and still verifies. + +The floor is each kernel's verification pass — one exact workload, because that +is what the golden checksum was computed against — plus the kernels whose base +iteration count is already 1 (`codec/lz` alone is 1.5 s and cannot be made +smaller). That is about 22 s of the 33, and it is why quick mode lands near +half a full run rather than near a tenth of one. --- ## What the user sees -**One primary action.** "Benchmark this Mac" (or PC). No cell picker, no engine -picker, no mention of R4400 vs R5000 — the App Store build is one binary with one -CPU and one engine, so there is nothing to choose. +**One primary action** — "Benchmark this Mac" / "this PC" — with a Quick +checkbox. No cell picker and no engine picker: the shipping build is one binary +with one CPU and one engine, so there is nothing to choose. + +**While it runs:** a progress bar, the current kernel, elapsed, and an estimate +that only appears once there is evidence for one. The first rows are the cheap +integer kernels and the last are the expensive codec ones, so extrapolating from +row two is confidently wrong; before four rows it says "about 35 s in total" +instead of pretending. -**While it runs:** a progress bar, the current kernel name, elapsed and estimated -remaining. Not a log tail — the current tab streams subprocess stdout, which is -right for a developer and wrong for everyone else. +Progress is honest because the guest says up front how many kernels it will run +(`IRIS-BENCH-PLAN benches=46`). It has to come from the guest: the count depends +on the CPU (some kernels are R5000-only) and on the group mask, and only the +guest can resolve either. -**When it finishes** — this is the shipping state, with an empty reference -table. Design for it first; it is what every user sees until someone measures -their machine: +**When it finishes:** ``` - Emulated Indy 71 DMIPS ← interpreter: the store build has no JIT - Emulator throughput 51 MIPS + Emulated Indy 74 DMIPS ← interpreter: the store build has no JIT + Emulator throughput 52 MIPS Accuracy 100% (40/40) ← correctness, not speed - Integer 38.5 M ops/s Imaging 1.0 M px/s - Floating 7.3 M ops/s Codec 7.6 MB/s + Integer 38.5 M ops/s + Floating 7.3 M ops/s Memory 30.7 MB/s + Imaging 1.0 M px/s + Codec 7.6 MB/s Reference statistics not gathered for this platform. - [ Details ] [ Copy ] [ Save report… ] -``` - -Once a matching row exists in `data/bench_reference.json`, the same block gains -a comparison column and the sentence is replaced: - -``` - Integer 38.5 M ops/s ████████████░░ 1.4× vs MacBook Air (M1) - Floating 7.3 M ops/s ███░░░░░░░░░░░ 0.7× - … + [ Copy ] [ Save report… ] ▸ Details ``` - **The absolute numbers carry the screen.** DMIPS has forty years of published figures behind it, guest MIPS is meaningful on its own, and accuracy needs no baseline at all — so an empty reference table costs the user very little. Comparison is an enhancement, never a dependency. -- **Accuracy is shown as prominently as speed.** It is the differentiator: no - other emulator reports whether it computed the right answer. A user seeing - 100% learns something real, and a user seeing 97% has found a bug worth - reporting. -- **Nothing is uploaded.** Results live in the app container; export is an - explicit save panel. (See `PRIVACY.md`.) - -Numbers above are the interpreter's, because that is what ships — see the JIT -note under Risks. A source build with `jitv2` scores ~203 MIPS / ~213 DMIPS on -the same host, which is why every stored result carries its engine. - -**Quick vs full.** The full suite is ~160 s interpreted, ~80 s with jitv2. That -is too long for a consumer button as the default. Ship a **quick mode** (~20 s) -as the default and full as an option. - -**Progress, not a log tail.** The suite already streams its table a line at a -time; the runner parses those lines anyway to know which kernel is running, so -the progress display and the raw console are the same data at two levels of -detail. Progress bar on top, `Show details ▸` for the console underneath. +- **Accuracy is as prominent as speed.** It is the differentiator: no other + emulator reports whether it computed the right answer. 100% tells a user + something real, and 97% has found a bug worth reporting. +- **Caveats are stated, not left to be discovered.** A quick run says it was + one. An interpreter build says a JIT build scores about four times higher and + is not comparable. A kernel that took an unexpected exception is named, + because the harness steps over faults and the kernel still reports a + throughput — for doing something other than what it claims. +- **The console is one click down.** A wall of monospace as the primary surface + reads as "something went wrong" to a reader who did not ask for a log. +- **Nothing is uploaded.** Export is an explicit `rfd` save panel. + +A benchmark is refused while a machine is running. Two emulators sharing the +host would measure whatever IRIX happened to be doing, and refusing is simpler +than explaining the result afterwards. --- -## Architecture +## How the pieces work -**Today (developer path):** +### Embeddability (`iris`) -``` -iris-gui ──spawn──> iris-bench ──spawn──> iris (process) - │ │ --load-elf bench/build/irisbench.elf - │ │ --test-device - │ stdout ──> IRIS-BENCH-BEGIN…END - └── parses the block, writes results/*.json -``` -Needs: a built ELF on disk, two binaries, subprocess spawn, filesystem writes. -None of that survives the sandbox. +| Was | Now | +|---|---| +| `TestDevice::exit` → `process::exit` | `new_embedded` takes an `ExitHook`; the standalone path is unchanged | +| `TestDevice::putc` → stdout | a `Vec` sink the embedder drains | +| `Machine::load_elf` takes a path | `load_elf_bytes` alongside it; `load_elf` calls it | +| `Machine::new` → `process::exit(1)` on the ultra64 slot clash | panics, which `iris-gui` already catches | -**Proposed (embedded path):** +**`exit` returning is safe, and that is the whole trick.** The store lands on +the CPU thread mid-instruction, which is why the original plan flagged this as +the delicate piece. It is not, because every guest reaches `EXIT` through +`testdev_exit()`, which spins forever afterwards — a bare-metal image has +nowhere to return to. So the hook fires, the store completes, the CPU thread +loops harmlessly in guest code, and the runner stops the machine from its own +thread. A hook that *blocked* would deadlock against `Machine::stop`'s join. -``` -iris-gui - └── iris::bench_runner::run(opts, progress_cb) - ├── MachineConfig { headless, no_audio, no scsi, test_device, banks } - ├── Machine::new(cfg) (in-process, worker thread) - ├── machine.load_elf_bytes(BENCH_ELF) (include_bytes!, ~285 KB) - ├── TestDevice sink ──> Vec ──> progress_cb per line - └── on TESTDEV_EXIT ──> stop ──> parse ──> Report -``` -Needs: nothing outside the process. Works identically on macOS, Windows, Linux. +Collected in `rules/testing/embedding-the-emulator-in-process.md`, along with the +three other things that bite (skip `register_system_controller`; spawn with a +16 MB stack; `Machine::start` does not start the CPU in a debug build). -The same `bench_runner` backs `iris-bench run` too, so there is one -implementation of "run the suite and parse the answer" rather than two. +### The suite as an asset (`bench/prebuilt/`) ---- +A known-good `irisbench.elf` is checked in and linked with `include_bytes!`. +Precedent: the 512 KB PROM is already embedded, though as a generated Rust array +— `include_bytes!` on a real file is smaller, faster to compile, and diffable as +the binary it is. -## Work items, in dependency order - -### P0 — make the emulator embeddable *(small, `iris` crate only)* - -1. `Machine::load_elf_bytes(&self, bytes: &[u8]) -> Result`. - Refactor `load_elf` to call it. `MipsCpu::load_elf` splits the same way. -2. `TestDevice` gains an output mode: - - `TestDevice::new_embedded(sink: Arc>>, on_exit: Box)` - alongside today's `new(dump_path)`. - - `putc` writes to the sink when embedded, `stdout` otherwise. - - `exit(code)` calls `on_exit(code)` and then **parks the CPU** instead of - `process::exit`. Getting this right is the one genuinely delicate piece: - the store that triggers it is executing on the CPU thread, so the handler - must not block on anything the CPU thread owns. Signal an `AtomicBool` + - `Condvar` and let the *runner* thread do the stopping. - - `dump()` becomes a no-op when no path is configured. -3. `Machine::new`'s `process::exit(1)` (`machine.rs:565`) becomes an error or a - panic. `Machine::new` already panics on bad input and the GUI already catches - that (`handle.rs:381`), so a full `Result` refactor is optional — converting - the one `exit` call to a panic is enough and is smaller. - -**Test**: a `#[test]` in `iris` that runs the embedded suite in quick mode and -asserts the report parses and accuracy is 100%. That single test covers most of -P0 and P1 and is the regression net for the whole feature. - -### P1 — ship the suite as an asset *(small)* - -4. Check in `bench/prebuilt/irisbench.elf` (~285 KB) plus the `golden.h` hash it - was built against. `include_bytes!` it from a new `src/benchsuite.rs`. - Precedent: the 512 KB PROM is already embedded (`src/prombin.rs`) — though as - a 3.2 MB Rust hex array, which is *not* the pattern to copy. `include_bytes!` - on a real file is smaller, faster to compile, and diffable as a binary. -5. Extend `.github/workflows/bench.yml`: rebuild the ELF and fail if it differs - from the checked-in one. Same discipline as `golden.h` and `fpvectors.c` - already have — a checked-in build product that can silently drift is worse - than no build product. -6. Move the report parser out of `src/bin/iris_bench.rs` into `iris` so the - library, the CLI and the GUI share one copy. - -### P2 — the runner *(medium)* - -7. `iris::bench_runner`: - ```rust - pub struct BenchOptions { pub quick: bool, pub groups: u32, pub banks: [u32; 4] } - pub enum Progress { Started { total: usize }, Kernel { name: String, index: usize }, Line(String) } - pub fn run(opts: BenchOptions, progress: impl FnMut(Progress) + Send) -> Result; - ``` -8. Rewire `iris-bench run` onto it (drops the subprocess for the local case; - `matrix` keeps spawning, because comparing builds inherently means comparing - binaries). - -### P3 — the GUI screen *(medium — this is where the design effort goes)* - -9. Replace `bench_ui.rs`'s subprocess + log tail with the runner + a results - view. The state machine is small (Idle → Running → Done/Failed); the work is - the results presentation, not the plumbing. -10. **No IRIS Index in v1** — see the reference-table section. Headline the - numbers that stand alone. -11. **Progress primary, console secondary.** The suite already streams its - human table a line at a time — deliberately, so a run that prints nothing - for two minutes is not mistaken for a hang. The runner has to consume that - stream anyway to know which kernel is running, so the progress events *are* - the parsed console lines and showing the raw text underneath costs nothing - extra. Progress bar + current kernel on top; `Show details ▸` reveals the - console. - - Do **not** route it through `serial_console.rs`: that is a TCP client to - `127.0.0.1:8881`, so it would require standing up the loopback serial - server for a benchmark that has no other use for it. Take the test-device - sink directly and reuse only the *view* half of that widget (scrollback - cap, autoscroll, monospace). Splitting `SerialConsole` into transport and - view is worth doing for its own sake. - - Raw console as the *primary* surface reads as "something went wrong" to a - non-technical user. It belongs one click down. -12. Keep matrix/cells/host-baseline buttons behind `!appstore`. - -### P4 — host baseline in-process *(medium, one build-system decision)* - -12. The kernels already compile natively — that is how `golden.h` is generated. - Compile `bench/kernels/*.c` + `bench/gen/hostplat.c` into `iris` with the - `cc` crate so the native comparison runs in-process. That preserves the - property the whole suite is built on: **the same C on both sides**, so the - native ratio is a real number rather than two benchmarks pretending to be - comparable. -13. **Decision needed**: `cc` means a C compiler becomes a build requirement for - the `iris` crate. Either (a) gate it behind a `bench-host` feature that - release builds turn on — contributors' builds then differ from shipped ones; - or (b) always on, with build.rs degrading to a stub when no compiler is - found. Recommend (b): the failure mode is a missing feature, not a broken - build, and shipped and local builds stay identical. - -### P5 — quick mode *(small, guest-side)* - -14. The suite deliberately has no runtime selector — a bare-metal binary loaded - with `--load-elf` has nowhere to take arguments from. Cleanest fix: **a new - test-device register** the guest reads at startup (`TESTDEV_CONFIG`, next to - `TESTDEV_CAPS`), carrying a group bitmask and a target-time scale. ~20 lines - in `harness/main.c`, one register in `testdev.rs`, and it composes with the - capability probe already there. - Alternatives considered: a second smaller ELF (doubles the asset and the - golden discipline), or poking a word into RAM after `load_elf_bytes` - (works, but invents a second ABI nobody documents). - -### P6 — sandbox and store *(small, mostly audit)* - -15. Unhide the tab for `appstore`. Audit: no path outside the container, no - subprocess, no `process::exit`, export only via `rfd` save panel. -16. Confirm what the App Store workflow actually builds (`appstore.yml` is not in - this branch). See the open question below. +**Drift is the danger, and CI is the answer.** Accuracy is scored against golden +checksums compiled *into* the image, so a stale image against fresh goldens +reports failures to users that are not real. `bench.yml` rebuilds it and fails +on any difference; `make -C bench prebuilt` refreshes it. ---- +### Quick mode (`TESTDEV_RUN_CONFIG`) -## The reference table +A bare-metal image has no argv and no environment, so the host leaves its +request in a register the guest reads at startup: -`data/bench_reference.json` — checked in, `include_str!`'d, updated by hand when -someone measures a machine worth recording. **It ships empty, and empty is a -normal state**: a machine with no row gets *"reference statistics not gathered -for this platform"* rather than a comparison. No upload, no download, no -user-writable override, no import/export. It is a static file and a pull -request. +``` + 31 16 15 12 11 0 + +---------------+-------+---------------+ + | groups |repeats| time_pct | + +---------------+-------+---------------+ +``` -Deliberately cut from an earlier draft of this plan: the layered -bundled/override/import design, and the IRIS Index. The index needs a frozen -normalization vector to mean anything, and there is nothing to freeze until the -table has entries — so v1 shows the numbers that stand on their own (guest MIPS, -DMIPS, accuracy) and an index can arrive later if it earns its place. +Every field means "unrestricted" when zero — which is exactly what an emulator +predating the register returns — so the guest reads it unconditionally. +Verified both ways: the current guest image runs correctly on an emulator built +before the register existed. -Note this is a different file from the golden checksums, which are the -correctness oracle and are already compiled *into the MIPS ELF*. Those must -never be user-editable — editing them would let someone "fix" an accuracy -failure. Externalising the performance table therefore carries no correctness -risk at all. - -### Adding a row *(built — works today)* - -```sh -./target/release/iris-bench run --label my-machine -./target/release/iris-bench reference \ - --id m1-max-interp --label "MacBook Pro (M1 Max) — interpreter" \ - --into data/bench_reference.json -``` +The effective configuration is echoed back in the report (`#run …`) and stored +on every result, so a shortened run can never be mistaken for a full one. +`iris-bench reference` refuses to put one in the reference table. -`reference` with no `--from` takes the newest result in `bench/build/results/`; -without `--into` it prints the row for pasting. See -`data/bench_reference.README.md`. +### The machine inventory -### Two fields that keep it honest +Every result now records what it ran on, read out of the hardware rather than +reported from what the runner configured: CPU identity and revision plus the +L1/L2 geometry from CP0 Config, and the RAM banks from the memory controller's +MEMCFG registers. It works with no PROM and no POST because `--load-elf` +programs MEMCFG exactly as POST would before the image starts. -**`suite_id`** — blake3 of the guest binary the numbers came from, recorded on -every result. Reference figures only mean something against the exact suite that -produced them: add or change a kernel and every stored number silently becomes a -comparison between two different workloads. So the merge refuses when a result -disagrees with a populated table, and the GUI treats a mismatch exactly like an -empty table — one fallback path covers both. An empty table adopts the suite of -the first row merged into it. +It is not decoration. The `mem/` kernels are a direct readout of the cache +hierarchy, so two results with different L1 sizes are not measuring the same +thing — and nothing in a stored result used to say so. -**`cpu` and `engine`** — the two engines differ by roughly 4x, so a row without -them cannot be compared with anything. This matters concretely: the App Store -build forces `IRIS_NO_JIT=1`, so rows meant for comparison against it must be -`"engine": "interp"`. +The CPU is named from PRId alone, so it is right on any MIPS machine and not +just the two this emulator models. The suite used to *refuse* to run on +anything else, on the stated grounds that the goldens were selected by PRId; +they are not — `golden.h` is one flat CPU-independent table and no kernel is +CPU-gated — so it now runs and says what it found. -### Multiple contributors +### The report model (`iris::bench_report`) -Not designed for. It is a file in the repo; rows arrive as pull requests. If -that ever becomes unwieldy the problem will announce itself, and the schema -already carries everything a merge would need. +The parser, the data model and the reference table moved out of +`src/bin/iris_bench.rs` into the library: three callers need them and only one +of them is that binary. One parser, one schema, one definition of what +"accuracy" means. --- -## Risks and open questions +## The reference table -| | Risk | Mitigation | -|---|---|---| -| **1** | `TestDevice::exit` firing on the CPU thread mid-store | Signal + park; let the runner thread stop the machine. Cover with the P0 test. | -| **2** | Two `Machine`s at once (benchmark while IRIX is running) | Refuse. `handle.rs:358` already has the one-machine guard. It also gives a clean measurement, so this is a feature. | -| **3** | Laptop measurement validity — thermal throttling, efficiency cores, other apps | Already best-of-two per kernel. Surface the spread; warn when the repeats disagree by >5%; say plainly that a laptop on battery will score lower. | -| **4** | Embedded ELF drifting from `golden.h` | CI check (item 5). Non-negotiable — a stale ELF against fresh goldens reports false accuracy failures to users. | -| **5** | A user reading a low score as "IRIS is broken" | Lead with the reference comparison, not the raw number. | -| **6** | App Store review reading it as a hardware-diagnostic utility | It benchmarks *the app's own emulator*, reports nothing about the host beyond a CPU model string, and uploads nothing. Frame the UI that way. | - -**Settled — the App Store build has no JIT at all.** `main.rs:116` forces -`IRIS_NO_JIT=1` under `feature = "appstore"`, and the comment there explains -why: Cranelift allocates executable memory with `mmap`+`mprotect`, not -`MAP_JIT`, and the sandbox only permits `MAP_JIT` pages -(`com.apple.security.cs.allow-jit` is the only code-signing entitlement the -store accepts; `allow-unsigned-executable-memory` and -`disable-executable-page-protection` are rejected by review). The first JITed -REX3 draw gets SIGKILL'd. So it is not just jitv2 — the REX3 draw-shader JIT is -off too. - -Consequences, all of which the design has to absorb rather than work around: - -- The App Store headline is **~51 MIPS / ~70 DMIPS**, not 203 / 213. -- **The shipped reference table must be interpreter numbers.** A jitv2 row next - to an App Store result is a 4x apples-to-oranges comparison. -- The benchmark is still worth shipping: "how fast is your Mac at emulating an - Indy" is the user-facing question, and the interpreter is what they have. -- If Cranelift is ever made `MAP_JIT`-aware, this reverses — which is another - reason entries carry an explicit `engine` field rather than an implied one. +`data/bench_reference.json` — checked in, `include_str!`'d, updated by hand. +**It ships empty, and empty is a normal state**: a machine with no row gets +"reference statistics not gathered for this platform" rather than a comparison. +No upload, no download, no user-writable override. It is a static file and a +pull request. ---- +Three things must agree before a comparison means anything, and +`ReferenceTable::matching` enforces all three: the **suite** (`suite_id`, a +blake3 of the guest binary — two different workloads under one name is not a +comparison), the **emulated CPU** (the R4400 and R5000 cache models differ +deeply), and the **engine** (interpreter and jitv2 are about 4x apart). A +mismatch on any of them is treated exactly like an empty table, so there is one +fallback path rather than four. -## What stays developer-only +No IRIS Index in v1. An index needs a frozen normalization vector to mean +anything, and there is nothing to freeze until the table has entries. -`iris-bench matrix` — building a separate emulator per cell is inherently a -source-checkout activity. Same for `--force-build`, the cell picker, and the -`bench/irix/` guest-OS suite (needs an IRIX image and a CI socket). - -The split is clean: **one embedded run** is a product feature, **comparing -builds** is a developer tool. +Note this is a different file from the golden checksums, which are the +correctness oracle and are compiled *into* the guest image. Those must never be +user-editable — editing them would let someone "fix" an accuracy failure. +Externalising the performance table therefore carries no correctness risk. --- -## Rough shape of the effort +## Risks, and what became of them -| Phase | Crates touched | Size | Risk | -|---|---|---|---| -| P0 embeddability | `iris` | small | **the exit path is the one delicate piece** | -| P1 asset + CI | `iris`, workflows | small | low | -| P2 runner | `iris`, `iris-bench` | medium | low | -| P3 GUI screen | `iris-gui` | medium | low (design effort, not technical) | -| P4 host baseline | `iris` + build.rs | medium | build-system decision | -| P5 quick mode | `bench/`, `iris` | small | low | -| P6 sandbox/store | `iris-gui`, workflows | small | gated on the jitv2 question | - -P0 + P1 + P2 is the spine: at the end of it `iris-bench run` works with no -subprocess and no ELF on disk, and the GUI is a view over something already -proven. P3 onwards is additive. +| | Risk | Outcome | +|---|---|---| +| 1 | `TestDevice::exit` firing on the CPU thread mid-store | Not a problem — the guest spins after it. Covered by the P0 test. | +| 2 | Two `Machine`s at once | Refused: the button is disabled while a machine runs. | +| 3 | Laptop measurement validity — throttling, efficiency cores, other apps | Still real. Best-of-two per kernel in a full run; quick mode says it is a quick mode. Surfacing per-kernel spread is [not built](#not-built). | +| 4 | Embedded ELF drifting from `golden.h` | CI rebuilds and diffs both. | +| 5 | A user reading a low score as "IRIS is broken" | The interpreter caveat is stated on the results screen. | +| 6 | App Store review reading it as a hardware-diagnostic utility | It benchmarks the app's own emulator, reports nothing about the host beyond a CPU model string, and uploads nothing. | + +**The App Store build has no JIT at all.** `main.rs` forces `IRIS_NO_JIT=1` under +`feature = "appstore"`: Cranelift allocates executable memory with +`mmap`+`mprotect` rather than `MAP_JIT`, and the sandbox only permits `MAP_JIT` +pages (`com.apple.security.cs.allow-jit` is the only code-signing entitlement +review accepts). The REX3 draw-shader JIT is off for the same reason. + +Consequences the design absorbs rather than works around: the headline is +~52 MIPS / ~74 DMIPS, not 203 / 213; the shipped reference table must hold +interpreter rows; and every stored result carries an explicit `engine` field +rather than an implied one, so this reverses cleanly if Cranelift is ever made +`MAP_JIT`-aware. + +--- + +## Not built + +**Host baseline in-process.** The kernels already compile natively — that is how +`golden.h` is generated — so compiling `bench/kernels/*.c` plus +`bench/gen/hostplat.c` into `iris` with the `cc` crate would let the native +comparison run in-process too, preserving the property the suite rests on: the +same C on both sides. + +The decision it needs: `cc` makes a C compiler a build requirement for the +`iris` crate. Either gate it behind a `bench-host` feature that release builds +turn on — contributors' builds then differ from shipped ones — or leave it +always on with `build.rs` degrading to a stub when no compiler is found. +Recommended: the latter. The failure mode is a missing feature rather than a +broken build, and shipped and local builds stay identical. + +Not required for anything above; the emulated numbers stand on their own, which +is the whole reason the results screen leads with them. + +**Anything but an Indy or Indigo2.** The bare-metal harness is written for +IP22/IP24 — the load address, the console and the memory inventory all assume +that machine — so the suite does not run on an O2, Octane or Origin, and no run +on real SGI hardware of any kind is recorded in this repo. It costs nothing +today, since IP22/IP24 is what IRIS emulates; it would matter for a +real-hardware reference number, which is the only kind that is not the +emulator's opinion of itself. Written up, with what a port would involve and +which parts of the analysis were actually tested, in +`rules/testing/bare-metal-harness-platform-assumptions.md`. + +**Measurement-spread warning.** Risk 3. The harness keeps the best of two timed +passes but does not report how far apart they were, so a thermally throttled +laptop looks the same as a quiet desktop. Reporting the spread and warning above +~5% needs one more field per row in the machine block. diff --git a/iris-gui/src/bench_ui.rs b/iris-gui/src/bench_ui.rs index b2db9c2..3aa428e 100644 --- a/iris-gui/src/bench_ui.rs +++ b/iris-gui/src/bench_ui.rs @@ -1,298 +1,955 @@ -//! Benchmark tab — run `iris-bench` from the GUI and watch it. +//! Benchmark tab — measure this machine's emulated Indy, in-process. //! -//! The suite is a developer tool and the command line is its natural home; this -//! exists so that "how fast is this build, and is it still right" is one click -//! away rather than a remembered incantation, and so the answer is legible -//! while it is still running. A full matrix takes tens of minutes and rebuilds -//! the emulator once per cell — a progress-free spinner would be useless, so -//! the child's output is streamed line by line into the panel. +//! One button, one number. The suite is a bare-metal MIPS binary that IRIS +//! carries inside itself (`iris::benchsuite`) and runs on a headless machine of +//! its own (`iris::bench_runner`): no toolchain, no subprocess, no file written +//! anywhere, and identical on macOS, Windows and Linux. That is what makes this +//! shippable rather than a developer convenience — the older version of this +//! tab spawned `iris-bench`, which spawned `iris`, which read an ELF off disk, +//! and none of those three steps survive an application sandbox. //! -//! Nothing here talks to a running machine. `iris-bench` spawns its own -//! headless emulator with its own bare-metal config, so this is safe to use -//! while a normal IRIX session is up. +//! It measures *the emulator*, not the host: how fast this build of IRIS runs +//! an Indy on the hardware it happens to be sitting on, and whether it still +//! computes the right answers after ten million instructions of doing it. The +//! accuracy score is the part no other emulator reports, so it is shown as +//! prominently as the speed. +//! +//! The matrix runner is still a subprocess and still developer-only: the CPU +//! model and the JIT are compile-time cargo features, so comparing them means +//! building and comparing binaries. -use std::io::{BufRead, BufReader}; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use eframe::egui::{self, Color32, RichText, ScrollArea, Ui}; +use iris::bench_report::{fmt_rate, ReferenceEntry, ReferenceTable, Run, CATEGORIES}; +use iris::bench_runner::{self, BenchOptions, Progress}; -/// How many output lines to keep. A matrix run prints a cargo build per cell; -/// the interesting part is always the tail. +/// How many console lines to keep. The suite prints a few hundred; the cap is +/// there for a run that goes wrong and starts repeating itself. const MAX_LINES: usize = 4000; -#[derive(Default)] +/// What the two modes cost on a plain interpreter build, for the button's +/// hover text. Measured on the reference host — treated as an order of +/// magnitude, not a promise, which is why the UI also shows real elapsed time. +const QUICK_SECS: u64 = 35; +const FULL_SECS: u64 = 60; + pub struct BenchState { - lines: Arc>>, - running: Arc, - child: Arc>>, - /// What finished last, and how — kept after the run so the panel still says - /// something once the thread is gone. - last: Option, - what: String, + live: Option, + /// The last finished run, kept so the panel still says something once the + /// worker is gone. + last: Option, String>>, + quick: bool, + /// Parsed once. Normally empty — see `iris::bench_report::bundled_reference`. + reference: Option, + dev: DevRunner, } -struct Outcome { - label: String, - ok: bool, - detail: String, +impl Default for BenchState { + fn default() -> Self { + Self { + live: None, + last: None, + // Quick by default. It reports the same figures to within a couple + // of percent for about half the wall clock, and it gives up no + // accuracy at all — every kernel still runs and still verifies. A + // full run is one click away for anyone who wants the tighter + // number, and is required before a result can go in the reference + // table. + quick: true, + reference: None, + dev: DevRunner::default(), + } + } } -/// Where the pieces are, relative to wherever the GUI was launched from. The -/// dev workflow runs it from the repo root; an installed layout puts the -/// binaries next to the executable. -fn locate(rel: &str) -> Option { - let exe_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(PathBuf::from)); - let name = Path::new(rel).file_name()?.to_owned(); - let mut candidates = vec![PathBuf::from(rel), PathBuf::from("..").join(rel)]; - if let Some(d) = exe_dir { - candidates.push(d.join(&name)); - candidates.push(d.join(rel)); - } - candidates.into_iter().find(|p| p.exists()) +/// A run in flight. Everything here is written by the worker thread and read by +/// the UI thread once a frame. +struct Live { + lines: Arc>>, + state: Arc>, + cancel: Arc, + done: Arc, String>>>>, + started: Instant, + quick: bool, } -fn iris_bench_bin() -> Option { - let exe = if cfg!(windows) { "iris-bench.exe" } else { "iris-bench" }; - locate(&format!("target/release/{}", exe)) +#[derive(Default, Clone)] +struct LiveState { + /// Kernels the guest said it would run. 0 until it says. + total: usize, + done: usize, + current: String, } -fn suite_elf() -> Option { locate("bench/build/irisbench.elf") } -fn host_bin() -> Option { - let exe = if cfg!(windows) { "irisbench-host.exe" } else { "irisbench-host" }; - locate(&format!("bench/build/{}", exe)) +impl LiveState { + fn fraction(&self) -> f32 { + if self.total == 0 { return 0.0; } + (self.done as f32 / self.total as f32).clamp(0.0, 1.0) + } } -fn results_dir() -> Option { locate("bench/build/results") } + +// ─── running ───────────────────────────────────────────────────────────────── impl BenchState { - pub fn is_running(&self) -> bool { self.running.load(Ordering::Relaxed) } + pub fn is_running(&self) -> bool { + self.live.is_some() || self.dev.is_running() + } - fn start(&mut self, label: &str, args: &[&str]) { + fn start(&mut self, quick: bool) { if self.is_running() { return; } - let Some(bin) = iris_bench_bin() else { - self.last = Some(Outcome { - label: label.to_string(), - ok: false, - detail: "iris-bench not built — run `cargo build --release --bin iris-bench`" - .to_string(), - }); - return; - }; - self.lines.lock().unwrap().clear(); - self.what = label.to_string(); - self.last = None; - self.running.store(true, Ordering::Relaxed); - - let lines = Arc::clone(&self.lines); - let running = Arc::clone(&self.running); - let child_slot = Arc::clone(&self.child); - let owned: Vec = args.iter().map(|s| s.to_string()).collect(); - // The repo root, so bench/ and target/ resolve the way iris-bench - // expects — it takes every path relative to there. - let cwd = bin.parent().and_then(|p| p.parent()).and_then(|p| p.parent()) - .map(PathBuf::from).unwrap_or_else(|| PathBuf::from(".")); - let label = label.to_string(); + let lines = Arc::new(Mutex::new(Vec::new())); + let state = Arc::new(Mutex::new(LiveState::default())); + let cancel = Arc::new(AtomicBool::new(false)); + let done = Arc::new(Mutex::new(None)); + let opts = BenchOptions { + quick, + label: iris::bench_report::cpu_model(), + cancel: Some(cancel.clone()), + ..Default::default() + }; + + let (l, s, d) = (lines.clone(), state.clone(), done.clone()); std::thread::spawn(move || { - let mut cmd = Command::new(&bin); - cmd.current_dir(&cwd) - .args(&owned) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - let mut child = match cmd.spawn() { - Ok(c) => c, - Err(e) => { - lines.lock().unwrap().push(format!("failed to start {}: {}", bin.display(), e)); - running.store(false, Ordering::Relaxed); - return; + let result = bench_runner::run(&opts, move |p| match p { + Progress::Started { total } => s.lock().unwrap().total = total, + Progress::Kernel { name, index } => { + let mut st = s.lock().unwrap(); + st.done = index; + st.current = name; } - }; - - let so = child.stdout.take(); - let se = child.stderr.take(); - *child_slot.lock().unwrap() = Some(child); - - // Both pipes on their own threads: a child that fills one while we - // read the other would stall instead of finishing. - let pump = |r: Option>, sink: Arc>>| { - std::thread::spawn(move || { - if let Some(r) = r { - for line in BufReader::new(r).lines().map_while(Result::ok) { - let mut v = sink.lock().unwrap(); - v.push(line); - if v.len() > MAX_LINES { let drop_n = v.len() - MAX_LINES; v.drain(..drop_n); } - } + Progress::Line(line) => { + let mut v = l.lock().unwrap(); + v.push(line); + if v.len() > MAX_LINES { + let drop_n = v.len() - MAX_LINES; + v.drain(..drop_n); } - }) - }; - let t1 = pump(so.map(|s| Box::new(s) as Box), Arc::clone(&lines)); - let t2 = pump(se.map(|s| Box::new(s) as Box), Arc::clone(&lines)); - - let status = child_slot.lock().unwrap().as_mut().map(|c| c.wait()); - let _ = t1.join(); - let _ = t2.join(); - *child_slot.lock().unwrap() = None; - - let ok = matches!(status, Some(Ok(s)) if s.success()); - lines.lock().unwrap().push(if ok { - format!("--- {} finished ---", label) - } else { - format!("--- {} failed ---", label) + } }); - running.store(false, Ordering::Relaxed); + *d.lock().unwrap() = Some(result.map(Box::new)); + }); + + self.last = None; + self.live = Some(Live { + lines, state, cancel, done, + started: Instant::now(), + quick, }); } + /// Move a finished worker's result into `last`. Called once a frame. + fn poll(&mut self) { + let Some(live) = &self.live else { return }; + let finished = live.done.lock().unwrap().take(); + if let Some(result) = finished { + self.last = Some(result); + self.live = None; + } + } + fn stop(&mut self) { - if let Some(c) = self.child.lock().unwrap().as_mut() { let _ = c.kill(); } + if let Some(live) = &self.live { + live.cancel.store(true, Ordering::Relaxed); + } + self.dev.stop(); } - /// Pull the headline numbers back out of the streamed output. iris-bench - /// prints one summary line per cell in a fixed shape, so this is a read of - /// what already scrolled past rather than a second source of truth. - fn summarize(&self) -> Vec { - self.lines - .lock() - .unwrap() - .iter() - .filter(|l| l.contains("accuracy") || l.starts_with("report:") || l.starts_with("matrix:")) - .cloned() - .collect() + fn reference(&mut self) -> &ReferenceTable { + self.reference.get_or_insert_with(iris::bench_report::bundled_reference) } } -pub fn show(ui: &mut Ui, st: &mut BenchState) { +// ─── the screen ────────────────────────────────────────────────────────────── + +pub fn show(ui: &mut Ui, st: &mut BenchState, machine_running: bool) { + st.poll(); + ui.heading("Benchmark"); ui.label( - "Runs bench/ — a bare-metal MIPS suite that measures this build of IRIS and \ - checks that it is still computing the right answers. No IRIX and no disk \ - image needed; it starts its own headless emulator, so it is safe to use \ - while a machine is running.", + "Measures how fast this build of IRIS emulates an Indy on this machine, and \ + checks that it is still computing the right answers. Everything it needs is \ + built in — no disk image, no IRIX, nothing downloaded, and nothing sent \ + anywhere.", ); - ui.add_space(6.0); - - // ── prerequisites ─────────────────────────────────────────────────────── - let bench_bin = iris_bench_bin(); - let elf = suite_elf(); - let hostb = host_bin(); - - egui::Grid::new("bench_paths").num_columns(2).striped(true).show(ui, |ui| { - let row = |ui: &mut Ui, name: &str, p: &Option, hint: &str| { - ui.label(name); - match p { - Some(p) => { ui.label(RichText::new(p.display().to_string()).monospace()); } - None => { ui.label(RichText::new(hint).color(Color32::from_rgb(220, 170, 90))); } + ui.add_space(8.0); + + controls(ui, st, machine_running); + + if let Some(live) = &st.live { + ui.add_space(8.0); + running_view(ui, live); + } + + match st.last.take() { + Some(Ok(run)) => { + ui.add_space(10.0); + ui.separator(); + ui.add_space(6.0); + let reference = st.reference().matching(&run).cloned(); + results_view(ui, &run, reference.as_ref()); + st.last = Some(Ok(run)); + } + Some(Err(e)) => { + ui.add_space(8.0); + if e == "stopped" { + ui.label(RichText::new("Stopped.").weak()); + } else { + ui.label(RichText::new(format!("The benchmark did not finish: {e}")) + .color(Color32::from_rgb(220, 170, 90))); } - ui.end_row(); - }; - row(ui, "iris-bench", &bench_bin, "not built — cargo build --release --bin iris-bench"); - row(ui, "suite binary", &elf, "not built — make -C bench (needs a MIPS cross toolchain)"); - row(ui, "host baseline", &hostb, "not built — make -C bench hostbench"); - }); + st.last = Some(Err(e)); + } + None => {} + } - ui.add_space(8.0); + details(ui, st); + + #[cfg(not(feature = "appstore"))] + developer_tools(ui, st); +} + +fn controls(ui: &mut Ui, st: &mut BenchState, machine_running: bool) { let busy = st.is_running(); ui.horizontal(|ui| { - ui.add_enabled_ui(!busy && bench_bin.is_some() && elf.is_some(), |ui| { + // Two machines at once would measure the wrong thing: the emulator + // would be sharing the host with itself, and every number would be a + // reading of whatever IRIX happened to be doing. Refusing is also + // simpler than explaining the result afterwards. + ui.add_enabled_ui(!busy && !machine_running, |ui| { + let secs = if st.quick { QUICK_SECS } else { FULL_SECS }; if ui - .button("Run once") - .on_hover_text( - "Run the suite against target/release/iris as it is built right now. \ - A couple of minutes on the interpreter.", - ) + .button(RichText::new(primary_label()).strong()) + .on_hover_text(format!("About {secs} seconds. Runs entirely inside this app.")) .clicked() { - st.start("run", &["run", "--label", "gui"]); + let quick = st.quick; + st.start(quick); } }); - ui.add_enabled_ui(!busy && bench_bin.is_some() && hostb.is_some(), |ui| { - if ui - .button("Measure this host") + ui.add_enabled_ui(!busy, |ui| { + ui.checkbox(&mut st.quick, "Quick") .on_hover_text( - "Run the identical kernels natively, for the ratio between \ - emulated and native. About ten seconds.", - ) - .clicked() - { - st.start("host", &["host"]); - } + "Shorter timed runs and one pass per kernel instead of the best of \ + two. Every kernel still runs and still checks its answer, so the \ + accuracy score means exactly the same thing — only the speed \ + figures are a little noisier.", + ); }); - ui.add_enabled_ui(!busy && bench_bin.is_some() && elf.is_some(), |ui| { - if ui - .button("Full matrix") - .on_hover_text( - "R4400 and R5000, interpreter and jitv2. Builds a separate emulator \ - for each — the CPU model and the JIT are compile-time cargo features. \ - Tens of minutes, and it needs cargo on PATH.", - ) - .clicked() - { - st.start("matrix", &["matrix"]); + if busy && ui.button("Stop").clicked() { + st.stop(); + } + }); + + if machine_running && !busy { + ui.add_space(4.0); + ui.label( + RichText::new("Stop the emulator first — a benchmark run needs the machine to itself.") + .weak(), + ); + } +} + +fn primary_label() -> String { + // Name the machine the way its owner does. This measures the emulator, but + // what varies between users is the hardware under it. + let what = if cfg!(target_os = "macos") { "this Mac" } else { "this PC" }; + format!("Benchmark {what}") +} + +fn running_view(ui: &mut Ui, live: &Live) { + let st = live.state.lock().unwrap().clone(); + let elapsed = live.started.elapsed(); + + ui.add(egui::ProgressBar::new(st.fraction()).show_percentage().animate(true)); + ui.horizontal(|ui| { + ui.label(if st.current.is_empty() { + "Starting the emulated machine…".to_string() + } else { + format!("{} ({} of {})", st.current, st.done, st.total) + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(remaining(&st, elapsed, live.quick)).weak()); + }); + }); + + // The worker writes into shared state; without this the panel only updates + // when the pointer moves over it. + ui.ctx().request_repaint_after(Duration::from_millis(200)); +} + +/// Elapsed, plus an estimate once there is enough evidence for one. +/// +/// Extrapolating from kernels-completed is only honest after a few of them: +/// the first rows are the cheap integer kernels and the last are the expensive +/// codec ones, so an estimate from row two is confidently wrong. Before that, +/// say only what is known. +fn remaining(st: &LiveState, elapsed: Duration, quick: bool) -> String { + let e = elapsed.as_secs(); + if st.done < 4 || st.total == 0 { + let guess = if quick { QUICK_SECS } else { FULL_SECS }; + return format!("{e}s elapsed · about {guess}s in total"); + } + let per = elapsed.as_secs_f64() / st.done as f64; + let left = (per * (st.total - st.done) as f64).round() as u64; + format!("{e}s elapsed · about {left}s left") +} + +// ─── results ───────────────────────────────────────────────────────────────── + +fn results_view(ui: &mut Ui, run: &Run, reference: Option<&ReferenceEntry>) { + ui.heading("Results"); + ui.add_space(4.0); + + // The three figures that stand on their own. DMIPS has forty years of + // published numbers behind it, guest MIPS is meaningful without a baseline, + // and accuracy needs no comparison at all — which is why an empty reference + // table costs the reader so little. + egui::Grid::new("bench_headline").num_columns(3).spacing([18.0, 6.0]).show(ui, |ui| { + headline(ui, "Emulated Indy", &format!("{:.0} DMIPS", run.dmips().unwrap_or(0.0)), + "Dhrystone 2.1, the figure every published workstation benchmark since \ + 1988 uses. A real 150 MHz Indy scored about 130."); + ui.end_row(); + headline(ui, "Emulator throughput", &format!("{:.0} MIPS", run.mips()), + "Guest instructions retired per second of host time."); + ui.end_row(); + headline(ui, "Accuracy", + &format!("{:.0}% ({}/{})", run.accuracy(), run.matched, run.checked), + "Share of kernels whose result matched a checksum computed independently \ + by building the same C natively. Anything below 100% is a real emulator \ + bug and worth reporting."); + ui.end_row(); + }); + + ui.add_space(10.0); + + egui::Grid::new("bench_categories").num_columns(3).spacing([18.0, 6.0]).striped(true) + .show(ui, |ui| { + for c in CATEGORIES { + let Some(rate) = run.category_rate(c) else { continue }; + ui.label(c.label); + ui.label(RichText::new(format!("{}{}", fmt_rate(rate), c.suffix)).monospace()); + match reference.and_then(|r| ratio(run, r, c.label)) { + Some((ratio, label)) => { + ui.label(RichText::new(format!("{ratio:.2}× vs {label}")).weak()); + } + None => { ui.label(""); } + } + ui.end_row(); } }); - if busy && ui.button("Stop").clicked() { st.stop(); } + ui.add_space(8.0); + if reference.is_none() { + ui.label( + RichText::new("Reference statistics not gathered for this platform.").weak(), + ); + } + caveats(ui, run); + + ui.add_space(8.0); + machine_view(ui, run); - if let Some(dir) = results_dir() { - if !busy && ui.button("Open results").clicked() { open_folder(&dir); } + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Copy").clicked() { + ui.ctx().copy_text(report_text(run)); + } + if ui.button("Save report…").clicked() { + save_report(run); } }); +} - if busy { - ui.add_space(4.0); - ui.horizontal(|ui| { - ui.spinner(); - ui.label(format!("{} running…", st.what)); +/// The machine that was measured, as the guest found it. +/// +/// Read out of the emulated hardware by the suite itself — CP0 Config for the +/// caches, the memory controller for the banks — not reported from what the +/// GUI configured. When those two disagree, the guest is right and the +/// disagreement is the interesting part. +/// +/// Collapsed by default: it is provenance, not a headline. But it belongs on +/// the results screen rather than only in the export, because it is what makes +/// the numbers above comparable with anything. +fn machine_view(ui: &mut Ui, run: &Run) { + let m = &run.machine; + egui::CollapsingHeader::new("Machine measured").default_open(false).show(ui, |ui| { + egui::Grid::new("bench_machine").num_columns(2).spacing([18.0, 4.0]).striped(true) + .show(ui, |ui| { + let mut row = |k: &str, v: String| { + ui.label(k); + ui.label(RichText::new(v).monospace()); + ui.end_row(); + }; + + let rev = if m.rev.is_empty() { String::new() } else { format!(" rev {}", m.rev) }; + row("CPU", format!("{}{} ({})", m.cpu, rev, m.prid)); + + if !m.cache.is_empty() { + let c = &m.cache; + row("L1 cache", format!( + "{} I ({} B lines) / {} D ({} B lines)", + fmt_bytes(c.l1i_bytes), c.l1i_line, + fmt_bytes(c.l1d_bytes), c.l1d_line)); + row("L2 cache", if !c.l2_present { + "absent".to_string() + } else if c.l2_bytes > 0 { + format!("{}, {} B lines", fmt_bytes(c.l2_bytes), c.l2_line) + } else { + // The architecture does not expose the size on anything + // but a Triton; the PROM reads it from the EEPROM. + format!("present, {} B lines, size not reported", c.l2_line) + }); + } + + if !m.memory.is_empty() { + let banks: Vec = m.memory.banks.iter() + .map(|b| format!("bank{} {} MB @ {:#010x}", b.index, b.mb, b.base)) + .collect(); + row("Memory", format!("{} MB {}", m.memory.total_mb, banks.join(" "))); + } + + if !m.sysid.is_empty() { row("Board", format!("SYSID {}", m.sysid)); } + row("Emulator", if run.features.is_empty() { + "no optional features".to_string() + } else { + run.features.join(" ") + }); + row("Host", format!("{} · {} {} · {} cores", + m_host(run), run.host.os, run.host.arch, run.host.cores)); + }); + }); +} + +fn m_host(run: &Run) -> &str { + if run.host.cpu_model.is_empty() { "unknown CPU" } else { &run.host.cpu_model } +} + +/// KB under a megabyte, MB above — the way a person reads a cache size. +fn fmt_bytes(n: u64) -> String { + if n >= 1 << 20 { format!("{} MB", n >> 20) } + else if n >= 1 << 10 { format!("{} KB", n >> 10) } + else { format!("{} B", n) } +} + +fn headline(ui: &mut Ui, label: &str, value: &str, help: &str) { + ui.label(label); + ui.label(RichText::new(value).size(18.0).strong()); + ui.label(RichText::new("?").weak()).on_hover_text(help); +} + +/// This run's category rate over the reference row's, when the row carries +/// enough kernels to compute one. +fn ratio(run: &Run, reference: &ReferenceEntry, category: &str) -> Option<(f64, String)> { + let c = CATEGORIES.iter().find(|c| c.label == category)?; + let mine = run.category_rate(c)?; + + // The stored row keeps per-kernel rates, so rebuild the category from them + // the same way — but weighted by this run's own times, since the reference + // row does not carry them. Close enough for a "roughly this much faster", + // which is all the comparison claims to be. + let (mut num, mut den) = (0.0f64, 0.0f64); + for r in &run.rows { + if r.status == "SKIP" || r.unit != c.unit || r.ns == 0 { continue; } + if !c.prefixes.iter().any(|p| r.name.starts_with(p)) { continue; } + let Some(their_rate) = reference.kernels.get(&r.name) else { continue }; + if *their_rate <= 0.0 { continue; } + num += *their_rate * r.ns as f64; + den += r.ns as f64; + } + if den == 0.0 || num == 0.0 { return None; } + Some((mine / (num / den), reference.label.clone())) +} + +/// Everything that would make a reader draw the wrong conclusion from the +/// numbers above, said plainly rather than left for them to discover. +fn caveats(ui: &mut Ui, run: &Run) { + if let Some(why) = run.settings.shortened_because() { + ui.label(RichText::new(format!("Quick run — {why}. Accuracy is unaffected.")) + .weak()); + } + if !run.features.iter().any(|f| f == "jitv2") { + ui.label(RichText::new( + "This build runs the interpreter. A build with the MIPS JIT scores roughly \ + four times higher, and is not comparable with these numbers.").weak()); + } + let unexpected: Vec<&str> = run.rows.iter() + .filter(|r| r.exc > 0 && !iris::bench_report::EXPECT_EXC.contains(&r.name.as_str())) + .map(|r| r.name.as_str()) + .collect(); + if !unexpected.is_empty() { + // A kernel that faults is stepped over by the harness and still reports + // a throughput — for doing something other than what it claims. + ui.label(RichText::new(format!( + "Took unexpected exceptions in {} — those figures are not trustworthy.", + unexpected.join(", "))) + .color(Color32::from_rgb(220, 170, 90))); + } +} + +// ─── details, export ───────────────────────────────────────────────────────── + +fn details(ui: &mut Ui, st: &mut BenchState) { + let lines = st.live.as_ref().map(|l| l.lines.clone()); + let has_output = lines.is_some() || st.dev.has_output(); + if !has_output && st.last.is_none() { + return; + } + + ui.add_space(8.0); + egui::CollapsingHeader::new("Details") + .default_open(false) + .show(ui, |ui| { + // The guest's own console, which is where the per-kernel table and + // any complaint from the harness appear. Secondary on purpose: a + // wall of monospace as the primary surface reads as "something went + // wrong" to a reader who did not ask for a log. + let buf = lines.or_else(|| st.dev.lines()); + ScrollArea::vertical() + .max_height(320.0) + .stick_to_bottom(true) + .auto_shrink([false, false]) + .show(ui, |ui| { + match buf { + Some(b) => { + let v = b.lock().unwrap(); + if v.is_empty() { + ui.label(RichText::new("(nothing yet)").weak()); + } + for l in v.iter() { + ui.label(RichText::new(l).monospace().size(11.0)); + } + } + None => { ui.label(RichText::new("(nothing yet)").weak()); } + } + }); }); - // A background thread is writing lines; without this the panel only - // updates when the pointer moves over it. - ui.ctx().request_repaint_after(std::time::Duration::from_millis(200)); +} + +fn report_text(run: &Run) -> String { + let mut s = String::new(); + s.push_str(&format!("IRIS benchmark — {}\n", run.host.cpu_model)); + s.push_str(&format!(" emulated CPU {} ({})\n", run.machine.cpu, + iris::bench_report::engine_of(run))); + s.push_str(&format!(" build features {}\n", + if run.features.is_empty() { "(none)".into() } else { run.features.join(" ") })); + s.push_str(&format!(" host {} {} · {} cores\n", + run.host.os, run.host.arch, run.host.cores)); + if !run.machine.rev.is_empty() { + s.push_str(&format!(" emulated PRId {} rev {}\n", run.machine.prid, run.machine.rev)); + } + if !run.machine.cache.is_empty() { + let c = &run.machine.cache; + s.push_str(&format!(" L1 cache {} I ({} B) / {} D ({} B)\n", + fmt_bytes(c.l1i_bytes), c.l1i_line, + fmt_bytes(c.l1d_bytes), c.l1d_line)); + s.push_str(&format!(" L2 cache {}\n", if !c.l2_present { + "absent".to_string() + } else if c.l2_bytes > 0 { + format!("{}, {} B lines", fmt_bytes(c.l2_bytes), c.l2_line) + } else { + format!("present, {} B lines, size not reported", c.l2_line) + })); + } + if !run.machine.memory.is_empty() { + s.push_str(&format!(" memory {} MB in {} bank(s)\n", + run.machine.memory.total_mb, run.machine.memory.banks.len())); + } + s.push('\n'); + s.push_str(&format!(" Emulated Indy {:.1} DMIPS\n", run.dmips().unwrap_or(0.0))); + s.push_str(&format!(" Throughput {:.1} MIPS\n", run.mips())); + s.push_str(&format!(" Accuracy {:.1}% ({}/{})\n", + run.accuracy(), run.matched, run.checked)); + s.push('\n'); + for c in CATEGORIES { + if let Some(rate) = run.category_rate(c) { + s.push_str(&format!(" {:<16} {}{}\n", c.label, fmt_rate(rate), c.suffix)); + } + } + s.push('\n'); + s.push_str(&format!(" suite {}\n", run.suite_id)); + if let Some(why) = run.settings.shortened_because() { + s.push_str(&format!(" quick run {}\n", why)); } + s.push_str(&format!(" wall clock {:.1} s\n", run.wall_s)); + s +} - let summary = st.summarize(); - if !summary.is_empty() { - ui.add_space(6.0); - ui.separator(); - for line in &summary { - ui.label(RichText::new(line).monospace().strong()); +fn save_report(run: &Run) { + // An explicit save panel, so the only place this ever lands outside the + // app's own container is one the user picked. Nothing is uploaded. + let name = format!("iris-benchmark-{}.json", run.cell.replace(['/', ' '], "-")); + let Some(path) = rfd::FileDialog::new() + .set_title("Save benchmark report") + .set_file_name(&name) + .add_filter("JSON", &["json"]) + .save_file() + else { + return; + }; + match serde_json::to_string_pretty(run) { + Ok(json) => { + if let Err(e) = std::fs::write(&path, json) { + log::warn!("saving the benchmark report to {}: {e}", path.display()); + } } + Err(e) => log::warn!("serialising the benchmark report: {e}"), } - if let Some(out) = &st.last { - ui.label( - RichText::new(format!("{}: {}", out.label, out.detail)) - .color(if out.ok { Color32::LIGHT_GREEN } else { Color32::from_rgb(220, 170, 90) }), - ); +} + +// ─── developer tools (source builds only) ──────────────────────────────────── + +/// The subprocess runner, for the things that genuinely need one. +/// +/// `matrix` builds a separate emulator per cell, because the CPU model and the +/// JIT are cargo features rather than runtime switches — comparing them means +/// comparing binaries. `host` runs the same kernels compiled natively. Both +/// need a source checkout, a toolchain and a writable tree, so both are hidden +/// from a distributed build. +#[derive(Default)] +struct DevRunner { + #[cfg(not(feature = "appstore"))] + inner: dev::State, +} + +impl DevRunner { + fn is_running(&self) -> bool { + #[cfg(not(feature = "appstore"))] { self.inner.is_running() } + #[cfg(feature = "appstore")] { false } } + fn stop(&mut self) { + #[cfg(not(feature = "appstore"))] { self.inner.stop(); } + } + fn has_output(&self) -> bool { + #[cfg(not(feature = "appstore"))] { self.inner.has_output() } + #[cfg(feature = "appstore")] { false } + } + fn lines(&self) -> Option>>> { + #[cfg(not(feature = "appstore"))] { self.inner.lines() } + #[cfg(feature = "appstore")] { None } + } +} - ui.add_space(6.0); +#[cfg(not(feature = "appstore"))] +fn developer_tools(ui: &mut Ui, st: &mut BenchState) { + ui.add_space(12.0); ui.separator(); - ui.label("Output"); - ScrollArea::vertical() - .max_height(320.0) - .stick_to_bottom(true) - .auto_shrink([false, false]) - .show(ui, |ui| { - let lines = st.lines.lock().unwrap(); - if lines.is_empty() { - ui.label(RichText::new("(nothing yet)").weak()); + egui::CollapsingHeader::new("Developer tools").default_open(false).show(ui, |ui| { + ui.label(RichText::new( + "These shell out to iris-bench and need a source checkout. The matrix builds \ + one emulator per cell — the CPU model and the JIT are compile-time features, \ + so comparing them means comparing binaries.").weak()); + ui.add_space(6.0); + + let busy = st.is_running(); + let have_bin = dev::iris_bench_bin(); + ui.horizontal(|ui| { + ui.add_enabled_ui(!busy && have_bin.is_some(), |ui| { + if ui.button("Full matrix") + .on_hover_text("R4400 and R5000, interpreter and jitv2. Tens of minutes, \ + and it needs cargo on PATH.") + .clicked() + { + st.dev.inner.start("matrix", &["matrix"]); + } + if ui.button("Measure this host") + .on_hover_text("The identical kernels compiled natively, for the ratio \ + between emulated and native.") + .clicked() + { + st.dev.inner.start("host", &["host"]); + } + }); + if have_bin.is_none() { + ui.label(RichText::new("iris-bench not built — cargo build --release --bin iris-bench") + .color(Color32::from_rgb(220, 170, 90))); } - for l in lines.iter() { - ui.label(RichText::new(l).monospace().size(11.0)); + if let Some(dir) = dev::results_dir() { + if !busy && ui.button("Open results").clicked() { dev::open_folder(&dir); } } }); + if let Some(o) = st.dev.inner.last_outcome() { + ui.label(RichText::new(o).weak()); + } + }); } -fn open_folder(dir: &Path) { - #[cfg(target_os = "windows")] - let _ = Command::new("explorer").arg(dir).spawn(); - #[cfg(target_os = "macos")] - let _ = Command::new("open").arg(dir).spawn(); - #[cfg(all(unix, not(target_os = "macos")))] - let _ = Command::new("xdg-open").arg(dir).spawn(); +#[cfg(not(feature = "appstore"))] +mod dev { + use super::*; + use std::io::{BufRead, BufReader}; + use std::path::{Path, PathBuf}; + use std::process::{Child, Command, Stdio}; + + #[derive(Default)] + pub struct State { + lines: Arc>>, + running: Arc, + child: Arc>>, + outcome: Arc>>, + } + + impl State { + pub fn is_running(&self) -> bool { self.running.load(Ordering::Relaxed) } + pub fn has_output(&self) -> bool { !self.lines.lock().unwrap().is_empty() } + pub fn lines(&self) -> Option>>> { + if self.has_output() { Some(self.lines.clone()) } else { None } + } + pub fn last_outcome(&self) -> Option { self.outcome.lock().unwrap().clone() } + + pub fn stop(&mut self) { + if let Some(c) = self.child.lock().unwrap().as_mut() { let _ = c.kill(); } + } + + pub fn start(&mut self, label: &str, args: &[&str]) { + if self.is_running() { return; } + let Some(bin) = iris_bench_bin() else { return }; + + self.lines.lock().unwrap().clear(); + *self.outcome.lock().unwrap() = None; + self.running.store(true, Ordering::Relaxed); + + let lines = Arc::clone(&self.lines); + let running = Arc::clone(&self.running); + let child_slot = Arc::clone(&self.child); + let outcome = Arc::clone(&self.outcome); + let owned: Vec = args.iter().map(|s| s.to_string()).collect(); + // The repo root, so bench/ and target/ resolve the way iris-bench + // expects — it takes every path relative to there. + let cwd = bin.parent().and_then(|p| p.parent()).and_then(|p| p.parent()) + .map(PathBuf::from).unwrap_or_else(|| PathBuf::from(".")); + let label = label.to_string(); + + std::thread::spawn(move || { + let mut cmd = Command::new(&bin); + cmd.current_dir(&cwd).args(&owned) + .stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + *outcome.lock().unwrap() = Some(format!("failed to start {}: {e}", bin.display())); + running.store(false, Ordering::Relaxed); + return; + } + }; + + let so = child.stdout.take(); + let se = child.stderr.take(); + *child_slot.lock().unwrap() = Some(child); + + // Both pipes on their own threads: a child that fills one while + // we read the other would stall instead of finishing. + let pump = |r: Option>, sink: Arc>>| { + std::thread::spawn(move || { + if let Some(r) = r { + for line in BufReader::new(r).lines().map_while(Result::ok) { + let mut v = sink.lock().unwrap(); + v.push(line); + if v.len() > MAX_LINES { let drop_n = v.len() - MAX_LINES; v.drain(..drop_n); } + } + } + }) + }; + let t1 = pump(so.map(|s| Box::new(s) as Box), Arc::clone(&lines)); + let t2 = pump(se.map(|s| Box::new(s) as Box), Arc::clone(&lines)); + + let status = child_slot.lock().unwrap().as_mut().map(|c| c.wait()); + let _ = t1.join(); + let _ = t2.join(); + *child_slot.lock().unwrap() = None; + + let ok = matches!(status, Some(Ok(s)) if s.success()); + *outcome.lock().unwrap() = + Some(format!("{label} {}", if ok { "finished" } else { "failed" })); + running.store(false, Ordering::Relaxed); + }); + } + } + + /// Where the pieces are, relative to wherever the GUI was launched from. + /// The dev workflow runs it from the repo root; an installed layout puts + /// the binaries next to the executable. + fn locate(rel: &str) -> Option { + let exe_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(PathBuf::from)); + let name = Path::new(rel).file_name()?.to_owned(); + let mut candidates = vec![PathBuf::from(rel), PathBuf::from("..").join(rel)]; + if let Some(d) = exe_dir { + candidates.push(d.join(&name)); + candidates.push(d.join(rel)); + } + candidates.into_iter().find(|p| p.exists()) + } + + pub fn iris_bench_bin() -> Option { + let exe = if cfg!(windows) { "iris-bench.exe" } else { "iris-bench" }; + locate(&format!("target/release/{exe}")) + } + + pub fn results_dir() -> Option { locate("bench/build/results") } + + pub fn open_folder(dir: &Path) { + #[cfg(target_os = "windows")] + let _ = Command::new("explorer").arg(dir).spawn(); + #[cfg(target_os = "macos")] + let _ = Command::new("open").arg(dir).spawn(); + #[cfg(all(unix, not(target_os = "macos")))] + let _ = Command::new("xdg-open").arg(dir).spawn(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use iris::bench_report::{HostInfo, MachineInfo, Row, RunSettings}; + use std::collections::BTreeMap; + + fn row(name: &str, unit: &str, work: u64, ns: u64) -> Row { + Row { + name: name.into(), unit: unit.into(), iters: 1, work, ns, + icount: work, count: 0, exc: 0, + checksum: "0x0".into(), golden: "0x0".into(), status: "OK".into(), + } + } + + fn run() -> Run { + Run { + cell: "test".into(), + features: vec!["tlbvmap".into()], + machine: MachineInfo { + cpu: "R4400".into(), timebase: true, + prid: "0x00000440".into(), rev: "4.0".into(), sysid: "0x00000013".into(), + cache: iris::bench_report::CacheInfo { + l1i_bytes: 16384, l1i_line: 16, + l1d_bytes: 16384, l1d_line: 16, + l2_present: true, l2_line: 128, l2_bytes: 0, + }, + memory: iris::bench_report::MemoryInfo { + total_mb: 256, + banks: vec![ + iris::bench_report::Bank { index: 0, mb: 128, base: 0x0800_0000 }, + iris::bench_report::Bank { index: 1, mb: 128, base: 0x1000_0000 }, + ], + }, + ..Default::default() + }, + host: HostInfo { cpu_model: "Test CPU".into(), os: "linux".into(), + arch: "x86_64".into(), cores: 8 }, + // Two kernels of the same unit but very different durations, so a + // test can tell a time-weighted aggregate from a plain average. + rows: vec![ + row("int/alu", "ops", 100, 1_000_000_000), + row("int/bitops", "ops", 300, 3_000_000_000), + ], + checked: 2, matched: 2, + total_ns: 4_000_000_000, total_icount: 400, + wall_s: 30.0, + suite_id: "blake3:0123456789abcdef".into(), + settings: RunSettings::default(), + } + } + + fn reference(alu: f64, bitops: f64) -> ReferenceEntry { + ReferenceEntry { + id: "ref".into(), label: "Reference machine".into(), + cpu: "R4400".into(), engine: "interp".into(), host: "Other CPU".into(), + measured: None, guest_mips: 1.0, dmips: 1.0, accuracy: 100.0, + kernels: BTreeMap::from([ + ("int/alu".to_string(), alu), + ("int/bitops".to_string(), bitops), + ]), + } + } + + #[test] + fn a_reference_that_matches_exactly_gives_a_ratio_of_one() { + // Both kernels ran at 100 units/s here; a reference row saying the same + // must come out as 1.00x however the two are weighted together. + let (r, label) = ratio(&run(), &reference(100.0, 100.0), "Integer").unwrap(); + assert!((r - 1.0).abs() < 1e-9, "expected 1.0, got {r}"); + assert_eq!(label, "Reference machine"); + } + + #[test] + fn the_comparison_weights_kernels_by_how_long_they_ran() { + // int/bitops took 3s of the 4s, so the reference aggregate must sit at + // 3/4 of the way to its value: (50*1 + 150*3)/4 = 125, and this run's + // own aggregate is 400 units / 4 s = 100. A plain mean of 50 and 150 + // would be 100 and give exactly 1.0 — which is the bug this catches. + let (r, _) = ratio(&run(), &reference(50.0, 150.0), "Integer").unwrap(); + assert!((r - 100.0 / 125.0).abs() < 1e-9, "expected 0.8, got {r}"); + } + + #[test] + fn a_reference_row_with_no_overlapping_kernels_yields_no_comparison() { + let mut r = reference(100.0, 100.0); + r.kernels.clear(); + assert!(ratio(&run(), &r, "Integer").is_none()); + // And a category this run has no rows for. + assert!(ratio(&run(), &reference(100.0, 100.0), "Codec").is_none()); + } + + #[test] + fn the_time_estimate_waits_for_evidence_before_making_one() { + let early = LiveState { total: 46, done: 2, current: "int/alu".into() }; + let s = remaining(&early, Duration::from_secs(3), true); + assert!(s.contains("in total"), "must not extrapolate from two rows: {s}"); + assert!(!s.contains("left")); + + // Halfway through 40s of a 46-kernel run: about 40s more to go. + let later = LiveState { total: 46, done: 23, current: "mem/copy".into() }; + let s = remaining(&later, Duration::from_secs(40), true); + assert!(s.contains("40s left"), "expected an estimate near 40s, got {s}"); + } + + #[test] + fn progress_is_a_fraction_even_before_the_guest_says_how_many() { + assert_eq!(LiveState::default().fraction(), 0.0); + assert_eq!(LiveState { total: 4, done: 1, ..Default::default() }.fraction(), 0.25); + // A guest that somehow reports past its own plan must not overflow the bar. + assert_eq!(LiveState { total: 4, done: 9, ..Default::default() }.fraction(), 1.0); + } + + #[test] + fn sizes_read_the_way_a_person_reads_them() { + assert_eq!(fmt_bytes(16384), "16 KB"); + assert_eq!(fmt_bytes(1 << 20), "1 MB"); + assert_eq!(fmt_bytes(512), "512 B"); + } + + #[test] + fn an_l2_of_unknown_size_is_reported_as_present_not_as_absent() { + // 0 bytes means "the architecture does not say", which is the normal + // case on everything but a Triton. Rendering that as "absent" would + // misdescribe every R4400 result there is. + let text = report_text(&run()); + assert!(text.contains("present, 128 B lines, size not reported"), + "an unsized L2 must still read as present:\n{text}"); + assert!(!text.contains("L2 cache absent")); + } + + #[test] + fn the_exported_report_carries_what_makes_the_numbers_meaningful() { + let text = report_text(&run()); + for want in ["Test CPU", "R4400", "interp", "DMIPS", "MIPS", "Accuracy", + "Integer", "blake3:0123456789abcdef", + // The inventory is what makes two results comparable at + // all — the mem/ kernels are a readout of this hierarchy. + "16 KB I", "256 MB in 2 bank(s)", "0x00000440 rev 4.0"] { + assert!(text.contains(want), "the report must mention {want}:\n{text}"); + } + // A full run says nothing about being shortened. + assert!(!text.contains("quick run")); + + let mut quick = run(); + quick.settings = RunSettings { groups: 0x3F, time_pct: 30, repeats: 1 }; + assert!(report_text(&quick).contains("quick run"), + "a shortened run must say so wherever its numbers travel"); + } } diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index de28b36..0ef7108 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -112,11 +112,13 @@ impl Tab { } if !cfg!(feature = "appstore") { tabs.push(Tab::Ci); - // Benchmarking spawns cargo and a second emulator; that is a - // developer workflow, and a sandboxed App Store build can do - // neither. Same reasoning as the CI tab it sits next to. - tabs.push(Tab::Bench); } + // The benchmark runs entirely inside this process — the guest image is + // linked in and the emulated machine is one this app builds itself — so + // unlike the CI tab it works in a sandbox and ships to everyone. Its + // developer half (the matrix runner, which builds one emulator per + // cell) is hidden inside the tab instead. + tabs.push(Tab::Bench); tabs } pub fn label(self) -> &'static str { @@ -198,7 +200,7 @@ pub fn show_tab( Tab::VideoIn => TabOutcome { action: show_vino(ui, cfg), ..Default::default() }, Tab::Debug => TabOutcome { action: show_debug(ui, cfg), ..Default::default() }, Tab::Ci => TabOutcome { action: show_ci(ui, cfg), ..Default::default() }, - Tab::Bench => { crate::bench_ui::show(ui, bench); TabOutcome::default() } + Tab::Bench => { crate::bench_ui::show(ui, bench, mem_ctx.running); TabOutcome::default() } }).inner } From 36c39f5cfaf933e35bfc66318ef211b8801a4209 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 21 Aug 2026 16:05:44 -0400 Subject: [PATCH 11/15] iris-gui: open file dialogs where the file is, not where you last browsed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking 📁 on a disk under Disks → scsi1 opened at whatever folder was last used, not at the folder the image lives in or is headed for. Every picker in the app had the same shape of bug: seed the panel at the current path's folder *if that folder happens to exist*, otherwise set nothing. Setting nothing is not neutral — NSOpenPanel and NSSavePanel restore their own last-used location when setDirectoryURL: is not given one, and the Windows and GTK/portal backends have equivalent remembered defaults. So "do nothing" means "open somewhere unrelated", and it did so in exactly the cases where it is most annoying: - a disk image that does not exist yet has no existing parent folder, so browsing for the thing you are about to create opened anywhere but where it is going; - a bare or relative path (`scsi1.raw`) has no parent at all; - the managed /disks folder is only created when a disk is created there, so on a fresh install the fallback did not exist either and was skipped too. Four of the pickers — every one in the New Machine dialog, which is the first thing a new user touches — never set a directory at all. Adds `filedialog`, which all of them now go through. It resolves the folder a file lives in or is destined for, resolving relative names against the managed directory rather than the process working directory (which differs between `cargo run` and a bundled .app); walks *up* to the nearest ancestor that exists rather than giving up, so a disk bound for ~/VMs/indy/disks/root.raw opens at ~/VMs/indy; and falls back to the app's own managed folder, creating that one, since it is ours and it is where the UI says disks go. The invariant is that it always returns a directory that exists, so the panel is never handed nothing — there is a test asserting exactly that, and it caught a hole in the first draft. Disk images, CD images and every disc in the changer anchor on the managed disks folder; PROM, NVRAM, logs and exports anchor on the data folder. `dialog_at_dir` opens *at* a folder rather than its parent, for the sandbox grant flow, whose whole point is that the user just confirms the folder we already named. Co-Authored-By: Claude Opus 5 (1M context) --- iris-gui/src/bench_ui.rs | 8 +- iris-gui/src/config_ui.rs | 46 ++++-- iris-gui/src/dialogs/create_disk.rs | 16 +- iris-gui/src/dialogs/new_machine.rs | 27 ++-- iris-gui/src/filedialog.rs | 233 ++++++++++++++++++++++++++++ iris-gui/src/main.rs | 24 +-- iris-gui/src/scsi_menu.rs | 17 +- 7 files changed, 312 insertions(+), 59 deletions(-) create mode 100644 iris-gui/src/filedialog.rs diff --git a/iris-gui/src/bench_ui.rs b/iris-gui/src/bench_ui.rs index 3aa428e..0fffaef 100644 --- a/iris-gui/src/bench_ui.rs +++ b/iris-gui/src/bench_ui.rs @@ -586,11 +586,9 @@ fn save_report(run: &Run) { // An explicit save panel, so the only place this ever lands outside the // app's own container is one the user picked. Nothing is uploaded. let name = format!("iris-benchmark-{}.json", run.cell.replace(['/', ' '], "-")); - let Some(path) = rfd::FileDialog::new() - .set_title("Save benchmark report") - .set_file_name(&name) - .add_filter("JSON", &["json"]) - .save_file() + let Some(path) = crate::filedialog::dialog_with( + "Save benchmark report", &name, crate::filedialog::Anchor::Data, + &[("JSON", &["json"])]).save_file() else { return; }; diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index 0ef7108..459d7dc 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -550,7 +550,7 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> (PathEdit, ConfigAction) } Grid::new(("scsi_grid", id)).num_columns(2).striped(true).show(ui, |ui| { ui.label("Image path"); - let e = path_row(ui, ("scsi_path", id), &mut dev.path, + let e = path_row_disk(ui, ("scsi_path", id), &mut dev.path, if dev.scratch { Pick::SaveFile } else { Pick::OpenFile }, DISK_FILTERS); edit.changed |= e.changed; @@ -641,7 +641,7 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> (PathEdit, ConfigAction) let mut drop_idx: Option = None; for (i, disc) in dev.discs.iter_mut().enumerate() { ui.horizontal(|ui| { - let e = path_row(ui, ("disc", id, i), disc, Pick::OpenFile, DISK_FILTERS); + let e = path_row_disk(ui, ("disc", id, i), disc, Pick::OpenFile, DISK_FILTERS); edit.changed |= e.changed; edit.picked |= e.picked; if ui.button("×").clicked() { drop_idx = Some(i); } @@ -1577,26 +1577,26 @@ fn scsi_type_combo(ui: &mut Ui, id: u8, dev: &mut ScsiDeviceConfig, edit: &mut P } /// A TextEdit + 📁 Browse button that updates `value` in place. See [`PathEdit`]. -fn path_row( +/// +/// Browsing opens at the folder `value` names — or, when it names nothing yet, +/// the managed folder for `anchor`. It never opens at the OS's last-used +/// location; `crate::filedialog` explains why that mattered. +fn path_row_in( ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, // egui 0.35 push_id needs AsIdSalt (Hash + Debug) value: &mut String, mode: Pick, filters: &[(&str, &[&str])], + anchor: crate::filedialog::Anchor, ) -> PathEdit { let mut out = PathEdit::default(); ui.push_id(id, |ui| { ui.horizontal(|ui| { out.changed |= ui.add(TextEdit::singleline(value).desired_width(320.0)).changed(); if ui.button("📁").on_hover_text("Browse…").clicked() { - let mut d = rfd::FileDialog::new(); - // Start in the existing path's folder, else the managed disks dir. - let p = Path::new(value.as_str()); - let dir = p.parent().filter(|d| !d.as_os_str().is_empty() && d.is_dir()) - .map(|d| d.to_path_buf()) - .or_else(|| crate::settings::GuiSettings::disks_dir().filter(|d| d.is_dir())); - if let Some(dir) = dir { d = d.set_directory(dir); } - if let Some(name) = p.file_name() { d = d.set_file_name(name.to_string_lossy()); } + // Open where this file lives, or is headed — never the OS's + // remembered folder. See `crate::filedialog`. + let mut d = crate::filedialog::dialog("Browse", value.as_str(), anchor); if matches!(mode, Pick::OpenFile | Pick::SaveFile) { for (label, exts) in filters { d = d.add_filter(*label, exts); @@ -1625,6 +1625,30 @@ fn path_row( out } +/// `path_row_in` anchored on the app's data folder: PROM, NVRAM, logs, shares. +fn path_row( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug, + value: &mut String, + mode: Pick, + filters: &[(&str, &[&str])], +) -> PathEdit { + path_row_in(ui, id, value, mode, filters, crate::filedialog::Anchor::Data) +} + +/// `path_row_in` anchored on the managed disks folder — every hard disk, CD +/// image and disc in the changer. Browsing one of these opens where that image +/// actually is, or where a not-yet-created one is destined for. +fn path_row_disk( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug, + value: &mut String, + mode: Pick, + filters: &[(&str, &[&str])], +) -> PathEdit { + path_row_in(ui, id, value, mode, filters, crate::filedialog::Anchor::Disks) +} + /// Same as `path_row` but for `Option` — Browse populates Some, /// the user can clear by emptying the text. fn path_row_opt( diff --git a/iris-gui/src/dialogs/create_disk.rs b/iris-gui/src/dialogs/create_disk.rs index b8a58ce..b681510 100644 --- a/iris-gui/src/dialogs/create_disk.rs +++ b/iris-gui/src/dialogs/create_disk.rs @@ -49,15 +49,13 @@ impl CreateDiskDialog { ui.horizontal(|ui| { ui.add(TextEdit::singleline(&mut self.filename).desired_width(220.0)); if ui.button("📁").clicked() { - let cur = std::path::Path::new(&self.filename); - let mut dlg = rfd::FileDialog::new().add_filter("Disk image", &["raw", "img"]); - if let Some(dir) = cur.parent().filter(|d| !d.as_os_str().is_empty()) { - let _ = std::fs::create_dir_all(dir); - dlg = dlg.set_directory(dir); - } - if let Some(name) = cur.file_name().and_then(|s| s.to_str()) { - dlg = dlg.set_file_name(name); - } + // Opens where this image is destined for, walking up + // to the nearest folder that exists rather than + // creating one the user has not asked for yet. + let dlg = crate::filedialog::dialog_with( + "New disk image", &self.filename, + crate::filedialog::Anchor::Disks, + &[("Disk image", &["raw", "img"])]); if let Some(p) = dlg.save_file() { self.filename = p.to_string_lossy().into_owned(); } diff --git a/iris-gui/src/dialogs/new_machine.rs b/iris-gui/src/dialogs/new_machine.rs index 94aada7..9412e65 100644 --- a/iris-gui/src/dialogs/new_machine.rs +++ b/iris-gui/src/dialogs/new_machine.rs @@ -114,9 +114,10 @@ impl NewMachineDialog { ui.add_enabled(!self.use_embedded_prom, TextEdit::singleline(&mut self.prom_path).desired_width(260.0)); if ui.add_enabled(!self.use_embedded_prom, egui::Button::new("📁")).clicked() { - if let Some(p) = rfd::FileDialog::new() - .add_filter("PROM image", &["bin"]) - .pick_file() + if let Some(p) = crate::filedialog::dialog_with( + "PROM image", &self.prom_path, + crate::filedialog::Anchor::Data, + &[("PROM image", &["bin"])]).pick_file() { self.prom_path = p.to_string_lossy().into_owned(); } @@ -131,8 +132,10 @@ impl NewMachineDialog { ui.horizontal(|ui| { ui.add(TextEdit::singleline(&mut self.nvram_path).desired_width(260.0)); if ui.button("📁").clicked() { - if let Some(p) = rfd::FileDialog::new() - .add_filter("NVRAM", &["bin"]).save_file() + if let Some(p) = crate::filedialog::dialog_with( + "NVRAM file", &self.nvram_path, + crate::filedialog::Anchor::Data, + &[("NVRAM", &["bin"])]).save_file() { self.nvram_path = p.to_string_lossy().into_owned(); } @@ -179,9 +182,10 @@ impl NewMachineDialog { ui.horizontal(|ui| { ui.add(TextEdit::singleline(&mut self.scsi1_path).desired_width(260.0)); if ui.button("📁").clicked() { - if let Some(p) = rfd::FileDialog::new() - .add_filter("Disk image", &["raw", "img", "chd"]) - .pick_file() + if let Some(p) = crate::filedialog::dialog_with( + "Hard disk image", &self.scsi1_path, + crate::filedialog::Anchor::Disks, + &[("Disk image", &["raw", "img", "chd"])]).pick_file() { self.scsi1_path = p.to_string_lossy().into_owned(); self.create_blank_scsi1 = false; @@ -198,9 +202,10 @@ impl NewMachineDialog { ui.horizontal(|ui| { ui.add(TextEdit::singleline(&mut self.cdrom4_path).desired_width(260.0)); if ui.button("📁").clicked() { - if let Some(p) = rfd::FileDialog::new() - .add_filter("ISO", &["iso"]) - .pick_file() + if let Some(p) = crate::filedialog::dialog_with( + "CD-ROM image", &self.cdrom4_path, + crate::filedialog::Anchor::Disks, + &[("ISO", &["iso"]), ("CD image", &["iso", "chd"])]).pick_file() { self.cdrom4_path = p.to_string_lossy().into_owned(); self.attach_cdrom = true; diff --git a/iris-gui/src/filedialog.rs b/iris-gui/src/filedialog.rs new file mode 100644 index 0000000..d2d4d32 --- /dev/null +++ b/iris-gui/src/filedialog.rs @@ -0,0 +1,233 @@ +//! Where a file dialog opens. +//! +//! The rule this module exists to enforce: **a dialog is always given a real +//! directory.** That sounds obvious, and every picker in the app used to get it +//! wrong in the same way — seed the panel at the current path's folder *if that +//! folder happens to exist*, otherwise set nothing at all. +//! +//! Setting nothing is not neutral. On macOS `NSOpenPanel`/`NSSavePanel` restore +//! their own last-used location when `setDirectoryURL:` is not given one, so +//! "do nothing" means "open wherever the user last happened to be". That is +//! wrong precisely when it is most annoying: a disk image that has not been +//! created yet has no existing parent folder, so browsing for it opened +//! somewhere unrelated instead of where the image is destined to go. Windows +//! and the GTK/portal backends have their own remembered defaults and behave +//! the same way, so this is a shared fix rather than a macOS workaround. +//! +//! So: resolve the folder the file lives in or is headed for, walk *up* to the +//! nearest ancestor that exists rather than giving up, and fall back to the +//! app's own managed directory — creating that one, since it is ours and it is +//! the location the UI tells people their disks go to. + +use std::path::{Path, PathBuf}; + +use crate::settings::GuiSettings; + +/// Which managed directory to fall back on when the current value offers no +/// usable folder of its own. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Anchor { + /// Disk images — `/disks`. + Disks, + /// Everything else the app owns: PROM, NVRAM, logs, exports, screenshots. + Data, +} + +impl Anchor { + /// The managed directory, created if missing. + /// + /// Creating it as a side effect of opening a picker is deliberate and is + /// limited to directories the app owns: it is where the UI says disks go, + /// an empty one is harmless, and the alternative is the panel opening + /// somewhere unrelated on a fresh install. A path the *user* chose is never + /// created here — that walks up to an existing ancestor instead. + fn managed(self) -> Option { + let dir = match self { + Anchor::Disks => GuiSettings::disks_dir()?, + Anchor::Data => GuiSettings::data_dir()?, + }; + if !dir.is_dir() { + let _ = std::fs::create_dir_all(&dir); + } + dir.is_dir().then_some(dir) + } +} + +/// The folder a dialog for `current` should open in. Always exists. +pub fn start_dir(current: &str, anchor: Anchor) -> PathBuf { + start_dir_in(current, anchor.managed()) +} + +/// `start_dir` with the managed directory supplied, so the logic is testable +/// without touching the real one. +fn start_dir_in(current: &str, managed: Option) -> PathBuf { + let current = current.trim(); + if !current.is_empty() { + // A bare or relative name means the managed directory, not the process + // working directory — that differs between `cargo run` and a bundled + // .app, which is the same trap `GuiSettings::default_nvram_path` + // documents. + let p = match (Path::new(current).is_absolute(), &managed) { + (false, Some(base)) => base.join(current), + _ => PathBuf::from(current), + }; + if let Some(dir) = p.parent().and_then(nearest_existing) { + return dir; + } + } + // Validated rather than trusted: `Anchor::managed` already filters, but this + // function must not depend on its caller having done so — returning a + // directory that does not exist is the exact failure it exists to prevent. + managed.filter(|d| d.is_dir()).unwrap_or_else(last_resort) +} + +/// The nearest ancestor of `dir` that exists — `dir` itself when it does. +/// +/// Walking up is what makes a not-yet-created destination useful: a disk bound +/// for `~/VMs/indy/disks/root.raw` opens at `~/VMs/indy` if that is as far as +/// the tree goes, which is one folder from where the user is aiming rather than +/// wherever they last browsed. +fn nearest_existing(dir: &Path) -> Option { + dir.ancestors() + .find(|a| !a.as_os_str().is_empty() && a.is_dir()) + .map(PathBuf::from) +} + +fn last_resort() -> PathBuf { + dirs::home_dir() + .filter(|d| d.is_dir()) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")) +} + +/// An `rfd::FileDialog` seeded at [`start_dir`], with the file name pre-filled +/// when `current` names one (which a save panel shows and an open panel +/// ignores). +pub fn dialog(title: &str, current: &str, anchor: Anchor) -> rfd::FileDialog { + let mut d = rfd::FileDialog::new() + .set_title(title) + .set_directory(start_dir(current, anchor)); + if let Some(name) = Path::new(current.trim()).file_name() { + d = d.set_file_name(name.to_string_lossy()); + } + d +} + +/// A picker opened *at* `dir` rather than at its parent. +/// +/// For choosing a folder when you already know which one you mean — granting +/// sandbox access to the folder a disk image sits in, say — so confirming it is +/// one click. `dialog` deliberately does the opposite for files, where the +/// parent is the useful view. +pub fn dialog_at_dir(title: &str, dir: &str, anchor: Anchor) -> rfd::FileDialog { + let start = nearest_existing(Path::new(dir.trim())) + .unwrap_or_else(|| start_dir("", anchor)); + rfd::FileDialog::new().set_title(title).set_directory(start) +} + +/// `dialog` with a set of `(label, extensions)` filters applied. +pub fn dialog_with( + title: &str, + current: &str, + anchor: Anchor, + filters: &[(&str, &[&str])], +) -> rfd::FileDialog { + let mut d = dialog(title, current, anchor); + for (label, exts) in filters { + d = d.add_filter(*label, exts); + } + d +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A directory this test owns, under the scratch area rather than the + /// user's real config dir. + fn tmp(name: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let dir = std::env::temp_dir().join(format!("iris-filedialog-{name}-{nanos}")); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn an_existing_folder_is_used_as_is() { + let managed = tmp("existing"); + let img = managed.join("scsi1.raw"); + std::fs::write(&img, b"x").unwrap(); + assert_eq!(start_dir_in(&img.to_string_lossy(), Some(managed.clone())), managed); + std::fs::remove_dir_all(&managed).ok(); + } + + /// The case that motivated all of this: the image does not exist yet, so + /// its folder does not either. The old code set no directory at all and the + /// panel opened at whatever the OS remembered. + #[test] + fn a_destination_that_does_not_exist_yet_walks_up_to_one_that_does() { + let managed = tmp("walkup"); + let deep = managed.join("indy").join("disks").join("root.raw"); + assert_eq!(start_dir_in(&deep.to_string_lossy(), Some(managed.clone())), managed, + "must climb to the nearest real folder, not give up"); + + // One level materialises: now that is the answer. + let mid = managed.join("indy"); + std::fs::create_dir_all(&mid).unwrap(); + assert_eq!(start_dir_in(&deep.to_string_lossy(), Some(managed.clone())), mid); + std::fs::remove_dir_all(&managed).ok(); + } + + #[test] + fn a_bare_name_resolves_against_the_managed_folder_not_the_working_directory() { + let managed = tmp("bare"); + // `scsi1.raw` with no directory means the managed one. Resolving it + // against the process cwd would differ between `cargo run` and a + // bundled .app. + assert_eq!(start_dir_in("scsi1.raw", Some(managed.clone())), managed); + assert_eq!(start_dir_in("sub/scsi1.raw", Some(managed.clone())), managed); + std::fs::remove_dir_all(&managed).ok(); + } + + #[test] + fn an_empty_value_falls_back_to_the_managed_folder() { + let managed = tmp("empty"); + assert_eq!(start_dir_in("", Some(managed.clone())), managed); + assert_eq!(start_dir_in(" ", Some(managed.clone())), managed); + std::fs::remove_dir_all(&managed).ok(); + } + + #[test] + fn a_folder_picker_opens_at_the_folder_not_above_it() { + let managed = tmp("atdir"); + let sub = managed.join("images"); + std::fs::create_dir_all(&sub).unwrap(); + + // The grant flow hands us a folder and wants it confirmed in one click, + // so taking its parent (which is right for a *file*) would be wrong. + let d = nearest_existing(&sub).unwrap(); + assert_eq!(d, sub); + // A folder that has since been deleted still lands somewhere real. + std::fs::remove_dir_all(&sub).unwrap(); + assert_eq!(nearest_existing(&sub).unwrap(), managed); + std::fs::remove_dir_all(&managed).ok(); + } + + /// The invariant the whole module exists for. Whatever it is handed — + /// including a managed directory that could not be created — it must name a + /// real directory, because handing the panel nothing is what made it open + /// in the wrong place. + #[test] + fn the_result_is_always_a_directory_that_exists() { + let cases = ["", " ", "scsi1.raw", "/nonexistent/deep/path/x.raw", "relative/x.raw"]; + for managed in [None, Some(PathBuf::from("/nonexistent/managed"))] { + for c in cases { + let d = start_dir_in(c, managed.clone()); + assert!(d.is_dir(), "start_dir_in({c:?}, {managed:?}) = {d:?}, which is not a directory"); + } + } + } +} diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index 044da96..4d6bead 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -1,6 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod bench_ui; +mod filedialog; mod camera_test; mod capture_access; mod config_ui; @@ -834,10 +835,14 @@ impl App { /// already pointed at `start_dir` (e.g. the folder of a CHD that needs a /// grant), so the user just confirms it. fn grant_disk_folder_at(&mut self, start_dir: Option<&str>) { - let mut dialog = rfd::FileDialog::new(); - if let Some(d) = start_dir { - if !d.is_empty() { dialog = dialog.set_directory(d); } - } + // `start_dir` is itself the folder that needs granting, so open *at* + // it — the caller's whole point is that the user just confirms. With + // none, the managed disks folder beats wherever rfd last was. + let dialog = filedialog::dialog_at_dir( + "Grant access to a disk folder", + start_dir.unwrap_or(""), + filedialog::Anchor::Disks, + ); if let Some(dir) = dialog.pick_folder() { let path = dir.to_string_lossy().into_owned(); if !self.prefs.disk_folders.contains(&path) { @@ -3632,15 +3637,14 @@ impl eframe::App for App { // We avoid `rfd` as a dependency for now to keep the dep tree slim. Use // `zenity` / `osascript` if available; otherwise return None and let the // caller paste a path into the recent-files / save-state fields. +// Anchored on the app's data folder rather than left to the OS's remembered +// location — see `crate::filedialog`. These handle iris.toml import/export and +// screenshots, none of which are disk images. fn native_open_dialog(title: &str, filters: &[(&str, &[&str])]) -> Option { - let mut d = rfd::FileDialog::new().set_title(title); - for (name, exts) in filters { d = d.add_filter(*name, exts); } - d.pick_file() + filedialog::dialog_with(title, "", filedialog::Anchor::Data, filters).pick_file() } fn native_save_dialog(title: &str, filters: &[(&str, &[&str])]) -> Option { - let mut d = rfd::FileDialog::new().set_title(title); - for (name, exts) in filters { d = d.add_filter(*name, exts); } - d.save_file() + filedialog::dialog_with(title, "", filedialog::Anchor::Data, filters).save_file() } #[cfg(test)] diff --git a/iris-gui/src/scsi_menu.rs b/iris-gui/src/scsi_menu.rs index 78d1854..de0a1a9 100644 --- a/iris-gui/src/scsi_menu.rs +++ b/iris-gui/src/scsi_menu.rs @@ -154,20 +154,11 @@ fn render_label(id: u8, dev: Option<&ScsiDeviceConfig>) -> String { } } -// Seed the picker at `cur`'s folder, else the managed disks dir — never the OS default. +// Seed the picker at `cur`'s folder, else the managed disks dir — never the OS +// default. See `crate::filedialog`, which is where the "else" used to be a +// silent no-op and the panel opened at whatever the user last browsed. fn dialog_at(title: &str, cur: &str) -> rfd::FileDialog { - let mut d = rfd::FileDialog::new().set_title(title); - let p = Path::new(cur); - match p.parent().filter(|d| !d.as_os_str().is_empty() && d.is_dir()) { - Some(dir) => { - d = d.set_directory(dir); - if let Some(n) = p.file_name() { d = d.set_file_name(n.to_string_lossy()); } - } - None => if let Some(dir) = crate::settings::GuiSettings::disks_dir().filter(|d| d.is_dir()) { - d = d.set_directory(dir); - } - } - d + crate::filedialog::dialog(title, cur, crate::filedialog::Anchor::Disks) } fn pick_disk(title: &str, cur: &str) -> Option { From edcb3d17371a9e7c794b0388a3b2f638bfffe7a8 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 21 Aug 2026 16:27:07 -0400 Subject: [PATCH 12/15] ci: one workflow for both bare-metal suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cpu-tests.yml and bench.yml were the same machine twice over: install the MIPS toolchain, build a guest binary, then a 2x2 CPU x engine matrix that builds an emulator per cell and runs the suite. Both trigger on src/mips_*.rs, so any change to the executor built *eight* emulators per push. The release profile is fat-LTO with codegen-units=1, which makes that the dominant cost in this repo's CI, and merging halves it — one toolchain install, one guest-build job, one emulator per cell running both suites. The trade is that a bench-only change now also runs cpu-tests and vice versa, but those are the cheap halves; the emulator builds are unchanged at four, and for the common case (an executor change, which fired both workflows anyway) it is a straight halving. Semantics are preserved, deliberately. In particular the two suites both run in every cell even when the first one fails: cpu-tests exits with its failure count and has known findings, so chaining them naively would abort before the benchmark ran and bury its accuracy result behind an unrelated red. cpu-tests now records its count and a single Result step turns it red, after the benchmark has had its turn. Structural failures — a timeout, a truncated run, a cell whose cargo features did not take and is running the wrong CPU — still fail on the spot, because those are the harness being broken rather than the emulator being wrong. Worth knowing when reading a red run: cpu-tests gates on *zero* failures and there are two outstanding, so this workflow is red until they are fixed. That is not new — cpu-tests.yml is red today for the same reason — but it is now more visible, and it means the gate catches nothing, since a permanently-red check cannot go redder. Gating on "no worse than a recorded baseline" would restore the signal. Left alone here rather than changed on the way past. Also: iris-bench is now built with the cell's features. It embeds the guest image and can run the suite in-process, so a default-feature build was quietly a different machine from the cell it was labelled as. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/bench.yml | 255 ----------------------- .github/workflows/cpu-tests.yml | 160 --------------- .github/workflows/suites.yml | 354 ++++++++++++++++++++++++++++++++ bench/README.md | 2 +- bench/prebuilt/README.md | 2 +- cpu-tests/PLAN.md | 2 +- docs/gui-benchmark-plan.md | 2 +- 7 files changed, 358 insertions(+), 419 deletions(-) delete mode 100644 .github/workflows/bench.yml delete mode 100644 .github/workflows/cpu-tests.yml create mode 100644 .github/workflows/suites.yml diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml deleted file mode 100644 index 0f1c607..0000000 --- a/.github/workflows/bench.yml +++ /dev/null @@ -1,255 +0,0 @@ -# Bare-metal benchmark suite (bench/). -# -# CI gates the ACCURACY score, not the performance numbers. A shared runner's -# throughput varies by more than most real regressions, so a perf threshold -# here would either be so loose it catches nothing or so tight it fires every -# other week. What does not vary is whether the emulator computed the right -# answer: every kernel checksums its result against a golden value produced by -# building the same C natively, and the suite's exit code is the number of -# mismatches. That is a genuine regression net, and it covers ground cpu-tests -# cannot — instruction-level tests run one operation at a time with clean -# state, while these run millions with whatever state the last million left. -# -# The performance figures are still collected and uploaded, so a run can be -# read after the fact or compared by hand across commits. - -name: Benchmark - -on: - push: - branches: [ "main" ] - paths: - - 'bench/**' - - 'cpu-tests/harness/**' - - 'src/mips_*.rs' - - 'src/jitv2/**' - - 'src/testdev.rs' - - 'src/bench_report.rs' - - 'src/bench_runner.rs' - - 'src/benchsuite.rs' - - 'src/bin/iris_bench.rs' - - '.github/workflows/bench.yml' - pull_request: - branches: [ "main" ] - paths: - - 'bench/**' - - 'cpu-tests/harness/**' - - 'src/mips_*.rs' - - 'src/jitv2/**' - - 'src/testdev.rs' - - 'src/bench_report.rs' - - 'src/bench_runner.rs' - - 'src/benchsuite.rs' - - 'src/bin/iris_bench.rs' - workflow_dispatch: - -env: - CARGO_TERM_COLOR: always - -jobs: - build-guest: - name: Build the guest binary and the oracle - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install the MIPS cross toolchain - run: | - sudo apt-get update - sudo apt-get install -y gcc-mips-linux-gnu binutils-mips-linux-gnu - - # golden.h is generated from the kernels themselves and checked in, so - # building the suite needs nothing but the cross toolchain. Regenerating - # here and failing on a diff is what stops the kernels and their expected - # values from drifting apart — and doubles as a determinism check on the - # suite itself, since a kernel whose result depends on uninitialised - # memory or on the host's byte order produces a different table on a - # different runner. Every one of those has already happened once; see - # rules/testing/benchmark-suite-gotchas.md. - - name: Verify the golden checksums are up to date - run: | - make -C bench golden - git diff --exit-code bench/golden/golden.h - - - name: Build irisbench.elf - run: make -C bench - - # bench/prebuilt/irisbench.elf is linked into `iris` with include_bytes! - # so the benchmark runs on a machine with no cross toolchain — a released - # app, a sandboxed one, or anyone who just wants the number. A checked-in - # build product that can drift is worse than no build product, and this - # one drifts dangerously: accuracy is scored against golden checksums - # compiled *into* the image, so a stale image against fresh goldens - # reports failures to users that are not real. - - name: The checked-in guest binary must match what we just built - run: | - make -C bench prebuilt - if ! git diff --exit-code bench/prebuilt/; then - echo "::error::bench/prebuilt/irisbench.elf is stale." - echo "::error::Run 'make -C bench prebuilt' and commit the result." - exit 1 - fi - - # One binary for every cell below — same as cpu-tests, and for the same - # reason: a differential comparison needs the guest side held constant. - - uses: actions/upload-artifact@v4 - with: - name: irisbench-elf - path: bench/build/irisbench.elf - if-no-files-found: error - - host-baseline: - name: Host baseline - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - # The same kernels, natively. Its accuracy score must be 100% by - # construction — it is the source of the golden values — so a failure - # here means a kernel is not deterministic even against itself. - - name: Build and run - run: | - make -C bench hostbench - ./bench/build/irisbench-host | tee bench/build/host.log - - name: The oracle must agree with itself - run: grep -q 'IRIS-BENCH-DONE rc=0' bench/build/host.log - - uses: actions/upload-artifact@v4 - if: always() - with: - name: bench-host-log - path: bench/build/host.log - if-no-files-found: ignore - - run: - name: ${{ matrix.cpu }} / ${{ matrix.engine }} - needs: build-guest - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - cpu: [r4400, r5000] - engine: [interp, jitv2] - steps: - - uses: actions/checkout@v4 - - - name: Install build dependencies - run: | - sudo apt-get update - sudo apt-get install -y pkg-config libasound2-dev - - - uses: actions/download-artifact@v4 - with: - name: irisbench-elf - path: bench/build - - - uses: Swatinem/rust-cache@v2 - - # The CPU model and the JIT are compile-time cargo features, not runtime - # switches — see rules/perf/hardware-profiles.md. Plain r5k only, for the - # reasons cpu-tests.yml sets out at length. - - name: Build IRIS - run: | - FEATURES="" - [ "${{ matrix.cpu }}" = "r5000" ] && FEATURES="r5k" - [ "${{ matrix.engine }}" = "jitv2" ] && FEATURES="${FEATURES:+$FEATURES,}jitv2" - if [ -n "$FEATURES" ]; then - cargo build --release --bin iris --features "$FEATURES" - else - cargo build --release --bin iris - fi - cargo build --release --bin iris-bench - - # --iris is required, not decorative: without it `run` measures this - # process, and iris-bench is built with default features rather than the - # cell's. The cell is the binary built in the step above, so name it. - - name: Run the suite - run: | - chmod +x bench/build/irisbench.elf - ./target/release/iris-bench run \ - --iris ./target/release/iris \ - --label "${{ matrix.cpu }}-${{ matrix.engine }}" \ - --timeout 2400 \ - 2>&1 | tee bench/build/run.log - - # Three ways this can fail quietly, so all three are checked. A build - # whose features did not take runs the wrong CPU and every result is - # mislabelled; the guest's banner is authoritative because it reads PRId. - # A kernel that faults is stepped over by the exception dispatcher and - # still reports a throughput. And a mismatch is the actual regression. - - name: Check the run - run: | - python3 - <<'PY' - import json, sys, glob - paths = glob.glob("bench/build/results/*.json") - if not paths: - sys.exit("::error::no result file was written") - run = json.load(open(paths[0])) - want = "${{ matrix.cpu }}".upper() - if run["machine"]["cpu"] != want: - sys.exit(f"::error::guest reports {run['machine']['cpu']}, expected {want}") - if not run["machine"]["timebase"]: - sys.exit("::error::no host time base — every timing is a guess") - - expect_exc = {"sys/exception", "sys/tlb_miss"} - bad = [r for r in run["rows"] if r["status"] == "MISMATCH"] - exc = [r for r in run["rows"] if r["exc"] and r["name"] not in expect_exc] - for r in bad: - print(f"::error::{r['name']} checksum {r['checksum']} != {r['golden']}") - for r in exc: - print(f"::error::{r['name']} took {r['exc']} unexpected exceptions") - - mips = run["total_icount"] * 1e3 / max(run["total_ns"], 1) - print(f"{run['cell']}: {run['matched']}/{run['checked']} matched, " - f"{mips:.1f} guest MIPS, {run['total_ns']/1e9:.1f} s timed") - sys.exit(1 if bad or exc else 0) - PY - - # The path a released application actually takes: no subprocess, no ELF on - # disk, the guest image read straight out of the binary. Quick mode so it - # costs about half a minute on top of the full run above. Only on the - # default cell — the embedded runner is the same code in every cell, and - # what it exercises is the plumbing, not the CPU model. - - name: The embedded runner must work too - if: matrix.cpu == 'r4400' && matrix.engine == 'interp' - run: cargo test --release --lib bench_runner -- --ignored --nocapture - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: bench-${{ matrix.cpu }}-${{ matrix.engine }} - path: | - bench/build/run.log - bench/build/results/*.json - if-no-files-found: ignore - - report: - name: Comparison report - needs: run - if: always() - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: Swatinem/rust-cache@v2 - - name: Install build dependencies - run: | - sudo apt-get update - sudo apt-get install -y pkg-config libasound2-dev - - uses: actions/download-artifact@v4 - with: - pattern: bench-* - path: artifacts - merge-multiple: true - - name: Assemble - run: | - mkdir -p bench/build/results - find artifacts -name '*.json' -exec cp {} bench/build/results/ \; || true - if [ -z "$(ls -A bench/build/results 2>/dev/null)" ]; then - echo "no results to report"; exit 0 - fi - cargo build --release --bin iris-bench - ./target/release/iris-bench report --format md > bench/build/report.md - cat bench/build/report.md >> "$GITHUB_STEP_SUMMARY" - - uses: actions/upload-artifact@v4 - with: - name: bench-report - path: bench/build/report.md - if-no-files-found: ignore diff --git a/.github/workflows/cpu-tests.yml b/.github/workflows/cpu-tests.yml deleted file mode 100644 index c408d63..0000000 --- a/.github/workflows/cpu-tests.yml +++ /dev/null @@ -1,160 +0,0 @@ -# Bare-metal CPU test suite (cpu-tests/). -# -# Builds the guest binary once with the MIPS cross toolchain, then runs it -# against IRIS built for each CPU. The same binary in every cell is the point: -# any disagreement between cells is an emulator bug rather than a test bug. -# -# R4400 vs R5000 — the mips4/ group requires each MIPS IV instruction to -# compute on R5000 and raise Reserved Instruction on R4400. -# interp vs jitv2 — a guest-visible ISA suite is the cleanest JIT differential -# available, and covers ground rules/jit/verify-mode.md says -# verify mode structurally cannot (blocks containing stores). - -name: CPU tests - -on: - push: - branches: [ "main" ] - paths: - - 'cpu-tests/**' - - 'src/mips_*.rs' - - 'src/elf.rs' - - 'src/testdev.rs' - - '.github/workflows/cpu-tests.yml' - pull_request: - branches: [ "main" ] - paths: - - 'cpu-tests/**' - - 'src/mips_*.rs' - - 'src/elf.rs' - - 'src/testdev.rs' - workflow_dispatch: - -env: - CARGO_TERM_COLOR: always - -jobs: - build-guest: - name: Build the guest binary - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install the MIPS cross toolchain - run: | - sudo apt-get update - sudo apt-get install -y gcc-mips-linux-gnu binutils-mips-linux-gnu - - # The FP expectation tables are generated by gen/fpvectors.py and checked - # in, so that building the suite needs nothing but the cross toolchain. - # Regenerating them here and failing on a diff is what stops the script - # and its output from drifting apart. `make vectors` also runs the - # generator's --check pass, which cross-checks its exact rational - # arithmetic against the runner's own FPU before writing anything. - - name: Verify the generated FP vectors are up to date - run: | - make -C cpu-tests vectors - git diff --exit-code cpu-tests/tests/fpu/fpvectors.c \ - cpu-tests/tests/fpu/fpvectors.h - - - name: Build cputest.elf - run: make -C cpu-tests - - # One binary for every cell below — that is what makes the matrix a - # differential test rather than four independent runs. - - uses: actions/upload-artifact@v4 - with: - name: cputest-elf - path: cpu-tests/build/cputest.elf - if-no-files-found: error - - run: - name: ${{ matrix.cpu }} / ${{ matrix.engine }} - needs: build-guest - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - cpu: [r4400, r5000] - engine: [interp, jitv2] - steps: - - uses: actions/checkout@v4 - - - name: Install build dependencies - run: | - sudo apt-get update - sudo apt-get install -y pkg-config libasound2-dev - - - uses: actions/download-artifact@v4 - with: - name: cputest-elf - path: cpu-tests/build - - - uses: Swatinem/rust-cache@v2 - - # The CPU is a compile-time cargo feature, not a runtime switch — see - # rules/perf/hardware-profiles.md. Plain r5k only: r5ksc (the external - # R4600SC-style secondary cache a real Indy R5000 board has) and - # r5ksc_triton (the O2's on-die L2, not a machine this emulator targets - # anyway) both fail mips_cache_v2's L1I tests and are refused at compile - # time (src/lib.rs) — see rules/testing/r5k-l1i-cache-bugs.md. Plain r5k - # reports Config.SC as no-L2, so the cache group tests a real R5000 - # minus its secondary cache rather than a machine that never shipped; - # CPU/FPU correctness (what this suite actually exercises) doesn't - # depend on the secondary cache working. - # - # jitv2, not the v1 jit: v1 is the speculative tiered block compiler being - # replaced, and is not what this project runs. jitv2 needs no runtime - # switch — it is active as soon as the feature is compiled in. - - name: Build IRIS - run: | - FEATURES="" - [ "${{ matrix.cpu }}" = "r5000" ] && FEATURES="r5k" - [ "${{ matrix.engine }}" = "jitv2" ] && FEATURES="${FEATURES:+$FEATURES,}jitv2" - if [ -n "$FEATURES" ]; then - cargo build --release --features "$FEATURES" - else - cargo build --release - fi - - - name: Run the suite - run: | - cd cpu-tests - timeout 900 ../target/release/iris \ - --config run/bare.toml \ - --load-elf build/cputest.elf \ - --test-device --test-device-dump build/dump.json \ - --headless --noaudio 2>&1 | tee build/run.log - rc=${PIPESTATUS[0]} - echo "iris exit code: $rc" - # The suite's exit code is its failure count, delivered through the - # test device. A timeout (124) is reported separately so a hang is - # never mistaken for a large number of failures. - if [ "$rc" = "124" ]; then echo "::error::suite timed out"; exit 1; fi - exit "$rc" - - # Two things that would otherwise fail silently: a truncated run that - # never reached its summary, and a build whose cargo features did not - # take — in which case an "r4400" cell would run an R5000 and every - # mips4 expectation would invert with no indication anything was wrong. - # The banner is authoritative: the guest reads the CPU out of PRId. - - name: Verify the run reached the end, on the CPU it claims - if: always() - run: | - cd cpu-tests - grep -q 'IRIS-CPUTEST-DONE' build/run.log \ - || { echo "::error::suite never printed its DONE token"; exit 1; } - want=$(echo '${{ matrix.cpu }}' | tr 'a-z' 'A-Z') - got=$(grep -o 'cpu=[A-Za-z0-9]*' build/run.log | head -1) - [ "$got" = "cpu=$want" ] \ - || { echo "::error::ran $got, expected cpu=$want"; exit 1; } - grep -E '^ RESULT:' build/run.log - - - uses: actions/upload-artifact@v4 - if: failure() - with: - name: cputest-${{ matrix.cpu }}-${{ matrix.engine }}-logs - path: | - cpu-tests/build/run.log - cpu-tests/build/dump.json - if-no-files-found: ignore diff --git a/.github/workflows/suites.yml b/.github/workflows/suites.yml new file mode 100644 index 0000000..92563ba --- /dev/null +++ b/.github/workflows/suites.yml @@ -0,0 +1,354 @@ +# The bare-metal suites — cpu-tests/ and bench/. +# +# One workflow because they are the same machine: both build a guest binary +# with the MIPS cross toolchain, then run it against IRIS built for each CPU x +# engine cell. Kept as two workflows, that meant installing the toolchain twice +# and — far more expensively — building *eight* emulators per push instead of +# four, since any change to src/mips_*.rs triggers both. The release profile is +# fat-LTO with codegen-units=1, so an emulator build is the dominant cost in +# this repo's CI and halving the count is the whole point of merging. +# +# The two suites answer different questions and neither replaces the other: +# +# cpu-tests is this instruction correct — one at a time, clean state. +# R4400 vs R5000 because the mips4/ group requires each MIPS IV +# instruction to compute on R5000 and raise Reserved Instruction +# on R4400. interp vs jitv2 because a guest-visible ISA suite is +# the cleanest JIT differential available, and covers ground +# rules/jit/verify-mode.md says verify mode structurally cannot +# (blocks containing stores). +# +# bench is it still correct after ten million of them, with whatever +# state the last million left — and how long did that take. +# +# CI gates bench on its ACCURACY score, never on its performance numbers: a +# shared runner's throughput varies by more than most real regressions, so a +# perf threshold would either catch nothing or fire every other week. The +# figures are still collected and uploaded so a run can be read after the fact. +# +# Both suites run in every cell even when the first one fails. cpu-tests exits +# with its failure count and has known findings (cpu-tests/docs/findings.md), +# so letting it abort the job would hide the benchmark's accuracy result behind +# an unrelated red. If you want this workflow to be green-when-healthy rather +# than red-until-every-finding-is-fixed, gate cpu-tests on "no worse than a +# recorded baseline" instead of on zero — that still catches regressions, which +# a permanently-red gate does not. + +name: Bare-metal suites + +on: + push: + branches: [ "main" ] + paths: &suite_paths + - 'cpu-tests/**' + - 'bench/**' + - 'src/mips_*.rs' + - 'src/jitv2/**' + - 'src/elf.rs' + - 'src/testdev.rs' + - 'src/bench_report.rs' + - 'src/bench_runner.rs' + - 'src/benchsuite.rs' + - 'src/bin/iris_bench.rs' + - '.github/workflows/suites.yml' + pull_request: + branches: [ "main" ] + paths: *suite_paths + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + guests: + name: Build the guest binaries + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install the MIPS cross toolchain + run: | + sudo apt-get update + sudo apt-get install -y gcc-mips-linux-gnu binutils-mips-linux-gnu + + # Three checked-in generated files, all verified the same way: regenerate + # and fail on a diff. That is what stops a generator and its output from + # drifting apart, and it is why building either suite needs nothing but + # the cross toolchain. + + # `make vectors` also runs the generator's --check pass, which cross-checks + # its exact rational arithmetic against the runner's own FPU before + # writing anything. + - name: cpu-tests — FP vectors up to date + run: | + make -C cpu-tests vectors + git diff --exit-code cpu-tests/tests/fpu/fpvectors.c \ + cpu-tests/tests/fpu/fpvectors.h + + # Doubles as a determinism check on the suite itself: a kernel whose + # result depends on uninitialised memory or on the host's byte order + # produces a different table on a different runner. Every one of those has + # already happened once — rules/testing/benchmark-suite-gotchas.md. + - name: bench — golden checksums up to date + run: | + make -C bench golden + git diff --exit-code bench/golden/golden.h + + - name: Build the guest binaries + run: | + make -C cpu-tests + make -C bench + + # bench/prebuilt/irisbench.elf is linked into `iris` with include_bytes! + # so the benchmark runs on a machine with no cross toolchain — a released + # app, a sandboxed one, or anyone who just wants the number. A checked-in + # build product that can drift is worse than no build product, and this + # one drifts dangerously: accuracy is scored against golden checksums + # compiled *into* the image, so a stale image against fresh goldens + # reports failures to users that are not real. + - name: bench — checked-in guest binary matches what we just built + run: | + make -C bench prebuilt + if ! git diff --exit-code bench/prebuilt/; then + echo "::error::bench/prebuilt/irisbench.elf is stale." + echo "::error::Run 'make -C bench prebuilt' and commit the result." + exit 1 + fi + + # One binary of each for every cell below — that is what makes the matrix + # a differential test rather than N independent runs. + - uses: actions/upload-artifact@v4 + with: + name: guest-elfs + path: | + cpu-tests/build/cputest.elf + bench/build/irisbench.elf + if-no-files-found: error + + host-baseline: + name: Host baseline + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # The same kernels, natively. Its accuracy score must be 100% by + # construction — it is the source of the golden values — so a failure + # here means a kernel is not deterministic even against itself. + - name: Build and run + run: | + make -C bench hostbench + ./bench/build/irisbench-host | tee bench/build/host.log + - name: The oracle must agree with itself + run: grep -q 'IRIS-BENCH-DONE rc=0' bench/build/host.log + - uses: actions/upload-artifact@v4 + if: always() + with: + name: bench-host-log + path: bench/build/host.log + if-no-files-found: ignore + + run: + name: ${{ matrix.cpu }} / ${{ matrix.engine }} + needs: guests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + cpu: [r4400, r5000] + engine: [interp, jitv2] + steps: + - uses: actions/checkout@v4 + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libasound2-dev + + - uses: actions/download-artifact@v4 + with: + name: guest-elfs + path: . + + - uses: Swatinem/rust-cache@v2 + + # The CPU and the JIT are compile-time cargo features, not runtime + # switches — see rules/perf/hardware-profiles.md. + # + # Plain r5k only: r5ksc (the external R4600SC-style secondary cache a real + # Indy R5000 board has) and r5ksc_triton (the O2's on-die L2, not a machine + # this emulator targets anyway) both fail mips_cache_v2's L1I tests and are + # refused at compile time (src/lib.rs) — see + # rules/testing/r5k-l1i-cache-bugs.md. Plain r5k reports Config.SC as + # no-L2, so the cache group tests a real R5000 minus its secondary cache + # rather than a machine that never shipped; CPU/FPU correctness (what + # cpu-tests actually exercises) doesn't depend on the secondary cache. + # + # jitv2, not the v1 jit: v1 is the speculative tiered block compiler being + # replaced, and is not what this project runs. jitv2 needs no runtime + # switch — it is active as soon as the feature is compiled in. + # + # iris-bench is built with the cell's features too. It embeds the guest + # image and can run the suite in-process, so a default-feature build here + # would quietly be a different machine from the one this cell is named + # after. + - name: Build IRIS + run: | + FEATURES="" + [ "${{ matrix.cpu }}" = "r5000" ] && FEATURES="r5k" + [ "${{ matrix.engine }}" = "jitv2" ] && FEATURES="${FEATURES:+$FEATURES,}jitv2" + if [ -n "$FEATURES" ]; then + cargo build --release --bin iris --bin iris-bench --features "$FEATURES" + else + cargo build --release --bin iris --bin iris-bench + fi + + # ── cpu-tests ──────────────────────────────────────────────────────── + # + # Records its result rather than exiting on it, so the benchmark below + # still runs and reports. The gate at the end of the job is what turns a + # nonzero count red. A timeout or a truncated run is different — that is + # the harness being broken rather than the emulator being wrong — and + # fails here and now. + - name: cpu-tests + run: | + cd cpu-tests + chmod +x build/cputest.elf + timeout 900 ../target/release/iris \ + --config run/bare.toml \ + --load-elf build/cputest.elf \ + --test-device --test-device-dump build/dump.json \ + --headless --noaudio 2>&1 | tee build/run.log + rc=${PIPESTATUS[0]} + echo "CPUTEST_RC=$rc" >> "$GITHUB_ENV" + if [ "$rc" = "124" ]; then echo "::error::cpu-tests timed out"; exit 1; fi + grep -q 'IRIS-CPUTEST-DONE' build/run.log \ + || { echo "::error::cpu-tests never printed its DONE token"; exit 1; } + # A build whose cargo features did not take would run the wrong CPU + # and invert every mips4 expectation with no other indication. The + # guest's banner is authoritative: it reads the CPU out of PRId. + want=$(echo '${{ matrix.cpu }}' | tr 'a-z' 'A-Z') + got=$(grep -o 'cpu=[A-Za-z0-9]*' build/run.log | head -1) + [ "$got" = "cpu=$want" ] \ + || { echo "::error::cpu-tests ran $got, expected cpu=$want"; exit 1; } + grep -E '^ RESULT:' build/run.log + + # ── bench ──────────────────────────────────────────────────────────── + # + # --iris is required, not decorative: without it `run` measures this + # process in-process, which is a different thing from the binary this cell + # built. --elf then defaults to the shared artifact, which is what holds + # the guest side constant across cells. + - name: bench + run: | + chmod +x bench/build/irisbench.elf + ./target/release/iris-bench run \ + --iris ./target/release/iris \ + --label "${{ matrix.cpu }}-${{ matrix.engine }}" \ + --timeout 2400 \ + 2>&1 | tee bench/build/run.log + + # Three ways this can fail quietly, so all three are checked. A build + # whose features did not take runs the wrong CPU and every result is + # mislabelled; the guest's banner is authoritative because it reads PRId. + # A kernel that faults is stepped over by the exception dispatcher and + # still reports a throughput. And a mismatch is the actual regression. + - name: Check the benchmark run + run: | + python3 - <<'PY' + import json, sys, glob + paths = glob.glob("bench/build/results/*.json") + if not paths: + sys.exit("::error::no result file was written") + run = json.load(open(paths[0])) + want = "${{ matrix.cpu }}".upper() + if run["machine"]["cpu"] != want: + sys.exit(f"::error::guest reports {run['machine']['cpu']}, expected {want}") + if not run["machine"]["timebase"]: + sys.exit("::error::no host time base — every timing is a guess") + + expect_exc = {"sys/exception", "sys/tlb_miss"} + bad = [r for r in run["rows"] if r["status"] == "MISMATCH"] + exc = [r for r in run["rows"] if r["exc"] and r["name"] not in expect_exc] + for r in bad: + print(f"::error::{r['name']} checksum {r['checksum']} != {r['golden']}") + for r in exc: + print(f"::error::{r['name']} took {r['exc']} unexpected exceptions") + + mips = run["total_icount"] * 1e3 / max(run["total_ns"], 1) + print(f"{run['cell']}: {run['matched']}/{run['checked']} matched, " + f"{mips:.1f} guest MIPS, {run['total_ns']/1e9:.1f} s timed") + sys.exit(1 if bad or exc else 0) + PY + + # The path a released application actually takes: no subprocess, no ELF on + # disk, the guest image read straight out of the binary. Quick mode, and + # only on the default cell — the embedded runner is the same code in every + # cell, and what it exercises is the plumbing, not the CPU model. + - name: The embedded runner must work too + if: matrix.cpu == 'r4400' && matrix.engine == 'interp' + run: cargo test --release --lib bench_runner -- --ignored --nocapture + + # One place both results are visible, and the only thing that turns a + # cpu-tests failure count red. + - name: Result + if: always() + run: | + { + echo "### ${{ matrix.cpu }} / ${{ matrix.engine }}" + echo '```' + grep -E '^ RESULT:' cpu-tests/build/run.log 2>/dev/null || echo " cpu-tests: no result" + grep -E 'accuracy|emulator speed' bench/build/run.log 2>/dev/null | tail -2 \ + || echo " bench: no result" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + if [ "${CPUTEST_RC:-1}" != "0" ]; then + echo "::error::cpu-tests reported ${CPUTEST_RC:-?} failing checks" + exit 1 + fi + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: suites-${{ matrix.cpu }}-${{ matrix.engine }} + path: | + cpu-tests/build/run.log + cpu-tests/build/dump.json + bench/build/run.log + bench/build/results/*.json + if-no-files-found: ignore + + report: + name: Comparison report + needs: run + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libasound2-dev + - uses: actions/download-artifact@v4 + with: + pattern: suites-* + path: artifacts + merge-multiple: true + - name: Assemble + run: | + mkdir -p bench/build/results + # Path-qualified, not just *.json: the per-cell artifact now also + # carries cpu-tests' testdev machine-state dump, and `iris-bench + # report` parses everything in this directory as a saved Run. + find artifacts -path '*/bench/build/results/*' -name '*.json' \ + -exec cp {} bench/build/results/ \; || true + if [ -z "$(ls -A bench/build/results 2>/dev/null)" ]; then + echo "no results to report"; exit 0 + fi + cargo build --release --bin iris-bench + ./target/release/iris-bench report --format md > bench/build/report.md + cat bench/build/report.md >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@v4 + with: + name: bench-report + path: bench/build/report.md + if-no-files-found: ignore diff --git a/bench/README.md b/bench/README.md index c532db3..1705abb 100644 --- a/bench/README.md +++ b/bench/README.md @@ -262,7 +262,7 @@ writable path to unpack an image to either. **Refresh it with `make -C bench prebuilt` whenever you change anything the guest is built from** — the kernels, `harness/`, `cpu-tests/harness/`, the link script, the compiler flags — and commit it alongside the source change. -`.github/workflows/bench.yml` rebuilds it and fails on any difference, because +`.github/workflows/suites.yml` rebuilds it and fails on any difference, because this is a build product that drifts dangerously: accuracy is scored against golden checksums compiled *into* the image, so a stale image against fresh goldens reports failures to users that are not real. diff --git a/bench/prebuilt/README.md b/bench/prebuilt/README.md index 089d61a..ffd0c24 100644 --- a/bench/prebuilt/README.md +++ b/bench/prebuilt/README.md @@ -9,7 +9,7 @@ copy of a known-good one. A checked-in build product that can drift is worse than no build product, and this one drifts dangerously: accuracy is scored against golden checksums compiled *into* the image, so a stale image against fresh goldens reports -failures that are not real. `.github/workflows/bench.yml` rebuilds it and fails +failures that are not real. `.github/workflows/suites.yml` rebuilds it and fails on any difference. Refresh it with `make -C bench prebuilt` after changing anything the guest is diff --git a/cpu-tests/PLAN.md b/cpu-tests/PLAN.md index 5659891..bb34c99 100644 --- a/cpu-tests/PLAN.md +++ b/cpu-tests/PLAN.md @@ -451,7 +451,7 @@ cpu-tests/ | **1** | Harness + `alu`, `muldiv`, `mem`, `branch` | **done** — 85 tests green | | **2** | `excep`, `cp0`, `tlb` | **done** | | **3** | `fpu`, `mips4` with the R4400-must-RI differential | **done** — the differential found two bugs; `fpu` has since grown to 88 tests across eight files and `gen/fpvectors.py` now generates its expectation tables, which is where findings 6-10 came from | -| **4** | `cache`; JIT-vs-interp matrix in CI | **done** — `run/matrix.sh`, `.github/workflows/cpu-tests.yml` | +| **4** | `cache`; JIT-vs-interp matrix in CI | **done** — `run/matrix.sh`, `.github/workflows/suites.yml` | | **5** | Volume header → bootable disk → **EFS CD** | **partly** — `mkvh` image boots through the PROM end to end; the EFS partition is the remaining piece | ### Where Phase 5 stands diff --git a/docs/gui-benchmark-plan.md b/docs/gui-benchmark-plan.md index e5bd096..458d16c 100644 --- a/docs/gui-benchmark-plan.md +++ b/docs/gui-benchmark-plan.md @@ -174,7 +174,7 @@ the binary it is. **Drift is the danger, and CI is the answer.** Accuracy is scored against golden checksums compiled *into* the image, so a stale image against fresh goldens -reports failures to users that are not real. `bench.yml` rebuilds it and fails +reports failures to users that are not real. `suites.yml` rebuilds it and fails on any difference; `make -C bench prebuilt` refreshes it. ### Quick mode (`TESTDEV_RUN_CONFIG`) From 07d3bf759616b93b434af746e60a2c96fe7687d8 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 21 Aug 2026 17:08:18 -0400 Subject: [PATCH 13/15] =?UTF-8?q?iris-gui:=20never=20set=20a=20file=20name?= =?UTF-8?q?=20beside=20a=20directory=20=E2=80=94=20it=20voids=20the=20dire?= =?UTF-8?q?ctory=20on=20macOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit seeded every dialog with a starting directory and the Open button still opened at Documents. Seeding it was not the problem. rfd's AppKit backend joins the two settings into one path and hands the result to setDirectoryURL: with isDirectory: YES (backend/macos/file_dialog/panel_ffi.rs, set_path): set_directory("~/disks") + set_file_name("scsi1.raw") -> NSURL fileURLWithPath:"~/disks/scsi1.raw" isDirectory:YES -> file:///Users/…/disks/scsi1.raw/ <- not a directory -> AppKit discards it; the panel opens at its own default So the directory was being thrown away by the file name sitting next to it, in the old code and in the new. The Linux portal backend keeps current_folder and current_name as separate fields and never had the problem, which is why this only ever showed up on the Mac. The two are mutually exclusive on macOS and the directory is what matters, so dialogs now say which kind they are. An open panel never pre-fills a name — it has no field to show one in, so it was pure downside — and a save panel fills it only where that does not cost the directory. The macOS trade is a save panel with an empty name field; getting both would mean driving NSSavePanel directly rather than through rfd. The decision is a plain function taking the platform behaviour as an argument, so both branches are tested from either host rather than only from a Mac, and the platform split is a `cfg!` value rather than a `#[cfg]` block — macOS compiles the same code, so this cannot rot on a platform CI does not build. Co-Authored-By: Claude Opus 5 (1M context) --- iris-gui/src/bench_ui.rs | 2 +- iris-gui/src/config_ui.rs | 6 +- iris-gui/src/dialogs/create_disk.rs | 1 + iris-gui/src/dialogs/new_machine.rs | 4 + iris-gui/src/filedialog.rs | 177 ++++++++++++++++++++++++++-- iris-gui/src/main.rs | 6 +- iris-gui/src/scsi_menu.rs | 3 +- 7 files changed, 187 insertions(+), 12 deletions(-) diff --git a/iris-gui/src/bench_ui.rs b/iris-gui/src/bench_ui.rs index 0fffaef..47dc4a6 100644 --- a/iris-gui/src/bench_ui.rs +++ b/iris-gui/src/bench_ui.rs @@ -588,7 +588,7 @@ fn save_report(run: &Run) { let name = format!("iris-benchmark-{}.json", run.cell.replace(['/', ' '], "-")); let Some(path) = crate::filedialog::dialog_with( "Save benchmark report", &name, crate::filedialog::Anchor::Data, - &[("JSON", &["json"])]).save_file() + crate::filedialog::Purpose::Save, &[("JSON", &["json"])]).save_file() else { return; }; diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index 459d7dc..db42673 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -1596,7 +1596,11 @@ fn path_row_in( if ui.button("📁").on_hover_text("Browse…").clicked() { // Open where this file lives, or is headed — never the OS's // remembered folder. See `crate::filedialog`. - let mut d = crate::filedialog::dialog("Browse", value.as_str(), anchor); + let purpose = match mode { + Pick::SaveFile => crate::filedialog::Purpose::Save, + Pick::OpenFile | Pick::Dir => crate::filedialog::Purpose::Open, + }; + let mut d = crate::filedialog::dialog("Browse", value.as_str(), anchor, purpose); if matches!(mode, Pick::OpenFile | Pick::SaveFile) { for (label, exts) in filters { d = d.add_filter(*label, exts); diff --git a/iris-gui/src/dialogs/create_disk.rs b/iris-gui/src/dialogs/create_disk.rs index b681510..97a4b3c 100644 --- a/iris-gui/src/dialogs/create_disk.rs +++ b/iris-gui/src/dialogs/create_disk.rs @@ -55,6 +55,7 @@ impl CreateDiskDialog { let dlg = crate::filedialog::dialog_with( "New disk image", &self.filename, crate::filedialog::Anchor::Disks, + crate::filedialog::Purpose::Save, &[("Disk image", &["raw", "img"])]); if let Some(p) = dlg.save_file() { self.filename = p.to_string_lossy().into_owned(); diff --git a/iris-gui/src/dialogs/new_machine.rs b/iris-gui/src/dialogs/new_machine.rs index 9412e65..3eec774 100644 --- a/iris-gui/src/dialogs/new_machine.rs +++ b/iris-gui/src/dialogs/new_machine.rs @@ -117,6 +117,7 @@ impl NewMachineDialog { if let Some(p) = crate::filedialog::dialog_with( "PROM image", &self.prom_path, crate::filedialog::Anchor::Data, + crate::filedialog::Purpose::Open, &[("PROM image", &["bin"])]).pick_file() { self.prom_path = p.to_string_lossy().into_owned(); @@ -135,6 +136,7 @@ impl NewMachineDialog { if let Some(p) = crate::filedialog::dialog_with( "NVRAM file", &self.nvram_path, crate::filedialog::Anchor::Data, + crate::filedialog::Purpose::Save, &[("NVRAM", &["bin"])]).save_file() { self.nvram_path = p.to_string_lossy().into_owned(); @@ -185,6 +187,7 @@ impl NewMachineDialog { if let Some(p) = crate::filedialog::dialog_with( "Hard disk image", &self.scsi1_path, crate::filedialog::Anchor::Disks, + crate::filedialog::Purpose::Open, &[("Disk image", &["raw", "img", "chd"])]).pick_file() { self.scsi1_path = p.to_string_lossy().into_owned(); @@ -205,6 +208,7 @@ impl NewMachineDialog { if let Some(p) = crate::filedialog::dialog_with( "CD-ROM image", &self.cdrom4_path, crate::filedialog::Anchor::Disks, + crate::filedialog::Purpose::Open, &[("ISO", &["iso"]), ("CD image", &["iso", "chd"])]).pick_file() { self.cdrom4_path = p.to_string_lossy().into_owned(); diff --git a/iris-gui/src/filedialog.rs b/iris-gui/src/filedialog.rs index d2d4d32..2383ef9 100644 --- a/iris-gui/src/filedialog.rs +++ b/iris-gui/src/filedialog.rs @@ -18,6 +18,30 @@ //! nearest ancestor that exists rather than giving up, and fall back to the //! app's own managed directory — creating that one, since it is ours and it is //! the location the UI tells people their disks go to. +//! +//! # Never set a file name and a directory together on macOS +//! +//! Doing both silently throws the directory away. rfd's AppKit backend +//! (`backend/macos/file_dialog/panel_ffi.rs`, `set_path`) joins them into a +//! single path and hands *that* to `setDirectoryURL:` with `isDirectory: YES`: +//! +//! ```text +//! set_directory("~/disks") + set_file_name("scsi1.raw") +//! -> NSURL fileURLWithPath:"~/disks/scsi1.raw" isDirectory:YES +//! -> file:///Users/…/disks/scsi1.raw/ <- not a directory +//! -> AppKit discards it, panel opens at its own default (Documents) +//! ``` +//! +//! Which is the exact symptom this module was first written to fix, and did +//! not: seeding the directory correctly does nothing while a file name is set +//! beside it. The two are mutually exclusive on macOS, and the directory is +//! what matters — an `NSOpenPanel` has no name field for the name to appear in +//! anyway, so setting it there is pure downside. +//! +//! The Linux portal backend keeps `current_folder` and `current_name` as +//! separate fields and is unaffected, so it still gets the pre-filled name on +//! a save panel. Hence [`Purpose`], and hence the platform split — which is +//! deliberate and load-bearing, not an oversight to be tidied away. use std::path::{Path, PathBuf}; @@ -53,6 +77,35 @@ impl Anchor { } } +/// What the dialog is for. Only a save panel has a name field worth filling, +/// and only some platforms can fill it without losing the directory. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Purpose { + /// Pick something that exists. Never pre-fills a name — an `NSOpenPanel` + /// has no field to put one in, and on macOS setting one costs the + /// directory. + Open, + /// Name something new. Pre-fills where the platform allows it. + Save, +} + +/// Whether pre-filling the name field would cost us the starting directory. +/// See the module docs — true on macOS, false everywhere else. +const NAME_FIELD_BREAKS_DIRECTORY: bool = cfg!(target_os = "macos"); + +/// The name to pre-fill, or `None` when it must be left alone. +/// +/// Takes the platform behaviour as an argument rather than reading the `cfg` +/// directly, so both branches are testable from either host. +fn name_to_prefill(current: &str, purpose: Purpose, name_breaks_dir: bool) -> Option { + if purpose == Purpose::Open || name_breaks_dir { + return None; + } + Path::new(current.trim()) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) +} + /// The folder a dialog for `current` should open in. Always exists. pub fn start_dir(current: &str, anchor: Anchor) -> PathBuf { start_dir_in(current, anchor.managed()) @@ -100,15 +153,17 @@ fn last_resort() -> PathBuf { .unwrap_or_else(|| PathBuf::from(".")) } -/// An `rfd::FileDialog` seeded at [`start_dir`], with the file name pre-filled -/// when `current` names one (which a save panel shows and an open panel -/// ignores). -pub fn dialog(title: &str, current: &str, anchor: Anchor) -> rfd::FileDialog { +/// An `rfd::FileDialog` seeded at [`start_dir`]. +/// +/// The starting directory is always set. The name field is filled only for a +/// save panel, and only where that does not cost us the directory — see the +/// module docs. +pub fn dialog(title: &str, current: &str, anchor: Anchor, purpose: Purpose) -> rfd::FileDialog { let mut d = rfd::FileDialog::new() .set_title(title) .set_directory(start_dir(current, anchor)); - if let Some(name) = Path::new(current.trim()).file_name() { - d = d.set_file_name(name.to_string_lossy()); + if let Some(name) = name_to_prefill(current, purpose, NAME_FIELD_BREAKS_DIRECTORY) { + d = d.set_file_name(name); } d } @@ -130,9 +185,10 @@ pub fn dialog_with( title: &str, current: &str, anchor: Anchor, + purpose: Purpose, filters: &[(&str, &[&str])], ) -> rfd::FileDialog { - let mut d = dialog(title, current, anchor); + let mut d = dialog(title, current, anchor, purpose); for (label, exts) in filters { d = d.add_filter(*label, exts); } @@ -216,6 +272,113 @@ mod tests { std::fs::remove_dir_all(&managed).ok(); } + /// The bug the module docs describe: on macOS a name beside a directory + /// throws the directory away, so there must be no name. + #[test] + fn a_name_is_never_pre_filled_where_it_would_cost_us_the_directory() { + // macOS: never, for either kind of panel. + assert_eq!(name_to_prefill("/d/scsi1.raw", Purpose::Save, true), None); + assert_eq!(name_to_prefill("/d/scsi1.raw", Purpose::Open, true), None); + + // Elsewhere: a save panel gets it, an open panel still does not — it + // has nowhere to show it and it is one more thing to go wrong. + assert_eq!(name_to_prefill("/d/scsi1.raw", Purpose::Save, false), + Some("scsi1.raw".to_string())); + assert_eq!(name_to_prefill("/d/scsi1.raw", Purpose::Open, false), None); + + // Nothing to take a name from. + assert_eq!(name_to_prefill("", Purpose::Save, false), None); + assert_eq!(name_to_prefill("/d/", Purpose::Save, false), Some("d".to_string())); + } + + /// Whatever the platform does about the name, the directory is set — that + /// is the half the user actually sees. + #[test] + fn the_directory_is_seeded_regardless_of_the_name_decision() { + let managed = tmp("both"); + let img = managed.join("scsi1.raw"); + std::fs::write(&img, b"x").unwrap(); + for breaks in [true, false] { + for purpose in [Purpose::Open, Purpose::Save] { + assert_eq!(start_dir_in(&img.to_string_lossy(), Some(managed.clone())), managed); + let _ = name_to_prefill(&img.to_string_lossy(), purpose, breaks); + } + } + std::fs::remove_dir_all(&managed).ok(); + } + + /// The reported case, verbatim: scsi1 pointing at the managed image on a + /// Mac. Browse must open the folder that holds it. + /// + /// The path is the real `~/Library/Application Support` one rather than a + /// sandbox container path, and it has a space in it — both worth keeping in + /// the fixture, since either could plausibly have been the culprit and + /// neither was. + #[test] + fn the_reported_mac_case_opens_the_folder_holding_the_image() { + let home = tmp("maccase"); + let disks = home.join("Library/Application Support/iris/disks"); + std::fs::create_dir_all(&disks).unwrap(); + let img = disks.join("scsi1.raw"); + std::fs::write(&img, b"x").unwrap(); + + let value = img.to_string_lossy().into_owned(); + assert_eq!(start_dir_in(&value, Some(disks.clone())), disks, + "Browse must open the disks folder, not anywhere else"); + + // And on macOS no name may ride along, or AppKit throws that directory + // away and falls back to Documents — which is the whole bug. + assert_eq!(name_to_prefill(&value, Purpose::Open, true), None); + assert_eq!(name_to_prefill(&value, Purpose::Open, false), None); + + // Still right if the image has not been created yet. + std::fs::remove_file(&img).unwrap(); + assert_eq!(start_dir_in(&value, Some(disks.clone())), disks); + + std::fs::remove_dir_all(&home).ok(); + } + + /// A model of what rfd's AppKit backend does with what we hand it, so the + /// bug and the fix are demonstrated rather than asserted from a code read. + /// + /// Mirrors `set_path` in rfd 0.17's + /// `backend/macos/file_dialog/panel_ffi.rs`: join the name onto the + /// directory when both are present, then hand the result to + /// `setDirectoryURL:` as though it were a directory. This is a copy, so it + /// cannot notice rfd changing — what it guards is *our* side never feeding + /// it the combination that breaks. + fn rfd_macos_directory_url(dir: &Path, file_name: Option<&str>) -> PathBuf { + match file_name { + Some(name) if dir.is_dir() => dir.join(name), + _ => dir.to_path_buf(), + } + } + + #[test] + fn what_we_hand_rfd_survives_its_macos_backend() { + let disks = tmp("appkit"); + let img = disks.join("scsi1.raw"); + std::fs::write(&img, b"x").unwrap(); + let value = img.to_string_lossy().into_owned(); + let dir = start_dir_in(&value, Some(disks.clone())); + + // What the old code did: directory *and* name. The backend joins them + // and setDirectoryURL: receives a file, which AppKit discards — the + // panel then opens at its own default, which is what was reported. + let broken = rfd_macos_directory_url(&dir, Some("scsi1.raw")); + assert!(!broken.is_dir(), + "the joined path is a file, which is exactly why AppKit ignored it: {broken:?}"); + + // What we do now on macOS: no name, so nothing is joined and the URL is + // the folder the image is in. + let name = name_to_prefill(&value, Purpose::Open, /* macOS */ true); + let good = rfd_macos_directory_url(&dir, name.as_deref()); + assert_eq!(good, disks); + assert!(good.is_dir(), "setDirectoryURL: must receive a real directory"); + + std::fs::remove_dir_all(&disks).ok(); + } + /// The invariant the whole module exists for. Whatever it is handed — /// including a managed directory that could not be created — it must name a /// real directory, because handing the panel nothing is what made it open diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index 4d6bead..055b66d 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -3641,10 +3641,12 @@ impl eframe::App for App { // location — see `crate::filedialog`. These handle iris.toml import/export and // screenshots, none of which are disk images. fn native_open_dialog(title: &str, filters: &[(&str, &[&str])]) -> Option { - filedialog::dialog_with(title, "", filedialog::Anchor::Data, filters).pick_file() + filedialog::dialog_with(title, "", filedialog::Anchor::Data, + filedialog::Purpose::Open, filters).pick_file() } fn native_save_dialog(title: &str, filters: &[(&str, &[&str])]) -> Option { - filedialog::dialog_with(title, "", filedialog::Anchor::Data, filters).save_file() + filedialog::dialog_with(title, "", filedialog::Anchor::Data, + filedialog::Purpose::Save, filters).save_file() } #[cfg(test)] diff --git a/iris-gui/src/scsi_menu.rs b/iris-gui/src/scsi_menu.rs index de0a1a9..132d80e 100644 --- a/iris-gui/src/scsi_menu.rs +++ b/iris-gui/src/scsi_menu.rs @@ -158,7 +158,8 @@ fn render_label(id: u8, dev: Option<&ScsiDeviceConfig>) -> String { // default. See `crate::filedialog`, which is where the "else" used to be a // silent no-op and the panel opened at whatever the user last browsed. fn dialog_at(title: &str, cur: &str) -> rfd::FileDialog { - crate::filedialog::dialog(title, cur, crate::filedialog::Anchor::Disks) + crate::filedialog::dialog(title, cur, crate::filedialog::Anchor::Disks, + crate::filedialog::Purpose::Open) } fn pick_disk(title: &str, cur: &str) -> Option { From 4648a3b99fd7a3119a3a4688859598d81459e020 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 21 Aug 2026 17:16:07 -0400 Subject: [PATCH 14/15] iris-gui: don't walk past a folder the sandbox merely hides from us MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the App Sandbox there are three kinds of location and they behave differently. The managed folder is inside the container, so it is ours and always readable. A folder the user has granted is fine too — macos_sandbox ::restore asserts every stored bookmark at startup and holds it for the process lifetime, so by the time a picker opens we can stat it like anything else. The third kind is an absolute path we hold no bookmark for: typed in, or carried over in gui.json from another machine. Statting it fails — but with PermissionDenied, not NotFound. The folder is very likely right there; we are just not allowed to look. `nearest_existing` treated that the same as absent and walked past it, sending the panel somewhere unrelated in precisely the case where the user is browsing *because* they need to re-grant access to that folder. The panel is not subject to our sandbox. It is the powerbox, out of process, and showing folders the app cannot read is the whole reason it exists — so a denial now means "point at it and let the panel decide" rather than "pretend it is not there". Distinguishing the two is just metadata()'s error kind; is_dir() throws that away, which is how it went unnoticed. Tested by stripping +x from a parent directory, which produces the same PermissionDenied a sandbox denial does. The test asserts its own precondition and reports a skip instead of passing vacuously if it cannot produce one (as root, which bypasses the check). Unix-only, since it manufactures the denial with directory permissions and Windows has no App Sandbox to imitate. Co-Authored-By: Claude Opus 5 (1M context) --- iris-gui/src/filedialog.rs | 90 +++++++++++++++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 6 deletions(-) diff --git a/iris-gui/src/filedialog.rs b/iris-gui/src/filedialog.rs index 2383ef9..4346da7 100644 --- a/iris-gui/src/filedialog.rs +++ b/iris-gui/src/filedialog.rs @@ -42,6 +42,20 @@ //! separate fields and is unaffected, so it still gets the pre-filled name on //! a save panel. Hence [`Purpose`], and hence the platform split — which is //! deliberate and load-bearing, not an oversight to be tidied away. +//! +//! # Under the App Sandbox +//! +//! Three kinds of location, and they behave differently: +//! +//! - **The managed folder** (`/…/iris/disks`). `dirs::config_dir()` +//! is redirected into the container, so this is ours, always readable and +//! always creatable. Nothing special needed. +//! - **A folder the user granted.** `macos_sandbox::restore` asserts every +//! stored bookmark at startup and holds it for the process lifetime, so by +//! the time a picker opens we can stat it like any other path. +//! - **An absolute path we hold no bookmark for** — typed in, or carried over +//! in `gui.json` from another machine. We cannot stat it, and that is the +//! case `nearest_existing` has to get right; see there. use std::path::{Path, PathBuf}; @@ -134,16 +148,36 @@ fn start_dir_in(current: &str, managed: Option) -> PathBuf { managed.filter(|d| d.is_dir()).unwrap_or_else(last_resort) } -/// The nearest ancestor of `dir` that exists — `dir` itself when it does. +/// The nearest ancestor of `dir` we should point the panel at. /// /// Walking up is what makes a not-yet-created destination useful: a disk bound /// for `~/VMs/indy/disks/root.raw` opens at `~/VMs/indy` if that is as far as /// the tree goes, which is one folder from where the user is aiming rather than /// wherever they last browsed. +/// +/// **"Cannot see it" is not "not there."** Under the App Sandbox a path outside +/// the container that we hold no bookmark for fails to stat with +/// `PermissionDenied`, not `NotFound` — the folder is very likely present, we +/// are simply not allowed to look. Walking past it would send the panel +/// somewhere unrelated in exactly the case where the user is browsing *because* +/// they need to re-grant access to it. The panel is not us: it is the +/// powerbox, running out of process, and showing folders the app cannot read is +/// its entire purpose. So a denial is treated as "point at it and let the panel +/// decide", which is what makes the sandboxed build behave like the plain one. fn nearest_existing(dir: &Path) -> Option { - dir.ancestors() - .find(|a| !a.as_os_str().is_empty() && a.is_dir()) - .map(PathBuf::from) + for a in dir.ancestors() { + if a.as_os_str().is_empty() { + continue; + } + match std::fs::metadata(a) { + Ok(m) if m.is_dir() => return Some(a.to_path_buf()), + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + return Some(a.to_path_buf()) + } + _ => {} + } + } + None } fn last_resort() -> PathBuf { @@ -379,10 +413,54 @@ mod tests { std::fs::remove_dir_all(&disks).ok(); } + /// The sandbox case: a folder we are not allowed to look at must still be + /// where the panel opens, because the panel can see it and we cannot. + /// + /// Reproduced with a parent directory stripped of `+x`, which makes + /// `metadata` on its child fail with `PermissionDenied` exactly as a + /// sandbox denial does. Skipped when the precondition does not hold — as + /// root, which bypasses the check entirely. + // Manufactures the denial with Unix directory permissions. macOS is the + // platform that matters here and is covered; Windows has no App Sandbox and + // no equivalent to reproduce. + #[cfg(unix)] + #[test] + fn a_folder_we_cannot_look_at_is_still_where_the_panel_should_open() { + use std::os::unix::fs::PermissionsExt; + + let root = tmp("denied"); + let locked = root.join("locked"); + let inner = locked.join("disks"); + std::fs::create_dir_all(&inner).unwrap(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + + let denied = std::fs::metadata(&inner) + .err() + .is_some_and(|e| e.kind() == std::io::ErrorKind::PermissionDenied); + if denied { + // Not "walk up to `root`" — point at the folder itself and let the + // panel, which is not sandboxed with us, do the rest. + assert_eq!(nearest_existing(&inner).unwrap(), inner, + "a denied folder must not be walked past"); + let img = inner.join("scsi1.raw"); + assert_eq!(start_dir_in(&img.to_string_lossy(), Some(root.clone())), inner); + } + + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::remove_dir_all(&root).ok(); + if !denied { + eprintln!("skipped: could not produce a PermissionDenied (running as root?)"); + } + } + /// The invariant the whole module exists for. Whatever it is handed — /// including a managed directory that could not be created — it must name a - /// real directory, because handing the panel nothing is what made it open - /// in the wrong place. + /// directory the panel can use, because handing the panel nothing is what + /// made it open in the wrong place. + /// + /// "Can use" rather than "exists": a sandbox denial deliberately returns a + /// path we cannot stat (see `nearest_existing`). None of the inputs here + /// produce one, so `is_dir` is the right assertion for these cases. #[test] fn the_result_is_always_a_directory_that_exists() { let cases = ["", " ", "scsi1.raw", "/nonexistent/deep/path/x.raw", "relative/x.raw"]; From 3fa3eccf262a7a74ad725fbd3b6bb29acf636db3 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 21 Aug 2026 20:05:57 -0400 Subject: [PATCH 15/15] ci: fix both failing checks, and close the gaps that hid them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two failures on 4648a3b, two different root causes, both mine. **Rust / Run tests.** Moving the report model and parser into iris::bench_report left src/bin/iris_bench.rs's #[cfg(test)] module referencing the names that moved. Nothing I ran locally compiled it: cargo build, cargo check --bin and cargo test --lib all skip test code in a binary target. It failed in ten seconds, before the test binaries had even finished building. The parser tests now live with the parser. Three of them duplicated coverage bench_report already had; two did not, and are ported rather than dropped — parse_features, and the per-row rate/mips arithmetic including the divide-by-zero guards. What stays in the binary is the one test about data the binary owns, its CELLS table. **Bare-metal suites / prebuilt check.** My drift check rebuilt the guest ELF in CI and byte-compared it against the checked-in copy. That can never pass: the image is compiled with -g, so DWARF records the build directory, and a runner's /home/runner/work/iris/iris is never a developer's ~/repos/iris. Toolchain versions differ on top of that. Two correct builds of identical source have different bytes, so the check was testing reproducibility rather than staleness. Replaced with a digest of the sources the image is built from — kernels, harness, the shared cpu-tests harness, golden.h, the Makefile that carries CFLAGS — recorded by `make prebuilt` and verified by `make check-prebuilt`. Toolchain-independent, needs no compile, and it is exact about the thing that must not drift: the pairing of image and source. Verified that it passes on a clean tree and fails on a kernel edit and on a shared-harness edit. A hash cannot tell you the image *works*; the embedded-runner test does that by running the suite out of the binary and requiring 100% accuracy against the golden checksums compiled into it. The two checks are complementary. **And the gap underneath both.** rust.yml ran plain `cargo build` / `cargo test`, which cover only the root package — iris-gui was never compiled in CI, so its 47 tests never ran and a GUI-only breakage could not turn anything red. Now --workspace. It needs no extra system packages: wayland, X11 and GL are dlopen'd at runtime, and the only thing iris-gui links is libasound, already installed. Full `cargo test --workspace` now passes locally: 455 + 47. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/rust.yml | 9 ++- .github/workflows/suites.yml | 16 ++--- bench/Makefile | 15 +++-- bench/README.md | 10 +-- bench/prebuilt-stamp.py | 124 +++++++++++++++++++++++++++++++++++ bench/prebuilt/PROVENANCE | 12 +++- bench/prebuilt/README.md | 13 +++- src/bench_report.rs | 44 +++++++++++++ src/bin/iris_bench.rs | 64 +----------------- 9 files changed, 219 insertions(+), 88 deletions(-) create mode 100755 bench/prebuilt-stamp.py diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index efa8907..bcb8881 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -18,7 +18,12 @@ jobs: - uses: actions/checkout@v4 - name: Install dependencies run: sudo apt-get update && sudo apt-get install -y pkg-config libasound2-dev + # --workspace, not the default. Without it cargo only builds and tests the + # root `iris` package, so iris-gui was never compiled here at all and its + # tests never ran — a GUI-only breakage could not turn this red. It needs no + # extra system packages: wayland, X11 and GL are dlopen'd at runtime, so the + # only thing it links is libasound, which is already installed above. - name: Build - run: cargo build --verbose + run: cargo build --workspace --verbose - name: Run tests - run: cargo test --verbose + run: cargo test --workspace --verbose diff --git a/.github/workflows/suites.yml b/.github/workflows/suites.yml index 92563ba..288d3fc 100644 --- a/.github/workflows/suites.yml +++ b/.github/workflows/suites.yml @@ -106,14 +106,14 @@ jobs: # one drifts dangerously: accuracy is scored against golden checksums # compiled *into* the image, so a stale image against fresh goldens # reports failures to users that are not real. - - name: bench — checked-in guest binary matches what we just built - run: | - make -C bench prebuilt - if ! git diff --exit-code bench/prebuilt/; then - echo "::error::bench/prebuilt/irisbench.elf is stale." - echo "::error::Run 'make -C bench prebuilt' and commit the result." - exit 1 - fi + # + # Compares a digest of the *sources* rather than the image bytes. Byte + # comparison against a fresh build does not work and this job proved it: + # the guest is compiled with -g, so DWARF records the build directory, and + # a runner's path is never a developer's. Two correct builds of identical + # source differ. See bench/prebuilt-stamp.py. + - name: bench — checked-in guest binary matches its sources + run: make -C bench check-prebuilt # One binary of each for every cell below — that is what makes the matrix # a differential test rather than N independent runs. diff --git a/bench/Makefile b/bench/Makefile index 5793e5e..b073f36 100644 --- a/bench/Makefile +++ b/bench/Makefile @@ -62,7 +62,7 @@ LIBGCC := PREBUILT := prebuilt/irisbench.elf -.PHONY: all clean run dis syms image golden hostbench matrix bench report check-size prebuilt +.PHONY: all clean run dis syms image golden hostbench matrix bench report check-size prebuilt check-prebuilt all: $(TARGET) # Refresh the copy of the guest binary that `iris` links in with include_bytes! @@ -75,14 +75,15 @@ all: $(TARGET) prebuilt: $(TARGET) @mkdir -p prebuilt cp $(TARGET) $(PREBUILT) - @python3 -c "import hashlib,os; \ - h=lambda p: hashlib.sha256(open(p,'rb').read()).hexdigest(); \ - open('prebuilt/PROVENANCE','w').write( \ - '# What the checked-in guest binary hashes to. See README.md.\n' + \ - 'irisbench.elf sha256 %s %d bytes\n' % (h('$(PREBUILT)'), os.path.getsize('$(PREBUILT)')) + \ - 'golden/golden.h sha256 %s %d bytes\n' % (h('golden/golden.h'), os.path.getsize('golden/golden.h')))" + @python3 prebuilt-stamp.py --write @echo "prebuilt: $(PREBUILT) refreshed — commit it with the change that caused it" +# Is the checked-in image still the one these sources build? Needs no cross +# toolchain and no compile — it hashes the inputs. See prebuilt-stamp.py for why +# this is not a byte comparison against a fresh build. +check-prebuilt: + @python3 prebuilt-stamp.py --check + $(BUILD)/%.o: harness/%.c @mkdir -p $(dir $@) $(CC) $(CFLAGS) -MMD -MP -c -o $@ $< diff --git a/bench/README.md b/bench/README.md index 1705abb..2b2842b 100644 --- a/bench/README.md +++ b/bench/README.md @@ -262,10 +262,12 @@ writable path to unpack an image to either. **Refresh it with `make -C bench prebuilt` whenever you change anything the guest is built from** — the kernels, `harness/`, `cpu-tests/harness/`, the link script, the compiler flags — and commit it alongside the source change. -`.github/workflows/suites.yml` rebuilds it and fails on any difference, because -this is a build product that drifts dangerously: accuracy is scored against -golden checksums compiled *into* the image, so a stale image against fresh -goldens reports failures to users that are not real. +`make -C bench check-prebuilt` — which CI runs — fails if the guest sources have +moved since the image was last refreshed, because this is a build product that +drifts dangerously: accuracy is scored against golden checksums compiled *into* +the image, so a stale image against fresh goldens reports failures to users that +are not real. It compares a digest of the *sources*, not the image bytes: +`bench/prebuilt-stamp.py` explains why byte comparison cannot work. --- diff --git a/bench/prebuilt-stamp.py b/bench/prebuilt-stamp.py new file mode 100755 index 0000000..7325baa --- /dev/null +++ b/bench/prebuilt-stamp.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Stamp and verify bench/prebuilt/ against the sources it was built from. + +Byte-comparing the checked-in guest image against a fresh build does not work, +and CI proved it: the image is compiled with -g, so DWARF records the build +directory — a runner's `/home/runner/work/iris/iris` is never a developer's +`~/repos/iris` — and the toolchain version differs on top of that. The bytes +legitimately differ between two correct builds of identical source. + +What must not drift is the *pairing*: the checked-in image and the sources it +was built from. That is what this hashes, so verification needs no cross +toolchain and no compilation at all — just the files. + +What it deliberately does not check is that the image *works*; a hash cannot +tell you that. The embedded-runner test does, by running the suite out of the +binary the image is linked into and requiring 100% accuracy against the golden +checksums compiled into it. + + prebuilt-stamp.py --write after `make prebuilt`, records the digest + prebuilt-stamp.py --check fails if the sources have moved since +""" + +import hashlib +import pathlib +import sys + +BENCH = pathlib.Path(__file__).resolve().parent +ROOT = BENCH.parent +STAMP = BENCH / "prebuilt" / "PROVENANCE" +ELF = BENCH / "prebuilt" / "irisbench.elf" + +#: Everything irisbench.elf is built from. `cpu-tests/harness` is in here +#: because the toolchain probe, the console, the startup code and the exception +#: dispatcher are shared with that suite — changing them changes this image. +SOURCES = [ + ("bench/harness", "*"), + ("bench/kernels", "*"), + ("bench/golden", "golden.h"), + ("bench", "Makefile"), + ("cpu-tests/harness", "*"), + ("cpu-tests", "toolchain.mk"), +] + + +def inputs(): + """Every source file, sorted, so the digest does not depend on readdir order.""" + out = [] + for subdir, pattern in SOURCES: + for p in sorted((ROOT / subdir).glob(pattern)): + if p.is_file(): + out.append(p) + return sorted(out) + + +def digest(): + """sha256 over each input's path *and* contents. + + The path is folded in so that adding or renaming a kernel changes the + digest even when the bytes of every individual file are unchanged. + """ + h = hashlib.sha256() + for p in inputs(): + h.update(str(p.relative_to(ROOT)).encode()) + h.update(b"\0") + h.update(p.read_bytes()) + h.update(b"\0") + return h.hexdigest() + + +def elf_sha(): + return hashlib.sha256(ELF.read_bytes()).hexdigest() if ELF.exists() else "(missing)" + + +def recorded(): + if not STAMP.exists(): + return None + for line in STAMP.read_text().splitlines(): + if line.startswith("sources"): + return line.split()[-1] + return None + + +def write(): + STAMP.write_text( + "# Generated by prebuilt-stamp.py; see it for what these mean.\n" + "#\n" + "# `sources` is the digest of everything irisbench.elf is built from.\n" + "# CI compares it and fails if the image was not refreshed alongside a\n" + "# source change. The ELF's own hash is informational only — two correct\n" + "# builds of identical source differ, because -g records the build path.\n" + f"sources sha256 {digest()}\n" + f"image sha256 {elf_sha()} {ELF.stat().st_size if ELF.exists() else 0} bytes\n" + f"inputs {len(inputs())} files\n" + ) + print(f"prebuilt-stamp: recorded {len(inputs())} inputs") + + +def check(): + want, got = recorded(), digest() + if want is None: + sys.exit("prebuilt-stamp: no digest recorded — run `make -C bench prebuilt`") + if not ELF.exists(): + sys.exit(f"prebuilt-stamp: {ELF} is missing") + if want != got: + sys.exit( + "prebuilt-stamp: bench/prebuilt/irisbench.elf is stale.\n" + f" recorded sources {want}\n" + f" current sources {got}\n" + " The guest sources changed without the checked-in image being\n" + " refreshed. `iris` links that image in, and accuracy is scored\n" + " against golden checksums compiled into it — a stale image reports\n" + " failures to users that are not real.\n" + " Fix: make -C bench prebuilt, and commit bench/prebuilt/." + ) + print(f"prebuilt-stamp: image matches its {len(inputs())} sources") + + +if __name__ == "__main__": + if "--write" in sys.argv: + write() + elif "--check" in sys.argv: + check() + else: + sys.exit(__doc__) diff --git a/bench/prebuilt/PROVENANCE b/bench/prebuilt/PROVENANCE index 910eb72..6311745 100644 --- a/bench/prebuilt/PROVENANCE +++ b/bench/prebuilt/PROVENANCE @@ -1,3 +1,9 @@ -# What the checked-in guest binary hashes to. See README.md. -irisbench.elf sha256 6b49cf9fc97e82b2ac305a8fbf4e8b8e84a64ab68c2863e6d169431b1d1b7209 299168 bytes -golden/golden.h sha256 c26eda9c1d331b5925b1339132a63320b4663315739ba103794e6304662144cb 2289 bytes +# Generated by prebuilt-stamp.py; see it for what these mean. +# +# `sources` is the digest of everything irisbench.elf is built from. +# CI compares it and fails if the image was not refreshed alongside a +# source change. The ELF's own hash is informational only — two correct +# builds of identical source differ, because -g records the build path. +sources sha256 75b9f676c4a2b2863ec0d9bb227d60ca4cd76b078f8f5a2f45b9c484cfa3091e +image sha256 6b49cf9fc97e82b2ac305a8fbf4e8b8e84a64ab68c2863e6d169431b1d1b7209 299168 bytes +inputs 28 files diff --git a/bench/prebuilt/README.md b/bench/prebuilt/README.md index ffd0c24..a8d73ba 100644 --- a/bench/prebuilt/README.md +++ b/bench/prebuilt/README.md @@ -16,6 +16,13 @@ Refresh it with `make -C bench prebuilt` after changing anything the guest is built from — the kernels, the harness, `cpu-tests/harness/`, the link script, the compiler flags — and commit the result alongside the source change. -`PROVENANCE` records what the checked-in bytes hash to. It is written by the -`prebuilt` target; nothing reads it, but a reviewer can check it by hand and a -`git log` on it shows every time the image moved. +`PROVENANCE` records a digest of every source the image is built from, written +by the `prebuilt` target and verified by `make -C bench check-prebuilt` (which +CI runs). It is a *source* digest, not an image one, because two correct builds +of identical source do not produce identical bytes — the image is compiled with +`-g`, so DWARF records the build directory, and toolchain versions differ. The +image's own hash is recorded alongside for humans, and nothing compares it. + +The digest cannot tell you the image *works*. The embedded-runner test does, by +running the suite out of the binary the image is linked into and requiring 100% +accuracy against the golden checksums compiled into it. diff --git a/src/bench_report.rs b/src/bench_report.rs index 75a10bd..7271066 100644 --- a/src/bench_report.rs +++ b/src/bench_report.rs @@ -837,6 +837,50 @@ IRIS-BENCH-END assert!(run.dmips().unwrap() > 0.0); } + /// Per-row derived units. Ported from `iris-bench`'s own tests when the + /// model moved here, so the arithmetic keeps its coverage. + #[test] + fn per_row_rates_use_the_units_they_claim() { + let p = parsed(); + let alu = p.rows.iter().find(|r| r.name == "int/alu").unwrap(); + // 15_601_504 work units in 254_727_060 ns. + let want = 15_601_504.0 * 1e9 / 254_727_060.0; + assert!((alu.rate() - want).abs() < 1.0, "rate was {}", alu.rate()); + // icount * 1e3 / ns is millions of instructions per second. + let want_mips = 26_327_595.0 * 1e3 / 254_727_060.0; + assert!((alu.mips() - want_mips).abs() < 0.01); + + // A row with no time, or no instruction counter, reports zero rather + // than dividing by it. + let mut dead = alu.clone(); + dead.ns = 0; + assert_eq!(dead.rate(), 0.0); + assert_eq!(dead.mips(), 0.0); + let mut hostish = alu.clone(); + hostish.icount = 0; + assert_eq!(hostish.mips(), 0.0, "no instruction counter is not zero MIPS of work"); + } + + /// `parse_features` reads the emulator's own startup banner, so a saved + /// result records what produced it rather than what the caller believed. + #[test] + fn features_come_from_the_emulator_banner() { + assert_eq!(parse_features("iris: build features: r5k jitv2 tlbvmap\n"), + vec!["r5k", "jitv2", "tlbvmap"]); + assert!(parse_features("iris: build features: (none)\n").is_empty()); + assert!(parse_features("no banner at all").is_empty()); + } + + /// A run with no whetstone row has no whetstone figure — `None`, not 0.0, + /// so a missing kernel cannot be read as a slow one. + #[test] + fn a_missing_kernel_has_no_figure_rather_than_a_zero_one() { + let run = a_run(); + assert!(run.whet_loops().is_none()); + assert!(run.linpack_mflops().is_none()); + assert!(run.dmips().is_some(), "the fixture does have a dhrystone row"); + } + #[test] fn a_category_aggregates_only_its_own_kernels_and_unit() { let run = a_run(); diff --git a/src/bin/iris_bench.rs b/src/bin/iris_bench.rs index 717b181..2e47698 100644 --- a/src/bin/iris_bench.rs +++ b/src/bin/iris_bench.rs @@ -1052,67 +1052,9 @@ fn dispatch(cmd: Cmd) -> Result<(), String> { mod tests { use super::*; - const SAMPLE: &str = "\ -noise before the block -IRIS-BENCH-BEGIN v1 -#machine cpu=R4400 prid=0x00000440 fir=0x00000500 config=0x00c08483 l2=1 testdev=1 timebase=1 -#timebase count_hz=33000000 measured=1 -#work base=0x88300000 bytes=25165824 -#cols name unit iters work ns icount count exc checksum golden status -int/alu ops 16384 262144 250000000 12500000 8250 0 0x1111 0x1111 OK -int/dhrystone dhry 1000 1757000 1000000000 50000000 33000 0 0x2222 0x3333 MISMATCH -sys/tlb_hit xlat 256 24576 250000000 5000000 8250 4096 0x0000 0x0000 UNCHECKED -#totals benches=3 checked=2 matched=1 ns=1500000000 icount=67500000 -IRIS-BENCH-END -"; - - #[test] - fn parses_a_report_block_out_of_surrounding_noise() { - let (m, rows, checked, matched, ns, ic) = parse_block(SAMPLE).unwrap(); - assert_eq!(m.cpu, "R4400"); - assert_eq!(m.count_hz, 33_000_000); - assert!(m.timebase && m.testdev && m.l2); - assert_eq!(m.work_bytes, 25_165_824); - assert_eq!(rows.len(), 3); - assert_eq!((checked, matched), (2, 1)); - assert_eq!((ns, ic), (1_500_000_000, 67_500_000)); - } - - #[test] - fn rates_and_derived_units() { - let (_, rows, checked, matched, ns, ic) = parse_block(SAMPLE).unwrap(); - let run = Run { - cell: "t".into(), features: vec![], machine: Machine::default(), - host: HostInfo::default(), rows, checked, matched, - total_ns: ns, total_icount: ic, wall_s: 2.0, - suite_id: "blake3:0000000000000000".into(), - }; - // 262144 work units in 0.25 s - assert!((run.row("int/alu").unwrap().rate() - 1_048_576.0).abs() < 1.0); - // 12.5M instructions in 0.25 s = 50 MIPS - assert!((run.row("int/alu").unwrap().mips() - 50.0).abs() < 0.01); - // 1,757,000 dhrystones in 1 s / 1757 = 1000 DMIPS - assert!((run.dmips().unwrap() - 1000.0).abs() < 0.01); - assert!(run.whet_loops().is_none(), "the sample block has no whetstone row"); - assert!((run.accuracy() - 50.0).abs() < 0.01); - assert!((run.mips() - 45.0).abs() < 0.01); - } - - #[test] - fn a_truncated_run_is_an_error_not_an_empty_report() { - let cut = SAMPLE.split(END).next().unwrap(); - assert!(parse_block(cut).unwrap_err().contains("IRIS-BENCH-END")); - assert!(parse_block("nothing here").unwrap_err().contains("IRIS-BENCH-BEGIN")); - } - - #[test] - fn features_come_from_the_emulator_banner() { - assert_eq!(parse_features("iris: build features: r5k jitv2 tlbvmap\n"), - vec!["r5k", "jitv2", "tlbvmap"]); - assert!(parse_features("iris: build features: (none)\n").is_empty()); - assert!(parse_features("no banner at all").is_empty()); - } - + /// Only what is local to this binary. The report model, its parser and the + /// derived figures moved to `iris::bench_report` and are tested there — + /// keeping a second copy here is how two parsers drift apart. #[test] fn every_cell_name_is_unique_and_maps_to_a_cpu() { for (i, a) in CELLS.iter().enumerate() {