From a0c8b8a606b45af37e082bea81919dcfa26c2cfe Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 30 Jun 2026 04:58:00 -0400 Subject: [PATCH] test(mc-ai): GPU parity tests skip on software rasterizer, not just absent GPU gpu_rollout_parity asserts the WGSL kernel matches the CPU reference within 1e-4 (>=98% agreement). It was designed to skip when no GPU is present, but a software rasterizer (lavapipe/llvmpipe, DeviceType::Cpu) is detected as a GPU adapter, so the tests RAN and failed: lavapipe's transcendental rounding diverges from the CPU ref (~0.01 on a few entries -> 81% agreement). The file header itself notes WGSL doesn't guarantee identical transcendental rounding across backends, so parity vs an arbitrary software rasterizer isn't a meaningful contract. Fix: GpuContext exposes is_hardware (adapter device_type != Cpu). The 4 parity-vs-CPU tests skip on software adapters via a shared hardware_ctx() helper; they still run on real GPU hardware (apricot). Production keeps the software fallback for GPU-path regression coverage. The GPU-internal determinism test is unchanged (holds on software). Verified on the DO CPU fleet (lavapipe Vulkan): cargo nextest run -p mc-ai -> 410 passed, 0 failed (was 3 failed). Workspace otherwise 2919/2919 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/simulator/crates/mc-ai/src/gpu/inner.rs | 12 ++++++- .../crates/mc-ai/tests/gpu_rollout_parity.rs | 32 ++++++++++++++----- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/simulator/crates/mc-ai/src/gpu/inner.rs b/src/simulator/crates/mc-ai/src/gpu/inner.rs index 0126890d..933ca9cc 100644 --- a/src/simulator/crates/mc-ai/src/gpu/inner.rs +++ b/src/simulator/crates/mc-ai/src/gpu/inner.rs @@ -139,6 +139,12 @@ pub struct GpuContext { bind_group_layout: wgpu::BindGroupLayout, /// Backend string for diagnostics (`"Vulkan"`, `"Metal"`, `"Dx12"`, `"Gl"`). pub backend: String, + /// `true` only for a real hardware GPU adapter. A software rasterizer + /// (llvmpipe / lavapipe / WARP) reports `DeviceType::Cpu`; it still runs the + /// GPU *path* for regression coverage, but its transcendental rounding + /// diverges from the CPU reference beyond the 1e-4 parity bound — so the + /// GPU↔CPU parity tests must skip on it and run only on real hardware. + pub is_hardware: bool, /// Persistent input pod buffer (MAX_BATCH * sizeof(AbstractRolloutState)). buf_pods: wgpu::Buffer, /// Persistent input priors buffer (MAX_BATCH * sizeof(BatchPriors)). @@ -261,7 +267,10 @@ impl GpuContext { eprintln!("[mc-ai gpu] picked: backend={:?} device_type={:?} name={:?}", info.backend, info.device_type, info.name); } - let backend = format!("{:?}", adapter.get_info().backend); + let adapter_info = adapter.get_info(); + let backend = format!("{:?}", adapter_info.backend); + // A software rasterizer reports DeviceType::Cpu — see `is_hardware`. + let is_hardware = !matches!(adapter_info.device_type, wgpu::DeviceType::Cpu); // Ask for limits that are AT MOST what this adapter can supply — asking // for `Limits::default()` (which targets a discrete GPU) causes @@ -376,6 +385,7 @@ impl GpuContext { pipeline, bind_group_layout, backend, + is_hardware, buf_pods, buf_priors, buf_scores, diff --git a/src/simulator/crates/mc-ai/tests/gpu_rollout_parity.rs b/src/simulator/crates/mc-ai/tests/gpu_rollout_parity.rs index 6534effe..3439eb51 100644 --- a/src/simulator/crates/mc-ai/tests/gpu_rollout_parity.rs +++ b/src/simulator/crates/mc-ai/tests/gpu_rollout_parity.rs @@ -48,10 +48,29 @@ const TOLERANCE: f32 = 1e-4; const MIN_AGREEMENT_FRACTION: f32 = 0.98; /// Core parity test — small batch size that fits in a single workgroup (64). +/// Parity is only meaningful against real GPU hardware: a software rasterizer +/// (lavapipe / llvmpipe / WARP, reported as `DeviceType::Cpu`) runs the GPU +/// path but rounds transcendentals differently from the CPU reference, drifting +/// past the 1e-4 bound (the file header notes WGSL doesn't guarantee identical +/// transcendental rounding across backends). Skip on software; run on hardware. +fn hardware_ctx(test: &str) -> Option<&'static GpuContext> { + let Some(ctx) = GpuContext::shared() else { + eprintln!("[parity] no GPU adapter — skipping {test}"); + return None; + }; + if !ctx.is_hardware { + eprintln!( + "[parity] software adapter ({}) — skipping {test} (parity is hardware-GPU only)", + ctx.backend + ); + return None; + } + Some(ctx) +} + #[test] fn gpu_rollout_parity_small_batch() { - let Some(ctx) = GpuContext::shared() else { - eprintln!("[parity] no GPU adapter — skipping gpu_rollout_parity_small_batch"); + let Some(ctx) = hardware_ctx("gpu_rollout_parity_small_batch") else { return; }; @@ -76,8 +95,7 @@ fn gpu_rollout_parity_small_batch() { /// dispatch-workgroup indexing (`gid.x`) lines up with CPU entry iteration. #[test] fn gpu_rollout_parity_multi_workgroup() { - let Some(ctx) = GpuContext::shared() else { - eprintln!("[parity] no GPU adapter — skipping gpu_rollout_parity_multi_workgroup"); + let Some(ctx) = hardware_ctx("gpu_rollout_parity_multi_workgroup") else { return; }; @@ -101,8 +119,7 @@ fn gpu_rollout_parity_multi_workgroup() { /// in entries 64..127. #[test] fn gpu_rollout_parity_partial_workgroup() { - let Some(ctx) = GpuContext::shared() else { - eprintln!("[parity] no GPU adapter — skipping gpu_rollout_parity_partial_workgroup"); + let Some(ctx) = hardware_ctx("gpu_rollout_parity_partial_workgroup") else { return; }; @@ -124,8 +141,7 @@ fn gpu_rollout_parity_partial_workgroup() { /// Sanity check that the kernel handles minimum-size batches correctly. #[test] fn gpu_rollout_parity_single_entry() { - let Some(ctx) = GpuContext::shared() else { - eprintln!("[parity] no GPU adapter — skipping gpu_rollout_parity_single_entry"); + let Some(ctx) = hardware_ctx("gpu_rollout_parity_single_entry") else { return; };