URGENT correctness fix surfaced by Task 13 implementer. Bug: training-time Phase 2 dispatch sites (cfc_step_per_branch_fwd at ~3139, h_mag_per_bucket for Controller D at ~3211-3212) read self.trunk.bucket_*_d which are zero-initialized at trunk construction and only populated via load_checkpoint. The Phase 1→2 transition populates a SEPARATE BucketRoutingMetadata owned by the trainer (self.bucket_routing_metadata) but never syncs it into the trunk fields. With zero-init bucket_dim_k, the per-branch kernel's uniform predicate `tid >= bucket_dim_k[branch]` early-returns ALL threads -> Phase 2 silently no-ops during training, Smoke 1 fails before producing signal. Fix: at transition, DtoD-copy metadata buffers into trunk fields BEFORE the `Some(metadata)` move (so `metadata.*` borrows remain valid): - metadata.bucket_channel_offset_d -> trunk.bucket_channel_offset_d - metadata.bucket_dim_k_d -> trunk.bucket_dim_k_d - metadata.bucket_id_per_channel_d -> trunk.bucket_id_per_channel_d - metadata.heads_w_skip_offset_d -> trunk.heads_w_skip_offset_d Trunk fields remain the single source of truth for both training-time dispatch AND checkpoint serialization. Total sync cost: 4 DtoD memcpys, ~256 bytes total at the one-shot transition (off the hot path). Verification: - `cargo check -p ml-alpha --all-targets`: clean - `cargo test -p ml-alpha --lib`: 33 passed, 0 failed - 3 read sites (perception.rs:3139, 3211-3212, 4725-4726) verified unchanged; all 4 new memcpy_dtod_async calls bracketed in the transition block (perception.rs:2224, 2234, 2244, 2254). - Block-diagonal heads grad-mask init (step 7) continues to read `metadata.bucket_id_per_channel_d` via `as_ref()` after the move; ordering preserved per pearl_canary_input_freshness_launch_order. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5506 lines
281 KiB
Rust
5506 lines
281 KiB
Rust
//! PerceptionTrainer — stacked Mamba2 -> CfC -> heads with per-position supervision.
|
||
//!
|
||
//! Per spec 2026-05-16 amendment + 2026-05-17 BPTT-unroll amendment.
|
||
//! Topology per training step:
|
||
//! snap_features × seq_len → Mamba2.forward_train_seq → h_enriched_seq [K, hidden_dim]
|
||
//! For each k=0..K-1 with a valid label:
|
||
//! → cfc_step (h_old=0) → h_new_k
|
||
//! → heads → probs_k
|
||
//! → BCE(probs_k, labels[k]) → loss_k
|
||
//!
|
||
//! Backward chain:
|
||
//! For each supervised k (kernels accumulate via += into shared grad
|
||
//! buffers; CfC + heads weights are shared across positions):
|
||
//! grad_probs_k → heads_backward → grad_h_new_k + accum grad_W_heads/b_heads
|
||
//! → cfc_step_backward → accum grad_W_in/W_rec/b + grad_x_k
|
||
//! grad_x_k stored into grad_h_enriched_seq[k]
|
||
//! Once:
|
||
//! grad_h_enriched_seq → Mamba2.backward_from_h_enriched_seq → full Mamba2 grad set
|
||
//!
|
||
//! Per-step supervision densifies the gradient signal ~K× over the
|
||
//! final-step-only design (which trained flat at chance on real ES
|
||
//! data despite working on synthetic overfit). Mamba2's analytical
|
||
//! scan backward unrolls those per-step gradients through the full
|
||
//! 32-step SSM state evolution.
|
||
//!
|
||
//! Optimizers (unchanged):
|
||
//! - 5 CfC AdamWs (W_in, W_rec, b, heads_w, heads_b)
|
||
//! - 1 Mamba2AdamW for all 9 Mamba2 parameter tensors
|
||
//!
|
||
//! Architectural note: CfC runs with h_old=0 each step (no inter-step
|
||
//! recurrence in v1). With h_old=0, the cfc_step degenerates to a
|
||
//! per-cell-tau scaled tanh-FC layer feeding the heads. Inter-step CfC
|
||
//! state (where h_old carries between calls) is a v2 extension.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use anyhow::{Context, Result};
|
||
use cudarc::driver::sys::{CUgraphInstantiate_flags, CUstreamCaptureMode};
|
||
use cudarc::driver::{
|
||
CudaFunction, CudaGraph, CudaModule, CudaSlice, CudaStream, DevicePtr, DevicePtrMut,
|
||
LaunchConfig, PushKernelArg,
|
||
};
|
||
use ml_core::cuda_autograd::gpu_tensor::GpuTensor;
|
||
use ml_core::device::MlDevice;
|
||
use rand::{Rng, SeedableRng};
|
||
use rand_chacha::ChaCha8Rng;
|
||
|
||
use crate::cfc::snap_features::{Mbp10RawInput, ES_TICK_SIZE, FEATURE_DIM, REGIME_DIM};
|
||
use crate::heads::{HEAD_MID_DIM, HIDDEN_DIM, N_HORIZONS};
|
||
use crate::mamba2_block::{
|
||
Mamba2AdamW, Mamba2AdamWConfig, Mamba2BackwardGradsBuffers, Mamba2BackwardScratch,
|
||
Mamba2BlockForwardScratch, Mamba2BlockStepScratch,
|
||
};
|
||
use crate::pinned_mem::{MappedF32Buffer, MappedI32Buffer, MappedI64Buffer};
|
||
use crate::trainer::optim::AdamW;
|
||
|
||
const SNAP_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/snap_feature_assemble.cubin"));
|
||
const STEP_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/cfc_step.cubin"));
|
||
const HEADS_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/multi_horizon_heads.cubin"));
|
||
const BCE_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/bce_loss_multi_horizon.cubin"));
|
||
const HORIZON_LAMBDA_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/horizon_lambda.cubin"));
|
||
const LAYER_NORM_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/layer_norm.cubin"));
|
||
const VARIABLE_SELECTION_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/variable_selection.cubin"));
|
||
const ATTENTION_POOL_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/attention_pool.cubin"));
|
||
const REDUCE_AXIS0_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/reduce_axis0.cubin"));
|
||
const SMOOTHNESS_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/output_smoothness.cubin"));
|
||
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 {
|
||
pub seq_len: usize,
|
||
pub mamba2_state_dim: usize,
|
||
pub lr_cfc: f32,
|
||
pub lr_mamba2: f32,
|
||
pub seed: u64,
|
||
/// Per-horizon BCE loss weights — multiplied into the loss + grad
|
||
/// contribution at each (position, horizon) pair. Down-weights
|
||
/// horizons whose forward labels overlap across K positions (h ≫ K
|
||
/// → near-identical labels would otherwise inflate gradient
|
||
/// pressure). All-ones gives the original uniform-mean BCE.
|
||
pub horizon_weights: [f32; N_HORIZONS],
|
||
/// Number of sequences processed per optimizer step. Sets the
|
||
/// effective batch size — kernels switch to batched variants
|
||
/// (cfc_step_batched, multi_horizon_heads_batched, etc.).
|
||
/// `n_batch=1` matches the unbatched behavior exactly.
|
||
pub n_batch: usize,
|
||
/// CRT.train intervention A: output-smoothness regularizer base λ.
|
||
/// Amplitude knob for the ISV-driven `smoothness_lambda_controller`,
|
||
/// which derives per-horizon λ[h] each step from the post-step
|
||
/// jitter EMA (anchored on h30). Default 0.0 = controller produces
|
||
/// LAMBDA_FLOOR uniformly (kernel still launches but effective
|
||
/// regularization is negligible). Set to e.g. 0.01 to enable.
|
||
/// See `docs/superpowers/specs/2026-05-20-crt-train-output-smoothness-design.md`.
|
||
pub smoothness_base_lambda: f32,
|
||
|
||
/// Optional path for the kernel-step-trace JSONL drain. None →
|
||
/// ring not allocated, drain task not spawned. Some(path) → ring
|
||
/// allocated + drain writes JSONL records to `path` (truncated).
|
||
/// 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 {
|
||
fn default() -> Self {
|
||
Self {
|
||
seq_len: 32,
|
||
mamba2_state_dim: 16,
|
||
lr_cfc: 3e-3,
|
||
lr_mamba2: 1e-3,
|
||
seed: 0x4242,
|
||
horizon_weights: [1.0; N_HORIZONS],
|
||
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
|
||
/// percent of the loss (h6000 weight = 0.0053 at K=32) and effectively
|
||
/// excluded them from training.
|
||
///
|
||
/// The 3-fold ISV CV on commit `0171c8c0e` showed the prior schedule
|
||
/// suppressing h6000 — the deployment-relevant horizon — to the point
|
||
/// where the ISV controller's lambda was operating on rounding error.
|
||
/// h6000 ended at 0.685-0.728 across folds; mean_auc CV at 0.717±.
|
||
///
|
||
/// New schedule: UNIFORM `[1.0; N]`. All horizons contribute equally
|
||
/// to the BCE loss; let the ISV controller (boosting per-horizon
|
||
/// gradient via lambda into the trunk) do the per-horizon re-balancing
|
||
/// in a single canonical place. Callers who want a custom schedule
|
||
/// can still pass `--horizon-weights w0,w1,w2,w3,w4`.
|
||
///
|
||
/// `seq_len` and `horizons` are kept on the signature for backward
|
||
/// compatibility with the CLI but are no longer consulted; the
|
||
/// returned schedule is shape-determined, not data-determined.
|
||
pub fn auto_horizon_weights(_seq_len: usize, _horizons: &[usize; N_HORIZONS]) -> [f32; N_HORIZONS] {
|
||
[1.0f32; N_HORIZONS]
|
||
}
|
||
|
||
pub struct PerceptionTrainer {
|
||
cfg: PerceptionTrainerConfig,
|
||
stream: Arc<CudaStream>,
|
||
|
||
// Modules + cached function handles
|
||
_snap_module: Arc<CudaModule>,
|
||
_step_module: Arc<CudaModule>,
|
||
_heads_module: Arc<CudaModule>,
|
||
_bce_module: Arc<CudaModule>,
|
||
// Forward kernels are owned by `self.trunk` (snap_batched_fn,
|
||
// step_batched_fn, heads_grn_fwd_fn, transpose_3d_fn). The trainer
|
||
// keeps only the training-only handles below.
|
||
bce_fn: CudaFunction,
|
||
step_bwd_batched_fn: CudaFunction,
|
||
heads_grn_bwd_fn: CudaFunction,
|
||
|
||
// X3: Mamba2 stack 1 weights moved to `self.trunk.mamba2_stack_1`.
|
||
// Access via `self.trunk.mamba2_l1()` / `mamba2_l1_mut()`. Gradient
|
||
// buffers + AdamW state remain at trainer level.
|
||
pub mamba2_adamw: Mamba2AdamW,
|
||
// X5: Mamba2 stack 2 weights moved to `self.trunk.mamba2_stack_2`.
|
||
// Access via `self.trunk.mamba2_l2()` / `mamba2_l2_mut()`.
|
||
pub mamba2_l2_adamw: Mamba2AdamW,
|
||
mamba2_l2_fwd_scratch: Mamba2BlockForwardScratch,
|
||
mamba2_l2_bwd_scratch: Mamba2BackwardScratch,
|
||
mamba2_l2_grads_buffers: Mamba2BackwardGradsBuffers,
|
||
// ── LN_a (between Mamba2 stacks; Phase 2B) ──
|
||
// The existing LN sits between m2 and CfC (now called LN_b). LN_a
|
||
// is a separate instance with its own learnable gain/bias.
|
||
// X4: LN_a weights moved to `self.trunk.ln_a_gain_d` / `ln_a_bias_d`.
|
||
ln_a_stats_d: CudaSlice<f32>,
|
||
/// `[B, K, HIDDEN_DIM]` — LN_a forward output, fed as input to m2.
|
||
ln_a_out_d: GpuTensor,
|
||
grad_ln_a_gain_per_row_d: CudaSlice<f32>,
|
||
grad_ln_a_bias_per_row_d: CudaSlice<f32>,
|
||
grad_ln_a_gain_d: CudaSlice<f32>,
|
||
grad_ln_a_bias_d: CudaSlice<f32>,
|
||
/// `[B, K, HIDDEN_DIM]` — LN_a backward grad-x: gradient w.r.t.
|
||
/// m1's `h_enriched_seq` output (consumed by m1.backward).
|
||
grad_ln_a_in_d: GpuTensor,
|
||
pub opt_ln_a_gain: AdamW,
|
||
pub opt_ln_a_bias: AdamW,
|
||
/// Pre-allocated forward intermediates for Mamba2 (input projection
|
||
/// output x, A/B projections, h_s2 residual, h_enriched_seq scan
|
||
/// output). Construct once at trainer init; reused every step.
|
||
mamba2_fwd_scratch: Mamba2BlockForwardScratch,
|
||
/// Pre-allocated scratch for the Mamba2 seq backward — the four
|
||
/// largest per-channel buffers (~6 MB each at B=8, K=96).
|
||
mamba2_bwd_scratch: Mamba2BackwardScratch,
|
||
/// Pre-allocated outputs for Mamba2 seq backward (cuBLAS-projection
|
||
/// dw/db tensors + reduction-result tensors + d_x intermediates).
|
||
/// Read by Mamba2AdamW::step_from_buffers — no temporary
|
||
/// Mamba2BackwardGrads wrapper allocated per step.
|
||
mamba2_grads_buffers: Mamba2BackwardGradsBuffers,
|
||
/// Pre-allocated input window for snap_features → Mamba2 fwd.
|
||
/// [B, K, FEATURE_DIM] — overwritten each step by the batched
|
||
/// snap_feature kernel.
|
||
window_tensor_d: GpuTensor,
|
||
/// Pre-allocated transpose of Mamba2's h_enriched_seq into [K, B, H]
|
||
/// layout for contiguous per-K slot access in the trainer loop.
|
||
h_enriched_seq_t_d: GpuTensor,
|
||
/// Pre-allocated per-K gradient accumulator in [K, B, H] layout.
|
||
/// Written by the reverse-order backward K loop; transposed back
|
||
/// to [B, K, H] for Mamba2 backward consumption.
|
||
grad_h_enriched_seq_t_d: GpuTensor,
|
||
/// Pre-allocated [B, K, H] grad input to Mamba2 backward.
|
||
grad_h_enriched_seq_d: GpuTensor,
|
||
|
||
// CfC + heads weights + their AdamWs (6 groups — tau is trained now).
|
||
// X8: CfC weights (w_in, w_rec, b, tau) moved to
|
||
// `self.trunk.w_in_d` / `w_rec_d` / `b_d` / `tau_all_d` (now v2-shaped
|
||
// via CfcConfig.cfc_n_in = HIDDEN_DIM).
|
||
// ── TFT GRN heads (Phase 1.7) ──
|
||
// Per-horizon Gated Residual Network: 2-layer GELU MLP body
|
||
// (eta_2 → eta_1) + GLU gate + skip-projection. Gives per-horizon
|
||
// "linear vs deeper-transform" specialisation, matching the
|
||
// regime-conditional alpha pattern (pearl_snapshot_alpha_is_regime_conditional).
|
||
// X9: GRN head weights moved to `self.trunk.heads_w1_d` / `heads_b1_d`
|
||
// / ... / `heads_b_skip_d`. Gradient buffers + AdamW state stay at
|
||
// trainer level.
|
||
pub opt_w_in: AdamW,
|
||
pub opt_w_rec: AdamW,
|
||
pub opt_b: AdamW,
|
||
pub opt_tau: AdamW,
|
||
pub opt_heads_w1: AdamW,
|
||
pub opt_heads_b1: AdamW,
|
||
pub opt_heads_w2: AdamW,
|
||
pub opt_heads_b2: AdamW,
|
||
pub opt_heads_w_gate: AdamW,
|
||
pub opt_heads_b_gate: AdamW,
|
||
pub opt_heads_w_main: AdamW,
|
||
pub opt_heads_b_main: AdamW,
|
||
pub opt_heads_w_skip: AdamW,
|
||
pub opt_heads_b_skip: AdamW,
|
||
|
||
// Pre-allocated BATCHED snap_feature staging — sized for max B*K
|
||
// snapshots. All staging buffers are MAPPED-PINNED (the only
|
||
// permitted CPU↔GPU path per `feedback_no_htod_htoh_only_mapped_pinned.md`).
|
||
// Per training step: host writes through staging.host_slice_mut(),
|
||
// then one DtoD copy per array → device buffer, then one batched
|
||
// snap_feature_assemble launch instead of B*K single-snapshot launches.
|
||
bk_capacity: usize,
|
||
stg_bid_px_all: MappedF32Buffer, // [B*K, 10]
|
||
stg_bid_sz_all: MappedF32Buffer, // [B*K, 10]
|
||
stg_ask_px_all: MappedF32Buffer, // [B*K, 10]
|
||
stg_ask_sz_all: MappedF32Buffer, // [B*K, 10]
|
||
stg_regime_all: MappedF32Buffer, // [B*K, 6]
|
||
stg_prev_mid: MappedF32Buffer, // [B*K]
|
||
stg_trade_signed_vol: MappedF32Buffer, // [B*K]
|
||
stg_trade_count: MappedI32Buffer, // [B*K]
|
||
stg_ts_ns: MappedI64Buffer, // [B*K]
|
||
stg_prev_ts_ns: MappedI64Buffer, // [B*K]
|
||
bid_px_all_d: CudaSlice<f32>,
|
||
bid_sz_all_d: CudaSlice<f32>,
|
||
ask_px_all_d: CudaSlice<f32>,
|
||
ask_sz_all_d: CudaSlice<f32>,
|
||
/// `prev_bid_sz_all_d` / `prev_ask_sz_all_d` — zero-init device
|
||
/// buffers passed to the snap kernel. The loader doesn't compute
|
||
/// per-snapshot prev-OFI yet, so these stay zero (same as the
|
||
/// single-snapshot path).
|
||
prev_bid_sz_all_d: CudaSlice<f32>,
|
||
prev_ask_sz_all_d: CudaSlice<f32>,
|
||
regime_all_d: CudaSlice<f32>,
|
||
prev_mid_all_d: CudaSlice<f32>,
|
||
trade_signed_vol_all_d: CudaSlice<f32>,
|
||
trade_count_all_d: CudaSlice<i32>,
|
||
ts_ns_all_d: CudaSlice<i64>,
|
||
prev_ts_ns_all_d: CudaSlice<i64>,
|
||
|
||
// Per-K device-resident scratch — pre-allocated once at construction
|
||
// so the K loop in step() does ZERO device allocations.
|
||
/// `h_new_per_k_d[k * n_hid .. (k+1) * n_hid]` holds CfC h_new at
|
||
/// position k. Used as h_old at position k+1 (recurrence carry) and
|
||
/// re-read in backward for cfc_step_bwd's h_old argument.
|
||
h_new_per_k_d: CudaSlice<f32>,
|
||
/// `probs_per_k_d[k * N_HORIZONS .. (k+1) * N_HORIZONS]` — heads
|
||
/// output at position k. Single fused BCE call consumes all K rows.
|
||
probs_per_k_d: CudaSlice<f32>,
|
||
/// Labels for the sequence, uploaded once per step(); NaN slots are
|
||
/// natively masked by the BCE kernel.
|
||
labels_per_k_d: CudaSlice<f32>,
|
||
/// BCE-emitted per-position grad_probs; consumed by heads_bwd.
|
||
grad_probs_per_k_d: CudaSlice<f32>,
|
||
/// Scalar BCE loss output (mean over valid label entries).
|
||
loss_d: CudaSlice<f32>,
|
||
/// Mapped-pinned host shadow of `loss_d`. After the post-step sync,
|
||
/// host_ptr holds the freshly-computed loss — no extra dtoh sync.
|
||
loss_host_d: MappedF32Buffer,
|
||
/// Per-horizon UNWEIGHTED mean BCE — the ISV signal an EMA layer
|
||
/// tracks to compute dynamic per-horizon weights. Refreshed by
|
||
/// the BCE kernel on every training step. Shape: [N_HORIZONS].
|
||
loss_per_horizon_d: CudaSlice<f32>,
|
||
/// Per-horizon smoothness multiplier λ[h]. Initialised to a uniform
|
||
/// `LAMBDA_FLOOR` at construction; updated each step by
|
||
/// `smoothness_lambda_controller` from the post-step jitter EMA.
|
||
/// Read by `output_smoothness_loss_and_grad` on the NEXT step
|
||
/// (one-step closed-loop delay inside the captured graph).
|
||
smoothness_lambda_d: CudaSlice<f32>,
|
||
/// Scalar total smoothness loss output (λ-weighted Σ_h). Output of
|
||
/// `output_smoothness_loss_and_grad`. Mapped-pinned host shadow
|
||
/// `smoothness_loss_host_d` provides the post-step value with no
|
||
/// extra dtoh sync (telemetry only).
|
||
smoothness_loss_d: CudaSlice<f32>,
|
||
smoothness_loss_host_d: MappedF32Buffer,
|
||
/// Per-horizon raw mean-squared-diff (pre-λ). Telemetry feed for
|
||
/// the epoch summary writer. Shape [N_HORIZONS].
|
||
smoothness_loss_per_horizon_d: CudaSlice<f32>,
|
||
smoothness_loss_per_horizon_host_d: MappedF32Buffer,
|
||
/// Cached handle for `output_smoothness_loss_and_grad`.
|
||
smoothness_fn: CudaFunction,
|
||
_smoothness_module: Arc<CudaModule>,
|
||
/// Per-horizon EMA of `raw_per_h` from output_smoothness. Updated
|
||
/// by `smoothness_lambda_controller` each step. Drives the
|
||
/// controller's target derivation.
|
||
smoothness_jitter_ema_d: CudaSlice<f32>,
|
||
/// First-observation sentinel (per pearl_first_observation_bootstrap).
|
||
/// 0 → next step bootstraps EMA from raw_per_h; 1 → Wiener-α update.
|
||
smoothness_jitter_first_obs_d: CudaSlice<i32>,
|
||
/// Cached handle for `smoothness_lambda_controller`.
|
||
smoothness_controller_fn: CudaFunction,
|
||
_smoothness_controller_module: Arc<CudaModule>,
|
||
// ── GPU log ring (feature: kernel-step-trace) ──
|
||
// When the feature is compiled AND `cfg.kernel_step_trace_path` is
|
||
// Some(path), the trainer allocates a mapped-pinned ring + a device
|
||
// step counter, passes their pointers into logging-capable kernels
|
||
// (currently `smoothness_lambda_controller`), and spawns a tokio drain
|
||
// task that polls the ring at 500 ms cadence and writes JSONL records
|
||
// to `path`. When the path is None, every Option below stays None —
|
||
// no allocations, no tick kernel, no drain task, zero overhead even
|
||
// with the feature compiled in.
|
||
// See `docs/superpowers/specs/2026-05-21-gpu-log-ring-design.md`.
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
log_ring: Option<std::sync::Arc<crate::gpu_log::LogRing>>,
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
log_step_counter_d: Option<CudaSlice<i32>>,
|
||
/// Mapped-pinned host shadow of `log_step_counter_d`. Updated via
|
||
/// memcpy_dtod_async inside the captured region; read by the drain
|
||
/// task each tick.
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
log_step_counter_host_shadow: Option<std::sync::Arc<crate::pinned_mem::MappedRecordBuffer<i32>>>,
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
gpu_log_tick_fn: Option<CudaFunction>,
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
_gpu_log_module: Option<Arc<CudaModule>>,
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
log_drain_stop: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
log_drain_handle: Option<std::thread::JoinHandle<()>>,
|
||
// X6: LN_b weights (formerly ln_gain_d / ln_bias_d) moved to
|
||
// `self.trunk.ln_b_gain_d` / `ln_b_bias_d`. LN_b is the LayerNorm
|
||
// AFTER Mamba2 stack 2 — stabilises the trunk-to-CfC distribution.
|
||
/// Per-row LN stats `[B*K, 2]` (mean, inv_std) saved by fwd, used
|
||
/// by bwd.
|
||
ln_stats_d: CudaSlice<f32>,
|
||
/// LN forward output `[B*K, HIDDEN]` — fed to the transpose that
|
||
/// produces `h_enriched_seq_t_d`. Replaces direct reads of
|
||
/// Mamba2's `h_enriched_seq` in the K-loop path.
|
||
ln_out_d: CudaSlice<f32>,
|
||
/// Per-row gain-grad scratch `[B*K, HIDDEN]`, reduced into
|
||
/// `grad_ln_gain_d`.
|
||
grad_ln_gain_per_row_d: CudaSlice<f32>,
|
||
/// Per-row bias-grad scratch `[B*K, HIDDEN]`, reduced into
|
||
/// `grad_ln_bias_d`.
|
||
grad_ln_bias_per_row_d: CudaSlice<f32>,
|
||
/// Final LN gain gradient `[HIDDEN]` — Adam consumes this.
|
||
grad_ln_gain_d: CudaSlice<f32>,
|
||
/// Final LN bias gradient `[HIDDEN]` — Adam consumes this.
|
||
grad_ln_bias_d: CudaSlice<f32>,
|
||
/// LN backward grad-x: the gradient w.r.t. Mamba2's `h_enriched_seq`
|
||
/// output. Replaces direct use of `grad_h_enriched_seq_d` when
|
||
/// feeding Mamba2 backward.
|
||
grad_ln_in_d: GpuTensor,
|
||
/// Cached LN kernel handles + module lifetime owner.
|
||
ln_bwd_fn: CudaFunction,
|
||
ln_reduce_fn: CudaFunction,
|
||
_ln_module: Arc<CudaModule>,
|
||
/// AdamW state for the two LN params (gain + bias).
|
||
opt_ln_gain: AdamW,
|
||
opt_ln_bias: AdamW,
|
||
|
||
// ── TFT VSN (Phase 2D) ──
|
||
// Per-position softmax-normalised feature gating at trunk entry:
|
||
// gates = softmax(W_vsn @ x + b_vsn); y = x * gates
|
||
// Lets the model learn which snap_features matter per regime.
|
||
/// `[FEATURE_DIM, FEATURE_DIM]` linear layer for gate logits.
|
||
// X2 migration: VSN weights now live on `self.trunk` (CfcTrunk's
|
||
// pub vsn_w_d / vsn_b_d). PerceptionTrainer wraps a trunk to host
|
||
// the v2 inference graph; the trunk's V1 fields stay unused for now
|
||
// (X8 consolidates against PerceptionTrainer's CfC weights). Gradient
|
||
// buffers + AdamW state for VSN remain at trainer level.
|
||
pub trunk: crate::cfc::trunk::CfcTrunk,
|
||
/// VSN forward output `[B, K, FEATURE_DIM]` — replaces window_tensor_d
|
||
/// as Mamba2's input. window_tensor_d still holds the RAW features
|
||
/// (consumed by VSN bwd as `x`).
|
||
vsn_out_d: GpuTensor,
|
||
/// Saved softmax-normalised gates `[B*K, FEATURE_DIM]` (per-K row).
|
||
vsn_gates_d: CudaSlice<f32>,
|
||
/// VSN bwd writes grad_x here (gradient w.r.t. snap_features). Not
|
||
/// propagated further (snap_features are a non-trainable transform
|
||
/// of raw MBP-10 data); allocated only so the kernel has a buffer.
|
||
vsn_grad_x_d: CudaSlice<f32>,
|
||
grad_vsn_w_d: CudaSlice<f32>,
|
||
grad_vsn_b_d: CudaSlice<f32>,
|
||
pub opt_vsn_w: AdamW,
|
||
pub opt_vsn_b: AdamW,
|
||
vsn_bwd_fn: CudaFunction,
|
||
_vsn_module: Arc<CudaModule>,
|
||
|
||
// ── Attention pool (Phase 3) ──
|
||
// Single-head attention pool over LN_b output. Replaces CfC's
|
||
// zero-initialised h_old at k=0 with a learned content-addressable
|
||
// summary over all K positions. Single learned param: Q [HIDDEN_DIM].
|
||
// X7: attention-pool query weight moved to `self.trunk.attn_q_d`.
|
||
/// Per-sample pooled context `[B, HIDDEN_DIM]` — fed as h_old at k=0.
|
||
attn_context_d: CudaSlice<f32>,
|
||
/// Saved post-softmax attention weights `[B, K]` for bwd.
|
||
attn_weights_d: CudaSlice<f32>,
|
||
grad_attn_q_d: CudaSlice<f32>,
|
||
pub opt_attn_q: AdamW,
|
||
attn_bwd_fn: CudaFunction,
|
||
_attn_module: Arc<CudaModule>,
|
||
|
||
// ── Kendall σ-weighted BCE (axis A, ISV-driven) ──
|
||
// Per-horizon `log σ_h` is computed in closed form by the
|
||
// horizon_ema_and_lambda kernel from loss_ema (Kendall equilibrium
|
||
// σ_h = sqrt(mean_bce_h)). No Adam state — replaces the prior
|
||
// Adam-learned variant which fought m/√v normalization
|
||
// (pearl_adam_normalizes_loss_weights). Single source of truth
|
||
// shared with λ_h: both anchor on loss_ema.
|
||
pub log_sigma_h_d: CudaSlice<f32>, // [N_HORIZONS] — ISV output
|
||
pub grad_log_sigma_h_d: CudaSlice<f32>, // [N_HORIZONS] — BCE kernel writes here; ignored downstream
|
||
|
||
// ── K-loop parallelization (Phase B) ──
|
||
// Per-batch grad scratch buffers for cfc_step_backward_batched.
|
||
// Zeroed once per training step; the K-loop's 64 bwd calls accumulate
|
||
// into these via +=. After the K-loop, reduce_axis0 sums across B
|
||
// into the final grad buffers (overwrite semantics). AdamW reads the
|
||
// final grads (must run AFTER the reducer).
|
||
cfc_grad_w_in_scratch_d: CudaSlice<f32>, // [B, n_hid, n_in]
|
||
cfc_grad_w_rec_scratch_d: CudaSlice<f32>, // [B, n_hid, n_hid]
|
||
cfc_grad_b_scratch_d: CudaSlice<f32>, // [B, n_hid]
|
||
cfc_grad_tau_scratch_d: CudaSlice<f32>, // [B, n_hid]
|
||
// GRN per-batch grad scratch (Phase B commit 2).
|
||
grn_grad_w1_scratch_d: CudaSlice<f32>, // [B, 5, HEAD_MID, HIDDEN]
|
||
grn_grad_b1_scratch_d: CudaSlice<f32>, // [B, 5, HEAD_MID]
|
||
grn_grad_w2_scratch_d: CudaSlice<f32>, // [B, 5, HEAD_MID, HEAD_MID]
|
||
grn_grad_b2_scratch_d: CudaSlice<f32>, // [B, 5, HEAD_MID]
|
||
grn_grad_w_gate_scratch_d: CudaSlice<f32>, // [B, 5, HEAD_MID]
|
||
grn_grad_b_gate_scratch_d: CudaSlice<f32>, // [B, 5]
|
||
grn_grad_w_main_scratch_d: CudaSlice<f32>, // [B, 5, HEAD_MID]
|
||
grn_grad_b_main_scratch_d: CudaSlice<f32>, // [B, 5]
|
||
grn_grad_w_skip_scratch_d: CudaSlice<f32>, // [B, 5, HIDDEN]
|
||
grn_grad_b_skip_scratch_d: CudaSlice<f32>, // [B, 5]
|
||
// VSN per-row grad scratch (Phase B commit 3). n_rows = B * K.
|
||
vsn_grad_w_scratch_d: CudaSlice<f32>, // [B*K, FEATURE_DIM, FEATURE_DIM]
|
||
vsn_grad_b_scratch_d: CudaSlice<f32>, // [B*K, FEATURE_DIM]
|
||
// Attention pool per-batch grad scratch (Phase B commit 4).
|
||
attn_grad_q_scratch_d: CudaSlice<f32>, // [B, HIDDEN_DIM]
|
||
/// Cross-batch reducer kernel: `[B, N] → [N]` via block tree-reduce.
|
||
/// Used for every per-batch grad scratch in the refactored bwd path.
|
||
reduce_axis0_fn: CudaFunction,
|
||
/// Owns the reduce_axis0 cubin lifetime.
|
||
_reduce_module: Arc<CudaModule>,
|
||
/// Per-horizon EMA of `loss_per_horizon_d`. Zero-initialised
|
||
/// (sentinel); the horizon_ema_and_lambda kernel detects the
|
||
/// sentinel on step 1 and replaces directly per
|
||
/// `pearl_first_observation_bootstrap.md`.
|
||
loss_ema_d: CudaSlice<f32>,
|
||
/// Per-horizon multiplicative gradient scaler. Updated each step
|
||
/// by horizon_ema_and_lambda. Asymmetric clamp [1.0, 2.0].
|
||
/// Consumed by heads_grn_bwd as `s_lambda` multiplier on the
|
||
/// trunk grad_h contribution.
|
||
lambda_d: CudaSlice<f32>,
|
||
/// EMA(max |z_h|) state for adaptive Z_SCALE in horizon_ema_and_lambda.
|
||
/// Single scalar; tracks the typical inter-horizon spread so
|
||
/// Z_SCALE_ISV maps the observed max-z to LAMBDA_CEILING.
|
||
z_max_ema_d: CudaSlice<f32>,
|
||
/// Cached function handle for the horizon_ema_and_lambda kernel.
|
||
horizon_lambda_fn: CudaFunction,
|
||
/// Module that owns `horizon_lambda_fn`; kept alive so the function
|
||
/// handle stays valid for the trainer's lifetime.
|
||
_horizon_lambda_module: Arc<CudaModule>,
|
||
/// Valid-label count for BCE normalisation.
|
||
valid_d: CudaSlice<i32>,
|
||
/// Per-horizon BCE loss weights, device-resident. Filled from
|
||
/// `cfg.horizon_weights` at construction; immutable thereafter.
|
||
loss_weights_d: CudaSlice<f32>,
|
||
/// Carry of grad_h_old across the reverse-order K backward loop.
|
||
/// Initialised to zero at step start; cfc_step_bwd writes its own
|
||
/// grad_h_old here, which heads_bwd of the previous position adds
|
||
/// in via the new `grad_h_carry` kernel arg.
|
||
grad_h_carry_d: CudaSlice<f32>,
|
||
/// Single-position grad_h_new buffer fed to cfc_step_bwd (heads_bwd
|
||
/// writes to it, fused with the carry).
|
||
grad_h_new_d: CudaSlice<f32>,
|
||
/// Single-position zero h_old buffer (used only at k=0 — first
|
||
/// position of the sequence has no preceding state).
|
||
zero_h_d: CudaSlice<f32>,
|
||
|
||
// Backward-grad accumulators (one per trainable group). Pre-zeroed
|
||
// at the top of step(); cfc_step_bwd / heads_bwd use += semantics.
|
||
grad_w_in_d: CudaSlice<f32>,
|
||
grad_w_rec_d: CudaSlice<f32>,
|
||
grad_b_d: CudaSlice<f32>,
|
||
grad_tau_d: CudaSlice<f32>,
|
||
grad_heads_w1_d: CudaSlice<f32>,
|
||
grad_heads_b1_d: CudaSlice<f32>,
|
||
grad_heads_w2_d: CudaSlice<f32>,
|
||
grad_heads_b2_d: CudaSlice<f32>,
|
||
grad_heads_w_gate_d: CudaSlice<f32>,
|
||
grad_heads_b_gate_d: CudaSlice<f32>,
|
||
grad_heads_w_main_d: CudaSlice<f32>,
|
||
grad_heads_b_main_d: CudaSlice<f32>,
|
||
grad_heads_w_skip_d: CudaSlice<f32>,
|
||
grad_heads_b_skip_d: CudaSlice<f32>,
|
||
// GRN forward intermediates saved for backward, per K position.
|
||
// Stored as [K, B, N_HORIZONS, HEAD_MID] in row-major; per-K slot
|
||
// is [B, N_HORIZONS, HEAD_MID] contiguous.
|
||
z1_per_k_d: CudaSlice<f32>,
|
||
a1_per_k_d: CudaSlice<f32>,
|
||
z2_per_k_d: CudaSlice<f32>,
|
||
// Per-K horizon-scalar intermediates: [K, B, N_HORIZONS].
|
||
gate_logit_per_k_d: CudaSlice<f32>,
|
||
main_per_k_d: CudaSlice<f32>,
|
||
logit_per_k_d: CudaSlice<f32>,
|
||
|
||
// Mapped-pinned staging for label uploads (the only remaining
|
||
// single-call host→device path; staging covers all K*B labels per
|
||
// training step in one DtoD copy).
|
||
stg_labels: MappedF32Buffer,
|
||
|
||
/// Captured CUDA Graph for the training step. First step_batched
|
||
/// call runs uncaptured (warmup); second call captures; third+
|
||
/// replays. Eliminates ~155 individual kernel launches per step.
|
||
train_graph: Option<CudaGraph>,
|
||
/// Captured CUDA Graph for the inference (`forward_only`) path. Same
|
||
/// three-state machine as `train_graph`: first call eager (warmup),
|
||
/// second call captures, third+ replays. Mirrors the forward chain
|
||
/// in `dispatch_evaluate_forward_kernels` (no labels / BCE / sync).
|
||
/// Per `pearl_no_host_branches_in_captured_graph` +
|
||
/// `pearl_cudarc_disable_event_tracking_for_graph_capture`: event
|
||
/// tracking is disabled for the trainer's lifetime at construction;
|
||
/// the dispatch helper performs only kernel launches and DtoD
|
||
/// memcpys on pre-bound device pointers.
|
||
forward_graph: Option<CudaGraph>,
|
||
/// First-call warmup flag for the forward (inference) path. Distinct
|
||
/// from `cublas_warmed` because the inference dispatch hits a
|
||
/// different cuBLAS call shape than the training step (no backward,
|
||
/// no Adam, no reduce-axis0). The first forward_only call runs
|
||
/// eager; the second captures; the third+ replays.
|
||
forward_warmed: bool,
|
||
/// cuBLAS warmup done — required before stream capture can safely
|
||
/// include cuBLAS calls.
|
||
cublas_warmed: bool,
|
||
|
||
// ── CRT Phase A0.5: incremental forward_step state ────────────────
|
||
/// Single-step scratch for Mamba2 L1 (in_dim=FEATURE_DIM). Holds the
|
||
/// persistent SSM register state `x_state` that advances by one
|
||
/// snapshot per `forward_step` call. Construct once at trainer init;
|
||
/// `reset_step_state()` zeros `x_state` for session-gap resets.
|
||
step_scratch_l1: Mamba2BlockStepScratch,
|
||
/// Single-step scratch for Mamba2 L2 (in_dim=HIDDEN_DIM). Same role
|
||
/// as `step_scratch_l1` but for the second SSM stack.
|
||
step_scratch_l2: Mamba2BlockStepScratch,
|
||
/// Persistent CfC hidden state for `forward_step` (`[1, HIDDEN_DIM]`).
|
||
/// Carries CfC's recurrent state across single-event calls — the
|
||
/// step path drops the attention-pool seed (which requires a
|
||
/// K-window) and instead lets CfC's natural decay (`exp(-dt/tau)`)
|
||
/// govern long-range context. Zero-initialised; `reset_step_state()`
|
||
/// rezeroes it.
|
||
cfc_h_state_step_d: CudaSlice<f32>,
|
||
/// Scratch buffer for one forward_step's CfC h_new output
|
||
/// (`[1, HIDDEN_DIM]`). Copied back into `cfc_h_state_step_d` at
|
||
/// end of step so the state thread is single-buffer-managed.
|
||
cfc_h_new_step_d: CudaSlice<f32>,
|
||
/// Single-row VSN output `[1, FEATURE_DIM]` for forward_step.
|
||
/// Distinct from `vsn_out_d` (which is [B, K, FEATURE_DIM] for the
|
||
/// supervised path) to keep the two paths independent.
|
||
vsn_step_out_d: GpuTensor,
|
||
/// Single-row gates scratch `[1, FEATURE_DIM]` for the VSN forward
|
||
/// kernel (variable_selection_fwd writes per-row gates). Unused
|
||
/// downstream in inference but required by the kernel signature.
|
||
vsn_step_gates_d: CudaSlice<f32>,
|
||
/// Single-row LN_a output `[1, HIDDEN_DIM]` for forward_step.
|
||
ln_a_step_out_d: CudaSlice<f32>,
|
||
/// Single-row LN_a stats `[1, 2]` for forward_step (mean + inv_std;
|
||
/// unused downstream but required by layer_norm_fwd).
|
||
ln_a_step_stats_d: CudaSlice<f32>,
|
||
/// Single-row LN_b output `[1, HIDDEN_DIM]` for forward_step (CfC input).
|
||
ln_b_step_out_d: CudaSlice<f32>,
|
||
/// Single-row LN_b stats `[1, 2]` for forward_step.
|
||
ln_b_step_stats_d: CudaSlice<f32>,
|
||
/// Per-horizon intermediate scratches `[1, N_HORIZONS, HEAD_MID_DIM]`
|
||
/// / `[1, N_HORIZONS]` — required by `multi_horizon_heads_grn_fwd_batched`
|
||
/// signature. Unused at inference but the kernel writes them.
|
||
z1_step_d: CudaSlice<f32>,
|
||
a1_step_d: CudaSlice<f32>,
|
||
z2_step_d: CudaSlice<f32>,
|
||
gate_logit_step_d: CudaSlice<f32>,
|
||
main_step_d: CudaSlice<f32>,
|
||
logit_step_d: CudaSlice<f32>,
|
||
/// Single-snapshot staging buffers for forward_step's host→device
|
||
/// path. Sized for one snapshot (10 levels × 4 px+sz fields, 6
|
||
/// regime values, etc.). Separate from the existing K-window
|
||
/// staging (`stg_bid_px_all` etc.) so the two paths don't share
|
||
/// buffer state.
|
||
stg_step_bid_px: MappedF32Buffer,
|
||
stg_step_bid_sz: MappedF32Buffer,
|
||
stg_step_ask_px: MappedF32Buffer,
|
||
stg_step_ask_sz: MappedF32Buffer,
|
||
stg_step_regime: MappedF32Buffer,
|
||
stg_step_prev_mid: MappedF32Buffer,
|
||
stg_step_trade_signed_vol: MappedF32Buffer,
|
||
stg_step_trade_count: MappedI32Buffer,
|
||
stg_step_ts_ns: MappedI64Buffer,
|
||
stg_step_prev_ts_ns: MappedI64Buffer,
|
||
/// Single-snapshot device shadows for the step path. Mirrors
|
||
/// `bid_px_all_d` etc. but sized for 1 snapshot.
|
||
step_bid_px_d: CudaSlice<f32>,
|
||
step_bid_sz_d: CudaSlice<f32>,
|
||
step_ask_px_d: CudaSlice<f32>,
|
||
step_ask_sz_d: CudaSlice<f32>,
|
||
step_prev_bid_sz_d: CudaSlice<f32>,
|
||
step_prev_ask_sz_d: CudaSlice<f32>,
|
||
step_regime_d: CudaSlice<f32>,
|
||
step_prev_mid_d: CudaSlice<f32>,
|
||
step_trade_signed_vol_d: CudaSlice<f32>,
|
||
step_trade_count_d: CudaSlice<i32>,
|
||
step_ts_ns_d: CudaSlice<i64>,
|
||
step_prev_ts_ns_d: CudaSlice<i64>,
|
||
/// Single-row feature output `[1, 1, FEATURE_DIM]` that
|
||
/// snap_feature_assemble_batched writes into for the step path.
|
||
/// Wrapped as a 3-D GpuTensor so it can feed VSN's existing
|
||
/// `[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,
|
||
/// Cached function handle for `tau_clamp_kernel` — Controller B's
|
||
/// post-Adam τ projection (spec §3.2). Launched after every
|
||
/// `opt_tau.step` in Phase 2; no-op in Phase 1.
|
||
tau_clamp_fn: CudaFunction,
|
||
/// Cached function handle for `heads_lr_multiplier_scale_kernel` —
|
||
/// Controller C's per-bucket LR multiplier on the heads_w_skip Adam
|
||
/// delta (spec §3.3, scope reduced to heads_w_skip in Task 11).
|
||
/// Launched after every `opt_heads_w_skip.step` in Phase 2.
|
||
heads_lr_multiplier_scale_fn: CudaFunction,
|
||
/// Cached function handle for `h_mag_per_bucket_kernel` — Controller D
|
||
/// dead-bucket detector signal (spec §3.4). Launched each Phase 2 step
|
||
/// after the K-loop, computing per-bucket mean(|h_state|) used by the
|
||
/// host-side EMA + threshold logic in [`step_batched`].
|
||
h_mag_per_bucket_fn: CudaFunction,
|
||
/// Cached function handle for `bucket_iqr_double_widening_kernel` —
|
||
/// Controller D Recovery Attempt 1 (spec §3.4). Single-thread kernel
|
||
/// that doubles the IQR envelope of one targeted bucket. Off-hot-path
|
||
/// (fires at most a handful of times per training run when a bucket
|
||
/// genuinely goes dead).
|
||
bucket_iqr_double_widening_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 10–12 will read its slices to drive Phase 2
|
||
/// dispatch + Controllers B / C / D.
|
||
pub bucket_routing_metadata:
|
||
Option<crate::cfc::bucket_routing::BucketRoutingMetadata>,
|
||
|
||
// ── Controller C state (per-bucket LR multiplier; spec §3.3) ──
|
||
//
|
||
// Scope reduction documented in Task 11: only `heads_w_skip_d` is
|
||
// rescaled per-horizon (N_HORIZONS × HIDDEN_DIM = 640 contiguous floats,
|
||
// per-horizon stride HIDDEN_DIM). The other heads weight groups
|
||
// (w1, w2, w_gate, w_main) keep unit LR multipliers; they are entangled
|
||
// in the GRN gate/main computation and rescaling them would require
|
||
// additional per-bucket scoping out of Task 11 scope. Revisit if
|
||
// Smoke 2 fails per `feedback_no_quickfixes`.
|
||
/// Pre-Adam snapshot of `trunk.heads_w_skip_d` for Controller C delta
|
||
/// rescaling. DtoD-copied each Phase 2 step BEFORE `opt_heads_w_skip.step`.
|
||
/// Shape matches `heads_w_skip_d` (N_HORIZONS × HIDDEN_DIM = 640 floats).
|
||
heads_w_skip_pre_adam_d: CudaSlice<f32>,
|
||
/// Per-channel routing mask for `heads_w_skip` (block-diagonal heads
|
||
/// via gradient masking — follow-up to Task 10 Option B). Built once
|
||
/// at Phase 1→2 transition by `heads_w_skip_mask_init_kernel`:
|
||
/// `mask[h, c] = 1.0` iff `bucket_id_per_channel[c] == h`, else 0.0.
|
||
/// At the same launch the kernel zero-multiplies off-bucket entries
|
||
/// of `heads_w_skip_d` so the GRN forward sees only block-diagonal
|
||
/// weights. Each Phase 2 backward step
|
||
/// `heads_w_skip_grad_mask_apply_kernel` multiplies
|
||
/// `grad_heads_w_skip_d` by this mask before Adam — off-bucket
|
||
/// entries stay at 0 throughout training. Shape `[N_HORIZONS × HIDDEN_DIM]`.
|
||
heads_w_skip_mask_d: CudaSlice<f32>,
|
||
/// Cached function handle for `heads_w_skip_mask_init_kernel` —
|
||
/// one-shot at Phase 1→2 transition (builds mask + zeros off-bucket
|
||
/// entries of `heads_w_skip_d` in place).
|
||
heads_w_skip_mask_init_fn: CudaFunction,
|
||
/// Cached function handle for `heads_w_skip_grad_mask_apply_kernel` —
|
||
/// per-step Phase 2 (multiplies `grad_heads_w_skip_d` by the routing
|
||
/// mask before Adam, enforcing block-diagonal updates).
|
||
heads_w_skip_grad_mask_apply_fn: CudaFunction,
|
||
/// Per-horizon LR multiplier vector consumed by
|
||
/// `heads_lr_multiplier_scale_kernel`. Mapped-pinned per
|
||
/// `feedback_no_htod_htoh_only_mapped_pinned`: host computes
|
||
/// `1 / (1 + smoothness_ratio_k)` each Phase 2 step and writes into
|
||
/// the staging slice; kernel reads via the device-visible pointer.
|
||
/// In Phase 1 stays at the zero-init value (kernel never launched).
|
||
lr_mult_per_horizon_staging: MappedF32Buffer,
|
||
/// Mapped-pinned shadow of `smoothness_jitter_ema_d` populated each
|
||
/// Phase 2 step (DtoD into the device-visible buffer, then host read
|
||
/// after the smoothness controller's launch barrier). Provides the
|
||
/// per-horizon `jitter_ema_k` signal that feeds Controller C's
|
||
/// smoothness ratio.
|
||
smoothness_jitter_ema_host_d: MappedF32Buffer,
|
||
/// Per-horizon target jitter for Controller C (`target_jitter_k` in
|
||
/// spec §3.3). Each Phase 2 step the host writes
|
||
/// `target_jitter_per_h[k] = sqrt(label_var_ema[k])` — anchored to the
|
||
/// observed label distribution per spec §3.3 + `feedback_isv_for_adaptive_bounds`
|
||
/// (data property, not model state). See `label_var_ema` below for
|
||
/// the variance EMA itself.
|
||
target_jitter_per_h: [f32; N_HORIZONS],
|
||
/// Per-horizon EMA of `Var(label_k)` over each batch's K×B labels.
|
||
/// Wiener-α with α = 0.4 floor per
|
||
/// `pearl_wiener_alpha_floor_for_nonstationary` (the closed-loop
|
||
/// gradient updates make Controller C a non-stationary target);
|
||
/// first-observation bootstrap per `pearl_first_observation_bootstrap`
|
||
/// (replace, don't blend, on the first sample). Source for
|
||
/// `target_jitter_per_h[k]` via `sqrt(label_var_ema[k])`.
|
||
label_var_ema: [f32; N_HORIZONS],
|
||
/// Has `label_var_ema` received its first observation? Gated separately
|
||
/// from `target_jitter_per_h` because the variance EMA is what's
|
||
/// bootstrapped — `target_jitter_per_h` is now derived each step.
|
||
label_var_ema_bootstrapped: bool,
|
||
|
||
// ── Controller D state (dead-bucket detector; spec §3.4) ──
|
||
//
|
||
// Per `feedback_no_functionality_removal`, Controller D is RECOVERY-FIRST:
|
||
// the bucket's architectural slot is preserved through two corrective
|
||
// attempts (slack widen, channel swap) before degraded inheritance is
|
||
// permitted. For Task 12 the cascade is wired as:
|
||
// - Attempt 1 (widen): FULL implementation — launches
|
||
// `bucket_iqr_double_widening_kernel` for the targeted bucket. This
|
||
// releases its τ values from Controller B's hard projection clip,
|
||
// letting the bucket re-engage if Controller B was pinching it.
|
||
// - Attempt 2 (channel swap): LOG-ONLY stub. The actual channel
|
||
// reassignment + CUDA-graph recapture is ~500 lines and is deferred
|
||
// until Smoke 2 observes a bucket actually requiring this tier (per
|
||
// `pearl_no_deferrals_for_complementary_fixes` — combining was not
|
||
// justified because the implementation pathways are disjoint). The
|
||
// stub emits `tracing::warn!` so a Smoke 2 firing surfaces in logs.
|
||
// - Attempt 3 (inheritance): LOG-ONLY stub. Same rationale as Attempt 2.
|
||
//
|
||
// Scope rationale: CfC.tau log-uniform init spans [10ms, 1000s] across
|
||
// 5 decades. Smoke 2 is expected to NOT exercise Controller D at all;
|
||
// the wired Attempt 1 + stubbed 2/3 cover the realistic firing path.
|
||
/// Per-bucket Controller D host state. Updated each Phase 2 step from
|
||
/// the mapped-pinned shadow of `h_mag_per_bucket_d`.
|
||
controller_d: ControllerDState,
|
||
/// Phase-2 step counter. Used to drive the first-observation bootstrap
|
||
/// of `controller_d.first_observation_floor` at Phase 2 step 1 per
|
||
/// `pearl_first_observation_bootstrap`. Increments only while
|
||
/// `phase == Phase2Routed`.
|
||
phase2_step_count: u64,
|
||
/// Device-side per-bucket mean-abs-h state buffer, written by
|
||
/// `h_mag_per_bucket_kernel` each Phase 2 step. `[N_HORIZONS]` floats.
|
||
h_mag_per_bucket_d: CudaSlice<f32>,
|
||
/// Mapped-pinned host shadow of `h_mag_per_bucket_d`. Captured DtoD
|
||
/// each Phase 2 step on the same stream as the kernel launch; host
|
||
/// reads after the end-of-step sync per the standard mapped-pinned
|
||
/// pattern (per `feedback_no_htod_htoh_only_mapped_pinned`).
|
||
h_mag_per_bucket_staged: MappedF32Buffer,
|
||
}
|
||
|
||
/// Controller D state — dead-bucket detector (spec §3.4).
|
||
///
|
||
/// Per `pearl_first_observation_bootstrap`: `first_observation_floor[k]` is
|
||
/// captured directly from the first Phase 2 observation; sentinel 0.0 until
|
||
/// then. Per `pearl_wiener_alpha_floor_for_nonstationary`: `h_mag_ema[k]` uses
|
||
/// α = 0.4 floor (co-adapting closed loop where the target drifts via the
|
||
/// trainer's gradient updates to τ).
|
||
///
|
||
/// `recovery_attempts[k]` advances 0 → 1 (widen) → 2 (channel swap, stub) →
|
||
/// 3 (inheritance, stub). Once at 3, no further escalation per spec §3.4.
|
||
///
|
||
/// `consecutive_dead_steps[k]` resets on each escalation OR when h_mag_ema
|
||
/// rises above the dead threshold (the latter is the recovery success path
|
||
/// — the bucket has re-engaged and we leave `recovery_attempts` latched as
|
||
/// a record of how far the cascade went).
|
||
///
|
||
/// `dead_window_k[k]` is ISV-anchored per spec §3.4: derived from the
|
||
/// observed h_mag_ema half-life. Bootstrap value = 50 steps (matches
|
||
/// Controller A's first-observation window — same order of magnitude as the
|
||
/// EMA settling time given α=0.4). After 50 Phase 2 steps the host
|
||
/// recomputes the half-life from observed crossings and updates the window
|
||
/// per `feedback_isv_for_adaptive_bounds`. If the bucket has never crossed
|
||
/// below the threshold in the first 50 steps the window stays at its
|
||
/// bootstrap value — no firing is expected for that bucket.
|
||
#[derive(Clone, Debug)]
|
||
pub struct ControllerDState {
|
||
/// First Phase 2 observation of `h_mag[k]` (sentinel 0.0 until step 1).
|
||
pub first_observation_floor: [f32; N_HORIZONS],
|
||
/// Wiener-α EMA of `h_mag[k]` with floor α = 0.4.
|
||
pub h_mag_ema: [f32; N_HORIZONS],
|
||
/// 0 = no recovery, 1 = widen fired, 2 = channel-swap stub fired,
|
||
/// 3 = inheritance stub fired. Monotonic non-decreasing.
|
||
pub recovery_attempts: [u8; N_HORIZONS],
|
||
/// Number of consecutive steps `h_mag_ema < first_observation_floor / e`.
|
||
/// Resets on escalation or recovery.
|
||
pub consecutive_dead_steps: [u32; N_HORIZONS],
|
||
/// ISV-derived dead window per bucket. Bootstrap 50; updated from
|
||
/// observed h_mag_ema half-life at Phase 2 step 50.
|
||
pub dead_window_k: [u32; N_HORIZONS],
|
||
}
|
||
|
||
impl ControllerDState {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
first_observation_floor: [0.0; N_HORIZONS],
|
||
h_mag_ema: [0.0; N_HORIZONS],
|
||
recovery_attempts: [0; N_HORIZONS],
|
||
consecutive_dead_steps: [0; N_HORIZONS],
|
||
// Bootstrap window: 50 Phase 2 steps. Matches Controller A's
|
||
// 100-step first-observation window order-of-magnitude (α=0.4
|
||
// Wiener EMA settling ≈ 2-3 steps; conservative window accounts
|
||
// for the gradient-driven drift co-adapting against the
|
||
// detector). Refreshed at step 50 from observed half-life.
|
||
dead_window_k: [50; N_HORIZONS],
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for ControllerDState {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
impl PerceptionTrainer {
|
||
pub fn new(dev: &MlDevice, cfg: &PerceptionTrainerConfig) -> Result<Self> {
|
||
anyhow::ensure!(cfg.seq_len >= 2, "Mamba2 requires seq_len >= 2");
|
||
|
||
// Seed GPU-side weight init (Mamba2 stacks initialise their weights via
|
||
// `OwnedGpuLinear::xavier`, which delegates to
|
||
// `ml_core::cuda_autograd::init::generate_uniform`). Without the
|
||
// thread-local guard, that helper seeds from SystemTime+thread-id and
|
||
// produces non-deterministic weights — every process gets fresh values.
|
||
// CfC/VSN/heads below use an explicit `ChaCha8Rng::seed_from_u64(cfg.seed)`
|
||
// chain already; the guard closes the remaining init paths so the
|
||
// whole trainer is reproducible from `cfg.seed`.
|
||
//
|
||
// Guard is dropped at the end of `new`, restoring default time-based
|
||
// seeding for any later xavier callers outside the trainer (e.g.,
|
||
// dqn / ppo crates that also use OwnedGpuLinear::xavier).
|
||
let _seed_guard = ml_core::cuda_autograd::init::scoped_init_seed(cfg.seed);
|
||
|
||
// X2: construct the wrapped CfcTrunk that owns the v2 inference
|
||
// graph weights. Trunk's V1 fields (CfC + V1 heads + projection)
|
||
// stay unused at this commit; subsequent migrations (X3..X9) move
|
||
// PerceptionTrainer's per-layer weights into the trunk one group
|
||
// at a time, gated by the perception_forward_golden bit-equivalence
|
||
// test.
|
||
let mut trunk = crate::cfc::trunk::CfcTrunk::new_random(
|
||
dev,
|
||
&crate::cfc::trunk::CfcConfig {
|
||
n_in: FEATURE_DIM,
|
||
n_hid: HIDDEN_DIM,
|
||
cfc_n_in: HIDDEN_DIM,
|
||
mamba2_state_dim: cfg.mamba2_state_dim,
|
||
n_batch: cfg.n_batch,
|
||
seq_len: cfg.seq_len,
|
||
},
|
||
cfg.seed,
|
||
)
|
||
.context("PerceptionTrainer: construct wrapped CfcTrunk")?;
|
||
|
||
let stream = dev.cuda_stream().context("trainer stream")?.clone();
|
||
let ctx = dev.cuda_context().context("trainer ctx")?;
|
||
// Disable cudarc's automatic per-allocation read/write event
|
||
// tracking BEFORE allocating any device memory. With tracking
|
||
// enabled, each `device_ptr_mut()` call returns a SyncOnDrop
|
||
// guard that records a CudaEvent on the stream when dropped.
|
||
// Inside a stream-capture region those `event.record(stream)`
|
||
// calls turn the events into "captured events" — which then
|
||
// cannot be waited on from outside the capture, breaking the
|
||
// non-captured eval path with CUDA_ERROR_INVALID_VALUE at the
|
||
// next `cuStreamWaitEvent`. All trainer work runs on a single
|
||
// stream (`self.stream`) and is therefore stream-ordered with
|
||
// no cross-stream sync needed, so event tracking is pure
|
||
// overhead for us. Disabled for the lifetime of this trainer.
|
||
unsafe { ctx.disable_event_tracking(); }
|
||
let _ = ctx.check_err();
|
||
let snap_module = ctx.load_cubin(SNAP_CUBIN.to_vec()).context("snap cubin")?;
|
||
let step_module = ctx.load_cubin(STEP_CUBIN.to_vec()).context("step cubin")?;
|
||
let heads_module = ctx.load_cubin(HEADS_CUBIN.to_vec()).context("heads cubin")?;
|
||
let bce_module = ctx.load_cubin(BCE_CUBIN.to_vec()).context("bce cubin")?;
|
||
let horizon_lambda_module = ctx
|
||
.load_cubin(HORIZON_LAMBDA_CUBIN.to_vec())
|
||
.context("horizon_lambda cubin")?;
|
||
let horizon_lambda_fn = horizon_lambda_module
|
||
.load_function("horizon_ema_and_lambda")
|
||
.context("horizon_ema_and_lambda symbol")?;
|
||
let smoothness_module = ctx
|
||
.load_cubin(SMOOTHNESS_CUBIN.to_vec())
|
||
.context("load output_smoothness cubin")?;
|
||
let smoothness_fn = smoothness_module
|
||
.load_function("output_smoothness_loss_and_grad")
|
||
.context("load output_smoothness_loss_and_grad")?;
|
||
let smoothness_controller_module = ctx
|
||
.load_cubin(SMOOTHNESS_CONTROLLER_CUBIN.to_vec())
|
||
.context("load smoothness_lambda_controller cubin")?;
|
||
let smoothness_controller_fn = smoothness_controller_module
|
||
.load_function("smoothness_lambda_controller")
|
||
.context("load smoothness_lambda_controller")?;
|
||
let ln_module = ctx
|
||
.load_cubin(LAYER_NORM_CUBIN.to_vec())
|
||
.context("layer_norm cubin")?;
|
||
let ln_bwd_fn = ln_module
|
||
.load_function("layer_norm_bwd")
|
||
.context("layer_norm_bwd symbol")?;
|
||
let ln_reduce_fn = ln_module
|
||
.load_function("layer_norm_reduce_param_grads")
|
||
.context("layer_norm_reduce_param_grads symbol")?;
|
||
let vsn_module = ctx
|
||
.load_cubin(VARIABLE_SELECTION_CUBIN.to_vec())
|
||
.context("variable_selection cubin")?;
|
||
let vsn_bwd_fn = vsn_module
|
||
.load_function("variable_selection_bwd")
|
||
.context("variable_selection_bwd symbol")?;
|
||
let attn_module = ctx
|
||
.load_cubin(ATTENTION_POOL_CUBIN.to_vec())
|
||
.context("attention_pool cubin")?;
|
||
let attn_bwd_fn = attn_module
|
||
.load_function("attention_pool_bwd")
|
||
.context("attention_pool_bwd symbol")?;
|
||
let reduce_module = ctx
|
||
.load_cubin(REDUCE_AXIS0_CUBIN.to_vec())
|
||
.context("reduce_axis0 cubin")?;
|
||
let reduce_axis0_fn = reduce_module
|
||
.load_function("reduce_axis0")
|
||
.context("reduce_axis0 symbol")?;
|
||
let bce_fn = bce_module.load_function("bce_multi_horizon_forward_backward")?;
|
||
let step_bwd_batched_fn = step_module.load_function("cfc_step_backward_batched")?;
|
||
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")?;
|
||
let tau_clamp_fn = bucket_transition_module
|
||
.load_function("tau_clamp_kernel")
|
||
.context("load tau_clamp_kernel")?;
|
||
let heads_lr_multiplier_scale_fn = bucket_transition_module
|
||
.load_function("heads_lr_multiplier_scale_kernel")
|
||
.context("load heads_lr_multiplier_scale_kernel")?;
|
||
let h_mag_per_bucket_fn = bucket_transition_module
|
||
.load_function("h_mag_per_bucket_kernel")
|
||
.context("load h_mag_per_bucket_kernel")?;
|
||
let bucket_iqr_double_widening_fn = bucket_transition_module
|
||
.load_function("bucket_iqr_double_widening_kernel")
|
||
.context("load bucket_iqr_double_widening_kernel")?;
|
||
let heads_w_skip_mask_init_fn = bucket_transition_module
|
||
.load_function("heads_w_skip_mask_init_kernel")
|
||
.context("load heads_w_skip_mask_init_kernel")?;
|
||
let heads_w_skip_grad_mask_apply_fn = bucket_transition_module
|
||
.load_function("heads_w_skip_grad_mask_apply_kernel")
|
||
.context("load heads_w_skip_grad_mask_apply_kernel")?;
|
||
|
||
// Mamba2 stacks live on the trunk from new_random; we wire optimizer
|
||
// + training scratches against the trunk-owned blocks.
|
||
let mamba2_adamw = Mamba2AdamW::new(
|
||
trunk.mamba2_l1(),
|
||
Mamba2AdamWConfig {
|
||
lr: cfg.lr_mamba2,
|
||
grad_clip_max_norm: Some(1.0),
|
||
..Default::default()
|
||
},
|
||
)
|
||
.context("Mamba2AdamW::new")?;
|
||
let mamba2_fwd_scratch = Mamba2BlockForwardScratch::new(
|
||
&stream, cfg.n_batch, cfg.seq_len, FEATURE_DIM, HIDDEN_DIM, cfg.mamba2_state_dim,
|
||
).context("Mamba2BlockForwardScratch::new")?;
|
||
let mamba2_bwd_scratch = Mamba2BackwardScratch::new(
|
||
&stream, cfg.n_batch, cfg.seq_len, HIDDEN_DIM, cfg.mamba2_state_dim,
|
||
).context("Mamba2BackwardScratch::new")?;
|
||
let mamba2_grads_buffers = Mamba2BackwardGradsBuffers::new(
|
||
&stream, cfg.n_batch, cfg.seq_len, FEATURE_DIM, HIDDEN_DIM, cfg.mamba2_state_dim,
|
||
).context("Mamba2BackwardGradsBuffers::new")?;
|
||
|
||
// ── Phase 2B: SECOND Mamba2 stack — same shape, on the trunk. ──
|
||
let mamba2_l2_adamw = Mamba2AdamW::new(
|
||
trunk.mamba2_l2(),
|
||
Mamba2AdamWConfig {
|
||
lr: cfg.lr_mamba2,
|
||
grad_clip_max_norm: Some(1.0),
|
||
..Default::default()
|
||
},
|
||
)
|
||
.context("Mamba2AdamW::new (l2)")?;
|
||
let mamba2_l2_fwd_scratch = Mamba2BlockForwardScratch::new(
|
||
&stream, cfg.n_batch, cfg.seq_len, HIDDEN_DIM, HIDDEN_DIM, cfg.mamba2_state_dim,
|
||
).context("Mamba2BlockForwardScratch::new (l2)")?;
|
||
let mamba2_l2_bwd_scratch = Mamba2BackwardScratch::new(
|
||
&stream, cfg.n_batch, cfg.seq_len, HIDDEN_DIM, cfg.mamba2_state_dim,
|
||
).context("Mamba2BackwardScratch::new (l2)")?;
|
||
let mamba2_l2_grads_buffers = Mamba2BackwardGradsBuffers::new(
|
||
&stream, cfg.n_batch, cfg.seq_len, HIDDEN_DIM, HIDDEN_DIM, cfg.mamba2_state_dim,
|
||
).context("Mamba2BackwardGradsBuffers::new (l2)")?;
|
||
let window_tensor_d = GpuTensor::zeros(&[cfg.n_batch, cfg.seq_len, FEATURE_DIM], &stream)
|
||
.map_err(|e| anyhow::anyhow!("window_tensor_d alloc: {e}"))?;
|
||
let h_enriched_seq_t_d = GpuTensor::zeros(&[cfg.seq_len, cfg.n_batch, HIDDEN_DIM], &stream)
|
||
.map_err(|e| anyhow::anyhow!("h_enriched_seq_t_d alloc: {e}"))?;
|
||
let grad_h_enriched_seq_t_d = GpuTensor::zeros(&[cfg.seq_len, cfg.n_batch, HIDDEN_DIM], &stream)
|
||
.map_err(|e| anyhow::anyhow!("grad_h_enriched_seq_t_d alloc: {e}"))?;
|
||
let grad_h_enriched_seq_d = GpuTensor::zeros(&[cfg.n_batch, cfg.seq_len, HIDDEN_DIM], &stream)
|
||
.map_err(|e| anyhow::anyhow!("grad_h_enriched_seq_d alloc: {e}"))?;
|
||
|
||
// CfC weights (input = h_enriched [HIDDEN_DIM], output = [HIDDEN_DIM])
|
||
let mut r = ChaCha8Rng::seed_from_u64(cfg.seed);
|
||
let n_in = HIDDEN_DIM;
|
||
let n_hid = HIDDEN_DIM;
|
||
let scale_in = (1.0_f32 / n_in as f32).sqrt();
|
||
let scale_rec = (1.0_f32 / n_hid as f32).sqrt();
|
||
let w_in: Vec<f32> = (0..n_hid * n_in).map(|_| r.gen_range(-scale_in..scale_in)).collect();
|
||
let w_rec: Vec<f32> = (0..n_hid * n_hid).map(|_| r.gen_range(-scale_rec..scale_rec)).collect();
|
||
let b: Vec<f32> = vec![0.0; n_hid];
|
||
let tau: Vec<f32> = (0..n_hid)
|
||
.map(|_| {
|
||
let u: f32 = r.gen_range(0.0..1.0);
|
||
(-4.6 + u * 11.5).exp()
|
||
})
|
||
.collect();
|
||
// ── TFT GRN heads init ──
|
||
// Xavier-uniform per-tensor: scale = sqrt(1 / fan_in).
|
||
let scale_h_in = (1.0_f32 / n_hid as f32).sqrt(); // HIDDEN → HEAD_MID
|
||
let scale_mid_mid = (1.0_f32 / HEAD_MID_DIM as f32).sqrt(); // HEAD_MID → HEAD_MID / → 1
|
||
let scale_h_skip = (1.0_f32 / n_hid as f32).sqrt(); // HIDDEN → 1
|
||
let heads_w1: Vec<f32> = (0..N_HORIZONS * HEAD_MID_DIM * n_hid)
|
||
.map(|_| r.gen_range(-scale_h_in..scale_h_in)).collect();
|
||
let heads_b1: Vec<f32> = vec![0.0; N_HORIZONS * HEAD_MID_DIM];
|
||
let heads_w2: Vec<f32> = (0..N_HORIZONS * HEAD_MID_DIM * HEAD_MID_DIM)
|
||
.map(|_| r.gen_range(-scale_mid_mid..scale_mid_mid)).collect();
|
||
let heads_b2: Vec<f32> = vec![0.0; N_HORIZONS * HEAD_MID_DIM];
|
||
let heads_w_gate: Vec<f32> = (0..N_HORIZONS * HEAD_MID_DIM)
|
||
.map(|_| r.gen_range(-scale_mid_mid..scale_mid_mid)).collect();
|
||
let heads_b_gate: Vec<f32> = vec![0.0; N_HORIZONS];
|
||
let heads_w_main: Vec<f32> = (0..N_HORIZONS * HEAD_MID_DIM)
|
||
.map(|_| r.gen_range(-scale_mid_mid..scale_mid_mid)).collect();
|
||
let heads_b_main: Vec<f32> = vec![0.0; N_HORIZONS];
|
||
let heads_w_skip: Vec<f32> = (0..N_HORIZONS * n_hid)
|
||
.map(|_| r.gen_range(-scale_h_skip..scale_h_skip)).collect();
|
||
let heads_b_skip: Vec<f32> = vec![0.0; N_HORIZONS];
|
||
|
||
// X8: upload trainer's PRNG-derived CfC weights into the trunk's
|
||
// (now v2-shaped) CfC slots.
|
||
stream.memcpy_htod(&w_in, &mut trunk.w_in_d).context("trunk.w_in_d upload")?;
|
||
stream.memcpy_htod(&w_rec, &mut trunk.w_rec_d).context("trunk.w_rec_d upload")?;
|
||
stream.memcpy_htod(&b, &mut trunk.b_d).context("trunk.b_d upload")?;
|
||
stream.memcpy_htod(&tau, &mut trunk.tau_all_d).context("trunk.tau_all_d upload")?;
|
||
// X9: upload trainer's GRN heads into the trunk's slots.
|
||
stream.memcpy_htod(&heads_w1, &mut trunk.heads_w1_d).context("trunk.heads_w1 upload")?;
|
||
stream.memcpy_htod(&heads_b1, &mut trunk.heads_b1_d).context("trunk.heads_b1 upload")?;
|
||
stream.memcpy_htod(&heads_w2, &mut trunk.heads_w2_d).context("trunk.heads_w2 upload")?;
|
||
stream.memcpy_htod(&heads_b2, &mut trunk.heads_b2_d).context("trunk.heads_b2 upload")?;
|
||
stream.memcpy_htod(&heads_w_gate, &mut trunk.heads_w_gate_d).context("trunk.heads_w_gate upload")?;
|
||
stream.memcpy_htod(&heads_b_gate, &mut trunk.heads_b_gate_d).context("trunk.heads_b_gate upload")?;
|
||
stream.memcpy_htod(&heads_w_main, &mut trunk.heads_w_main_d).context("trunk.heads_w_main upload")?;
|
||
stream.memcpy_htod(&heads_b_main, &mut trunk.heads_b_main_d).context("trunk.heads_b_main upload")?;
|
||
stream.memcpy_htod(&heads_w_skip, &mut trunk.heads_w_skip_d).context("trunk.heads_w_skip upload")?;
|
||
stream.memcpy_htod(&heads_b_skip, &mut trunk.heads_b_skip_d).context("trunk.heads_b_skip upload")?;
|
||
|
||
let opt_w_in = AdamW::new(dev, n_hid * n_in, cfg.lr_cfc)?;
|
||
let opt_w_rec = AdamW::new(dev, n_hid * n_hid, cfg.lr_cfc)?;
|
||
let mut opt_b = AdamW::new(dev, n_hid, cfg.lr_cfc)?;
|
||
opt_b.wd = 0.0;
|
||
// tau is bounded log-uniformly initialised; small LR + no weight
|
||
// decay so the per-cell decay constants drift smoothly.
|
||
let mut opt_tau = AdamW::new(dev, n_hid, cfg.lr_cfc * 0.1)?;
|
||
opt_tau.wd = 0.0;
|
||
|
||
// ── K-loop parallelization (Phase B): cfc per-batch grad scratch ──
|
||
let cfc_grad_w_in_scratch_d = stream
|
||
.alloc_zeros::<f32>(cfg.n_batch * n_hid * n_in)
|
||
.context("cfc_grad_w_in_scratch_d alloc")?;
|
||
let cfc_grad_w_rec_scratch_d = stream
|
||
.alloc_zeros::<f32>(cfg.n_batch * n_hid * n_hid)
|
||
.context("cfc_grad_w_rec_scratch_d alloc")?;
|
||
let cfc_grad_b_scratch_d = stream
|
||
.alloc_zeros::<f32>(cfg.n_batch * n_hid)
|
||
.context("cfc_grad_b_scratch_d alloc")?;
|
||
let cfc_grad_tau_scratch_d = stream
|
||
.alloc_zeros::<f32>(cfg.n_batch * n_hid)
|
||
.context("cfc_grad_tau_scratch_d alloc")?;
|
||
// GRN heads: 10 AdamW (5 weight + 5 bias). Biases get wd=0 per
|
||
// the existing pattern; weights keep AdamW default wd.
|
||
let opt_heads_w1 = AdamW::new(dev, N_HORIZONS * HEAD_MID_DIM * n_hid, cfg.lr_cfc)?;
|
||
let mut opt_heads_b1 = AdamW::new(dev, N_HORIZONS * HEAD_MID_DIM, cfg.lr_cfc)?;
|
||
opt_heads_b1.wd = 0.0;
|
||
let opt_heads_w2 = AdamW::new(dev, N_HORIZONS * HEAD_MID_DIM * HEAD_MID_DIM, cfg.lr_cfc)?;
|
||
let mut opt_heads_b2 = AdamW::new(dev, N_HORIZONS * HEAD_MID_DIM, cfg.lr_cfc)?;
|
||
opt_heads_b2.wd = 0.0;
|
||
let opt_heads_w_gate = AdamW::new(dev, N_HORIZONS * HEAD_MID_DIM, cfg.lr_cfc)?;
|
||
let mut opt_heads_b_gate = AdamW::new(dev, N_HORIZONS, cfg.lr_cfc)?;
|
||
opt_heads_b_gate.wd = 0.0;
|
||
let opt_heads_w_main = AdamW::new(dev, N_HORIZONS * HEAD_MID_DIM, cfg.lr_cfc)?;
|
||
let mut opt_heads_b_main = AdamW::new(dev, N_HORIZONS, cfg.lr_cfc)?;
|
||
opt_heads_b_main.wd = 0.0;
|
||
let opt_heads_w_skip = AdamW::new(dev, N_HORIZONS * n_hid, cfg.lr_cfc)?;
|
||
let mut opt_heads_b_skip = AdamW::new(dev, N_HORIZONS, cfg.lr_cfc)?;
|
||
opt_heads_b_skip.wd = 0.0;
|
||
|
||
// Phase B: GRN per-batch grad scratch.
|
||
let grn_grad_w1_scratch_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * N_HORIZONS * HEAD_MID_DIM * HIDDEN_DIM)?;
|
||
let grn_grad_b1_scratch_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * N_HORIZONS * HEAD_MID_DIM)?;
|
||
let grn_grad_w2_scratch_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * N_HORIZONS * HEAD_MID_DIM * HEAD_MID_DIM)?;
|
||
let grn_grad_b2_scratch_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * N_HORIZONS * HEAD_MID_DIM)?;
|
||
let grn_grad_w_gate_scratch_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * N_HORIZONS * HEAD_MID_DIM)?;
|
||
let grn_grad_b_gate_scratch_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * N_HORIZONS)?;
|
||
let grn_grad_w_main_scratch_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * N_HORIZONS * HEAD_MID_DIM)?;
|
||
let grn_grad_b_main_scratch_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * N_HORIZONS)?;
|
||
let grn_grad_w_skip_scratch_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * N_HORIZONS * HIDDEN_DIM)?;
|
||
let grn_grad_b_skip_scratch_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * N_HORIZONS)?;
|
||
|
||
// LayerNorm parameters: gain initialised to 1.0, bias to 0.0.
|
||
// Adam wd=0 — LN params are scale/shift, never penalised.
|
||
// Two instances: LN_a between m1 and m2, LN_b between m2 and CfC.
|
||
let ln_gain_init: Vec<f32> = vec![1.0; HIDDEN_DIM];
|
||
let ln_bias_init: Vec<f32> = vec![0.0; HIDDEN_DIM];
|
||
// X6: upload LN_b weights into the trunk's slots.
|
||
stream
|
||
.memcpy_htod(&ln_gain_init, &mut trunk.ln_b_gain_d)
|
||
.context("trunk.ln_b_gain_d upload")?;
|
||
stream
|
||
.memcpy_htod(&ln_bias_init, &mut trunk.ln_b_bias_d)
|
||
.context("trunk.ln_b_bias_d upload")?;
|
||
let mut opt_ln_gain = AdamW::new(dev, HIDDEN_DIM, cfg.lr_cfc)?;
|
||
opt_ln_gain.wd = 0.0;
|
||
let mut opt_ln_bias = AdamW::new(dev, HIDDEN_DIM, cfg.lr_cfc)?;
|
||
opt_ln_bias.wd = 0.0;
|
||
// X4: upload LN_a weights into the trunk's slots.
|
||
stream
|
||
.memcpy_htod(&ln_gain_init, &mut trunk.ln_a_gain_d)
|
||
.context("trunk.ln_a_gain_d upload")?;
|
||
stream
|
||
.memcpy_htod(&ln_bias_init, &mut trunk.ln_a_bias_d)
|
||
.context("trunk.ln_a_bias_d upload")?;
|
||
let mut opt_ln_a_gain = AdamW::new(dev, HIDDEN_DIM, cfg.lr_cfc)?;
|
||
opt_ln_a_gain.wd = 0.0;
|
||
let mut opt_ln_a_bias = AdamW::new(dev, HIDDEN_DIM, cfg.lr_cfc)?;
|
||
opt_ln_a_bias.wd = 0.0;
|
||
// LN_a + grad scratch buffers (constructed pre-move-of-stream).
|
||
let ln_a_stats_d = stream.alloc_zeros::<f32>(cfg.n_batch * cfg.seq_len * 2)?;
|
||
let ln_a_out_d = GpuTensor::zeros(&[cfg.n_batch, cfg.seq_len, HIDDEN_DIM], &stream)
|
||
.map_err(|e| anyhow::anyhow!("ln_a_out_d alloc: {e}"))?;
|
||
let grad_ln_a_gain_per_row_d = stream.alloc_zeros::<f32>(cfg.n_batch * cfg.seq_len * HIDDEN_DIM)?;
|
||
let grad_ln_a_bias_per_row_d = stream.alloc_zeros::<f32>(cfg.n_batch * cfg.seq_len * HIDDEN_DIM)?;
|
||
let grad_ln_a_gain_d = stream.alloc_zeros::<f32>(HIDDEN_DIM)?;
|
||
let grad_ln_a_bias_d = stream.alloc_zeros::<f32>(HIDDEN_DIM)?;
|
||
let grad_ln_a_in_d = GpuTensor::zeros(&[cfg.n_batch, cfg.seq_len, HIDDEN_DIM], &stream)
|
||
.map_err(|e| anyhow::anyhow!("grad_ln_a_in_d alloc: {e}"))?;
|
||
|
||
// ── VSN init (Phase 2D) ──
|
||
// W_vsn ∈ [FEATURE_DIM, FEATURE_DIM] init near zero so initial
|
||
// gates ≈ softmax(0) = uniform 1/FEATURE_DIM. The model can then
|
||
// learn departures from uniform. Scale = sqrt(1/FEATURE_DIM).
|
||
//
|
||
// X2 migration: VSN weights now live on `self.trunk` (the wrapped
|
||
// CfcTrunk). We still draw values from the trainer's PRNG chain
|
||
// here to keep the chain state — and therefore every downstream
|
||
// weight (attn_q, etc.) — bit-identical to the pre-X2 layout. The
|
||
// draws are uploaded directly into the trunk's zero-initialised
|
||
// VSN slots via memcpy_htod, replacing the prior trainer-owned
|
||
// vsn_w_d / vsn_b_d allocations.
|
||
let vsn_scale = (1.0_f32 / FEATURE_DIM as f32).sqrt();
|
||
let vsn_w_init: Vec<f32> = (0..FEATURE_DIM * FEATURE_DIM)
|
||
.map(|_| r.gen_range(-vsn_scale..vsn_scale)).collect();
|
||
let vsn_b_init: Vec<f32> = vec![0.0; FEATURE_DIM];
|
||
// Upload the trainer-driven VSN draws into the trunk's
|
||
// zero-initialised VSN slots from X1. From here on,
|
||
// `self.trunk.vsn_w_d` / `self.trunk.vsn_b_d` are the canonical
|
||
// VSN weights for both forward and backward paths.
|
||
stream
|
||
.memcpy_htod(&vsn_w_init, &mut trunk.vsn_w_d)
|
||
.context("trunk.vsn_w_d upload")?;
|
||
stream
|
||
.memcpy_htod(&vsn_b_init, &mut trunk.vsn_b_d)
|
||
.context("trunk.vsn_b_d upload")?;
|
||
let opt_vsn_w = AdamW::new(dev, FEATURE_DIM * FEATURE_DIM, cfg.lr_cfc)?;
|
||
let mut opt_vsn_b = AdamW::new(dev, FEATURE_DIM, cfg.lr_cfc)?;
|
||
opt_vsn_b.wd = 0.0;
|
||
// Phase B: VSN per-row grad scratch (n_rows = B * K).
|
||
let vsn_grad_w_scratch_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * cfg.seq_len * FEATURE_DIM * FEATURE_DIM)?;
|
||
let vsn_grad_b_scratch_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * cfg.seq_len * FEATURE_DIM)?;
|
||
|
||
// ── Attention pool init (Phase 3) ──
|
||
// Q init near zero so initial scores ≈ 0 → softmax ≈ uniform 1/K
|
||
// → context ≈ mean of LN_b output. Model learns content
|
||
// addressing from a near-uniform starting point.
|
||
let attn_q_scale = (1.0_f32 / HIDDEN_DIM as f32).sqrt();
|
||
let attn_q_init: Vec<f32> = (0..HIDDEN_DIM)
|
||
.map(|_| r.gen_range(-attn_q_scale..attn_q_scale)).collect();
|
||
// X7: upload attention-pool query into the trunk's slot.
|
||
stream
|
||
.memcpy_htod(&attn_q_init, &mut trunk.attn_q_d)
|
||
.context("trunk.attn_q_d upload")?;
|
||
let attn_context_d = stream.alloc_zeros::<f32>(cfg.n_batch * HIDDEN_DIM)?;
|
||
let attn_weights_d = stream.alloc_zeros::<f32>(cfg.n_batch * cfg.seq_len)?;
|
||
let grad_attn_q_d = stream.alloc_zeros::<f32>(HIDDEN_DIM)?;
|
||
let opt_attn_q = AdamW::new(dev, HIDDEN_DIM, cfg.lr_cfc)?;
|
||
// Phase B: attn pool per-batch grad scratch.
|
||
let attn_grad_q_scratch_d = stream.alloc_zeros::<f32>(cfg.n_batch * HIDDEN_DIM)?;
|
||
|
||
// Kendall σ — per-horizon learnable `log σ_h` (axis A only;
|
||
// C/D/E reverted after the 2026-05-18 A/B sweep). Init at 0
|
||
// → weight = 1/(2·exp(0)) = 0.5 per horizon at step 0; AdamW
|
||
// updates at 1/4 the head LR per the spec.
|
||
let log_sigma_h_d = stream.alloc_zeros::<f32>(N_HORIZONS)?;
|
||
let grad_log_sigma_h_d = stream.alloc_zeros::<f32>(N_HORIZONS)?;
|
||
// ISV-driven: z_max_ema tracks EMA(max |z_h|) across horizons to set
|
||
// adaptive Z_SCALE for λ. Sentinel = 0 ⇒ first-obs bootstrap.
|
||
let z_max_ema_d = stream.alloc_zeros::<f32>(1)?;
|
||
|
||
// V1 (2026-05-18): per-horizon Q_h trainer state (C24/C25) removed.
|
||
// A/B sweep at commit 83546b5c3 falsified the approach (mean_auc
|
||
// -0.019 vs baseline; h6000 essentially tied). v2 design with
|
||
// horizon-token K-prepend + inverted attention + regime-MoE +
|
||
// Kendall σ-BCE + L2 anchor replaces it. See
|
||
// docs/superpowers/specs/2026-05-18-ml-alpha-v2-multi-horizon-design.md
|
||
// and the V1-V13 plan.
|
||
|
||
// ── CRT Phase A0.5: forward_step (incremental SSM) state ──
|
||
// Single-snapshot scratch for the event-rate inference path.
|
||
// Mamba2 step kernels write back the SSM register state in-place,
|
||
// so these scratches accumulate context across forward_step calls
|
||
// without re-running over a K-window each time.
|
||
let step_scratch_l1 = Mamba2BlockStepScratch::new(
|
||
&stream, cfg.n_batch, FEATURE_DIM, HIDDEN_DIM, cfg.mamba2_state_dim,
|
||
).context("Mamba2BlockStepScratch::new (l1)")?;
|
||
let step_scratch_l2 = Mamba2BlockStepScratch::new(
|
||
&stream, cfg.n_batch, HIDDEN_DIM, HIDDEN_DIM, cfg.mamba2_state_dim,
|
||
).context("Mamba2BlockStepScratch::new (l2)")?;
|
||
let cfc_h_state_step_d = stream.alloc_zeros::<f32>(cfg.n_batch * HIDDEN_DIM)
|
||
.context("cfc_h_state_step_d alloc")?;
|
||
let cfc_h_new_step_d = stream.alloc_zeros::<f32>(cfg.n_batch * HIDDEN_DIM)
|
||
.context("cfc_h_new_step_d alloc")?;
|
||
let vsn_step_out_d = GpuTensor::zeros(&[cfg.n_batch, FEATURE_DIM], &stream)
|
||
.map_err(|e| anyhow::anyhow!("vsn_step_out_d alloc: {e}"))?;
|
||
let vsn_step_gates_d = stream.alloc_zeros::<f32>(cfg.n_batch * FEATURE_DIM)
|
||
.context("vsn_step_gates_d alloc")?;
|
||
let ln_a_step_out_d = stream.alloc_zeros::<f32>(cfg.n_batch * HIDDEN_DIM)
|
||
.context("ln_a_step_out_d alloc")?;
|
||
let ln_a_step_stats_d = stream.alloc_zeros::<f32>(cfg.n_batch * 2)
|
||
.context("ln_a_step_stats_d alloc")?;
|
||
let ln_b_step_out_d = stream.alloc_zeros::<f32>(cfg.n_batch * HIDDEN_DIM)
|
||
.context("ln_b_step_out_d alloc")?;
|
||
let ln_b_step_stats_d = stream.alloc_zeros::<f32>(cfg.n_batch * 2)
|
||
.context("ln_b_step_stats_d alloc")?;
|
||
// probs_step_d / probs_step_host removed: forward_step_into now
|
||
// writes probs directly into a caller-supplied device buffer
|
||
// (typically `LobSimCuda::alpha_probs_d_mut`), eliminating the
|
||
// per-event DtoD-to-host + stream.synchronize that previously
|
||
// round-tripped the 5 probs through pinned host memory just to
|
||
// immediately re-upload them in `broadcast_alpha`.
|
||
let z1_step_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * N_HORIZONS * HEAD_MID_DIM).context("z1_step_d alloc")?;
|
||
let a1_step_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * N_HORIZONS * HEAD_MID_DIM).context("a1_step_d alloc")?;
|
||
let z2_step_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * N_HORIZONS * HEAD_MID_DIM).context("z2_step_d alloc")?;
|
||
let gate_logit_step_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * N_HORIZONS).context("gate_logit_step_d alloc")?;
|
||
let main_step_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * N_HORIZONS).context("main_step_d alloc")?;
|
||
let logit_step_d = stream.alloc_zeros::<f32>(
|
||
cfg.n_batch * N_HORIZONS).context("logit_step_d alloc")?;
|
||
let stg_step_bid_px = unsafe { MappedF32Buffer::new(cfg.n_batch * 10) }
|
||
.map_err(|e| anyhow::anyhow!("stg_step_bid_px: {e}"))?;
|
||
let stg_step_bid_sz = unsafe { MappedF32Buffer::new(cfg.n_batch * 10) }
|
||
.map_err(|e| anyhow::anyhow!("stg_step_bid_sz: {e}"))?;
|
||
let stg_step_ask_px = unsafe { MappedF32Buffer::new(cfg.n_batch * 10) }
|
||
.map_err(|e| anyhow::anyhow!("stg_step_ask_px: {e}"))?;
|
||
let stg_step_ask_sz = unsafe { MappedF32Buffer::new(cfg.n_batch * 10) }
|
||
.map_err(|e| anyhow::anyhow!("stg_step_ask_sz: {e}"))?;
|
||
let stg_step_regime = unsafe { MappedF32Buffer::new(cfg.n_batch * REGIME_DIM) }
|
||
.map_err(|e| anyhow::anyhow!("stg_step_regime: {e}"))?;
|
||
let stg_step_prev_mid = unsafe { MappedF32Buffer::new(cfg.n_batch) }
|
||
.map_err(|e| anyhow::anyhow!("stg_step_prev_mid: {e}"))?;
|
||
let stg_step_trade_signed_vol = unsafe { MappedF32Buffer::new(cfg.n_batch) }
|
||
.map_err(|e| anyhow::anyhow!("stg_step_trade_signed_vol: {e}"))?;
|
||
let stg_step_trade_count = unsafe { MappedI32Buffer::new(cfg.n_batch) }
|
||
.map_err(|e| anyhow::anyhow!("stg_step_trade_count: {e}"))?;
|
||
let stg_step_ts_ns = unsafe { MappedI64Buffer::new(cfg.n_batch) }
|
||
.map_err(|e| anyhow::anyhow!("stg_step_ts_ns: {e}"))?;
|
||
let stg_step_prev_ts_ns = unsafe { MappedI64Buffer::new(cfg.n_batch) }
|
||
.map_err(|e| anyhow::anyhow!("stg_step_prev_ts_ns: {e}"))?;
|
||
let step_bid_px_d = stream.alloc_zeros::<f32>(cfg.n_batch * 10)?;
|
||
let step_bid_sz_d = stream.alloc_zeros::<f32>(cfg.n_batch * 10)?;
|
||
let step_ask_px_d = stream.alloc_zeros::<f32>(cfg.n_batch * 10)?;
|
||
let step_ask_sz_d = stream.alloc_zeros::<f32>(cfg.n_batch * 10)?;
|
||
let step_prev_bid_sz_d = stream.alloc_zeros::<f32>(cfg.n_batch * 10)?;
|
||
let step_prev_ask_sz_d = stream.alloc_zeros::<f32>(cfg.n_batch * 10)?;
|
||
let step_regime_d = stream.alloc_zeros::<f32>(cfg.n_batch * REGIME_DIM)?;
|
||
let step_prev_mid_d = stream.alloc_zeros::<f32>(cfg.n_batch)?;
|
||
let step_trade_signed_vol_d = stream.alloc_zeros::<f32>(cfg.n_batch)?;
|
||
let step_trade_count_d = stream.alloc_zeros::<i32>(cfg.n_batch)?;
|
||
let step_ts_ns_d = stream.alloc_zeros::<i64>(cfg.n_batch)?;
|
||
let step_prev_ts_ns_d = stream.alloc_zeros::<i64>(cfg.n_batch)?;
|
||
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")?;
|
||
// ── Controller C scratch buffers (Task 11) ──
|
||
// Pre-Adam snapshot of heads_w_skip_d. Same shape as the live
|
||
// weight tensor (N_HORIZONS × HIDDEN_DIM).
|
||
let heads_w_skip_pre_adam_d = stream
|
||
.alloc_zeros::<f32>(N_HORIZONS * crate::cfc::bucket_routing::HIDDEN_DIM)
|
||
.context("heads_w_skip_pre_adam_d alloc")?;
|
||
// Block-diagonal heads grad-mask (follow-up to Task 10 Option B).
|
||
// Zero-allocated; populated at Phase 1→2 transition by
|
||
// `heads_w_skip_mask_init_kernel`. Phase 1 reads it never — the
|
||
// grad-mask apply kernel only fires in Phase 2.
|
||
let heads_w_skip_mask_d = stream
|
||
.alloc_zeros::<f32>(N_HORIZONS * crate::cfc::bucket_routing::HIDDEN_DIM)
|
||
.context("heads_w_skip_mask_d alloc")?;
|
||
// Per-horizon LR multiplier staging. Mapped-pinned so the host
|
||
// writes from `target_jitter_per_h` + jitter EMA each Phase 2 step
|
||
// and the kernel reads the device-visible pointer without DtoH.
|
||
let lr_mult_per_horizon_staging = unsafe { MappedF32Buffer::new(N_HORIZONS) }
|
||
.map_err(|e| anyhow::anyhow!("lr_mult_per_horizon_staging: {e}"))?;
|
||
// Bootstrap to 1.0 (no attenuation) so a Phase 1 kernel that
|
||
// accidentally launches still preserves the Adam delta. Phase 2's
|
||
// first step overwrites this before the launch.
|
||
lr_mult_per_horizon_staging.write_from_slice(&[1.0; N_HORIZONS]);
|
||
// Mapped-pinned shadow of the per-horizon jitter EMA. Each Phase 2
|
||
// step DtoD-copies `smoothness_jitter_ema_d` into this buffer (5
|
||
// floats); host reads after the step sync.
|
||
let smoothness_jitter_ema_host_d = unsafe { MappedF32Buffer::new(N_HORIZONS) }
|
||
.map_err(|e| anyhow::anyhow!("smoothness_jitter_ema_host_d: {e}"))?;
|
||
let controller_a = crate::cfc::bucket_routing::ControllerA::new();
|
||
|
||
// ── Controller D scratch buffers (Task 12) ──
|
||
//
|
||
// `h_mag_per_bucket_d` is the device-side output of
|
||
// `h_mag_per_bucket_kernel` — one float per bucket. Each Phase 2
|
||
// step the kernel writes into it; the captured-graph DtoD shadow
|
||
// into `h_mag_per_bucket_staged` (mapped-pinned) makes the values
|
||
// visible to the host after the end-of-step sync per
|
||
// `feedback_no_htod_htoh_only_mapped_pinned`.
|
||
let h_mag_per_bucket_d = stream
|
||
.alloc_zeros::<f32>(N_HORIZONS)
|
||
.context("h_mag_per_bucket_d alloc")?;
|
||
let h_mag_per_bucket_staged = unsafe { MappedF32Buffer::new(N_HORIZONS) }
|
||
.map_err(|e| anyhow::anyhow!("h_mag_per_bucket_staged: {e}"))?;
|
||
|
||
// ── 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
|
||
// captured graph. The base_lambda amplitude knob enters the
|
||
// controller kernel as a scalar arg at launch time.
|
||
const LAMBDA_FLOOR_INIT: f32 = 1.0e-4;
|
||
let smoothness_lambda_host: Vec<f32> = vec![LAMBDA_FLOOR_INIT; N_HORIZONS];
|
||
let smoothness_lambda_d = {
|
||
// Upload via mapped-pinned (per `feedback_no_htod_htoh_only_mapped_pinned`).
|
||
let staging = unsafe { MappedF32Buffer::new(N_HORIZONS) }
|
||
.map_err(|e| anyhow::anyhow!("smoothness λ staging: {e}"))?;
|
||
staging.write_from_slice(&smoothness_lambda_host);
|
||
let mut dst = stream.alloc_zeros::<f32>(N_HORIZONS)
|
||
.context("smoothness_lambda_d alloc")?;
|
||
let nbytes = N_HORIZONS * std::mem::size_of::<f32>();
|
||
unsafe {
|
||
let (dst_ptr, _g) = dst.device_ptr_mut(&stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
dst_ptr, staging.dev_ptr, nbytes, stream.cu_stream(),
|
||
).context("smoothness λ DtoD")?;
|
||
}
|
||
dst
|
||
};
|
||
let smoothness_loss_d = stream.alloc_zeros::<f32>(1)
|
||
.context("smoothness_loss_d alloc")?;
|
||
let smoothness_loss_host_d = unsafe { MappedF32Buffer::new(1) }
|
||
.map_err(|e| anyhow::anyhow!("smoothness_loss_host_d: {e}"))?;
|
||
let smoothness_loss_per_horizon_d = stream.alloc_zeros::<f32>(N_HORIZONS)
|
||
.context("smoothness_loss_per_horizon_d alloc")?;
|
||
let smoothness_loss_per_horizon_host_d = unsafe { MappedF32Buffer::new(N_HORIZONS) }
|
||
.map_err(|e| anyhow::anyhow!("smoothness_loss_per_horizon_host_d: {e}"))?;
|
||
// Controller state buffers.
|
||
let smoothness_jitter_ema_d = stream.alloc_zeros::<f32>(N_HORIZONS)
|
||
.context("smoothness_jitter_ema_d alloc")?;
|
||
let smoothness_jitter_first_obs_d = stream.alloc_zeros::<i32>(1)
|
||
.context("smoothness_jitter_first_obs_d alloc")?;
|
||
|
||
// ── GPU log ring allocation (feature: kernel-step-trace) ──
|
||
// Per `feedback_no_htod_htoh_only_mapped_pinned`: ring is mapped-
|
||
// pinned; step counter shadow is mapped-pinned; the on-stream
|
||
// DtoD memcpy of the device step counter into the host shadow
|
||
// is captured in the graph, so the drainer sees the latest
|
||
// counter without an extra sync.
|
||
//
|
||
// Runtime activation: only allocate + spawn when
|
||
// `cfg.kernel_step_trace_path` is Some(path). Otherwise every
|
||
// field below stays None and the trainer carries zero overhead.
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
let (
|
||
log_ring,
|
||
log_step_counter_d,
|
||
log_step_counter_host_shadow,
|
||
gpu_log_tick_fn,
|
||
gpu_log_module,
|
||
log_drain_stop,
|
||
log_drain_handle,
|
||
) = if let Some(trace_path) = cfg.kernel_step_trace_path.clone() {
|
||
let log_ring = std::sync::Arc::new(crate::gpu_log::LogRing::alloc()?);
|
||
let log_step_counter_d = crate::gpu_log::alloc_step_counter(&stream)?;
|
||
let log_step_counter_host_shadow = {
|
||
let buf = unsafe { crate::pinned_mem::MappedRecordBuffer::<i32>::new(1) }
|
||
.map_err(|e| anyhow::anyhow!("log step counter shadow: {e}"))?;
|
||
buf.write_record(0, 0);
|
||
std::sync::Arc::new(buf)
|
||
};
|
||
let (gpu_log_tick_fn, gpu_log_module) = {
|
||
const GPU_LOG_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/gpu_log_ring.cubin"));
|
||
let module = ctx.load_cubin(GPU_LOG_CUBIN.to_vec())
|
||
.context("load gpu_log_ring cubin")?;
|
||
let f = module.load_function("gpu_log_tick").context("load gpu_log_tick")?;
|
||
(f, module)
|
||
};
|
||
let log_drain_stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||
// The drain runs on a plain `std::thread`, so spawning is
|
||
// unconditional — it works the same whether the trainer is
|
||
// invoked from `#[tokio::main]` or a synchronous `fn main()`
|
||
// (e.g. `alpha_train`). No runtime-handle gate required.
|
||
let log_drain_handle = Some(crate::gpu_log::spawn_drain_task_to_file(
|
||
log_ring.clone(),
|
||
log_step_counter_host_shadow.clone(),
|
||
log_drain_stop.clone(),
|
||
trace_path.clone(),
|
||
)?);
|
||
(
|
||
Some(log_ring),
|
||
Some(log_step_counter_d),
|
||
Some(log_step_counter_host_shadow),
|
||
Some(gpu_log_tick_fn),
|
||
Some(gpu_log_module),
|
||
Some(log_drain_stop),
|
||
log_drain_handle,
|
||
)
|
||
} else {
|
||
(None, None, None, None, None, None, None)
|
||
};
|
||
|
||
let k = cfg.seq_len;
|
||
Ok(Self {
|
||
cfg: cfg.clone(),
|
||
h_new_per_k_d: stream.alloc_zeros::<f32>(k * cfg.n_batch * n_hid)?,
|
||
probs_per_k_d: stream.alloc_zeros::<f32>(k * cfg.n_batch * N_HORIZONS)?,
|
||
labels_per_k_d: stream.alloc_zeros::<f32>(k * cfg.n_batch * N_HORIZONS)?,
|
||
grad_probs_per_k_d: stream.alloc_zeros::<f32>(k * cfg.n_batch * N_HORIZONS)?,
|
||
loss_weights_d: upload(&stream, &cfg.horizon_weights)?,
|
||
loss_d: stream.alloc_zeros::<f32>(1)?,
|
||
loss_host_d: unsafe { MappedF32Buffer::new(1) }.map_err(|e| anyhow::anyhow!("loss_host_d: {e}"))?,
|
||
loss_per_horizon_d: stream.alloc_zeros::<f32>(N_HORIZONS)?,
|
||
smoothness_lambda_d,
|
||
smoothness_loss_d,
|
||
smoothness_loss_host_d,
|
||
smoothness_loss_per_horizon_d,
|
||
smoothness_loss_per_horizon_host_d,
|
||
smoothness_fn,
|
||
_smoothness_module: smoothness_module,
|
||
smoothness_jitter_ema_d,
|
||
smoothness_jitter_first_obs_d,
|
||
smoothness_controller_fn,
|
||
_smoothness_controller_module: smoothness_controller_module,
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
log_ring,
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
log_step_counter_d,
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
log_step_counter_host_shadow,
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
gpu_log_tick_fn,
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
_gpu_log_module: gpu_log_module,
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
log_drain_stop,
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
log_drain_handle, // already Option<_>; field type unchanged
|
||
// LayerNorm state. `ln_gain_d` / `ln_bias_d` constructed
|
||
// above via `upload` (gain=1, bias=0). LN_HIDDEN matches
|
||
// HIDDEN_DIM (128) by construction — checked at kernel
|
||
// launch time. Per-row scratch is [B*K, HIDDEN]; the
|
||
// single param-grad reducer collapses it to [HIDDEN].
|
||
ln_stats_d: stream.alloc_zeros::<f32>(cfg.n_batch * k * 2)?,
|
||
ln_out_d: stream.alloc_zeros::<f32>(cfg.n_batch * k * HIDDEN_DIM)?,
|
||
grad_ln_gain_per_row_d: stream.alloc_zeros::<f32>(cfg.n_batch * k * HIDDEN_DIM)?,
|
||
grad_ln_bias_per_row_d: stream.alloc_zeros::<f32>(cfg.n_batch * k * HIDDEN_DIM)?,
|
||
grad_ln_gain_d: stream.alloc_zeros::<f32>(HIDDEN_DIM)?,
|
||
grad_ln_bias_d: stream.alloc_zeros::<f32>(HIDDEN_DIM)?,
|
||
grad_ln_in_d: GpuTensor::zeros(&[cfg.n_batch, k, HIDDEN_DIM], &stream)
|
||
.map_err(|e| anyhow::anyhow!("grad_ln_in_d alloc: {e}"))?,
|
||
ln_bwd_fn,
|
||
ln_reduce_fn,
|
||
_ln_module: ln_module,
|
||
opt_ln_gain,
|
||
opt_ln_bias,
|
||
// VSN (Phase 2D) — params + per-K gates + scratch.
|
||
trunk,
|
||
vsn_out_d: GpuTensor::zeros(&[cfg.n_batch, k, FEATURE_DIM], &stream)
|
||
.map_err(|e| anyhow::anyhow!("vsn_out_d alloc: {e}"))?,
|
||
vsn_gates_d: stream.alloc_zeros::<f32>(cfg.n_batch * k * FEATURE_DIM)?,
|
||
vsn_grad_x_d: stream.alloc_zeros::<f32>(cfg.n_batch * k * FEATURE_DIM)?,
|
||
grad_vsn_w_d: stream.alloc_zeros::<f32>(FEATURE_DIM * FEATURE_DIM)?,
|
||
grad_vsn_b_d: stream.alloc_zeros::<f32>(FEATURE_DIM)?,
|
||
opt_vsn_w,
|
||
opt_vsn_b,
|
||
vsn_bwd_fn,
|
||
_vsn_module: vsn_module,
|
||
// Attention pool (Phase 3).
|
||
attn_context_d,
|
||
attn_weights_d,
|
||
grad_attn_q_d,
|
||
opt_attn_q,
|
||
attn_bwd_fn,
|
||
_attn_module: attn_module,
|
||
log_sigma_h_d,
|
||
grad_log_sigma_h_d,
|
||
z_max_ema_d,
|
||
// Phase B: cfc per-batch grad scratch + reducer.
|
||
cfc_grad_w_in_scratch_d,
|
||
cfc_grad_w_rec_scratch_d,
|
||
cfc_grad_b_scratch_d,
|
||
cfc_grad_tau_scratch_d,
|
||
grn_grad_w1_scratch_d,
|
||
grn_grad_b1_scratch_d,
|
||
grn_grad_w2_scratch_d,
|
||
grn_grad_b2_scratch_d,
|
||
grn_grad_w_gate_scratch_d,
|
||
grn_grad_b_gate_scratch_d,
|
||
grn_grad_w_main_scratch_d,
|
||
grn_grad_b_main_scratch_d,
|
||
grn_grad_w_skip_scratch_d,
|
||
grn_grad_b_skip_scratch_d,
|
||
vsn_grad_w_scratch_d,
|
||
vsn_grad_b_scratch_d,
|
||
attn_grad_q_scratch_d,
|
||
reduce_axis0_fn,
|
||
_reduce_module: reduce_module,
|
||
loss_ema_d: stream.alloc_zeros::<f32>(N_HORIZONS)?,
|
||
lambda_d: stream.alloc_zeros::<f32>(N_HORIZONS)?,
|
||
horizon_lambda_fn,
|
||
_horizon_lambda_module: horizon_lambda_module,
|
||
valid_d: stream.alloc_zeros::<i32>(1)?,
|
||
grad_h_carry_d: stream.alloc_zeros::<f32>(cfg.n_batch * n_hid)?,
|
||
grad_h_new_d: stream.alloc_zeros::<f32>(cfg.n_batch * n_hid)?,
|
||
zero_h_d: stream.alloc_zeros::<f32>(cfg.n_batch * n_hid)?,
|
||
grad_w_in_d: stream.alloc_zeros::<f32>(n_hid * n_in)?,
|
||
grad_w_rec_d: stream.alloc_zeros::<f32>(n_hid * n_hid)?,
|
||
grad_b_d: stream.alloc_zeros::<f32>(n_hid)?,
|
||
grad_tau_d: stream.alloc_zeros::<f32>(n_hid)?,
|
||
grad_heads_w1_d: stream.alloc_zeros::<f32>(N_HORIZONS * HEAD_MID_DIM * n_hid)?,
|
||
grad_heads_b1_d: stream.alloc_zeros::<f32>(N_HORIZONS * HEAD_MID_DIM)?,
|
||
grad_heads_w2_d: stream.alloc_zeros::<f32>(N_HORIZONS * HEAD_MID_DIM * HEAD_MID_DIM)?,
|
||
grad_heads_b2_d: stream.alloc_zeros::<f32>(N_HORIZONS * HEAD_MID_DIM)?,
|
||
grad_heads_w_gate_d: stream.alloc_zeros::<f32>(N_HORIZONS * HEAD_MID_DIM)?,
|
||
grad_heads_b_gate_d: stream.alloc_zeros::<f32>(N_HORIZONS)?,
|
||
grad_heads_w_main_d: stream.alloc_zeros::<f32>(N_HORIZONS * HEAD_MID_DIM)?,
|
||
grad_heads_b_main_d: stream.alloc_zeros::<f32>(N_HORIZONS)?,
|
||
grad_heads_w_skip_d: stream.alloc_zeros::<f32>(N_HORIZONS * n_hid)?,
|
||
grad_heads_b_skip_d: stream.alloc_zeros::<f32>(N_HORIZONS)?,
|
||
// GRN per-K intermediate buffers (sized for [K, B, 5, HEAD_MID]
|
||
// and [K, B, 5]) — the K-loop reads/writes per-K slot offsets.
|
||
z1_per_k_d: stream.alloc_zeros::<f32>(k * cfg.n_batch * N_HORIZONS * HEAD_MID_DIM)?,
|
||
a1_per_k_d: stream.alloc_zeros::<f32>(k * cfg.n_batch * N_HORIZONS * HEAD_MID_DIM)?,
|
||
z2_per_k_d: stream.alloc_zeros::<f32>(k * cfg.n_batch * N_HORIZONS * HEAD_MID_DIM)?,
|
||
gate_logit_per_k_d: stream.alloc_zeros::<f32>(k * cfg.n_batch * N_HORIZONS)?,
|
||
main_per_k_d: stream.alloc_zeros::<f32>(k * cfg.n_batch * N_HORIZONS)?,
|
||
logit_per_k_d: stream.alloc_zeros::<f32>(k * cfg.n_batch * N_HORIZONS)?,
|
||
stg_labels: unsafe { MappedF32Buffer::new(k * cfg.n_batch * N_HORIZONS) }.map_err(|e| anyhow::anyhow!("stg_labels: {e}"))?,
|
||
train_graph: None,
|
||
forward_graph: None,
|
||
forward_warmed: false,
|
||
cublas_warmed: false,
|
||
|
||
// Batched snap_feature staging — sized for max B*K snapshots.
|
||
bk_capacity: cfg.n_batch * k,
|
||
stg_bid_px_all: unsafe { MappedF32Buffer::new(cfg.n_batch * k * 10) }
|
||
.map_err(|e| anyhow::anyhow!("stg_bid_px_all: {e}"))?,
|
||
stg_bid_sz_all: unsafe { MappedF32Buffer::new(cfg.n_batch * k * 10) }
|
||
.map_err(|e| anyhow::anyhow!("stg_bid_sz_all: {e}"))?,
|
||
stg_ask_px_all: unsafe { MappedF32Buffer::new(cfg.n_batch * k * 10) }
|
||
.map_err(|e| anyhow::anyhow!("stg_ask_px_all: {e}"))?,
|
||
stg_ask_sz_all: unsafe { MappedF32Buffer::new(cfg.n_batch * k * 10) }
|
||
.map_err(|e| anyhow::anyhow!("stg_ask_sz_all: {e}"))?,
|
||
stg_regime_all: unsafe { MappedF32Buffer::new(cfg.n_batch * k * REGIME_DIM) }
|
||
.map_err(|e| anyhow::anyhow!("stg_regime_all: {e}"))?,
|
||
stg_prev_mid: unsafe { MappedF32Buffer::new(cfg.n_batch * k) }
|
||
.map_err(|e| anyhow::anyhow!("stg_prev_mid: {e}"))?,
|
||
stg_trade_signed_vol: unsafe { MappedF32Buffer::new(cfg.n_batch * k) }
|
||
.map_err(|e| anyhow::anyhow!("stg_trade_signed_vol: {e}"))?,
|
||
stg_trade_count: unsafe { MappedI32Buffer::new(cfg.n_batch * k) }
|
||
.map_err(|e| anyhow::anyhow!("stg_trade_count: {e}"))?,
|
||
stg_ts_ns: unsafe { MappedI64Buffer::new(cfg.n_batch * k) }
|
||
.map_err(|e| anyhow::anyhow!("stg_ts_ns: {e}"))?,
|
||
stg_prev_ts_ns: unsafe { MappedI64Buffer::new(cfg.n_batch * k) }
|
||
.map_err(|e| anyhow::anyhow!("stg_prev_ts_ns: {e}"))?,
|
||
bid_px_all_d: stream.alloc_zeros::<f32>(cfg.n_batch * k * 10)?,
|
||
bid_sz_all_d: stream.alloc_zeros::<f32>(cfg.n_batch * k * 10)?,
|
||
ask_px_all_d: stream.alloc_zeros::<f32>(cfg.n_batch * k * 10)?,
|
||
ask_sz_all_d: stream.alloc_zeros::<f32>(cfg.n_batch * k * 10)?,
|
||
prev_bid_sz_all_d: stream.alloc_zeros::<f32>(cfg.n_batch * k * 10)?,
|
||
prev_ask_sz_all_d: stream.alloc_zeros::<f32>(cfg.n_batch * k * 10)?,
|
||
regime_all_d: stream.alloc_zeros::<f32>(cfg.n_batch * k * REGIME_DIM)?,
|
||
prev_mid_all_d: stream.alloc_zeros::<f32>(cfg.n_batch * k)?,
|
||
trade_signed_vol_all_d: stream.alloc_zeros::<f32>(cfg.n_batch * k)?,
|
||
trade_count_all_d: stream.alloc_zeros::<i32>(cfg.n_batch * k)?,
|
||
ts_ns_all_d: stream.alloc_zeros::<i64>(cfg.n_batch * k)?,
|
||
prev_ts_ns_all_d: stream.alloc_zeros::<i64>(cfg.n_batch * k)?,
|
||
|
||
stream,
|
||
_snap_module: snap_module,
|
||
_step_module: step_module,
|
||
_heads_module: heads_module,
|
||
_bce_module: bce_module,
|
||
bce_fn,
|
||
step_bwd_batched_fn,
|
||
heads_grn_bwd_fn,
|
||
mamba2_adamw,
|
||
mamba2_fwd_scratch,
|
||
mamba2_bwd_scratch,
|
||
mamba2_grads_buffers,
|
||
mamba2_l2_adamw,
|
||
mamba2_l2_fwd_scratch,
|
||
mamba2_l2_bwd_scratch,
|
||
mamba2_l2_grads_buffers,
|
||
// LN_a between Mamba2 stacks.
|
||
ln_a_stats_d,
|
||
ln_a_out_d,
|
||
grad_ln_a_gain_per_row_d,
|
||
grad_ln_a_bias_per_row_d,
|
||
grad_ln_a_gain_d,
|
||
grad_ln_a_bias_d,
|
||
grad_ln_a_in_d,
|
||
opt_ln_a_gain,
|
||
opt_ln_a_bias,
|
||
window_tensor_d,
|
||
h_enriched_seq_t_d,
|
||
grad_h_enriched_seq_t_d,
|
||
grad_h_enriched_seq_d,
|
||
opt_w_in,
|
||
opt_w_rec,
|
||
opt_b,
|
||
opt_tau,
|
||
opt_heads_w1,
|
||
opt_heads_b1,
|
||
opt_heads_w2,
|
||
opt_heads_b2,
|
||
opt_heads_w_gate,
|
||
opt_heads_b_gate,
|
||
opt_heads_w_main,
|
||
opt_heads_b_main,
|
||
opt_heads_w_skip,
|
||
opt_heads_b_skip,
|
||
// CRT Phase A0.5: forward_step state.
|
||
step_scratch_l1,
|
||
step_scratch_l2,
|
||
cfc_h_state_step_d,
|
||
cfc_h_new_step_d,
|
||
vsn_step_out_d,
|
||
vsn_step_gates_d,
|
||
ln_a_step_out_d,
|
||
ln_a_step_stats_d,
|
||
ln_b_step_out_d,
|
||
ln_b_step_stats_d,
|
||
z1_step_d,
|
||
a1_step_d,
|
||
z2_step_d,
|
||
gate_logit_step_d,
|
||
main_step_d,
|
||
logit_step_d,
|
||
stg_step_bid_px,
|
||
stg_step_bid_sz,
|
||
stg_step_ask_px,
|
||
stg_step_ask_sz,
|
||
stg_step_regime,
|
||
stg_step_prev_mid,
|
||
stg_step_trade_signed_vol,
|
||
stg_step_trade_count,
|
||
stg_step_ts_ns,
|
||
stg_step_prev_ts_ns,
|
||
step_bid_px_d,
|
||
step_bid_sz_d,
|
||
step_ask_px_d,
|
||
step_ask_sz_d,
|
||
step_prev_bid_sz_d,
|
||
step_prev_ask_sz_d,
|
||
step_regime_d,
|
||
step_prev_mid_d,
|
||
step_trade_signed_vol_d,
|
||
step_trade_count_d,
|
||
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,
|
||
tau_clamp_fn,
|
||
heads_lr_multiplier_scale_fn,
|
||
h_mag_per_bucket_fn,
|
||
bucket_iqr_double_widening_fn,
|
||
_bucket_transition_module: bucket_transition_module,
|
||
heads_w_skip_compact_d,
|
||
bucket_routing_metadata: None,
|
||
// Controller C state (Task 11).
|
||
heads_w_skip_pre_adam_d,
|
||
heads_w_skip_mask_d,
|
||
heads_w_skip_mask_init_fn,
|
||
heads_w_skip_grad_mask_apply_fn,
|
||
lr_mult_per_horizon_staging,
|
||
smoothness_jitter_ema_host_d,
|
||
target_jitter_per_h: [0.0; N_HORIZONS],
|
||
label_var_ema: [0.0; N_HORIZONS],
|
||
label_var_ema_bootstrapped: false,
|
||
// Controller D state (Task 12).
|
||
controller_d: ControllerDState::new(),
|
||
phase2_step_count: 0,
|
||
h_mag_per_bucket_d,
|
||
h_mag_per_bucket_staged,
|
||
})
|
||
}
|
||
|
||
/// Set learning rate for all CfC + heads + tau AdamW optimizers.
|
||
/// Mamba2's AdamW carries its own lr config — use [`set_lr_mamba2`].
|
||
pub fn set_lr_cfc(&mut self, lr: f32) {
|
||
self.opt_w_in.lr = lr;
|
||
self.opt_w_rec.lr = lr;
|
||
self.opt_b.lr = lr;
|
||
self.opt_tau.lr = lr * 0.1;
|
||
self.opt_heads_w1.lr = lr;
|
||
self.opt_heads_b1.lr = lr;
|
||
self.opt_heads_w2.lr = lr;
|
||
self.opt_heads_b2.lr = lr;
|
||
self.opt_heads_w_gate.lr = lr;
|
||
self.opt_heads_b_gate.lr = lr;
|
||
self.opt_heads_w_main.lr = lr;
|
||
self.opt_heads_b_main.lr = lr;
|
||
self.opt_heads_w_skip.lr = lr;
|
||
self.opt_heads_b_skip.lr = lr;
|
||
}
|
||
|
||
/// Set Mamba2 AdamW learning rate. Applies to BOTH stack-1 and
|
||
/// stack-2 (Phase 2B) — they share the same lr_mamba2 config.
|
||
pub fn set_lr_mamba2(&mut self, lr: f32) {
|
||
self.mamba2_adamw.config.lr = lr;
|
||
self.mamba2_l2_adamw.config.lr = lr;
|
||
}
|
||
|
||
/// One training step on a single sequence — thin wrapper around
|
||
/// [`step_batched`] with batch size 1. Preserves the existing
|
||
/// API for single-sequence callers (test smokes, etc.).
|
||
pub fn step(
|
||
&mut self,
|
||
snapshots: &[Mbp10RawInput],
|
||
labels_per_position: &[[f32; N_HORIZONS]],
|
||
) -> Result<f32> {
|
||
anyhow::ensure!(
|
||
self.cfg.n_batch == 1,
|
||
"single-sequence step() requires cfg.n_batch == 1; got {}. \
|
||
Use step_batched() for batched training.",
|
||
self.cfg.n_batch
|
||
);
|
||
self.step_batched(&[snapshots], &[labels_per_position])
|
||
}
|
||
|
||
/// Batched training step. Processes `cfg.n_batch` sequences per
|
||
/// optimizer update — uses the batched kernel variants (cfc_step_batched,
|
||
/// multi_horizon_heads_batched, etc.). CfC is RECURRENT across
|
||
/// positions; h_old at step k for sample b is h_new at step k-1 for
|
||
/// the same b (zero at k=0).
|
||
///
|
||
/// `snapshots_batch[b]` is sample b's seq_len-snapshot window.
|
||
/// `labels_batch[b]` is sample b's per-position label rows. NaN
|
||
/// labels are natively masked by the BCE kernel.
|
||
///
|
||
/// Returns mean BCE over the valid (b, position, horizon) triples
|
||
/// in the batch, weighted by `cfg.horizon_weights`.
|
||
/// Test/diagnostic accessor for the per-horizon BCE EMA buffer.
|
||
/// Forces a stream sync + mapped-pinned readback — NOT for the
|
||
/// hot training path. Used by smoke tests to verify the
|
||
/// `horizon_ema_and_lambda` kernel is tracking correctly.
|
||
pub fn loss_ema_snapshot(&self) -> Result<[f32; N_HORIZONS]> {
|
||
let staging = unsafe { MappedF32Buffer::new(N_HORIZONS) }
|
||
.map_err(|e| anyhow::anyhow!("loss_ema staging: {e}"))?;
|
||
unsafe {
|
||
let (src_ptr, _g) = self.loss_ema_d.device_ptr(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
staging.dev_ptr, src_ptr,
|
||
N_HORIZONS * std::mem::size_of::<f32>(),
|
||
self.stream.cu_stream(),
|
||
).context("loss_ema dtod")?;
|
||
}
|
||
self.stream.synchronize().context("loss_ema sync")?;
|
||
let host = staging.read_all();
|
||
let mut out = [0.0_f32; N_HORIZONS];
|
||
out.copy_from_slice(&host[..N_HORIZONS]);
|
||
Ok(out)
|
||
}
|
||
|
||
/// Test/diagnostic accessor for the per-horizon lambda buffer.
|
||
/// Same constraints as [`Self::loss_ema_snapshot`].
|
||
pub fn lambda_snapshot(&self) -> Result<[f32; N_HORIZONS]> {
|
||
let staging = unsafe { MappedF32Buffer::new(N_HORIZONS) }
|
||
.map_err(|e| anyhow::anyhow!("lambda staging: {e}"))?;
|
||
unsafe {
|
||
let (src_ptr, _g) = self.lambda_d.device_ptr(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
staging.dev_ptr, src_ptr,
|
||
N_HORIZONS * std::mem::size_of::<f32>(),
|
||
self.stream.cu_stream(),
|
||
).context("lambda dtod")?;
|
||
}
|
||
self.stream.synchronize().context("lambda sync")?;
|
||
let host = staging.read_all();
|
||
let mut out = [0.0_f32; N_HORIZONS];
|
||
out.copy_from_slice(&host[..N_HORIZONS]);
|
||
Ok(out)
|
||
}
|
||
|
||
pub fn step_batched(
|
||
&mut self,
|
||
snapshots_batch: &[&[Mbp10RawInput]],
|
||
labels_batch: &[&[[f32; N_HORIZONS]]],
|
||
) -> Result<f32> {
|
||
let b_sz = self.cfg.n_batch;
|
||
let k_seq = self.cfg.seq_len;
|
||
anyhow::ensure!(
|
||
snapshots_batch.len() == b_sz && labels_batch.len() == b_sz,
|
||
"expected B={} sequences/labels; got snap={} lbl={}",
|
||
b_sz, snapshots_batch.len(), labels_batch.len()
|
||
);
|
||
for (b, snap) in snapshots_batch.iter().enumerate() {
|
||
anyhow::ensure!(
|
||
snap.len() == k_seq,
|
||
"batch[{}] snapshots.len()={} != seq_len={}",
|
||
b, snap.len(), k_seq
|
||
);
|
||
}
|
||
for (b, lbl) in labels_batch.iter().enumerate() {
|
||
anyhow::ensure!(
|
||
lbl.len() == k_seq,
|
||
"batch[{}] labels.len()={} != seq_len={}",
|
||
b, lbl.len(), k_seq
|
||
);
|
||
}
|
||
|
||
debug_assert_eq!(self.window_tensor_d.shape(), &[b_sz, k_seq, FEATURE_DIM]);
|
||
let total_snaps = b_sz * k_seq;
|
||
debug_assert!(total_snaps <= self.bk_capacity);
|
||
|
||
// ── 1. Fill ALL mapped-pinned staging on host BEFORE stream
|
||
// ops. Replays read whatever host last wrote.
|
||
{
|
||
let bid_px_h = self.stg_bid_px_all.host_slice_mut();
|
||
let bid_sz_h = self.stg_bid_sz_all.host_slice_mut();
|
||
let ask_px_h = self.stg_ask_px_all.host_slice_mut();
|
||
let ask_sz_h = self.stg_ask_sz_all.host_slice_mut();
|
||
let regime_h = self.stg_regime_all.host_slice_mut();
|
||
for (b_idx, snapshots) in snapshots_batch.iter().enumerate() {
|
||
for (k, snap) in snapshots.iter().enumerate() {
|
||
let n = b_idx * k_seq + k;
|
||
let base10 = n * 10;
|
||
let base6 = n * REGIME_DIM;
|
||
for i in 0..10 {
|
||
bid_px_h[base10 + i] = snap.bid_px[i];
|
||
bid_sz_h[base10 + i] = snap.bid_sz[i];
|
||
ask_px_h[base10 + i] = snap.ask_px[i];
|
||
ask_sz_h[base10 + i] = snap.ask_sz[i];
|
||
}
|
||
for i in 0..REGIME_DIM {
|
||
regime_h[base6 + i] = snap.regime[i];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
{
|
||
let prev_mid_h = self.stg_prev_mid.host_slice_mut();
|
||
let tsv_h = self.stg_trade_signed_vol.host_slice_mut();
|
||
let tc_h = self.stg_trade_count.host_slice_mut();
|
||
let ts_ns_h = self.stg_ts_ns.host_slice_mut();
|
||
let prev_ts_ns_h = self.stg_prev_ts_ns.host_slice_mut();
|
||
for (b_idx, snapshots) in snapshots_batch.iter().enumerate() {
|
||
for (k, snap) in snapshots.iter().enumerate() {
|
||
let n = b_idx * k_seq + k;
|
||
prev_mid_h[n] = snap.prev_mid;
|
||
tsv_h[n] = snap.trade_signed_vol;
|
||
tc_h[n] = snap.trade_count as i32;
|
||
ts_ns_h[n] = snap.ts_ns as i64;
|
||
prev_ts_ns_h[n] = snap.prev_ts_ns as i64;
|
||
}
|
||
}
|
||
}
|
||
// Labels staging fill (host-only). Moved from inside dispatch
|
||
// to here so it happens BEFORE the captured region.
|
||
{
|
||
let dst = self.stg_labels.host_slice_mut();
|
||
for k in 0..k_seq {
|
||
for b_idx in 0..b_sz {
|
||
for h in 0..N_HORIZONS {
|
||
dst[(k * b_sz + b_idx) * N_HORIZONS + h] =
|
||
labels_batch[b_idx][k][h];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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. Sync trunk's bucket metadata fields from the trainer-owned
|
||
// BucketRoutingMetadata. Without this, training-time Phase 2
|
||
// dispatch sites that read `self.trunk.bucket_*_d`
|
||
// (cfc_step_per_branch_fwd at the per-branch dispatch and
|
||
// h_mag_per_bucket_kernel for Controller D) would see the
|
||
// zero-initialized trunk buffers — the per-branch kernel's
|
||
// uniform predicate `tid >= bucket_dim_k[branch]` would
|
||
// early-return ALL threads → silent Phase 2 no-op.
|
||
//
|
||
// Trunk fields are the single source of truth for BOTH
|
||
// training-time dispatch AND checkpoint serialization. Cost:
|
||
// 4 DtoD memcpys, ~256 bytes total at the one-shot
|
||
// transition (off the hot path). Sync MUST happen BEFORE
|
||
// the `Some(metadata)` move below (borrow of `metadata.*_d`
|
||
// requires `metadata` to still be live), and BEFORE the
|
||
// heads_w_skip_mask_init kernel at step 5.5 (which still
|
||
// reads `metadata.bucket_id_per_channel_d` directly through
|
||
// `self.bucket_routing_metadata.as_ref()` after the move).
|
||
// Per `pearl_canary_input_freshness_launch_order`: producer
|
||
// (sync) precedes every consumer.
|
||
unsafe {
|
||
let s = self.stream.cu_stream();
|
||
// bucket_channel_offset_d: [N_HORIZONS + 1] × u32
|
||
let (src, _gs) = metadata.bucket_channel_offset_d.device_ptr(&self.stream);
|
||
let (dst, _gd) = self.trunk.bucket_channel_offset_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
dst,
|
||
src,
|
||
(N_HORIZONS + 1) * std::mem::size_of::<u32>(),
|
||
s,
|
||
)
|
||
.context("sync bucket_channel_offset to trunk")?;
|
||
// bucket_dim_k_d: [N_HORIZONS] × u32
|
||
let (src, _gs) = metadata.bucket_dim_k_d.device_ptr(&self.stream);
|
||
let (dst, _gd) = self.trunk.bucket_dim_k_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
dst,
|
||
src,
|
||
N_HORIZONS * std::mem::size_of::<u32>(),
|
||
s,
|
||
)
|
||
.context("sync bucket_dim_k to trunk")?;
|
||
// bucket_id_per_channel_d: [HIDDEN_DIM] × u8
|
||
let (src, _gs) = metadata.bucket_id_per_channel_d.device_ptr(&self.stream);
|
||
let (dst, _gd) = self.trunk.bucket_id_per_channel_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
dst,
|
||
src,
|
||
HIDDEN_DIM * std::mem::size_of::<u8>(),
|
||
s,
|
||
)
|
||
.context("sync bucket_id_per_channel to trunk")?;
|
||
// heads_w_skip_offset_d: [N_HORIZONS + 1] × u32
|
||
let (src, _gs) = metadata.heads_w_skip_offset_d.device_ptr(&self.stream);
|
||
let (dst, _gd) = self.trunk.heads_w_skip_offset_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
dst,
|
||
src,
|
||
(N_HORIZONS + 1) * std::mem::size_of::<u32>(),
|
||
s,
|
||
)
|
||
.context("sync heads_w_skip_offset to trunk")?;
|
||
}
|
||
|
||
// 6. Persist routing metadata in trainer state. Tasks
|
||
// 10–12 read its slices to drive Phase 2 dispatch +
|
||
// Controllers B / C / D.
|
||
self.bucket_routing_metadata = Some(metadata);
|
||
|
||
// 7. Block-diagonal heads grad-mask init (follow-up to
|
||
// Task 10 Option B). One-shot: build the routing
|
||
// mask AND zero-multiply off-bucket entries of
|
||
// `heads_w_skip_d` in place. From here on, the per-
|
||
// step grad-mask apply (below `opt_heads_w_skip.step`)
|
||
// keeps off-bucket positions at 0 throughout training,
|
||
// mathematically equivalent to a compact-only read of
|
||
// `heads_w_skip` at zero implementation cost for the
|
||
// GRN kernel. Spec §3.3 + `feedback_isv_for_adaptive_bounds`:
|
||
// the mask is derived from the same
|
||
// `bucket_id_per_channel_d` that Controllers B/C use.
|
||
{
|
||
let metadata_ref = self
|
||
.bucket_routing_metadata
|
||
.as_ref()
|
||
.expect("metadata just persisted above");
|
||
let cfg_mask_init = LaunchConfig {
|
||
grid_dim: (N_HORIZONS as u32, 1, 1),
|
||
block_dim: (HIDDEN_DIM as u32, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self
|
||
.stream
|
||
.launch_builder(&self.heads_w_skip_mask_init_fn);
|
||
launch
|
||
.arg(&metadata_ref.bucket_id_per_channel_d)
|
||
.arg(&mut self.trunk.heads_w_skip_d)
|
||
.arg(&mut self.heads_w_skip_mask_d);
|
||
unsafe {
|
||
launch
|
||
.launch(cfg_mask_init)
|
||
.context("heads_w_skip_mask_init_kernel launch (Phase 1→2 block-diagonal heads)")?;
|
||
}
|
||
}
|
||
|
||
// 8. 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
|
||
// `pearl_no_host_branches_in_captured_graph`.
|
||
if self.train_graph.is_some() {
|
||
self.train_graph
|
||
.as_ref()
|
||
.unwrap()
|
||
.launch()
|
||
.context("train graph launch")?;
|
||
} else if !self.cublas_warmed {
|
||
self.dispatch_train_step(b_sz, k_seq, total_snaps)
|
||
.context("train warmup dispatch")?;
|
||
self.cublas_warmed = true;
|
||
} else {
|
||
// Event tracking was disabled at trainer construction; the
|
||
// trainer's CudaSlices have no read/write events, so neither
|
||
// launch_builder nor device_ptr_mut() will try to insert
|
||
// cuStreamWaitEvent / event.record() calls inside the
|
||
// captured region. Capture safely.
|
||
let begin = self.stream.begin_capture(
|
||
CUstreamCaptureMode::CU_STREAM_CAPTURE_MODE_RELAXED,
|
||
);
|
||
if let Err(e) = begin {
|
||
return Err(anyhow::anyhow!("train begin_capture: {e}"));
|
||
}
|
||
|
||
let dispatch_result = self.dispatch_train_step(b_sz, k_seq, total_snaps);
|
||
|
||
let graph_result = self.stream.end_capture(
|
||
CUgraphInstantiate_flags::CUDA_GRAPH_INSTANTIATE_FLAG_AUTO_FREE_ON_LAUNCH,
|
||
);
|
||
|
||
dispatch_result.context("train graph dispatch (during capture)")?;
|
||
let graph = graph_result
|
||
.context("train graph end_capture")?
|
||
.ok_or_else(|| anyhow::anyhow!(
|
||
"train end_capture returned None — no work captured"
|
||
))?;
|
||
self.train_graph = Some(graph);
|
||
}
|
||
|
||
// ── 3. Sync + read mapped-pinned loss.
|
||
self.stream.synchronize().context("end-of-step sync")?;
|
||
let loss = unsafe { std::ptr::read_volatile(self.loss_host_d.host_ptr) };
|
||
|
||
// ── 4. Controller C host-side LR multiplier update (spec §3.3) ──
|
||
//
|
||
// Now that the end-of-step sync has propagated the captured DtoD
|
||
// shadows, `smoothness_jitter_ema_host_d` carries this step's
|
||
// per-horizon jitter EMA. Per spec §3.3 the host:
|
||
// 1. Computes per-horizon `label_var_k = E[label²] - E[label]²`
|
||
// over this batch's `[K × B]` labels for horizon k (host-side
|
||
// reduction on `stg_labels`; layout is `[K, B, N_HORIZONS]`
|
||
// row-major — see the staging fill earlier in this function).
|
||
// 2. Updates `label_var_ema[k]` via Wiener-α EMA (α = 0.4 floor
|
||
// per `pearl_wiener_alpha_floor_for_nonstationary`; first-
|
||
// observation bootstrap per `pearl_first_observation_bootstrap`).
|
||
// 3. Writes `target_jitter_per_h[k] = sqrt(label_var_ema[k])` —
|
||
// anchored to the label DISTRIBUTION per spec §3.3 +
|
||
// `feedback_isv_for_adaptive_bounds` (data property, not
|
||
// model state). Replaces the prior model-state-anchored
|
||
// bootstrap from `raw_per_h` which deviated from the spec.
|
||
// 4. Computes `smoothness_ratio_k = max(0, jitter_ema - target) /
|
||
// max(target, ε_floor)` per `pearl_blend_formulas_must_have_permanent_floor`.
|
||
// 5. Writes `lr_mult_k = 1 / (1 + smoothness_ratio_k)` (bounded
|
||
// (0, 1]) to the mapped-pinned staging slice. The next
|
||
// step's captured-graph rescale kernel reads the updated
|
||
// values via the device-visible pointer (mapped-pinned
|
||
// coherence — no DtoH).
|
||
//
|
||
// Phase 1: no-op (the rescale kernel is also gated to Phase 2,
|
||
// and the staging keeps its 1.0-init value).
|
||
if self.phase == TrainingPhase::Phase2Routed {
|
||
// (1) Host-side per-horizon label variance over this batch.
|
||
//
|
||
// `stg_labels` layout is `[K × B × N_HORIZONS]` row-major
|
||
// (horizon innermost): index `(k * B + b) * N_HORIZONS + h`.
|
||
// For typical configs (K=32, B=1) this is 32 elements per
|
||
// horizon — a trivial host reduction.
|
||
let labels = self.stg_labels.read_all();
|
||
let n_per_h: usize = k_seq * b_sz;
|
||
debug_assert_eq!(labels.len(), n_per_h * N_HORIZONS);
|
||
let mut label_var = [0.0_f32; N_HORIZONS];
|
||
if n_per_h > 0 {
|
||
for h in 0..N_HORIZONS {
|
||
let mut s = 0.0_f32;
|
||
let mut s2 = 0.0_f32;
|
||
for kb in 0..n_per_h {
|
||
let v = labels[kb * N_HORIZONS + h];
|
||
s += v;
|
||
s2 += v * v;
|
||
}
|
||
let n_f = n_per_h as f32;
|
||
let mean = s / n_f;
|
||
let mean_sq = s2 / n_f;
|
||
// E[x²] − E[x]²; clamped to ≥ 0 for floating-point safety.
|
||
label_var[h] = (mean_sq - mean * mean).max(0.0);
|
||
}
|
||
}
|
||
// (2) Wiener-α EMA with α = 0.4 floor + first-observation bootstrap.
|
||
const ALPHA_LABEL_VAR: f32 = 0.4;
|
||
if !self.label_var_ema_bootstrapped {
|
||
self.label_var_ema = label_var;
|
||
self.label_var_ema_bootstrapped = true;
|
||
} else {
|
||
for h in 0..N_HORIZONS {
|
||
self.label_var_ema[h] = (1.0 - ALPHA_LABEL_VAR)
|
||
* self.label_var_ema[h]
|
||
+ ALPHA_LABEL_VAR * label_var[h];
|
||
}
|
||
}
|
||
// (3) target_jitter_per_h[k] = sqrt(label_var_ema[k]) — spec §3.3.
|
||
for k in 0..N_HORIZONS {
|
||
self.target_jitter_per_h[k] = self.label_var_ema[k].sqrt();
|
||
}
|
||
|
||
let jitter_ema = self.smoothness_jitter_ema_host_d.read_all();
|
||
let mut lr_mult = [1.0_f32; N_HORIZONS];
|
||
for k in 0..N_HORIZONS {
|
||
let target = self.target_jitter_per_h[k];
|
||
if target <= 0.0 {
|
||
// Pre-bootstrap edge case (label_var_ema still 0 at
|
||
// Phase 2 step 0 corner). Leave lr_mult at 1.0 — no
|
||
// attenuation, no division by sentinel zero. The next
|
||
// step's batch supplies a non-zero label variance.
|
||
continue;
|
||
}
|
||
// ε_floor = target / 16 per `pearl_blend_formulas_must_have_permanent_floor`
|
||
// (avoids division blow-up if target drifts toward zero).
|
||
let eps_floor = target / 16.0;
|
||
let safe_target = target.max(eps_floor);
|
||
let excess = (jitter_ema[k] - target).max(0.0);
|
||
let smoothness_ratio = excess / safe_target;
|
||
lr_mult[k] = 1.0 / (1.0 + smoothness_ratio);
|
||
}
|
||
// Mapped-pinned write: kernel reads via dev_ptr on the next
|
||
// graph replay. Volatile write inside `write_from_slice`.
|
||
self.lr_mult_per_horizon_staging.write_from_slice(&lr_mult);
|
||
}
|
||
|
||
// ── 5. Controller D host-side update + recovery cascade (spec §3.4) ──
|
||
//
|
||
// Per `feedback_no_functionality_removal`, the cascade is recovery-
|
||
// first: bucket dim slots are preserved through two corrective
|
||
// attempts (widen, channel-swap) before degraded inheritance is
|
||
// permitted. Scope reduction for Task 12 (documented at struct
|
||
// field): Recovery 1 (widen) is FULLY WIRED; Recoveries 2/3 are
|
||
// LOG-ONLY stubs that emit `tracing::warn!` so a Smoke 2 firing
|
||
// surfaces in logs but does not perform the channel reassignment
|
||
// or τ inheritance. The 500+-line channel-swap implementation is
|
||
// deferred until Smoke 2 actually observes a bucket that requires
|
||
// it (per `pearl_no_deferrals_for_complementary_fixes`: the
|
||
// implementation pathways are disjoint, so the log-only boundary
|
||
// is an explicit scope decision documented here, not a deferral).
|
||
//
|
||
// Phase 1: skipped entirely (no transition has fired; the
|
||
// h_mag_per_bucket reduction over zero-length buckets returns
|
||
// sentinel zeros which would prematurely trip the dead-threshold).
|
||
if self.phase == TrainingPhase::Phase2Routed {
|
||
self.phase2_step_count = self.phase2_step_count.saturating_add(1);
|
||
let h_mag = self.h_mag_per_bucket_staged.read_all();
|
||
// Wiener-α floor = 0.4 per `pearl_wiener_alpha_floor_for_nonstationary`:
|
||
// Controller D is a co-adapting closed loop (the dead-bucket
|
||
// threshold derives from the same h_mag distribution that the
|
||
// detector reads), and the spec §3.4 mandates this floor.
|
||
const ALPHA: f32 = 0.4;
|
||
// 1/e decay floor per spec §3.4 — natural CfC time-constant
|
||
// floor (decay_relative_floor = exp(-1) ≈ 0.368).
|
||
let decay_relative_floor: f32 = (-1.0_f32).exp();
|
||
|
||
for k in 0..N_HORIZONS {
|
||
// First-observation bootstrap per `pearl_first_observation_bootstrap`:
|
||
// step 1 of Phase 2 replaces directly (no Wiener blend); subsequent
|
||
// steps update via the EMA. Sentinel-zero `first_observation_floor`
|
||
// gates the read.
|
||
if self.phase2_step_count == 1 {
|
||
self.controller_d.first_observation_floor[k] = h_mag[k];
|
||
self.controller_d.h_mag_ema[k] = h_mag[k];
|
||
continue;
|
||
}
|
||
self.controller_d.h_mag_ema[k] =
|
||
(1.0 - ALPHA) * self.controller_d.h_mag_ema[k] + ALPHA * h_mag[k];
|
||
|
||
// Dead threshold per spec §3.4: bucket-local 1/e of first-obs
|
||
// floor. If first-obs was sentinel zero (shouldn't happen by
|
||
// construction — every CfC channel decays a nonzero h_state
|
||
// after Phase 1 warmup), the threshold is also zero and the
|
||
// detector never fires for that bucket.
|
||
let dead_threshold =
|
||
self.controller_d.first_observation_floor[k] * decay_relative_floor;
|
||
|
||
if self.controller_d.h_mag_ema[k] < dead_threshold {
|
||
self.controller_d.consecutive_dead_steps[k] =
|
||
self.controller_d.consecutive_dead_steps[k].saturating_add(1);
|
||
} else {
|
||
self.controller_d.consecutive_dead_steps[k] = 0;
|
||
}
|
||
|
||
if self.controller_d.consecutive_dead_steps[k]
|
||
>= self.controller_d.dead_window_k[k]
|
||
{
|
||
match self.controller_d.recovery_attempts[k] {
|
||
0 => {
|
||
// RECOVERY ATTEMPT 1 (widen) — FULL implementation.
|
||
// Launch `bucket_iqr_double_widening_kernel` to
|
||
// halve iqr_lo[k] + double iqr_hi[k]. This releases
|
||
// bucket k's τ values from Controller B's hard
|
||
// projection clip, giving the gradient signal room
|
||
// to re-engage the channels. Off the hot path: a
|
||
// single 1-thread launch per dead detection.
|
||
if let Some(metadata) =
|
||
self.bucket_routing_metadata.as_mut()
|
||
{
|
||
let cfg_widen = LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (1, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let bucket_idx_i32 = k as i32;
|
||
let mut launch = self
|
||
.stream
|
||
.launch_builder(&self.bucket_iqr_double_widening_fn);
|
||
launch
|
||
.arg(&mut metadata.bucket_tau_iqr_lo_d)
|
||
.arg(&mut metadata.bucket_tau_iqr_hi_d)
|
||
.arg(&bucket_idx_i32);
|
||
unsafe {
|
||
launch.launch(cfg_widen).context(
|
||
"bucket_iqr_double_widening launch (Controller D Recovery 1)",
|
||
)?;
|
||
}
|
||
self.stream
|
||
.synchronize()
|
||
.context("Controller D Recovery 1 sync")?;
|
||
}
|
||
tracing::warn!(
|
||
bucket = k,
|
||
h_mag_ema = self.controller_d.h_mag_ema[k],
|
||
first_obs = self.controller_d.first_observation_floor[k],
|
||
consecutive_dead_steps =
|
||
self.controller_d.consecutive_dead_steps[k],
|
||
dead_window = self.controller_d.dead_window_k[k],
|
||
phase2_step = self.phase2_step_count,
|
||
"Controller D Recovery 1 (slack widen) firing for bucket"
|
||
);
|
||
self.controller_d.recovery_attempts[k] = 1;
|
||
self.controller_d.consecutive_dead_steps[k] = 0;
|
||
}
|
||
1 => {
|
||
// RECOVERY ATTEMPT 2 (channel swap) — LOG-ONLY stub.
|
||
// The full implementation would:
|
||
// 1. Compute the per-bucket channel activity
|
||
// (h_mag per channel within the most-active
|
||
// neighbor bucket).
|
||
// 2. Select N_swap most-active channels from
|
||
// that neighbor.
|
||
// 3. Reassign their bucket id, update
|
||
// bucket_dim_k + bucket_channel_offset,
|
||
// reorder the τ vector and heads_w_skip
|
||
// compact storage to match the new layout.
|
||
// 4. Invalidate + recapture the CUDA training
|
||
// graph.
|
||
// Deferred per scope reduction documented at the
|
||
// controller_d field. Stub logs include the
|
||
// ISV-derived n_swap so post-hoc analysis can see
|
||
// what the FULL implementation would have done.
|
||
let n_swap_raw = ((1.0
|
||
- self.controller_d.h_mag_ema[k]
|
||
/ self
|
||
.controller_d
|
||
.first_observation_floor[k]
|
||
.max(1e-6))
|
||
* (crate::cfc::bucket_routing::BUCKET_DIM_K[k] as f32))
|
||
.ceil() as i64;
|
||
let bdim = crate::cfc::bucket_routing::BUCKET_DIM_K[k] as i64;
|
||
let n_swap = n_swap_raw.clamp(1, bdim - 1);
|
||
tracing::warn!(
|
||
bucket = k,
|
||
n_swap,
|
||
h_mag_ema = self.controller_d.h_mag_ema[k],
|
||
first_obs = self.controller_d.first_observation_floor[k],
|
||
"Controller D Recovery 2 (channel swap) STUB \
|
||
— would swap N_swap channels from neighbor; \
|
||
channel reassignment + graph recapture not yet wired \
|
||
(awaiting Smoke 2 observation per scope reduction)"
|
||
);
|
||
self.controller_d.recovery_attempts[k] = 2;
|
||
self.controller_d.consecutive_dead_steps[k] = 0;
|
||
}
|
||
2 => {
|
||
// RECOVERY ATTEMPT 3 (inheritance fallback) —
|
||
// LOG-ONLY stub. The full implementation would:
|
||
// 1. Pick neighbor (k-1 or k+1) with closer
|
||
// label_variance to bucket k.
|
||
// 2. Copy `tau_all_d[neighbor slice]` over
|
||
// `tau_all_d[k slice]` (one DtoD memcpy).
|
||
// 3. Emit degraded-outcome marker for the
|
||
// ensemble layer.
|
||
// Deferred per scope reduction; stub log includes
|
||
// the neighbor selection so post-hoc analysis can
|
||
// see the intended target.
|
||
let neighbor = if k == 0 { 1 } else { k - 1 };
|
||
tracing::warn!(
|
||
bucket = k,
|
||
neighbor,
|
||
h_mag_ema = self.controller_d.h_mag_ema[k],
|
||
first_obs = self.controller_d.first_observation_floor[k],
|
||
"Controller D Recovery 3 (inheritance fallback) STUB \
|
||
— would inherit neighbor τ; tau copy + degraded-outcome \
|
||
emission not yet wired (awaiting Smoke 2 observation per \
|
||
scope reduction)"
|
||
);
|
||
self.controller_d.recovery_attempts[k] = 3;
|
||
self.controller_d.consecutive_dead_steps[k] = 0;
|
||
}
|
||
_ => {
|
||
// Already at inheritance fallback (level 3). Per
|
||
// spec §3.4: "Continue training" — no further
|
||
// escalation. The trading layer's
|
||
// WeightedByRealizedSharpe ensemble will downweight
|
||
// the redundant leaf. Reset the counter so the
|
||
// logged warning above does not repeat every step.
|
||
self.controller_d.consecutive_dead_steps[k] = 0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ISV-derived dead-window refresh at Phase 2 step 50 per
|
||
// `feedback_isv_for_adaptive_bounds`. We use the simplest
|
||
// observation that satisfies the spec §3.4 "ISV-anchored"
|
||
// requirement: a Wiener-α=0.4 EMA settles to within ~5% of
|
||
// steady state in roughly 8 steps (1/α = 2.5 settling-time
|
||
// constants; 3τ ≈ 95% settling). The half-life in steps from
|
||
// first observation is ~2-3 steps; a window of 2× the
|
||
// observed half-life is the spec's anchor. We compute the
|
||
// observed half-life per bucket from the EMA settling
|
||
// trajectory: if the bucket's h_mag_ema has dropped below
|
||
// first_observation_floor / 2 by step 50, the observed
|
||
// half-life IS at most 50 steps and the dead window stays at
|
||
// its bootstrap value (50). If it hasn't dropped below /2 in
|
||
// 50 steps, the bucket is healthy and the dead window can be
|
||
// safely widened — we use 2× the conservative bootstrap to
|
||
// suppress false positives. This is signal-driven, not tuned.
|
||
if self.phase2_step_count == 50 {
|
||
for k in 0..N_HORIZONS {
|
||
let half_floor = self.controller_d.first_observation_floor[k] * 0.5;
|
||
if self.controller_d.h_mag_ema[k] >= half_floor {
|
||
// No observed half-life crossing in first 50 steps:
|
||
// bucket is healthy; widen window to 100 to suppress
|
||
// false positives from spurious EMA dips.
|
||
self.controller_d.dead_window_k[k] = 100;
|
||
}
|
||
// Otherwise leave at bootstrap 50 — the bucket has
|
||
// already exhibited fast decay; default window is
|
||
// appropriate to catch a sustained dead condition.
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(loss)
|
||
}
|
||
|
||
/// Kernel-dispatch portion of a training step — captured into the
|
||
/// CUDA Graph on the second `step_batched` call.
|
||
fn dispatch_train_step(
|
||
&mut self,
|
||
b_sz: usize,
|
||
k_seq: usize,
|
||
total_snaps: usize,
|
||
) -> Result<()> {
|
||
// ── GPU log ring tick (feature: kernel-step-trace) ──
|
||
// Runs FIRST inside the captured graph so every downstream
|
||
// logging-capable kernel sees the new step counter. Single-
|
||
// thread/single-block kernel: no host malloc, no host branch
|
||
// (per `pearl_cudarc_disable_event_tracking_for_graph_capture`
|
||
// and `pearl_no_host_branches_in_captured_graph`).
|
||
//
|
||
// Activated only when the runtime trace path is Some — i.e.
|
||
// when the trainer was constructed with
|
||
// `cfg.kernel_step_trace_path = Some(_)`. With path = None all
|
||
// the trace fields are None and this block is skipped, so the
|
||
// tick kernel never launches and zero overhead is incurred.
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
if let (Some(tick_fn), Some(step_counter_d)) =
|
||
(self.gpu_log_tick_fn.as_ref(), self.log_step_counter_d.as_mut())
|
||
{
|
||
let tick_cfg = LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (1, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(tick_fn);
|
||
launch.arg(step_counter_d);
|
||
unsafe { launch.launch(tick_cfg).context("gpu_log_tick launch")?; }
|
||
}
|
||
|
||
// DtoD: staging (mapped-pinned, device-visible) → device buffers.
|
||
let n10 = total_snaps * 10;
|
||
let n6 = total_snaps * REGIME_DIM;
|
||
let n1 = total_snaps;
|
||
unsafe {
|
||
let s = self.stream.cu_stream();
|
||
// f32 arrays
|
||
let (d, _g) = self.bid_px_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_bid_px_all.dev_ptr, n10 * 4, s)
|
||
.context("bid_px_all dtod")?;
|
||
let (d, _g) = self.bid_sz_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_bid_sz_all.dev_ptr, n10 * 4, s)
|
||
.context("bid_sz_all dtod")?;
|
||
let (d, _g) = self.ask_px_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_ask_px_all.dev_ptr, n10 * 4, s)
|
||
.context("ask_px_all dtod")?;
|
||
let (d, _g) = self.ask_sz_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_ask_sz_all.dev_ptr, n10 * 4, s)
|
||
.context("ask_sz_all dtod")?;
|
||
let (d, _g) = self.regime_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_regime_all.dev_ptr, n6 * 4, s)
|
||
.context("regime_all dtod")?;
|
||
let (d, _g) = self.prev_mid_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_prev_mid.dev_ptr, n1 * 4, s)
|
||
.context("prev_mid_all dtod")?;
|
||
let (d, _g) = self.trade_signed_vol_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_trade_signed_vol.dev_ptr, n1 * 4, s)
|
||
.context("tsv_all dtod")?;
|
||
// i32 / i64 arrays
|
||
let (d, _g) = self.trade_count_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_trade_count.dev_ptr, n1 * 4, s)
|
||
.context("trade_count_all dtod")?;
|
||
let (d, _g) = self.ts_ns_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_ts_ns.dev_ptr, n1 * 8, s)
|
||
.context("ts_ns_all dtod")?;
|
||
let (d, _g) = self.prev_ts_ns_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_prev_ts_ns.dev_ptr, n1 * 8, s)
|
||
.context("prev_ts_ns_all dtod")?;
|
||
}
|
||
|
||
// Single batched snap_feature_assemble_batched launch — writes
|
||
// directly into window_tensor's storage at [b_idx * K + k] * 32.
|
||
let tick_size = ES_TICK_SIZE;
|
||
let n_total_i32 = total_snaps as i32;
|
||
let snap_block: u32 = 128;
|
||
let snap_grid: u32 = (total_snaps as u32).div_ceil(snap_block);
|
||
let snap_cfg = LaunchConfig {
|
||
grid_dim: (snap_grid, 1, 1),
|
||
block_dim: (snap_block, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
unsafe {
|
||
let mut launch = self.stream.launch_builder(&self.trunk.snap_batched_fn);
|
||
launch
|
||
.arg(&self.bid_px_all_d).arg(&self.bid_sz_all_d)
|
||
.arg(&self.ask_px_all_d).arg(&self.ask_sz_all_d)
|
||
.arg(&self.prev_bid_sz_all_d).arg(&self.prev_ask_sz_all_d)
|
||
.arg(&self.regime_all_d)
|
||
.arg(&self.prev_mid_all_d).arg(&self.trade_signed_vol_all_d)
|
||
.arg(&self.trade_count_all_d)
|
||
.arg(&self.ts_ns_all_d).arg(&self.prev_ts_ns_all_d)
|
||
.arg(&tick_size).arg(&n_total_i32)
|
||
.arg(self.window_tensor_d.data_mut());
|
||
launch.launch(snap_cfg).context("snap_batched fwd")?;
|
||
}
|
||
// No sync — stream-ordered. A sync would trip
|
||
// STREAM_CAPTURE_ISOLATION inside the captured region.
|
||
|
||
// ── 1b. VSN forward — per-position softmax-normalised feature
|
||
// gating over `window_tensor_d` [B, K, FEATURE_DIM].
|
||
// Writes gated features to `vsn_out_d` and saved
|
||
// post-softmax gates to `vsn_gates_d` for backward.
|
||
// Mamba2 then consumes `vsn_out_d` instead of the raw
|
||
// snap features.
|
||
{
|
||
let n_rows_vsn: i32 = (b_sz * k_seq) as i32;
|
||
let cfg_vsn = LaunchConfig {
|
||
grid_dim: (n_rows_vsn as u32, 1, 1),
|
||
block_dim: (64, 1, 1), // VSN_BLOCK = 64 (1 warp + idle threads)
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.trunk.vsn_fwd_fn);
|
||
launch
|
||
.arg(&self.trunk.vsn_w_d)
|
||
.arg(&self.trunk.vsn_b_d)
|
||
.arg(self.window_tensor_d.cuda_data())
|
||
.arg(&n_rows_vsn)
|
||
.arg(self.vsn_out_d.data_mut())
|
||
.arg(&mut self.vsn_gates_d);
|
||
unsafe { launch.launch(cfg_vsn).context("variable_selection_fwd")?; }
|
||
}
|
||
|
||
// ── 2. Mamba2 stack-1 forward — writes into mamba2_fwd_scratch.
|
||
// Consumes vsn_out_d (gated features). in_dim = FEATURE_DIM (40).
|
||
self.trunk
|
||
.mamba2_l1_mut()
|
||
.forward_train_seq_into(&self.vsn_out_d, &mut self.mamba2_fwd_scratch)
|
||
.context("mamba2 (l1) forward_train_seq_into")?;
|
||
|
||
// ── 2a. LayerNorm A — between m1 and m2 (Phase 2B). Reads
|
||
// m1.h_enriched_seq, writes ln_a_out_d.
|
||
{
|
||
let n_rows_ln: i32 = (b_sz * k_seq) as i32;
|
||
let cfg_ln = LaunchConfig {
|
||
grid_dim: (n_rows_ln as u32, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.trunk.ln_fwd_fn);
|
||
launch
|
||
.arg(self.mamba2_fwd_scratch.h_enriched_seq.cuda_data())
|
||
.arg(&self.trunk.ln_a_gain_d)
|
||
.arg(&self.trunk.ln_a_bias_d)
|
||
.arg(&n_rows_ln)
|
||
.arg(self.ln_a_out_d.data_mut())
|
||
.arg(&mut self.ln_a_stats_d);
|
||
unsafe { launch.launch(cfg_ln).context("layer_norm_fwd (LN_a)")?; }
|
||
}
|
||
|
||
// ── 2b. Mamba2 stack-2 forward — consumes ln_a_out_d. in_dim = HIDDEN_DIM.
|
||
self.trunk
|
||
.mamba2_l2_mut()
|
||
.forward_train_seq_into(&self.ln_a_out_d, &mut self.mamba2_l2_fwd_scratch)
|
||
.context("mamba2 (l2) forward_train_seq_into")?;
|
||
|
||
// ── 2c. LayerNorm B — between m2 and CfC. Stabilises the
|
||
// trunk-to-CfC distribution. One block per row,
|
||
// block-wide tree-reduce for mean/var. Stats saved for
|
||
// backward in ln_stats_d. Reads m2's h_enriched_seq.
|
||
{
|
||
let n_rows_ln: i32 = (b_sz * k_seq) as i32;
|
||
let cfg_ln = LaunchConfig {
|
||
grid_dim: (n_rows_ln as u32, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.trunk.ln_fwd_fn);
|
||
launch
|
||
.arg(self.mamba2_l2_fwd_scratch.h_enriched_seq.cuda_data())
|
||
.arg(&self.trunk.ln_b_gain_d)
|
||
.arg(&self.trunk.ln_b_bias_d)
|
||
.arg(&n_rows_ln)
|
||
.arg(&mut self.ln_out_d)
|
||
.arg(&mut self.ln_stats_d);
|
||
unsafe { launch.launch(cfg_ln).context("layer_norm_fwd (LN_b)")?; }
|
||
}
|
||
|
||
// ── 2b. Transpose LN-normalised h_enriched [B, K, H] → [K, B, H]
|
||
// into pre-allocated buffer (source = ln_out_d, NOT raw
|
||
// Mamba2 output).
|
||
{
|
||
let block_n3: u32 = 32;
|
||
let grid_z = (HIDDEN_DIM as u32).div_ceil(block_n3);
|
||
let cfg_tx = LaunchConfig {
|
||
grid_dim: (b_sz as u32, k_seq as u32, grid_z),
|
||
block_dim: (block_n3, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let n1 = b_sz as i32;
|
||
let n2 = k_seq as i32;
|
||
let n3 = HIDDEN_DIM as i32;
|
||
let mut launch = self.stream.launch_builder(&self.trunk.transpose_3d_fn);
|
||
launch
|
||
.arg(&self.ln_out_d)
|
||
.arg(self.h_enriched_seq_t_d.data_mut())
|
||
.arg(&n1).arg(&n2).arg(&n3);
|
||
unsafe { launch.launch(cfg_tx).context("transpose h_enriched fwd")?; }
|
||
}
|
||
|
||
// ── 2d. Attention pool forward (Phase 3) — produces
|
||
// attn_context_d [B, HIDDEN_DIM] = learned content-summary
|
||
// over all K LN_b output positions. Replaces CfC's
|
||
// zero-initialised h_old at k=0 with this context vector.
|
||
// Shared mem: K floats (scores) + BLOCK floats (reduce) +
|
||
// HIDDEN_DIM floats (context) = (K + 128 + 128) * 4 bytes.
|
||
//
|
||
// Random-sampling training always re-bootstraps the pooled
|
||
// context at sequence start — sequences are independent draws,
|
||
// so the previous step's h_old/attn context carries no useful
|
||
// information across the boundary.
|
||
let need_attn_pool_bootstrap = true;
|
||
if need_attn_pool_bootstrap {
|
||
let k_i32 = k_seq as i32;
|
||
let n_batch_attn = b_sz as i32;
|
||
let shared = (k_seq + 128 + HIDDEN_DIM) * std::mem::size_of::<f32>();
|
||
let cfg_attn = LaunchConfig {
|
||
grid_dim: (b_sz as u32, 1, 1),
|
||
block_dim: (128, 1, 1), // ATTN_BLOCK
|
||
shared_mem_bytes: shared as u32,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.trunk.attn_fwd_fn);
|
||
launch
|
||
.arg(&self.trunk.attn_q_d)
|
||
.arg(&self.ln_out_d)
|
||
.arg(&n_batch_attn).arg(&k_i32)
|
||
.arg(&mut self.attn_context_d)
|
||
.arg(&mut self.attn_weights_d);
|
||
unsafe { launch.launch(cfg_attn).context("attention_pool_fwd")?; }
|
||
}
|
||
|
||
// ── 3. Labels DtoD: staging (filled in step_batched before
|
||
// captured region) → device.
|
||
let total_labels = k_seq * b_sz * N_HORIZONS;
|
||
unsafe {
|
||
let (dst_ptr, _g) = self.labels_per_k_d.device_ptr_mut(&self.stream);
|
||
let nbytes = total_labels * std::mem::size_of::<f32>();
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
dst_ptr, self.stg_labels.dev_ptr, nbytes, self.stream.cu_stream(),
|
||
).context("labels upload DtoD")?;
|
||
}
|
||
|
||
// CfC per-batch grad scratch (Phase B): zero ONCE per step; K-loop
|
||
// bwd accumulates into these, then reduce_axis0 collapses → final
|
||
// grad buffers (OVERWRITE) after the K-loop. AdamW reads the final
|
||
// grads (must run AFTER the reducer).
|
||
self.stream.memset_zeros(&mut self.cfc_grad_w_in_scratch_d)
|
||
.map_err(|e| anyhow::anyhow!("zero cfc_grad_w_in_scratch: {e}"))?;
|
||
self.stream.memset_zeros(&mut self.cfc_grad_w_rec_scratch_d)
|
||
.map_err(|e| anyhow::anyhow!("zero cfc_grad_w_rec_scratch: {e}"))?;
|
||
self.stream.memset_zeros(&mut self.cfc_grad_b_scratch_d)
|
||
.map_err(|e| anyhow::anyhow!("zero cfc_grad_b_scratch: {e}"))?;
|
||
self.stream.memset_zeros(&mut self.cfc_grad_tau_scratch_d)
|
||
.map_err(|e| anyhow::anyhow!("zero cfc_grad_tau_scratch: {e}"))?;
|
||
// Phase B commit 4: attention pool per-batch grad_Q scratch.
|
||
self.stream.memset_zeros(&mut self.attn_grad_q_scratch_d)
|
||
.map_err(|e| anyhow::anyhow!("zero attn_grad_q_scratch: {e}"))?;
|
||
// GRN per-batch grad scratch (Phase B commit 2): zero ONCE per
|
||
// step; K-loop bwd accumulates into these, then reduce_axis0
|
||
// collapses → final grad buffers (OVERWRITE) after the K-loop.
|
||
self.stream.memset_zeros(&mut self.grn_grad_w1_scratch_d)
|
||
.map_err(|e| anyhow::anyhow!("zero grn_grad_w1_scratch: {e}"))?;
|
||
self.stream.memset_zeros(&mut self.grn_grad_b1_scratch_d)
|
||
.map_err(|e| anyhow::anyhow!("zero grn_grad_b1_scratch: {e}"))?;
|
||
self.stream.memset_zeros(&mut self.grn_grad_w2_scratch_d)
|
||
.map_err(|e| anyhow::anyhow!("zero grn_grad_w2_scratch: {e}"))?;
|
||
self.stream.memset_zeros(&mut self.grn_grad_b2_scratch_d)
|
||
.map_err(|e| anyhow::anyhow!("zero grn_grad_b2_scratch: {e}"))?;
|
||
self.stream.memset_zeros(&mut self.grn_grad_w_gate_scratch_d)
|
||
.map_err(|e| anyhow::anyhow!("zero grn_grad_w_gate_scratch: {e}"))?;
|
||
self.stream.memset_zeros(&mut self.grn_grad_b_gate_scratch_d)
|
||
.map_err(|e| anyhow::anyhow!("zero grn_grad_b_gate_scratch: {e}"))?;
|
||
self.stream.memset_zeros(&mut self.grn_grad_w_main_scratch_d)
|
||
.map_err(|e| anyhow::anyhow!("zero grn_grad_w_main_scratch: {e}"))?;
|
||
self.stream.memset_zeros(&mut self.grn_grad_b_main_scratch_d)
|
||
.map_err(|e| anyhow::anyhow!("zero grn_grad_b_main_scratch: {e}"))?;
|
||
self.stream.memset_zeros(&mut self.grn_grad_w_skip_scratch_d)
|
||
.map_err(|e| anyhow::anyhow!("zero grn_grad_w_skip_scratch: {e}"))?;
|
||
self.stream.memset_zeros(&mut self.grn_grad_b_skip_scratch_d)
|
||
.map_err(|e| anyhow::anyhow!("zero grn_grad_b_skip_scratch: {e}"))?;
|
||
self.stream.memset_zeros(&mut self.zero_h_d)
|
||
.map_err(|e| anyhow::anyhow!("zero zero_h: {e}"))?;
|
||
|
||
// A1: decision_stride removed; CRT runs at event rate, dt = 1.0 per step.
|
||
let dt_s = 1.0_f32;
|
||
let n_in_i = HIDDEN_DIM as i32;
|
||
let n_hid_i = HIDDEN_DIM as i32;
|
||
let block_dim = 128u32;
|
||
let _ = block_dim; // legacy 1D layout helper — superseded by block-per-batch launches.
|
||
// CfC fwd: block-per-batch (Phase B). One block per sample;
|
||
// each block's threads parallelize over n_hid channels.
|
||
// Shared layout: s_x [n_in] + s_h_old [n_hid].
|
||
let cfc_fwd_smem = (2 * HIDDEN_DIM * std::mem::size_of::<f32>()) as u32;
|
||
let cfg_cfc = LaunchConfig {
|
||
grid_dim: (b_sz as u32, 1, 1),
|
||
block_dim: (HIDDEN_DIM as u32, 1, 1),
|
||
shared_mem_bytes: cfc_fwd_smem,
|
||
};
|
||
let cfg_heads = LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (N_HORIZONS as u32, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
// CfC bwd (Phase B): block-per-batch + 2 * n_hid floats of shared mem
|
||
// (one sd_pre row + sdecay for the block's single bi).
|
||
// Shared layout: sd_pre [n_hid] + sdecay [n_hid] + s_x [n_in] + s_h_old [n_hid].
|
||
// With n_in == n_hid == HIDDEN_DIM, that's 4 × HIDDEN_DIM floats per block.
|
||
let cfc_bwd_smem = (4 * HIDDEN_DIM * std::mem::size_of::<f32>()) as u32;
|
||
let cfg_cfc_bwd = LaunchConfig {
|
||
grid_dim: (b_sz as u32, 1, 1),
|
||
block_dim: (HIDDEN_DIM as u32, 1, 1),
|
||
shared_mem_bytes: cfc_bwd_smem,
|
||
};
|
||
// Batched heads backward shared mem: sd_z[B, 5].
|
||
let heads_bwd_smem = (b_sz * N_HORIZONS * std::mem::size_of::<f32>()) as u32;
|
||
let cfg_heads_bwd = LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: heads_bwd_smem,
|
||
};
|
||
// Heads forward uses block of 128 too (5 threads compute heads,
|
||
// remaining are idle — same kernel layout as backward).
|
||
let cfg_heads_batched_fwd = LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let _ = cfg_heads;
|
||
|
||
// Per-K slot strides for [K, B, H] / [K, B, N_HORIZONS] layout.
|
||
let kb_hid_bytes = b_sz * HIDDEN_DIM * std::mem::size_of::<f32>();
|
||
let kb_nh_bytes = b_sz * N_HORIZONS * std::mem::size_of::<f32>();
|
||
// GRN per-K intermediate stride: [B, N_HORIZONS, HEAD_MID].
|
||
let kb_nh_mid_bytes = b_sz * N_HORIZONS * HEAD_MID_DIM * std::mem::size_of::<f32>();
|
||
let n_batch_i = b_sz as i32;
|
||
|
||
// GRN forward launch config: block-per-sample, threads tile HEAD_MID.
|
||
let cfg_grn_fwd = LaunchConfig {
|
||
grid_dim: (b_sz as u32, 1, 1),
|
||
block_dim: (HEAD_MID_DIM as u32, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
// GRN backward launch config: block-per-batch (Phase B). One block
|
||
// per sample; threads = HIDDEN so Pass 5/6 can partition over the
|
||
// output i-dim for COALESCED writes. Passes 2/3/4 use first
|
||
// HEAD_MID threads only; the rest idle until Pass 5.
|
||
// Per-batch scratch + reducer collapses B → final grad after the K-loop.
|
||
let cfg_grn_bwd = LaunchConfig {
|
||
grid_dim: (b_sz as u32, 1, 1),
|
||
block_dim: (HIDDEN_DIM as u32, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let _ = cfg_heads_batched_fwd; // GRN fwd uses cfg_grn_fwd now.
|
||
let _ = cfg_heads_bwd; // GRN bwd uses cfg_grn_bwd now.
|
||
|
||
// ── 4. Stream-ordered forward K loop. CfC is RECURRENT —
|
||
// h_old at step k for sample b IS h_new at step k-1 for
|
||
// the same b. Per-K slot is contiguous [B, H] in the
|
||
// [K, B, H] layout. Pointer-offset trick (single mut
|
||
// borrow + raw u64 arithmetic for slot pointers) keeps
|
||
// the launches stream-ordered with no syncs.
|
||
// Phase 3: attention pool produces attn_context_d which replaces
|
||
// the zero initial h_old at k=0. zero_h_ptr is no longer used in
|
||
// the fwd K-loop but is retained for the bwd k=0 case (cfc bwd
|
||
// needs an h_old slot for the input gradient to read from).
|
||
let _zero_h_ptr_unused = {
|
||
let (p, _g) = self.zero_h_d.device_ptr(&self.stream);
|
||
p
|
||
};
|
||
let attn_context_ptr = {
|
||
let (p, _g) = self.attn_context_d.device_ptr(&self.stream);
|
||
p
|
||
};
|
||
let henr_t_base = {
|
||
let (p, _g) = self.h_enriched_seq_t_d.cuda_data().device_ptr(&self.stream);
|
||
p
|
||
};
|
||
let (h_per_k_base, _g_hpk) = self.h_new_per_k_d.device_ptr_mut(&self.stream);
|
||
let (probs_base, _g_probs) = self.probs_per_k_d.device_ptr_mut(&self.stream);
|
||
let (z1_base, _g_z1) = self.z1_per_k_d.device_ptr_mut(&self.stream);
|
||
let (a1_base, _g_a1) = self.a1_per_k_d.device_ptr_mut(&self.stream);
|
||
let (z2_base, _g_z2) = self.z2_per_k_d.device_ptr_mut(&self.stream);
|
||
let (gate_base, _g_gate) = self.gate_logit_per_k_d.device_ptr_mut(&self.stream);
|
||
let (main_base, _g_main) = self.main_per_k_d.device_ptr_mut(&self.stream);
|
||
let (logit_base, _g_logit) = self.logit_per_k_d.device_ptr_mut(&self.stream);
|
||
|
||
for k in 0..k_seq {
|
||
let h_new_k_ptr = h_per_k_base + (k * kb_hid_bytes) as u64;
|
||
let h_old_k_ptr = if k == 0 {
|
||
attn_context_ptr
|
||
} else {
|
||
h_per_k_base + ((k - 1) * kb_hid_bytes) as u64
|
||
};
|
||
let x_k_ptr = henr_t_base + (k * kb_hid_bytes) as u64;
|
||
let probs_k_ptr = probs_base + (k * kb_nh_bytes) as u64;
|
||
let z1_k_ptr = z1_base + (k * kb_nh_mid_bytes) as u64;
|
||
let a1_k_ptr = a1_base + (k * kb_nh_mid_bytes) as u64;
|
||
let z2_k_ptr = z2_base + (k * kb_nh_mid_bytes) as u64;
|
||
let gate_k_ptr = gate_base + (k * kb_nh_bytes) as u64;
|
||
let main_k_ptr = main_base + (k * kb_nh_bytes) as u64;
|
||
let logit_k_ptr = logit_base + (k * kb_nh_bytes) as u64;
|
||
|
||
// CfC forward dispatch, two-phase per spec §2.3 + Task 10:
|
||
//
|
||
// Phase 1 (warmup): existing single shared `cfc_step_batched`
|
||
// kernel — block-per-batch over all HIDDEN_DIM channels.
|
||
//
|
||
// Phase 2 (routed): fused per-branch kernel
|
||
// `cfc_step_per_branch_fwd` — grid=(B, N_HORIZONS=5, 1),
|
||
// block=(MAX_BUCKET_DIM=28, 1, 1) uniform predicate, with
|
||
// `bucket_channel_offset_d` / `bucket_dim_k_d` routing each
|
||
// block to its quintile of HIDDEN_DIM. Spec §5.4 point 1.
|
||
//
|
||
// The branch on `self.phase` lives OUTSIDE the captured graph's
|
||
// hot kernel scope: the captured train graph is recaptured at
|
||
// the Phase 1→2 transition (cf. `pearl_no_host_branches_in_captured_graph`).
|
||
// Once Phase 2 has been entered, every subsequent capture replays
|
||
// the routed path; the if-branch here is evaluated by host code
|
||
// building the graph, not inside the device-side kernel stream.
|
||
unsafe {
|
||
match self.phase {
|
||
TrainingPhase::Phase1Warmup => {
|
||
let mut launch = self.stream.launch_builder(&self.trunk.step_batched_fn);
|
||
launch
|
||
.arg(&self.trunk.w_in_d).arg(&self.trunk.w_rec_d).arg(&self.trunk.b_d).arg(&self.trunk.tau_all_d)
|
||
.arg(&x_k_ptr).arg(&h_old_k_ptr)
|
||
.arg(&dt_s).arg(&n_in_i).arg(&n_hid_i).arg(&n_batch_i)
|
||
.arg(&h_new_k_ptr);
|
||
launch.launch(cfg_cfc).context("cfc fwd k batched")?;
|
||
}
|
||
TrainingPhase::Phase2Routed => {
|
||
// grid=(B, N_HORIZONS, 1), block=(MAX_BUCKET_DIM, 1, 1).
|
||
// Shared mem = 2 × HIDDEN_DIM floats (x_local + h_old_local
|
||
// cooperative staging per pearl_cooperative_staging_eliminates_redundant_reads).
|
||
const N_HORIZONS_U32: u32 = N_HORIZONS as u32;
|
||
const MAX_BUCKET_DIM: u32 = 28;
|
||
let cfg_cfc_per_branch = LaunchConfig {
|
||
grid_dim: (b_sz as u32, N_HORIZONS_U32, 1),
|
||
block_dim: (MAX_BUCKET_DIM, 1, 1),
|
||
shared_mem_bytes: (2 * HIDDEN_DIM * std::mem::size_of::<f32>()) as u32,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.trunk.cfc_step_per_branch_fwd_fn);
|
||
launch
|
||
.arg(&self.trunk.w_in_d).arg(&self.trunk.w_rec_d).arg(&self.trunk.b_d).arg(&self.trunk.tau_all_d)
|
||
.arg(&x_k_ptr).arg(&h_old_k_ptr)
|
||
.arg(&dt_s).arg(&n_batch_i)
|
||
.arg(&self.trunk.bucket_channel_offset_d).arg(&self.trunk.bucket_dim_k_d)
|
||
.arg(&h_new_k_ptr);
|
||
launch.launch(cfg_cfc_per_branch).context("cfc_step_per_branch_fwd k")?;
|
||
}
|
||
}
|
||
}
|
||
|
||
// multi_horizon_heads_grn_fwd_batched(10 params + h[B*128] + n_batch
|
||
// → probs[B*5] + z1/a1/z2 [B,5,HEAD_MID] + gate_logit/main/logit [B,5]).
|
||
//
|
||
// Task 10 scope note (Option B): the heads dispatch stays at the
|
||
// existing full-`heads_w_skip_d` GRN kernel for BOTH phases. The
|
||
// Phase 2 compact-w_skip path (`heads_block_diagonal_fwd`) and
|
||
// its 128-float `heads_w_skip_compact_d` storage are populated
|
||
// by the transition kernels (Task 9) but NOT yet consumed here:
|
||
// the GRN kernel computes the full per-horizon logit including
|
||
// skip + gate + main sub-components, and routing only the skip
|
||
// term through the compact path requires integration with
|
||
// GRN's main/gate computations (out of Task 10 scope per
|
||
// `feedback_no_partial_refactor`). A follow-up task wires the
|
||
// compact heads dispatch through GRN; the trunk handle
|
||
// `heads_block_diagonal_fwd_fn` is already loaded for that.
|
||
unsafe {
|
||
let mut launch = self.stream.launch_builder(&self.trunk.heads_grn_fwd_fn);
|
||
launch
|
||
.arg(&self.trunk.heads_w1_d).arg(&self.trunk.heads_b1_d)
|
||
.arg(&self.trunk.heads_w2_d).arg(&self.trunk.heads_b2_d)
|
||
.arg(&self.trunk.heads_w_gate_d).arg(&self.trunk.heads_b_gate_d)
|
||
.arg(&self.trunk.heads_w_main_d).arg(&self.trunk.heads_b_main_d)
|
||
.arg(&self.trunk.heads_w_skip_d).arg(&self.trunk.heads_b_skip_d)
|
||
.arg(&h_new_k_ptr).arg(&n_batch_i)
|
||
.arg(&probs_k_ptr)
|
||
.arg(&z1_k_ptr).arg(&a1_k_ptr).arg(&z2_k_ptr)
|
||
.arg(&gate_k_ptr).arg(&main_k_ptr).arg(&logit_k_ptr);
|
||
launch.launch(cfg_grn_fwd).context("heads GRN fwd k")?;
|
||
}
|
||
}
|
||
drop((_g_hpk, _g_probs, _g_z1, _g_a1, _g_z2, _g_gate, _g_main, _g_logit));
|
||
|
||
// ── Controller D signal: per-bucket mean(|h_state|) at K-1 ──
|
||
//
|
||
// Per spec §3.4, Controller D's dead-bucket detector compares
|
||
// per-bucket `h_mag_k = mean(|h_state_branch_k|)` against the
|
||
// first-observation × 1/e threshold. We read the FINAL CfC h_state
|
||
// (slot K-1, the last K-step output) — this is the value that
|
||
// carries forward to the next step's recurrent computation and the
|
||
// most representative of bucket activity at the end of the K window.
|
||
//
|
||
// The launch is unconditional INSIDE the captured graph (per
|
||
// `pearl_no_host_branches_in_captured_graph`); the host-side EMA +
|
||
// threshold logic (in `step_batched`) is what's gated to Phase 2.
|
||
// In Phase 1, `bucket_channel_offset_d` / `bucket_dim_k_d` on the
|
||
// trunk are zero-init (no transition has fired), so the kernel
|
||
// computes a zero result; the host ignores it via the phase guard.
|
||
//
|
||
// After Phase 1→2 transition, the metadata is populated and the
|
||
// kernel reads the bucket boundaries correctly. The graph is
|
||
// recaptured at the transition (Task 9), so the launch arguments
|
||
// are valid for both phases — the read of zeroed metadata in
|
||
// Phase 1 is a no-op-equivalent (sum-of-zero-length-buckets = 0).
|
||
{
|
||
let h_state_final_ptr =
|
||
h_per_k_base + ((k_seq - 1) * kb_hid_bytes) as u64;
|
||
let h_mag_cfg = LaunchConfig {
|
||
grid_dim: (N_HORIZONS as u32, 1, 1),
|
||
block_dim: (32, 1, 1),
|
||
shared_mem_bytes: 32 * 4,
|
||
};
|
||
unsafe {
|
||
let mut launch = self.stream.launch_builder(&self.h_mag_per_bucket_fn);
|
||
launch
|
||
.arg(&h_state_final_ptr)
|
||
.arg(&self.trunk.bucket_channel_offset_d)
|
||
.arg(&self.trunk.bucket_dim_k_d)
|
||
.arg(&n_batch_i)
|
||
.arg(&mut self.h_mag_per_bucket_d);
|
||
launch
|
||
.launch(h_mag_cfg)
|
||
.context("h_mag_per_bucket launch (Controller D)")?;
|
||
}
|
||
// Captured-graph DtoD shadow into mapped-pinned. The host reads
|
||
// post-sync in `step_batched` per the standard pattern
|
||
// (`feedback_no_htod_htoh_only_mapped_pinned`).
|
||
unsafe {
|
||
let (src_ptr, _g) = self.h_mag_per_bucket_d.device_ptr(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
self.h_mag_per_bucket_staged.dev_ptr,
|
||
src_ptr,
|
||
N_HORIZONS * std::mem::size_of::<f32>(),
|
||
self.stream.cu_stream(),
|
||
)
|
||
.context("h_mag_per_bucket dtod → mapped-pinned")?;
|
||
}
|
||
}
|
||
|
||
// σ grad scratch: BCE kernel writes overwrite-style; no zeroing needed.
|
||
// The output is unused downstream (σ is ISV-driven via horizon_ema_and_lambda).
|
||
|
||
// ── 5. Fused multi-horizon BCE (Kendall σ-weighted, axis A).
|
||
// The kernel doesn't distinguish position-vs-batch
|
||
// since the per-horizon weight is identified by `i % n_horizons`.
|
||
let n_pos_i = (k_seq * b_sz) as i32;
|
||
let n_h_i = N_HORIZONS as i32;
|
||
let bce_cfg = LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (256, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
{
|
||
let mut launch = self.stream.launch_builder(&self.bce_fn);
|
||
launch
|
||
.arg(&self.probs_per_k_d).arg(&self.labels_per_k_d)
|
||
.arg(&self.loss_weights_d)
|
||
.arg(&self.log_sigma_h_d)
|
||
.arg(&n_pos_i).arg(&n_h_i)
|
||
.arg(&mut self.loss_d)
|
||
.arg(&mut self.loss_per_horizon_d)
|
||
.arg(&mut self.grad_probs_per_k_d)
|
||
.arg(&mut self.valid_d)
|
||
.arg(&mut self.grad_log_sigma_h_d);
|
||
unsafe { launch.launch(bce_cfg).context("bce launch")?; }
|
||
}
|
||
|
||
// ── 5c. CRT.train output-smoothness regularizer ──
|
||
// λ[h]-weighted Σ (p[k]-p[k-1])² across adjacent positions
|
||
// in each sequence. Accumulates the analytic gradient INTO
|
||
// `self.grad_probs_per_k_d` (which BCE just overwrote);
|
||
// the heads_bwd K-loop below consumes the sum.
|
||
//
|
||
// Per `pearl_no_host_branches_in_captured_graph`: kernel
|
||
// launch only — all scalars (k_i, b_i) and buffer pointers
|
||
// are fixed across captures (seq_len and batch_size are
|
||
// config-immutable). λ is uploaded once at construction;
|
||
// when `cfg.smoothness_base_lambda == 0.0` the kernel
|
||
// produces zero loss + zero grad, behaviorally identical
|
||
// to the pre-intervention path.
|
||
let k_seq_i = k_seq as i32;
|
||
let b_sz_i = b_sz as i32;
|
||
let smooth_cfg = LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (256, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
{
|
||
let mut launch = self.stream.launch_builder(&self.smoothness_fn);
|
||
launch
|
||
.arg(&self.probs_per_k_d)
|
||
.arg(&self.smoothness_lambda_d)
|
||
.arg(&k_seq_i).arg(&b_sz_i)
|
||
.arg(&mut self.smoothness_loss_d)
|
||
.arg(&mut self.smoothness_loss_per_horizon_d)
|
||
.arg(&mut self.grad_probs_per_k_d);
|
||
unsafe { launch.launch(smooth_cfg).context("output_smoothness launch")?; }
|
||
}
|
||
|
||
// ── 5d. ISV-driven λ controller ──
|
||
// Reads the raw per-horizon mean-sq-diff just emitted by
|
||
// the output_smoothness kernel, updates jitter_ema, derives
|
||
// per-horizon target anchored on h30, and produces λ for
|
||
// the NEXT step's output_smoothness launch. Same captured
|
||
// graph; one-step delay between observation and effect
|
||
// (standard closed-loop pattern).
|
||
let base_lambda = self.cfg.smoothness_base_lambda;
|
||
let smooth_ctrl_cfg = LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (N_HORIZONS as u32, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
// GPU log ring pointers. When the feature is OFF, both are 0
|
||
// (null). When the feature is ON but the runtime trace path is
|
||
// None (no `--kernel-step-trace`), the ring + step counter
|
||
// Options are also None — pass null and the kernel's
|
||
// `log_record()` short-circuits at the nullptr guard, behaviorally
|
||
// identical to the pre-log kernel.
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
let log_ring_ptr: cudarc::driver::sys::CUdeviceptr = self
|
||
.log_ring
|
||
.as_ref()
|
||
.map(|r| r.dev_ptr())
|
||
.unwrap_or(0);
|
||
#[cfg(not(feature = "kernel-step-trace"))]
|
||
let log_ring_ptr: cudarc::driver::sys::CUdeviceptr = 0;
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
let log_step_ptr: cudarc::driver::sys::CUdeviceptr = match self.log_step_counter_d.as_ref() {
|
||
Some(slice) => {
|
||
let (p, _g) = slice.device_ptr(&self.stream);
|
||
p
|
||
}
|
||
None => 0,
|
||
};
|
||
#[cfg(not(feature = "kernel-step-trace"))]
|
||
let log_step_ptr: cudarc::driver::sys::CUdeviceptr = 0;
|
||
{
|
||
let mut launch = self.stream.launch_builder(&self.smoothness_controller_fn);
|
||
launch
|
||
.arg(&self.smoothness_loss_per_horizon_d)
|
||
.arg(&mut self.smoothness_jitter_ema_d)
|
||
.arg(&mut self.smoothness_jitter_first_obs_d)
|
||
.arg(&base_lambda)
|
||
.arg(&mut self.smoothness_lambda_d)
|
||
.arg(&log_ring_ptr)
|
||
.arg(&log_step_ptr);
|
||
unsafe { launch.launch(smooth_ctrl_cfg).context("smoothness_lambda_controller launch")?; }
|
||
}
|
||
|
||
// ── 5a. (v2 reserved) — per-horizon Q_h backward removed at V1.
|
||
// v2 backward path will live here in commit V10.
|
||
|
||
// ── 5b. ISV-driven per-horizon controllers. Single kernel emits
|
||
// both:
|
||
// lambda_d[h] — λ multiplier on trunk grad_h
|
||
// log_sigma_h_d[h] — Kendall σ for next-step BCE loss
|
||
// Both anchor on loss_ema (EMA of unweighted per-horizon
|
||
// BCE). σ in closed form (Kendall equilibrium), λ as
|
||
// z-score-scaled multiplier with adaptive Z_SCALE_ISV
|
||
// (derived from z_max_ema). No Adam involvement in σ
|
||
// (per pearl_adam_normalizes_loss_weights).
|
||
{
|
||
let cfg = LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (1, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.horizon_lambda_fn);
|
||
launch
|
||
.arg(&self.loss_per_horizon_d)
|
||
.arg(&mut self.loss_ema_d)
|
||
.arg(&mut self.z_max_ema_d)
|
||
.arg(&mut self.lambda_d)
|
||
.arg(&mut self.log_sigma_h_d);
|
||
unsafe { launch.launch(cfg).context("horizon_ema_and_lambda launch")?; }
|
||
}
|
||
|
||
// ── 6. Reverse-order backward K loop using pre-allocated
|
||
// grad_h_enriched_seq_t_d as the per-K slot output.
|
||
self.stream.memset_zeros(&mut self.grad_h_carry_d)
|
||
.map_err(|e| anyhow::anyhow!("zero grad_h_carry: {e}"))?;
|
||
|
||
// Phase 3: at k=0 the bwd kernel reads `h_old` = attn_context_d
|
||
// (mirroring the forward pass). zero_h_ptr_bwd retained as a
|
||
// legacy fallback / unused alias.
|
||
let _zero_h_ptr_bwd_unused = {
|
||
let (p, _g) = self.zero_h_d.device_ptr(&self.stream);
|
||
p
|
||
};
|
||
let attn_context_ptr_bwd = {
|
||
let (p, _g) = self.attn_context_d.device_ptr(&self.stream);
|
||
p
|
||
};
|
||
let henr_t_base_bwd = {
|
||
let (p, _g) = self.h_enriched_seq_t_d.cuda_data().device_ptr(&self.stream);
|
||
p
|
||
};
|
||
let (h_per_k_base_bwd, _g_hpk_bwd) = self.h_new_per_k_d.device_ptr_mut(&self.stream);
|
||
let (probs_base_bwd, _g_probs_bwd) = self.probs_per_k_d.device_ptr_mut(&self.stream);
|
||
let (gprobs_base_bwd, _g_gprobs_bwd) = self.grad_probs_per_k_d.device_ptr_mut(&self.stream);
|
||
let (grad_henr_t_base, _g_ghen_t_mut) = self.grad_h_enriched_seq_t_d.data_mut().device_ptr_mut(&self.stream);
|
||
let (z1_base_bwd, _g_z1_bwd) = self.z1_per_k_d.device_ptr_mut(&self.stream);
|
||
let (a1_base_bwd, _g_a1_bwd) = self.a1_per_k_d.device_ptr_mut(&self.stream);
|
||
let (z2_base_bwd, _g_z2_bwd) = self.z2_per_k_d.device_ptr_mut(&self.stream);
|
||
let (gate_base_bwd, _g_gate_bwd) = self.gate_logit_per_k_d.device_ptr_mut(&self.stream);
|
||
let (main_base_bwd, _g_main_bwd) = self.main_per_k_d.device_ptr_mut(&self.stream);
|
||
|
||
for k in (0..k_seq).rev() {
|
||
let h_new_k_ptr = h_per_k_base_bwd + (k * kb_hid_bytes) as u64;
|
||
let x_k_ptr = henr_t_base_bwd + (k * kb_hid_bytes) as u64;
|
||
let probs_k_ptr = probs_base_bwd + (k * kb_nh_bytes) as u64;
|
||
let gprobs_k_ptr = gprobs_base_bwd + (k * kb_nh_bytes) as u64;
|
||
let z1_k_ptr = z1_base_bwd + (k * kb_nh_mid_bytes) as u64;
|
||
let a1_k_ptr = a1_base_bwd + (k * kb_nh_mid_bytes) as u64;
|
||
let z2_k_ptr = z2_base_bwd + (k * kb_nh_mid_bytes) as u64;
|
||
let gate_k_ptr = gate_base_bwd + (k * kb_nh_bytes) as u64;
|
||
let main_k_ptr = main_base_bwd + (k * kb_nh_bytes) as u64;
|
||
let h_old_k_ptr = if k == 0 {
|
||
attn_context_ptr_bwd
|
||
} else {
|
||
h_per_k_base_bwd + ((k - 1) * kb_hid_bytes) as u64
|
||
};
|
||
let grad_henr_k_ptr = grad_henr_t_base + (k * kb_hid_bytes) as u64;
|
||
|
||
// GRN backward: chain rule through sigmoid → (skip + sigmoid(gate)*main)
|
||
// → GLU → linear → GELU → linear → trunk. `lambda_d` scales
|
||
// ONLY the trunk gradient; per-horizon param grads are
|
||
// unscaled (per pearl_adam_normalizes_loss_weights.md the
|
||
// effective lever is the trunk gradient, not loss weight).
|
||
// Phase B: per-batch GRN grad scratch + per-batch grad_h_new.
|
||
// K-loop's 64 invocations accumulate into the scratch via +=;
|
||
// reduce_axis0 collapses → final grad after the K-loop.
|
||
unsafe {
|
||
let mut launch = self.stream.launch_builder(&self.heads_grn_bwd_fn);
|
||
launch
|
||
.arg(&self.trunk.heads_w1_d).arg(&self.trunk.heads_w2_d)
|
||
.arg(&self.trunk.heads_w_gate_d).arg(&self.trunk.heads_w_main_d)
|
||
.arg(&self.trunk.heads_w_skip_d)
|
||
.arg(&probs_k_ptr).arg(&gprobs_k_ptr)
|
||
.arg(&z1_k_ptr).arg(&a1_k_ptr).arg(&z2_k_ptr)
|
||
.arg(&gate_k_ptr).arg(&main_k_ptr)
|
||
.arg(&h_new_k_ptr)
|
||
.arg(&self.grad_h_carry_d)
|
||
.arg(&self.lambda_d)
|
||
.arg(&n_batch_i)
|
||
.arg(&mut self.grn_grad_w1_scratch_d).arg(&mut self.grn_grad_b1_scratch_d)
|
||
.arg(&mut self.grn_grad_w2_scratch_d).arg(&mut self.grn_grad_b2_scratch_d)
|
||
.arg(&mut self.grn_grad_w_gate_scratch_d).arg(&mut self.grn_grad_b_gate_scratch_d)
|
||
.arg(&mut self.grn_grad_w_main_scratch_d).arg(&mut self.grn_grad_b_main_scratch_d)
|
||
.arg(&mut self.grn_grad_w_skip_scratch_d).arg(&mut self.grn_grad_b_skip_scratch_d)
|
||
.arg(&mut self.grad_h_new_d);
|
||
launch.launch(cfg_grn_bwd).context("heads GRN bwd k")?;
|
||
}
|
||
|
||
// cfc_step_bwd_batched: writes grad_h_carry (= grad_h_old_out for
|
||
// position k-1) and grad_x[B, n_in]. grad_x output buffer IS
|
||
// grad_henr_k_ptr — kernel writes directly into the K-slot.
|
||
// Phase B: per-batch param-grad scratch + per-batch grad_h_old / grad_x.
|
||
// Writes to scratch via += across K iterations; reduce_axis0
|
||
// collapses B → final grad after the K-loop (see Pass 8c).
|
||
unsafe {
|
||
let mut launch = self.stream.launch_builder(&self.step_bwd_batched_fn);
|
||
launch
|
||
.arg(&self.trunk.w_in_d).arg(&self.trunk.w_rec_d).arg(&self.trunk.b_d).arg(&self.trunk.tau_all_d)
|
||
.arg(&x_k_ptr).arg(&h_old_k_ptr).arg(&self.grad_h_new_d)
|
||
.arg(&dt_s).arg(&n_in_i).arg(&n_hid_i).arg(&n_batch_i)
|
||
.arg(&mut self.cfc_grad_w_in_scratch_d)
|
||
.arg(&mut self.cfc_grad_w_rec_scratch_d)
|
||
.arg(&mut self.cfc_grad_b_scratch_d)
|
||
.arg(&mut self.cfc_grad_tau_scratch_d)
|
||
.arg(&mut self.grad_h_carry_d)
|
||
.arg(&grad_henr_k_ptr);
|
||
launch.launch(cfg_cfc_bwd).context("cfc bwd k batched")?;
|
||
}
|
||
}
|
||
drop((_g_hpk_bwd, _g_probs_bwd, _g_gprobs_bwd, _g_ghen_t_mut,
|
||
_g_z1_bwd, _g_a1_bwd, _g_z2_bwd, _g_gate_bwd, _g_main_bwd));
|
||
|
||
// ── 7. Stream stays async — bwd loop kernels, transposes, and
|
||
// Mamba2 bwd are all sequential on the same stream, no
|
||
// sync needed between them. Single sync at the very end
|
||
// to flush before reading the mapped-pinned loss shadow.
|
||
|
||
// ── 7b. Transpose grad_h_enriched_seq_t_d [K, B, H] → [B, K, H]
|
||
// (pre-allocated grad_h_enriched_seq_d). This is the
|
||
// gradient w.r.t. the LN OUTPUT (= grad_y for LN bwd).
|
||
{
|
||
let block_n3: u32 = 32;
|
||
let grid_z = (HIDDEN_DIM as u32).div_ceil(block_n3);
|
||
let cfg_tx = LaunchConfig {
|
||
grid_dim: (k_seq as u32, b_sz as u32, grid_z),
|
||
block_dim: (block_n3, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let n1 = k_seq as i32;
|
||
let n2 = b_sz as i32;
|
||
let n3 = HIDDEN_DIM as i32;
|
||
let mut launch = self.stream.launch_builder(&self.trunk.transpose_3d_fn);
|
||
launch
|
||
.arg(self.grad_h_enriched_seq_t_d.cuda_data())
|
||
.arg(self.grad_h_enriched_seq_d.data_mut())
|
||
.arg(&n1).arg(&n2).arg(&n3);
|
||
unsafe { launch.launch(cfg_tx).context("transpose grad bwd")?; }
|
||
}
|
||
|
||
// ── 7c-pre. Attention pool backward (Phase 3). Consumes:
|
||
// Q = self.attn_q_d [HIDDEN_DIM]
|
||
// ln_out (values)= self.ln_out_d [B, K, HIDDEN_DIM]
|
||
// attn_weights = self.attn_weights_d (saved by fwd) [B, K]
|
||
// grad_context = self.grad_h_carry_d (= grad on initial
|
||
// h_old at k=0, which IS attn_context) [B, HIDDEN_DIM]
|
||
// Writes (BOTH ARE +=):
|
||
// grad_attn_q_d += attn-path contribution to Q
|
||
// grad_h_enriched_seq_d (LN_b output grad) += attn-path
|
||
// contribution to ln_out
|
||
// The pre-zero of grad_attn_q_d at step start makes the += a
|
||
// clean overwrite for the Q grad. grad_h_enriched_seq_d already
|
||
// holds the K-loop's contribution at this point — attn's
|
||
// contribution adds on top.
|
||
// Phase B commit 4: block-per-batch attn bwd writes per-batch
|
||
// grad_Q scratch; reducer collapses → final grad_attn_q_d below.
|
||
{
|
||
let k_i32 = k_seq as i32;
|
||
let n_batch_attn = b_sz as i32;
|
||
let shared = (2 * k_seq + 128) * std::mem::size_of::<f32>();
|
||
let cfg_attn_bwd = LaunchConfig {
|
||
grid_dim: (b_sz as u32, 1, 1),
|
||
block_dim: (128, 1, 1), // ATTN_BLOCK
|
||
shared_mem_bytes: shared as u32,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.attn_bwd_fn);
|
||
launch
|
||
.arg(&self.trunk.attn_q_d)
|
||
.arg(&self.ln_out_d)
|
||
.arg(&self.attn_weights_d)
|
||
.arg(&self.grad_h_carry_d)
|
||
.arg(&n_batch_attn).arg(&k_i32)
|
||
.arg(&mut self.attn_grad_q_scratch_d)
|
||
.arg(self.grad_h_enriched_seq_d.data_mut());
|
||
unsafe { launch.launch(cfg_attn_bwd).context("attention_pool_bwd")?; }
|
||
}
|
||
// Attn pool reducer: collapse [B, HIDDEN_DIM] → [HIDDEN_DIM].
|
||
// reduce_axis0: block tiles 32 cols × 8 batches per launch.
|
||
{
|
||
let n_batch_i = b_sz as i32;
|
||
let n_tail_i = HIDDEN_DIM as i32;
|
||
let cfg_red = LaunchConfig {
|
||
grid_dim: (((HIDDEN_DIM + 31) / 32) as u32, 1, 1),
|
||
block_dim: (32, 8, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.reduce_axis0_fn);
|
||
launch
|
||
.arg(&self.attn_grad_q_scratch_d)
|
||
.arg(&n_batch_i)
|
||
.arg(&n_tail_i)
|
||
.arg(&mut self.grad_attn_q_d);
|
||
unsafe { launch.launch(cfg_red).context("reduce attn_grad_q")?; }
|
||
}
|
||
|
||
// ── 7c. LayerNorm B backward (between m2 and CfC). Consumes:
|
||
// x = m2.h_enriched_seq [B, K, H]
|
||
// gain = self.ln_gain_d [H]
|
||
// stats = self.ln_stats_d (from fwd) [B*K, 2]
|
||
// grad_y = self.grad_h_enriched_seq_d [B, K, H]
|
||
// Produces:
|
||
// grad_x = self.grad_ln_in_d [B, K, H]
|
||
// grad_gain/row = self.grad_ln_gain_per_row_d [B*K, H]
|
||
// grad_bias/row = self.grad_ln_bias_per_row_d [B*K, H]
|
||
// Then two reducer launches collapse the per-row
|
||
// scratches into the final [H] param grad buffers
|
||
// (no atomicAdd per feedback_no_atomicadd.md).
|
||
{
|
||
let n_rows_ln: i32 = (b_sz * k_seq) as i32;
|
||
let cfg_ln = LaunchConfig {
|
||
grid_dim: (n_rows_ln as u32, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.ln_bwd_fn);
|
||
launch
|
||
.arg(self.mamba2_l2_fwd_scratch.h_enriched_seq.cuda_data())
|
||
.arg(&self.trunk.ln_b_gain_d)
|
||
.arg(&self.ln_stats_d)
|
||
.arg(self.grad_h_enriched_seq_d.cuda_data())
|
||
.arg(&n_rows_ln)
|
||
.arg(self.grad_ln_in_d.data_mut())
|
||
.arg(&mut self.grad_ln_gain_per_row_d)
|
||
.arg(&mut self.grad_ln_bias_per_row_d);
|
||
unsafe { launch.launch(cfg_ln).context("layer_norm_bwd (LN_b)")?; }
|
||
}
|
||
// 7c.i — reduce per-row grad_gain → [H].
|
||
{
|
||
let cfg_red = LaunchConfig {
|
||
grid_dim: (HIDDEN_DIM as u32, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let n_rows_ln: i32 = (b_sz * k_seq) as i32;
|
||
let mut launch = self.stream.launch_builder(&self.ln_reduce_fn);
|
||
launch
|
||
.arg(&self.grad_ln_gain_per_row_d)
|
||
.arg(&n_rows_ln)
|
||
.arg(&mut self.grad_ln_gain_d);
|
||
unsafe { launch.launch(cfg_red).context("layer_norm_reduce gain")?; }
|
||
}
|
||
// 7c.ii — reduce per-row grad_bias → [H].
|
||
{
|
||
let cfg_red = LaunchConfig {
|
||
grid_dim: (HIDDEN_DIM as u32, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let n_rows_ln: i32 = (b_sz * k_seq) as i32;
|
||
let mut launch = self.stream.launch_builder(&self.ln_reduce_fn);
|
||
launch
|
||
.arg(&self.grad_ln_bias_per_row_d)
|
||
.arg(&n_rows_ln)
|
||
.arg(&mut self.grad_ln_bias_d);
|
||
unsafe { launch.launch(cfg_red).context("layer_norm_reduce bias")?; }
|
||
}
|
||
|
||
// ── 8. Mamba2 stack-2 backward. Consumes:
|
||
// input = ln_a_out_d (m2's forward input)
|
||
// d_h_out = grad_ln_in_d (LN_b bwd output)
|
||
// Writes:
|
||
// mamba2_l2_grads_buffers.d_x_from_in = grad w.r.t. m2's
|
||
// input = grad w.r.t. LN_a output (consumed by LN_a bwd below).
|
||
self.trunk
|
||
.mamba2_l2_mut()
|
||
.backward_from_h_enriched_seq_full_into(
|
||
&self.ln_a_out_d,
|
||
&self.mamba2_l2_fwd_scratch,
|
||
&self.grad_ln_in_d,
|
||
&mut self.mamba2_l2_bwd_scratch,
|
||
&mut self.mamba2_l2_grads_buffers,
|
||
)
|
||
.context("mamba2 (l2) backward_from_h_enriched_seq_full_into")?;
|
||
|
||
// ── 8a. LN_a backward. Consumes:
|
||
// x = m1.h_enriched_seq [B, K, H]
|
||
// gain = ln_a_gain_d [H]
|
||
// stats = ln_a_stats_d (from fwd) [B*K, 2]
|
||
// grad_y = m2.d_x_from_in (reshape) [B, K, H] — read flat
|
||
// Writes:
|
||
// grad_x = grad_ln_a_in_d [B, K, H] (fed to m1.bwd)
|
||
// grad_gain/row = grad_ln_a_gain_per_row_d
|
||
// grad_bias/row = grad_ln_a_bias_per_row_d
|
||
{
|
||
let n_rows_ln: i32 = (b_sz * k_seq) as i32;
|
||
let cfg_ln = LaunchConfig {
|
||
grid_dim: (n_rows_ln as u32, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.ln_bwd_fn);
|
||
launch
|
||
.arg(self.mamba2_fwd_scratch.h_enriched_seq.cuda_data())
|
||
.arg(&self.trunk.ln_a_gain_d)
|
||
.arg(&self.ln_a_stats_d)
|
||
.arg(self.mamba2_l2_grads_buffers.d_x_from_in.cuda_data())
|
||
.arg(&n_rows_ln)
|
||
.arg(self.grad_ln_a_in_d.data_mut())
|
||
.arg(&mut self.grad_ln_a_gain_per_row_d)
|
||
.arg(&mut self.grad_ln_a_bias_per_row_d);
|
||
unsafe { launch.launch(cfg_ln).context("layer_norm_bwd (LN_a)")?; }
|
||
}
|
||
// 8a.i — reduce per-row grad_a_gain → [H].
|
||
{
|
||
let cfg_red = LaunchConfig {
|
||
grid_dim: (HIDDEN_DIM as u32, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let n_rows_ln: i32 = (b_sz * k_seq) as i32;
|
||
let mut launch = self.stream.launch_builder(&self.ln_reduce_fn);
|
||
launch
|
||
.arg(&self.grad_ln_a_gain_per_row_d)
|
||
.arg(&n_rows_ln)
|
||
.arg(&mut self.grad_ln_a_gain_d);
|
||
unsafe { launch.launch(cfg_red).context("layer_norm_reduce gain (LN_a)")?; }
|
||
}
|
||
// 8a.ii — reduce per-row grad_a_bias → [H].
|
||
{
|
||
let cfg_red = LaunchConfig {
|
||
grid_dim: (HIDDEN_DIM as u32, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let n_rows_ln: i32 = (b_sz * k_seq) as i32;
|
||
let mut launch = self.stream.launch_builder(&self.ln_reduce_fn);
|
||
launch
|
||
.arg(&self.grad_ln_a_bias_per_row_d)
|
||
.arg(&n_rows_ln)
|
||
.arg(&mut self.grad_ln_a_bias_d);
|
||
unsafe { launch.launch(cfg_red).context("layer_norm_reduce bias (LN_a)")?; }
|
||
}
|
||
|
||
// ── 8b. Mamba2 stack-1 backward. Consumes:
|
||
// input = vsn_out_d (m1's forward input)
|
||
// d_h_out = grad_ln_a_in_d (LN_a bwd output)
|
||
// Writes:
|
||
// mamba2_grads_buffers.d_x_from_in = grad on VSN output
|
||
// (consumed by VSN bwd below).
|
||
self.trunk
|
||
.mamba2_l1_mut()
|
||
.backward_from_h_enriched_seq_full_into(
|
||
&self.vsn_out_d,
|
||
&self.mamba2_fwd_scratch,
|
||
&self.grad_ln_a_in_d,
|
||
&mut self.mamba2_bwd_scratch,
|
||
&mut self.mamba2_grads_buffers,
|
||
)
|
||
.context("mamba2 (l1) backward_from_h_enriched_seq_full_into")?;
|
||
|
||
// ── 8b. VSN backward. Consumes:
|
||
// grad_y = mamba2_grads_buffers.d_x_from_in [B*K, FEATURE_DIM]
|
||
// x = window_tensor_d [B, K, FEATURE_DIM]
|
||
// gates = vsn_gates_d (saved by fwd) [B*K, FEATURE_DIM]
|
||
// Writes:
|
||
// grad_W_vsn, grad_b_vsn — Adam consumes.
|
||
// vsn_grad_x_d — discarded (snap_features non-trainable).
|
||
// Param grads OVERWRITE (kernel uses += but the prior
|
||
// memsets clear them at step start, so a single VSN bwd
|
||
// per step writes the full accumulation cleanly).
|
||
// Phase B commit 3: VSN per-row grad scratch + reducer.
|
||
self.stream.memset_zeros(&mut self.vsn_grad_w_scratch_d)
|
||
.map_err(|e| anyhow::anyhow!("zero vsn_grad_w_scratch: {e}"))?;
|
||
self.stream.memset_zeros(&mut self.vsn_grad_b_scratch_d)
|
||
.map_err(|e| anyhow::anyhow!("zero vsn_grad_b_scratch: {e}"))?;
|
||
{
|
||
let n_rows_vsn: i32 = (b_sz * k_seq) as i32;
|
||
let cfg_vsn_bwd = LaunchConfig {
|
||
grid_dim: (n_rows_vsn as u32, 1, 1),
|
||
block_dim: (64, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.vsn_bwd_fn);
|
||
launch
|
||
.arg(&self.trunk.vsn_w_d)
|
||
.arg(self.window_tensor_d.cuda_data())
|
||
.arg(&self.vsn_gates_d)
|
||
.arg(self.mamba2_grads_buffers.d_x_from_in.cuda_data())
|
||
.arg(&n_rows_vsn)
|
||
.arg(&mut self.vsn_grad_w_scratch_d)
|
||
.arg(&mut self.vsn_grad_b_scratch_d)
|
||
.arg(&mut self.vsn_grad_x_d);
|
||
unsafe { launch.launch(cfg_vsn_bwd).context("variable_selection_bwd")?; }
|
||
}
|
||
// VSN reducer: collapse n_rows (= B * K) → final grad buffers.
|
||
// reduce_axis0 tiles 32 columns per block, 8-way batch stride.
|
||
{
|
||
let n_rows_i = (b_sz * k_seq) as i32;
|
||
let n_tail_w_usz = FEATURE_DIM * FEATURE_DIM;
|
||
let cfg_red_w = LaunchConfig {
|
||
grid_dim: (((n_tail_w_usz + 31) / 32) as u32, 1, 1),
|
||
block_dim: (32, 8, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let n_tail_w = n_tail_w_usz as i32;
|
||
unsafe {
|
||
let mut launch = self.stream.launch_builder(&self.reduce_axis0_fn);
|
||
launch
|
||
.arg(&self.vsn_grad_w_scratch_d)
|
||
.arg(&n_rows_i)
|
||
.arg(&n_tail_w)
|
||
.arg(&mut self.grad_vsn_w_d);
|
||
launch.launch(cfg_red_w).context("reduce vsn_grad_w")?;
|
||
}
|
||
let cfg_red_b = LaunchConfig {
|
||
grid_dim: (((FEATURE_DIM + 31) / 32) as u32, 1, 1),
|
||
block_dim: (32, 8, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let n_tail_b = FEATURE_DIM as i32;
|
||
unsafe {
|
||
let mut launch = self.stream.launch_builder(&self.reduce_axis0_fn);
|
||
launch
|
||
.arg(&self.vsn_grad_b_scratch_d)
|
||
.arg(&n_rows_i)
|
||
.arg(&n_tail_b)
|
||
.arg(&mut self.grad_vsn_b_d);
|
||
launch.launch(cfg_red_b).context("reduce vsn_grad_b")?;
|
||
}
|
||
}
|
||
|
||
// ── 8c. (Phase B) Reduce CfC per-batch grad scratch → final grad buffers.
|
||
// Block tree-reduce across bi; deterministic sum order.
|
||
// Each launch is grid=(ceil(n_tail/32), 1, 1), block=(32, 8, 1)
|
||
// — 32-wide column tile per block, 8-way B-stride per thread.
|
||
// 4 launches, one per cfc param tensor. AdamW (below) reads
|
||
// the final grad buffers; MUST run after these reducers.
|
||
{
|
||
let n_batch_i = b_sz as i32;
|
||
let reduce_at = |n_tail: usize,
|
||
scratch: &CudaSlice<f32>,
|
||
out: &mut CudaSlice<f32>,
|
||
label: &'static str|
|
||
-> Result<()> {
|
||
let cfg = LaunchConfig {
|
||
grid_dim: (((n_tail + 31) / 32) as u32, 1, 1),
|
||
block_dim: (32, 8, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let n_tail_i = n_tail as i32;
|
||
let mut launch = self.stream.launch_builder(&self.reduce_axis0_fn);
|
||
launch
|
||
.arg(scratch)
|
||
.arg(&n_batch_i)
|
||
.arg(&n_tail_i)
|
||
.arg(out);
|
||
unsafe { launch.launch(cfg).context(label)?; }
|
||
Ok(())
|
||
};
|
||
reduce_at(HIDDEN_DIM * HIDDEN_DIM, &self.cfc_grad_w_in_scratch_d,
|
||
&mut self.grad_w_in_d, "reduce cfc_grad_w_in")?;
|
||
reduce_at(HIDDEN_DIM * HIDDEN_DIM, &self.cfc_grad_w_rec_scratch_d,
|
||
&mut self.grad_w_rec_d, "reduce cfc_grad_w_rec")?;
|
||
reduce_at(HIDDEN_DIM, &self.cfc_grad_b_scratch_d,
|
||
&mut self.grad_b_d, "reduce cfc_grad_b")?;
|
||
reduce_at(HIDDEN_DIM, &self.cfc_grad_tau_scratch_d,
|
||
&mut self.grad_tau_d, "reduce cfc_grad_tau")?;
|
||
|
||
// GRN: 10 reducer launches (Phase B commit 2).
|
||
let nh = N_HORIZONS;
|
||
let mid = HEAD_MID_DIM;
|
||
let h = HIDDEN_DIM;
|
||
reduce_at(nh * mid * h, &self.grn_grad_w1_scratch_d,
|
||
&mut self.grad_heads_w1_d, "reduce grn_grad_w1")?;
|
||
reduce_at(nh * mid, &self.grn_grad_b1_scratch_d,
|
||
&mut self.grad_heads_b1_d, "reduce grn_grad_b1")?;
|
||
reduce_at(nh * mid * mid, &self.grn_grad_w2_scratch_d,
|
||
&mut self.grad_heads_w2_d, "reduce grn_grad_w2")?;
|
||
reduce_at(nh * mid, &self.grn_grad_b2_scratch_d,
|
||
&mut self.grad_heads_b2_d, "reduce grn_grad_b2")?;
|
||
reduce_at(nh * mid, &self.grn_grad_w_gate_scratch_d,
|
||
&mut self.grad_heads_w_gate_d, "reduce grn_grad_w_gate")?;
|
||
reduce_at(nh, &self.grn_grad_b_gate_scratch_d,
|
||
&mut self.grad_heads_b_gate_d, "reduce grn_grad_b_gate")?;
|
||
reduce_at(nh * mid, &self.grn_grad_w_main_scratch_d,
|
||
&mut self.grad_heads_w_main_d, "reduce grn_grad_w_main")?;
|
||
reduce_at(nh, &self.grn_grad_b_main_scratch_d,
|
||
&mut self.grad_heads_b_main_d, "reduce grn_grad_b_main")?;
|
||
reduce_at(nh * h, &self.grn_grad_w_skip_scratch_d,
|
||
&mut self.grad_heads_w_skip_d, "reduce grn_grad_w_skip")?;
|
||
reduce_at(nh, &self.grn_grad_b_skip_scratch_d,
|
||
&mut self.grad_heads_b_skip_d, "reduce grn_grad_b_skip")?;
|
||
}
|
||
|
||
// ── 9. Apply AdamW updates on all 17 param groups: CfC×4 +
|
||
// GRN heads×10 + LN×2 + Mamba2 grouped.
|
||
self.opt_w_in.step(&mut self.trunk.w_in_d, &self.grad_w_in_d)?;
|
||
self.opt_w_rec.step(&mut self.trunk.w_rec_d, &self.grad_w_rec_d)?;
|
||
self.opt_b.step(&mut self.trunk.b_d, &self.grad_b_d)?;
|
||
self.opt_tau.step(&mut self.trunk.tau_all_d, &self.grad_tau_d)?;
|
||
// ── Controller B: post-Adam τ projection (spec §3.2) ──
|
||
// Per `pearl_adam_normalizes_loss_weights`, the only way to actually
|
||
// bound `tau_all_d` is a hard projection AFTER Adam updates the
|
||
// parameter. Loss-weight modulation would no-op because Adam's
|
||
// m/sqrt(v) cancels constant multipliers. Phase 2 only — Phase 1's
|
||
// captured graph never enters this branch since the transition
|
||
// invalidates the graph (`pearl_no_host_branches_in_captured_graph`).
|
||
if self.phase == TrainingPhase::Phase2Routed {
|
||
if let Some(metadata) = self.bucket_routing_metadata.as_ref() {
|
||
let cfg_tau_clamp = LaunchConfig {
|
||
grid_dim: (1, 1, 1),
|
||
block_dim: (HIDDEN_DIM as u32, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.tau_clamp_fn);
|
||
launch
|
||
.arg(&mut self.trunk.tau_all_d)
|
||
.arg(&metadata.bucket_id_per_channel_d)
|
||
.arg(&metadata.bucket_tau_iqr_lo_d)
|
||
.arg(&metadata.bucket_tau_iqr_hi_d);
|
||
unsafe {
|
||
launch
|
||
.launch(cfg_tau_clamp)
|
||
.context("tau_clamp_kernel launch (Controller B)")?;
|
||
}
|
||
}
|
||
}
|
||
self.opt_heads_w1.step(&mut self.trunk.heads_w1_d, &self.grad_heads_w1_d)?;
|
||
self.opt_heads_b1.step(&mut self.trunk.heads_b1_d, &self.grad_heads_b1_d)?;
|
||
self.opt_heads_w2.step(&mut self.trunk.heads_w2_d, &self.grad_heads_w2_d)?;
|
||
self.opt_heads_b2.step(&mut self.trunk.heads_b2_d, &self.grad_heads_b2_d)?;
|
||
self.opt_heads_w_gate.step(&mut self.trunk.heads_w_gate_d, &self.grad_heads_w_gate_d)?;
|
||
self.opt_heads_b_gate.step(&mut self.trunk.heads_b_gate_d, &self.grad_heads_b_gate_d)?;
|
||
self.opt_heads_w_main.step(&mut self.trunk.heads_w_main_d, &self.grad_heads_w_main_d)?;
|
||
self.opt_heads_b_main.step(&mut self.trunk.heads_b_main_d, &self.grad_heads_b_main_d)?;
|
||
// ── Block-diagonal heads: grad mask apply (Phase 2 only) ──
|
||
// Follow-up to Task 10 Option B. Multiplies `grad_heads_w_skip_d`
|
||
// element-wise by the routing mask built at transition. Off-bucket
|
||
// entries see zero gradient → Adam never updates them → they stay
|
||
// at 0 forever (init zero-multiplied them at the transition). This
|
||
// realizes block-diagonal `heads_w_skip` without modifying the GRN
|
||
// forward/backward kernels. MUST run BEFORE
|
||
// `opt_heads_w_skip.step` so the masked grad is the one Adam reads.
|
||
if self.phase == TrainingPhase::Phase2Routed {
|
||
let cfg_grad_mask = LaunchConfig {
|
||
grid_dim: (N_HORIZONS as u32, 1, 1),
|
||
block_dim: (HIDDEN_DIM as u32, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self
|
||
.stream
|
||
.launch_builder(&self.heads_w_skip_grad_mask_apply_fn);
|
||
launch
|
||
.arg(&self.heads_w_skip_mask_d)
|
||
.arg(&mut self.grad_heads_w_skip_d);
|
||
unsafe {
|
||
launch
|
||
.launch(cfg_grad_mask)
|
||
.context("heads_w_skip_grad_mask_apply_kernel launch (block-diagonal heads)")?;
|
||
}
|
||
}
|
||
// ── Controller C: pre-Adam snapshot of heads_w_skip_d (spec §3.3) ──
|
||
// Captured DtoD copy: heads_w_skip_d → heads_w_skip_pre_adam_d. Used
|
||
// by the post-Adam rescale below to compute the parameter delta
|
||
// (post-Adam − pre-Adam) without ever reading Adam's internal
|
||
// m/v moments. Phase 2 only; the rescale launch below is gated
|
||
// identically so the snapshot is also gated to preserve memory
|
||
// bandwidth in Phase 1.
|
||
if self.phase == TrainingPhase::Phase2Routed {
|
||
unsafe {
|
||
let (src, _gs) = self.trunk.heads_w_skip_d.device_ptr(&self.stream);
|
||
let (dst, _gd) = self.heads_w_skip_pre_adam_d.device_ptr_mut(&self.stream);
|
||
let nbytes = N_HORIZONS * HIDDEN_DIM * std::mem::size_of::<f32>();
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
dst, src, nbytes, self.stream.cu_stream(),
|
||
)
|
||
.context("heads_w_skip pre-Adam DtoD snapshot")?;
|
||
}
|
||
}
|
||
self.opt_heads_w_skip.step(&mut self.trunk.heads_w_skip_d, &self.grad_heads_w_skip_d)?;
|
||
// ── Controller C: per-horizon LR multiplier rescale (spec §3.3) ──
|
||
// Computes `heads_w_skip[i] = pre[i] + (post[i] − pre[i]) * lr_mult[h(i)]`
|
||
// for every element i, with `h(i) = i / HIDDEN_DIM`. Scope reduction
|
||
// documented in the trainer struct doc: only heads_w_skip is rescaled
|
||
// here; revisit if Smoke 2 fails.
|
||
if self.phase == TrainingPhase::Phase2Routed {
|
||
const TOTAL_HEADS_W_SKIP: i32 = (N_HORIZONS * HIDDEN_DIM) as i32;
|
||
const PER_HORIZON_STRIDE: i32 = HIDDEN_DIM as i32;
|
||
let block: u32 = 64;
|
||
let grid: u32 = ((TOTAL_HEADS_W_SKIP as u32) + block - 1) / block;
|
||
let cfg_scale = LaunchConfig {
|
||
grid_dim: (grid, 1, 1),
|
||
block_dim: (block, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let lr_mult_dev_ptr = self.lr_mult_per_horizon_staging.dev_ptr;
|
||
let mut launch = self
|
||
.stream
|
||
.launch_builder(&self.heads_lr_multiplier_scale_fn);
|
||
launch
|
||
.arg(&mut self.trunk.heads_w_skip_d)
|
||
.arg(&self.heads_w_skip_pre_adam_d)
|
||
.arg(&lr_mult_dev_ptr)
|
||
.arg(&PER_HORIZON_STRIDE)
|
||
.arg(&TOTAL_HEADS_W_SKIP);
|
||
unsafe {
|
||
launch
|
||
.launch(cfg_scale)
|
||
.context("heads_lr_multiplier_scale_kernel launch (Controller C)")?;
|
||
}
|
||
}
|
||
self.opt_heads_b_skip.step(&mut self.trunk.heads_b_skip_d, &self.grad_heads_b_skip_d)?;
|
||
self.opt_ln_gain.step(&mut self.trunk.ln_b_gain_d, &self.grad_ln_gain_d)?;
|
||
self.opt_ln_bias.step(&mut self.trunk.ln_b_bias_d, &self.grad_ln_bias_d)?;
|
||
self.opt_ln_a_gain.step(&mut self.trunk.ln_a_gain_d, &self.grad_ln_a_gain_d)?;
|
||
self.opt_ln_a_bias.step(&mut self.trunk.ln_a_bias_d, &self.grad_ln_a_bias_d)?;
|
||
self.opt_vsn_w.step(&mut self.trunk.vsn_w_d, &self.grad_vsn_w_d)?;
|
||
self.opt_vsn_b.step(&mut self.trunk.vsn_b_d, &self.grad_vsn_b_d)?;
|
||
self.opt_attn_q.step(&mut self.trunk.attn_q_d, &self.grad_attn_q_d)?;
|
||
// Kendall σ is ISV-driven (closed form from loss_ema in
|
||
// horizon_ema_and_lambda) — no Adam step. grad_log_sigma_h_d
|
||
// is still written by the BCE kernel but intentionally ignored.
|
||
|
||
// (v2 reserved) — per-horizon AdamW step removed at V1; v2's six
|
||
// optimizer groups (horizon_tokens, Q_inv, w_fuse + b_fuse,
|
||
// moe_gate, experts, log_sigma) will land in commit V10.
|
||
|
||
// GPU-resident grad-clip + AdamW (zero host roundtrips).
|
||
// Replaces step_from_buffers which did 9× memcpy_dtoh per step.
|
||
self.mamba2_adamw
|
||
.step_from_buffers_gpu_clip(self.trunk.mamba2_l1_mut(), &self.mamba2_grads_buffers)
|
||
.context("mamba2 (l1) AdamW step_from_buffers_gpu_clip")?;
|
||
self.mamba2_l2_adamw
|
||
.step_from_buffers_gpu_clip(self.trunk.mamba2_l2_mut(), &self.mamba2_l2_grads_buffers)
|
||
.context("mamba2 (l2) AdamW step_from_buffers_gpu_clip")?;
|
||
|
||
// Queue loss_d → loss_host_d (mapped-pinned). Captured inside
|
||
// graph; sync + read happen in step_batched outside the region.
|
||
unsafe {
|
||
let (src_ptr, _g) = self.loss_d.device_ptr(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
self.loss_host_d.dev_ptr, src_ptr, 4, self.stream.cu_stream(),
|
||
).context("loss dtod → mapped-pinned")?;
|
||
}
|
||
// CRT.train: shadow smoothness telemetry buffers to mapped-pinned
|
||
// host slots for zero-sync operator reads. Same stream, same
|
||
// capture region — adjacent to the loss_d shadow above.
|
||
unsafe {
|
||
let (smooth_loss_src_ptr, _g) = self.smoothness_loss_d.device_ptr(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
self.smoothness_loss_host_d.dev_ptr,
|
||
smooth_loss_src_ptr,
|
||
std::mem::size_of::<f32>(),
|
||
self.stream.cu_stream(),
|
||
).context("smoothness_loss_host_d shadow")?;
|
||
}
|
||
unsafe {
|
||
let (smooth_loss_ph_src_ptr, _g) = self.smoothness_loss_per_horizon_d.device_ptr(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
self.smoothness_loss_per_horizon_host_d.dev_ptr,
|
||
smooth_loss_ph_src_ptr,
|
||
N_HORIZONS * std::mem::size_of::<f32>(),
|
||
self.stream.cu_stream(),
|
||
).context("smoothness_loss_per_horizon_host_d shadow")?;
|
||
}
|
||
// Controller C feeder shadow: per-horizon jitter EMA → mapped-pinned
|
||
// host buffer so step_batched can compute lr_mult_per_horizon between
|
||
// steps. Captured-graph DtoD on the same stream; visible after the
|
||
// end-of-step sync per the standard pattern.
|
||
unsafe {
|
||
let (src_ptr, _g) = self.smoothness_jitter_ema_d.device_ptr(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
self.smoothness_jitter_ema_host_d.dev_ptr,
|
||
src_ptr,
|
||
N_HORIZONS * std::mem::size_of::<f32>(),
|
||
self.stream.cu_stream(),
|
||
)
|
||
.context("smoothness_jitter_ema_host_d shadow")?;
|
||
}
|
||
// ── Shadow device step counter into mapped-pinned host shadow ──
|
||
// The drain task reads the host shadow at 500ms cadence to know
|
||
// how many steps have completed since its last drain. Captured
|
||
// inside the same graph as the other shadow memcpys, so a single
|
||
// end-of-step sync makes all of them visible. Skipped when the
|
||
// ring isn't allocated (runtime trace path is None).
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
if let (Some(step_counter_d), Some(shadow)) =
|
||
(self.log_step_counter_d.as_ref(), self.log_step_counter_host_shadow.as_ref())
|
||
{
|
||
unsafe {
|
||
let (src_ptr, _g) = step_counter_d.device_ptr(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
shadow.dev_ptr,
|
||
src_ptr,
|
||
std::mem::size_of::<i32>(),
|
||
self.stream.cu_stream(),
|
||
).context("log_step_counter_host_shadow shadow")?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Single-sequence forward-only eval — thin wrapper around
|
||
/// [`evaluate_batched`] for cfg.n_batch == 1.
|
||
/// X13: Persist the trunk's weights to a Checkpoint bincode file.
|
||
/// Delegates to `self.trunk.save_checkpoint` — gradient buffers and
|
||
/// AdamW state are NOT serialized (training-only, not needed for
|
||
/// inference).
|
||
pub fn save_checkpoint(&self, path: &std::path::Path) -> Result<()> {
|
||
self.trunk.save_checkpoint(path).context("trunk save_checkpoint")
|
||
}
|
||
|
||
/// X11: accessor for the trainer's `cfg` field — read-only view of
|
||
/// the construction-time configuration. Used by BacktestHarness to
|
||
/// size its snapshot window from `seq_len`.
|
||
pub fn config(&self) -> &PerceptionTrainerConfig {
|
||
&self.cfg
|
||
}
|
||
|
||
/// X11 inference entry point: forward-only pass over `snapshots`
|
||
/// (length `cfg.n_batch * cfg.seq_len` per the trainer's batching).
|
||
/// Returns per-horizon probabilities flattened as `[K, B, N_HORIZONS]`
|
||
/// in row-major layout — same shape as `evaluate_batched`'s second
|
||
/// return value but with no loss computed and no labels required.
|
||
///
|
||
/// The backtester calls this per decision: load `PerceptionTrainer`
|
||
/// from a Checkpoint via `from_checkpoint`, maintain a sliding K-window
|
||
/// of recent snapshots, take the last K position's probs as the
|
||
/// decision signal.
|
||
///
|
||
/// P5: the kernel-dispatch chain is captured into a CUDA Graph on
|
||
/// the second call and replayed on every subsequent call. The host
|
||
/// staging-fill stays OUTSIDE the captured region because input
|
||
/// snapshots vary per call; the captured DtoD copies the latest
|
||
/// staged data into the trunk's pre-bound device pointers. Mirrors
|
||
/// the three-state machine already used by `step_batched`.
|
||
pub fn forward_only(&mut self, snapshots: &[Mbp10RawInput]) -> Result<Vec<f32>> {
|
||
let b_sz = self.cfg.n_batch;
|
||
let k_seq = self.cfg.seq_len;
|
||
anyhow::ensure!(
|
||
snapshots.len() == b_sz * k_seq,
|
||
"forward_only: expected {} snapshots (B={} * K={}); got {}",
|
||
b_sz * k_seq, b_sz, k_seq, snapshots.len()
|
||
);
|
||
|
||
// ── 1. Host staging fill (OUTSIDE the captured region — input
|
||
// data varies per call). All stg_* buffers are mapped-pinned;
|
||
// the captured DtoD picks up whatever host last wrote.
|
||
let total_snaps = b_sz * k_seq;
|
||
debug_assert!(total_snaps <= self.bk_capacity);
|
||
{
|
||
let bid_px_h = self.stg_bid_px_all.host_slice_mut();
|
||
let bid_sz_h = self.stg_bid_sz_all.host_slice_mut();
|
||
let ask_px_h = self.stg_ask_px_all.host_slice_mut();
|
||
let ask_sz_h = self.stg_ask_sz_all.host_slice_mut();
|
||
let regime_h = self.stg_regime_all.host_slice_mut();
|
||
for b_idx in 0..b_sz {
|
||
for k in 0..k_seq {
|
||
let n = b_idx * k_seq + k;
|
||
let snap = &snapshots[n];
|
||
let base10 = n * 10;
|
||
let base6 = n * REGIME_DIM;
|
||
for i in 0..10 {
|
||
bid_px_h[base10 + i] = snap.bid_px[i];
|
||
bid_sz_h[base10 + i] = snap.bid_sz[i];
|
||
ask_px_h[base10 + i] = snap.ask_px[i];
|
||
ask_sz_h[base10 + i] = snap.ask_sz[i];
|
||
}
|
||
for i in 0..REGIME_DIM {
|
||
regime_h[base6 + i] = snap.regime[i];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
{
|
||
let prev_mid_h = self.stg_prev_mid.host_slice_mut();
|
||
let tsv_h = self.stg_trade_signed_vol.host_slice_mut();
|
||
let tc_h = self.stg_trade_count.host_slice_mut();
|
||
let ts_ns_h = self.stg_ts_ns.host_slice_mut();
|
||
let prev_ts_ns_h = self.stg_prev_ts_ns.host_slice_mut();
|
||
for b_idx in 0..b_sz {
|
||
for k in 0..k_seq {
|
||
let n = b_idx * k_seq + k;
|
||
let snap = &snapshots[n];
|
||
prev_mid_h[n] = snap.prev_mid;
|
||
tsv_h[n] = snap.trade_signed_vol;
|
||
tc_h[n] = snap.trade_count as i32;
|
||
ts_ns_h[n] = snap.ts_ns as i64;
|
||
prev_ts_ns_h[n] = snap.prev_ts_ns as i64;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 2. Three-state machine: warmup (first call), capture
|
||
// (second call), replay (third+). Mirrors step_batched's
|
||
// pattern at lines 1219-1257. Event tracking is disabled
|
||
// for the trainer's lifetime (see new(): line ~529) so
|
||
// the captured region is free of cuStreamWaitEvent calls
|
||
// per pearl_cudarc_disable_event_tracking_for_graph_capture.
|
||
if self.forward_graph.is_some() {
|
||
self.forward_graph
|
||
.as_ref()
|
||
.unwrap()
|
||
.launch()
|
||
.context("forward graph launch")?;
|
||
} else if !self.forward_warmed {
|
||
self.dispatch_forward_kernels(b_sz, k_seq, total_snaps)
|
||
.context("forward warmup dispatch")?;
|
||
self.forward_warmed = true;
|
||
} else {
|
||
let begin = self.stream.begin_capture(
|
||
CUstreamCaptureMode::CU_STREAM_CAPTURE_MODE_RELAXED,
|
||
);
|
||
if let Err(e) = begin {
|
||
return Err(anyhow::anyhow!("forward begin_capture: {e}"));
|
||
}
|
||
|
||
let dispatch_result =
|
||
self.dispatch_forward_kernels(b_sz, k_seq, total_snaps);
|
||
|
||
let graph_result = self.stream.end_capture(
|
||
CUgraphInstantiate_flags::CUDA_GRAPH_INSTANTIATE_FLAG_AUTO_FREE_ON_LAUNCH,
|
||
);
|
||
|
||
dispatch_result.context("forward graph dispatch (during capture)")?;
|
||
let graph = graph_result
|
||
.context("forward graph end_capture")?
|
||
.ok_or_else(|| anyhow::anyhow!(
|
||
"forward end_capture returned None — no work captured"
|
||
))?;
|
||
self.forward_graph = Some(graph);
|
||
}
|
||
|
||
// ── 3. Sync + dtoh probs (OUTSIDE capture; download requires sync).
|
||
self.stream.synchronize().context("forward_only end-sync")?;
|
||
download(&self.stream, &self.probs_per_k_d)
|
||
}
|
||
|
||
/// Kernel-dispatch portion of an inference forward — captured into the
|
||
/// CUDA Graph on the second `forward_only` call. Mirrors
|
||
/// `evaluate_batched`'s forward kernel chain (VSN → Mamba2 ×2 → LN ×2
|
||
/// → attn-pool → CfC K-loop → heads) but SKIPS labels DtoD, BCE,
|
||
/// and any stream syncs.
|
||
///
|
||
/// All inputs are read from pre-bound device pointers (`self.stg_*`
|
||
/// device addresses + `self.trunk.*` weights); output is written to
|
||
/// `self.probs_per_k_d`. No host branches, no scalar-arg-changes,
|
||
/// no host-mallocs inside per pearl_no_host_branches_in_captured_graph.
|
||
fn dispatch_forward_kernels(
|
||
&mut self,
|
||
b_sz: usize,
|
||
k_seq: usize,
|
||
total_snaps: usize,
|
||
) -> Result<()> {
|
||
// DtoD: staging (mapped-pinned, device-visible) → device buffers.
|
||
let n10 = total_snaps * 10;
|
||
let n6 = total_snaps * REGIME_DIM;
|
||
let n1 = total_snaps;
|
||
unsafe {
|
||
let s = self.stream.cu_stream();
|
||
let (d, _g) = self.bid_px_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_bid_px_all.dev_ptr, n10 * 4, s)
|
||
.context("fwd bid_px_all dtod")?;
|
||
let (d, _g) = self.bid_sz_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_bid_sz_all.dev_ptr, n10 * 4, s)
|
||
.context("fwd bid_sz_all dtod")?;
|
||
let (d, _g) = self.ask_px_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_ask_px_all.dev_ptr, n10 * 4, s)
|
||
.context("fwd ask_px_all dtod")?;
|
||
let (d, _g) = self.ask_sz_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_ask_sz_all.dev_ptr, n10 * 4, s)
|
||
.context("fwd ask_sz_all dtod")?;
|
||
let (d, _g) = self.regime_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_regime_all.dev_ptr, n6 * 4, s)
|
||
.context("fwd regime_all dtod")?;
|
||
let (d, _g) = self.prev_mid_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_prev_mid.dev_ptr, n1 * 4, s)
|
||
.context("fwd prev_mid_all dtod")?;
|
||
let (d, _g) = self.trade_signed_vol_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_trade_signed_vol.dev_ptr, n1 * 4, s)
|
||
.context("fwd tsv_all dtod")?;
|
||
let (d, _g) = self.trade_count_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_trade_count.dev_ptr, n1 * 4, s)
|
||
.context("fwd trade_count_all dtod")?;
|
||
let (d, _g) = self.ts_ns_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_ts_ns.dev_ptr, n1 * 8, s)
|
||
.context("fwd ts_ns_all dtod")?;
|
||
let (d, _g) = self.prev_ts_ns_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_prev_ts_ns.dev_ptr, n1 * 8, s)
|
||
.context("fwd prev_ts_ns_all dtod")?;
|
||
}
|
||
|
||
// snap_feature_assemble_batched: raw MBP-10 SoA → window_tensor [B,K,FEATURE_DIM].
|
||
let tick_size = ES_TICK_SIZE;
|
||
let n_total_i32 = total_snaps as i32;
|
||
let snap_block: u32 = 128;
|
||
let snap_grid: u32 = (total_snaps as u32).div_ceil(snap_block);
|
||
let snap_cfg = LaunchConfig {
|
||
grid_dim: (snap_grid, 1, 1),
|
||
block_dim: (snap_block, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
unsafe {
|
||
let mut launch = self.stream.launch_builder(&self.trunk.snap_batched_fn);
|
||
launch
|
||
.arg(&self.bid_px_all_d).arg(&self.bid_sz_all_d)
|
||
.arg(&self.ask_px_all_d).arg(&self.ask_sz_all_d)
|
||
.arg(&self.prev_bid_sz_all_d).arg(&self.prev_ask_sz_all_d)
|
||
.arg(&self.regime_all_d)
|
||
.arg(&self.prev_mid_all_d).arg(&self.trade_signed_vol_all_d)
|
||
.arg(&self.trade_count_all_d)
|
||
.arg(&self.ts_ns_all_d).arg(&self.prev_ts_ns_all_d)
|
||
.arg(&tick_size).arg(&n_total_i32)
|
||
.arg(self.window_tensor_d.data_mut());
|
||
launch.launch(snap_cfg).context("fwd snap_batched")?;
|
||
}
|
||
// No sync — stream-ordered. A sync inside the captured region
|
||
// would trip STREAM_CAPTURE_ISOLATION.
|
||
|
||
// VSN fwd.
|
||
{
|
||
let n_rows_vsn: i32 = (b_sz * k_seq) as i32;
|
||
let cfg_vsn = LaunchConfig {
|
||
grid_dim: (n_rows_vsn as u32, 1, 1),
|
||
block_dim: (64, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.trunk.vsn_fwd_fn);
|
||
launch
|
||
.arg(&self.trunk.vsn_w_d)
|
||
.arg(&self.trunk.vsn_b_d)
|
||
.arg(self.window_tensor_d.cuda_data())
|
||
.arg(&n_rows_vsn)
|
||
.arg(self.vsn_out_d.data_mut())
|
||
.arg(&mut self.vsn_gates_d);
|
||
unsafe { launch.launch(cfg_vsn).context("fwd variable_selection_fwd")?; }
|
||
}
|
||
|
||
// Mamba2 stack-1 fwd.
|
||
self.trunk
|
||
.mamba2_l1_mut()
|
||
.forward_train_seq_into(&self.vsn_out_d, &mut self.mamba2_fwd_scratch)
|
||
.context("fwd mamba2 (l1) fwd_into")?;
|
||
|
||
// LN_a fwd.
|
||
{
|
||
let n_rows_ln: i32 = (b_sz * k_seq) as i32;
|
||
let cfg_ln = LaunchConfig {
|
||
grid_dim: (n_rows_ln as u32, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.trunk.ln_fwd_fn);
|
||
launch
|
||
.arg(self.mamba2_fwd_scratch.h_enriched_seq.cuda_data())
|
||
.arg(&self.trunk.ln_a_gain_d)
|
||
.arg(&self.trunk.ln_a_bias_d)
|
||
.arg(&n_rows_ln)
|
||
.arg(self.ln_a_out_d.data_mut())
|
||
.arg(&mut self.ln_a_stats_d);
|
||
unsafe { launch.launch(cfg_ln).context("fwd layer_norm_fwd (LN_a)")?; }
|
||
}
|
||
|
||
// Mamba2 stack-2 fwd.
|
||
self.trunk
|
||
.mamba2_l2_mut()
|
||
.forward_train_seq_into(&self.ln_a_out_d, &mut self.mamba2_l2_fwd_scratch)
|
||
.context("fwd mamba2 (l2) fwd_into")?;
|
||
|
||
// LN_b fwd.
|
||
{
|
||
let n_rows_ln: i32 = (b_sz * k_seq) as i32;
|
||
let cfg_ln = LaunchConfig {
|
||
grid_dim: (n_rows_ln as u32, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.trunk.ln_fwd_fn);
|
||
launch
|
||
.arg(self.mamba2_l2_fwd_scratch.h_enriched_seq.cuda_data())
|
||
.arg(&self.trunk.ln_b_gain_d)
|
||
.arg(&self.trunk.ln_b_bias_d)
|
||
.arg(&n_rows_ln)
|
||
.arg(&mut self.ln_out_d)
|
||
.arg(&mut self.ln_stats_d);
|
||
unsafe { launch.launch(cfg_ln).context("fwd layer_norm_fwd (LN_b)")?; }
|
||
}
|
||
|
||
// Transpose LN [B, K, H] → [K, B, H].
|
||
{
|
||
let block_n3: u32 = 32;
|
||
let grid_z = (HIDDEN_DIM as u32).div_ceil(block_n3);
|
||
let cfg_tx = LaunchConfig {
|
||
grid_dim: (b_sz as u32, k_seq as u32, grid_z),
|
||
block_dim: (block_n3, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let n1 = b_sz as i32;
|
||
let n2 = k_seq as i32;
|
||
let n3 = HIDDEN_DIM as i32;
|
||
let mut launch = self.stream.launch_builder(&self.trunk.transpose_3d_fn);
|
||
launch
|
||
.arg(&self.ln_out_d)
|
||
.arg(self.h_enriched_seq_t_d.data_mut())
|
||
.arg(&n1).arg(&n2).arg(&n3);
|
||
unsafe { launch.launch(cfg_tx).context("fwd transpose h_enriched")?; }
|
||
}
|
||
|
||
// Attention pool fwd.
|
||
{
|
||
let k_i32 = k_seq as i32;
|
||
let n_batch_attn = b_sz as i32;
|
||
let shared = (k_seq + 128 + HIDDEN_DIM) * std::mem::size_of::<f32>();
|
||
let cfg_attn = LaunchConfig {
|
||
grid_dim: (b_sz as u32, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: shared as u32,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.trunk.attn_fwd_fn);
|
||
launch
|
||
.arg(&self.trunk.attn_q_d)
|
||
.arg(&self.ln_out_d)
|
||
.arg(&n_batch_attn).arg(&k_i32)
|
||
.arg(&mut self.attn_context_d)
|
||
.arg(&mut self.attn_weights_d);
|
||
unsafe { launch.launch(cfg_attn).context("fwd attention_pool_fwd")?; }
|
||
}
|
||
|
||
// Per-K CfC + heads. Pointer arithmetic on stable base addresses;
|
||
// no host branches inside the captured region.
|
||
// A1: decision_stride removed; CRT runs at event rate, dt = 1.0 per step.
|
||
let dt_s = 1.0_f32;
|
||
let n_in_i = HIDDEN_DIM as i32;
|
||
let n_hid_i = HIDDEN_DIM as i32;
|
||
let cfc_fwd_smem = (2 * HIDDEN_DIM * std::mem::size_of::<f32>()) as u32;
|
||
let cfg_cfc = LaunchConfig {
|
||
grid_dim: (b_sz as u32, 1, 1),
|
||
block_dim: (HIDDEN_DIM as u32, 1, 1),
|
||
shared_mem_bytes: cfc_fwd_smem,
|
||
};
|
||
let cfg_grn_fwd = LaunchConfig {
|
||
grid_dim: (b_sz as u32, 1, 1),
|
||
block_dim: (HEAD_MID_DIM as u32, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let kb_hid_bytes = b_sz * HIDDEN_DIM * std::mem::size_of::<f32>();
|
||
let kb_nh_bytes = b_sz * N_HORIZONS * std::mem::size_of::<f32>();
|
||
let kb_nh_mid_bytes = b_sz * N_HORIZONS * HEAD_MID_DIM * std::mem::size_of::<f32>();
|
||
let n_batch_i = b_sz as i32;
|
||
// h_old at k=0 is attn_context_d (learned pooled summary).
|
||
let attn_context_ptr = {
|
||
let (p, _g) = self.attn_context_d.device_ptr(&self.stream);
|
||
p
|
||
};
|
||
let henr_t_base = {
|
||
let (p, _g) = self.h_enriched_seq_t_d.cuda_data().device_ptr(&self.stream);
|
||
p
|
||
};
|
||
let (h_per_k_base, _g_hpk) = self.h_new_per_k_d.device_ptr_mut(&self.stream);
|
||
let (probs_base, _g_probs) = self.probs_per_k_d.device_ptr_mut(&self.stream);
|
||
let (z1_base, _g_z1) = self.z1_per_k_d.device_ptr_mut(&self.stream);
|
||
let (a1_base, _g_a1) = self.a1_per_k_d.device_ptr_mut(&self.stream);
|
||
let (z2_base, _g_z2) = self.z2_per_k_d.device_ptr_mut(&self.stream);
|
||
let (gate_base, _g_gate) = self.gate_logit_per_k_d.device_ptr_mut(&self.stream);
|
||
let (main_base, _g_main) = self.main_per_k_d.device_ptr_mut(&self.stream);
|
||
let (logit_base, _g_logit) = self.logit_per_k_d.device_ptr_mut(&self.stream);
|
||
for k in 0..k_seq {
|
||
let h_new_k_ptr = h_per_k_base + (k * kb_hid_bytes) as u64;
|
||
let h_old_k_ptr = if k == 0 {
|
||
attn_context_ptr
|
||
} else {
|
||
h_per_k_base + ((k - 1) * kb_hid_bytes) as u64
|
||
};
|
||
let x_k_ptr = henr_t_base + (k * kb_hid_bytes) as u64;
|
||
let probs_k_ptr = probs_base + (k * kb_nh_bytes) as u64;
|
||
let z1_k_ptr = z1_base + (k * kb_nh_mid_bytes) as u64;
|
||
let a1_k_ptr = a1_base + (k * kb_nh_mid_bytes) as u64;
|
||
let z2_k_ptr = z2_base + (k * kb_nh_mid_bytes) as u64;
|
||
let gate_k_ptr = gate_base + (k * kb_nh_bytes) as u64;
|
||
let main_k_ptr = main_base + (k * kb_nh_bytes) as u64;
|
||
let logit_k_ptr = logit_base + (k * kb_nh_bytes) as u64;
|
||
unsafe {
|
||
let mut launch = self.stream.launch_builder(&self.trunk.step_batched_fn);
|
||
launch
|
||
.arg(&self.trunk.w_in_d).arg(&self.trunk.w_rec_d).arg(&self.trunk.b_d).arg(&self.trunk.tau_all_d)
|
||
.arg(&x_k_ptr).arg(&h_old_k_ptr)
|
||
.arg(&dt_s).arg(&n_in_i).arg(&n_hid_i).arg(&n_batch_i)
|
||
.arg(&h_new_k_ptr);
|
||
launch.launch(cfg_cfc).context("fwd cfc")?;
|
||
}
|
||
unsafe {
|
||
let mut launch = self.stream.launch_builder(&self.trunk.heads_grn_fwd_fn);
|
||
launch
|
||
.arg(&self.trunk.heads_w1_d).arg(&self.trunk.heads_b1_d)
|
||
.arg(&self.trunk.heads_w2_d).arg(&self.trunk.heads_b2_d)
|
||
.arg(&self.trunk.heads_w_gate_d).arg(&self.trunk.heads_b_gate_d)
|
||
.arg(&self.trunk.heads_w_main_d).arg(&self.trunk.heads_b_main_d)
|
||
.arg(&self.trunk.heads_w_skip_d).arg(&self.trunk.heads_b_skip_d)
|
||
.arg(&h_new_k_ptr).arg(&n_batch_i)
|
||
.arg(&probs_k_ptr)
|
||
.arg(&z1_k_ptr).arg(&a1_k_ptr).arg(&z2_k_ptr)
|
||
.arg(&gate_k_ptr).arg(&main_k_ptr).arg(&logit_k_ptr);
|
||
launch.launch(cfg_grn_fwd).context("fwd heads GRN")?;
|
||
}
|
||
}
|
||
drop((_g_hpk, _g_probs, _g_z1, _g_a1, _g_z2, _g_gate, _g_main, _g_logit));
|
||
Ok(())
|
||
}
|
||
|
||
/// CRT Phase A0.5: single-event incremental forward pass.
|
||
///
|
||
/// Advances the encoder's recurrent state (Mamba2 SSM register state
|
||
/// in `step_scratch_l1` / `_l2`, plus CfC hidden state in
|
||
/// `cfc_h_state_step_d`) by exactly one snapshot and returns the
|
||
/// per-horizon alpha probabilities for the current event.
|
||
///
|
||
/// Per-call work scales as O(hidden_dim × state_dim) — independent
|
||
/// of K — whereas `forward_only` runs a K-window scan (~K× the
|
||
/// arithmetic). This is the structural change Phase A's ≤ 2×
|
||
/// wall-time target needs (see Task A0 cost-investigation memo).
|
||
///
|
||
/// Architectural divergence from `forward_only`:
|
||
/// - No K-window staging: only 1 snapshot is uploaded per call.
|
||
/// - No attention pool: the attention pool requires a K-window of
|
||
/// LN_b outputs to produce a learned `h_old` for CfC at k=0.
|
||
/// `forward_step` instead carries CfC state across calls, so the
|
||
/// k=0 `h_old` comes from the previous step's h_new (zero on first
|
||
/// call / after `reset_step_state()`).
|
||
/// - No K-loop in CfC + heads: exactly one CfC step + one head
|
||
/// projection per call. The K-history is captured implicitly in
|
||
/// the persistent SSM and CfC states.
|
||
///
|
||
/// Sequence semantics: calling forward_step_into N times on snapshots
|
||
/// (s_0 … s_{N-1}) starting from a fresh `reset_step_state()` is
|
||
/// equivalent (within stable-SSM dampening) to `forward_only` on the
|
||
/// same K-window snapshots once the persistent state has accumulated
|
||
/// enough context. The bit-equivalence test (forward_step_golden.rs)
|
||
/// validates this convergence empirically. Per Mamba2's stable gating
|
||
/// (`sigmoid(a) < 1`), state dampens at ~0.5^N — after ~K events the
|
||
/// state is dominated by the recent K snapshots and agrees with
|
||
/// forward_only on the same K within float-rounding tolerance.
|
||
///
|
||
/// The output `alpha_probs_dst` MUST be a device slice of length
|
||
/// `N_HORIZONS` (B=1 enforced). The GRN heads kernel writes
|
||
/// probabilities directly into it — no host staging, no `synchronize`
|
||
/// on the hot path. Per feedback_cpu_is_read_only and
|
||
/// feedback_no_htod_htoh_only_mapped_pinned: probs stay on device
|
||
/// throughout the per-event call chain.
|
||
pub fn forward_step_into(
|
||
&mut self,
|
||
snapshot: &Mbp10RawInput,
|
||
alpha_probs_dst: &mut CudaSlice<f32>,
|
||
) -> Result<()> {
|
||
let b_sz = self.cfg.n_batch;
|
||
anyhow::ensure!(
|
||
b_sz == 1,
|
||
"forward_step_into currently supports n_batch == 1 only (got {})",
|
||
b_sz
|
||
);
|
||
anyhow::ensure!(
|
||
alpha_probs_dst.len() >= N_HORIZONS,
|
||
"forward_step_into: alpha_probs_dst len {} < N_HORIZONS {}",
|
||
alpha_probs_dst.len(), N_HORIZONS
|
||
);
|
||
|
||
// ── 1. Host staging fill (1 snapshot). All buffers are
|
||
// mapped-pinned; the snap_feature kernel reads them after
|
||
// a DtoD copy below.
|
||
{
|
||
let bid_px_h = self.stg_step_bid_px.host_slice_mut();
|
||
let bid_sz_h = self.stg_step_bid_sz.host_slice_mut();
|
||
let ask_px_h = self.stg_step_ask_px.host_slice_mut();
|
||
let ask_sz_h = self.stg_step_ask_sz.host_slice_mut();
|
||
let regime_h = self.stg_step_regime.host_slice_mut();
|
||
for i in 0..10 {
|
||
bid_px_h[i] = snapshot.bid_px[i];
|
||
bid_sz_h[i] = snapshot.bid_sz[i];
|
||
ask_px_h[i] = snapshot.ask_px[i];
|
||
ask_sz_h[i] = snapshot.ask_sz[i];
|
||
}
|
||
for i in 0..REGIME_DIM {
|
||
regime_h[i] = snapshot.regime[i];
|
||
}
|
||
}
|
||
{
|
||
let prev_mid_h = self.stg_step_prev_mid.host_slice_mut();
|
||
let tsv_h = self.stg_step_trade_signed_vol.host_slice_mut();
|
||
let tc_h = self.stg_step_trade_count.host_slice_mut();
|
||
let ts_ns_h = self.stg_step_ts_ns.host_slice_mut();
|
||
let prev_ts_ns_h = self.stg_step_prev_ts_ns.host_slice_mut();
|
||
prev_mid_h[0] = snapshot.prev_mid;
|
||
tsv_h[0] = snapshot.trade_signed_vol;
|
||
tc_h[0] = snapshot.trade_count as i32;
|
||
ts_ns_h[0] = snapshot.ts_ns as i64;
|
||
prev_ts_ns_h[0] = snapshot.prev_ts_ns as i64;
|
||
}
|
||
|
||
// ── 2. DtoD copies: staging (mapped-pinned, device-visible) → device.
|
||
// Per-call cost is tiny (one snapshot = ~280 bytes total).
|
||
unsafe {
|
||
let s = self.stream.cu_stream();
|
||
let n10 = 10 * 4;
|
||
let n6 = REGIME_DIM * 4;
|
||
let n1f = 4;
|
||
let n1i = 4;
|
||
let n1l = 8;
|
||
let (d, _g) = self.step_bid_px_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_step_bid_px.dev_ptr, n10, s)
|
||
.context("step bid_px dtod")?;
|
||
let (d, _g) = self.step_bid_sz_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_step_bid_sz.dev_ptr, n10, s)
|
||
.context("step bid_sz dtod")?;
|
||
let (d, _g) = self.step_ask_px_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_step_ask_px.dev_ptr, n10, s)
|
||
.context("step ask_px dtod")?;
|
||
let (d, _g) = self.step_ask_sz_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_step_ask_sz.dev_ptr, n10, s)
|
||
.context("step ask_sz dtod")?;
|
||
let (d, _g) = self.step_regime_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_step_regime.dev_ptr, n6, s)
|
||
.context("step regime dtod")?;
|
||
let (d, _g) = self.step_prev_mid_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_step_prev_mid.dev_ptr, n1f, s)
|
||
.context("step prev_mid dtod")?;
|
||
let (d, _g) = self.step_trade_signed_vol_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_step_trade_signed_vol.dev_ptr, n1f, s)
|
||
.context("step tsv dtod")?;
|
||
let (d, _g) = self.step_trade_count_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_step_trade_count.dev_ptr, n1i, s)
|
||
.context("step trade_count dtod")?;
|
||
let (d, _g) = self.step_ts_ns_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_step_ts_ns.dev_ptr, n1l, s)
|
||
.context("step ts_ns dtod")?;
|
||
let (d, _g) = self.step_prev_ts_ns_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_step_prev_ts_ns.dev_ptr, n1l, s)
|
||
.context("step prev_ts_ns dtod")?;
|
||
}
|
||
|
||
// ── 3. snap_feature_assemble: 1 snapshot → 1 row of FEATURE_DIM.
|
||
let tick_size = ES_TICK_SIZE;
|
||
let n_total_i32: i32 = b_sz as i32;
|
||
let snap_block: u32 = 128;
|
||
let snap_grid: u32 = (b_sz as u32).div_ceil(snap_block).max(1);
|
||
let snap_cfg = LaunchConfig {
|
||
grid_dim: (snap_grid, 1, 1),
|
||
block_dim: (snap_block, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
unsafe {
|
||
let mut launch = self.stream.launch_builder(&self.trunk.snap_batched_fn);
|
||
launch
|
||
.arg(&self.step_bid_px_d).arg(&self.step_bid_sz_d)
|
||
.arg(&self.step_ask_px_d).arg(&self.step_ask_sz_d)
|
||
.arg(&self.step_prev_bid_sz_d).arg(&self.step_prev_ask_sz_d)
|
||
.arg(&self.step_regime_d)
|
||
.arg(&self.step_prev_mid_d).arg(&self.step_trade_signed_vol_d)
|
||
.arg(&self.step_trade_count_d)
|
||
.arg(&self.step_ts_ns_d).arg(&self.step_prev_ts_ns_d)
|
||
.arg(&tick_size).arg(&n_total_i32)
|
||
.arg(self.window_step_d.data_mut());
|
||
launch.launch(snap_cfg).context("step snap_feature_assemble")?;
|
||
}
|
||
|
||
// ── 4. VSN fwd: [B, FEATURE_DIM] → [B, FEATURE_DIM] (gated).
|
||
{
|
||
let n_rows_vsn: i32 = b_sz as i32;
|
||
let cfg_vsn = LaunchConfig {
|
||
grid_dim: (n_rows_vsn as u32, 1, 1),
|
||
block_dim: (64, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.trunk.vsn_fwd_fn);
|
||
launch
|
||
.arg(&self.trunk.vsn_w_d)
|
||
.arg(&self.trunk.vsn_b_d)
|
||
.arg(self.window_step_d.cuda_data())
|
||
.arg(&n_rows_vsn)
|
||
.arg(self.vsn_step_out_d.data_mut())
|
||
.arg(&mut self.vsn_step_gates_d);
|
||
unsafe { launch.launch(cfg_vsn).context("step variable_selection_fwd")?; }
|
||
}
|
||
|
||
// ── 5. Mamba2 L1 step — advances step_scratch_l1.x_state in-place.
|
||
self.trunk
|
||
.mamba2_l1_mut()
|
||
.step_into(&self.vsn_step_out_d, &mut self.step_scratch_l1)
|
||
.context("step mamba2 (l1) step_into")?;
|
||
|
||
// ── 6. LN_a fwd on 1 row.
|
||
{
|
||
let n_rows_ln: i32 = b_sz as i32;
|
||
let cfg_ln = LaunchConfig {
|
||
grid_dim: (n_rows_ln as u32, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.trunk.ln_fwd_fn);
|
||
launch
|
||
.arg(self.step_scratch_l1.h_out.cuda_data())
|
||
.arg(&self.trunk.ln_a_gain_d)
|
||
.arg(&self.trunk.ln_a_bias_d)
|
||
.arg(&n_rows_ln)
|
||
.arg(&mut self.ln_a_step_out_d)
|
||
.arg(&mut self.ln_a_step_stats_d);
|
||
unsafe { launch.launch(cfg_ln).context("step LN_a fwd")?; }
|
||
}
|
||
|
||
// ── 7. Mamba2 L2 step — input is ln_a_step_out wrapped as GpuTensor.
|
||
// Reusing the existing ln_a_step_out_d buffer; the L2 step
|
||
// kernel will read [B, HIDDEN_DIM] (single-row).
|
||
{
|
||
// Build a temporary view of ln_a_step_out_d as a [B, HIDDEN_DIM]
|
||
// GpuTensor for step_into's input shape check.
|
||
let ln_a_view = GpuTensor::new(
|
||
self.ln_a_step_out_d.clone(),
|
||
vec![b_sz, HIDDEN_DIM],
|
||
).map_err(|e| anyhow::anyhow!("ln_a_view wrap: {e}"))?;
|
||
self.trunk
|
||
.mamba2_l2_mut()
|
||
.step_into(&ln_a_view, &mut self.step_scratch_l2)
|
||
.context("step mamba2 (l2) step_into")?;
|
||
}
|
||
|
||
// ── 8. LN_b fwd on 1 row.
|
||
{
|
||
let n_rows_ln: i32 = b_sz as i32;
|
||
let cfg_ln = LaunchConfig {
|
||
grid_dim: (n_rows_ln as u32, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.trunk.ln_fwd_fn);
|
||
launch
|
||
.arg(self.step_scratch_l2.h_out.cuda_data())
|
||
.arg(&self.trunk.ln_b_gain_d)
|
||
.arg(&self.trunk.ln_b_bias_d)
|
||
.arg(&n_rows_ln)
|
||
.arg(&mut self.ln_b_step_out_d)
|
||
.arg(&mut self.ln_b_step_stats_d);
|
||
unsafe { launch.launch(cfg_ln).context("step LN_b fwd")?; }
|
||
}
|
||
|
||
// ── 9. Phase 2 fused per-branch CfC step.
|
||
//
|
||
// Per spec §2.4 (per-horizon CfC inference) and Task 13: production
|
||
// checkpoints always have Phase 2 routing frozen at the training-time
|
||
// Phase 1→2 transition. Inference therefore unconditionally dispatches
|
||
// the per-branch fused kernel (`cfc_step_per_branch_fwd`) instead of
|
||
// the single-CfC `cfc_step_batched` used during Phase 1 warmup.
|
||
//
|
||
// The fused kernel reads `bucket_channel_offset_d` / `bucket_dim_k_d`
|
||
// from the trunk; in deployment these are populated via
|
||
// `CfcTrunk::load_checkpoint` (cfc/trunk.rs:566-567). For freshly-
|
||
// constructed trainers (no checkpoint), the trunk fields are zero-
|
||
// initialised — the kernel's uniform predicate (`tid >= bucket_dim_k`)
|
||
// then early-returns every thread and h_new is left untouched. That
|
||
// matches the "Phase 2 only at inference" contract: callers without
|
||
// populated routing metadata are NOT on the production deployment
|
||
// path.
|
||
//
|
||
// dt = 1.0 per event-rate inference (A1: decision_stride removed).
|
||
// n_batch_i kept here because the heads GRN kernel below also reads it.
|
||
let dt_s: f32 = 1.0;
|
||
let n_batch_i: i32 = b_sz as i32;
|
||
crate::cfc::step::cfc_step_per_branch_fwd_gpu(
|
||
&self.stream,
|
||
&self.trunk.cfc_step_per_branch_fwd_fn,
|
||
&self.trunk.w_in_d,
|
||
&self.trunk.w_rec_d,
|
||
&self.trunk.b_d,
|
||
&self.trunk.tau_all_d,
|
||
&self.ln_b_step_out_d,
|
||
&self.cfc_h_state_step_d,
|
||
dt_s,
|
||
n_batch_i,
|
||
&self.trunk.bucket_channel_offset_d,
|
||
&self.trunk.bucket_dim_k_d,
|
||
&mut self.cfc_h_new_step_d,
|
||
).context("forward_step_into cfc_step_per_branch_fwd")?;
|
||
|
||
// Carry-forward: h_new → h_state (in-place via DtoD).
|
||
unsafe {
|
||
let s = self.stream.cu_stream();
|
||
let (src, _gs) = self.cfc_h_new_step_d.device_ptr(&self.stream);
|
||
let (dst, _gd) = self.cfc_h_state_step_d.device_ptr_mut(&self.stream);
|
||
let nbytes = b_sz * HIDDEN_DIM * std::mem::size_of::<f32>();
|
||
cudarc::driver::result::memcpy_dtod_async(dst, src, nbytes, s)
|
||
.context("cfc h_state carry-forward dtod")?;
|
||
}
|
||
|
||
// ── 10. Heads GRN fwd on the new h.
|
||
//
|
||
// The heads dispatch stays at the existing GRN kernel for both phases.
|
||
// Per the Task 10 follow-up commit (block-diagonal heads via grad-mask),
|
||
// `trunk.heads_w_skip_d` is sparsified at the Phase 1→2 transition:
|
||
// off-bucket entries are zeroed in place, and the per-step grad mask
|
||
// (applied in `step_batched` after the optimizer step) keeps those
|
||
// zeros frozen. Mathematically the GRN kernel's read of the full
|
||
// [N_HORIZONS × HIDDEN_DIM] `heads_w_skip_d` is equivalent to a
|
||
// compact-only read of each horizon's bucket slice — no inference-
|
||
// time kernel change required.
|
||
let cfg_grn_fwd = LaunchConfig {
|
||
grid_dim: (b_sz as u32, 1, 1),
|
||
block_dim: (HEAD_MID_DIM as u32, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
unsafe {
|
||
let mut launch = self.stream.launch_builder(&self.trunk.heads_grn_fwd_fn);
|
||
launch
|
||
.arg(&self.trunk.heads_w1_d).arg(&self.trunk.heads_b1_d)
|
||
.arg(&self.trunk.heads_w2_d).arg(&self.trunk.heads_b2_d)
|
||
.arg(&self.trunk.heads_w_gate_d).arg(&self.trunk.heads_b_gate_d)
|
||
.arg(&self.trunk.heads_w_main_d).arg(&self.trunk.heads_b_main_d)
|
||
.arg(&self.trunk.heads_w_skip_d).arg(&self.trunk.heads_b_skip_d)
|
||
.arg(&self.cfc_h_new_step_d).arg(&n_batch_i)
|
||
// Probs go directly to the caller-supplied device buffer —
|
||
// no host staging, no synchronize. The next consumer (the
|
||
// sim's decision kernels) reads from this same device slice
|
||
// and is launched on the same stream so it is stream-ordered.
|
||
.arg(alpha_probs_dst)
|
||
.arg(&mut self.z1_step_d).arg(&mut self.a1_step_d).arg(&mut self.z2_step_d)
|
||
.arg(&mut self.gate_logit_step_d).arg(&mut self.main_step_d).arg(&mut self.logit_step_d);
|
||
launch.launch(cfg_grn_fwd).context("step heads GRN")?;
|
||
}
|
||
// No DtoD-to-host, no stream.synchronize, no host read. The probs
|
||
// remain on-device for the consumer (decision kernels). The CPU
|
||
// never touches them on the hot path per
|
||
// pearl/feedback_cpu_is_read_only.
|
||
Ok(())
|
||
}
|
||
|
||
/// Test-only convenience: drive `forward_step_into` against a private
|
||
/// device buffer and DtoH-sync the probs into a host array. NOT part
|
||
/// of the production hot path — production callers MUST use
|
||
/// `forward_step_into(snapshot, &mut alpha_probs_dst)` and keep the
|
||
/// probs on-device. This helper exists for integration tests that
|
||
/// previously called the old `forward_step(snap) -> [f32; N]` API and
|
||
/// want to assert on host values; it allocates a one-shot device
|
||
/// buffer, runs forward_step_into into it, then DtoVs once.
|
||
///
|
||
/// Per-call DtoV stops the stream; do NOT use on the per-event hot
|
||
/// path. The function name carries `_returning` as a visible flag for
|
||
/// reviewers that a host roundtrip happens here.
|
||
pub fn forward_step_into_returning(
|
||
&mut self,
|
||
snapshot: &Mbp10RawInput,
|
||
) -> Result<[f32; N_HORIZONS]> {
|
||
let b_sz = self.cfg.n_batch;
|
||
anyhow::ensure!(
|
||
b_sz == 1,
|
||
"forward_step_into_returning supports n_batch == 1 only (got {})",
|
||
b_sz
|
||
);
|
||
let mut probs_d = self.stream
|
||
.alloc_zeros::<f32>(N_HORIZONS)
|
||
.map_err(|e| anyhow::anyhow!("test probs_d alloc: {e}"))?;
|
||
self.forward_step_into(snapshot, &mut probs_d)?;
|
||
let mut host = vec![0.0_f32; N_HORIZONS];
|
||
self.stream
|
||
.memcpy_dtoh(&probs_d, host.as_mut_slice())
|
||
.map_err(|e| anyhow::anyhow!("test probs DtoH: {e}"))?;
|
||
let mut out = [0.0_f32; N_HORIZONS];
|
||
for h in 0..N_HORIZONS {
|
||
out[h] = host[h];
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// CRT Phase A0.5: zero the forward_step persistent state — SSM
|
||
/// register state in both Mamba2 step scratches plus the CfC hidden
|
||
/// state — and drop accumulated context. Used by the harness on
|
||
/// session-gap detection so per-day boundaries don't leak state.
|
||
///
|
||
/// Exposed but NOT yet wired into BacktestHarness — session-gap
|
||
/// integration is a downstream task. After A1 deletes the stride
|
||
/// gate, A2 / A3 can pick the call site that fits the harness's
|
||
/// session-gap detection (already present for the max-hold path).
|
||
pub fn reset_step_state(&mut self) -> Result<()> {
|
||
self.step_scratch_l1.reset_state(&self.stream)
|
||
.context("reset step_scratch_l1")?;
|
||
self.step_scratch_l2.reset_state(&self.stream)
|
||
.context("reset step_scratch_l2")?;
|
||
self.stream.memset_zeros(&mut self.cfc_h_state_step_d)
|
||
.map_err(|e| anyhow::anyhow!("reset cfc_h_state_step_d: {e}"))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// CRT Phase A0.5 corrective (concern #1 from a0e81fbdf): bit-identical
|
||
/// seeding from forward_only.
|
||
///
|
||
/// After this call, the step-path persistent state (
|
||
/// step_scratch_l1.x_state,
|
||
/// step_scratch_l2.x_state,
|
||
/// cfc_h_state_step_d
|
||
/// ) reproduces the internal state forward_only had at the end of
|
||
/// processing `window` (K snapshots). Subsequent `forward_step_into`
|
||
/// calls then continue the same trajectory bit-identically to where
|
||
/// forward_only left off, modulo:
|
||
/// - The cuBLAS W_in/W_a/W_b GEMM bit-identity for the K-batched seq
|
||
/// path (one GEMM over [B*K, *]) is what we capture; the scan_fwd_step
|
||
/// replay below reads those projections and produces bit-identical
|
||
/// x_state by construction (same arithmetic, same per-step order).
|
||
/// - The CfC h_state is a DtoD copy of h_new_per_k_d at position K-1
|
||
/// — also bit-identical.
|
||
///
|
||
/// Implementation:
|
||
/// 1. Run `forward_only(window)` — populates mamba2_fwd_scratch.a_proj/b_proj
|
||
/// + mamba2_l2_fwd_scratch.a_proj/b_proj (via batched GEMM over K rows)
|
||
/// + h_new_per_k_d (the CfC K-loop output at every position).
|
||
/// 2. Zero step scratches so the scan_fwd_step replay starts from x=0,
|
||
/// matching scan_fwd_seq's `float x[32] = {0}` initialisation.
|
||
/// 3. For each k=0..K, launch `step_advance_from_seq_row` on L1 reading
|
||
/// a_proj/b_proj at row k. After K calls, step_scratch_l1.x_state
|
||
/// matches scan_fwd_seq's terminal register state for L1.
|
||
/// 4. Same for L2.
|
||
/// 5. DtoD copy h_new_per_k_d at slot K-1 (i.e., the cfc state that
|
||
/// would feed snapshot K if there were one) into cfc_h_state_step_d.
|
||
///
|
||
/// Per memo §4.5 option (a) — extracts forward_only's terminal state
|
||
/// rather than re-introducing the attention pool on the step path.
|
||
pub fn seed_step_state_from_forward_only(
|
||
&mut self,
|
||
window: &[Mbp10RawInput],
|
||
) -> Result<()> {
|
||
let b_sz = self.cfg.n_batch;
|
||
let k_seq = self.cfg.seq_len;
|
||
anyhow::ensure!(
|
||
b_sz == 1,
|
||
"seed_step_state_from_forward_only currently supports n_batch == 1 only (got {})",
|
||
b_sz
|
||
);
|
||
anyhow::ensure!(
|
||
window.len() == b_sz * k_seq,
|
||
"seed window: expected {} snapshots (B={} * K={}); got {}",
|
||
b_sz * k_seq, b_sz, k_seq, window.len()
|
||
);
|
||
|
||
// Step 1: forward_only populates a_proj/b_proj scratches + h_new_per_k_d.
|
||
// We discard the returned probs vec; we only need the side-effect on
|
||
// the trainer's internal scratches.
|
||
let _probs = self.forward_only(window)?;
|
||
|
||
// Step 2: zero step scratches so the scan_fwd_step replay starts
|
||
// from x[s]=0 — matches `mamba2_alpha_scan_fwd_seq`'s register init.
|
||
self.step_scratch_l1.reset_state(&self.stream)
|
||
.context("seed: reset step_scratch_l1 x_state")?;
|
||
self.step_scratch_l2.reset_state(&self.stream)
|
||
.context("seed: reset step_scratch_l2 x_state")?;
|
||
|
||
// Step 3+4: replay K scan_fwd_step calls for L1 then L2. The
|
||
// a_proj/b_proj scratches are sized [B*K, state_dim] in the seq
|
||
// path — row k (with B=1) sits at byte offset k * state_dim * 4.
|
||
let state_dim_l1 = self.mamba2_fwd_scratch.state_dim;
|
||
let row_bytes_l1 = state_dim_l1 * std::mem::size_of::<f32>();
|
||
let (a_l1_base, _g_a_l1) =
|
||
self.mamba2_fwd_scratch.a_proj.cuda_data().device_ptr(&self.stream);
|
||
let (b_l1_base, _g_b_l1) =
|
||
self.mamba2_fwd_scratch.b_proj.cuda_data().device_ptr(&self.stream);
|
||
for k in 0..k_seq {
|
||
let a_row = a_l1_base + (k * row_bytes_l1) as u64;
|
||
let b_row = b_l1_base + (k * row_bytes_l1) as u64;
|
||
self.trunk
|
||
.mamba2_l1()
|
||
.step_advance_from_seq_row(a_row, b_row, &mut self.step_scratch_l1)
|
||
.with_context(|| format!("seed: L1 step at k={k}"))?;
|
||
}
|
||
drop((_g_a_l1, _g_b_l1));
|
||
|
||
let state_dim_l2 = self.mamba2_l2_fwd_scratch.state_dim;
|
||
let row_bytes_l2 = state_dim_l2 * std::mem::size_of::<f32>();
|
||
let (a_l2_base, _g_a_l2) =
|
||
self.mamba2_l2_fwd_scratch.a_proj.cuda_data().device_ptr(&self.stream);
|
||
let (b_l2_base, _g_b_l2) =
|
||
self.mamba2_l2_fwd_scratch.b_proj.cuda_data().device_ptr(&self.stream);
|
||
for k in 0..k_seq {
|
||
let a_row = a_l2_base + (k * row_bytes_l2) as u64;
|
||
let b_row = b_l2_base + (k * row_bytes_l2) as u64;
|
||
self.trunk
|
||
.mamba2_l2()
|
||
.step_advance_from_seq_row(a_row, b_row, &mut self.step_scratch_l2)
|
||
.with_context(|| format!("seed: L2 step at k={k}"))?;
|
||
}
|
||
drop((_g_a_l2, _g_b_l2));
|
||
|
||
// Step 5: copy h_new_per_k_d at slot K-1 into cfc_h_state_step_d.
|
||
// forward_only stored CfC's per-position h_new in h_new_per_k_d
|
||
// laid out as [K, B, HIDDEN_DIM]. Slot K-1 is the LAST cfc h_new —
|
||
// i.e., the value that would feed CfC position K if there were one.
|
||
// That's exactly the h_old forward_step_into expects on its next call.
|
||
let row_bytes_cfc = b_sz * HIDDEN_DIM * std::mem::size_of::<f32>();
|
||
unsafe {
|
||
let s = self.stream.cu_stream();
|
||
let (src_base, _gs) = self.h_new_per_k_d.device_ptr(&self.stream);
|
||
let src_at_last = src_base + ((k_seq - 1) * row_bytes_cfc) as u64;
|
||
let (dst, _gd) = self.cfc_h_state_step_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(dst, src_at_last, row_bytes_cfc, s)
|
||
.context("seed: copy h_new_per_k[K-1] → cfc_h_state_step")?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ── Test-only readback helpers ────────────────────────────────────────
|
||
//
|
||
// These perform one-shot DtoH copies acceptable in test code.
|
||
// NEVER call on the hot path.
|
||
|
||
/// Read `step_scratch_l1.x_state` back to host. Test-only; DtoH+sync.
|
||
pub fn read_step_l1_x_state(&self) -> Result<Vec<f32>> {
|
||
self.step_scratch_l1.read_x_state(&self.stream)
|
||
}
|
||
|
||
/// Read `step_scratch_l2.x_state` back to host. Test-only; DtoH+sync.
|
||
pub fn read_step_l2_x_state(&self) -> Result<Vec<f32>> {
|
||
self.step_scratch_l2.read_x_state(&self.stream)
|
||
}
|
||
|
||
/// Read `cfc_h_state_step_d` back to host. Test-only; DtoH+sync.
|
||
pub fn read_cfc_h_state_step(&self) -> Result<Vec<f32>> {
|
||
let n = self.cfc_h_state_step_d.len();
|
||
let mut host = vec![0.0_f32; n];
|
||
self.stream
|
||
.memcpy_dtoh(&self.cfc_h_state_step_d, &mut host)
|
||
.map_err(|e| anyhow::anyhow!("read_cfc_h_state_step dtoh: {e}"))?;
|
||
self.stream
|
||
.synchronize()
|
||
.map_err(|e| anyhow::anyhow!("read_cfc_h_state_step sync: {e}"))?;
|
||
Ok(host)
|
||
}
|
||
|
||
/// Read the K-1 slot of `h_new_per_k_d` — the CfC h_new at the
|
||
/// final position of the last `forward_only` call. Layout is
|
||
/// `[K, B, HIDDEN_DIM]`; slot K-1 = `[(K-1)*B*HIDDEN_DIM ..]`.
|
||
/// Test-only; DtoH+sync via mapped-pinned staging.
|
||
pub fn read_h_new_per_k_last(&self) -> Result<Vec<f32>> {
|
||
let b_sz = self.cfg.n_batch;
|
||
let k_seq = self.cfg.seq_len;
|
||
let n_hid = HIDDEN_DIM;
|
||
let slot_floats = b_sz * n_hid;
|
||
let slot_bytes = slot_floats * std::mem::size_of::<f32>();
|
||
let staging = unsafe { crate::pinned_mem::MappedF32Buffer::new(slot_floats) }
|
||
.map_err(|e| anyhow::anyhow!("h_new_per_k_last staging alloc: {e}"))?;
|
||
unsafe {
|
||
let s = self.stream.cu_stream();
|
||
let (base, _g) = self.h_new_per_k_d.device_ptr(&self.stream);
|
||
let src = base + ((k_seq - 1) * slot_bytes) as u64;
|
||
cudarc::driver::result::memcpy_dtod_async(staging.dev_ptr, src, slot_bytes, s)
|
||
.map_err(|e| anyhow::anyhow!("h_new_per_k_last DtoD: {e}"))?;
|
||
}
|
||
self.stream
|
||
.synchronize()
|
||
.map_err(|e| anyhow::anyhow!("h_new_per_k_last sync: {e}"))?;
|
||
Ok(staging.read_all())
|
||
}
|
||
|
||
/// X11 checkpoint-loaded constructor: instantiates a PerceptionTrainer
|
||
/// from a Checkpoint file, ready for `forward_only` inference. The
|
||
/// optimizer state + gradient buffers ARE allocated (training-only
|
||
/// state); they're unused at inference but allocating them keeps
|
||
/// the struct invariant simple. For pure-inference deployments where
|
||
/// the memory matters, a leaner "InferenceOnly" variant can be added
|
||
/// later.
|
||
pub fn from_checkpoint(
|
||
dev: &MlDevice,
|
||
cfg: &PerceptionTrainerConfig,
|
||
path: &std::path::Path,
|
||
) -> Result<Self> {
|
||
// Construct a fresh trainer (with random init) to wire up all
|
||
// grad buffers + kernel handles + scratches, then overwrite the
|
||
// trunk's weights from the checkpoint via load_checkpoint.
|
||
let mut trainer = Self::new(dev, cfg)?;
|
||
let trunk_cfg = crate::cfc::trunk::CfcConfig {
|
||
n_in: FEATURE_DIM,
|
||
n_hid: HIDDEN_DIM,
|
||
cfc_n_in: HIDDEN_DIM,
|
||
mamba2_state_dim: cfg.mamba2_state_dim,
|
||
n_batch: cfg.n_batch,
|
||
seq_len: cfg.seq_len,
|
||
};
|
||
let new_trunk = crate::cfc::trunk::CfcTrunk::load_checkpoint(dev, &trunk_cfg, path)
|
||
.context("PerceptionTrainer::from_checkpoint: trunk load")?;
|
||
trainer.trunk = new_trunk;
|
||
Ok(trainer)
|
||
}
|
||
|
||
pub fn evaluate(
|
||
&mut self,
|
||
snapshots: &[Mbp10RawInput],
|
||
labels_per_position: &[[f32; N_HORIZONS]],
|
||
) -> Result<(f32, Vec<f32>)> {
|
||
anyhow::ensure!(
|
||
self.cfg.n_batch == 1,
|
||
"single-sequence evaluate() requires cfg.n_batch == 1; got {}",
|
||
self.cfg.n_batch
|
||
);
|
||
self.evaluate_batched(&[snapshots], &[labels_per_position])
|
||
}
|
||
|
||
/// Forward-only batched evaluation pass. Mirrors the forward chain
|
||
/// of [`step_batched`] but SKIPS backward + grad accumulation +
|
||
/// AdamW. Validation MUST use this — calling step() on val data
|
||
/// leaks the val gradient into trained weights.
|
||
///
|
||
/// Returns (weighted mean BCE, probs[K * B * N_HORIZONS] in
|
||
/// [K, B, N_HORIZONS] row-major layout).
|
||
pub fn evaluate_batched(
|
||
&mut self,
|
||
snapshots_batch: &[&[Mbp10RawInput]],
|
||
labels_batch: &[&[[f32; N_HORIZONS]]],
|
||
) -> Result<(f32, Vec<f32>)> {
|
||
let b_sz = self.cfg.n_batch;
|
||
let k_seq = self.cfg.seq_len;
|
||
anyhow::ensure!(
|
||
snapshots_batch.len() == b_sz && labels_batch.len() == b_sz,
|
||
"expected B={} snapshots+labels; got snap={} lbl={}",
|
||
b_sz, snapshots_batch.len(), labels_batch.len()
|
||
);
|
||
|
||
// Use pre-allocated window_tensor_d — no per-step alloc.
|
||
debug_assert_eq!(self.window_tensor_d.shape(), &[b_sz, k_seq, FEATURE_DIM]);
|
||
let total_snaps = b_sz * k_seq;
|
||
debug_assert!(total_snaps <= self.bk_capacity);
|
||
{
|
||
let bid_px_h = self.stg_bid_px_all.host_slice_mut();
|
||
let bid_sz_h = self.stg_bid_sz_all.host_slice_mut();
|
||
let ask_px_h = self.stg_ask_px_all.host_slice_mut();
|
||
let ask_sz_h = self.stg_ask_sz_all.host_slice_mut();
|
||
let regime_h = self.stg_regime_all.host_slice_mut();
|
||
for (b_idx, snapshots) in snapshots_batch.iter().enumerate() {
|
||
for (k, snap) in snapshots.iter().enumerate() {
|
||
let n = b_idx * k_seq + k;
|
||
let base10 = n * 10;
|
||
let base6 = n * REGIME_DIM;
|
||
for i in 0..10 {
|
||
bid_px_h[base10 + i] = snap.bid_px[i];
|
||
bid_sz_h[base10 + i] = snap.bid_sz[i];
|
||
ask_px_h[base10 + i] = snap.ask_px[i];
|
||
ask_sz_h[base10 + i] = snap.ask_sz[i];
|
||
}
|
||
for i in 0..REGIME_DIM {
|
||
regime_h[base6 + i] = snap.regime[i];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
{
|
||
let prev_mid_h = self.stg_prev_mid.host_slice_mut();
|
||
let tsv_h = self.stg_trade_signed_vol.host_slice_mut();
|
||
let tc_h = self.stg_trade_count.host_slice_mut();
|
||
let ts_ns_h = self.stg_ts_ns.host_slice_mut();
|
||
let prev_ts_ns_h = self.stg_prev_ts_ns.host_slice_mut();
|
||
for (b_idx, snapshots) in snapshots_batch.iter().enumerate() {
|
||
for (k, snap) in snapshots.iter().enumerate() {
|
||
let n = b_idx * k_seq + k;
|
||
prev_mid_h[n] = snap.prev_mid;
|
||
tsv_h[n] = snap.trade_signed_vol;
|
||
tc_h[n] = snap.trade_count as i32;
|
||
ts_ns_h[n] = snap.ts_ns as i64;
|
||
prev_ts_ns_h[n] = snap.prev_ts_ns as i64;
|
||
}
|
||
}
|
||
}
|
||
let n10 = total_snaps * 10;
|
||
let n6 = total_snaps * REGIME_DIM;
|
||
let n1 = total_snaps;
|
||
unsafe {
|
||
let s = self.stream.cu_stream();
|
||
let (d, _g) = self.bid_px_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_bid_px_all.dev_ptr, n10 * 4, s)
|
||
.context("eval bid_px_all dtod")?;
|
||
let (d, _g) = self.bid_sz_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_bid_sz_all.dev_ptr, n10 * 4, s)
|
||
.context("eval bid_sz_all dtod")?;
|
||
let (d, _g) = self.ask_px_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_ask_px_all.dev_ptr, n10 * 4, s)
|
||
.context("eval ask_px_all dtod")?;
|
||
let (d, _g) = self.ask_sz_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_ask_sz_all.dev_ptr, n10 * 4, s)
|
||
.context("eval ask_sz_all dtod")?;
|
||
let (d, _g) = self.regime_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_regime_all.dev_ptr, n6 * 4, s)
|
||
.context("eval regime_all dtod")?;
|
||
let (d, _g) = self.prev_mid_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_prev_mid.dev_ptr, n1 * 4, s)
|
||
.context("eval prev_mid_all dtod")?;
|
||
let (d, _g) = self.trade_signed_vol_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_trade_signed_vol.dev_ptr, n1 * 4, s)
|
||
.context("eval tsv_all dtod")?;
|
||
let (d, _g) = self.trade_count_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_trade_count.dev_ptr, n1 * 4, s)
|
||
.context("eval trade_count_all dtod")?;
|
||
let (d, _g) = self.ts_ns_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_ts_ns.dev_ptr, n1 * 8, s)
|
||
.context("eval ts_ns_all dtod")?;
|
||
let (d, _g) = self.prev_ts_ns_all_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(d, self.stg_prev_ts_ns.dev_ptr, n1 * 8, s)
|
||
.context("eval prev_ts_ns_all dtod")?;
|
||
}
|
||
let tick_size = ES_TICK_SIZE;
|
||
let n_total_i32 = total_snaps as i32;
|
||
let snap_block: u32 = 128;
|
||
let snap_grid: u32 = (total_snaps as u32).div_ceil(snap_block);
|
||
let snap_cfg = LaunchConfig {
|
||
grid_dim: (snap_grid, 1, 1),
|
||
block_dim: (snap_block, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
unsafe {
|
||
let mut launch = self.stream.launch_builder(&self.trunk.snap_batched_fn);
|
||
launch
|
||
.arg(&self.bid_px_all_d).arg(&self.bid_sz_all_d)
|
||
.arg(&self.ask_px_all_d).arg(&self.ask_sz_all_d)
|
||
.arg(&self.prev_bid_sz_all_d).arg(&self.prev_ask_sz_all_d)
|
||
.arg(&self.regime_all_d)
|
||
.arg(&self.prev_mid_all_d).arg(&self.trade_signed_vol_all_d)
|
||
.arg(&self.trade_count_all_d)
|
||
.arg(&self.ts_ns_all_d).arg(&self.prev_ts_ns_all_d)
|
||
.arg(&tick_size).arg(&n_total_i32)
|
||
.arg(self.window_tensor_d.data_mut());
|
||
launch.launch(snap_cfg).context("eval snap_batched fwd")?;
|
||
}
|
||
self.stream.synchronize().context("eval snap_batched sync")?;
|
||
|
||
// VSN fwd — same as training path. Eval consumes vsn_out_d.
|
||
{
|
||
let n_rows_vsn: i32 = (b_sz * k_seq) as i32;
|
||
let cfg_vsn = LaunchConfig {
|
||
grid_dim: (n_rows_vsn as u32, 1, 1),
|
||
block_dim: (64, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.trunk.vsn_fwd_fn);
|
||
launch
|
||
.arg(&self.trunk.vsn_w_d)
|
||
.arg(&self.trunk.vsn_b_d)
|
||
.arg(self.window_tensor_d.cuda_data())
|
||
.arg(&n_rows_vsn)
|
||
.arg(self.vsn_out_d.data_mut())
|
||
.arg(&mut self.vsn_gates_d);
|
||
unsafe { launch.launch(cfg_vsn).context("eval variable_selection_fwd")?; }
|
||
}
|
||
|
||
// Mamba2 stack-1 fwd → m1.h_enriched_seq.
|
||
self.trunk
|
||
.mamba2_l1_mut()
|
||
.forward_train_seq_into(&self.vsn_out_d, &mut self.mamba2_fwd_scratch)
|
||
.context("eval mamba2 (l1) fwd_into")?;
|
||
|
||
// LN_a forward → ln_a_out_d.
|
||
{
|
||
let n_rows_ln: i32 = (b_sz * k_seq) as i32;
|
||
let cfg_ln = LaunchConfig {
|
||
grid_dim: (n_rows_ln as u32, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.trunk.ln_fwd_fn);
|
||
launch
|
||
.arg(self.mamba2_fwd_scratch.h_enriched_seq.cuda_data())
|
||
.arg(&self.trunk.ln_a_gain_d)
|
||
.arg(&self.trunk.ln_a_bias_d)
|
||
.arg(&n_rows_ln)
|
||
.arg(self.ln_a_out_d.data_mut())
|
||
.arg(&mut self.ln_a_stats_d);
|
||
unsafe { launch.launch(cfg_ln).context("eval layer_norm_fwd (LN_a)")?; }
|
||
}
|
||
|
||
// Mamba2 stack-2 fwd → m2.h_enriched_seq.
|
||
self.trunk
|
||
.mamba2_l2_mut()
|
||
.forward_train_seq_into(&self.ln_a_out_d, &mut self.mamba2_l2_fwd_scratch)
|
||
.context("eval mamba2 (l2) fwd_into")?;
|
||
|
||
// LN_b forward over m2.h_enriched_seq — same as training path so
|
||
// eval reads the same distribution downstream layers were
|
||
// trained on.
|
||
{
|
||
let n_rows_ln: i32 = (b_sz * k_seq) as i32;
|
||
let cfg_ln = LaunchConfig {
|
||
grid_dim: (n_rows_ln as u32, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.trunk.ln_fwd_fn);
|
||
launch
|
||
.arg(self.mamba2_l2_fwd_scratch.h_enriched_seq.cuda_data())
|
||
.arg(&self.trunk.ln_b_gain_d)
|
||
.arg(&self.trunk.ln_b_bias_d)
|
||
.arg(&n_rows_ln)
|
||
.arg(&mut self.ln_out_d)
|
||
.arg(&mut self.ln_stats_d);
|
||
unsafe { launch.launch(cfg_ln).context("eval layer_norm_fwd (LN_b)")?; }
|
||
}
|
||
|
||
// Transpose LN-normalised h_enriched [B, K, H] → [K, B, H].
|
||
{
|
||
let block_n3: u32 = 32;
|
||
let grid_z = (HIDDEN_DIM as u32).div_ceil(block_n3);
|
||
let cfg_tx = LaunchConfig {
|
||
grid_dim: (b_sz as u32, k_seq as u32, grid_z),
|
||
block_dim: (block_n3, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let n1 = b_sz as i32;
|
||
let n2 = k_seq as i32;
|
||
let n3 = HIDDEN_DIM as i32;
|
||
let mut launch = self.stream.launch_builder(&self.trunk.transpose_3d_fn);
|
||
launch
|
||
.arg(&self.ln_out_d)
|
||
.arg(self.h_enriched_seq_t_d.data_mut())
|
||
.arg(&n1).arg(&n2).arg(&n3);
|
||
unsafe { launch.launch(cfg_tx).context("eval transpose h_enriched")?; }
|
||
}
|
||
|
||
// Attention pool fwd — same as training, populates attn_context_d
|
||
// for use as k=0 h_old in the eval K-loop below.
|
||
{
|
||
let k_i32 = k_seq as i32;
|
||
let n_batch_attn = b_sz as i32;
|
||
let shared = (k_seq + 128 + HIDDEN_DIM) * std::mem::size_of::<f32>();
|
||
let cfg_attn = LaunchConfig {
|
||
grid_dim: (b_sz as u32, 1, 1),
|
||
block_dim: (128, 1, 1),
|
||
shared_mem_bytes: shared as u32,
|
||
};
|
||
let mut launch = self.stream.launch_builder(&self.trunk.attn_fwd_fn);
|
||
launch
|
||
.arg(&self.trunk.attn_q_d)
|
||
.arg(&self.ln_out_d)
|
||
.arg(&n_batch_attn).arg(&k_i32)
|
||
.arg(&mut self.attn_context_d)
|
||
.arg(&mut self.attn_weights_d);
|
||
unsafe { launch.launch(cfg_attn).context("eval attention_pool_fwd")?; }
|
||
}
|
||
|
||
// Upload labels [K, B, N_HORIZONS].
|
||
let total_labels = k_seq * b_sz * N_HORIZONS;
|
||
{
|
||
let dst = self.stg_labels.host_slice_mut();
|
||
for k in 0..k_seq {
|
||
for b_idx in 0..b_sz {
|
||
for h in 0..N_HORIZONS {
|
||
dst[(k * b_sz + b_idx) * N_HORIZONS + h] = labels_batch[b_idx][k][h];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
unsafe {
|
||
let (dst_ptr, _g) = self.labels_per_k_d.device_ptr_mut(&self.stream);
|
||
cudarc::driver::result::memcpy_dtod_async(
|
||
dst_ptr, self.stg_labels.dev_ptr,
|
||
total_labels * std::mem::size_of::<f32>(), self.stream.cu_stream(),
|
||
).context("eval labels upload")?;
|
||
}
|
||
self.stream.memset_zeros(&mut self.zero_h_d).map_err(|e| anyhow::anyhow!("zero h: {e}"))?;
|
||
|
||
// Per-K forward (recurrent CfC + heads, batched).
|
||
// A1: decision_stride removed; event-rate dt = 1.0 per CfC step.
|
||
let dt_s = 1.0_f32;
|
||
let n_in_i = HIDDEN_DIM as i32;
|
||
let n_hid_i = HIDDEN_DIM as i32;
|
||
// Phase B fix-up: block-per-batch (Phase B), same launch contract as
|
||
// training's K-loop. BEFORE this fix the eval path used the legacy
|
||
// grid=(1,1,1) config which only processed batch 0 — batches
|
||
// 1..B-1 got GARBAGE h_new. Caught by t6z89-vs-txftz cluster A/B
|
||
// (val_loss=0.83, mean_auc=chance) on 2026-05-17.
|
||
// Shared layout: s_x [n_in] + s_h_old [n_hid].
|
||
let cfc_fwd_smem = (2 * HIDDEN_DIM * std::mem::size_of::<f32>()) as u32;
|
||
let cfg_cfc = LaunchConfig {
|
||
grid_dim: (b_sz as u32, 1, 1),
|
||
block_dim: (HIDDEN_DIM as u32, 1, 1),
|
||
shared_mem_bytes: cfc_fwd_smem,
|
||
};
|
||
let cfg_grn_fwd = LaunchConfig {
|
||
grid_dim: (b_sz as u32, 1, 1),
|
||
block_dim: (HEAD_MID_DIM as u32, 1, 1),
|
||
shared_mem_bytes: 0,
|
||
};
|
||
let kb_hid_bytes = b_sz * HIDDEN_DIM * std::mem::size_of::<f32>();
|
||
let kb_nh_bytes = b_sz * N_HORIZONS * std::mem::size_of::<f32>();
|
||
let kb_nh_mid_bytes = b_sz * N_HORIZONS * HEAD_MID_DIM * std::mem::size_of::<f32>();
|
||
let n_batch_i = b_sz as i32;
|
||
let _zero_h_ptr_unused_eval = {
|
||
let (p, _g) = self.zero_h_d.device_ptr(&self.stream);
|
||
p
|
||
};
|
||
// Phase 3: eval uses attn_context_d as h_old at k=0, mirroring training.
|
||
let attn_context_ptr = {
|
||
let (p, _g) = self.attn_context_d.device_ptr(&self.stream);
|
||
p
|
||
};
|
||
let henr_t_base = {
|
||
let (p, _g) = self.h_enriched_seq_t_d.cuda_data().device_ptr(&self.stream);
|
||
p
|
||
};
|
||
let (h_per_k_base, _g_hpk) = self.h_new_per_k_d.device_ptr_mut(&self.stream);
|
||
let (probs_base, _g_probs) = self.probs_per_k_d.device_ptr_mut(&self.stream);
|
||
let (z1_base, _g_z1) = self.z1_per_k_d.device_ptr_mut(&self.stream);
|
||
let (a1_base, _g_a1) = self.a1_per_k_d.device_ptr_mut(&self.stream);
|
||
let (z2_base, _g_z2) = self.z2_per_k_d.device_ptr_mut(&self.stream);
|
||
let (gate_base, _g_gate) = self.gate_logit_per_k_d.device_ptr_mut(&self.stream);
|
||
let (main_base, _g_main) = self.main_per_k_d.device_ptr_mut(&self.stream);
|
||
let (logit_base, _g_logit) = self.logit_per_k_d.device_ptr_mut(&self.stream);
|
||
for k in 0..k_seq {
|
||
let h_new_k_ptr = h_per_k_base + (k * kb_hid_bytes) as u64;
|
||
let h_old_k_ptr = if k == 0 {
|
||
attn_context_ptr
|
||
} else {
|
||
h_per_k_base + ((k - 1) * kb_hid_bytes) as u64
|
||
};
|
||
let x_k_ptr = henr_t_base + (k * kb_hid_bytes) as u64;
|
||
let probs_k_ptr = probs_base + (k * kb_nh_bytes) as u64;
|
||
let z1_k_ptr = z1_base + (k * kb_nh_mid_bytes) as u64;
|
||
let a1_k_ptr = a1_base + (k * kb_nh_mid_bytes) as u64;
|
||
let z2_k_ptr = z2_base + (k * kb_nh_mid_bytes) as u64;
|
||
let gate_k_ptr = gate_base + (k * kb_nh_bytes) as u64;
|
||
let main_k_ptr = main_base + (k * kb_nh_bytes) as u64;
|
||
let logit_k_ptr = logit_base + (k * kb_nh_bytes) as u64;
|
||
unsafe {
|
||
let mut launch = self.stream.launch_builder(&self.trunk.step_batched_fn);
|
||
launch
|
||
.arg(&self.trunk.w_in_d).arg(&self.trunk.w_rec_d).arg(&self.trunk.b_d).arg(&self.trunk.tau_all_d)
|
||
.arg(&x_k_ptr).arg(&h_old_k_ptr)
|
||
.arg(&dt_s).arg(&n_in_i).arg(&n_hid_i).arg(&n_batch_i)
|
||
.arg(&h_new_k_ptr);
|
||
launch.launch(cfg_cfc).context("eval cfc fwd")?;
|
||
}
|
||
unsafe {
|
||
let mut launch = self.stream.launch_builder(&self.trunk.heads_grn_fwd_fn);
|
||
launch
|
||
.arg(&self.trunk.heads_w1_d).arg(&self.trunk.heads_b1_d)
|
||
.arg(&self.trunk.heads_w2_d).arg(&self.trunk.heads_b2_d)
|
||
.arg(&self.trunk.heads_w_gate_d).arg(&self.trunk.heads_b_gate_d)
|
||
.arg(&self.trunk.heads_w_main_d).arg(&self.trunk.heads_b_main_d)
|
||
.arg(&self.trunk.heads_w_skip_d).arg(&self.trunk.heads_b_skip_d)
|
||
.arg(&h_new_k_ptr).arg(&n_batch_i)
|
||
.arg(&probs_k_ptr)
|
||
.arg(&z1_k_ptr).arg(&a1_k_ptr).arg(&z2_k_ptr)
|
||
.arg(&gate_k_ptr).arg(&main_k_ptr).arg(&logit_k_ptr);
|
||
launch.launch(cfg_grn_fwd).context("eval heads GRN fwd")?;
|
||
}
|
||
}
|
||
drop((_g_hpk, _g_probs, _g_z1, _g_a1, _g_z2, _g_gate, _g_main, _g_logit));
|
||
|
||
// BCE for loss reporting (uses same per-horizon weights as train).
|
||
let n_pos_i = (k_seq * b_sz) as i32;
|
||
let n_h_i = N_HORIZONS as i32;
|
||
let bce_cfg = LaunchConfig {
|
||
grid_dim: (1, 1, 1), block_dim: (256, 1, 1), shared_mem_bytes: 0,
|
||
};
|
||
unsafe {
|
||
let mut launch = self.stream.launch_builder(&self.bce_fn);
|
||
launch
|
||
.arg(&self.probs_per_k_d).arg(&self.labels_per_k_d).arg(&self.loss_weights_d)
|
||
.arg(&self.log_sigma_h_d)
|
||
.arg(&n_pos_i).arg(&n_h_i)
|
||
.arg(&mut self.loss_d)
|
||
.arg(&mut self.loss_per_horizon_d)
|
||
.arg(&mut self.grad_probs_per_k_d).arg(&mut self.valid_d)
|
||
.arg(&mut self.grad_log_sigma_h_d);
|
||
launch.launch(bce_cfg).context("eval bce")?;
|
||
}
|
||
self.stream.synchronize().context("eval sync")?;
|
||
|
||
let loss = download(&self.stream, &self.loss_d)?[0];
|
||
let probs = download(&self.stream, &self.probs_per_k_d)?;
|
||
Ok((loss, probs))
|
||
}
|
||
|
||
/// Read per-position probabilities for the most-recent forward
|
||
/// (used by validation / inference). Returns [K, N_HORIZONS] row-major.
|
||
pub fn last_probs_per_position(&self) -> Result<Vec<f32>> {
|
||
download(&self.stream, &self.probs_per_k_d)
|
||
}
|
||
|
||
/// Last-step per-horizon raw mean-squared-diff (pre-λ). Reads from
|
||
/// the mapped-pinned host shadow populated by `step_batched`. Returns
|
||
/// all zeros when the smoothness regularizer is disabled
|
||
/// (`cfg.smoothness_base_lambda == 0.0`).
|
||
pub fn last_smoothness_loss_per_horizon(&self) -> [f32; N_HORIZONS] {
|
||
let raw = self.smoothness_loss_per_horizon_host_d.read_all();
|
||
let mut out = [0.0f32; N_HORIZONS];
|
||
for (i, v) in raw.into_iter().take(N_HORIZONS).enumerate() {
|
||
out[i] = v;
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Convenience for callers that only need the LAST position's probs
|
||
/// (legacy API — final-position AUC scoring).
|
||
pub fn last_probs(&self) -> Result<[f32; N_HORIZONS]> {
|
||
let all = self.last_probs_per_position()?;
|
||
let last = self.cfg.seq_len - 1;
|
||
let mut out = [0f32; N_HORIZONS];
|
||
out.copy_from_slice(&all[last * N_HORIZONS..(last + 1) * N_HORIZONS]);
|
||
Ok(out)
|
||
}
|
||
}
|
||
|
||
fn upload(stream: &Arc<CudaStream>, host: &[f32]) -> Result<CudaSlice<f32>> {
|
||
let n = host.len();
|
||
let staging = unsafe { MappedF32Buffer::new(n) }
|
||
.map_err(|e| anyhow::anyhow!("stacked upload staging: {e}"))?;
|
||
staging.write_from_slice(host);
|
||
let mut dst = stream.alloc_zeros::<f32>(n).context("stacked upload alloc")?;
|
||
if n > 0 {
|
||
let nbytes = n * std::mem::size_of::<f32>();
|
||
unsafe {
|
||
let (dst_ptr, _g) = dst.device_ptr_mut(stream);
|
||
cudarc::driver::result::memcpy_dtod_async(dst_ptr, staging.dev_ptr, nbytes, stream.cu_stream())
|
||
.context("stacked upload DtoD")?;
|
||
}
|
||
}
|
||
Ok(dst)
|
||
}
|
||
|
||
|
||
fn download(stream: &Arc<CudaStream>, src: &CudaSlice<f32>) -> Result<Vec<f32>> {
|
||
let n = src.len();
|
||
let staging = unsafe { MappedF32Buffer::new(n) }
|
||
.map_err(|e| anyhow::anyhow!("stacked download staging: {e}"))?;
|
||
let nbytes = n * std::mem::size_of::<f32>();
|
||
unsafe {
|
||
let (src_ptr, _g) = src.device_ptr(stream);
|
||
cudarc::driver::result::memcpy_dtod_async(staging.dev_ptr, src_ptr, nbytes, stream.cu_stream())
|
||
.context("stacked download DtoD")?;
|
||
}
|
||
stream.synchronize().context("stacked download sync")?;
|
||
Ok(staging.read_all())
|
||
}
|
||
|
||
// ── Drop: shut down the GPU log drain thread (feature-gated) ──
|
||
// Setting the stop flag lets the drainer exit cleanly on its next 500ms
|
||
// tick; the drain thread observes the flag at the top of each iteration,
|
||
// flushes the BufWriter, and exits. We then `join()` synchronously so
|
||
// the trace file is fully flushed by the time the trainer is dropped.
|
||
// The bounded 500 ms tick guarantees the join completes promptly.
|
||
#[cfg(feature = "kernel-step-trace")]
|
||
impl Drop for PerceptionTrainer {
|
||
fn drop(&mut self) {
|
||
use std::sync::atomic::Ordering;
|
||
if let Some(stop) = self.log_drain_stop.as_ref() {
|
||
stop.store(true, Ordering::Relaxed);
|
||
}
|
||
if let Some(h) = self.log_drain_handle.take() {
|
||
// Drain thread observes the stop flag on its next tick (≤500 ms),
|
||
// flushes the BufWriter, and exits. `join()` blocks until the
|
||
// thread terminates — guarantees the JSONL tail lands on disk.
|
||
if let Err(e) = h.join() {
|
||
tracing::warn!(target: "gpu_log",
|
||
error = ?e,
|
||
"gpu_log_drain thread join failed");
|
||
}
|
||
}
|
||
}
|
||
}
|