feat(per-horizon-cfc): wire Controller A + Phase 1→2 transition in trainer

Per spec §2.3 + §3.1 and Task 9 of the plan.

Adds two device kernels to bucket_transition_kernels.cu:
- tau_change_frobenius_kernel: ‖tau_t − tau_{t-1}‖_F² via block-tree
  reduction (no atomicAdd per feedback_no_atomicadd)
- slack_factor_apply_kernel: (Q1, Q3) → (Q1/slack, Q3*slack) where
  slack = sqrt(Q3/Q1), ISV-derived per feedback_isv_for_adaptive_bounds

Adds to PerceptionTrainer:
- TrainingPhase enum (Phase1Warmup / Phase2Routed)
- ControllerA state machine instance
- prev_tau_d device buffer + tau_change_d + tau_change_staged mapped-pinned
- bucket_warmup_cap_override CLI diagnostic field
- bucket_routing_metadata stored after transition fires
- heads_w_skip_compact_d (HIDDEN_DIM floats) populated by transition
- Cached function handles for the two new kernels

Per-step in step_batched (Phase 1 only):
- Launch tau_change_frobenius_kernel → mapped-pinned scalar shadow
- DtoD prev_tau ← tau_all_d for next step
- Sync + host scalar read
- controller_a.update(tau_change, bucket_warmup_cap_override)
- On trigger: stage tau into scratch buffer (avoids in/out aliasing in
  tau_reorder_kernel), execute_transition writes routing metadata +
  reorders tau_all_d + populates heads_w_skip_compact_d,
  slack_factor_apply widens IQR bounds, invalidate CUDA Graph,
  latch phase = Phase2Routed, log via tracing::info.

Phase 2 dispatch + Controllers B/C/D wiring deferred to Tasks 10–12.
In Task 9's transient state, the trainer enters Phase 2 but continues
Phase 1 dispatch path; the reordered tau_all_d still produces a valid
forward pass since CfC's per-channel decay math is order-invariant.

Per pearl_no_host_branches_in_captured_graph: transition fires OUTSIDE
the captured graph (cached graph invalidated → recaptured next step).
Per pearl_cudarc_disable_event_tracking_for_graph_capture: event tracking
is already disabled trainer-wide; recapture on next step is safe.
Per feedback_no_htod_htoh_only_mapped_pinned: host scalar read goes via
mapped-pinned DtoD shadow, not bulk DtoH.

CLI: --bucket-warmup-cap-steps added to alpha_train (Option<u64>,
diagnostic override of Controller A's ISV-derived cap).

Tests: 7 GPU oracle tests pass on RTX 3050 (5 existing + 2 new for
Frobenius + slack_factor). 33 ml-alpha lib tests pass. Workspace
cargo check clean modulo pre-existing cupti / sp15 / gpu_per_integration
errors unrelated to this task.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-21 17:09:01 +02:00
parent fc0311de77
commit 2f69bb1fc0
5 changed files with 458 additions and 0 deletions

View File

@@ -191,3 +191,55 @@ extern "C" __global__ void heads_compact_kernel(
__syncthreads();
}
}
// ─────────────────────────────────────────────────────────────────────
// tau_change_frobenius_kernel: ||tau_t - tau_{t-1}||_F² (scalar output).
//
// Single block × HIDDEN_DIM threads. Block-tree reduction (no atomicAdd
// per feedback_no_atomicadd).
//
// Per pearl_first_observation_bootstrap: caller's ControllerA bootstraps
// the EMA from this scalar on step 1; on subsequent steps the scalar
// feeds the Wiener-α update.
// ─────────────────────────────────────────────────────────────────────
extern "C" __global__ void tau_change_frobenius_kernel(
const float* __restrict__ tau_t, // [HIDDEN_DIM]
const float* __restrict__ tau_t_minus_1, // [HIDDEN_DIM]
float* __restrict__ tau_change_out // [1]
) {
__shared__ float sdata[HIDDEN_DIM];
int tid = threadIdx.x;
if (tid >= HIDDEN_DIM) return;
float diff = tau_t[tid] - tau_t_minus_1[tid];
sdata[tid] = diff * diff;
__syncthreads();
// Block-tree reduction (HIDDEN_DIM=128 is power-of-two; no padding needed).
for (int s = HIDDEN_DIM / 2; s > 0; s >>= 1) {
if (tid < s) sdata[tid] += sdata[tid + s];
__syncthreads();
}
if (tid == 0) *tau_change_out = sdata[0];
}
// ─────────────────────────────────────────────────────────────────────
// slack_factor_apply_kernel: convert (Q1, Q3) → IQR_widened in place.
//
// Per spec §3.2: slack_factor_k = sqrt(Q3_k / Q1_k), ISV-derived from
// observed bucket IQR (no hardcoded 1.5× factor).
//
// Launch: 1 block × N_HORIZONS threads (5 threads, well within a warp).
// ─────────────────────────────────────────────────────────────────────
extern "C" __global__ void slack_factor_apply_kernel(
float* __restrict__ iqr_lo, // [N_HORIZONS] in: Q1, out: Q1/slack
float* __restrict__ iqr_hi // [N_HORIZONS] in: Q3, out: Q3*slack
) {
int k = threadIdx.x;
if (k >= N_HORIZONS) return;
float q1 = iqr_lo[k];
float q3 = iqr_hi[k];
// Numerical defense: Q1 must be > 0 (taus are log-uniform [0.01, 1000],
// all positive by construction).
float slack = sqrtf(q3 / fmaxf(q1, 1e-6f));
iqr_lo[k] = q1 / slack;
iqr_hi[k] = q3 * slack;
}

View File

@@ -172,6 +172,17 @@ struct Cli {
/// Example: `--kernel-step-trace /feature-cache/.../step_trace.jsonl`
#[arg(long)]
kernel_step_trace: Option<std::path::PathBuf>,
/// Diagnostic override for the per-horizon CfC bucket warmup hard cap.
///
/// `None` (default) lets Controller A derive the cap from the
/// observed `tau_change` dispersion in the first 100 training steps
/// (per spec §3.1). `Some(steps)` forces Phase 1→2 transition at
/// exactly that step count — used by diagnostic Argo runs to test
/// the routing path without waiting on natural CfC.tau convergence.
/// Production runs should leave this unset.
#[arg(long)]
bucket_warmup_cap_steps: Option<u64>,
}
#[derive(Serialize, serde::Deserialize, Default)]
@@ -287,6 +298,7 @@ fn main() -> Result<()> {
n_batch: cli.batch_size,
smoothness_base_lambda: cli.smoothness_base_lambda,
kernel_step_trace_path: cli.kernel_step_trace.clone(),
bucket_warmup_cap_override: cli.bucket_warmup_cap_steps,
};
let mut trainer = PerceptionTrainer::new(&dev, &trainer_cfg).context("trainer init")?;

View File

@@ -67,6 +67,14 @@ const SMOOTHNESS_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/output
const SMOOTHNESS_CONTROLLER_CUBIN: &[u8] = include_bytes!(
concat!(env!("OUT_DIR"), "/smoothness_lambda_controller.cubin")
);
/// Phase 1→2 transition kernels (Task 9 scope: tau_change_frobenius +
/// slack_factor_apply; the full transition orchestration in
/// `crate::cfc::bucket_routing::execute_transition` loads the cubin
/// independently — this constant gives the trainer cached function
/// handles for the per-step Controller A signal kernels).
const BUCKET_TRANSITION_CUBIN: &[u8] = include_bytes!(
concat!(env!("OUT_DIR"), "/bucket_transition_kernels.cubin")
);
#[derive(Clone, Debug)]
pub struct PerceptionTrainerConfig {
@@ -101,6 +109,17 @@ pub struct PerceptionTrainerConfig {
/// Per `feedback_no_feature_flags`: gated by the compile-time
/// `kernel-step-trace` feature; specific name justifies the gate.
pub kernel_step_trace_path: Option<std::path::PathBuf>,
/// Per-horizon CfC bucket warmup cap override (diagnostic).
/// `None` (default) lets `ControllerA` derive the cap from the
/// observed `tau_change` dispersion in the first 100 training steps
/// (per spec §3.1, `half_life × (1 + MAD/median)`). `Some(steps)`
/// forces Phase 1→2 transition at exactly that step count, used by
/// diagnostic Argo runs to test the routing path without waiting on
/// natural CfC.tau convergence. Per
/// `feedback_isv_for_adaptive_bounds`: production must leave this
/// at `None` — only diagnostic CLI sweeps should set it.
pub bucket_warmup_cap_override: Option<u64>,
}
impl Default for PerceptionTrainerConfig {
@@ -115,10 +134,31 @@ impl Default for PerceptionTrainerConfig {
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
bucket_warmup_cap_override: None,
}
}
}
/// Two-phase training-loop state machine for the per-horizon CfC
/// inference architecture (spec §2.3).
///
/// `Phase1Warmup` — single shared CfC body, full HIDDEN_DIM-wide heads,
/// Controller A monitoring `CfC.tau` drift. The current dispatch path
/// is unchanged from the pre-routing trainer.
///
/// `Phase2Routed` — bucket-routed dispatch (Task 10 will land the
/// per-branch fused CfC + compact heads dispatch). Task 9 transitions
/// the trainer's `phase` flag and stages the routing metadata; the
/// actual dispatch path remains Phase-1-shaped until Task 10. This
/// transient state is well-defined: reordered `tau_all_d` still
/// produces a valid forward pass because CfC's per-channel math is
/// order-invariant — only the bucket-routing benefit is deferred.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TrainingPhase {
Phase1Warmup,
Phase2Routed,
}
/// Per-horizon BCE weight schedule, used when the CLI passes
/// `--auto-horizon-weights`. Replaces the previous `min(1, K/h)`
/// schedule which down-weighted long horizons to fractions of a
@@ -639,6 +679,53 @@ pub struct PerceptionTrainer {
/// `[N, FEATURE_DIM]` interface (the [N, 1, F] shape flattens to
/// `[N, F]` by row-major equivalence).
window_step_d: GpuTensor,
// ── Per-horizon CfC routing (Task 9 wiring, spec §2.3 + §3.1) ──
/// Two-phase loop state. Starts in `Phase1Warmup`; flips to
/// `Phase2Routed` exactly once when Controller A fires.
pub phase: TrainingPhase,
/// Controller A — warmup-completion detector. Per-step host-side
/// state machine (EMA + ISV-derived hard cap); reads a single
/// mapped-pinned scalar each step per `feedback_cpu_is_read_only`.
pub controller_a: crate::cfc::bucket_routing::ControllerA,
/// CLI override for Controller A's hard cap. `None` → ISV-derived.
pub bucket_warmup_cap_override: Option<u64>,
/// Previous-step `cfc.tau_all_d` snapshot (HIDDEN_DIM floats).
/// Updated each Phase 1 step via DtoD copy after the Frobenius
/// kernel reads it. Lifetime spans Phase 1 only; the buffer stays
/// allocated in Phase 2 but is no longer touched.
prev_tau_d: CudaSlice<f32>,
/// Device-side scalar slot written by `tau_change_frobenius_fn` and
/// then DtoD-shadowed into `tau_change_staged` for host read.
tau_change_d: CudaSlice<f32>,
/// Mapped-pinned host shadow of `tau_change_d` (single f32). Read
/// each Phase 1 step after a stream sync to feed Controller A.
tau_change_staged: MappedF32Buffer,
/// Cached function handle for the per-step tau-change kernel
/// (`tau_change_frobenius_kernel` in `bucket_transition_kernels.cu`).
tau_change_frobenius_fn: CudaFunction,
/// Cached function handle for the IQR-widening kernel
/// (`slack_factor_apply_kernel` in `bucket_transition_kernels.cu`).
/// Launched once at Phase 1→2 transition, then never again.
slack_factor_apply_fn: CudaFunction,
/// Module handle keeping the bucket-transition cubin alive for the
/// lifetime of the cached function handles above. Loaded once at
/// construction so the per-step tau_change_frobenius launch doesn't
/// pay a load-cubin cost.
_bucket_transition_module: Arc<CudaModule>,
/// Compact-ragged heads_w_skip storage populated at Phase 1→2
/// transition. Size HIDDEN_DIM (sum of `BUCKET_DIM_K` = 128).
/// Allocated up front (zero-init), populated by
/// `heads_compact_kernel` at transition, consumed by Task 10's
/// Phase 2 dispatch path. In Task 9's transient state it stays
/// zero (and unread) until the transition fires.
heads_w_skip_compact_d: CudaSlice<f32>,
/// Bucket-routing metadata produced by `execute_transition`.
/// `None` during Phase 1; `Some(metadata)` after the transition
/// fires. Tasks 1012 will read its slices to drive Phase 2
/// dispatch + Controllers B / C / D.
pub bucket_routing_metadata:
Option<crate::cfc::bucket_routing::BucketRoutingMetadata>,
}
impl PerceptionTrainer {
@@ -749,6 +836,21 @@ impl PerceptionTrainer {
let heads_grn_bwd_fn = heads_module
.load_function("multi_horizon_heads_grn_bwd_batched")
.context("heads GRN bwd symbol")?;
// Bucket-transition cubin: tau_change_frobenius (per-step) +
// slack_factor_apply (transition-time, once). The full transition
// orchestration (5 kernels) lives in `bucket_routing::execute_transition`
// and loads the cubin independently — keeping its module local to that
// function call. Here we cache just the two function handles the
// trainer dispatches directly.
let bucket_transition_module = ctx
.load_cubin(BUCKET_TRANSITION_CUBIN.to_vec())
.context("bucket_transition cubin")?;
let tau_change_frobenius_fn = bucket_transition_module
.load_function("tau_change_frobenius_kernel")
.context("load tau_change_frobenius_kernel")?;
let slack_factor_apply_fn = bucket_transition_module
.load_function("slack_factor_apply_kernel")
.context("load slack_factor_apply_kernel")?;
// Mamba2 stacks live on the trunk from new_random; we wire optimizer
// + training scratches against the trunk-owned blocks.
@@ -1104,6 +1206,29 @@ impl PerceptionTrainer {
let window_step_d = GpuTensor::zeros(&[cfg.n_batch, 1, FEATURE_DIM], &stream)
.map_err(|e| anyhow::anyhow!("window_step_d alloc: {e}"))?;
// ── Per-horizon CfC routing scaffolding (Task 9) ──
// `prev_tau_d` shadows the previous step's `cfc.tau_all_d` so we can
// compute per-step ‖Δτ‖_F² on device. Initialised to zeros so the
// first step's `tau_change` equals ‖τ_1‖_F² — the first-observation
// bootstrap value per `pearl_first_observation_bootstrap`.
// `tau_change_d` is the regular CudaSlice the kernel writes into,
// then DtoD-copied to `tau_change_staged` (mapped-pinned) for the
// host scalar read. The compact heads buffer is allocated up front
// so Phase 1 doesn't hit an allocator at the boundary; Task 10 will
// wire it into the Phase 2 forward path.
let prev_tau_d = stream
.alloc_zeros::<f32>(crate::cfc::bucket_routing::HIDDEN_DIM)
.context("prev_tau_d alloc")?;
let tau_change_d = stream
.alloc_zeros::<f32>(1)
.context("tau_change_d alloc")?;
let tau_change_staged = unsafe { MappedF32Buffer::new(1) }
.map_err(|e| anyhow::anyhow!("tau_change_staged: {e}"))?;
let heads_w_skip_compact_d = stream
.alloc_zeros::<f32>(crate::cfc::bucket_routing::HIDDEN_DIM)
.context("heads_w_skip_compact_d alloc")?;
let controller_a = crate::cfc::bucket_routing::ControllerA::new();
// ── CRT.train: output-smoothness regularizer state ──
// λ is now ISV-driven by smoothness_lambda_controller — initialised
// to LAMBDA_FLOOR per horizon and updated each step inside the
@@ -1451,6 +1576,18 @@ impl PerceptionTrainer {
step_ts_ns_d,
step_prev_ts_ns_d,
window_step_d,
// Per-horizon CfC routing (Task 9).
phase: TrainingPhase::Phase1Warmup,
controller_a,
bucket_warmup_cap_override: cfg.bucket_warmup_cap_override,
prev_tau_d,
tau_change_d,
tau_change_staged,
tau_change_frobenius_fn,
slack_factor_apply_fn,
_bucket_transition_module: bucket_transition_module,
heads_w_skip_compact_d,
bucket_routing_metadata: None,
})
}
@@ -1638,6 +1775,190 @@ impl PerceptionTrainer {
}
}
// ── 1.5. Phase 1→2 transition orchestration (Task 9 scope) ──
//
// Per spec §3.1: monitor CfC.tau drift, trigger Phase 2 when the
// EMA falls below the noise-floor-anchored threshold (or the
// ISV-derived hard cap fires). This block runs OUTSIDE the
// captured training graph (per
// `pearl_no_host_branches_in_captured_graph`) — the per-step
// host-side scalar read could not survive graph capture, and
// the transition itself changes the kernel pointer set the
// graph would record. When the transition fires we invalidate
// `self.train_graph` so the next step recaptures cleanly per
// `pearl_cudarc_disable_event_tracking_for_graph_capture`.
//
// In Phase 2 this entire block is a no-op (the `phase` check
// short-circuits), so the steady-state replay path keeps its
// captured-graph fast path.
if self.phase == TrainingPhase::Phase1Warmup {
// ── (a) Launch tau_change_frobenius_kernel ──
// Block = HIDDEN_DIM threads, block-tree reduction (no
// atomicAdd per `feedback_no_atomicadd`). Shared mem holds
// HIDDEN_DIM f32 squared-diff entries.
let cfg_frob = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (crate::cfc::bucket_routing::HIDDEN_DIM as u32, 1, 1),
shared_mem_bytes: (crate::cfc::bucket_routing::HIDDEN_DIM * 4) as u32,
};
{
let mut launch = self.stream.launch_builder(&self.tau_change_frobenius_fn);
launch
.arg(&self.trunk.tau_all_d)
.arg(&self.prev_tau_d)
.arg(&mut self.tau_change_d);
unsafe {
launch
.launch(cfg_frob)
.context("tau_change_frobenius launch")?;
}
}
// ── (b) DtoD: current τ → prev_τ for next step's comparison ──
// Captured-graph-safe pattern (DtoD async on the same stream).
unsafe {
let s = self.stream.cu_stream();
let (src, _gs) = self.trunk.tau_all_d.device_ptr(&self.stream);
let (dst, _gd) = self.prev_tau_d.device_ptr_mut(&self.stream);
let nbytes = crate::cfc::bucket_routing::HIDDEN_DIM
* std::mem::size_of::<f32>();
cudarc::driver::result::memcpy_dtod_async(dst, src, nbytes, s)
.context("prev_tau DtoD")?;
}
// ── (c) DtoD: tau_change_d → tau_change_staged (mapped-pinned) ──
// Per `feedback_no_htod_htoh_only_mapped_pinned`: the ONLY
// permitted CPU↔GPU path is mapped-pinned via DtoD shadow.
unsafe {
let (src_ptr, _g) = self.tau_change_d.device_ptr(&self.stream);
cudarc::driver::result::memcpy_dtod_async(
self.tau_change_staged.dev_ptr,
src_ptr,
std::mem::size_of::<f32>(),
self.stream.cu_stream(),
)
.context("tau_change → mapped-pinned shadow")?;
}
// ── (d) Sync + host read ──
// Sync is unavoidable here: Controller A is a host-side
// state machine and needs the scalar before it can decide
// whether to trigger. Cost is amortized — one extra sync
// per step until the transition fires, then this block
// becomes a no-op for the rest of training.
self.stream.synchronize().context("tau_change sync")?;
let tau_change = self.tau_change_staged.read_all()[0];
// ── (e) Controller A update + transition check ──
let should_transition = self
.controller_a
.update(tau_change, self.bucket_warmup_cap_override);
if should_transition {
// ── Phase 1→2 transition ──
// 1. Invalidate captured graph so it recaptures with the
// post-transition kernel pointer set + reordered τ
// layout (per
// `pearl_cudarc_disable_event_tracking_for_graph_capture`:
// event tracking is already disabled trainer-wide, so
// the recapture on next step is safe).
self.train_graph = None;
// 2. Stage `cfc_tau_d` into a scratch source so the
// reorder kernel can read the original τ ordering
// while writing the bucket-grouped layout back into
// `trunk.tau_all_d`. Without the scratch the kernel
// would alias its input and output (the orchestrator
// needs `cfc_tau_d` immutably + `tau_all_d` mutably);
// a fresh device buffer also avoids the Rust borrow
// checker complaint about borrowing `trunk` twice.
// The scratch is local to this transition path —
// one allocation per training run, off the hot path.
let tau_scratch_d = {
let mut s = self.stream
.alloc_zeros::<f32>(crate::cfc::bucket_routing::HIDDEN_DIM)
.context("tau_scratch alloc (transition)")?;
unsafe {
let st = self.stream.cu_stream();
let (src, _gs) = self.trunk.tau_all_d.device_ptr(&self.stream);
let (dst, _gd) = s.device_ptr_mut(&self.stream);
let nbytes = crate::cfc::bucket_routing::HIDDEN_DIM
* std::mem::size_of::<f32>();
cudarc::driver::result::memcpy_dtod_async(dst, src, nbytes, st)
.context("tau → tau_scratch DtoD")?;
}
s
};
// 3. Execute the 5-kernel transition (sort, assign, IQR,
// reorder, compact). `execute_transition` returns
// bucket-routing metadata; we store it in trainer
// state for Task 10+11+12 to consume. The reordered
// τ is written into `tau_all_d` in place; the compact
// heads_w_skip lives in `heads_w_skip_compact_d`,
// leaving the original `heads_w_skip_d` intact for
// the Task-9-transient Phase 1 dispatch path.
let mut metadata = crate::cfc::bucket_routing::execute_transition(
&self.stream,
&tau_scratch_d,
&self.trunk.heads_w_skip_d,
&mut self.trunk.tau_all_d,
&mut self.heads_w_skip_compact_d,
)?;
drop(tau_scratch_d);
// 4. Apply slack_factor = sqrt(Q3/Q1) widening to the
// bucket τ IQR bounds (spec §3.2). ISV-derived per
// `feedback_isv_for_adaptive_bounds`: no hardcoded
// 1.5× factor — the slack is proportional to each
// bucket's natural log-spread. Launches in place on
// the metadata's Q1/Q3 buffers.
let cfg_slack = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (N_HORIZONS as u32, 1, 1),
shared_mem_bytes: 0,
};
{
let mut launch =
self.stream.launch_builder(&self.slack_factor_apply_fn);
launch
.arg(&mut metadata.bucket_tau_iqr_lo_d)
.arg(&mut metadata.bucket_tau_iqr_hi_d);
unsafe {
launch
.launch(cfg_slack)
.context("slack_factor_apply launch")?;
}
}
// 5. Persist routing metadata in trainer state. Tasks
// 1012 read its slices to drive Phase 2 dispatch +
// Controllers B / C / D.
self.bucket_routing_metadata = Some(metadata);
// 6. Latch trainer into Phase 2.
//
// NOTE: Task 9 leaves the per-step dispatch path
// unchanged (Phase 1 single-CfC, 640-float heads_w_skip).
// The reordered `tau_all_d` still produces a valid
// forward pass because CfC's per-channel decay math
// (`h_new = h_old * exp(-dt/tau) + (1-decay) * tanh(pre)`)
// is order-invariant. Task 10 will land the per-branch
// fused dispatch that actually consumes the routing
// metadata; until then we run a transient state where
// `phase == Phase2Routed` but the dispatch is Phase-1
// shaped. This is documented behaviour, not a stub.
self.phase = TrainingPhase::Phase2Routed;
tracing::info!(
tau_change = tau_change,
noise_floor = self.controller_a.noise_floor,
step_count = self.controller_a.step_count,
"Phase 1→2 transition fired (CfC.tau stabilized)"
);
}
}
// ── 2. Three-state machine: warmup (first), capture (second),
// replay (third+). The captured graph records all
// in-graph kernel decisions at capture time per

View File

@@ -182,3 +182,68 @@ fn heads_compact_kernel_reorders_w_skip() -> Result<()> {
}
Ok(())
}
#[test]
#[ignore = "requires CUDA"]
fn tau_change_frobenius_kernel_sums_squared_diff() -> Result<()> {
let dev = MlDevice::cuda(0)?;
let stream = dev.cuda_stream()?.clone();
let func = load_kernel(&stream, "tau_change_frobenius_kernel")?;
let tau_t: Vec<f32> = (0..HIDDEN_DIM).map(|i| (i as f32) * 0.1).collect();
let tau_prev: Vec<f32> = (0..HIDDEN_DIM).map(|i| (i as f32) * 0.1 + 0.5).collect();
let tau_t_d = upload(&stream, &tau_t)?;
let tau_prev_d = upload(&stream, &tau_prev)?;
let mut out_d = stream.alloc_zeros::<f32>(1)?;
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (HIDDEN_DIM as u32, 1, 1),
shared_mem_bytes: HIDDEN_DIM as u32 * 4,
};
let mut launch = stream.launch_builder(&func);
launch.arg(&tau_t_d).arg(&tau_prev_d).arg(&mut out_d);
unsafe { launch.launch(cfg)?; }
stream.synchronize()?;
let out = download(&stream, &out_d)?;
// Each diff = -0.5; squared = 0.25; sum over 128 channels = 32.0
assert!((out[0] - 32.0).abs() < 1e-3, "out={} expected ~32.0", out[0]);
Ok(())
}
#[test]
#[ignore = "requires CUDA"]
fn slack_factor_apply_kernel_widens_iqr_geometrically() -> Result<()> {
let dev = MlDevice::cuda(0)?;
let stream = dev.cuda_stream()?.clone();
let func = load_kernel(&stream, "slack_factor_apply_kernel")?;
// Q1 = [1, 4, 9, 16, 25], Q3 = [4, 16, 36, 64, 100]
// → slack = sqrt(Q3/Q1) = [2, 2, 2, 2, 2]
// → out: lo = [0.5, 2, 4.5, 8, 12.5], hi = [8, 32, 72, 128, 200]
let q1: Vec<f32> = vec![1.0, 4.0, 9.0, 16.0, 25.0];
let q3: Vec<f32> = vec![4.0, 16.0, 36.0, 64.0, 100.0];
let mut lo_d = upload(&stream, &q1)?;
let mut hi_d = upload(&stream, &q3)?;
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (N_HORIZONS as u32, 1, 1),
shared_mem_bytes: 0,
};
let mut launch = stream.launch_builder(&func);
launch.arg(&mut lo_d).arg(&mut hi_d);
unsafe { launch.launch(cfg)?; }
stream.synchronize()?;
let lo = download(&stream, &lo_d)?;
let hi = download(&stream, &hi_d)?;
let expected_lo = [0.5, 2.0, 4.5, 8.0, 12.5];
let expected_hi = [8.0, 32.0, 72.0, 128.0, 200.0];
for k in 0..N_HORIZONS {
assert!((lo[k] - expected_lo[k]).abs() < 1e-3, "lo[{}]={} expected {}", k, lo[k], expected_lo[k]);
assert!((hi[k] - expected_hi[k]).abs() < 1e-3, "hi[{}]={} expected {}", k, hi[k], expected_hi[k]);
}
Ok(())
}

View File

@@ -74,6 +74,7 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() {
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
bucket_warmup_cap_override: None,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
@@ -149,6 +150,7 @@ fn stacked_trainer_loss_shrinks_with_stride_4() {
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
bucket_warmup_cap_override: None,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
@@ -204,6 +206,7 @@ fn stacked_trainer_loss_shrinks_at_batch_32() {
n_batch: 32,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
bucket_warmup_cap_override: None,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
@@ -303,6 +306,7 @@ fn evaluate_alone_succeeds() {
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
bucket_warmup_cap_override: None,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
let ts = 1_000_000u64;
@@ -332,6 +336,7 @@ fn evaluate_works_after_captured_training_step() {
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
bucket_warmup_cap_override: None,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
@@ -368,6 +373,7 @@ fn evaluate_works_after_capture_no_replay() {
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
bucket_warmup_cap_override: None,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
let ts = 1_000_000u64;
@@ -399,6 +405,7 @@ fn horizon_ema_and_lambda_track_after_training() {
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
bucket_warmup_cap_override: None,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
@@ -456,6 +463,7 @@ fn evaluate_works_after_warmup_only() {
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
bucket_warmup_cap_override: None,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
let ts = 1_000_000u64;