diff --git a/clanker/rex3/rex-jit-queue-retry.md b/clanker/rex3/rex-jit-queue-retry.md index 96d463bd7..37fb4838f 100644 --- a/clanker/rex3/rex-jit-queue-retry.md +++ b/clanker/rex3/rex-jit-queue-retry.md @@ -1,11 +1,20 @@ -# Retry rejected REX JIT compile requests +# Retry rejected REX JIT cold compile requests -The compile channel holds 256 requests. If `try_send` fails, remove the shader -key from `queued`; otherwise subsequent draws see a phantom queued entry and -never request compilation again. A 263-entry warm-up profile reproduced this -failure and 30-second waits in graphics tests. +The REX JIT compile queue (`src/dev/ng1/rex3_jit_queue.rs`) has two lanes. The +draw path uses the **hot** lane (`RexJit::request_compile`), which is never +rejected for a full queue; warm-up and best-effort prefetch use the **cold** lane +(`request_compile_cold` / `request_compile_blocking`), which holds 256 requests. + +If a cold `send` fails, remove the shader key from the map; otherwise subsequent +draws see a phantom queued entry and never request compilation again. A +263-entry warm-up profile reproduced this failure and 30-second waits in +graphics tests. Warm-up itself uses `send_cold_blocking`, so it waits rather than +truncating the profile; the cold *drop* path exists so a non-blocking prefetch +caller can retry later. Unit tests must start without loading the user's persistent REX profile. Test warm-up explicitly with fixtures rather than depending on a host's `~/.iris` -state. `full_compile_queue_allows_retry` verifies saturation and retry without -starting the real compiler. +state. `hot_draw_request_jumps_a_full_cold_queue` fills the cold lane and checks +that a hot draw request is still admitted and drained first; +`hot_request_is_admitted_ahead_of_a_full_cold_queue` exercises the same at the +queue level without starting the real compiler. diff --git a/src/dev/ng1/mod.rs b/src/dev/ng1/mod.rs index 4e2442092..c5b344c84 100644 --- a/src/dev/ng1/mod.rs +++ b/src/dev/ng1/mod.rs @@ -22,6 +22,11 @@ pub mod rex3_shape; /// records what the guest draws, and the generated shader table serves draws in /// builds with no Cranelift at all. pub mod rex3_profile; +/// Priority compile queue for the REX3 JIT. Split out of `rex3_jit` so it +/// carries no Cranelift types: it is compiled (and unit-tested) without the +/// `rex-jit` feature. +#[cfg(any(feature = "rex-jit", test))] +pub mod rex3_jit_queue; #[cfg(feature = "rex-jit")] pub mod rex3_jit; pub mod vc2; diff --git a/src/dev/ng1/rex3_jit/mod.rs b/src/dev/ng1/rex3_jit/mod.rs index 76f5c0784..0be590fd8 100644 --- a/src/dev/ng1/rex3_jit/mod.rs +++ b/src/dev/ng1/rex3_jit/mod.rs @@ -17,13 +17,13 @@ pub use crate::dev::ng1::rex3_profile as profile; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, RwLock}; -use std::sync::mpsc::{self, SyncSender}; use std::thread; #[cfg(feature = "developer")] use std::sync::atomic::{AtomicU64, Ordering as AtomicOrd}; use crate::dev::ng1::rex3::Rex3Context; +use crate::dev::ng1::rex3_jit_queue::{CompileQueue, CompileRequest}; use compiler::ShaderCompiler; /// A compiled draw shader and its housekeeping metadata. @@ -127,15 +127,13 @@ pub type PublishMap = pub struct RexJit { store: Arc, - compile_tx: SyncSender, + /// Hot/cold priority queue. The draw path uses the hot lane (never dropped + /// for a full queue); warm-up and prefetch use the cold lane (drop-on-full + /// with a `Queued`-marker retry). + queue: Arc, _compiler_thread: thread::JoinHandle<()>, } -enum CompileRequest { - Compile(u32, u32, u32), - Shutdown, -} - impl RexJit { /// Create the JIT subsystem and start the compiler thread. /// Immediately queues all keys from the saved profile for warm-up compilation. @@ -144,57 +142,53 @@ impl RexJit { let store_clone = Arc::clone(&store); let publish_clone = Arc::clone(&publish); - // Bounded channel: if the queue fills (many unique draw modes on first boot), - // request_compile() drops new requests rather than blocking the draw thread. - let (tx, rx) = mpsc::sync_channel::(256); + // Two-lane priority queue: the draw path is hot and never dropped for a + // full queue, warm-up/prefetch is cold and may be. See rex3_jit_queue. + let queue = Arc::new(CompileQueue::new()); + let queue_worker = Arc::clone(&queue); let compiler_thread = thread::Builder::new() .name("rex3-jit".into()) .spawn(move || { let mut compiler = ShaderCompiler::new(); - for req in rx { - match req { - CompileRequest::Compile(dm0, dm1, cm) => { - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - compiler.compile_shader(dm0, dm1, cm) - })); - let result = match result { - Ok(r) => r, - Err(e) => { - let msg = if let Some(s) = e.downcast_ref::<&str>() { s.to_string() } - else if let Some(s) = e.downcast_ref::() { s.clone() } - else { "(unknown panic)".to_string() }; - eprintln!("REX JIT: compile_shader panicked for dm0={dm0:#010x} dm1={dm1:#010x} cm={cm:#010x}: {msg}"); - None - } + while let Some(CompileRequest { dm0, dm1, cm }) = queue_worker.recv() { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + compiler.compile_shader(dm0, dm1, cm) + })); + let result = match result { + Ok(r) => r, + Err(e) => { + let msg = if let Some(s) = e.downcast_ref::<&str>() { s.to_string() } + else if let Some(s) = e.downcast_ref::() { s.clone() } + else { "(unknown panic)".to_string() }; + eprintln!("REX JIT: compile_shader panicked for dm0={dm0:#010x} dm1={dm1:#010x} cm={cm:#010x}: {msg}"); + None + } + }; + match result { + Some((entry, code_bytes)) => { + let shader = CompiledShader::new(entry, code_bytes); + let count = { + let mut map = store_clone.shaders.write().unwrap(); + // Publish into the dispatch map too: that is + // what execute_go reads, and it is shared with + // the generated LLVM shaders. + publish_clone.write().insert((dm0, dm1, cm), entry); + map.insert((dm0, dm1, cm), ShaderState::Compiled(shader)); + map.len() }; - match result { - Some((entry, code_bytes)) => { - let shader = CompiledShader::new(entry, code_bytes); - let count = { - let mut map = store_clone.shaders.write().unwrap(); - // Publish into the dispatch map too: that is - // what execute_go reads, and it is shared with - // the generated LLVM shaders. - publish_clone.write().insert((dm0, dm1, cm), entry); - map.insert((dm0, dm1, cm), ShaderState::Compiled(shader)); - map.len() - }; - crate::dlog!( - crate::devlog::LogModule::Rex3, - "REX JIT: compiled dm0={dm0:#010x} dm1={dm1:#010x} cm={cm:#010x} ({code_bytes}B, total: {count})" - ); - } - None => { - store_clone - .shaders - .write() - .unwrap() - .insert((dm0, dm1, cm), ShaderState::Failed); - } - } + crate::dlog!( + crate::devlog::LogModule::Rex3, + "REX JIT: compiled dm0={dm0:#010x} dm1={dm1:#010x} cm={cm:#010x} ({code_bytes}B, total: {count})" + ); + } + None => { + store_clone + .shaders + .write() + .unwrap() + .insert((dm0, dm1, cm), ShaderState::Failed); } - CompileRequest::Shutdown => break, } } eprintln!("REX JIT: compiler thread exiting"); @@ -203,7 +197,7 @@ impl RexJit { let jit = Self { store, - compile_tx: tx, + queue, _compiler_thread: compiler_thread, }; @@ -223,13 +217,14 @@ impl RexJit { // and the corpus bled away across runs (observed: 160 entries decaying // to 48). Seeding here makes the round-trip lossless: a key that was // ever seen stays in the corpus even if this run never compiled it. - // Feed the queue from a thread: `request_compile_blocking` waits when the - // channel is full, and `RexJit::new` runs inside `Rex3::new`, so doing - // this inline would stall emulator startup behind the whole profile. + // Feed the queue from a thread: `send_cold_blocking` waits when the cold + // lane is full, and `RexJit::new` runs inside `Rex3::new`, so doing this + // inline would stall emulator startup behind the whole profile. // Off-thread, a profile of any size is queued in full without dropping - // entries and without delaying boot. + // entries and without delaying boot. Warm-up is cold: the hot lane is + // reserved for draws the guest makes while this backlog is draining. { - let tx = jit.compile_tx.clone(); + let queue = Arc::clone(&jit.queue); let store = Arc::clone(&jit.store); let to_queue = profile.clone(); thread::Builder::new() @@ -253,9 +248,9 @@ impl RexJit { } map.insert((dm0, dm1, cm), ShaderState::Queued); } - if tx.send(CompileRequest::Compile(dm0, dm1, cm)).is_err() { + if queue.send_cold_blocking(CompileRequest { dm0, dm1, cm }).is_err() { store.shaders.write().unwrap().remove(&(dm0, dm1, cm)); - break; // receiver gone: shutting down + break; // queue closed: shutting down } } }) @@ -348,39 +343,49 @@ impl RexJit { .collect() } - /// Request background compilation for the given (dm0, dm1, clipmode_key) triple. - /// No-op if already compiled, permanently failed, or already queued. + /// Request a **hot** background compile: a shape the draw path is asking + /// for right now. No-op if already compiled, permanently failed, or already + /// queued. + /// + /// Hot requests go to the priority lane the worker drains first and are + /// never rejected for a full queue, so a live draw cannot be starved by + /// warm-up/prefetch backlog. This path must not block (it runs on the GFIFO + /// consumer thread); the hot lane is non-blocking. pub fn request_compile(&self, dm0: u32, dm1: u32, cm: u32) { - // One lock, one lookup. Any existing entry — compiled, disabled, queued - // or failed — means there is nothing to request; the four-set version - // needed three probes in a fixed order to establish the same thing. - { - let mut map = self.store.shaders.write().unwrap(); - if map.contains_key(&(dm0, dm1, cm)) { - return; - } - map.insert((dm0, dm1, cm), ShaderState::Queued); - } - if self.compile_tx.try_send(CompileRequest::Compile(dm0, dm1, cm)).is_err() { - // A full channel did not accept this shader. Drop the Queued marker - // so a later draw retries, rather than leaving a phantom entry that - // suppresses every future request (see - // clanker/rex3/rex-jit-queue-retry.md). - // - // This path must not block: it runs on the GFIFO consumer thread, - // and stalling there stalls the guest. Warm-up uses - // `request_compile_blocking` instead, which can afford to wait. - self.store.shaders.write().unwrap().remove(&(dm0, dm1, cm)); - } + self.enqueue_compile(dm0, dm1, cm, |q, req| q.send_hot(req)); + } + + /// Request a **cold** background compile: best-effort prefetch. No-op if + /// already known. A full cold lane rejects the request, the `Queued` marker + /// is dropped, and a later draw retries — the drop-and-retry contract of + /// `clanker/rex3/rex-jit-queue-retry.md`. + pub fn request_compile_cold(&self, dm0: u32, dm1: u32, cm: u32) { + self.enqueue_compile(dm0, dm1, cm, |q, req| q.send_cold(req)); } - /// Queue a compile, waiting for room if the channel is full. + /// Queue a compile, waiting for room if the cold lane is full. /// /// Only for warm-up, which runs on its own thread before the guest is /// drawing: blocking there costs nothing and means a profile larger than the - /// 256-slot channel is fully compiled instead of silently truncated. The - /// draw path must keep using [`Self::request_compile`]. + /// cold lane is fully compiled instead of silently truncated. The draw path + /// must keep using [`Self::request_compile`]. pub fn request_compile_blocking(&self, dm0: u32, dm1: u32, cm: u32) { + self.enqueue_compile(dm0, dm1, cm, |q, req| q.send_cold_blocking(req)); + } + + /// Mark the shape `Queued` and hand it to the queue via `send`. On rejection + /// remove the marker so a later draw can retry instead of seeing a phantom + /// entry that suppresses every future request. + fn enqueue_compile( + &self, + dm0: u32, + dm1: u32, + cm: u32, + send: impl Fn(&CompileQueue, CompileRequest) -> Result<(), CompileRequest>, + ) { + // One lock, one lookup. Any existing entry — compiled, disabled, queued + // or failed — means there is nothing to request; the four-set version + // needed three probes in a fixed order to establish the same thing. { let mut map = self.store.shaders.write().unwrap(); if map.contains_key(&(dm0, dm1, cm)) { @@ -388,9 +393,7 @@ impl RexJit { } map.insert((dm0, dm1, cm), ShaderState::Queued); } - // `send` blocks until the compiler thread drains a slot. It only fails - // if the receiver is gone, i.e. we are shutting down. - if self.compile_tx.send(CompileRequest::Compile(dm0, dm1, cm)).is_err() { + if send(&self.queue, CompileRequest { dm0, dm1, cm }).is_err() { self.store.shaders.write().unwrap().remove(&(dm0, dm1, cm)); } } @@ -472,27 +475,45 @@ pub struct ShaderInfo { impl Drop for RexJit { fn drop(&mut self) { - let _ = self.compile_tx.try_send(CompileRequest::Shutdown); + self.queue.close(); } } #[cfg(test)] mod queue_tests { use super::*; + use crate::dev::ng1::rex3_jit_queue::COLD_QUEUE_CAPACITY; - #[test] - fn full_compile_queue_allows_retry() { - let (tx, rx) = mpsc::sync_channel(1); - assert!(tx.try_send(CompileRequest::Compile(1, 2, 3)).is_ok()); - let jit = RexJit { - store: Arc::new(ShaderStore::new()), compile_tx: tx, + fn rex_jit_with_queue() -> RexJit { + RexJit { + store: Arc::new(ShaderStore::new()), + queue: Arc::new(CompileQueue::new()), _compiler_thread: thread::spawn(|| {}), - }; - jit.request_compile(4, 5, 6); - assert_eq!(jit.queued_count(), 0); - rx.recv().unwrap(); - jit.request_compile(4, 5, 6); - assert_eq!(jit.queued_count(), 1); - assert!(matches!(rx.recv().unwrap(), CompileRequest::Compile(4, 5, 6))); + } + } + + /// The draw path (`request_compile`) is the hot lane: a cold backlog filling + /// its lane to capacity must not stop a drawn shape being admitted, and the + /// worker must pop it first. + #[test] + fn hot_draw_request_jumps_a_full_cold_queue() { + let jit = rex_jit_with_queue(); + for i in 0..COLD_QUEUE_CAPACITY as u32 { + jit.request_compile_cold(0, i, 0); + } + + // The cold lane is full: another cold request is dropped and its Queued + // marker removed, so a later draw retries it (the queue-retry rule). + jit.request_compile_cold(0, 0xDEAD, 0); + assert!(!jit.store.shaders.read().unwrap().contains_key(&(0, 0xDEAD, 0))); + assert_eq!(jit.queued_count(), COLD_QUEUE_CAPACITY); + + // A draw (hot) request is admitted anyway, and drained first. + jit.request_compile(1, 0xBEEF, 0); + assert_eq!(jit.queued_count(), COLD_QUEUE_CAPACITY + 1); + assert_eq!( + jit.queue.try_pop(), + Some(CompileRequest { dm0: 1, dm1: 0xBEEF, cm: 0 }), + ); } } diff --git a/src/dev/ng1/rex3_jit_queue.rs b/src/dev/ng1/rex3_jit_queue.rs new file mode 100644 index 000000000..f4e2b80fa --- /dev/null +++ b/src/dev/ng1/rex3_jit_queue.rs @@ -0,0 +1,209 @@ +//! Priority compile queue for the REX3 shader JIT. +//! +//! The async shader compiler used to take its work from a single bounded +//! `SyncSender`. A full channel dropped the request — a deliberate choice so the +//! GFIFO consumer thread never blocks — but that made no distinction between a +//! one-shot warm-up/prefetch shape and a shape the guest is drawing *right now*. +//! A boot-time flood of profile shapes could fill all 256 slots and starve the +//! shader IRIX was actually asking for, so the hot draw kept missing the JIT +//! and ran on the interpreter until the backlog drained. +//! +//! This queue gives the compiler two lanes: +//! +//! - a bounded **cold** lane for warm-up/prefetch. It is best-effort: when it is +//! full a request is rejected, the caller drops the `Queued` marker, and a +//! later draw retries (see `clanker/rex3/rex-jit-queue-retry.md`). +//! - an **hot** lane for whatever the draw path is asking for at dispatch time. +//! The worker drains it before the cold lane and it is never rejected for a +//! full queue, so live draw work cannot be starved by prefetch. +//! +//! The worker pops hot-first; [`CompileQueue::recv`] parks on a condvar rather +//! than spinning. The queue carries no Cranelift types, so it is defined outside +//! the `rex-jit` feature gate and its behaviour is unit-tested without a +//! compiler. + +use std::collections::VecDeque; +use std::sync::{Condvar, Mutex}; + +/// Depth of the cold (warm-up/prefetch) lane. A full cold lane drops the +/// request; this is the old single-channel capacity. +pub const COLD_QUEUE_CAPACITY: usize = 256; + +/// A compile request: the normalised `(DrawMode0, DrawMode1, clipmode)` triple +/// that keys both the dispatch map and the compiler. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CompileRequest { + pub dm0: u32, + pub dm1: u32, + pub cm: u32, +} + +#[derive(Default)] +struct State { + cold: VecDeque, + hot: VecDeque, + closed: bool, +} + +/// A two-lane compile queue. See the module docs for the cold/hot split. +pub struct CompileQueue { + state: Mutex, + ready: Condvar, +} + +impl CompileQueue { + pub fn new() -> Self { + Self { + state: Mutex::new(State::default()), + ready: Condvar::new(), + } + } + + /// Enqueue a best-effort cold request. Returns the request back when the + /// cold lane is full (or the queue is closed) so the caller can drop its + /// `Queued` marker and let a later draw retry. + pub fn send_cold(&self, req: CompileRequest) -> Result<(), CompileRequest> { + let mut st = self.state.lock().unwrap(); + if st.closed || st.cold.len() >= COLD_QUEUE_CAPACITY { + return Err(req); + } + st.cold.push_back(req); + self.ready.notify_one(); + Ok(()) + } + + /// Enqueue a cold request, waiting for room if the lane is full. + /// + /// Only warm-up blocks here: it runs on its own thread before the guest is + /// drawing and wants the whole profile queued. The draw path must use + /// [`Self::send_hot`] instead. + pub fn send_cold_blocking(&self, req: CompileRequest) -> Result<(), CompileRequest> { + let mut st = self.state.lock().unwrap(); + while !st.closed && st.cold.len() >= COLD_QUEUE_CAPACITY { + st = self.ready.wait(st).unwrap(); + } + if st.closed { + return Err(req); + } + st.cold.push_back(req); + self.ready.notify_one(); + Ok(()) + } + + /// Enqueue a hot request. Only a closed queue rejects it: the whole point of + /// the hot lane is that a shape being drawn is never dropped for a full cold + /// lane. The lane is unbounded, but the draw path files at most one request + /// per uncompiled shape (it marks the shape `Queued`), so it holds at most + /// one entry per outstanding shape. + pub fn send_hot(&self, req: CompileRequest) -> Result<(), CompileRequest> { + let mut st = self.state.lock().unwrap(); + if st.closed { + return Err(req); + } + st.hot.push_back(req); + self.ready.notify_one(); + Ok(()) + } + + /// The next request, hot lane first, or `None` once the queue is closed and + /// drained. Blocks while the queue is open and empty. + pub fn recv(&self) -> Option { + let mut st = self.state.lock().unwrap(); + loop { + if let Some(req) = st.hot.pop_front() { + return Some(req); + } + if let Some(req) = st.cold.pop_front() { + return Some(req); + } + if st.closed { + return None; + } + st = self.ready.wait(st).unwrap(); + } + } + + /// Pop the next request without blocking: hot lane first, then cold. For + /// tests that inspect lane order. + #[cfg(test)] + pub(crate) fn try_pop(&self) -> Option { + let mut st = self.state.lock().unwrap(); + st.hot.pop_front().or_else(|| st.cold.pop_front()) + } + + /// Close the queue. [`Self::recv`] drains whatever is left and then returns + /// `None`; further sends are rejected. + pub fn close(&self) { + let mut st = self.state.lock().unwrap(); + st.closed = true; + self.ready.notify_all(); + } +} + +impl Default for CompileQueue { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A full cold lane must not stop a hot request being admitted, and the + /// worker must pop it before any queued cold work. + #[test] + fn hot_request_is_admitted_ahead_of_a_full_cold_queue() { + let q = CompileQueue::new(); + for i in 0..COLD_QUEUE_CAPACITY as u32 { + assert!(q.send_cold(CompileRequest { dm0: 0, dm1: i, cm: 0 }).is_ok()); + } + // The cold lane is full: another cold request is rejected. + let cold = CompileRequest { dm0: 0, dm1: 0xDEAD, cm: 0 }; + assert_eq!(q.send_cold(cold), Err(cold)); + + // A hot request is still admitted... + let hot = CompileRequest { dm0: 1, dm1: 0xBEEF, cm: 7 }; + assert!(q.send_hot(hot).is_ok()); + + // ...and drained ahead of the cold backlog. + assert_eq!(q.try_pop(), Some(hot)); + } + + /// The hot request must survive to the worker even when the cold lane never + /// had room for it — the drop-on-full path is cold-only. + #[test] + fn hot_request_survives_a_full_queue() { + let q = CompileQueue::new(); + for i in 0..COLD_QUEUE_CAPACITY as u32 { + q.send_cold(CompileRequest { dm0: 0, dm1: i, cm: 0 }).unwrap(); + } + let hot = CompileRequest { dm0: 2, dm1: 7, cm: 3 }; + q.send_hot(hot).unwrap(); + + let mut seen = 0; + while let Some(req) = q.try_pop() { + if req == hot { + seen += 1; + } + } + assert_eq!(seen, 1, "hot request was dropped while the cold lane was full"); + } + + /// `recv` prefers hot, drains what is queued after close, then stops. + #[test] + fn recv_drains_hot_first_and_stops_after_close() { + let q = CompileQueue::new(); + let cold = CompileRequest { dm0: 0, dm1: 1, cm: 0 }; + let hot = CompileRequest { dm0: 0, dm1: 2, cm: 0 }; + q.send_cold(cold).unwrap(); + q.send_hot(hot).unwrap(); + + assert_eq!(q.recv(), Some(hot)); + q.close(); + // Cold work queued before close is still delivered. + assert_eq!(q.recv(), Some(cold)); + assert_eq!(q.recv(), None); + assert_eq!(q.send_hot(hot), Err(hot)); + } +}