Files
foxhunt/crates/ml/tests/sp20_stats_compute_test.rs
jgrusewski 4e21c38b85 fixup(sp20): Phase 1.1 K=3 → K=2 retarget (production aux head is K=2)
The Phase 1.1 sp20_stats_compute kernel (de922c6a4) assumed the SP14-C aux
head emits 3-class logits {short, hold, long} with baseline 1/3. This was
an error in the SP19+20 spec — production aux head emits K=2 logits
{down, up} per gpu_aux_heads.rs:61 (AUX_NEXT_BAR_K = 2, established by
SP13 B1.1a). Wiring K=2 production aux into the K=3 kernel = OOB reads +
corrupt stats.

Retargets the kernel + launcher + tests to K=2 atomically per
feedback_no_partial_refactor:

- Kernel: SP20_K_CLASSES 3→2; SP20_UNIFORM_K3 0.333…→SP20_UNIFORM_K2 0.5f;
  Pass A reads 2 logits (was 3); aux_conf range [0, 1/2] (was [0, 2/3]).
- Launcher: AUX_K_CLASSES 3→2; renamed unit test
  aux_k_classes_is_three → aux_k_classes_matches_production_aux_head.
- Tests: rewrote CPU oracle for K=2 input shape and 0.5 baseline; updated
  expected p50/std ranges; redesigned heterogeneous-distribution test to
  use ramped (not lockstep) clusters — K=2's saturated softmax in the hot
  half collapses every row to bin 255, triggering the
  pearl_sp4_histogram_warp_tile_undercount trap; ramped clusters distribute
  bin indices across each warp's 32 lanes so the histogram path matches
  the CPU oracle within bin_width tolerance.

Phase 1.2 (sp20_emas_compute) and Phase 1.3 (sp20_controllers_compute)
consume the scalar [p50, std] outputs and do NOT carry the K dimension.
Both regression suites verified passing unmodified:
- sp20_emas_compute_test: 4 GPU + 1 unit, all pass.
- sp20_controllers_compute_test: 7 GPU, all pass.

Verification (RTX 3050 Ti, sm_86):
- sp20_stats_compute_test: 4 GPU oracle + 4 launcher unit, all pass.
- cargo check -p ml --features cuda: clean (pre-existing warnings only).

Spec + plan amended at top with "AMENDED 2026-05-09" notes; audit doc
Phase 1.1 entry has a "K=2 fixup" subsection documenting the change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:46:17 +02:00

326 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! SP20 Phase 1.1 (2026-05-09) — `sp20_stats_compute_kernel` GPU oracle tests.
//!
//! Verifies the fused stats producer emits
//! `[aux_conf_p50, aux_conf_std]` consistent with the closed-form CPU
//! oracle for two characteristic distributions of `aux_logits [B, K=2]`:
//!
//! 1. **Uniform**: `aux_logits = 0` everywhere → softmax = [0.5, 0.5]
//! → `aux_conf = max - 0.5 = 0` → p50 ≈ 0, std ≈ 0.
//! 2. **Concentrated**: `aux_logits[*, 0] = 10`, rest = 0 → softmax
//! ≈ [1, 0] (one-hot) → `aux_conf ≈ 0.5` → p50 ≈ 0.5, std ≈ 0.
//!
//! Tests also cover a heterogeneous distribution with non-trivial p50
//! and std so the kernel is exercised on both the histogram path and
//! the running-sum/sum-of-squares paths together.
//!
//! K=2 fixup (2026-05-09, post Phase 1.3): the kernel originally assumed
//! K=3 `{down, flat, up}` but the production aux head emits K=2
//! `{down, up}` per `gpu_aux_heads.rs:61` (`AUX_NEXT_BAR_K = 2`,
//! established by SP13 B1.1a). This test file was rewritten to exercise
//! the K=2 contract; the CPU oracle, expected p50/std ranges, and the
//! degenerate-uniform branch all reflect the K=2 baseline `1/2 = 0.5`
//! and the resulting `aux_conf ∈ [0, 0.5]` range.
//!
//! Per `feedback_no_atomicadd` the kernel uses block tree-reduce + per-
//! warp tile binning. Per `feedback_no_htod_htoh_only_mapped_pinned`
//! all CPU↔GPU buffers are `MappedF32Buffer`.
//!
//! Run on a GPU host:
//!
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
//! cargo test -p ml --test sp20_stats_compute_test --features cuda \
//! -- --ignored --nocapture
#![allow(clippy::tests_outside_test_module)]
#[cfg(feature = "cuda")]
#[allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
mod gpu {
use std::sync::Arc;
use cudarc::driver::{CudaContext, CudaFunction, CudaStream};
use ml::cuda_pipeline::mapped_pinned::MappedF32Buffer;
use ml::cuda_pipeline::sp20_stats_compute::{
launch_sp20_stats_compute, AUX_K_CLASSES,
};
const SP20_STATS_COMPUTE_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/sp20_stats_compute_kernel.cubin"));
fn make_test_stream() -> Arc<CudaStream> {
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
ctx.default_stream()
}
fn load_sp20_stats_compute(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP20_STATS_COMPUTE_CUBIN.to_vec())
.expect("load sp20_stats_compute_kernel cubin");
module
.load_function("sp20_stats_compute_kernel")
.expect("load sp20_stats_compute_kernel function")
}
/// CPU oracle: per-row K=2 softmax → max → minus 1/2, then median + std.
/// Mirrors the kernel's math exactly so the tolerances below pin
/// fp32 rounding only, not algorithmic deltas.
fn cpu_oracle(aux_logits: &[f32], b: usize) -> (f32, f32) {
assert_eq!(aux_logits.len(), b * AUX_K_CLASSES);
let one_half = 0.5_f32;
let mut conf: Vec<f32> = (0..b)
.map(|i| {
let off = i * AUX_K_CLASSES;
let l0 = aux_logits[off];
let l1 = aux_logits[off + 1];
let m = l0.max(l1);
let e0 = (l0 - m).exp();
let e1 = (l1 - m).exp();
let s = e0 + e1;
let max_sm = (e0 / s).max(e1 / s);
max_sm - one_half
})
.collect();
// Sort for the median: matches the histogram-bin upper-edge
// semantics of the kernel (the sorted-array median is what the
// histogram converges to as bin_width → 0; for our two
// characteristic tests below the values are concentrated at
// 0 or 0.5 which the histogram resolves exactly).
conf.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median_idx = (b + 1) / 2 - 1; // 0-based ⌈B/2⌉ - 1
let median = conf[median_idx];
let mean: f32 = conf.iter().sum::<f32>() / (b as f32);
let var: f32 = conf
.iter()
.map(|x| (x - mean).powi(2))
.sum::<f32>() / (b as f32);
let std = var.max(0.0).sqrt();
(median, std)
}
fn run_kernel(b: usize, aux_logits: &[f32]) -> (f32, f32) {
let stream = make_test_stream();
let kernel = load_sp20_stats_compute(&stream);
let aux_buf = unsafe { MappedF32Buffer::new(aux_logits.len()) }
.expect("alloc aux_logits buf");
aux_buf.write_from_slice(aux_logits);
let out_buf = unsafe { MappedF32Buffer::new(2) }
.expect("alloc out buf");
out_buf.write_from_slice(&[0.0, 0.0]);
unsafe {
launch_sp20_stats_compute(
&stream,
&kernel,
aux_buf.dev_ptr,
out_buf.dev_ptr,
b as i32,
)
.expect("launch sp20_stats_compute_kernel");
}
stream.synchronize().expect("sync after sp20_stats_compute_kernel");
let out = out_buf.read_all();
(out[0], out[1])
}
/// Test 1 — uniform-logit distribution.
///
/// `aux_logits = 0` everywhere ⇒ softmax = [0.5, 0.5] for every
/// row ⇒ `aux_conf = 0.5 - 0.5 = 0` for every row.
/// Expected: p50 ≈ 0, std ≈ 0. The kernel's degenerate-distribution
/// branch (step_max == 0) writes both outputs to 0 directly.
#[test]
#[ignore = "requires GPU"]
fn uniform_logits_emit_zero_stats() {
const B: usize = 128;
let aux_logits = vec![0.0_f32; B * AUX_K_CLASSES];
let (p50, std) = run_kernel(B, &aux_logits);
// Both outputs MUST be exactly zero — the kernel's
// step_max == 0 branch writes 0 directly.
assert!(
p50.abs() < 1e-6,
"uniform: p50 expected 0, got {p50}",
);
assert!(
std.abs() < 1e-6,
"uniform: std expected 0, got {std}",
);
}
/// Test 2 — concentrated-logit distribution (varied confidence).
///
/// Per-row class-0 logit varies linearly from 0 to 3 across the
/// batch, with class 1 held at 0. softmax(class 0) varies from 0.5
/// (logit 0, uniform) to ≈ 0.953 (logit 3), so `aux_conf` varies
/// from 0 to ≈ 0.453 — a 0.45 range spread across well over half of
/// the 256 histogram bins (per
/// `pearl_sp4_histogram_warp_tile_undercount`: lockstep-uniform
/// inputs cause warp-tile collisions in the non-atomic per-warp
/// histogram tiles, so test data MUST vary per row across many
/// bins, not just within one bin's saturating-softmax tail).
///
/// Logits are kept under the saturating-softmax regime (max < 4)
/// because the softmax tail compresses 0.95 → 1.0 confidences
/// into a range smaller than one bin_width (≈ 0.4% of step_max),
/// re-creating the warp-tile collision pattern this test
/// explicitly avoids.
///
/// Expected: p50 is the median of a monotonically-increasing
/// aux_conf vector — i.e., the value at the lower midpoint
/// (`(B+1)/2 - 1` 0-based). The CPU oracle computes it exactly;
/// the GPU kernel matches to within a couple of bin_widths.
#[test]
#[ignore = "requires GPU"]
fn varied_confidence_logits_match_cpu_oracle() {
const B: usize = 128;
let mut aux_logits = vec![0.0_f32; B * AUX_K_CLASSES];
for i in 0..B {
// Linear ramp 0 → 3 on class 0 — confidence ramps from
// uniform (0.5) to a non-saturating peak (≈ 0.953).
let frac = (i as f32) / ((B - 1) as f32);
aux_logits[i * AUX_K_CLASSES] = 3.0 * frac;
}
let (p50_gpu, std_gpu) = run_kernel(B, &aux_logits);
let (p50_cpu, std_cpu) = cpu_oracle(&aux_logits, B);
// The GPU histogram bin_width ≈ step_max / 256 ≈ 0.45 / 256 ≈
// 0.0018; agreement to within a few bin_widths is the design
// tolerance for the histogram path.
let p50_tol = 0.02_f32;
assert!(
(p50_gpu - p50_cpu).abs() < p50_tol,
"concentrated: p50_gpu={p50_gpu} vs cpu_oracle={p50_cpu} (diff={:.3e}, tol={p50_tol})",
(p50_gpu - p50_cpu).abs(),
);
// CPU oracle sanity: with logits ramping 0 → 3, the median
// aux_conf is the row at logit ≈ 1.5 → softmax ≈ 0.818 →
// aux_conf ≈ 0.318. We pin it to (0.10, 0.40) to catch
// contract drift while tolerating fp32 / softmax math.
assert!(
p50_cpu > 0.10_f32 && p50_cpu < 0.40_f32,
"CPU oracle: concentrated p50 should land in (0.10, 0.40), got {p50_cpu}",
);
// std non-trivial; CPU and GPU within fp32 noise.
let std_tol = 5e-3_f32;
assert!(
(std_gpu - std_cpu).abs() < std_tol,
"concentrated: std_gpu={std_gpu} vs cpu_oracle={std_cpu} (diff={:.3e}, tol={std_tol})",
(std_gpu - std_cpu).abs(),
);
// CPU oracle std: the aux_conf vector spans ≈ 0.45 with a
// monotonic-but-saturating ramp, so std lands roughly in the
// 0.05-0.20 range for a sigmoid-shaped spread.
assert!(
std_cpu > 0.05_f32 && std_cpu < 0.20_f32,
"CPU oracle: concentrated std should land in (0.05, 0.20), got {std_cpu}",
);
}
/// Test 3 — heterogeneous distribution exercising both p50 and std.
///
/// Two clusters with ramped (not lockstep) aux_conf within each
/// cluster:
/// - Lower half (rows 0..64): logit-0 ramps 0.0 → 1.0, logit-1
/// held at 0 ⇒ aux_conf ramps 0 → sigmoid(1.0)-0.5 ≈ 0.231
/// - Upper half (rows 64..128): logit-0 ramps 2.5 → 4.0, logit-1
/// held at 0 ⇒ aux_conf ramps sigmoid(2.5)-0.5 ≈ 0.424 to
/// sigmoid(4.0)-0.5 ≈ 0.491
///
/// The two ramps are well-separated so the median of the sorted
/// aux_conf vector lands at the top of the lower cluster
/// (≈ 0.231). Std is dominated by the inter-cluster gap and lands
/// near 0.15.
///
/// Per-row ramping (rather than per-row jitter on a constant
/// cluster center) ensures each warp's 32 lanes compute distinct
/// bin indices, avoiding the
/// `pearl_sp4_histogram_warp_tile_undercount` lockstep-uniform
/// trap where 32 lanes racing on the same non-atomic tile slot
/// undercount the histogram. Production aux logits are naturally
/// distributed across bins, so spread test data IS the realistic-
/// input model.
#[test]
#[ignore = "requires GPU"]
fn heterogeneous_logits_match_cpu_oracle() {
const B: usize = 128;
let mut aux_logits = vec![0.0_f32; B * AUX_K_CLASSES];
// Lower cluster: l0 ramps 0 → 1.0; aux_conf 0 → 0.231.
for i in 0..(B / 2) {
let frac = (i as f32) / ((B / 2 - 1) as f32);
aux_logits[i * AUX_K_CLASSES + 0] = 1.0 * frac;
// l1 = 0
}
// Upper cluster: l0 ramps 2.5 → 4.0; aux_conf 0.424 → 0.491.
for i in (B / 2)..B {
let k = i - B / 2;
let frac = (k as f32) / ((B / 2 - 1) as f32);
aux_logits[i * AUX_K_CLASSES + 0] = 2.5 + 1.5 * frac;
// l1 = 0
}
let (p50_gpu, std_gpu) = run_kernel(B, &aux_logits);
let (p50_cpu, std_cpu) = cpu_oracle(&aux_logits, B);
// p50 sanity: the sorted aux_conf vector has 64 lower-cluster
// values (0 → 0.231) then 64 upper-cluster values (0.424 →
// 0.491). ⌈B/2⌉-th element (1-based) = 64th = the largest
// lower-cluster value ≈ 0.231. Both CPU and GPU should agree
// within a few bin_widths (bin_width = step_max/256 ≈
// 0.491/256 ≈ 0.002).
let p50_tol = 0.02_f32;
assert!(
(p50_gpu - p50_cpu).abs() < p50_tol,
"het: p50_gpu={p50_gpu} vs cpu_oracle={p50_cpu} (diff={:.3e}, tol={p50_tol})",
(p50_gpu - p50_cpu).abs(),
);
// CPU oracle sanity: median should be at the top of the lower
// cluster, ≈ 0.231. Pin to (0.18, 0.27) to catch contract
// drift while tolerating fp32 / sigmoid math.
assert!(
p50_cpu > 0.18_f32 && p50_cpu < 0.27_f32,
"CPU oracle: het p50 should land in (0.18, 0.27), got {p50_cpu}",
);
// std sanity: with two well-separated ramped clusters (lower
// 0..0.231, upper 0.424..0.491), the variance is dominated
// by the inter-cluster gap. Jitter perturbs this slightly;
// allow ±5e-3 between the GPU result and the CPU oracle.
let std_tol = 5e-3_f32;
assert!(
(std_gpu - std_cpu).abs() < std_tol,
"het: std_gpu={std_gpu} vs cpu_oracle={std_cpu} (diff={:.3e}, tol={std_tol})",
(std_gpu - std_cpu).abs(),
);
// CPU oracle std lands roughly in the 0.120.20 band given
// the bimodal ramped layout.
assert!(
std_cpu > 0.12_f32 && std_cpu < 0.20_f32,
"CPU oracle: het std should land in (0.12, 0.20), got {std_cpu}",
);
}
/// Test 4 — empty-batch degenerate guard.
///
/// `B = 0` ⇒ the kernel's degenerate branch writes `[0, 0]` and
/// returns without launching the full pipeline. This is the
/// trainer's pre-warmup path (no aux logits yet).
#[test]
#[ignore = "requires GPU"]
fn empty_batch_writes_zero_stats() {
// We still need a non-zero allocation for the device pointer to
// be valid; the kernel will not read it because B == 0.
let aux_logits = vec![0.0_f32; AUX_K_CLASSES];
let (p50, std) = run_kernel(0, &aux_logits);
assert!(p50.abs() < 1e-6, "empty: p50 expected 0, got {p50}");
assert!(std.abs() < 1e-6, "empty: std expected 0, got {std}");
}
}