Files
foxhunt/crates/ml-alpha/tests/gpu_log_ring_invariants.rs
jgrusewski 2efedcd6b8 refactor(per-horizon): N_HORIZONS 5→3 — remaining ml-alpha tests
Five test files migrated to clear the last cargo check --all-targets errors:

- tests/multi_horizon_loader.rs:22,64 — hardcoded [usize;5] horizons literal
  → ml_alpha::heads::HORIZONS; for-loop bounds 0..5 → 0..N_HORIZONS
- tests/output_smoothness_grad_finite_diff.rs:198 — [0.1, 0.3, 1.0, 3.0, 10.0]
  → [0.1, 1.0, 10.0] preserving 100× span across horizons
- src/data/loader.rs:560,640 (inline lib tests) — hardcoded [30,100,300,
  1000,6000] literal → crate::heads::HORIZONS
- tests/perception_overfit.rs:318,358 (audit-discovered 5-isms) —
  cfg.seq_len * 5 → cfg.seq_len * N_HORIZONS
- tests/gpu_log_ring_invariants.rs:173 (audit-discovered) — payload field
  name v["payload"]["raw_h30"] → "raw_h10" (matches gpu_log.rs schema
  migrated in Task 5)

cargo check --workspace --all-targets: clean (only pre-existing third-party
cudarc cupti example error, unrelated).
cargo test -p ml-alpha --lib: 33 passed, 0 failed, 6 ignored — baseline.

Golden fixtures deferred to runtime regeneration:
- tests/fixtures/perception_forward_golden.bin (644 → 388 bytes post-rebase).
  Test is #[ignore]-d and rewrites if missing; regenerate during Task 9
  local validation by deleting the .bin and re-running with --ignored.

gpu_log.rs migration verified complete by Task 5 (no remnant 5-horizon
field names in payload_json decoders for RT_INPUT/RT_STATE/RT_OUTPUT).

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

324 lines
13 KiB
Rust

//! Invariant tests for the GPU log ring: allocator, magic validation,
//! ring wrap, drainer fall-behind detection.
//!
//! Per `feedback_no_cpu_test_fallbacks`: unit tests #1-#6 exercise the
//! HOST-SIDE invariants only (struct layout, slot arithmetic, magic
//! validation, drain dispatch). They write LogRecord structs directly
//! into the mapped-pinned buffer — `#[repr(C)]` makes the host write
//! binary-identical to what a kernel would write.
//!
//! Test #7 is the GPU oracle path: launches the tick kernel via cudarc,
//! confirms the device-side step counter increments.
#![cfg(feature = "kernel-step-trace")]
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use ml_alpha::gpu_log::{
spawn_drain_task_to_file, LogHeader, LogRecord, LogRing, KID_SMOOTHNESS_CONTROLLER, MAGIC,
N_RECORDS_PER_STEP, N_RT_PER_KERNEL, N_SLOTS, PAYLOAD_F32, RT_INPUT, RT_OUTPUT, RT_STATE,
};
use ml_alpha::pinned_mem::MappedRecordBuffer;
use ml_core::device::MlDevice;
use tempfile::NamedTempFile;
// ── helpers ──────────────────────────────────────────────────────────
/// Acquire a CUDA device — required even for "unit" tests because
/// `LogRing::alloc()` calls `cuMemHostAlloc` which needs an active
/// context on the calling thread. Per `feedback_no_cpu_test_fallbacks`,
/// the only valid CPU↔GPU memory channel is mapped-pinned (see
/// `feedback_no_htod_htoh_only_mapped_pinned`), so a CUDA-less unit
/// test is not possible for the ring allocator.
fn test_device() -> MlDevice {
MlDevice::cuda(0).expect("CUDA 0 required for gpu_log_ring tests")
}
/// Compute the slot index for a (step, kernel_id, record_type) tuple.
/// Mirrors the kernel-side arithmetic in `gpu_log_ring.cu`.
fn slot_of(step: u32, kernel_id: u8, record_type: u8) -> usize {
((step as usize) * N_RECORDS_PER_STEP
+ (kernel_id as usize) * N_RT_PER_KERNEL
+ record_type as usize)
& (N_SLOTS - 1)
}
/// Build a `LogRecord` with the given fields.
fn make_record(step: u32, kid: u8, rt: u8, payload: &[f32]) -> LogRecord {
let mut rec = LogRecord {
header: LogHeader {
magic: MAGIC,
step,
kernel_id: kid,
record_type: rt,
payload_words: payload.len().min(PAYLOAD_F32) as u8,
reserved: 0,
_padding: 0,
},
payload: [0.0; PAYLOAD_F32],
};
for (i, &v) in payload.iter().take(PAYLOAD_F32).enumerate() {
rec.payload[i] = v;
}
rec
}
/// Allocate a host-visible step-counter shadow (1 i32).
fn alloc_host_counter() -> Arc<MappedRecordBuffer<i32>> {
let buf = unsafe { MappedRecordBuffer::<i32>::new(1) }
.expect("alloc step counter shadow");
buf.write_record(0, 0);
Arc::new(buf)
}
// ── unit tests (require CUDA context for mapped-pinned alloc only) ───
#[test]
fn ring_alloc_zeros_magic() {
let _dev = test_device();
let ring = LogRing::alloc().expect("alloc ring");
// Every slot's magic must be 0 (zero-initialised — must not match MAGIC).
for i in 0..N_SLOTS {
let rec = ring.buffer.read_record(i);
assert_eq!(
rec.header.magic, 0,
"slot {i} magic should be 0 on fresh alloc, got 0x{:x}",
rec.header.magic
);
}
}
#[test]
fn host_write_round_trip() {
let _dev = test_device();
let ring = LogRing::alloc().expect("alloc ring");
let payload = [1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
let rec = make_record(42, KID_SMOOTHNESS_CONTROLLER, RT_INPUT, &payload);
// Write at the canonical slot for (step=42, KID_SMOOTHNESS_CONTROLLER, RT_INPUT).
let slot = slot_of(42, KID_SMOOTHNESS_CONTROLLER, RT_INPUT);
ring.buffer.write_record(slot, rec);
// Read back — all fields must match.
let read = ring.buffer.read_record(slot);
assert_eq!(read.header.magic, MAGIC);
assert_eq!(read.header.step, 42);
assert_eq!(read.header.kernel_id, KID_SMOOTHNESS_CONTROLLER);
assert_eq!(read.header.record_type, RT_INPUT);
assert_eq!(read.header.payload_words, 10);
for (i, &expected) in payload.iter().enumerate() {
assert_eq!(read.payload[i], expected, "payload[{i}] mismatch");
}
// Untouched payload slots remain zero.
for i in payload.len()..PAYLOAD_F32 {
assert_eq!(read.payload[i], 0.0, "untouched payload[{i}] should be 0");
}
}
#[test]
fn magic_validation_skips_torn() {
let _dev = test_device();
let ring = Arc::new(LogRing::alloc().expect("alloc ring"));
let counter = alloc_host_counter();
let stop = Arc::new(AtomicBool::new(false));
// Slot for step=1, KID_SMOOTHNESS_CONTROLLER, RT_INPUT — valid record.
let valid_slot = slot_of(1, KID_SMOOTHNESS_CONTROLLER, RT_INPUT);
// Slot for step=1, KID_SMOOTHNESS_CONTROLLER, RT_STATE — torn (magic=0).
let torn_slot = slot_of(1, KID_SMOOTHNESS_CONTROLLER, RT_STATE);
// Slot for step=1, KID_SMOOTHNESS_CONTROLLER, RT_OUTPUT — header.step mismatched.
let mismatch_slot = slot_of(1, KID_SMOOTHNESS_CONTROLLER, RT_OUTPUT);
let valid_payload = [100.0_f32; 10];
ring.buffer.write_record(
valid_slot,
make_record(1, KID_SMOOTHNESS_CONTROLLER, RT_INPUT, &valid_payload),
);
// Torn record: write with magic=0 so drainer must skip.
let mut torn = make_record(1, KID_SMOOTHNESS_CONTROLLER, RT_STATE, &[1.0; 10]);
torn.header.magic = 0;
ring.buffer.write_record(torn_slot, torn);
// Mismatch record: claims step=99 but lives in slot for step=1 — drainer must skip.
let mismatch = make_record(99, KID_SMOOTHNESS_CONTROLLER, RT_OUTPUT, &[2.0; 10]);
ring.buffer.write_record(mismatch_slot, mismatch);
counter.write_record(0, 1);
let tmp = NamedTempFile::new().expect("tempfile");
let path = tmp.path().to_path_buf();
let h = spawn_drain_task_to_file(ring.clone(), counter.clone(), stop.clone(), path.clone())
.expect("spawn drain");
// Give drainer one tick (>500 ms cadence + a bit of slack).
std::thread::sleep(Duration::from_millis(700));
stop.store(true, Ordering::Relaxed);
// Wait for the drain thread to observe the stop flag and flush.
let _ = h.join();
// The valid record should produce exactly one JSONL line; the torn
// (magic=0) and mismatched-step records must NOT appear.
let contents = std::fs::read_to_string(&path).expect("read trace file");
let lines: Vec<&str> = contents.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 1, "expected exactly 1 valid record, got {}", lines.len());
let v: serde_json::Value = serde_json::from_str(lines[0]).expect("parse JSONL");
assert_eq!(v["step"], 1);
assert_eq!(v["kid"], KID_SMOOTHNESS_CONTROLLER);
assert_eq!(v["rt"], RT_INPUT);
assert_eq!(v["kname"], "smoothness_controller");
assert_eq!(v["rt_name"], "input");
// Payload field comes from the smoothness_input schema.
assert!((v["payload"]["raw_h10"].as_f64().unwrap() - 100.0).abs() < 1e-6);
}
#[test]
fn ring_wrap_modulo_arithmetic() {
// For step values past N_SLOTS/N_RECORDS_PER_STEP, slot indices wrap.
let wrap_step = (N_SLOTS / N_RECORDS_PER_STEP) as u32; // = 1024
// step 0 and step `wrap_step` for (KID=0, RT=0) hash to the same slot.
assert_eq!(
slot_of(0, 0, 0),
slot_of(wrap_step, 0, 0),
"wrap_step={wrap_step} should collide with step=0 at same (kid, rt)"
);
// step 0 and step (wrap_step * 2) collide on the next cycle, etc.
assert_eq!(slot_of(0, 0, 0), slot_of(wrap_step * 2, 0, 0));
// Different (kid, rt) at same step never collide (within one cycle).
assert_ne!(slot_of(0, 0, 0), slot_of(0, 0, 1));
assert_ne!(slot_of(0, 0, 0), slot_of(0, 1, 0));
// All slots stay in [0, N_SLOTS).
for step in 0..(wrap_step * 3) {
for kid in 0..8u8 {
for rt in 0..4u8 {
let s = slot_of(step, kid, rt);
assert!(
s < N_SLOTS,
"slot {s} out of bounds for (step={step}, kid={kid}, rt={rt})"
);
}
}
}
}
#[test]
fn step_gap_detection_emits_warn() {
let _dev = test_device();
let ring = Arc::new(LogRing::alloc().expect("alloc ring"));
let counter = alloc_host_counter();
let stop = Arc::new(AtomicBool::new(false));
// Drainer ring window = N_SLOTS / N_RECORDS_PER_STEP = 1024 steps.
// Jump the counter past the window so drainer must clamp.
let big_jump = ((N_SLOTS / N_RECORDS_PER_STEP) + 10) as i32;
counter.write_record(0, big_jump);
let tmp = NamedTempFile::new().expect("tempfile");
let h = spawn_drain_task_to_file(
ring.clone(),
counter.clone(),
stop.clone(),
tmp.path().to_path_buf(),
)
.expect("spawn drain");
std::thread::sleep(Duration::from_millis(700));
stop.store(true, Ordering::Relaxed);
let _ = h.join();
// Pass if no panic — drainer clamped `last_drained_step` to `oldest_safe_step`
// and emitted a warn. We don't tap tracing here; the absence of panic +
// the drainer's known structure (warns then clamps) is the pass condition.
}
#[test]
fn decoder_dispatch_unknown_falls_through() {
let _dev = test_device();
let ring = Arc::new(LogRing::alloc().expect("alloc ring"));
let counter = alloc_host_counter();
let stop = Arc::new(AtomicBool::new(false));
// Write a valid record with kernel_id=99 (out of declared range — no decoder registered).
let unknown_kid: u8 = 99;
let rec = make_record(1, unknown_kid, RT_INPUT, &[7.0; 10]);
let slot = ((1usize) * N_RECORDS_PER_STEP
+ (unknown_kid as usize) * N_RT_PER_KERNEL
+ RT_INPUT as usize)
& (N_SLOTS - 1);
ring.buffer.write_record(slot, rec);
counter.write_record(0, 1);
let tmp = NamedTempFile::new().expect("tempfile");
let h = spawn_drain_task_to_file(
ring.clone(),
counter.clone(),
stop.clone(),
tmp.path().to_path_buf(),
)
.expect("spawn drain");
std::thread::sleep(Duration::from_millis(700));
stop.store(true, Ordering::Relaxed);
let _ = h.join();
// The drainer's iteration is `for kid in 0..N_KERNELS` — so kid=99 will
// never be visited by the drainer's slot walk. But the `decode_to_json()`
// function itself MUST handle unknown kernel_id without panic. Exercise
// it directly — unknown (kid, rt) pairs fall through to a "values" array.
let v1 = ml_alpha::gpu_log::decode_to_json(unknown_kid, RT_INPUT, 1, &[7.0; 10]);
assert_eq!(v1["kname"], "unknown");
assert!(v1["payload"]["values"].is_array());
let v2 = ml_alpha::gpu_log::decode_to_json(0, 99, 1, &[7.0; 10]); // unknown record_type
assert_eq!(v2["rt_name"], "unknown");
}
// ── CUDA integration test #7 ─────────────────────────────────────────
/// Fire the `gpu_log_tick` kernel via cudarc twice; confirm that the
/// device-side step counter increments from 0 → 1 → 2. This validates
/// the cubin includes the `gpu_log_tick` symbol and that the host can
/// drive the GPU log ring's step-counter advance path end-to-end.
///
/// The full kernel-writes → drainer-reads loop is covered by the
/// `perception_overfit` smoke once a kernel wires `log_record()` into
/// the ring (Task 7).
#[test]
#[ignore = "requires CUDA — run via `cargo test --features kernel-step-trace -- --ignored`"]
fn kernel_writes_visible_to_host_smoke() {
use cudarc::driver::{LaunchConfig, PushKernelArg};
let dev = test_device();
let stream = dev.cuda_stream().expect("stream");
let ctx = dev.cuda_context().expect("ctx");
let cubin = include_bytes!(concat!(env!("OUT_DIR"), "/gpu_log_ring.cubin"));
let module = ctx.load_cubin(cubin.to_vec()).expect("load cubin");
let tick_fn = module.load_function("gpu_log_tick").expect("load gpu_log_tick");
// Allocate the step counter, fire tick, confirm device increments to 1.
let mut step_counter = stream.alloc_zeros::<i32>(1).expect("alloc counter");
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (1, 1, 1),
shared_mem_bytes: 0,
};
let mut launch = stream.launch_builder(&tick_fn);
launch.arg(&mut step_counter);
unsafe {
launch.launch(cfg).expect("launch tick");
}
stream.synchronize().expect("sync");
let host: Vec<i32> = stream.clone_dtoh(&step_counter).expect("dtoh");
assert_eq!(host[0], 1, "after one tick, step_counter should be 1");
// Fire again — should increment to 2.
let mut launch = stream.launch_builder(&tick_fn);
launch.arg(&mut step_counter);
unsafe {
launch.launch(cfg).expect("launch tick 2");
}
stream.synchronize().expect("sync");
let host: Vec<i32> = stream.clone_dtoh(&step_counter).expect("dtoh 2");
assert_eq!(host[0], 2, "after two ticks, step_counter should be 2");
}