Files
foxhunt/crates/ml-alpha/tests/smoothness_lambda_controller_invariants.rs
jgrusewski adc3506d3e feat(gpu-log): wire smoothness_lambda_controller as first ring producer
Atomic refactor wiring the GPU log ring's first producer end-to-end:

- cuda/gpu_log_helpers.cuh (new): extract LogHeader/LogRecord/LogRing
  struct definitions + the log_record() device __forceinline__ helper
  into a shared header. Single source of truth — other kernels include
  this rather than redefining the structs.
- cuda/gpu_log_ring.cu: refactor to include gpu_log_helpers.cuh; retain
  only the gpu_log_tick kernel.
- cuda/smoothness_lambda_controller.cu: include gpu_log_helpers.cuh, add
  trailing (LogRing*, const int*) args, capture pre-EMA jitter +
  post-EMA jitter + target + excess_ratio into shared mem, and emit
  three records per call (RT_INPUT, RT_STATE, RT_OUTPUT) gated on
  non-null ring pointer.
- trainer/perception.rs: feature-gated (cuda-diag-log) ring allocation +
  step counter (mapped-pinned host shadow), gpu_log_tick launch FIRST
  inside the captured graph, two new pointer args on the smoothness
  controller launch (null when feature off), step-counter DtoD shadow
  alongside the other telemetry shadows, drain task spawn (skipped when
  no tokio runtime — sync tests still construct the trainer), and a
  feature-gated Drop impl that aborts the drain task on shutdown.
- tests/smoothness_lambda_controller_invariants.rs: pass null pointers
  for the two new kernel args; the kernel's nullptr guard preserves
  pre-existing behaviour.
- build.rs: rerun-if-changed on cuda/gpu_log_ids.h and
  cuda/gpu_log_helpers.cuh so header edits trigger cubin rebuilds.

Verified: cargo build / check --all-targets clean both with and without
the cuda-diag-log feature; 4/4 smoothness controller invariant tests
pass; 9/9 perception_overfit integration tests pass under the feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 01:36:18 +02:00

264 lines
9.7 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.
//! Invariant tests for the ISV-driven λ controller.
//!
//! Per `feedback_no_cpu_test_fallbacks`: GPU oracle only. Per
//! `pearl_tests_must_prove_not_lock_observations`: assertions check
//! invariants (sentinel bootstrap, steady-state at target, excess
//! proportionality, floor activation under zero amplitude) — not
//! arbitrary numeric snapshots.
//!
//! Per `feedback_no_htod_htoh_only_mapped_pinned`: all CPU↔GPU
//! transfers go via `MappedF32Buffer` + `memcpy_dtod_async`. The i32
//! sentinel is shuttled by reinterpreting the same mapped-pinned
//! buffer as i32 (canonical pattern, see `loss.rs::download_i32`).
use std::sync::Arc;
use anyhow::{Context, Result};
use approx::assert_relative_eq;
use cudarc::driver::{CudaSlice, CudaStream, DevicePtr, DevicePtrMut, LaunchConfig, PushKernelArg};
use ml_alpha::heads::N_HORIZONS;
use ml_alpha::pinned_mem::MappedF32Buffer;
use ml_core::device::MlDevice;
const CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/smoothness_lambda_controller.cubin"));
const LAMBDA_FLOOR: f32 = 1.0e-4;
struct ControllerOut {
jitter_ema: Vec<f32>,
first_obs: i32,
lambda: Vec<f32>,
}
fn run_controller(
dev: &MlDevice,
raw_per_h: &[f32; N_HORIZONS],
jitter_ema_init: &[f32; N_HORIZONS],
first_obs_init: i32,
base_lambda: f32,
) -> Result<ControllerOut> {
let stream: &Arc<CudaStream> = dev.cuda_stream().context("controller stream")?;
let ctx = dev.cuda_context().context("controller ctx")?;
let module = ctx.load_cubin(CUBIN.to_vec()).context("load controller cubin")?;
let func = module.load_function("smoothness_lambda_controller").context("load fn")?;
let raw_d = upload_f32(stream, raw_per_h.as_slice())?;
let mut jitter_d = upload_f32(stream, jitter_ema_init.as_slice())?;
let mut first_obs_d = upload_i32(stream, &[first_obs_init])?;
let mut lambda_d = stream.alloc_zeros::<f32>(N_HORIZONS).context("lambda alloc")?;
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (N_HORIZONS as u32, 1, 1),
shared_mem_bytes: 0,
};
// The kernel now accepts two trailing pointer args for the GPU log
// ring (`LogRing*` + `int* g_step_counter`). Passing null pointers
// makes `log_record()` short-circuit at the `g_log_ring == nullptr`
// guard — behaviorally identical to the pre-log signature. We must
// still push the args because launch_builder validates the count.
let null_ring: cudarc::driver::sys::CUdeviceptr = 0;
let null_step: cudarc::driver::sys::CUdeviceptr = 0;
let mut launch = stream.launch_builder(&func);
launch
.arg(&raw_d)
.arg(&mut jitter_d)
.arg(&mut first_obs_d)
.arg(&base_lambda)
.arg(&mut lambda_d)
.arg(&null_ring)
.arg(&null_step);
unsafe {
launch.launch(cfg).context("controller launch")?;
}
stream.synchronize().context("controller sync")?;
Ok(ControllerOut {
jitter_ema: download_f32(stream, &jitter_d)?,
first_obs: download_i32(stream, &first_obs_d)?[0],
lambda: download_f32(stream, &lambda_d)?,
})
}
fn upload_f32(stream: &Arc<CudaStream>, host: &[f32]) -> Result<CudaSlice<f32>> {
let n = host.len();
let staging = unsafe { MappedF32Buffer::new(n) }
.map_err(|e| anyhow::anyhow!("upload staging: {e}"))?;
staging.write_from_slice(host);
let mut dst = stream.alloc_zeros::<f32>(n).context("upload alloc")?;
if n > 0 {
let nbytes = n * std::mem::size_of::<f32>();
unsafe {
let (dst_ptr, _g) = dst.device_ptr_mut(stream);
cudarc::driver::result::memcpy_dtod_async(
dst_ptr,
staging.dev_ptr,
nbytes,
stream.cu_stream(),
)
.context("upload DtoD")?;
}
}
Ok(dst)
}
/// Upload an `i32` slice via the canonical mapped-pinned path by
/// reinterpreting `MappedF32Buffer`'s host pointer as `*mut i32`
/// (same pattern as `trainer/loss.rs::download_i32`). One stream sync
/// barrier inside the kernel launch makes the host write visible
/// to the device through the same mapped page.
fn upload_i32(stream: &Arc<CudaStream>, host: &[i32]) -> Result<CudaSlice<i32>> {
let n = host.len();
let staging = unsafe { MappedF32Buffer::new(n) }
.map_err(|e| anyhow::anyhow!("i32 upload staging: {e}"))?;
// Write through the host pointer reinterpreted as i32.
unsafe {
let host_ptr = staging.host_ptr.cast::<i32>();
for (i, &v) in host.iter().enumerate() {
std::ptr::write_volatile(host_ptr.add(i), v);
}
}
let mut dst = stream.alloc_zeros::<i32>(n).context("i32 upload alloc")?;
if n > 0 {
let nbytes = n * std::mem::size_of::<i32>();
unsafe {
let (dst_ptr, _g) = dst.device_ptr_mut(stream);
cudarc::driver::result::memcpy_dtod_async(
dst_ptr,
staging.dev_ptr,
nbytes,
stream.cu_stream(),
)
.context("i32 upload DtoD")?;
}
}
// Keep staging alive until the DtoD has been issued onto the stream.
// `dst` carries an internal record; the subsequent kernel launch and
// sync barrier in `run_controller` flushes the copy before staging
// drops here.
stream.synchronize().context("i32 upload sync")?;
drop(staging);
Ok(dst)
}
fn download_f32(stream: &Arc<CudaStream>, src: &CudaSlice<f32>) -> Result<Vec<f32>> {
let n = src.len();
let staging = unsafe { MappedF32Buffer::new(n) }
.map_err(|e| anyhow::anyhow!("download staging: {e}"))?;
let nbytes = n * std::mem::size_of::<f32>();
unsafe {
let (src_ptr, _g) = src.device_ptr(stream);
cudarc::driver::result::memcpy_dtod_async(
staging.dev_ptr,
src_ptr,
nbytes,
stream.cu_stream(),
)
.context("download DtoD")?;
}
stream.synchronize().context("download sync")?;
Ok(staging.read_all())
}
/// Download an `i32` slice via the canonical mapped-pinned path
/// (matches `trainer/loss.rs::download_i32`).
fn download_i32(stream: &Arc<CudaStream>, src: &CudaSlice<i32>) -> Result<Vec<i32>> {
let n = src.len();
let staging = unsafe { MappedF32Buffer::new(n) }
.map_err(|e| anyhow::anyhow!("i32 download staging: {e}"))?;
let nbytes = n * std::mem::size_of::<i32>();
unsafe {
let (src_ptr, _g) = src.device_ptr(stream);
cudarc::driver::result::memcpy_dtod_async(
staging.dev_ptr,
src_ptr,
nbytes,
stream.cu_stream(),
)
.context("i32 download DtoD")?;
}
stream.synchronize().context("i32 download sync")?;
let host_ptr = staging.host_ptr.cast::<i32>();
let mut out = Vec::with_capacity(n);
unsafe {
for i in 0..n {
out.push(std::ptr::read_volatile(host_ptr.add(i)));
}
}
Ok(out)
}
fn test_device() -> MlDevice {
MlDevice::cuda(0).expect("CUDA 0 required for ml-alpha tests")
}
// ───── Tests ─────
#[test]
fn first_observation_bootstraps_ema_and_sets_sentinel() {
let dev = test_device();
// raw_per_h satisfies the target ratio exactly (raw[h]/raw[0] = K_h30/K_h),
// so on the bootstrap step jitter_ema == raw → target == jitter → excess_ratio = 0.
let raw = [0.5_f32, 0.15, 0.05, 0.015, 0.0025];
let zero_ema = [0.0_f32; N_HORIZONS];
let base = 0.01_f32;
let out = run_controller(&dev, &raw, &zero_ema, 0, base).unwrap();
assert_eq!(
out.first_obs, 1,
"sentinel should advance to 1 on first observation"
);
for h in 0..N_HORIZONS {
assert_relative_eq!(out.jitter_ema[h], raw[h], epsilon = 1e-6);
// excess_ratio = 0 → lambda = base; floor doesn't engage (base > floor).
assert_relative_eq!(out.lambda[h], base, epsilon = 1e-6);
}
}
#[test]
fn steady_state_at_target_yields_base_lambda() {
let dev = test_device();
let ema = [0.5_f32, 0.15, 0.05, 0.015, 0.0025];
let raw = ema; // identical → EMA update is a no-op (0.5*old + 0.5*old = old)
let base = 0.01_f32;
let out = run_controller(&dev, &raw, &ema, 1, base).unwrap();
for h in 0..N_HORIZONS {
assert_relative_eq!(out.jitter_ema[h], ema[h], epsilon = 1e-6);
assert_relative_eq!(out.lambda[h], base, epsilon = 1e-5);
}
}
#[test]
fn excess_at_h6000_lifts_lambda_proportionally() {
let dev = test_device();
// h6000 EMA at 0.025 = 10× target (= jitter_ema[h30] * 30/6000 = 0.0025)
let ema = [0.5_f32, 0.15, 0.05, 0.015, 0.025];
let raw = ema; // EMA update is no-op
let base = 0.01_f32;
let out = run_controller(&dev, &raw, &ema, 1, base).unwrap();
// h30: at-target → lambda = base
assert_relative_eq!(out.lambda[0], base, epsilon = 1e-5);
// h100, h300, h1000: at-target → lambda = base
assert_relative_eq!(out.lambda[1], base, epsilon = 1e-5);
assert_relative_eq!(out.lambda[2], base, epsilon = 1e-5);
assert_relative_eq!(out.lambda[3], base, epsilon = 1e-5);
// h6000: excess_ratio = 0.025/0.0025 - 1 = 9 → lambda = base * (1 + 9) = 0.10
let expected_lambda_h6000 = base * 10.0;
assert_relative_eq!(out.lambda[4], expected_lambda_h6000, max_relative = 1e-3);
}
#[test]
fn lambda_floor_when_base_lambda_zero() {
let dev = test_device();
let ema = [0.5_f32; N_HORIZONS];
let raw = ema;
let base = 0.0_f32;
let out = run_controller(&dev, &raw, &ema, 1, base).unwrap();
// base = 0 → lambda_new = 0 (regardless of excess_ratio) → floor activates
for h in 0..N_HORIZONS {
assert!(
(out.lambda[h] - LAMBDA_FLOOR).abs() < 1e-9,
"lambda[{h}] should equal LAMBDA_FLOOR (={LAMBDA_FLOOR:.1e}); got {}",
out.lambda[h]
);
}
}