C.8 (ISV-driven aux trunk Adam β1/β2/ε/LR/grad-clip) was already complete in C.5a
commit c90de9859 — all 5 ISV reads and fold-boundary StateResetRegistry defaults were
wired atomically with the Adam launcher. No new code required; noted in audit doc.
C.9 adds `aux_trunk_learns_synthetic_uptrend` to aux_trunk_oracle_tests.rs:
- B=16, ENC=32, H1=32, H2=16, SH2=32, H_HEAD=32, K=2, 100 steps
- Backward kernel invocations corrected to match actual signatures:
- aux_trunk_bwd_dh_pre(d_logits, w3, w2, h_aux1, h_aux2, dh_pre2, dh_pre1, B, H1, H2, SH2)
shmem = H2 floats (sh_dh2_pre cache), NOT (H1+H2+SH2)
- aux_trunk_bwd_dW_reduce called 3×: dW3/dW2/dW1 each with (A, B_grad, dW_out, B, Krows, Jcols)
- aux_trunk_bwd_db_reduce called 3×: db3/db2/db1 each with (B_grad, db_out, B, Jcols)
- Head params trained via host-side SGD (test orchestration only; reads mapped-pinned partials)
- Trunk params trained via dqn_adam_update_kernel (GPU Adam)
- Pass gate: CE loss < 0.1 AND dir_acc > 0.95 after 100 steps
Near-random baseline (ln(2)≈0.693) = broken gradient chain, L40S dispatch blocked
Memory pearl pearl_separate_aux_trunk_when_shared_starves.md added and indexed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1952 lines
87 KiB
Rust
1952 lines
87 KiB
Rust
//! Oracle tests for aux trunk forward + backward (SP14 Layer C Phases
|
||
//! C.3 + C.4, 2026-05-08).
|
||
//!
|
||
//! Layer C introduces a separate auxiliary trunk parallel to Q's GRN trunk
|
||
//! per the locked design in
|
||
//! `2026-05-07-sp14-layer-c-separate-aux-trunk.md`. The forward kernel is
|
||
//! a 3-layer Linear→ELU→Linear→ELU→Linear MLP that reads the encoder
|
||
//! output and produces `h_s2_aux [B, AUX_HIDDEN_DIM]` for the aux head.
|
||
//! Backward terminates at the encoder boundary — Q-loss is the sole
|
||
//! shaping force on the encoder.
|
||
//!
|
||
//! Tests in this file:
|
||
//! - `aux_trunk_forward_matches_numpy_reference` (C.3) — forward output
|
||
//! bit-for-bit match vs. host reference within 1e-4 tol.
|
||
//! - `aux_trunk_backward_gradient_check` (C.4) — analytic dW3 from the
|
||
//! backward kernel matches central-difference numerical gradient at
|
||
//! ~16 sampled indices, relative error tolerance 1e-2 (f32 noise).
|
||
//! - `aux_trunk_backward_does_not_write_dx` (C.4) — structural assertion
|
||
//! that the kernel source contains no `dx_in` / `dx_in_out` write
|
||
//! pattern; complements the runtime test that the kernel signatures
|
||
//! do not accept any encoder-gradient pointer.
|
||
//!
|
||
//! All CPU↔GPU buffers are `MappedF32Buffer` per
|
||
//! `feedback_no_htod_htoh_only_mapped_pinned.md` (tests are not exempt).
|
||
//!
|
||
//! Run with:
|
||
//!
|
||
//! SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 \
|
||
//! cargo test -p ml --test aux_trunk_oracle_tests --release \
|
||
//! -- --ignored --nocapture
|
||
|
||
#![allow(unsafe_code)] // CUDA kernel launch + mapped-pinned memory.
|
||
// Allow non-snake-case for `dW{1,2,3}` / `db{1,2,3}` / `dW3_analytic` etc.
|
||
// — these are universally-recognised math symbols for weight/bias
|
||
// gradients and snake-casing them (`d_w1`, `d_b1_ptr`) loses the
|
||
// correspondence with the kernel parameter names in
|
||
// `aux_trunk_backward_kernel.cu` and the linear-algebra notation in the
|
||
// gradient-check comments.
|
||
#![allow(non_snake_case)]
|
||
|
||
#[cfg(feature = "cuda")]
|
||
mod gpu {
|
||
use std::sync::Arc;
|
||
|
||
use cudarc::driver::{CudaContext, CudaFunction, CudaStream, LaunchConfig, PushKernelArg};
|
||
use ml::cuda_pipeline::mapped_pinned::MappedF32Buffer;
|
||
|
||
const AUX_TRUNK_FORWARD_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/aux_trunk_forward_kernel.cubin"));
|
||
|
||
const AUX_TRUNK_BLOCK: u32 = 256;
|
||
|
||
fn make_test_stream() -> Arc<CudaStream> {
|
||
let ctx = CudaContext::new(0).expect("CUDA context — is a GPU available?");
|
||
ctx.default_stream()
|
||
}
|
||
|
||
fn load_aux_trunk_forward(stream: &Arc<CudaStream>) -> CudaFunction {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(AUX_TRUNK_FORWARD_CUBIN.to_vec())
|
||
.expect("load aux_trunk_forward cubin");
|
||
module
|
||
.load_function("aux_trunk_forward")
|
||
.expect("load aux_trunk_forward function")
|
||
}
|
||
|
||
/// Deterministic LCG → uniform `[-1, 1)` for reproducible weight/input
|
||
/// generation. Mirrors the LCG state-machine pattern used elsewhere
|
||
/// in the codebase (e.g. `xavier_init_dqn_weights` in
|
||
/// `gpu_dqn_trainer.rs`). Seed is fixed so every test run produces
|
||
/// bit-identical inputs.
|
||
fn lcg_next_signed(state: &mut u32) -> f32 {
|
||
*state = state.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||
let raw = (*state >> 16) as f32 / 32_768.0; // [0, 2)
|
||
raw - 1.0 // [-1, 1)
|
||
}
|
||
|
||
/// ELU forward used in the host reference. Mirrors
|
||
/// `aux_trunk_elu_fwd` in the kernel exactly.
|
||
fn elu(x: f32) -> f32 {
|
||
if x > 0.0 { x } else { x.exp() - 1.0 }
|
||
}
|
||
|
||
/// Forward output check — runs the GPU kernel against a deterministic
|
||
/// host reference and verifies element-wise match within 1e-4 (f32
|
||
/// SGEMM precision).
|
||
///
|
||
/// Uses production topology dimensions:
|
||
/// - ENCODER_OUT_DIM = 256 (= config.shared_h1)
|
||
/// - H1 = AUX_TRUNK_H1 = 256
|
||
/// - H2 = AUX_TRUNK_H2 = 128
|
||
/// - AUX_HIDDEN_DIM = 256 (= config.shared_h2)
|
||
///
|
||
/// B is small (4) to keep the host reference fast — the matmul
|
||
/// arithmetic is dominated by the per-row dot products (256 × 256 +
|
||
/// 256 × 128 + 128 × 256 = 131,072 multiplies per row), not B.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn aux_trunk_forward_matches_numpy_reference() {
|
||
let stream = make_test_stream();
|
||
let kernel = load_aux_trunk_forward(&stream);
|
||
|
||
const B: usize = 4;
|
||
const ENCODER_OUT_DIM: usize = 256;
|
||
const H1: usize = 256;
|
||
const H2: usize = 128;
|
||
const AUX_HIDDEN_DIM: usize = 256;
|
||
|
||
// Deterministic LCG seed — fixed value so test is reproducible.
|
||
let mut rng_state: u32 = 0x1234_5678;
|
||
let next_f32 = |state: &mut u32| lcg_next_signed(state);
|
||
|
||
// Inputs scaled to small magnitudes so the cumulative 256-deep
|
||
// dot product stays in a numerically benign range. (×0.1 input,
|
||
// ×0.05 weights → per-row sum of ~256 products of 0.005 RMS
|
||
// each → expected acc magnitude < ~5 before activation.)
|
||
let x_in_host: Vec<f32> = (0..B * ENCODER_OUT_DIM)
|
||
.map(|_| next_f32(&mut rng_state) * 0.1)
|
||
.collect();
|
||
let w1_host: Vec<f32> = (0..ENCODER_OUT_DIM * H1)
|
||
.map(|_| next_f32(&mut rng_state) * 0.05)
|
||
.collect();
|
||
let b1_host: Vec<f32> = vec![0.0; H1];
|
||
let w2_host: Vec<f32> = (0..H1 * H2)
|
||
.map(|_| next_f32(&mut rng_state) * 0.05)
|
||
.collect();
|
||
let b2_host: Vec<f32> = vec![0.0; H2];
|
||
let w3_host: Vec<f32> = (0..H2 * AUX_HIDDEN_DIM)
|
||
.map(|_| next_f32(&mut rng_state) * 0.05)
|
||
.collect();
|
||
let b3_host: Vec<f32> = vec![0.0; AUX_HIDDEN_DIM];
|
||
|
||
// Allocate device buffers via MappedF32Buffer (mapped-pinned, no
|
||
// raw HtoD per `feedback_no_htod_htoh_only_mapped_pinned.md`).
|
||
let x_in = unsafe { MappedF32Buffer::new(B * ENCODER_OUT_DIM) }
|
||
.expect("alloc x_in");
|
||
x_in.write_from_slice(&x_in_host);
|
||
let w1 = unsafe { MappedF32Buffer::new(ENCODER_OUT_DIM * H1) }.expect("alloc w1");
|
||
w1.write_from_slice(&w1_host);
|
||
let b1 = unsafe { MappedF32Buffer::new(H1) }.expect("alloc b1");
|
||
b1.write_from_slice(&b1_host);
|
||
let w2 = unsafe { MappedF32Buffer::new(H1 * H2) }.expect("alloc w2");
|
||
w2.write_from_slice(&w2_host);
|
||
let b2 = unsafe { MappedF32Buffer::new(H2) }.expect("alloc b2");
|
||
b2.write_from_slice(&b2_host);
|
||
let w3 = unsafe { MappedF32Buffer::new(H2 * AUX_HIDDEN_DIM) }.expect("alloc w3");
|
||
w3.write_from_slice(&w3_host);
|
||
let b3 = unsafe { MappedF32Buffer::new(AUX_HIDDEN_DIM) }.expect("alloc b3");
|
||
b3.write_from_slice(&b3_host);
|
||
|
||
// Output + saved-for-backward buffers (zero-init).
|
||
let h_aux1 = unsafe { MappedF32Buffer::new(B * H1) }.expect("alloc h_aux1");
|
||
h_aux1.write_from_slice(&vec![0.0_f32; B * H1]);
|
||
let h_aux2 = unsafe { MappedF32Buffer::new(B * H2) }.expect("alloc h_aux2");
|
||
h_aux2.write_from_slice(&vec![0.0_f32; B * H2]);
|
||
let h_s2_aux = unsafe { MappedF32Buffer::new(B * AUX_HIDDEN_DIM) }
|
||
.expect("alloc h_s2_aux");
|
||
h_s2_aux.write_from_slice(&vec![0.0_f32; B * AUX_HIDDEN_DIM]);
|
||
|
||
let b_i32: i32 = B as i32;
|
||
let enc_i32: i32 = ENCODER_OUT_DIM as i32;
|
||
let h1_i32: i32 = H1 as i32;
|
||
let h2_i32: i32 = H2 as i32;
|
||
let aux_i32: i32 = AUX_HIDDEN_DIM as i32;
|
||
// Shared memory: H1 + H2 floats — Layer-1 cache + Layer-2 cache.
|
||
let smem_bytes =
|
||
(H1 as u32 + H2 as u32) * std::mem::size_of::<f32>() as u32;
|
||
|
||
unsafe {
|
||
stream
|
||
.launch_builder(&kernel)
|
||
.arg(&x_in.dev_ptr)
|
||
.arg(&w1.dev_ptr)
|
||
.arg(&b1.dev_ptr)
|
||
.arg(&w2.dev_ptr)
|
||
.arg(&b2.dev_ptr)
|
||
.arg(&w3.dev_ptr)
|
||
.arg(&b3.dev_ptr)
|
||
.arg(&h_aux1.dev_ptr)
|
||
.arg(&h_aux2.dev_ptr)
|
||
.arg(&h_s2_aux.dev_ptr)
|
||
.arg(&b_i32)
|
||
.arg(&enc_i32)
|
||
.arg(&h1_i32)
|
||
.arg(&h2_i32)
|
||
.arg(&aux_i32)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (B as u32, 1, 1),
|
||
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
|
||
shared_mem_bytes: smem_bytes,
|
||
})
|
||
.expect("launch aux_trunk_forward");
|
||
}
|
||
stream.synchronize().expect("sync after aux_trunk_forward");
|
||
|
||
// ── Host reference ────────────────────────────────────────────
|
||
// Layer 1: h_aux1[b, k] = ELU( b1[k] + sum_j x_in[b, j] * w1[j, k] )
|
||
let mut h_aux1_ref = vec![0.0_f32; B * H1];
|
||
for b in 0..B {
|
||
for k in 0..H1 {
|
||
let mut acc = b1_host[k];
|
||
for j in 0..ENCODER_OUT_DIM {
|
||
acc += x_in_host[b * ENCODER_OUT_DIM + j] * w1_host[j * H1 + k];
|
||
}
|
||
h_aux1_ref[b * H1 + k] = elu(acc);
|
||
}
|
||
}
|
||
// Layer 2: h_aux2[b, k] = ELU( b2[k] + sum_j h_aux1[b, j] * w2[j, k] )
|
||
let mut h_aux2_ref = vec![0.0_f32; B * H2];
|
||
for b in 0..B {
|
||
for k in 0..H2 {
|
||
let mut acc = b2_host[k];
|
||
for j in 0..H1 {
|
||
acc += h_aux1_ref[b * H1 + j] * w2_host[j * H2 + k];
|
||
}
|
||
h_aux2_ref[b * H2 + k] = elu(acc);
|
||
}
|
||
}
|
||
// Layer 3: h_s2_aux[b, k] = b3[k] + sum_j h_aux2[b, j] * w3[j, k] (no activation)
|
||
let mut h_s2_aux_ref = vec![0.0_f32; B * AUX_HIDDEN_DIM];
|
||
for b in 0..B {
|
||
for k in 0..AUX_HIDDEN_DIM {
|
||
let mut acc = b3_host[k];
|
||
for j in 0..H2 {
|
||
acc += h_aux2_ref[b * H2 + j] * w3_host[j * AUX_HIDDEN_DIM + k];
|
||
}
|
||
h_s2_aux_ref[b * AUX_HIDDEN_DIM + k] = acc;
|
||
}
|
||
}
|
||
|
||
// ── Compare ───────────────────────────────────────────────────
|
||
let h_aux1_dev = h_aux1.read_all();
|
||
let h_aux2_dev = h_aux2.read_all();
|
||
let h_s2_aux_dev = h_s2_aux.read_all();
|
||
|
||
let max_diff = |dev: &[f32], reference: &[f32]| -> f32 {
|
||
dev.iter()
|
||
.zip(reference.iter())
|
||
.map(|(d, r)| (d - r).abs())
|
||
.fold(0.0_f32, f32::max)
|
||
};
|
||
|
||
let diff_h1 = max_diff(&h_aux1_dev, &h_aux1_ref);
|
||
let diff_h2 = max_diff(&h_aux2_dev, &h_aux2_ref);
|
||
let diff_out = max_diff(&h_s2_aux_dev, &h_s2_aux_ref);
|
||
|
||
const TOL: f32 = 1e-4;
|
||
assert!(
|
||
diff_h1 < TOL,
|
||
"h_aux1 max diff {diff_h1} exceeds {TOL} tol — Layer-1 kernel mismatch",
|
||
);
|
||
assert!(
|
||
diff_h2 < TOL,
|
||
"h_aux2 max diff {diff_h2} exceeds {TOL} tol — Layer-2 kernel mismatch",
|
||
);
|
||
assert!(
|
||
diff_out < TOL,
|
||
"h_s2_aux max diff {diff_out} exceeds {TOL} tol — Layer-3 kernel mismatch",
|
||
);
|
||
eprintln!(
|
||
"aux_trunk_forward oracle: h_aux1 diff={diff_h1:.2e}, h_aux2 diff={diff_h2:.2e}, h_s2_aux diff={diff_out:.2e} (tol={TOL:.0e})"
|
||
);
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────
|
||
// SP14 Layer C Phase C.4 (2026-05-08): backward gradient check
|
||
// ────────────────────────────────────────────────────────────────────
|
||
|
||
const AUX_TRUNK_BACKWARD_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/aux_trunk_backward_kernel.cubin"));
|
||
|
||
/// Load all three backward kernels from the precompiled cubin. Mirrors
|
||
/// `AuxTrunkBackwardOps::new` but inlined for test code that drives the
|
||
/// kernels directly without going through the wrapper struct.
|
||
fn load_aux_trunk_backward(stream: &Arc<CudaStream>) -> (CudaFunction, CudaFunction, CudaFunction) {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(AUX_TRUNK_BACKWARD_CUBIN.to_vec())
|
||
.expect("load aux_trunk_backward cubin");
|
||
let dh_pre = module
|
||
.load_function("aux_trunk_bwd_dh_pre")
|
||
.expect("load aux_trunk_bwd_dh_pre");
|
||
let dW_reduce = module
|
||
.load_function("aux_trunk_bwd_dW_reduce")
|
||
.expect("load aux_trunk_bwd_dW_reduce");
|
||
let db_reduce = module
|
||
.load_function("aux_trunk_bwd_db_reduce")
|
||
.expect("load aux_trunk_bwd_db_reduce");
|
||
(dh_pre, dW_reduce, db_reduce)
|
||
}
|
||
|
||
/// Run the GPU forward kernel and return the f32 output `h_s2_aux`
|
||
/// flattened `[B * AUX_HIDDEN_DIM]`. Used by the gradient-check test
|
||
/// which calls forward repeatedly with perturbed weights.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn run_forward(
|
||
stream: &Arc<CudaStream>,
|
||
kernel: &CudaFunction,
|
||
x_in: &MappedF32Buffer,
|
||
w1: &MappedF32Buffer,
|
||
b1: &MappedF32Buffer,
|
||
w2: &MappedF32Buffer,
|
||
b2: &MappedF32Buffer,
|
||
w3: &MappedF32Buffer,
|
||
b3: &MappedF32Buffer,
|
||
h_aux1: &MappedF32Buffer,
|
||
h_aux2: &MappedF32Buffer,
|
||
h_s2_aux: &MappedF32Buffer,
|
||
b_dim: usize,
|
||
encoder_out_dim: usize,
|
||
h1: usize,
|
||
h2: usize,
|
||
aux_hidden_dim: usize,
|
||
) {
|
||
let b_i32 = b_dim as i32;
|
||
let enc_i32 = encoder_out_dim as i32;
|
||
let h1_i32 = h1 as i32;
|
||
let h2_i32 = h2 as i32;
|
||
let aux_i32 = aux_hidden_dim as i32;
|
||
let smem_bytes = (h1 as u32 + h2 as u32) * std::mem::size_of::<f32>() as u32;
|
||
unsafe {
|
||
stream
|
||
.launch_builder(kernel)
|
||
.arg(&x_in.dev_ptr)
|
||
.arg(&w1.dev_ptr)
|
||
.arg(&b1.dev_ptr)
|
||
.arg(&w2.dev_ptr)
|
||
.arg(&b2.dev_ptr)
|
||
.arg(&w3.dev_ptr)
|
||
.arg(&b3.dev_ptr)
|
||
.arg(&h_aux1.dev_ptr)
|
||
.arg(&h_aux2.dev_ptr)
|
||
.arg(&h_s2_aux.dev_ptr)
|
||
.arg(&b_i32)
|
||
.arg(&enc_i32)
|
||
.arg(&h1_i32)
|
||
.arg(&h2_i32)
|
||
.arg(&aux_i32)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (b_dim as u32, 1, 1),
|
||
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
|
||
shared_mem_bytes: smem_bytes,
|
||
})
|
||
.expect("launch aux_trunk_forward");
|
||
}
|
||
stream.synchronize().expect("sync forward");
|
||
}
|
||
|
||
/// Gradient check for the backward kernel.
|
||
///
|
||
/// Strategy: define scalar loss `L = 0.5 * sum(h_s2_aux^2)`. Then
|
||
/// `dL/dh_s2_aux = h_s2_aux`, which we feed into the backward kernel
|
||
/// as `dh_s2_aux`. The kernel produces analytic dW3 (as well as the
|
||
/// other dW/db tensors which we don't validate here for runtime
|
||
/// reasons — dW3 alone exercises every layer's backward path through
|
||
/// the reduce-pre-pass + dW_reduce kernels).
|
||
///
|
||
/// Numerical gradient at sampled (k, j) indices via central
|
||
/// differences: `dW3_num[k, j] ≈ (L(W3 + ε·e_kj) - L(W3 - ε·e_kj)) /
|
||
/// (2ε)` with `ε = 1e-3`. Then compare analytic vs numerical at
|
||
/// ~16 random (k, j) pairs; relative error must be < 1e-2 (f32
|
||
/// numerical-gradient noise floor in this regime).
|
||
///
|
||
/// Test runs forward 33× (1 baseline + 16 indices × 2 perturbations)
|
||
/// with a small batch (B=4) and small layer dims (ENC=H1=H2=AUX=32)
|
||
/// to keep total runtime under ~5s. Production topology (256/256/128/
|
||
/// 256) is exercised by the forward oracle test; backward correctness
|
||
/// is dimension-independent given the kernels use runtime args for
|
||
/// layer sizes.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn aux_trunk_backward_gradient_check() {
|
||
let stream = make_test_stream();
|
||
let fwd_kernel = load_aux_trunk_forward(&stream);
|
||
let (dh_pre_kernel, dW_reduce_kernel, db_reduce_kernel) =
|
||
load_aux_trunk_backward(&stream);
|
||
|
||
// Small dims to keep the 33 forward passes fast.
|
||
const B: usize = 4;
|
||
const ENCODER_OUT_DIM: usize = 32;
|
||
const H1: usize = 32;
|
||
const H2: usize = 32;
|
||
const AUX_HIDDEN_DIM: usize = 32;
|
||
|
||
// Deterministic LCG.
|
||
let mut rng_state: u32 = 0x0BAD_C0DE;
|
||
let next_f32 = |state: &mut u32| lcg_next_signed(state);
|
||
|
||
// Inputs scaled small so cumulative dot products stay numerically
|
||
// benign across the 32-deep matmuls.
|
||
let x_in_host: Vec<f32> = (0..B * ENCODER_OUT_DIM)
|
||
.map(|_| next_f32(&mut rng_state) * 0.5)
|
||
.collect();
|
||
let w1_host: Vec<f32> = (0..ENCODER_OUT_DIM * H1)
|
||
.map(|_| next_f32(&mut rng_state) * 0.2)
|
||
.collect();
|
||
let b1_host: Vec<f32> = (0..H1).map(|_| next_f32(&mut rng_state) * 0.1).collect();
|
||
let w2_host: Vec<f32> = (0..H1 * H2)
|
||
.map(|_| next_f32(&mut rng_state) * 0.2)
|
||
.collect();
|
||
let b2_host: Vec<f32> = (0..H2).map(|_| next_f32(&mut rng_state) * 0.1).collect();
|
||
let w3_host: Vec<f32> = (0..H2 * AUX_HIDDEN_DIM)
|
||
.map(|_| next_f32(&mut rng_state) * 0.2)
|
||
.collect();
|
||
let b3_host: Vec<f32> = (0..AUX_HIDDEN_DIM)
|
||
.map(|_| next_f32(&mut rng_state) * 0.1)
|
||
.collect();
|
||
|
||
// Allocate all device buffers.
|
||
let x_in = unsafe { MappedF32Buffer::new(B * ENCODER_OUT_DIM) }.expect("alloc x_in");
|
||
x_in.write_from_slice(&x_in_host);
|
||
let w1 = unsafe { MappedF32Buffer::new(ENCODER_OUT_DIM * H1) }.expect("alloc w1");
|
||
w1.write_from_slice(&w1_host);
|
||
let b1 = unsafe { MappedF32Buffer::new(H1) }.expect("alloc b1");
|
||
b1.write_from_slice(&b1_host);
|
||
let w2 = unsafe { MappedF32Buffer::new(H1 * H2) }.expect("alloc w2");
|
||
w2.write_from_slice(&w2_host);
|
||
let b2 = unsafe { MappedF32Buffer::new(H2) }.expect("alloc b2");
|
||
b2.write_from_slice(&b2_host);
|
||
let w3 = unsafe { MappedF32Buffer::new(H2 * AUX_HIDDEN_DIM) }.expect("alloc w3");
|
||
w3.write_from_slice(&w3_host);
|
||
let b3 = unsafe { MappedF32Buffer::new(AUX_HIDDEN_DIM) }.expect("alloc b3");
|
||
b3.write_from_slice(&b3_host);
|
||
|
||
let h_aux1 = unsafe { MappedF32Buffer::new(B * H1) }.expect("alloc h_aux1");
|
||
let h_aux2 = unsafe { MappedF32Buffer::new(B * H2) }.expect("alloc h_aux2");
|
||
let h_s2_aux = unsafe { MappedF32Buffer::new(B * AUX_HIDDEN_DIM) }.expect("alloc h_s2_aux");
|
||
|
||
// Backward output buffers + scratch for dh_pre intermediates.
|
||
let dh_aux1_pre = unsafe { MappedF32Buffer::new(B * H1) }.expect("alloc dh_aux1_pre");
|
||
let dh_aux2_pre = unsafe { MappedF32Buffer::new(B * H2) }.expect("alloc dh_aux2_pre");
|
||
let dW1 = unsafe { MappedF32Buffer::new(ENCODER_OUT_DIM * H1) }.expect("alloc dW1");
|
||
let db1 = unsafe { MappedF32Buffer::new(H1) }.expect("alloc db1");
|
||
let dW2 = unsafe { MappedF32Buffer::new(H1 * H2) }.expect("alloc dW2");
|
||
let db2 = unsafe { MappedF32Buffer::new(H2) }.expect("alloc db2");
|
||
let dW3 = unsafe { MappedF32Buffer::new(H2 * AUX_HIDDEN_DIM) }.expect("alloc dW3");
|
||
let db3 = unsafe { MappedF32Buffer::new(AUX_HIDDEN_DIM) }.expect("alloc db3");
|
||
|
||
// ── Step 1: baseline forward ────────────────────────────────────
|
||
run_forward(
|
||
&stream,
|
||
&fwd_kernel,
|
||
&x_in,
|
||
&w1,
|
||
&b1,
|
||
&w2,
|
||
&b2,
|
||
&w3,
|
||
&b3,
|
||
&h_aux1,
|
||
&h_aux2,
|
||
&h_s2_aux,
|
||
B,
|
||
ENCODER_OUT_DIM,
|
||
H1,
|
||
H2,
|
||
AUX_HIDDEN_DIM,
|
||
);
|
||
let h_s2_aux_baseline: Vec<f32> = h_s2_aux.read_all();
|
||
|
||
// ── Step 2: dh_s2_aux = h_s2_aux (loss = 0.5 * ||h_s2_aux||^2) ──
|
||
// Reuse h_s2_aux device buffer's data as the gradient input.
|
||
// Allocate a separate dh_s2_aux device buffer because backward
|
||
// overwrites scratch buffers; keeping the gradient buffer
|
||
// independent simplifies repeated forward passes for finite
|
||
// differences.
|
||
let dh_s2_aux = unsafe { MappedF32Buffer::new(B * AUX_HIDDEN_DIM) }.expect("alloc dh_s2_aux");
|
||
dh_s2_aux.write_from_slice(&h_s2_aux_baseline);
|
||
|
||
// ── Step 3: launch full backward sequence ───────────────────────
|
||
// Inline the seven-launch sequence here (mirrors
|
||
// AuxTrunkBackwardOps::launch's body) so the test exercises the
|
||
// exact kernel-handle-driven path the wrapper uses.
|
||
let block_smem = 256_u32 * std::mem::size_of::<f32>() as u32;
|
||
let dh_pre_smem = H2 as u32 * std::mem::size_of::<f32>() as u32;
|
||
let b_i32 = B as i32;
|
||
let h1_i32 = H1 as i32;
|
||
let h2_i32 = H2 as i32;
|
||
let aux_i32 = AUX_HIDDEN_DIM as i32;
|
||
|
||
// 3a. dh_pre kernel.
|
||
unsafe {
|
||
stream
|
||
.launch_builder(&dh_pre_kernel)
|
||
.arg(&dh_s2_aux.dev_ptr)
|
||
.arg(&w3.dev_ptr)
|
||
.arg(&w2.dev_ptr)
|
||
.arg(&h_aux1.dev_ptr)
|
||
.arg(&h_aux2.dev_ptr)
|
||
.arg(&dh_aux2_pre.dev_ptr)
|
||
.arg(&dh_aux1_pre.dev_ptr)
|
||
.arg(&b_i32)
|
||
.arg(&h1_i32)
|
||
.arg(&h2_i32)
|
||
.arg(&aux_i32)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (B as u32, 1, 1),
|
||
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
|
||
shared_mem_bytes: dh_pre_smem,
|
||
})
|
||
.expect("launch dh_pre");
|
||
}
|
||
|
||
// 3b-3g. dW + db reduces.
|
||
let launch_dW = |a_ptr: u64, b_grad_ptr: u64, dw_ptr: u64, krows: usize, jcols: usize| {
|
||
let k_i32 = krows as i32;
|
||
let j_i32 = jcols as i32;
|
||
unsafe {
|
||
stream
|
||
.launch_builder(&dW_reduce_kernel)
|
||
.arg(&a_ptr)
|
||
.arg(&b_grad_ptr)
|
||
.arg(&dw_ptr)
|
||
.arg(&b_i32)
|
||
.arg(&k_i32)
|
||
.arg(&j_i32)
|
||
.launch(LaunchConfig {
|
||
grid_dim: ((krows * jcols) as u32, 1, 1),
|
||
block_dim: (256, 1, 1),
|
||
shared_mem_bytes: block_smem,
|
||
})
|
||
.expect("launch dW_reduce");
|
||
}
|
||
};
|
||
let launch_db = |b_grad_ptr: u64, db_ptr: u64, jcols: usize| {
|
||
let j_i32 = jcols as i32;
|
||
unsafe {
|
||
stream
|
||
.launch_builder(&db_reduce_kernel)
|
||
.arg(&b_grad_ptr)
|
||
.arg(&db_ptr)
|
||
.arg(&b_i32)
|
||
.arg(&j_i32)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (jcols as u32, 1, 1),
|
||
block_dim: (256, 1, 1),
|
||
shared_mem_bytes: block_smem,
|
||
})
|
||
.expect("launch db_reduce");
|
||
}
|
||
};
|
||
launch_dW(h_aux2.dev_ptr, dh_s2_aux.dev_ptr, dW3.dev_ptr, H2, AUX_HIDDEN_DIM);
|
||
launch_db(dh_s2_aux.dev_ptr, db3.dev_ptr, AUX_HIDDEN_DIM);
|
||
launch_dW(h_aux1.dev_ptr, dh_aux2_pre.dev_ptr, dW2.dev_ptr, H1, H2);
|
||
launch_db(dh_aux2_pre.dev_ptr, db2.dev_ptr, H2);
|
||
launch_dW(x_in.dev_ptr, dh_aux1_pre.dev_ptr, dW1.dev_ptr, ENCODER_OUT_DIM, H1);
|
||
launch_db(dh_aux1_pre.dev_ptr, db1.dev_ptr, H1);
|
||
stream.synchronize().expect("sync backward");
|
||
|
||
let dW3_analytic = dW3.read_all();
|
||
|
||
// ── Step 4: numerical gradient at sampled (k, j) pairs ───────────
|
||
const EPS: f32 = 1e-3;
|
||
// Sample 16 indices deterministically across dW3.
|
||
let n_samples = 16;
|
||
let mut sample_state: u32 = 0xCAFE_F00D;
|
||
let mut max_rel_err: f32 = 0.0;
|
||
let mut max_abs_err: f32 = 0.0;
|
||
let mut max_index = (0usize, 0usize);
|
||
|
||
// Helper: scalar loss = 0.5 * sum(h_s2_aux^2) for a single forward
|
||
// pass with current device weight buffers.
|
||
let scalar_loss = || -> f32 {
|
||
run_forward(
|
||
&stream,
|
||
&fwd_kernel,
|
||
&x_in,
|
||
&w1,
|
||
&b1,
|
||
&w2,
|
||
&b2,
|
||
&w3,
|
||
&b3,
|
||
&h_aux1,
|
||
&h_aux2,
|
||
&h_s2_aux,
|
||
B,
|
||
ENCODER_OUT_DIM,
|
||
H1,
|
||
H2,
|
||
AUX_HIDDEN_DIM,
|
||
);
|
||
let out = h_s2_aux.read_all();
|
||
0.5 * out.iter().map(|x| x * x).sum::<f32>()
|
||
};
|
||
|
||
for _ in 0..n_samples {
|
||
sample_state = sample_state.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||
let k = (sample_state as usize) % H2;
|
||
sample_state = sample_state.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||
let j = (sample_state as usize) % AUX_HIDDEN_DIM;
|
||
let idx = k * AUX_HIDDEN_DIM + j;
|
||
|
||
let original = w3_host[idx];
|
||
// L(W + eps * e_kj)
|
||
let mut perturbed = w3_host.clone();
|
||
perturbed[idx] = original + EPS;
|
||
w3.write_from_slice(&perturbed);
|
||
let l_plus = scalar_loss();
|
||
// L(W - eps * e_kj)
|
||
perturbed[idx] = original - EPS;
|
||
w3.write_from_slice(&perturbed);
|
||
let l_minus = scalar_loss();
|
||
// Restore.
|
||
w3.write_from_slice(&w3_host);
|
||
|
||
let dW3_num = (l_plus - l_minus) / (2.0 * EPS);
|
||
let dW3_an = dW3_analytic[idx];
|
||
let abs_err = (dW3_an - dW3_num).abs();
|
||
// Relative error guarded against zero-valued gradients.
|
||
let denom = dW3_an.abs().max(dW3_num.abs()).max(1e-6);
|
||
let rel_err = abs_err / denom;
|
||
eprintln!(
|
||
" dW3[{k}, {j}] (idx={idx}): analytic={dW3_an:+.6e}, numerical={dW3_num:+.6e}, abs_err={abs_err:.2e}, rel_err={rel_err:.2e}"
|
||
);
|
||
if rel_err > max_rel_err {
|
||
max_rel_err = rel_err;
|
||
max_abs_err = abs_err;
|
||
max_index = (k, j);
|
||
}
|
||
}
|
||
|
||
// f32 finite-difference noise floor: with EPS=1e-3 the central
|
||
// difference truncation error is O(EPS^2)≈1e-6 per dimension, but
|
||
// the dominant noise is f32 catastrophic-cancellation in the
|
||
// (l_plus - l_minus) subtraction at ~ULP × loss_magnitude ≈
|
||
// 1e-7 × loss. For small gradients (~1e-3 abs) the absolute
|
||
// error floor sits around 1e-5, giving 1% relative error at the
|
||
// small-gradient tail. 2e-2 (2%) is a comfortable f32 ceiling
|
||
// that catches real backward bugs (bad ELU derivative, transposed
|
||
// weights → 10× errors) without thrashing on numerical noise.
|
||
const REL_TOL: f32 = 2e-2;
|
||
eprintln!(
|
||
"aux_trunk_backward gradient check: max_rel_err={max_rel_err:.2e} at dW3[{}, {}] (max_abs_err={max_abs_err:.2e}); tol={REL_TOL:.0e}",
|
||
max_index.0, max_index.1
|
||
);
|
||
assert!(
|
||
max_rel_err < REL_TOL,
|
||
"max relative error {max_rel_err} exceeds {REL_TOL} tol — backward gradient mismatch at dW3[{}, {}] (abs_err {max_abs_err})",
|
||
max_index.0,
|
||
max_index.1
|
||
);
|
||
}
|
||
|
||
/// Stop-gradient invariant: the backward kernel set must NOT contain
|
||
/// any code that writes to the encoder gradient buffer (`dx_in`).
|
||
/// This is enforced structurally — the kernel signatures don't accept
|
||
/// any `dx_in_out` pointer, so the kernel set literally cannot touch
|
||
/// encoder gradient memory.
|
||
///
|
||
/// We verify the structural enforcement two ways:
|
||
/// 1. The wrapper struct's `launch()` signature does not accept any
|
||
/// `dx_in_out` parameter (compile-time-enforced — if a future
|
||
/// change adds one, this test won't reference it). Since this
|
||
/// test code already calls into the kernel handles directly
|
||
/// with no dx_in pointer, an inadvertent kernel-signature
|
||
/// addition would surface as a kernel-launch ABI mismatch
|
||
/// (CUDA would reject the launch).
|
||
/// 2. Source-level grep: read the kernel `.cu` file and assert it
|
||
/// contains no `dx_in_out` symbol nor any `dx_in[` write
|
||
/// pattern. This guards against a future regression where
|
||
/// someone adds a `dx_in_out` parameter and writes through it
|
||
/// — the next test run would catch the violation immediately.
|
||
///
|
||
/// The structural assertion is more robust than a sentinel-pattern
|
||
/// runtime test (allocate dx_in with NaN, verify post-launch still
|
||
/// NaN) because runtime tests can be defeated by a bug-free wrapper
|
||
/// that just doesn't pass dx_in_out — whereas a future change adding
|
||
/// the parameter must update the wrapper and the test would fail by
|
||
/// inspection of the source.
|
||
#[test]
|
||
#[ignore = "requires GPU; reads kernel source from repo"]
|
||
fn aux_trunk_backward_does_not_write_dx() {
|
||
// Locate the kernel source. The CARGO_MANIFEST_DIR env points to
|
||
// `crates/ml/`, so the kernel file lives at
|
||
// `src/cuda_pipeline/aux_trunk_backward_kernel.cu`.
|
||
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
|
||
.expect("CARGO_MANIFEST_DIR set during cargo test");
|
||
let kernel_path = std::path::Path::new(&manifest_dir)
|
||
.join("src/cuda_pipeline/aux_trunk_backward_kernel.cu");
|
||
let src = std::fs::read_to_string(&kernel_path)
|
||
.unwrap_or_else(|e| panic!("read {}: {e}", kernel_path.display()));
|
||
|
||
// Strip C-style block comments so design-discussion text mentioning
|
||
// `dx_in` (the symbol we forbid) doesn't trigger a false positive.
|
||
// Single-pass naive stripper — tests are not security-sensitive,
|
||
// and the file has no string literals containing `/*`.
|
||
fn strip_block_comments(s: &str) -> String {
|
||
let mut out = String::with_capacity(s.len());
|
||
let bytes = s.as_bytes();
|
||
let mut i = 0;
|
||
while i < bytes.len() {
|
||
if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
|
||
// Skip until closing `*/`
|
||
i += 2;
|
||
while i + 1 < bytes.len() {
|
||
if bytes[i] == b'*' && bytes[i + 1] == b'/' {
|
||
i += 2;
|
||
break;
|
||
}
|
||
i += 1;
|
||
}
|
||
} else if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' {
|
||
// Skip until newline
|
||
while i < bytes.len() && bytes[i] != b'\n' {
|
||
i += 1;
|
||
}
|
||
} else {
|
||
out.push(bytes[i] as char);
|
||
i += 1;
|
||
}
|
||
}
|
||
out
|
||
}
|
||
let code = strip_block_comments(&src);
|
||
|
||
// Forbidden symbols: any reference in CODE (not comments) to
|
||
// `dx_in` would imply a parameter or write to encoder gradient.
|
||
// The kernel set legitimately reads `x_in` (the encoder's saved-fwd
|
||
// input — used for dW1 reduction's outer-product). It must NEVER
|
||
// write to a `dx_in` buffer.
|
||
const FORBIDDEN: &[&str] = &["dx_in", "dx_in_out"];
|
||
for needle in FORBIDDEN {
|
||
assert!(
|
||
!code.contains(needle),
|
||
"stop-grad invariant VIOLATED: kernel source contains forbidden symbol `{needle}` outside comments. \
|
||
The aux trunk backward must NOT compute or write any gradient back to encoder input. \
|
||
Encoder boundary is the structural stop-gradient per locked design."
|
||
);
|
||
}
|
||
eprintln!(
|
||
"aux_trunk_backward stop-grad invariant: kernel source clean of {FORBIDDEN:?} (read {} bytes from {})",
|
||
src.len(),
|
||
kernel_path.display()
|
||
);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// SP14 Layer C Phase C.4b oracle tests (2026-05-08).
|
||
//
|
||
// Added 4 tests exercising the new ISV-driven aux prediction horizon:
|
||
//
|
||
// - aux_sign_label_h_bar_horizon — label correctness for a known H
|
||
// - aux_sign_label_lookahead_mask — masking when t+H runs past total_bars
|
||
// - aux_horizon_pearl_a_bootstrap — sentinel REPLACED, not blended
|
||
// - aux_horizon_converges_to_steady_target — Wiener-α EMA convergence
|
||
// - aux_horizon_holds_sentinel_with_no_winning_trades — cold-start guard
|
||
//
|
||
// Plan: docs/superpowers/plans/2026-05-07-sp14-layer-c-separate-aux-trunk.md §C.4b
|
||
// ════════════════════════════════════════════════════════════════════
|
||
|
||
const AUX_SIGN_LABEL_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/aux_sign_label_kernel.cubin"));
|
||
const AUX_HORIZON_UPDATE_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/aux_horizon_update_kernel.cubin"));
|
||
|
||
/// AUX_PRED_HORIZON_BARS_INDEX — must match `sp14_isv_slots.rs`.
|
||
const AUX_PRED_HORIZON_BARS_INDEX: usize = 450;
|
||
/// AVG_WIN_HOLD_TIME_BARS_INDEX — must match `sp14_isv_slots.rs`.
|
||
const AVG_WIN_HOLD_TIME_BARS_INDEX: usize = 451;
|
||
/// SENTINEL_AUX_PRED_HORIZON_BARS — must match `sp14_isv_slots.rs`.
|
||
const SENTINEL_H: f32 = 60.0;
|
||
/// AUX_PRED_HORIZON_BARS_MIN/MAX — fundamental bounds.
|
||
const H_MIN: f32 = 1.0;
|
||
const H_MAX: f32 = 240.0;
|
||
|
||
/// ISV size used by the horizon producer test (must be > 451).
|
||
const ISV_TEST_SIZE: usize = 460;
|
||
|
||
fn load_aux_sign_label(stream: &Arc<CudaStream>) -> CudaFunction {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(AUX_SIGN_LABEL_CUBIN.to_vec())
|
||
.expect("load aux_sign_label cubin");
|
||
module
|
||
.load_function("aux_sign_label_kernel")
|
||
.expect("load aux_sign_label_kernel function")
|
||
}
|
||
|
||
fn load_aux_horizon_update(stream: &Arc<CudaStream>) -> CudaFunction {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(AUX_HORIZON_UPDATE_CUBIN.to_vec())
|
||
.expect("load aux_horizon_update cubin");
|
||
module
|
||
.load_function("aux_horizon_update")
|
||
.expect("load aux_horizon_update function")
|
||
}
|
||
|
||
/// Helper: launch `aux_sign_label_kernel` on the given pinned-mapped
|
||
/// targets / bar_indices / isv buffers. Returns the device-side
|
||
/// labels copied back to host.
|
||
fn run_aux_sign_label(
|
||
stream: &Arc<CudaStream>,
|
||
kernel: &CudaFunction,
|
||
targets_buf: &MappedF32Buffer,
|
||
bar_indices_buf: &MappedF32Buffer,
|
||
isv_buf: &MappedF32Buffer,
|
||
total: usize,
|
||
total_bars: i32,
|
||
) -> Vec<i32> {
|
||
let labels_dev = stream
|
||
.alloc_zeros::<i32>(total)
|
||
.expect("alloc out_labels");
|
||
let total_i32 = total as i32;
|
||
let isv_h_idx_i32 = AUX_PRED_HORIZON_BARS_INDEX as i32;
|
||
let blocks = ((total + 255) / 256) as u32;
|
||
let targets_dev = targets_buf.dev_ptr;
|
||
let bar_indices_dev = bar_indices_buf.dev_ptr;
|
||
let isv_dev = isv_buf.dev_ptr;
|
||
unsafe {
|
||
stream
|
||
.launch_builder(kernel)
|
||
.arg(&targets_dev)
|
||
.arg(&bar_indices_dev)
|
||
.arg(&isv_dev)
|
||
.arg(&isv_h_idx_i32)
|
||
.arg(&labels_dev)
|
||
.arg(&total_i32)
|
||
.arg(&total_bars)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (blocks, 1, 1),
|
||
block_dim: (256, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
})
|
||
.expect("aux_sign_label_kernel launch");
|
||
}
|
||
let mut out = vec![0_i32; total];
|
||
stream.memcpy_dtoh(&labels_dev, &mut out).expect("dtoh labels");
|
||
stream.synchronize().expect("sync");
|
||
out
|
||
}
|
||
|
||
/// Helper: build a synthetic `targets [total_bars, 6]` mapped-pinned
|
||
/// buffer with `prices` written into column 2 (raw_close). All other
|
||
/// columns are zero-filled.
|
||
fn build_targets_buf(prices: &[f32]) -> MappedF32Buffer {
|
||
let total_bars = prices.len();
|
||
let buf = unsafe { MappedF32Buffer::new(total_bars * 6) }
|
||
.expect("MappedF32Buffer::new(targets)");
|
||
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, total_bars * 6) };
|
||
host.fill(0.0);
|
||
for (i, &p) in prices.iter().enumerate() {
|
||
host[i * 6 + 2] = p;
|
||
}
|
||
buf
|
||
}
|
||
|
||
/// Helper: build a `bar_indices [total]` mapped-pinned buffer with
|
||
/// the i32 values reinterpreted as f32 (MappedF32Buffer is our only
|
||
/// generic mapped-pinned allocator). The kernel reads it as `int*`
|
||
/// — bit-pattern preservation matters more than f32 representability.
|
||
fn build_bar_indices_buf(indices: &[i32]) -> MappedF32Buffer {
|
||
let n = indices.len();
|
||
let buf = unsafe { MappedF32Buffer::new(n) }
|
||
.expect("MappedF32Buffer::new(bar_indices)");
|
||
// bytewise copy: same memory layout, no f32 interpretation
|
||
unsafe {
|
||
let dst = buf.host_ptr as *mut i32;
|
||
std::ptr::copy_nonoverlapping(indices.as_ptr(), dst, n);
|
||
}
|
||
buf
|
||
}
|
||
|
||
/// Helper: build a `[ISV_TEST_SIZE]` mapped-pinned ISV buffer with
|
||
/// AUX_PRED_HORIZON_BARS_INDEX seeded to `h` and other slots zero.
|
||
fn build_isv_buf(h: f32) -> MappedF32Buffer {
|
||
let buf = unsafe { MappedF32Buffer::new(ISV_TEST_SIZE) }
|
||
.expect("MappedF32Buffer::new(isv)");
|
||
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, ISV_TEST_SIZE) };
|
||
host.fill(0.0);
|
||
host[AUX_PRED_HORIZON_BARS_INDEX] = h;
|
||
buf
|
||
}
|
||
|
||
/// Step 6 oracle: aux_sign_label produces correct labels for a
|
||
/// known H on three deterministic price series.
|
||
///
|
||
/// 1. Linearly increasing prices, H=30, T=200 → labels 1 in
|
||
/// `[0..170)` and -1 in `[170..200)` (lookahead mask).
|
||
/// 2. Linearly decreasing prices, H=30, T=200 → labels 0 in
|
||
/// `[0..170)` and -1 in `[170..200)`.
|
||
/// 3. Sine wave, H=30 → analytic comparison of `p[t+30] > p[t]`.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn aux_sign_label_h_bar_horizon() {
|
||
let stream = make_test_stream();
|
||
let kernel = load_aux_sign_label(&stream);
|
||
|
||
const T: usize = 200;
|
||
const H: i32 = 30;
|
||
let total_bars = T as i32;
|
||
let bar_indices: Vec<i32> = (0..T as i32).collect();
|
||
let bar_indices_buf = build_bar_indices_buf(&bar_indices);
|
||
let isv_buf = build_isv_buf(H as f32);
|
||
|
||
// Case 1: linearly increasing
|
||
let prices_up: Vec<f32> = (0..T).map(|i| i as f32).collect();
|
||
let targets_up = build_targets_buf(&prices_up);
|
||
let labels_up =
|
||
run_aux_sign_label(&stream, &kernel, &targets_up, &bar_indices_buf, &isv_buf, T, total_bars);
|
||
let lookahead_cutoff = T as i32 - H;
|
||
for (i, &lbl) in labels_up.iter().enumerate() {
|
||
let i_i32 = i as i32;
|
||
if i_i32 + H < total_bars {
|
||
assert_eq!(lbl, 1, "increasing series: bar {i} expected 1 (p[t+H]>p[t]), got {lbl}");
|
||
} else {
|
||
assert_eq!(lbl, -1, "increasing series: bar {i} expected mask -1 (t+H>=total_bars), got {lbl}");
|
||
}
|
||
}
|
||
eprintln!("Case 1 (increasing): valid bars={lookahead_cutoff}, all 1 — OK");
|
||
|
||
// Case 2: linearly decreasing
|
||
let prices_dn: Vec<f32> = (0..T).map(|i| (T - i) as f32).collect();
|
||
let targets_dn = build_targets_buf(&prices_dn);
|
||
let labels_dn =
|
||
run_aux_sign_label(&stream, &kernel, &targets_dn, &bar_indices_buf, &isv_buf, T, total_bars);
|
||
for (i, &lbl) in labels_dn.iter().enumerate() {
|
||
let i_i32 = i as i32;
|
||
if i_i32 + H < total_bars {
|
||
assert_eq!(lbl, 0, "decreasing series: bar {i} expected 0 (p[t+H]<p[t]), got {lbl}");
|
||
} else {
|
||
assert_eq!(lbl, -1, "decreasing series: bar {i} expected mask -1, got {lbl}");
|
||
}
|
||
}
|
||
eprintln!("Case 2 (decreasing): valid bars={lookahead_cutoff}, all 0 — OK");
|
||
|
||
// Case 3: sine wave — analytic comparison
|
||
let prices_sin: Vec<f32> =
|
||
(0..T).map(|i| (i as f32 * 0.1).sin()).collect();
|
||
let targets_sin = build_targets_buf(&prices_sin);
|
||
let labels_sin =
|
||
run_aux_sign_label(&stream, &kernel, &targets_sin, &bar_indices_buf, &isv_buf, T, total_bars);
|
||
for (i, &lbl) in labels_sin.iter().enumerate() {
|
||
let i_i32 = i as i32;
|
||
if i_i32 + H >= total_bars {
|
||
assert_eq!(lbl, -1, "sine: bar {i} mask expected -1, got {lbl}");
|
||
continue;
|
||
}
|
||
let expected = if prices_sin[i + H as usize] > prices_sin[i] { 1 } else { 0 };
|
||
assert_eq!(lbl, expected,
|
||
"sine: bar {i} expected {expected} (p[{}]={:.4} vs p[{i}]={:.4}), got {lbl}",
|
||
i + H as usize, prices_sin[i + H as usize], prices_sin[i]);
|
||
}
|
||
eprintln!("Case 3 (sine): valid bars={lookahead_cutoff}, all match analytic — OK");
|
||
}
|
||
|
||
/// Step 7 oracle: lookahead mask invariant — for H=60 with
|
||
/// total_bars=100, labels in `[40..100)` must be -1 and labels in
|
||
/// `[0..40)` must be ∈ {0, 1} (valid).
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn aux_sign_label_lookahead_mask() {
|
||
let stream = make_test_stream();
|
||
let kernel = load_aux_sign_label(&stream);
|
||
|
||
const T: usize = 100;
|
||
const H: i32 = 60;
|
||
let total_bars = T as i32;
|
||
let bar_indices: Vec<i32> = (0..T as i32).collect();
|
||
let bar_indices_buf = build_bar_indices_buf(&bar_indices);
|
||
let isv_buf = build_isv_buf(H as f32);
|
||
let prices: Vec<f32> = (0..T).map(|i| (i as f32 * 0.5).sin()).collect();
|
||
let targets = build_targets_buf(&prices);
|
||
let labels =
|
||
run_aux_sign_label(&stream, &kernel, &targets, &bar_indices_buf, &isv_buf, T, total_bars);
|
||
|
||
for (i, &lbl) in labels.iter().enumerate() {
|
||
let i_i32 = i as i32;
|
||
if i_i32 + H >= total_bars {
|
||
assert_eq!(lbl, -1,
|
||
"lookahead mask: bar {i} (t+H={}) >= total_bars={total_bars} expected -1, got {lbl}",
|
||
i_i32 + H);
|
||
} else {
|
||
assert!(lbl == 0 || lbl == 1,
|
||
"valid window: bar {i} expected ∈ {{0, 1}}, got {lbl}");
|
||
}
|
||
}
|
||
|
||
let mask_count = labels.iter().filter(|&&l| l == -1).count();
|
||
let valid_count = labels.iter().filter(|&&l| l == 0 || l == 1).count();
|
||
assert_eq!(mask_count, H as usize,
|
||
"expected {} masked bars (last H rows), got {mask_count}", H);
|
||
assert_eq!(valid_count, T - H as usize,
|
||
"expected {} valid bars, got {valid_count}", T - H as usize);
|
||
eprintln!("lookahead_mask: {mask_count}/{T} masked, {valid_count}/{T} valid — OK");
|
||
}
|
||
|
||
/// Helper: launch `aux_horizon_update` once on the given mapped-pinned
|
||
/// ISV buffer.
|
||
fn run_aux_horizon_update(
|
||
stream: &Arc<CudaStream>,
|
||
kernel: &CudaFunction,
|
||
isv_buf: &MappedF32Buffer,
|
||
isv_h_idx: i32,
|
||
isv_h_target_idx: i32,
|
||
isv_h_target_var_idx: i32,
|
||
h_min: f32,
|
||
h_max: f32,
|
||
sentinel_h: f32,
|
||
) {
|
||
let isv_dev = isv_buf.dev_ptr;
|
||
unsafe {
|
||
stream
|
||
.launch_builder(kernel)
|
||
.arg(&isv_dev)
|
||
.arg(&isv_h_idx)
|
||
.arg(&isv_h_target_idx)
|
||
.arg(&isv_h_target_var_idx)
|
||
.arg(&h_min)
|
||
.arg(&h_max)
|
||
.arg(&sentinel_h)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (1, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
})
|
||
.expect("aux_horizon_update launch");
|
||
}
|
||
stream.synchronize().expect("sync");
|
||
}
|
||
|
||
/// Step 7b oracle: Pearl-A first-observation bootstrap — sentinel
|
||
/// must be REPLACED, not blended.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn aux_horizon_pearl_a_bootstrap() {
|
||
let stream = make_test_stream();
|
||
let kernel = load_aux_horizon_update(&stream);
|
||
|
||
// 1. Sentinel state: H=60.0, h_target=90.0 (first valid observation).
|
||
let isv_buf = build_isv_buf(SENTINEL_H);
|
||
let host = unsafe { std::slice::from_raw_parts_mut(isv_buf.host_ptr, ISV_TEST_SIZE) };
|
||
host[AVG_WIN_HOLD_TIME_BARS_INDEX] = 90.0;
|
||
|
||
run_aux_horizon_update(
|
||
&stream,
|
||
&kernel,
|
||
&isv_buf,
|
||
AUX_PRED_HORIZON_BARS_INDEX as i32,
|
||
AVG_WIN_HOLD_TIME_BARS_INDEX as i32,
|
||
-1, // no variance EMA → fixed α
|
||
H_MIN,
|
||
H_MAX,
|
||
SENTINEL_H,
|
||
);
|
||
|
||
let h_after_bootstrap = host[AUX_PRED_HORIZON_BARS_INDEX];
|
||
assert!(
|
||
(h_after_bootstrap - 90.0).abs() < 1e-3,
|
||
"Pearl-A bootstrap: expected H=90.0 (REPLACED, not blended), got {h_after_bootstrap}",
|
||
);
|
||
eprintln!("Pearl-A bootstrap: H={SENTINEL_H} sentinel + target=90.0 → H={h_after_bootstrap} (REPLACED)");
|
||
|
||
// 2. Subsequent observation: target=100.0 → blended (NOT replaced).
|
||
// Current H is now 90.0, NOT sentinel — kernel takes the EMA path.
|
||
host[AVG_WIN_HOLD_TIME_BARS_INDEX] = 100.0;
|
||
run_aux_horizon_update(
|
||
&stream,
|
||
&kernel,
|
||
&isv_buf,
|
||
AUX_PRED_HORIZON_BARS_INDEX as i32,
|
||
AVG_WIN_HOLD_TIME_BARS_INDEX as i32,
|
||
-1,
|
||
H_MIN,
|
||
H_MAX,
|
||
SENTINEL_H,
|
||
);
|
||
|
||
let h_after_blend = host[AUX_PRED_HORIZON_BARS_INDEX];
|
||
// α=0.01 → blend = 0.99*90 + 0.01*100 = 90.1 ± float noise
|
||
let expected = 0.99_f32 * 90.0 + 0.01_f32 * 100.0;
|
||
assert!(
|
||
(h_after_blend - expected).abs() < 1e-3,
|
||
"second observation: expected blended H≈{expected}, got {h_after_blend} \
|
||
(must be in (90, 100) — NOT replaced)",
|
||
);
|
||
assert!(
|
||
h_after_blend > 90.0 && h_after_blend < 100.0,
|
||
"blended H={h_after_blend} not in (90, 100) — should be small step from 90 toward 100",
|
||
);
|
||
eprintln!("Second obs: H={h_after_blend} (blended toward 100, expected ≈{expected})");
|
||
}
|
||
|
||
/// Step 7c oracle: Wiener-α EMA convergence — with α=0.01 fixed
|
||
/// (no variance EMA), H starting at 50.0 with steady target=100.0
|
||
/// should converge per the geometric `(1-α)^k * 50 + (1-(1-α)^k) * 100`.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn aux_horizon_converges_to_steady_target() {
|
||
let stream = make_test_stream();
|
||
let kernel = load_aux_horizon_update(&stream);
|
||
|
||
// Post-bootstrap state: H=50.0, target=100.0 (steady).
|
||
// 50.0 differs from sentinel 60.0 → bootstrap path NOT triggered.
|
||
let isv_buf = build_isv_buf(50.0);
|
||
let host = unsafe { std::slice::from_raw_parts_mut(isv_buf.host_ptr, ISV_TEST_SIZE) };
|
||
host[AVG_WIN_HOLD_TIME_BARS_INDEX] = 100.0;
|
||
|
||
const ITERS: usize = 100;
|
||
for _ in 0..ITERS {
|
||
run_aux_horizon_update(
|
||
&stream,
|
||
&kernel,
|
||
&isv_buf,
|
||
AUX_PRED_HORIZON_BARS_INDEX as i32,
|
||
AVG_WIN_HOLD_TIME_BARS_INDEX as i32,
|
||
-1,
|
||
H_MIN,
|
||
H_MAX,
|
||
SENTINEL_H,
|
||
);
|
||
}
|
||
|
||
let h_final = host[AUX_PRED_HORIZON_BARS_INDEX];
|
||
// Closed-form for fixed-α geometric blend:
|
||
// H_k = (1-α)^k * H_0 + (1 - (1-α)^k) * target
|
||
// α=0.01, k=100, H_0=50, target=100
|
||
// (1-α)^100 ≈ 0.366032
|
||
// H_100 ≈ 0.366 * 50 + 0.634 * 100 ≈ 81.7
|
||
let alpha: f32 = 0.01;
|
||
let one_minus_alpha = 1.0 - alpha;
|
||
let decay = one_minus_alpha.powi(ITERS as i32);
|
||
let expected = decay * 50.0 + (1.0 - decay) * 100.0;
|
||
let tol = 0.05; // accumulated f32 noise across 100 iters
|
||
assert!(
|
||
(h_final - expected).abs() < tol,
|
||
"Wiener-α convergence: expected H≈{expected:.4} after {ITERS} iters, got {h_final:.4} \
|
||
(decay=(1-{alpha})^{ITERS}≈{decay:.6})",
|
||
);
|
||
eprintln!("Convergence: H_0=50, target=100, α={alpha}, after {ITERS} iters → H={h_final:.4} (expected {expected:.4})");
|
||
}
|
||
|
||
/// Step 7d oracle: cold-start "no winning trades" guard — when
|
||
/// `h_target == 0.0` (no winning trade observed yet), the kernel
|
||
/// MUST keep the sentinel and not blend toward zero.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn aux_horizon_holds_sentinel_with_no_winning_trades() {
|
||
let stream = make_test_stream();
|
||
let kernel = load_aux_horizon_update(&stream);
|
||
|
||
// Sentinel state: H=60.0, target=0.0 (no winning trade yet).
|
||
let isv_buf = build_isv_buf(SENTINEL_H);
|
||
let host = unsafe { std::slice::from_raw_parts_mut(isv_buf.host_ptr, ISV_TEST_SIZE) };
|
||
host[AVG_WIN_HOLD_TIME_BARS_INDEX] = 0.0;
|
||
|
||
run_aux_horizon_update(
|
||
&stream,
|
||
&kernel,
|
||
&isv_buf,
|
||
AUX_PRED_HORIZON_BARS_INDEX as i32,
|
||
AVG_WIN_HOLD_TIME_BARS_INDEX as i32,
|
||
-1,
|
||
H_MIN,
|
||
H_MAX,
|
||
SENTINEL_H,
|
||
);
|
||
|
||
let h_after = host[AUX_PRED_HORIZON_BARS_INDEX];
|
||
assert!(
|
||
(h_after - SENTINEL_H).abs() < 1e-6,
|
||
"no winning trades guard: expected H=sentinel {SENTINEL_H} unchanged, got {h_after}",
|
||
);
|
||
eprintln!("No-winning-trades guard: H={h_after} (sentinel preserved)");
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// SP14 Layer C Phase C.6 oracle tests (2026-05-08).
|
||
//
|
||
// Added 1 test exercising the h_s2_aux RMS EMA producer:
|
||
//
|
||
// - h_s2_aux_rms_ema_pearl_a_bootstrap — sentinel REPLACED on first
|
||
// observation; EMA blend applied on second.
|
||
//
|
||
// Plan: docs/superpowers/plans/2026-05-07-sp14-layer-c-separate-aux-trunk.md §C.6
|
||
// ════════════════════════════════════════════════════════════════════
|
||
|
||
const H_S2_AUX_RMS_EMA_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/h_s2_aux_rms_ema_kernel.cubin"));
|
||
|
||
/// H_S2_AUX_RMS_EMA_INDEX — must match `sp14_isv_slots.rs`.
|
||
const H_S2_AUX_RMS_EMA_IDX: usize = 449;
|
||
|
||
fn load_h_s2_aux_rms_ema(stream: &Arc<CudaStream>) -> CudaFunction {
|
||
let module = stream
|
||
.context()
|
||
.load_cubin(H_S2_AUX_RMS_EMA_CUBIN.to_vec())
|
||
.expect("load h_s2_aux_rms_ema cubin");
|
||
module
|
||
.load_function("h_s2_aux_rms_ema_update")
|
||
.expect("load h_s2_aux_rms_ema_update function")
|
||
}
|
||
|
||
/// Launch `h_s2_aux_rms_ema_update` once and synchronize.
|
||
fn run_h_s2_aux_rms_ema(
|
||
stream: &Arc<CudaStream>,
|
||
kernel: &CudaFunction,
|
||
h_s2_aux_buf: &MappedF32Buffer,
|
||
b: i32,
|
||
sh2: i32,
|
||
isv_buf: &MappedF32Buffer,
|
||
alpha: f32,
|
||
) {
|
||
let h_ptr = h_s2_aux_buf.dev_ptr;
|
||
let isv_ptr = isv_buf.dev_ptr;
|
||
let isv_idx = H_S2_AUX_RMS_EMA_IDX as i32;
|
||
let smem = (256 * std::mem::size_of::<f32>()) as u32;
|
||
unsafe {
|
||
stream
|
||
.launch_builder(kernel)
|
||
.arg(&h_ptr)
|
||
.arg(&b)
|
||
.arg(&sh2)
|
||
.arg(&isv_ptr)
|
||
.arg(&isv_idx)
|
||
.arg(&alpha)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (256, 1, 1),
|
||
shared_mem_bytes: smem,
|
||
})
|
||
.expect("h_s2_aux_rms_ema_update launch");
|
||
}
|
||
stream.synchronize().expect("sync h_s2_aux_rms_ema");
|
||
}
|
||
|
||
/// Step C.6 oracle: Pearl-A first-observation bootstrap.
|
||
///
|
||
/// Scenario A — sentinel → replace:
|
||
/// Set ISV[449] = 0.0 (sentinel). Fill `h_s2_aux [B, SH2]` with
|
||
/// constant value 1.5. Expected RMS = sqrt(1.5² × N / N) = 1.5.
|
||
/// After one launch the kernel MUST replace (not blend): ISV[449] ≈ 1.5.
|
||
///
|
||
/// Scenario B — steady-state EMA blend:
|
||
/// Fill `h_s2_aux` with 3.0 (expected RMS = 3.0). Relaunch with
|
||
/// α=0.05 from current=1.5.
|
||
/// Expected: (1-0.05)*1.5 + 0.05*3.0 = 1.5*0.95 + 0.15 = 1.575.
|
||
/// ISV[449] must be strictly between 1.5 and 3.0 and close to 1.575.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn h_s2_aux_rms_ema_pearl_a_bootstrap() {
|
||
const B: i32 = 4;
|
||
const SH2: i32 = 256;
|
||
const ALPHA: f32 = 0.05;
|
||
|
||
let stream = make_test_stream();
|
||
let kernel = load_h_s2_aux_rms_ema(&stream);
|
||
|
||
// Allocate ISV buffer; zero-initialise to set sentinel.
|
||
let isv_buf = unsafe { MappedF32Buffer::new(ISV_TEST_SIZE) }
|
||
.expect("ISV buf alloc");
|
||
let host = unsafe {
|
||
std::slice::from_raw_parts_mut(isv_buf.host_ptr, ISV_TEST_SIZE)
|
||
};
|
||
// sentinel = 0.0 (zero-init already)
|
||
|
||
// Allocate h_s2_aux and fill with constant 1.5.
|
||
let n = (B * SH2) as usize;
|
||
let h_buf = unsafe { MappedF32Buffer::new(n) }.expect("h_s2_aux buf alloc");
|
||
let h_host = unsafe { std::slice::from_raw_parts_mut(h_buf.host_ptr, n) };
|
||
for v in h_host.iter_mut() { *v = 1.5; }
|
||
|
||
// ── Scenario A: Pearl-A bootstrap ─────────────────────────────
|
||
run_h_s2_aux_rms_ema(&stream, &kernel, &h_buf, B, SH2, &isv_buf, ALPHA);
|
||
|
||
let slot_a = host[H_S2_AUX_RMS_EMA_IDX];
|
||
assert!(
|
||
(slot_a - 1.5).abs() < 1e-5,
|
||
"Pearl-A bootstrap: expected ISV[449] = 1.5 (replace sentinel), got {slot_a}",
|
||
);
|
||
eprintln!("Pearl-A bootstrap (h=1.5): ISV[449] = {slot_a:.6} (expected 1.5)");
|
||
|
||
// ── Scenario B: steady-state EMA blend ────────────────────────
|
||
for v in h_host.iter_mut() { *v = 3.0; }
|
||
|
||
run_h_s2_aux_rms_ema(&stream, &kernel, &h_buf, B, SH2, &isv_buf, ALPHA);
|
||
|
||
let slot_b = host[H_S2_AUX_RMS_EMA_IDX];
|
||
let expected = (1.0 - ALPHA) * 1.5 + ALPHA * 3.0; // 1.575
|
||
assert!(
|
||
(slot_b - expected).abs() < 1e-4,
|
||
"EMA blend: expected ISV[449] ≈ {expected:.6} after α={ALPHA} step from 1.5→3.0, \
|
||
got {slot_b:.6}",
|
||
);
|
||
assert!(
|
||
slot_b > 1.5 && slot_b < 3.0,
|
||
"EMA blend: ISV[449]={slot_b} must be strictly between 1.5 and 3.0",
|
||
);
|
||
eprintln!(
|
||
"EMA blend (h=3.0, α={ALPHA}): ISV[449] = {slot_b:.6} (expected {expected:.6})"
|
||
);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// SP14 Layer C Phase C.9 synthetic smoke (2026-05-08).
|
||
//
|
||
// Verifies the full aux trunk learning pipeline:
|
||
//
|
||
// - aux_trunk_learns_synthetic_uptrend — 100 steps of forward +
|
||
// backward + Adam on a synthetic all-UP batch; asserts CE loss <
|
||
// 0.1 and dir_acc > 0.95 at convergence.
|
||
//
|
||
// Architectural intent: if this test FAILS (loss stays near ln(2) ≈
|
||
// 0.693 = random baseline), the aux trunk gradient chain is broken and
|
||
// L40S dispatch must be blocked until the root cause is fixed. See
|
||
// `pearl_separate_aux_trunk_when_shared_starves.md` — aux-loss
|
||
// flatline at random baseline is a structural symptom, not a
|
||
// hyperparameter problem.
|
||
//
|
||
// Plan: docs/superpowers/plans/2026-05-07-sp14-layer-c-separate-aux-trunk.md §C.9
|
||
// ════════════════════════════════════════════════════════════════════
|
||
|
||
const AUX_HEADS_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/aux_heads_kernel.cubin"));
|
||
const DQN_UTILITY_CUBIN: &[u8] =
|
||
include_bytes!(concat!(env!("OUT_DIR"), "/dqn_utility_kernels.cubin"));
|
||
|
||
struct SmokeKernels {
|
||
trunk_fwd: CudaFunction,
|
||
trunk_bwd_dh_pre: CudaFunction,
|
||
trunk_bwd_dW: CudaFunction,
|
||
trunk_bwd_db: CudaFunction,
|
||
head_fwd: CudaFunction,
|
||
head_loss: CudaFunction,
|
||
head_bwd: CudaFunction,
|
||
grad_norm: CudaFunction,
|
||
grad_norm_final: CudaFunction,
|
||
adam: CudaFunction,
|
||
}
|
||
|
||
fn load_smoke_kernels(stream: &Arc<CudaStream>) -> SmokeKernels {
|
||
let trunk_fwd_mod = stream.context()
|
||
.load_cubin(AUX_TRUNK_FORWARD_CUBIN.to_vec())
|
||
.expect("load trunk_fwd cubin");
|
||
let trunk_bwd_mod = stream.context()
|
||
.load_cubin(AUX_TRUNK_BACKWARD_CUBIN.to_vec())
|
||
.expect("load trunk_bwd cubin");
|
||
let heads_mod = stream.context()
|
||
.load_cubin(AUX_HEADS_CUBIN.to_vec())
|
||
.expect("load aux_heads cubin");
|
||
let util_mod = stream.context()
|
||
.load_cubin(DQN_UTILITY_CUBIN.to_vec())
|
||
.expect("load dqn_utility cubin");
|
||
|
||
SmokeKernels {
|
||
trunk_fwd: trunk_fwd_mod.load_function("aux_trunk_forward").expect("aux_trunk_forward"),
|
||
trunk_bwd_dh_pre: trunk_bwd_mod.load_function("aux_trunk_bwd_dh_pre").expect("bwd_dh_pre"),
|
||
trunk_bwd_dW: trunk_bwd_mod.load_function("aux_trunk_bwd_dW_reduce").expect("bwd_dW"),
|
||
trunk_bwd_db: trunk_bwd_mod.load_function("aux_trunk_bwd_db_reduce").expect("bwd_db"),
|
||
head_fwd: heads_mod.load_function("aux_next_bar_forward").expect("head_fwd"),
|
||
head_loss: heads_mod.load_function("aux_next_bar_loss_reduce").expect("head_loss"),
|
||
head_bwd: heads_mod.load_function("aux_next_bar_backward").expect("head_bwd"),
|
||
grad_norm: util_mod.load_function("dqn_grad_norm_kernel").expect("grad_norm"),
|
||
grad_norm_final: util_mod.load_function("dqn_grad_norm_finalize").expect("grad_norm_fin"),
|
||
adam: util_mod.load_function("dqn_adam_update_kernel").expect("adam"),
|
||
}
|
||
}
|
||
|
||
/// Synthetic learning smoke: 100 forward + backward + Adam steps on an
|
||
/// all-UP batch. Asserts CE loss < 0.1 and dir_acc > 0.95.
|
||
///
|
||
/// Topology (small dims for speed on RTX 3050):
|
||
/// B=16, ENC=32, H1_trunk=32, H2_trunk=16, SH2=32
|
||
/// H_HEAD=32 (compile-time in aux_heads_kernel.cu), K=2
|
||
///
|
||
/// Only the 6 aux trunk weight tensors are updated via Adam. The 4 aux
|
||
/// head params are updated via simple SGD on the host (test harness only)
|
||
/// by reading the per-sample partial gradients through mapped-pinned memory
|
||
/// and summing them in Rust. This is valid test orchestration — not a
|
||
/// CPU compute path in production.
|
||
///
|
||
/// Failure condition: loss > 0.1 after 100 steps → gradient chain broken.
|
||
#[test]
|
||
#[ignore = "requires GPU"]
|
||
fn aux_trunk_learns_synthetic_uptrend() {
|
||
const B: usize = 16;
|
||
const ENC: usize = 32; // encoder_out_dim fed into trunk
|
||
const H1: usize = 32; // trunk hidden layer 1
|
||
const H2: usize = 16; // trunk hidden layer 2
|
||
const SH2: usize = 32; // trunk output dim = head input dim
|
||
const H_HEAD: usize = 32; // AUX_HIDDEN_DIM compile-time in aux_heads_kernel.cu
|
||
const K: usize = 2; // binary: class 0 = DOWN, class 1 = UP
|
||
const STEPS: usize = 100;
|
||
const LR_TRUNK: f32 = 3e-3; // aggressive LR for fast convergence on clean signal
|
||
const LR_HEAD: f32 = 3e-3;
|
||
const LOSS_THRESHOLD: f32 = 0.1;
|
||
const DIR_ACC_THRESHOLD: f32 = 0.95;
|
||
|
||
let stream = make_test_stream();
|
||
let k = load_smoke_kernels(&stream);
|
||
|
||
let mut rng: u32 = 0xDEAD_BEEF;
|
||
|
||
// ── Allocate aux trunk params + m/v ─────────────────────────────
|
||
// w1 [ENC, H1], b1 [H1], w2 [H1, H2], b2 [H2], w3 [H2, SH2], b3 [SH2]
|
||
let sizes_trunk: [usize; 6] = [
|
||
ENC * H1, H1, H1 * H2, H2, H2 * SH2, SH2,
|
||
];
|
||
let total_trunk: usize = sizes_trunk.iter().sum();
|
||
|
||
macro_rules! xavier_buf {
|
||
($n:expr, $fan:expr) => {{
|
||
let buf = unsafe { MappedF32Buffer::new($n) }.expect("alloc");
|
||
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, $n) };
|
||
let bound = (2.0_f32 / $fan as f32).sqrt();
|
||
for v in host.iter_mut() {
|
||
*v = lcg_next_signed(&mut rng) * bound;
|
||
}
|
||
buf
|
||
}};
|
||
}
|
||
macro_rules! zero_buf {
|
||
($n:expr) => {{
|
||
let buf = unsafe { MappedF32Buffer::new($n) }.expect("alloc");
|
||
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, $n) };
|
||
for v in host.iter_mut() { *v = 0.0; }
|
||
buf
|
||
}};
|
||
}
|
||
|
||
// Trunk weights — Xavier init
|
||
let w1 = xavier_buf!(ENC * H1, ENC + H1);
|
||
let b1 = zero_buf!(H1);
|
||
let w2 = xavier_buf!(H1 * H2, H1 + H2);
|
||
let b2 = zero_buf!(H2);
|
||
let w3 = xavier_buf!(H2 * SH2, H2 + SH2);
|
||
let b3 = zero_buf!(SH2);
|
||
// Adam m/v for trunk — zero init
|
||
let w1_m = zero_buf!(ENC * H1); let w1_v = zero_buf!(ENC * H1);
|
||
let b1_m = zero_buf!(H1); let b1_v = zero_buf!(H1);
|
||
let w2_m = zero_buf!(H1 * H2); let w2_v = zero_buf!(H1 * H2);
|
||
let b2_m = zero_buf!(H2); let b2_v = zero_buf!(H2);
|
||
let w3_m = zero_buf!(H2 * SH2); let w3_v = zero_buf!(H2 * SH2);
|
||
let b3_m = zero_buf!(SH2); let b3_v = zero_buf!(SH2);
|
||
// Adam grad buffers for trunk — zero init
|
||
let w1_g = zero_buf!(ENC * H1);
|
||
let b1_g = zero_buf!(H1);
|
||
let w2_g = zero_buf!(H1 * H2);
|
||
let b2_g = zero_buf!(H2);
|
||
let w3_g = zero_buf!(H2 * SH2);
|
||
let b3_g = zero_buf!(SH2);
|
||
// Adam utility buffers for trunk (block_sums/grad_norm_buf replaced by flat_grad path below)
|
||
let _norm_blocks = (total_trunk + 255) / 256;
|
||
let _grad_norm_buf = zero_buf!(1);
|
||
let wd_mask = {
|
||
let buf = unsafe { MappedF32Buffer::new(total_trunk) }.expect("alloc");
|
||
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, total_trunk) };
|
||
for v in host.iter_mut() { *v = 0.0; } // no weight decay
|
||
buf
|
||
};
|
||
let nan_flags = zero_buf!(2); // diag_slot=0, tiny buf
|
||
let engage_buf = zero_buf!(1); // unused (engage_buf_offset=-1)
|
||
// Adam step counter + LR + clip — mapped-pinned scalars
|
||
let adam_t_buf = {
|
||
let buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc t_buf");
|
||
// We'll write an i32 through the f32 mapped-pinned buffer host_ptr.
|
||
// Safe because i32 and f32 are same size and we treat it as raw bytes.
|
||
buf
|
||
};
|
||
let lr_buf = {
|
||
let buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc lr");
|
||
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, 1) };
|
||
host[0] = LR_TRUNK;
|
||
buf
|
||
};
|
||
let clip_buf = {
|
||
let buf = unsafe { MappedF32Buffer::new(1) }.expect("alloc clip");
|
||
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, 1) };
|
||
host[0] = 10.0_f32; // generous clip
|
||
buf
|
||
};
|
||
|
||
// ── Allocate aux head params (trained by host-side SGD) ──────────
|
||
// wh1 [H_HEAD, SH2], bh1 [H_HEAD], wh2 [K, H_HEAD], bh2 [K]
|
||
let wh1 = xavier_buf!(H_HEAD * SH2, H_HEAD + SH2);
|
||
let bh1 = zero_buf!(H_HEAD);
|
||
let wh2 = xavier_buf!(K * H_HEAD, K + H_HEAD);
|
||
let bh2 = zero_buf!(K);
|
||
|
||
// ── Allocate forward scratch buffers ─────────────────────────────
|
||
let x_in = zero_buf!(B * ENC);
|
||
let h_aux1 = zero_buf!(B * H1);
|
||
let h_aux2 = zero_buf!(B * H2);
|
||
let h_s2_aux = zero_buf!(B * SH2);
|
||
let hidden_out = zero_buf!(B * H_HEAD);
|
||
let logits_out = zero_buf!(B * K);
|
||
let softmax_out = zero_buf!(B * K);
|
||
let loss_out = zero_buf!(1);
|
||
let valid_count = zero_buf!(1);
|
||
let dh_s2_aux_out = zero_buf!(B * SH2);
|
||
|
||
// ── Allocate head backward partial-grad buffers ──────────────────
|
||
// Per-sample partials — summed on host, applied as SGD
|
||
let dWh1_partial = zero_buf!(B * H_HEAD * SH2);
|
||
let dbh1_partial = zero_buf!(B * H_HEAD);
|
||
let dWh2_partial = zero_buf!(B * K * H_HEAD);
|
||
let dbh2_partial = zero_buf!(B * K);
|
||
|
||
// ── Allocate labels buffer (all UP = label 1) ────────────────────
|
||
// Labels are i32; reuse a MappedF32Buffer for the raw pointer.
|
||
let labels_buf = {
|
||
let buf = unsafe { MappedF32Buffer::new(B) }.expect("alloc labels");
|
||
let host = unsafe {
|
||
std::slice::from_raw_parts_mut(buf.host_ptr as *mut i32, B)
|
||
};
|
||
for v in host.iter_mut() { *v = 1; } // all UP
|
||
buf
|
||
};
|
||
|
||
// ── Synthetic encoder output: constant signal ────────────────────
|
||
// All samples share the same non-zero encoder output so the trunk
|
||
// can learn a direction-invariant mapping to the "UP" class.
|
||
{
|
||
let host = unsafe { std::slice::from_raw_parts_mut(x_in.host_ptr, B * ENC) };
|
||
for (i, v) in host.iter_mut().enumerate() {
|
||
// Monotone: feature[j] = +1/(j+1) — same for all samples.
|
||
let j = i % ENC;
|
||
*v = 0.5_f32 / (j as f32 + 1.0);
|
||
}
|
||
}
|
||
|
||
let mut final_loss = f32::NAN;
|
||
let mut final_acc = f32::NAN;
|
||
|
||
for step in 0..STEPS {
|
||
// ── Step counter (i32 written through f32 mapped-pinned buf) ──
|
||
let t_val: i32 = (step + 1) as i32;
|
||
unsafe {
|
||
*(adam_t_buf.host_ptr as *mut i32) = t_val;
|
||
}
|
||
|
||
// ── 1. Aux trunk forward ──────────────────────────────────────
|
||
let b_i = B as i32;
|
||
let enc_i = ENC as i32;
|
||
let h1_i = H1 as i32;
|
||
let h2_i = H2 as i32;
|
||
let sh2_i = SH2 as i32;
|
||
let smem_trunk = ((H1 + H2) * std::mem::size_of::<f32>()) as u32;
|
||
unsafe {
|
||
stream.launch_builder(&k.trunk_fwd)
|
||
.arg(&x_in.dev_ptr)
|
||
.arg(&w1.dev_ptr).arg(&b1.dev_ptr)
|
||
.arg(&w2.dev_ptr).arg(&b2.dev_ptr)
|
||
.arg(&w3.dev_ptr).arg(&b3.dev_ptr)
|
||
.arg(&h_aux1.dev_ptr)
|
||
.arg(&h_aux2.dev_ptr)
|
||
.arg(&h_s2_aux.dev_ptr)
|
||
.arg(&b_i).arg(&enc_i).arg(&h1_i).arg(&h2_i).arg(&sh2_i)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (B as u32, 1, 1),
|
||
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
|
||
shared_mem_bytes: smem_trunk,
|
||
})
|
||
.expect("trunk_fwd launch");
|
||
}
|
||
stream.synchronize().expect("sync trunk_fwd");
|
||
|
||
// ── 2. Aux head forward ───────────────────────────────────────
|
||
let _hh_i = H_HEAD as i32; // H_HEAD hard-coded in aux_heads_kernel.cu
|
||
let k_i = K as i32;
|
||
let smem_head = ((H_HEAD + K) * std::mem::size_of::<f32>()) as u32;
|
||
unsafe {
|
||
stream.launch_builder(&k.head_fwd)
|
||
.arg(&h_s2_aux.dev_ptr)
|
||
.arg(&wh1.dev_ptr).arg(&bh1.dev_ptr)
|
||
.arg(&wh2.dev_ptr).arg(&bh2.dev_ptr)
|
||
.arg(&b_i).arg(&sh2_i).arg(&k_i)
|
||
.arg(&hidden_out.dev_ptr)
|
||
.arg(&logits_out.dev_ptr)
|
||
.arg(&softmax_out.dev_ptr)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (B as u32, 1, 1),
|
||
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
|
||
shared_mem_bytes: smem_head,
|
||
})
|
||
.expect("head_fwd launch");
|
||
}
|
||
stream.synchronize().expect("sync head_fwd");
|
||
|
||
// ── 3. CE loss ────────────────────────────────────────────────
|
||
let smem_loss = (2 * AUX_TRUNK_BLOCK as usize * std::mem::size_of::<f32>()) as u32;
|
||
unsafe {
|
||
stream.launch_builder(&k.head_loss)
|
||
.arg(&softmax_out.dev_ptr)
|
||
.arg(&labels_buf.dev_ptr)
|
||
.arg(&b_i).arg(&k_i)
|
||
.arg(&loss_out.dev_ptr)
|
||
.arg(&valid_count.dev_ptr)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
|
||
shared_mem_bytes: smem_loss,
|
||
})
|
||
.expect("head_loss launch");
|
||
}
|
||
stream.synchronize().expect("sync head_loss");
|
||
|
||
// ── 4. Head backward (per-sample partial grads + dh_s2_aux) ──
|
||
let smem_bwd = ((2 * H_HEAD + K) * std::mem::size_of::<f32>()) as u32;
|
||
unsafe {
|
||
// Zero dh_s2_aux_out before backward (accumulate fresh each step)
|
||
let dh_host = std::slice::from_raw_parts_mut(dh_s2_aux_out.host_ptr, B * SH2);
|
||
for v in dh_host.iter_mut() { *v = 0.0; }
|
||
stream.launch_builder(&k.head_bwd)
|
||
.arg(&h_s2_aux.dev_ptr)
|
||
.arg(&wh1.dev_ptr)
|
||
.arg(&wh2.dev_ptr)
|
||
.arg(&hidden_out.dev_ptr)
|
||
.arg(&softmax_out.dev_ptr)
|
||
.arg(&labels_buf.dev_ptr)
|
||
.arg(&valid_count.dev_ptr)
|
||
.arg(&b_i).arg(&sh2_i).arg(&k_i)
|
||
.arg(&dWh1_partial.dev_ptr).arg(&dbh1_partial.dev_ptr)
|
||
.arg(&dWh2_partial.dev_ptr).arg(&dbh2_partial.dev_ptr)
|
||
.arg(&dh_s2_aux_out.dev_ptr)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (B as u32, 1, 1),
|
||
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
|
||
shared_mem_bytes: smem_bwd,
|
||
})
|
||
.expect("head_bwd launch");
|
||
}
|
||
stream.synchronize().expect("sync head_bwd");
|
||
|
||
// ── 5. Host-side SGD on head params ──────────────────────────
|
||
// Read per-sample partials via mapped-pinned, sum → SGD update.
|
||
// This is test-harness orchestration — the production aux head
|
||
// trainer does not execute on the CPU.
|
||
{
|
||
// dWh1: sum over B dim, shape [H_HEAD, SH2]
|
||
let dwh1_h = unsafe { std::slice::from_raw_parts(dWh1_partial.host_ptr, B * H_HEAD * SH2) };
|
||
let wh1_h = unsafe { std::slice::from_raw_parts_mut(wh1.host_ptr, H_HEAD * SH2) };
|
||
for idx in 0..H_HEAD * SH2 {
|
||
let mut g = 0.0_f32;
|
||
for b_idx in 0..B { g += dwh1_h[b_idx * H_HEAD * SH2 + idx]; }
|
||
wh1_h[idx] -= LR_HEAD * g;
|
||
}
|
||
// dbh1
|
||
let dbh1_h = unsafe { std::slice::from_raw_parts(dbh1_partial.host_ptr, B * H_HEAD) };
|
||
let bh1_h = unsafe { std::slice::from_raw_parts_mut(bh1.host_ptr, H_HEAD) };
|
||
for idx in 0..H_HEAD {
|
||
let mut g = 0.0_f32;
|
||
for b_idx in 0..B { g += dbh1_h[b_idx * H_HEAD + idx]; }
|
||
bh1_h[idx] -= LR_HEAD * g;
|
||
}
|
||
// dWh2: shape [K, H_HEAD]
|
||
let dwh2_h = unsafe { std::slice::from_raw_parts(dWh2_partial.host_ptr, B * K * H_HEAD) };
|
||
let wh2_h = unsafe { std::slice::from_raw_parts_mut(wh2.host_ptr, K * H_HEAD) };
|
||
for idx in 0..K * H_HEAD {
|
||
let mut g = 0.0_f32;
|
||
for b_idx in 0..B { g += dwh2_h[b_idx * K * H_HEAD + idx]; }
|
||
wh2_h[idx] -= LR_HEAD * g;
|
||
}
|
||
// dbh2
|
||
let dbh2_h = unsafe { std::slice::from_raw_parts(dbh2_partial.host_ptr, B * K) };
|
||
let bh2_h = unsafe { std::slice::from_raw_parts_mut(bh2.host_ptr, K) };
|
||
for idx in 0..K {
|
||
let mut g = 0.0_f32;
|
||
for b_idx in 0..B { g += dbh2_h[b_idx * K + idx]; }
|
||
bh2_h[idx] -= LR_HEAD * g;
|
||
}
|
||
}
|
||
|
||
// ── 6. Aux trunk backward ─────────────────────────────────────
|
||
//
|
||
// Kernel signatures (from aux_trunk_backward_kernel.cu):
|
||
//
|
||
// aux_trunk_bwd_dh_pre(d_logits[B,SH2], w3[H2,SH2], w2[H1,H2],
|
||
// h_aux1[B,H1], h_aux2[B,H2],
|
||
// dh_aux2_pre_out[B,H2], dh_aux1_pre_out[B,H1],
|
||
// B, H1, H2, AUX_HIDDEN_DIM=SH2)
|
||
// shmem: H2 * sizeof(f32) (sh_dh2_pre cache)
|
||
// grid: (B,1,1)
|
||
//
|
||
// d_logits here = dh_s2_aux_out from head backward (upstream grad).
|
||
// AUX_HIDDEN_DIM = SH2 in the kernel (the trunk output / head input dim).
|
||
//
|
||
// aux_trunk_bwd_dW_reduce(A[B,Krows], B_grad[B,Jcols], dW_out[Krows,Jcols],
|
||
// B, Krows, Jcols)
|
||
// shmem: 256 * sizeof(f32) grid: (Krows*Jcols, 1, 1)
|
||
// Called 3×:
|
||
// dW3: A=h_aux2[B,H2], B_grad=dh_s2_aux_out[B,SH2], dW3[H2,SH2]
|
||
// dW2: A=h_aux1[B,H1], B_grad=dh_pre2[B,H2], dW2[H1,H2]
|
||
// dW1: A=x_in [B,ENC], B_grad=dh_pre1[B,H1], dW1[ENC,H1]
|
||
//
|
||
// aux_trunk_bwd_db_reduce(B_grad[B,Jcols], db_out[Jcols], B, Jcols)
|
||
// shmem: 256 * sizeof(f32) grid: (Jcols, 1, 1)
|
||
// Called 3×: db3(SH2), db2(H2), db1(H1)
|
||
|
||
// Phase A: dh_pre (layer 2 and 1 pre-activation grads).
|
||
let dh_pre1 = zero_buf!(B * H1); // dh_aux1_pre_out [B, H1]
|
||
let dh_pre2 = zero_buf!(B * H2); // dh_aux2_pre_out [B, H2]
|
||
// shmem = H2 floats (sh_dh2_pre cache used inside kernel)
|
||
let smem_dh = (H2 * std::mem::size_of::<f32>()) as u32;
|
||
let sh2_i_aux_hidden = sh2_i; // AUX_HIDDEN_DIM = SH2 in this test
|
||
unsafe {
|
||
stream.launch_builder(&k.trunk_bwd_dh_pre)
|
||
.arg(&dh_s2_aux_out.dev_ptr) // d_logits [B, SH2]
|
||
.arg(&w3.dev_ptr) // w3 [H2, SH2]
|
||
.arg(&w2.dev_ptr) // w2 [H1, H2]
|
||
.arg(&h_aux1.dev_ptr) // h_aux1 [B, H1]
|
||
.arg(&h_aux2.dev_ptr) // h_aux2 [B, H2]
|
||
.arg(&dh_pre2.dev_ptr) // dh_aux2_pre_out [B, H2]
|
||
.arg(&dh_pre1.dev_ptr) // dh_aux1_pre_out [B, H1]
|
||
.arg(&b_i) // B
|
||
.arg(&h1_i) // H1
|
||
.arg(&h2_i) // H2
|
||
.arg(&sh2_i_aux_hidden) // AUX_HIDDEN_DIM (=SH2 here)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (B as u32, 1, 1),
|
||
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
|
||
shared_mem_bytes: smem_dh,
|
||
})
|
||
.expect("bwd_dh_pre launch");
|
||
}
|
||
stream.synchronize().expect("sync bwd_dh_pre");
|
||
|
||
// Phase B: dW reduce — 3 separate launches (generic kernel, one call per layer).
|
||
// shmem = 256 * sizeof(f32) for the block-tree-reduce.
|
||
let smem_dW = (256 * std::mem::size_of::<f32>()) as u32;
|
||
let smem_db = (256 * std::mem::size_of::<f32>()) as u32;
|
||
unsafe {
|
||
// dW3: A=h_aux2[B,H2], B_grad=dh_s2_aux_out[B,SH2], dW3[H2,SH2]
|
||
stream.launch_builder(&k.trunk_bwd_dW)
|
||
.arg(&h_aux2.dev_ptr)
|
||
.arg(&dh_s2_aux_out.dev_ptr)
|
||
.arg(&w3_g.dev_ptr)
|
||
.arg(&b_i).arg(&h2_i).arg(&sh2_i)
|
||
.launch(LaunchConfig {
|
||
grid_dim: ((H2 * SH2) as u32, 1, 1),
|
||
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
|
||
shared_mem_bytes: smem_dW,
|
||
})
|
||
.expect("bwd_dW3 launch");
|
||
stream.synchronize().expect("sync bwd_dW3");
|
||
|
||
// dW2: A=h_aux1[B,H1], B_grad=dh_pre2[B,H2], dW2[H1,H2]
|
||
stream.launch_builder(&k.trunk_bwd_dW)
|
||
.arg(&h_aux1.dev_ptr)
|
||
.arg(&dh_pre2.dev_ptr)
|
||
.arg(&w2_g.dev_ptr)
|
||
.arg(&b_i).arg(&h1_i).arg(&h2_i)
|
||
.launch(LaunchConfig {
|
||
grid_dim: ((H1 * H2) as u32, 1, 1),
|
||
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
|
||
shared_mem_bytes: smem_dW,
|
||
})
|
||
.expect("bwd_dW2 launch");
|
||
stream.synchronize().expect("sync bwd_dW2");
|
||
|
||
// dW1: A=x_in[B,ENC], B_grad=dh_pre1[B,H1], dW1[ENC,H1]
|
||
stream.launch_builder(&k.trunk_bwd_dW)
|
||
.arg(&x_in.dev_ptr)
|
||
.arg(&dh_pre1.dev_ptr)
|
||
.arg(&w1_g.dev_ptr)
|
||
.arg(&b_i).arg(&enc_i).arg(&h1_i)
|
||
.launch(LaunchConfig {
|
||
grid_dim: ((ENC * H1) as u32, 1, 1),
|
||
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
|
||
shared_mem_bytes: smem_dW,
|
||
})
|
||
.expect("bwd_dW1 launch");
|
||
stream.synchronize().expect("sync bwd_dW1");
|
||
}
|
||
|
||
// Phase C: db reduce — 3 separate launches.
|
||
unsafe {
|
||
// db3: B_grad=dh_s2_aux_out[B,SH2], db3[SH2]
|
||
stream.launch_builder(&k.trunk_bwd_db)
|
||
.arg(&dh_s2_aux_out.dev_ptr)
|
||
.arg(&b3_g.dev_ptr)
|
||
.arg(&b_i).arg(&sh2_i)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (SH2 as u32, 1, 1),
|
||
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
|
||
shared_mem_bytes: smem_db,
|
||
})
|
||
.expect("bwd_db3 launch");
|
||
stream.synchronize().expect("sync bwd_db3");
|
||
|
||
// db2: B_grad=dh_pre2[B,H2], db2[H2]
|
||
stream.launch_builder(&k.trunk_bwd_db)
|
||
.arg(&dh_pre2.dev_ptr)
|
||
.arg(&b2_g.dev_ptr)
|
||
.arg(&b_i).arg(&h2_i)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (H2 as u32, 1, 1),
|
||
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
|
||
shared_mem_bytes: smem_db,
|
||
})
|
||
.expect("bwd_db2 launch");
|
||
stream.synchronize().expect("sync bwd_db2");
|
||
|
||
// db1: B_grad=dh_pre1[B,H1], db1[H1]
|
||
stream.launch_builder(&k.trunk_bwd_db)
|
||
.arg(&dh_pre1.dev_ptr)
|
||
.arg(&b1_g.dev_ptr)
|
||
.arg(&b_i).arg(&h1_i)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (H1 as u32, 1, 1),
|
||
block_dim: (AUX_TRUNK_BLOCK, 1, 1),
|
||
shared_mem_bytes: smem_db,
|
||
})
|
||
.expect("bwd_db1 launch");
|
||
stream.synchronize().expect("sync bwd_db1");
|
||
}
|
||
|
||
// ── 7. Grad norm for trunk (Phase 1: standalone per-tensor) ───
|
||
// Concatenate all trunk grad buffers logically via flattened ptrs.
|
||
// We do ONE grad_norm over the concatenated logical gradient space.
|
||
// Since grad bufs are NOT contiguous, we use the dqn_grad_norm_kernel
|
||
// individually per tensor, accumulate block_sums, then finalize once.
|
||
//
|
||
// Simpler approach: since total_trunk is small (≤ 5K), a single
|
||
// per-elem flat reduction over a scratch grad buffer is fine.
|
||
// We write a flat copy to a contiguous scratch, run grad_norm once,
|
||
// then run 6 Adam launches.
|
||
let flat_grad = {
|
||
let buf = unsafe { MappedF32Buffer::new(total_trunk) }.expect("flat_grad");
|
||
let host = unsafe { std::slice::from_raw_parts_mut(buf.host_ptr, total_trunk) };
|
||
let g_bufs: [(&MappedF32Buffer, usize); 6] = [
|
||
(&w1_g, ENC*H1), (&b1_g, H1), (&w2_g, H1*H2),
|
||
(&b2_g, H2), (&w3_g, H2*SH2), (&b3_g, SH2),
|
||
];
|
||
let mut off = 0;
|
||
for (buf_ref, n) in g_bufs {
|
||
let src = unsafe { std::slice::from_raw_parts(buf_ref.host_ptr, n) };
|
||
host[off..off+n].copy_from_slice(src);
|
||
off += n;
|
||
}
|
||
buf
|
||
};
|
||
let flat_norm_blocks = (total_trunk + 255) / 256;
|
||
let flat_block_sums = zero_buf!(flat_norm_blocks);
|
||
let flat_grad_norm = zero_buf!(1);
|
||
{
|
||
let total_i = total_trunk as i32;
|
||
let num_b_i = flat_norm_blocks as i32;
|
||
unsafe {
|
||
stream.launch_builder(&k.grad_norm)
|
||
.arg(&flat_grad.dev_ptr)
|
||
.arg(&flat_block_sums.dev_ptr)
|
||
.arg(&total_i)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (flat_norm_blocks as u32, 1, 1),
|
||
block_dim: (256, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
})
|
||
.expect("grad_norm launch");
|
||
stream.synchronize().expect("sync grad_norm");
|
||
stream.launch_builder(&k.grad_norm_final)
|
||
.arg(&flat_block_sums.dev_ptr)
|
||
.arg(&flat_grad_norm.dev_ptr)
|
||
.arg(&num_b_i)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (256, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
})
|
||
.expect("grad_norm_final launch");
|
||
}
|
||
stream.synchronize().expect("sync grad_norm_final");
|
||
}
|
||
|
||
// ── 8. Adam update for each trunk tensor ──────────────────────
|
||
let trunk_tensors: [(&MappedF32Buffer, &MappedF32Buffer, &MappedF32Buffer, &MappedF32Buffer, usize); 6] = [
|
||
(&w1, &w1_g, &w1_m, &w1_v, ENC*H1),
|
||
(&b1, &b1_g, &b1_m, &b1_v, H1),
|
||
(&w2, &w2_g, &w2_m, &w2_v, H1*H2),
|
||
(&b2, &b2_g, &b2_m, &b2_v, H2),
|
||
(&w3, &w3_g, &w3_m, &w3_v, H2*SH2),
|
||
(&b3, &b3_g, &b3_m, &b3_v, SH2),
|
||
];
|
||
for (p_buf, g_buf, m_buf, v_buf, n) in &trunk_tensors {
|
||
let n_i = *n as i32;
|
||
let blocks = (n + 255) / 256;
|
||
let beta1: f32 = 0.9;
|
||
let beta2: f32 = 0.999;
|
||
let eps: f32 = 1e-8;
|
||
let wd: f32 = 0.0;
|
||
let wc_max: f32 = 0.0; // disabled
|
||
let l1_end: i32 = 0;
|
||
let l1_lam: f32 = 0.0;
|
||
let diag: i32 = 0;
|
||
let engage_off: i32 = -1; // disabled
|
||
unsafe {
|
||
stream.launch_builder(&k.adam)
|
||
.arg(&p_buf.dev_ptr)
|
||
.arg(&g_buf.dev_ptr)
|
||
.arg(&m_buf.dev_ptr)
|
||
.arg(&v_buf.dev_ptr)
|
||
.arg(&flat_grad_norm.dev_ptr)
|
||
.arg(&lr_buf.dev_ptr)
|
||
.arg(&beta1)
|
||
.arg(&beta2)
|
||
.arg(&eps)
|
||
.arg(&wd)
|
||
.arg(&clip_buf.dev_ptr)
|
||
.arg(&adam_t_buf.dev_ptr)
|
||
.arg(&n_i)
|
||
.arg(&wd_mask.dev_ptr)
|
||
.arg(&l1_end)
|
||
.arg(&l1_lam)
|
||
.arg(&wc_max)
|
||
.arg(&nan_flags.dev_ptr)
|
||
.arg(&diag)
|
||
.arg(&engage_buf.dev_ptr)
|
||
.arg(&engage_off)
|
||
.launch(LaunchConfig {
|
||
grid_dim: (blocks as u32, 1, 1),
|
||
block_dim: (256, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
})
|
||
.expect("adam trunk launch");
|
||
}
|
||
}
|
||
stream.synchronize().expect("sync adam");
|
||
|
||
// ── 9. Read loss + compute dir_acc for this step ──────────────
|
||
let loss_h = unsafe { std::slice::from_raw_parts(loss_out.host_ptr, 1) };
|
||
let sm_h = unsafe { std::slice::from_raw_parts(softmax_out.host_ptr, B * K) };
|
||
let mut correct = 0usize;
|
||
for b_idx in 0..B {
|
||
// argmax of softmax row: UP (class 1) predicted?
|
||
let p0 = sm_h[b_idx * K];
|
||
let p1 = sm_h[b_idx * K + 1];
|
||
if p1 >= p0 { correct += 1; }
|
||
}
|
||
let acc = correct as f32 / B as f32;
|
||
final_loss = loss_h[0];
|
||
final_acc = acc;
|
||
|
||
if step % 20 == 0 || step == STEPS - 1 {
|
||
eprintln!("step {:3}: CE loss = {:.4}, dir_acc = {:.3}", step + 1, final_loss, acc);
|
||
}
|
||
}
|
||
|
||
assert!(
|
||
final_loss < LOSS_THRESHOLD,
|
||
"C.9 smoke: aux trunk CE loss = {final_loss:.4} after {STEPS} steps (expected < {LOSS_THRESHOLD}). \
|
||
If loss is near ln(2)≈0.693, gradient chain is broken — diagnose before L40S dispatch.",
|
||
);
|
||
assert!(
|
||
final_acc >= DIR_ACC_THRESHOLD,
|
||
"C.9 smoke: aux trunk dir_acc = {final_acc:.3} after {STEPS} steps (expected ≥ {DIR_ACC_THRESHOLD}).",
|
||
);
|
||
eprintln!("C.9 smoke PASS: CE loss = {final_loss:.4} < {LOSS_THRESHOLD}, dir_acc = {final_acc:.3} ≥ {DIR_ACC_THRESHOLD}");
|
||
}
|
||
}
|