use std::{
sync::{
Mutex,
atomic::{AtomicBool, Ordering},
},
thread,
time::{Duration, Instant},
};
use glam::Vec3;
use super::{
GpuAccumulate, GpuBackend, GpuBatchItem, GpuSceneRef,
recovery::{COOL_DOWN, MAX_ATTEMPTS_PER_WINDOW, RecoveryPolicy, WINDOW},
turnstile::Turnstile,
};
use crate::{
geometry::cuts::StandardGemCuts,
optics::{
materials::GemMaterial,
raytracer::{Camera, LightingPreset},
},
};
pub(super) fn gpu_required() -> bool {
std::env::var("INDICATRIX_REQUIRE_GPU").is_ok_and(|value| value == "1")
}
pub(super) fn acquire_or_skip(test: &str) -> Option<GpuBackend> {
let backend = GpuBackend::acquire();
if backend.renderer.is_some() {
return Some(backend);
}
assert!(
!gpu_required(),
"{test}: INDICATRIX_REQUIRE_GPU=1 but no GPU adapter could be acquired"
);
println!("skipping {test}: no GPU adapter");
None
}
#[test]
fn turnstile_serves_tickets_in_order_even_when_threads_queue_out_of_order() {
let turnstile = Turnstile::new();
let served_order: Mutex<Vec<u64>> = Mutex::new(Vec::new());
let t0 = turnstile.take_ticket();
let t1 = turnstile.take_ticket();
let t2 = turnstile.take_ticket();
thread::scope(|scope| {
scope.spawn(|| {
let _turn = turnstile.wait_for_turn(t2);
served_order
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(2);
});
scope.spawn(|| {
let _turn = turnstile.wait_for_turn(t1);
served_order
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(1);
});
{
let _turn0 = turnstile.wait_for_turn(t0);
served_order
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(0);
}
});
assert_eq!(
*served_order
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
vec![0, 1, 2],
"tickets must be served in the order they were taken, not the order threads \
happened to start waiting"
);
}
#[test]
fn turnstile_releases_on_an_early_return_out_of_the_turn_scope() {
fn takes_a_turn_then_bails_early(turnstile: &Turnstile, ticket: u64) -> Option<()> {
let _turn = turnstile.wait_for_turn(ticket);
None }
let turnstile = Turnstile::new();
let t0 = turnstile.take_ticket();
let t1 = turnstile.take_ticket();
assert_eq!(takes_a_turn_then_bails_early(&turnstile, t0), None);
let _turn1 = turnstile.wait_for_turn(t1);
}
#[test]
fn poisoned_renderer_mutex_declines_and_sets_lost() {
let Some(backend) = acquire_or_skip("poisoned_renderer_mutex_declines_and_sets_lost") else {
return;
};
let mutex = backend
.renderer
.as_ref()
.expect("acquire_or_skip only returns a backend with a renderer");
let join_result = thread::scope(|scope| {
scope
.spawn(|| {
let _guard = mutex
.lock()
.expect("first lock of a fresh mutex cannot fail");
panic!("deliberate poison for poisoned_renderer_mutex_declines_and_sets_lost");
})
.join()
});
assert!(
join_result.is_err(),
"the scoped thread was expected to panic (that's what poisons the mutex)"
);
assert!(mutex.is_poisoned(), "the mutex should now be poisoned");
let camera = Camera::new(0.35, 0.28, 5.0, 18.0);
let planes = StandardGemCuts::standard_round_brilliant();
let material = GemMaterial::by_name("Spinel").expect("Spinel is a built-in cubic material");
let scene = GpuSceneRef {
camera: &camera,
width: 4,
height: 4,
planes: &planes,
facet_finishes: &[],
material: &material,
max_bounces: 2,
environment: LightingPreset::Daylight.studio(1.0, 0.4, 0.35),
};
let mut accum = vec![Vec3::ZERO; 16];
let never_cancel = AtomicBool::new(false);
let outcome = backend.try_accumulate_cancellable(&scene, 0, 1, &mut accum, &never_cancel);
assert_eq!(
outcome,
GpuAccumulate::Declined,
"a poisoned renderer mutex must decline, not dispatch into possibly-corrupt state"
);
assert!(
backend.lost.load(std::sync::atomic::Ordering::Relaxed),
"a poisoned renderer mutex must set `lost`, like DeviceLost does"
);
let outcome2 = backend.try_accumulate_cancellable(&scene, 0, 1, &mut accum, &never_cancel);
assert_eq!(outcome2, GpuAccumulate::Declined);
}
#[test]
fn viewport_frames_with_camera_changes_never_poison_the_backend() {
let Some(backend) =
acquire_or_skip("viewport_frames_with_camera_changes_never_poison_the_backend")
else {
return;
};
let planes = StandardGemCuts::standard_round_brilliant();
let material = GemMaterial::by_name("Spinel").expect("Spinel is a built-in cubic material");
let environment = LightingPreset::Daylight.studio(1.0, 0.4, 0.35);
let never_cancel = AtomicBool::new(false);
let (width, height) = (480u32, 360u32);
let spp = 2u32;
let mut accum = vec![Vec3::ZERO; (width * height) as usize];
for frame in 0..12u32 {
let yaw = (frame as f32).mul_add(0.29, 0.1);
let pitch = (frame as f32).mul_add(0.13, 0.2).clamp(-1.4, 1.4);
let camera = Camera::new(yaw, pitch, 5.0, 18.0);
let scene = GpuSceneRef {
camera: &camera,
width,
height,
planes: &planes,
facet_finishes: &[],
material: &material,
max_bounces: 4,
environment,
};
let outcome =
backend.try_accumulate_cancellable(&scene, frame * spp, spp, &mut accum, &never_cancel);
assert_eq!(
outcome,
GpuAccumulate::Done,
"frame {frame} (yaw={yaw}, pitch={pitch}) did not complete -- got {outcome:?} \
instead of Done; `backend.lost` = {}",
backend.lost.load(std::sync::atomic::Ordering::Relaxed)
);
}
assert!(
accum.iter().any(|v| v.length_squared() > 0.0),
"a lit studio-rig scene traced over 12 frames must leave SOME nonzero radiance"
);
}
const fn spinel_scene<'a>(
camera: &'a Camera,
planes: &'a [crate::geometry::GpuFacetPlane],
material: &'a GemMaterial,
width: u32,
height: u32,
) -> GpuSceneRef<'a> {
GpuSceneRef {
camera,
width,
height,
planes,
facet_finishes: &[],
material,
max_bounces: 2,
environment: LightingPreset::Daylight.studio(1.0, 0.4, 0.35),
}
}
#[test]
fn a_failure_on_a_later_turn_leaves_accum_untouched() {
let Some(backend) = acquire_or_skip("a_failure_on_a_later_turn_leaves_accum_untouched") else {
return;
};
let camera = Camera::new(0.35, 0.28, 5.0, 18.0);
let planes = StandardGemCuts::standard_round_brilliant();
let material = GemMaterial::by_name("Spinel").expect("Spinel is a built-in cubic material");
let (width, height) = (1024u32, 768u32);
let scene = spinel_scene(&camera, &planes, &material, width, height);
let mut accum = vec![Vec3::ZERO; (width * height) as usize];
let never_cancel = AtomicBool::new(false);
backend.fail_on_turn.store(2, Ordering::Relaxed);
let outcome = backend.try_accumulate_cancellable(&scene, 0, 4, &mut accum, &never_cancel);
assert_eq!(
backend.fail_on_turn.load(Ordering::Relaxed),
0,
"the request finished before its second turn, so nothing was injected -- enlarge \
the frame (outcome {outcome:?})"
);
assert_eq!(outcome, GpuAccumulate::Declined);
assert!(
accum.iter().all(|pixel| *pixel == Vec3::ZERO),
"a decline on the second turn left the first turn's samples in accum"
);
assert!(
backend.is_lost(),
"an injected DeviceLost marks the backend lost"
);
}
#[test]
fn a_lost_backend_recovers_after_the_cool_down_and_renders_again() {
let Some(backend) =
acquire_or_skip("a_lost_backend_recovers_after_the_cool_down_and_renders_again")
else {
return;
};
let camera = Camera::new(0.35, 0.28, 5.0, 18.0);
let planes = StandardGemCuts::standard_round_brilliant();
let material = GemMaterial::by_name("Spinel").expect("Spinel is a built-in cubic material");
let scene = spinel_scene(&camera, &planes, &material, 64, 64);
let mut accum = vec![Vec3::ZERO; 64 * 64];
let never_cancel = AtomicBool::new(false);
backend.fail_on_turn.store(1, Ordering::Relaxed);
assert_eq!(
backend.try_accumulate_cancellable(&scene, 0, 1, &mut accum, &never_cancel),
GpuAccumulate::Declined
);
assert!(backend.is_lost());
assert!(
backend.adapter_label().is_none(),
"a lost backend must not claim an adapter"
);
assert_eq!(
backend.try_accumulate_cancellable(&scene, 0, 1, &mut accum, &never_cancel),
GpuAccumulate::Declined,
"inside the cool-down the backend must keep declining"
);
assert!(backend.is_lost());
let Some(aged) = Instant::now().checked_sub(COOL_DOWN * 2) else {
println!("skipping the recovery half: the monotonic clock is younger than the cool-down");
return;
};
backend
.recovery
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.record_loss(aged);
assert_eq!(
backend.try_accumulate_cancellable(&scene, 0, 1, &mut accum, &never_cancel),
GpuAccumulate::Done,
"after the cool-down the next request must re-acquire the device and render"
);
assert!(!backend.is_lost());
assert!(backend.adapter_label().is_some());
assert!(
accum.iter().any(|pixel| pixel.length_squared() > 0.0),
"the recovered render must have produced radiance"
);
}
#[test]
fn a_disabled_backend_declines_and_is_never_lost() {
let backend = GpuBackend::disabled();
let camera = Camera::new(0.35, 0.28, 5.0, 18.0);
let planes = StandardGemCuts::standard_round_brilliant();
let material = GemMaterial::by_name("Spinel").expect("Spinel is a built-in cubic material");
let scene = spinel_scene(&camera, &planes, &material, 8, 8);
let mut accum = vec![Vec3::ZERO; 64];
let never_cancel = AtomicBool::new(false);
assert_eq!(
backend.try_accumulate_cancellable(&scene, 0, 1, &mut accum, &never_cancel),
GpuAccumulate::Declined
);
assert!(accum.iter().all(|pixel| *pixel == Vec3::ZERO));
assert!(!backend.is_lost());
assert!(
backend.try_recover(),
"nothing is lost, so nothing to recover"
);
assert!(backend.adapter_label().is_none());
}
#[test]
fn recovery_waits_out_the_cool_down() {
let start = Instant::now();
let mut policy = RecoveryPolicy::new();
assert!(!policy.may_attempt(start), "no loss on record");
policy.record_loss(start);
assert!(!policy.may_attempt(start + COOL_DOWN.saturating_sub(Duration::from_secs(1))));
assert!(policy.may_attempt(start + COOL_DOWN));
policy.record_recovered();
assert!(
!policy.may_attempt(start + COOL_DOWN * 10),
"a recovered device has no pending attempt"
);
}
#[test]
fn a_failed_attempt_restarts_the_cool_down() {
let start = Instant::now();
let mut policy = RecoveryPolicy::new();
policy.record_loss(start);
let attempt = start + COOL_DOWN;
assert!(policy.may_attempt(attempt));
policy.record_attempt(attempt);
assert!(!policy.may_attempt(attempt + COOL_DOWN.saturating_sub(Duration::from_secs(1))));
assert!(policy.may_attempt(attempt + COOL_DOWN));
}
#[test]
fn at_most_six_attempts_start_per_hour() {
assert_eq!(MAX_ATTEMPTS_PER_WINDOW, 6);
assert_eq!(WINDOW, Duration::from_hours(1));
let start = Instant::now();
let mut policy = RecoveryPolicy::new();
policy.record_loss(start);
let mut now = start;
for _ in 0..MAX_ATTEMPTS_PER_WINDOW {
now += COOL_DOWN;
assert!(policy.may_attempt(now));
policy.record_attempt(now);
}
now += COOL_DOWN;
assert!(
!policy.may_attempt(now),
"a seventh attempt inside the hour must be refused even after the cool-down"
);
let first_attempt = start + COOL_DOWN;
assert!(!policy.may_attempt(first_attempt + WINDOW.saturating_sub(Duration::from_secs(1))));
assert!(policy.may_attempt(first_attempt + WINDOW));
}
struct BatchSpec {
camera: Camera,
material: GemMaterial,
width: u32,
height: u32,
first_sample: u32,
samples: u32,
}
impl BatchSpec {
fn new(
material: GemMaterial,
yaw: f32,
(width, height): (u32, u32),
first_sample: u32,
samples: u32,
) -> Self {
Self {
camera: Camera::new(yaw, 0.28, 5.0, 18.0),
material,
width,
height,
first_sample,
samples,
}
}
const fn scene<'a>(&'a self, planes: &'a [crate::geometry::GpuFacetPlane]) -> GpuSceneRef<'a> {
spinel_scene(
&self.camera,
planes,
&self.material,
self.width,
self.height,
)
}
const fn pixels(&self) -> usize {
self.width as usize * self.height as usize
}
}
fn three_different_pictures() -> Vec<BatchSpec> {
vec![
BatchSpec::new(
GemMaterial::by_name("Spinel").expect("built-in"),
0.35,
(64, 48),
0,
3,
),
BatchSpec::new(GemMaterial::diamond(), 0.9, (40, 40), 5, 2),
BatchSpec::new(
GemMaterial::by_name("Ruby").expect("built-in"),
1.7,
(56, 32),
0,
4,
),
]
}
pub(super) fn bits(pixels: &[Vec3]) -> Vec<[u32; 3]> {
pixels
.iter()
.map(|p| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()])
.collect()
}
fn traced_alone(
backend: &GpuBackend,
specs: &[BatchSpec],
planes: &[crate::geometry::GpuFacetPlane],
) -> Vec<Vec<Vec3>> {
let never_cancel = AtomicBool::new(false);
specs
.iter()
.map(|spec| {
let mut accum = vec![Vec3::ZERO; spec.pixels()];
let outcome = backend.try_accumulate_cancellable(
&spec.scene(planes),
spec.first_sample,
spec.samples,
&mut accum,
&never_cancel,
);
assert_eq!(
outcome,
GpuAccumulate::Done,
"reference render must succeed"
);
accum
})
.collect()
}
fn traced_as_batch(
backend: &GpuBackend,
specs: &[BatchSpec],
planes: &[crate::geometry::GpuFacetPlane],
cancel: &AtomicBool,
mut on_done: impl FnMut(usize, GpuAccumulate),
) -> (Vec<Vec<Vec3>>, Vec<Option<GpuAccumulate>>) {
let mut outs: Vec<Vec<Vec3>> = specs
.iter()
.map(|spec| vec![Vec3::ZERO; spec.pixels()])
.collect();
let mut outcomes = vec![None; specs.len()];
{
let mut items: Vec<GpuBatchItem<'_>> = specs
.iter()
.zip(outs.iter_mut())
.map(|(spec, out)| GpuBatchItem {
scene: spec.scene(planes),
tools: &[],
first_sample: spec.first_sample,
samples: spec.samples,
out: out.as_mut_slice(),
})
.collect();
backend.try_accumulate_batch_cancellable(&mut items, cancel, &mut |index, outcome| {
assert!(
outcomes[index].is_none(),
"picture {index} reported more than once"
);
outcomes[index] = Some(outcome);
on_done(index, outcome);
});
}
(outs, outcomes)
}
#[test]
fn a_batch_of_different_scenes_equals_each_scene_alone() {
let Some(backend) = acquire_or_skip("a_batch_of_different_scenes_equals_each_scene_alone")
else {
return;
};
let planes = StandardGemCuts::standard_round_brilliant();
let specs = three_different_pictures();
let expected = traced_alone(&backend, &specs, &planes);
let never_cancel = AtomicBool::new(false);
let mut order = Vec::new();
let (outs, outcomes) = traced_as_batch(&backend, &specs, &planes, &never_cancel, |index, _| {
order.push(index);
});
assert_eq!(order, vec![0, 1, 2], "pictures are reported in input order");
for (index, outcome) in outcomes.iter().enumerate() {
assert_eq!(*outcome, Some(GpuAccumulate::Done), "picture {index}");
assert_eq!(
bits(&outs[index]),
bits(&expected[index]),
"picture {index} differs from its single-request render"
);
}
assert!(
outs.iter()
.any(|out| out.iter().any(|p| p.length_squared() > 0.0)),
"the batch must have produced radiance"
);
}
#[test]
fn a_batch_interleaved_with_a_concurrent_single_request_stays_bit_identical() {
let Some(backend) =
acquire_or_skip("a_batch_interleaved_with_a_concurrent_single_request_stays_bit_identical")
else {
return;
};
let planes = StandardGemCuts::standard_round_brilliant();
let specs = vec![
BatchSpec::new(
GemMaterial::by_name("Spinel").expect("built-in"),
0.2,
(512, 384),
0,
4,
),
BatchSpec::new(
GemMaterial::by_name("Ruby").expect("built-in"),
1.1,
(400, 300),
3,
4,
),
BatchSpec::new(GemMaterial::diamond(), 2.0, (320, 320), 0, 4),
];
let single = BatchSpec::new(
GemMaterial::by_name("Spinel").expect("built-in"),
0.7,
(384, 256),
2,
4,
);
let expected_batch = traced_alone(&backend, &specs, &planes);
let expected_single = traced_alone(&backend, std::slice::from_ref(&single), &planes);
let never_cancel = AtomicBool::new(false);
thread::scope(|scope| {
let single_thread = scope.spawn(|| {
(0..3)
.map(|_| {
let mut accum = vec![Vec3::ZERO; single.pixels()];
let outcome = backend.try_accumulate_cancellable(
&single.scene(&planes),
single.first_sample,
single.samples,
&mut accum,
&never_cancel,
);
(outcome, accum)
})
.collect::<Vec<_>>()
});
let (outs, outcomes) = traced_as_batch(&backend, &specs, &planes, &never_cancel, |_, _| {});
for (index, outcome) in outcomes.iter().enumerate() {
assert_eq!(*outcome, Some(GpuAccumulate::Done), "batch picture {index}");
assert_eq!(
bits(&outs[index]),
bits(&expected_batch[index]),
"batch picture {index} changed under contention"
);
}
for (outcome, accum) in single_thread.join().expect("single-request thread") {
assert_eq!(outcome, GpuAccumulate::Done);
assert_eq!(
bits(&accum),
bits(&expected_single[0]),
"the concurrent single request changed under contention"
);
}
});
}
#[test]
fn cancelling_mid_batch_reports_cancelled_and_leaves_the_renderer_reusable() {
let Some(backend) =
acquire_or_skip("cancelling_mid_batch_reports_cancelled_and_leaves_the_renderer_reusable")
else {
return;
};
let planes = StandardGemCuts::standard_round_brilliant();
let specs = three_different_pictures();
let expected = traced_alone(&backend, &specs, &planes);
let cancel = AtomicBool::new(false);
let (outs, outcomes) = traced_as_batch(&backend, &specs, &planes, &cancel, |index, outcome| {
if index == 0 && outcome == GpuAccumulate::Done {
cancel.store(true, Ordering::Relaxed);
}
});
assert_eq!(outcomes[0], Some(GpuAccumulate::Done));
assert_eq!(bits(&outs[0]), bits(&expected[0]));
assert!(matches!(
outcomes[1],
Some(GpuAccumulate::Done | GpuAccumulate::Cancelled)
));
assert_eq!(outcomes[2], Some(GpuAccumulate::Cancelled));
for (index, outcome) in outcomes.iter().enumerate() {
match outcome {
Some(GpuAccumulate::Done) => assert_eq!(bits(&outs[index]), bits(&expected[index])),
Some(GpuAccumulate::Cancelled) => assert!(
outs[index].iter().all(|p| *p == Vec3::ZERO),
"cancelled picture {index} must leave its output untouched"
),
other => panic!("picture {index}: unexpected outcome {other:?}"),
}
}
assert!(!backend.is_lost(), "a cancel is not a device loss");
let never_cancel = AtomicBool::new(false);
let (outs, outcomes) = traced_as_batch(&backend, &specs, &planes, &never_cancel, |_, _| {});
for index in 0..specs.len() {
assert_eq!(outcomes[index], Some(GpuAccumulate::Done), "rerun {index}");
assert_eq!(bits(&outs[index]), bits(&expected[index]), "rerun {index}");
}
}
#[test]
fn a_device_loss_during_a_batch_declines_the_rest_and_the_backend_recovers() {
let Some(backend) =
acquire_or_skip("a_device_loss_during_a_batch_declines_the_rest_and_the_backend_recovers")
else {
return;
};
let planes = StandardGemCuts::standard_round_brilliant();
let specs = three_different_pictures();
let expected = traced_alone(&backend, &specs, &planes);
let never_cancel = AtomicBool::new(false);
backend.fail_on_turn.store(1, Ordering::Relaxed);
let (outs, outcomes) = traced_as_batch(&backend, &specs, &planes, &never_cancel, |_, _| {});
assert!(
outcomes
.iter()
.all(|outcome| *outcome == Some(GpuAccumulate::Declined)),
"{outcomes:?}"
);
assert!(
outs.iter().all(|out| out.iter().all(|p| *p == Vec3::ZERO)),
"a declined picture must leave its output untouched"
);
assert!(backend.is_lost());
let Some(aged) = Instant::now().checked_sub(COOL_DOWN * 2) else {
println!("skipping the recovery half: the monotonic clock is younger than the cool-down");
return;
};
backend
.recovery
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.record_loss(aged);
let (outs, outcomes) = traced_as_batch(&backend, &specs, &planes, &never_cancel, |_, _| {});
assert!(!backend.is_lost());
for index in 0..specs.len() {
assert_eq!(
outcomes[index],
Some(GpuAccumulate::Done),
"recovered {index}"
);
assert_eq!(
bits(&outs[index]),
bits(&expected[index]),
"recovered {index}"
);
}
}
#[test]
fn a_disabled_backend_declines_every_batch_item() {
let backend = GpuBackend::disabled();
let planes = StandardGemCuts::standard_round_brilliant();
let specs = three_different_pictures();
let never_cancel = AtomicBool::new(false);
let (outs, outcomes) = traced_as_batch(&backend, &specs, &planes, &never_cancel, |_, _| {});
assert!(
outcomes
.iter()
.all(|outcome| *outcome == Some(GpuAccumulate::Declined))
);
assert!(outs.iter().all(|out| out.iter().all(|p| *p == Vec3::ZERO)));
}
#[test]
fn has_waiters_behind_sees_only_later_tickets() {
let turnstile = Turnstile::new();
let first = turnstile.take_ticket();
assert!(!turnstile.has_waiters_behind(first));
let second = turnstile.take_ticket();
assert!(turnstile.has_waiters_behind(first));
assert!(!turnstile.has_waiters_behind(second));
}