Files
foxhunt/crates/ml/tests/sp18_weight_drift_test.rs
jgrusewski aab13a83f2 feat(sp18-v2): weight-drift HEALTH_DIAG diagnostic
Add per-epoch HEALTH_DIAG line measuring how far current online params
have drifted from the best-Sharpe checkpoint. The fold-transition
restore-best fix preserves peak weights across folds, but within-fold
edge-decay still happens (Adam keeps stepping after peak Sharpe). The
drift diagnostic surfaces the trajectory so operators can correlate
Sharpe-peak decay with parameter movement.

HEALTH_DIAG line:
  HEALTH_DIAG[N]: weight_drift [norm_l2={:.6} relative={:.6} branch_max={:.6}]

Where:
- norm_l2  = ||params_flat - best_params||₂            (absolute L2)
- relative = norm_l2 / max(||best_params||₂, EPS)      (relative drift)
- branch_max — currently mirrors `relative` (single-scalar form per spec).
  Per-branch breakdown is a deferred follow-up: branch heads are
  protected from S&P (skip_start..skip_end), so trunk drift dominates
  the L2 in any case.

Cold-start: when best_params_snapshot is None (first fold or any fold
without a Sharpe improvement yet), launcher emits [0.0, 0.0] directly
(kernel skipped, mapped-pinned host slice written from CPU).
Distinguishable from "snapshot matches current" via best_epoch elsewhere.

Kernel (weight_drift_diag_kernel.cu): single block × 256 threads. Two
block tree-reductions sharing one shmem tile sequentially:
  Pass 1: ||params - best||₂² over (params - best)
  Pass 2: ||best||₂²         over best
Thread 0 finalizes sqrt + EPS-floored ratio + writes via
__threadfence_system() for PCIe-visible coherence.

Per feedback_no_atomicadd — block tree-reduce only.
Per feedback_no_htod_htoh_only_mapped_pinned — output is
MappedF32Buffer<2>; cold-start bypass uses host_slice_mut.

Wire-up (atomic):
- crates/ml/build.rs: cubin manifest entry
- crates/ml/src/cuda_pipeline/weight_drift_diag_kernel.cu (NEW)
- crates/ml/src/trainers/dqn/fused_training.rs: field + constructor
  + launch_weight_drift_diag() + read_weight_drift_diag()
- crates/ml/src/trainers/dqn/trainer/mod.rs:
  DQNTrainer::read_weight_drift_diag() public wrapper (CPU-only path
  emits zeros)
- crates/ml/src/trainers/dqn/trainer/training_loop.rs: per-epoch
  HEALTH_DIAG emit adjacent to SP17 dueling block

GPU oracle test (crates/ml/tests/sp18_weight_drift_test.rs, NEW):
4 cases, all #[ignore = "requires GPU"]:
  1. weight_drift_matches_cpu_oracle_4096 — N=4096, ε=1e-5
  2. weight_drift_is_zero_when_params_equals_best
  3. weight_drift_eps_floor_when_best_is_zero (catches NaN/Inf edge)
  4. weight_drift_n_zero_emits_zeros (degenerate guard)
All 4 pass on local RTX 3050 Ti.

Pre-commit Invariant 7: docs/dqn-wire-up-audit.md updated with kernel
algorithm, wire-up table, cross-pearl invariants, and oracle-test
inventory.

Per:
- feedback_no_atomicadd (block tree-reduce in drift kernel)
- feedback_no_htod_htoh_only_mapped_pinned (MappedF32Buffer<2>)
- feedback_wire_everything_up (kernel + launcher + emit + test atomic)
- pearl_no_host_branches_in_captured_graph (cold-path, outside graph)
- feedback_no_partial_refactor (single atomic commit)
2026-05-09 01:47:48 +02:00

239 lines
8.9 KiB
Rust
Raw Permalink 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.
//! SP18 v2 weight-drift diagnostic kernel oracle tests (2026-05-09).
//!
//! Verifies the `weight_drift_diag_kernel` produces
//! `[||params - best||₂, ||params - best||₂ / max(||best||₂, EPS)]`
//! to within ε=1e-5 of the CPU oracle on synthetic GPU buffers.
//!
//! Per `feedback_no_atomicadd` the kernel uses block tree-reduce.
//! 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 sp18_weight_drift_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, LaunchConfig, PushKernelArg};
use ml::cuda_pipeline::mapped_pinned::MappedF32Buffer;
const SP18_WEIGHT_DRIFT_DIAG_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/weight_drift_diag_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_weight_drift_diag(stream: &Arc<CudaStream>) -> CudaFunction {
let module = stream
.context()
.load_cubin(SP18_WEIGHT_DRIFT_DIAG_CUBIN.to_vec())
.expect("load weight_drift_diag_kernel cubin");
module
.load_function("weight_drift_diag_update")
.expect("load weight_drift_diag_update function")
}
/// CPU oracle: `(||params - best||₂, ||params - best||₂ / max(||best||₂, EPS))`.
/// EPS matches the kernel's `SP18_DRIFT_EPS_F` = 1e-12.
fn cpu_oracle(params: &[f32], best: &[f32]) -> (f32, f32) {
assert_eq!(params.len(), best.len());
let sumsq_diff: f32 = params
.iter()
.zip(best.iter())
.map(|(&p, &b)| (p - b).powi(2))
.sum();
let sumsq_best: f32 = best.iter().map(|x| x * x).sum();
let l2_diff = sumsq_diff.sqrt();
let norm_best = sumsq_best.sqrt();
let denom = if norm_best > 1e-12 { norm_best } else { 1e-12 };
let rel = l2_diff / denom;
(l2_diff, rel)
}
fn launch_drift(
stream: &Arc<CudaStream>,
kernel: &CudaFunction,
params: &MappedF32Buffer,
best: &MappedF32Buffer,
out: &MappedF32Buffer,
n: i32,
) {
const BLOCK_DIM: u32 = 256;
let shmem_bytes: u32 = BLOCK_DIM * std::mem::size_of::<f32>() as u32;
unsafe {
stream
.launch_builder(kernel)
.arg(&params.dev_ptr)
.arg(&best.dev_ptr)
.arg(&out.dev_ptr)
.arg(&n)
.launch(LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (BLOCK_DIM, 1, 1),
shared_mem_bytes: shmem_bytes,
})
.expect("launch weight_drift_diag_update");
}
stream.synchronize().expect("sync after weight_drift_diag");
}
/// Test A: synthetic params + best buffers with known L2 distance.
/// Uses N=4096 (representative of trainer params count) and matches
/// the CPU oracle to ε=1e-5.
#[test]
#[ignore = "requires GPU"]
fn weight_drift_matches_cpu_oracle_4096() {
let stream = make_test_stream();
let kernel = load_weight_drift_diag(&stream);
const N: usize = 4096;
// best: smooth pattern (best-Sharpe peak weights).
let best: Vec<f32> = (0..N).map(|i| (i as f32 * 0.001).sin() * 0.5).collect();
// params: best + drift (HD5-like decay).
let params: Vec<f32> = best
.iter()
.enumerate()
.map(|(i, &b)| b + 0.05 * (i as f32 * 0.013).cos())
.collect();
let (cpu_l2, cpu_rel) = cpu_oracle(&params, &best);
let params_buf = unsafe { MappedF32Buffer::new(N) }.expect("alloc params");
params_buf.write_from_slice(&params);
let best_buf = unsafe { MappedF32Buffer::new(N) }.expect("alloc best");
best_buf.write_from_slice(&best);
let out_buf = unsafe { MappedF32Buffer::new(2) }.expect("alloc out");
launch_drift(&stream, &kernel, &params_buf, &best_buf, &out_buf, N as i32);
let out = out_buf.read_all();
let gpu_l2 = out[0];
let gpu_rel = out[1];
let eps = 1e-5_f32;
// Use relative tolerance for L2 since the magnitude depends on N.
let l2_tol = (cpu_l2.abs() * 1e-4).max(eps);
assert!(
(gpu_l2 - cpu_l2).abs() < l2_tol,
"L2 mismatch: gpu={} cpu={} diff={:.3e} tol={:.3e}",
gpu_l2, cpu_l2, (gpu_l2 - cpu_l2).abs(), l2_tol,
);
assert!(
(gpu_rel - cpu_rel).abs() < eps,
"relative mismatch: gpu={} cpu={} diff={:.3e}",
gpu_rel, cpu_rel, (gpu_rel - cpu_rel).abs(),
);
}
/// Test B: params == best → drift is exactly zero.
/// This is the "snapshot just saved, no training step yet" case.
#[test]
#[ignore = "requires GPU"]
fn weight_drift_is_zero_when_params_equals_best() {
let stream = make_test_stream();
let kernel = load_weight_drift_diag(&stream);
const N: usize = 1024;
let w: Vec<f32> = (0..N).map(|i| (i as f32 * 0.01).sin()).collect();
let params_buf = unsafe { MappedF32Buffer::new(N) }.expect("alloc params");
params_buf.write_from_slice(&w);
let best_buf = unsafe { MappedF32Buffer::new(N) }.expect("alloc best");
best_buf.write_from_slice(&w);
let out_buf = unsafe { MappedF32Buffer::new(2) }.expect("alloc out");
launch_drift(&stream, &kernel, &params_buf, &best_buf, &out_buf, N as i32);
let out = out_buf.read_all();
assert!(
out[0].abs() < 1e-5,
"L2 should be 0 when params == best, got {}",
out[0],
);
assert!(
out[1].abs() < 1e-5,
"relative should be 0 when params == best, got {}",
out[1],
);
}
/// Test C: best == 0 → relative drift uses EPS floor (not divide by zero).
/// Catches the cold-start "snapshot uninitialized but kernel still runs"
/// edge case (e.g. if save_best_params was called with an all-zero
/// trainer for some test setup).
#[test]
#[ignore = "requires GPU"]
fn weight_drift_eps_floor_when_best_is_zero() {
let stream = make_test_stream();
let kernel = load_weight_drift_diag(&stream);
const N: usize = 256;
let params: Vec<f32> = vec![0.1; N];
let best: Vec<f32> = vec![0.0; N];
let params_buf = unsafe { MappedF32Buffer::new(N) }.expect("alloc params");
params_buf.write_from_slice(&params);
let best_buf = unsafe { MappedF32Buffer::new(N) }.expect("alloc best");
best_buf.write_from_slice(&best);
let out_buf = unsafe { MappedF32Buffer::new(2) }.expect("alloc out");
launch_drift(&stream, &kernel, &params_buf, &best_buf, &out_buf, N as i32);
let out = out_buf.read_all();
// L2 = sqrt(N × 0.1²) = sqrt(2.56) ≈ 1.6
let expected_l2 = (N as f32 * 0.01).sqrt();
assert!(
(out[0] - expected_l2).abs() < 1e-4,
"L2 expected ≈ {}, got {}",
expected_l2, out[0],
);
// norm_best = 0, denom = EPS = 1e-12, so rel = L2 / 1e-12 ≈ 1.6e12.
// Just verify it's finite and very large (not NaN).
assert!(
out[1].is_finite(),
"relative should be finite (got NaN/inf) when best is all-zero: {}",
out[1],
);
assert!(
out[1] > 1e10,
"relative should be very large (>1e10) when best is all-zero, got {}",
out[1],
);
}
/// Test D: n == 0 degenerate guard.
#[test]
#[ignore = "requires GPU"]
fn weight_drift_n_zero_emits_zeros() {
let stream = make_test_stream();
let kernel = load_weight_drift_diag(&stream);
// Allocate something — kernel uses n=0 to early-return.
let params_buf = unsafe { MappedF32Buffer::new(16) }.expect("alloc params");
params_buf.write_from_slice(&vec![1.0; 16]);
let best_buf = unsafe { MappedF32Buffer::new(16) }.expect("alloc best");
best_buf.write_from_slice(&vec![2.0; 16]);
let out_buf = unsafe { MappedF32Buffer::new(2) }.expect("alloc out");
// Pre-fill out with sentinel so we can verify it gets written to 0.
out_buf.write_from_slice(&[99.0_f32, 99.0_f32]);
launch_drift(&stream, &kernel, &params_buf, &best_buf, &out_buf, 0_i32);
let out = out_buf.read_all();
assert_eq!(out[0], 0.0, "L2 should be 0 for n=0, got {}", out[0]);
assert_eq!(out[1], 0.0, "relative should be 0 for n=0, got {}", out[1]);
}
}