#3028: keep dedicated preview lanes waiting on GPU admission

This commit is contained in:
FredApps
2026-11-09 20:21:23 +01:00
parent 989b37b1d1
commit 59c41fee0a
3 changed files with 101 additions and 6 deletions
+21 -5
View File
@@ -101,6 +101,12 @@ impl GpuRenderSlots {
/// A Rayon helper may be executing a nested frame while an admitted render waits for
/// its work. Blocking that helper on a permit would deadlock the render (#3028).
fn for_render(&self, max: usize) -> Option<GpuRenderSlot<'_>> {
// Dedicated preview pools have one thread, protected from nested drains. They cannot
// be helpers of another admitted frame, so waiting preserves GPU preview throughput.
#[cfg(not(target_arch = "wasm32"))]
if DRAINING_LANE.get() {
return Some(self.acquire(max));
}
if rayon::current_thread_index().is_some() { self.try_acquire(max) } else { Some(self.acquire(max)) }
}
@@ -1686,15 +1692,29 @@ struct Worker {
thread_local! {
/// This pool thread is inside [`Worker::drain`] (rendering a frame).
static DRAINING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// Only the single thread of a dedicated preview lane may wait for GPU admission.
static DRAINING_LANE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Clears [`DRAINING`] when the drain ends, even by a panic.
#[cfg(not(target_arch = "wasm32"))]
struct Draining;
#[cfg(not(target_arch = "wasm32"))]
impl Draining {
fn enter(lane: Option<usize>) -> Option<Self> {
if DRAINING.replace(true) {
return None;
}
DRAINING_LANE.set(lane.is_some());
Some(Self)
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Drop for Draining {
fn drop(&mut self) {
DRAINING_LANE.set(false);
DRAINING.set(false);
}
}
@@ -1723,11 +1743,7 @@ impl Worker {
/// `lane`: the lane this runs on (see [`Queue::idle_lanes`]), `None` on the pool.
#[cfg(not(target_arch = "wasm32"))]
fn drain(&self, lane: Option<usize>) {
if DRAINING.get() {
return;
}
DRAINING.set(true);
let _reset = Draining;
let Some(_reset) = Draining::enter(lane) else { return };
while self.run_next(lane) {}
}
+78
View File
@@ -3237,6 +3237,42 @@ fn gpu_worker_routes_admit_display_readback_and_auto_effects() {
assert!(worker.gpu_ready());
assert!(gpu.context().check_health().is_ok());
// The real dedicated-lane drain waits, then caches a GPU frame instead of silently
// switching a healthy preview to CPU rendering while the other lanes are busy.
frames.queue.lock().unwrap().jobs.push(Job {
key: job.key,
src: job.src.clone(),
comp,
t: job.t,
opts: RenderOpts { backend: Backend::Gpu, ..job.opts },
urgent: false,
background: true,
seq: 1,
cancel: Arc::default(),
});
let first = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS);
let second = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS);
let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap();
let (started, ready) = std::sync::mpsc::channel();
let (tx, rx) = std::sync::mpsc::channel();
let (early, finished) = std::thread::scope(|scope| {
scope.spawn(|| {
pool.install(|| {
started.send(()).unwrap();
worker.drain(Some(0));
tx.send(()).unwrap();
})
});
ready.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
let early = rx.recv_timeout(std::time::Duration::from_millis(100));
drop((first, second));
let finished = early.or_else(|_| rx.recv_timeout(std::time::Duration::from_secs(2)));
(early, finished)
});
assert!(early.is_err(), "preview lane must wait for an available GPU permit");
assert_eq!(finished, Ok(()));
assert!(frames.cache.lock().unwrap().map.get(&job.key).unwrap().img.is_gpu());
// A retirement found inside the scope releases the permit before the fresh CPU retry.
gpu.context().device_failure_notifier()("synthetic worker retirement".into());
let before = admissions();
@@ -3288,3 +3324,45 @@ fn gpu_render_slots_never_block_rayon_helpers() {
assert_eq!(declined, Ok(true), "Rayon helpers must not wait on GPU permits");
assert_eq!(*slots.busy.lock().unwrap(), 0);
}
/// Dedicated preview lanes have one protected thread and can wait without creating the
/// shared-pool helper dependency cycle. They must keep rendering on a healthy GPU.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn gpu_preview_lane_waits_for_admission_and_resets_its_marker() {
let slots = GpuRenderSlots::new();
let first = slots.acquire(2);
let second = slots.acquire(2);
let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap();
let (started, ready) = std::sync::mpsc::channel();
let (tx, rx) = std::sync::mpsc::channel();
let (early, admitted) = std::thread::scope(|scope| {
scope.spawn(|| {
pool.install(|| {
let _drain = Draining::enter(Some(0)).unwrap();
assert!(Draining::enter(None).is_none(), "a lane cannot start a nested drain");
assert!(DRAINING_LANE.get(), "nested rejection must not clear the lane marker");
started.send(()).unwrap();
tx.send(slots.for_render(2).is_some()).unwrap();
});
});
ready.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
let early = rx.recv_timeout(std::time::Duration::from_millis(100));
drop((first, second));
let admitted = early.or_else(|_| rx.recv_timeout(std::time::Duration::from_secs(2)));
(early, admitted)
});
assert!(early.is_err(), "busy dedicated lane must wait instead of falling back to CPU");
assert_eq!(admitted, Ok(true));
pool.install(|| {
assert!(!DRAINING.get());
assert!(!DRAINING_LANE.get());
let panic = std::panic::catch_unwind(|| {
let _drain = Draining::enter(Some(0)).unwrap();
panic!("synthetic lane failure");
});
assert!(panic.is_err());
assert!(!DRAINING.get());
assert!(!DRAINING_LANE.get());
});
}
+2 -1
View File
@@ -414,7 +414,8 @@ tests, device errors are logged rather than fatal. A viewer frame that runs out
(`Gpu::within_memory`) renders on the CPU, the uploaded layers and pooled textures are freed, and
the RAM preview keeps half as many GPU frames from then on. However many frame threads the viewer
runs, at most two of them start GPU render attempts at once. Shared-pool workers use the CPU
when both slots are busy rather than blocking Rayon helpers; other render threads wait, so
when both slots are busy rather than blocking Rayon helpers; dedicated single-thread preview
lanes wait for admission and remain on the GPU, so
working-texture pressure is bounded by concurrent attempts rather than the thread count (#3028).
A single large frame or retained frames can still exceed the available video memory. The same guard catches a frame's
validation errors (an invalid texture, often left by a failed allocation): that frame renders on