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)
This commit is contained in:
jgrusewski
2026-05-09 01:47:48 +02:00
parent 3e9cefdbd9
commit aab13a83f2
7 changed files with 718 additions and 0 deletions

View File

@@ -0,0 +1,238 @@
//! 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]);
}
}