Files
foxhunt/crates/ml-alpha/src/mamba2_block.rs
jgrusewski 629ebd667c feat(ml-alpha): deterministic same-seed training + Tier 1.5 fast-dev-cycle
Two same-seed runs now produce bit-equal eval_summary.json, alpha_rl_train_summary.json,
and diag.jsonl (modulo wall-clock elapsed_s). The 5-phase falsification chain landed:

  Phase 2   PER tree-rebuild: __threadfence is NOT a grid-wide barrier; multiple blocks
            raced across sum-tree levels. Fix: Grid=(1) Block=(1024) + __syncthreads
            in rl_per_tree_rebuild.cu.

  Phase 2.3 cuBLAS GEMM_DFALT + TF32 default-math allowed split-K non-deterministic
            accumulation at 3 sites. New crates/ml-alpha/src/cublas_determinism.rs
            applies CUBLAS_PEDANTIC_MATH via FOXHUNT_DETERMINISTIC env toggle
            (0=TF32 prod, 1=PEDANTIC dev default, 2=DEFAULT_MATH control).

  Phase 2.6 Two bugs surfaced sequentially in the backward kernel chain:
            (1) rl_iqn_tau_cos_features had a multi-block r/w race on prng_state[batch]
                — all N_TAU=32 blocks read seed; only tau_idx==0 wrote back; no
                inter-block barrier. Fix: split into READ-ONLY rl_iqn_tau_cos_features
                + new sibling rl_iqn_advance_prng_state launched on same stream
                (kernel-launch ordering = grid-wide barrier).
            (2) OutcomeHead::new called near_zero_xavier without scoped_init_seed,
                falling back to time+thread-id RNG. Stayed dormant until first done
                event activated non-sentinel labels and divergent weights flowed via
                grad_h_t_outcome into encoder gradient. Fix: add seed param + install
                scoped_init_seed(dqn_seed.wrapping_add(0x0CE0)) guard.

Validation (./scripts/determinism-check.sh --quick, RTX 3050, b=128, 200+50 steps):
  - All 200 rows of checksums.* leaves match (rel-tol 1e-5, abs-tol 1e-7)
  - eval_summary.json, alpha_rl_train_summary.json byte-equal between runs
  - diag.jsonl byte-equal modulo elapsed_s
  - Eval pnl identical run-A vs run-B at seed 42

Pre-fix baseline (Phase 2.5 measurement): same-seed eval pnl spread $450k
($187k vs -$261k). Post-fix: $0 spread.

Speed cost: ~1.5ms/step amortised; ~10-15% slower than TF32 production
(PEDANTIC tax — acceptable in dev, toggle to FOXHUNT_DETERMINISTIC=0 for prod).

Mapped-pinned discipline: all 11 NEW memcpy_dtoh sites in diagnostic dump methods
+ per-step checksum readback use a new pub(crate) helper
read_slice_d_into<T: Copy>(stream, src, dst) — MappedRecordBuffer + raw
memcpy_dtod_async + raw_stream_sync + volatile read. Generic over T (f32, f64,
i32, u32, u8). Satisfies feedback_no_htod_htoh_only_mapped_pinned + hook guard.

Bundled Tier 1.5 fast-dev-cycle infrastructure (spec
docs/superpowers/specs/2026-06-02-fast-dev-cycle.md):
  - scripts/local-mid-smoke.sh        b=128, 2000+500, ~10min on RTX 3050
  - scripts/determinism-check.sh      runs mid-smoke twice, diffs checksums
  - scripts/tier1_5_verdict.py        behavioral kill verdict
  - AdamW checkpoint save/load (crates/ml-alpha/src/trainer/optim.rs)
  - IntegratedTrainer checkpoint save/load (resume from checkpoint)
  - 15 Phase 1 checksum leaves in build_diag_value
  - Env-gated dump methods (FOXHUNT_DETERMINISM_DEBUG_PER/MAMBA2/RL/BACKWARD)
    for future divergence-chasing — never run in production

Documentation:
  - docs/superpowers/specs/2026-06-02-determinism-foundation.md
  - docs/superpowers/specs/2026-06-02-fast-dev-cycle.md
  - docs/superpowers/plans/2026-06-02-determinism-foundation-implementation.md
  - docs/superpowers/notes/2026-06-02-determinism-phase{1,2,2.2,2.5,2.6}-*.md
  - Adjacent specs/plans/notes from the analytical chain that surfaced determinism
    as the load-bearing blocker (eval-summary, eval-boundary, regime-observer,
    multi-head policy, regime-invariance, Phase 3 IQN-complement post-mortem)

Unlocks: every controller / architecture / reward-shaping A/B from this commit
onward attributes outcome differences to the change, not random-init kernel-race
drift cascading through training x eval LOB-sim trajectories. The eval-collapse
investigation (pearl_reward_signal_anti_aligned_with_pnl, multi-head spec,
regime-invariance spec) is now testable with trustworthy verdicts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 17:56:00 +02:00

2828 lines
127 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Phase 1d.1 — From-scratch Mamba2 sequence block for ml-alpha.
//!
//! GPU-pure stateful encoder over snapshot streams. Architecture:
//!
//! ```text
//! input [B, K, in_dim]
//! │ cuBLAS GEMM (W_in)
//! ▼
//! x [B, K, hidden_dim]
//! ├─ cuBLAS GEMM (W_a) ──▶ a_proj [B, K, state_dim]
//! ├─ cuBLAS GEMM (W_b) ──▶ b_proj [B, K, state_dim]
//! ▼
//! mamba2_scan_projected_fwd kernel
//! (selective SSM scan over K timesteps; sigmoid gating; W_c mixes
//! state into per-position hidden output)
//! ▼
//! h_enriched [B, hidden_dim] (residual added in kernel via h_s2; we
//! pass zeros for the SSM-residual term)
//! │ cuBLAS GEMM (W_out)
//! ▼
//! logit [B, 1]
//! ```
//!
//! Backward: analytical via `mamba2_scan_projected_bwd` kernel; cuBLAS
//! transposed-GEMMs for the projection backward passes. Lands in a follow-up
//! session — this module establishes the skeleton: weight allocation,
//! cubin loading, kernel-symbol resolution.
//!
//! Discipline (per project memory):
//! - All weights / state / activations live on GPU (`CudaSlice<f32>`).
//! - Weight init: `OwnedGpuLinear::xavier` from ml-core, which uses pinned
//! host buffers for the seed values.
//! - No `atomicAdd` (kernel uses block tree-reduce).
//! - State dim hardcoded at ≤ 32 in the kernel (`float x[32]`); we expose
//! it as a config field but the constructor rejects values > 32.
use std::sync::Arc;
use anyhow::{anyhow, Result};
use cudarc::cublas::CudaBlas;
use cudarc::driver::{CudaFunction, CudaModule, CudaSlice, CudaStream};
use cudarc::driver::sys::CUstream;
use ml_core::cuda_autograd::gpu_tensor::GpuTensor;
use ml_core::cuda_autograd::linear::{LinearActivations, LinearGrads, OwnedGpuLinear};
use crate::trainer::raw_launch::{RawArgs, raw_launch};
/// Hard kernel limits. The forward and backward kernels use register/local
/// arrays sized at compile-time: `float x[32]` (state register) and
/// `float x_hist[K * 32]` up to ~12 KiB per thread of local memory at
/// K=96. Raised from 16 → 32 to allow scaling Mamba2 SSM state capacity;
/// L40S/H100 register file (256 KiB/SM) handles this without occupancy
/// collapse for our block dim (32-128 threads). Exceeding either limit
/// silently corrupts; the Rust constructor enforces.
pub const MAMBA2_KERNEL_STATE_MAX: usize = 32;
pub const MAMBA2_KERNEL_SEQ_MAX: usize = 96;
/// Configuration for a Mamba2 sequence block.
#[derive(Debug, Clone)]
pub struct Mamba2BlockConfig {
/// Per-snapshot feature dim (e.g. 81 for the snapshot pipeline).
pub in_dim: usize,
/// Hidden / model dim (=`sh2` in the kernel signature).
pub hidden_dim: usize,
/// SSM state dim (≤ 16).
pub state_dim: usize,
/// Sequence length per batch (K in kernel; number of timesteps).
pub seq_len: usize,
}
impl Mamba2BlockConfig {
pub fn validate(&self) -> Result<()> {
if self.in_dim == 0 || self.hidden_dim == 0 || self.state_dim == 0 || self.seq_len < 2 {
return Err(anyhow!(
"Mamba2BlockConfig: invalid dims (in={}, hidden={}, state={}, seq_len={})",
self.in_dim, self.hidden_dim, self.state_dim, self.seq_len
));
}
if self.state_dim > MAMBA2_KERNEL_STATE_MAX {
return Err(anyhow!(
"Mamba2BlockConfig: state_dim {} exceeds kernel max {} (mamba2_alpha_kernel.cu \
hardcodes `float x[{}]` register array per thread)",
self.state_dim, MAMBA2_KERNEL_STATE_MAX, MAMBA2_KERNEL_STATE_MAX
));
}
if self.seq_len > MAMBA2_KERNEL_SEQ_MAX {
return Err(anyhow!(
"Mamba2BlockConfig: seq_len {} exceeds kernel max {} (backward kernel's \
x_hist replay cache is `float x_hist[{}*{}]` per thread)",
self.seq_len, MAMBA2_KERNEL_SEQ_MAX,
MAMBA2_KERNEL_SEQ_MAX, MAMBA2_KERNEL_STATE_MAX
));
}
Ok(())
}
}
/// All parameter gradients produced by [`Mamba2Block::backward`].
///
/// Tensor shapes match the corresponding forward parameters:
/// - `dw_in / db_in` : `[hidden_dim, in_dim] / [hidden_dim]`
/// - `dw_a / db_a` : `[state_dim, hidden_dim] / [state_dim]`
/// - `dw_b / db_b` : `[state_dim, hidden_dim] / [state_dim]`
/// - `dw_c` : `[hidden_dim, state_dim]` (stored as flat slice;
/// layout matches the `W_c` weight)
/// - `dw_out / db_out` : `[1, hidden_dim] / [1]`
pub struct Mamba2BackwardGrads {
pub dw_in: GpuTensor,
pub db_in: GpuTensor,
pub dw_a: GpuTensor,
pub db_a: GpuTensor,
pub dw_b: GpuTensor,
pub db_b: GpuTensor,
pub dw_c: CudaSlice<f32>,
pub dw_out: GpuTensor,
pub db_out: GpuTensor,
}
/// Saved activations + per-stage tensors that the backward pass needs. Returned
/// by [`Mamba2Block::forward_train`]; consumed by [`Mamba2Block::backward`].
/// For inference-only ([`Mamba2Block::forward`]) the cache is computed but
/// immediately dropped.
pub struct Mamba2ForwardCache {
/// Input as reshaped 2-D `[N*K, in_dim]` — needed to backprop into W_in.
pub input_2d: GpuTensor,
/// Output of W_in projection, `[N*K, hidden_dim]`. Needed to backprop into
/// W_a / W_b and to compose dx/dx_prev for the W_in gradient.
pub x: GpuTensor,
/// A projection output `[N*K, state_dim]`. Backward kernel reads this to
/// replay the forward state path.
pub a_proj: GpuTensor,
/// B projection output `[N*K, state_dim]`. Same role as a_proj.
pub b_proj: GpuTensor,
/// Output of the scan kernel, `[N, hidden_dim]`. Needed to backprop into
/// W_out (and is also the input to the W_out forward GEMM).
pub h_enriched: GpuTensor,
}
/// Cache produced by [`Mamba2Block::forward_train_seq`] — variant of
/// [`Mamba2ForwardCache`] that exposes the SSM output at EVERY timestep
/// instead of just the final position. Used by ml-alpha when supervising
/// the model at every snapshot in a sequence (per-step heads + BCE).
pub struct Mamba2ForwardCacheSeq {
pub input_2d: GpuTensor,
pub x: GpuTensor,
pub a_proj: GpuTensor,
pub b_proj: GpuTensor,
/// Per-step enriched state, `[N, K, hidden_dim]`. Slot `[i, t, j]` is
/// `h_s2[i, j] + sum_s w_c[j, s] * x[i, t, s]` where x is the SSM
/// state AFTER step t (post-gate update).
pub h_enriched_seq: GpuTensor,
}
/// Pre-allocated forward intermediates for [`Mamba2Block::forward_train_seq_into`].
/// Holds all GpuTensors that the regular `forward_train_seq` allocates per
/// call (input_2d projection output `x`, A/B projections, h_s2 residual,
/// h_enriched_seq scan output). Constructed once per (n_batch, seq_len,
/// in_dim, hidden_dim, state_dim) tuple and reused — eliminates the
/// per-step `cudaMalloc` calls that block CUDA Graph capture.
///
/// `h_s2` is zero-initialised once and never written (no residual carry
/// from a prior chunk in the supervised path). The scan kernel reads it
/// as a constant addition to h_enriched_seq.
pub struct Mamba2BlockForwardScratch {
pub x: GpuTensor, // [n_rows, hidden_dim] (W_in output)
pub a_proj: GpuTensor, // [n_rows, state_dim]
pub b_proj: GpuTensor, // [n_rows, state_dim]
pub h_s2: GpuTensor, // [n_batch, hidden_dim] (zero residual)
pub h_enriched_seq: GpuTensor, // [n_batch, seq_len, hidden_dim]
pub n_batch: usize,
pub seq_len: usize,
pub in_dim: usize,
pub hidden_dim: usize,
pub state_dim: usize,
}
impl Mamba2BlockForwardScratch {
pub fn new(
stream: &Arc<CudaStream>,
n_batch: usize,
seq_len: usize,
in_dim: usize,
hidden_dim: usize,
state_dim: usize,
) -> Result<Self> {
let n_rows = n_batch * seq_len;
Ok(Self {
x: GpuTensor::zeros(&[n_rows, hidden_dim], stream)
.map_err(|e| anyhow!("fwd scratch x: {e}"))?,
a_proj: GpuTensor::zeros(&[n_rows, state_dim], stream)
.map_err(|e| anyhow!("fwd scratch a_proj: {e}"))?,
b_proj: GpuTensor::zeros(&[n_rows, state_dim], stream)
.map_err(|e| anyhow!("fwd scratch b_proj: {e}"))?,
h_s2: GpuTensor::zeros(&[n_batch, hidden_dim], stream)
.map_err(|e| anyhow!("fwd scratch h_s2: {e}"))?,
h_enriched_seq: GpuTensor::zeros(&[n_batch, seq_len, hidden_dim], stream)
.map_err(|e| anyhow!("fwd scratch h_enriched_seq: {e}"))?,
n_batch, seq_len, in_dim, hidden_dim, state_dim,
})
}
}
/// Pre-allocated scratch for [`Mamba2Block::step_into`] (CRT Phase A0.5).
/// Sized for a single-snapshot forward pass (K=1) and holds the
/// PERSISTENT recurrent SSM register state across calls so the encoder
/// can advance by one event per call instead of re-running over a
/// K-window every time.
///
/// Layout differences vs [`Mamba2BlockForwardScratch`]:
/// - `x`, `a_proj`, `b_proj` are `[n_batch, ...]` instead of `[n_batch*K, ...]`
/// - `x_state` is new: `[n_batch, hidden_dim, state_dim]` — the per-(i, j)
/// thread register state read at start of each step kernel and written
/// back at the end. Zero-initialised on construction; updated in-place
/// by [`Mamba2Block::step_into`]. Use [`Self::reset_state`] to zero it
/// between sessions.
/// - `h_out` replaces `h_enriched_seq`: shape `[n_batch, hidden_dim]`,
/// the single-step enriched output (no K dimension).
///
/// `h_s2` stays zero-initialised (matches the supervised path's
/// residual-zero convention). Construct once per (n_batch, in_dim,
/// hidden_dim, state_dim) tuple; reuse for every forward_step call.
pub struct Mamba2BlockStepScratch {
/// W_in output `[n_batch, hidden_dim]` (single-row projection).
pub x: GpuTensor,
/// W_a output `[n_batch, state_dim]`.
pub a_proj: GpuTensor,
/// W_b output `[n_batch, state_dim]`.
pub b_proj: GpuTensor,
/// SSM register state `[n_batch, hidden_dim, state_dim]` — PERSISTENT
/// across step_into calls. Zero-initialised on `new()`. The step
/// kernel reads this at entry, advances by one step, writes back.
pub x_state: CudaSlice<f32>,
/// h_s2 residual `[n_batch, hidden_dim]` — kept zero by convention,
/// matches the supervised forward_train_seq_into path.
pub h_s2: GpuTensor,
/// Single-step enriched output `[n_batch, hidden_dim]`.
pub h_out: GpuTensor,
pub n_batch: usize,
pub in_dim: usize,
pub hidden_dim: usize,
pub state_dim: usize,
}
impl Mamba2BlockStepScratch {
pub fn new(
stream: &Arc<CudaStream>,
n_batch: usize,
in_dim: usize,
hidden_dim: usize,
state_dim: usize,
) -> Result<Self> {
Ok(Self {
x: GpuTensor::zeros(&[n_batch, hidden_dim], stream)
.map_err(|e| anyhow!("step scratch x: {e}"))?,
a_proj: GpuTensor::zeros(&[n_batch, state_dim], stream)
.map_err(|e| anyhow!("step scratch a_proj: {e}"))?,
b_proj: GpuTensor::zeros(&[n_batch, state_dim], stream)
.map_err(|e| anyhow!("step scratch b_proj: {e}"))?,
x_state: stream
.alloc_zeros::<f32>(n_batch * hidden_dim * state_dim)
.map_err(|e| anyhow!("step scratch x_state: {e}"))?,
h_s2: GpuTensor::zeros(&[n_batch, hidden_dim], stream)
.map_err(|e| anyhow!("step scratch h_s2: {e}"))?,
h_out: GpuTensor::zeros(&[n_batch, hidden_dim], stream)
.map_err(|e| anyhow!("step scratch h_out: {e}"))?,
n_batch, in_dim, hidden_dim, state_dim,
})
}
/// Zero the persistent SSM register state. Used for session-gap
/// resets — drop accumulated context and restart from a clean slate.
/// h_s2 stays at its construction zero; only x_state needs an
/// explicit reset because step_into writes back to it every call.
pub fn reset_state(&mut self, stream: &Arc<CudaStream>) -> Result<()> {
stream
.memset_zeros(&mut self.x_state)
.map_err(|e| anyhow!("step scratch reset: {e}"))?;
Ok(())
}
}
impl Mamba2BlockStepScratch {
/// Test-only readback of the persistent x_state register buffer.
/// One-shot DtoH — acceptable in tests; NEVER call on the hot path.
pub fn read_x_state(&self, stream: &Arc<CudaStream>) -> Result<Vec<f32>> {
let n = self.x_state.len();
let mut host = vec![0.0_f32; n];
stream
.memcpy_dtoh(&self.x_state, &mut host)
.map_err(|e| anyhow!("read_x_state dtoh: {e}"))?;
stream
.synchronize()
.map_err(|e| anyhow!("read_x_state sync: {e}"))?;
Ok(host)
}
}
/// Pre-allocated outputs + intermediates for the full Mamba2 seq
/// backward — see [`Mamba2Block::backward_from_h_enriched_seq_full_into`].
/// Holds the cuBLAS linear-backward outputs (dw_in/db_in/dw_a/db_a/
/// dw_b/db_b plus the reshaped d_a_proj_2d / d_b_proj_2d / d_x_from_a
/// / d_x_from_b / d_x intermediates) so the backward pass does zero
/// per-step allocations.
///
/// W_out is unused in the seq path — dw_out / db_out are zero-init
/// shells of the right shape so the Mamba2AdamW step is a no-op for
/// those parameters.
pub struct Mamba2BackwardGradsBuffers {
pub d_a_proj_2d: GpuTensor, // [n_rows, state_dim] (cuBLAS-reshape of d_a_proj_flat)
pub d_b_proj_2d: GpuTensor, // [n_rows, state_dim]
pub d_x_from_a: GpuTensor, // [n_rows, hidden_dim]
pub d_x_from_b: GpuTensor, // [n_rows, hidden_dim]
pub d_x: GpuTensor, // [n_rows, hidden_dim]
pub dw_in: GpuTensor, // [hidden_dim, in_dim]
pub db_in: GpuTensor, // [hidden_dim]
pub dw_a: GpuTensor, // [state_dim, hidden_dim]
pub db_a: GpuTensor, // [state_dim]
pub dw_b: GpuTensor, // [state_dim, hidden_dim]
pub db_b: GpuTensor, // [state_dim]
pub dw_c: GpuTensor, // [hidden_dim, state_dim]
pub d_x_from_in: GpuTensor, // [n_rows, in_dim] (unused but allocated for w_in_into's dx_out)
pub dw_out: GpuTensor, // [1, hidden_dim] (zero)
pub db_out: GpuTensor, // [1] (zero)
pub n_batch: usize,
pub seq_len: usize,
pub in_dim: usize,
pub hidden_dim: usize,
pub state_dim: usize,
}
impl Mamba2BackwardGradsBuffers {
pub fn new(
stream: &Arc<CudaStream>,
n_batch: usize,
seq_len: usize,
in_dim: usize,
hidden_dim: usize,
state_dim: usize,
) -> Result<Self> {
let n_rows = n_batch * seq_len;
Ok(Self {
d_a_proj_2d: GpuTensor::zeros(&[n_rows, state_dim], stream)
.map_err(|e| anyhow!("bwd grads d_a_proj_2d: {e}"))?,
d_b_proj_2d: GpuTensor::zeros(&[n_rows, state_dim], stream)
.map_err(|e| anyhow!("bwd grads d_b_proj_2d: {e}"))?,
d_x_from_a: GpuTensor::zeros(&[n_rows, hidden_dim], stream)
.map_err(|e| anyhow!("bwd grads d_x_from_a: {e}"))?,
d_x_from_b: GpuTensor::zeros(&[n_rows, hidden_dim], stream)
.map_err(|e| anyhow!("bwd grads d_x_from_b: {e}"))?,
d_x: GpuTensor::zeros(&[n_rows, hidden_dim], stream)
.map_err(|e| anyhow!("bwd grads d_x: {e}"))?,
dw_in: GpuTensor::zeros(&[hidden_dim, in_dim], stream)
.map_err(|e| anyhow!("bwd grads dw_in: {e}"))?,
db_in: GpuTensor::zeros(&[hidden_dim], stream)
.map_err(|e| anyhow!("bwd grads db_in: {e}"))?,
dw_a: GpuTensor::zeros(&[state_dim, hidden_dim], stream)
.map_err(|e| anyhow!("bwd grads dw_a: {e}"))?,
db_a: GpuTensor::zeros(&[state_dim], stream)
.map_err(|e| anyhow!("bwd grads db_a: {e}"))?,
dw_b: GpuTensor::zeros(&[state_dim, hidden_dim], stream)
.map_err(|e| anyhow!("bwd grads dw_b: {e}"))?,
db_b: GpuTensor::zeros(&[state_dim], stream)
.map_err(|e| anyhow!("bwd grads db_b: {e}"))?,
dw_c: GpuTensor::zeros(&[hidden_dim, state_dim], stream)
.map_err(|e| anyhow!("bwd grads dw_c: {e}"))?,
d_x_from_in: GpuTensor::zeros(&[n_rows, in_dim], stream)
.map_err(|e| anyhow!("bwd grads d_x_from_in: {e}"))?,
dw_out: GpuTensor::zeros(&[1, hidden_dim], stream)
.map_err(|e| anyhow!("bwd grads dw_out: {e}"))?,
db_out: GpuTensor::zeros(&[1], stream)
.map_err(|e| anyhow!("bwd grads db_out: {e}"))?,
n_batch, seq_len, in_dim, hidden_dim, state_dim,
})
}
}
/// Pre-allocated scratch for [`Mamba2Block::backward_from_h_enriched_seq_into`].
/// Holds the 4 BIG per-step scratch buffers (the ones at ~6MB each that
/// dominated the per-call `alloc_zeros` churn). Smaller buffers
/// (`d_a_proj_flat`, `d_b_proj_flat`, `dw_c`) remain per-call —
/// they're tiny and feed directly into ownership-transferred
/// LinearGrads outputs, where pre-allocation would force a
/// refactor of ml-core's cuBLAS wrappers without proportional gain.
///
/// Construct ONCE per (n_batch, seq_len, hidden_dim, state_dim) tuple.
pub struct Mamba2BackwardScratch {
pub d_a_per_channel: CudaSlice<f32>, // [N, sh2, K, state_d]
pub d_b_per_channel: CudaSlice<f32>, // [N, sh2, K, state_d]
pub d_w_c_per_sample: CudaSlice<f32>, // [N, sh2, state_d]
pub d_h_s2: CudaSlice<f32>, // [N, sh2]
pub n_batch: usize,
pub seq_len: usize,
pub hidden_dim: usize,
pub state_dim: usize,
}
impl Mamba2BackwardScratch {
pub fn new(
stream: &Arc<CudaStream>,
n_batch: usize,
seq_len: usize,
hidden_dim: usize,
state_dim: usize,
) -> Result<Self> {
let per_chan_n = n_batch * hidden_dim * seq_len * state_dim;
let per_sample_n = n_batch * hidden_dim * state_dim;
Ok(Self {
d_a_per_channel: stream.alloc_zeros::<f32>(per_chan_n)
.map_err(|e| anyhow!("scratch d_a_per_channel: {e}"))?,
d_b_per_channel: stream.alloc_zeros::<f32>(per_chan_n)
.map_err(|e| anyhow!("scratch d_b_per_channel: {e}"))?,
d_w_c_per_sample: stream.alloc_zeros::<f32>(per_sample_n)
.map_err(|e| anyhow!("scratch d_w_c_per_sample: {e}"))?,
d_h_s2: stream.alloc_zeros::<f32>(n_batch * hidden_dim)
.map_err(|e| anyhow!("scratch d_h_s2: {e}"))?,
n_batch, seq_len, hidden_dim, state_dim,
})
}
}
/// GPU-resident Mamba2 sequence block.
///
/// Owns all parameter tensors (`W_in`, `W_a`, `W_b`, `W_c`, `W_out`) on the
/// CUDA device. The forward + backward kernels are loaded once at
/// construction from the precompiled cubin.
pub struct Mamba2Block {
pub config: Mamba2BlockConfig,
pub stream: Arc<CudaStream>,
/// Cached raw CUstream handle for [`raw_launch`] — avoids the
/// Arc deref + method call on every kernel launch.
pub raw_stream: CUstream,
// ── Parameters ────────────────────────────────────────────────────
/// Input projection: `in_dim → hidden_dim`. Cuts the snapshot row down
/// to model space before the selective scan.
pub w_in: OwnedGpuLinear,
/// A-projection (gate): `hidden_dim → state_dim`. The kernel applies
/// sigmoid to produce per-state gates (`a_proj`).
pub w_a: OwnedGpuLinear,
/// B-projection (input contribution): `hidden_dim → state_dim`. Added
/// to the carried state each step (`b_proj`).
pub w_b: OwnedGpuLinear,
/// Context-mix weights `W_c` in the kernel signature, shape
/// `[hidden_dim, state_dim]`. Multiplies the final state to produce the
/// per-position context that's added to `h_s2`. Stored as a raw
/// `CudaSlice<f32>` (no bias) because the kernel reads it directly.
pub w_c: CudaSlice<f32>,
/// Output projection: `hidden_dim → 1`. Reduces the enriched hidden
/// state to the binary direction logit.
pub w_out: OwnedGpuLinear,
// ── Kernel handles ────────────────────────────────────────────────
_module: Arc<CudaModule>,
pub kernel_fwd: CudaFunction,
pub kernel_bwd: CudaFunction,
/// Per-step variant of the forward scan — writes h_enriched at every
/// timestep (used by per-position supervision in PerceptionTrainer).
pub kernel_fwd_seq: CudaFunction,
/// SINGLE-STEP variant of the forward scan — reads and writes the
/// recurrent SSM register state to/from device memory so the SSM can
/// be advanced one snapshot per launch across many forward_step calls.
/// Used by [`Mamba2Block::step_into`] for event-rate inference
/// (CRT Phase A0.5). The supervised path keeps using
/// `kernel_fwd_seq` over a full K-window.
pub kernel_fwd_step: CudaFunction,
/// Per-step variant of the backward scan — accepts d_h_enriched_seq
/// and injects gradient contributions into d_state at every step.
pub kernel_bwd_seq: CudaFunction,
/// Reduces `d_a_per_channel` or `d_b_per_channel` (same kernel, two
/// call sites with different I/O pointers).
pub kernel_reduce_d_proj: CudaFunction,
/// Reduces `d_w_c_per_sample[N, sh2, state_d]` across N.
pub kernel_reduce_d_w_c: CudaFunction,
/// AdamW step kernel — applied per parameter tensor.
pub kernel_adamw: CudaFunction,
/// cuBLAS handle for the four linear projections (input / A / B / output).
pub cublas: CudaBlas,
/// Pre-allocated cuBLAS workspace. cuBLAS lazily allocates internal
/// workspace per gemm call when none is set, which is incompatible
/// with CUDA Graph stream capture (cudaMalloc inside the captured
/// region trips `CUDA_ERROR_STREAM_CAPTURE_UNSUPPORTED`). Setting a
/// user-managed workspace via `cublasSetWorkspace_v2` removes the
/// per-call alloc; the buffer stays alive for the lifetime of the
/// block, large enough for all gemm sizes used by the projections.
_cublas_workspace: CudaSlice<u8>,
}
impl Mamba2Block {
/// Construct a fresh Mamba2 block with Xavier-initialised projections
/// and the SSM scan kernels loaded from the precompiled cubin.
pub fn new(config: Mamba2BlockConfig, stream: Arc<CudaStream>) -> Result<Self> {
config.validate()?;
// ── Load the precompiled cubin and resolve kernel symbols. ────
// The cubin is produced by `build.rs` at compile time; no nvrtc.
static CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/mamba2_alpha_kernel.cubin"));
let module = stream
.context()
.load_cubin(CUBIN.to_vec())
.map_err(|e| anyhow!("Mamba2Block: cubin load failed: {e}"))?;
let kernel_fwd = module
.load_function("mamba2_alpha_scan_fwd")
.map_err(|e| anyhow!("Mamba2Block: forward kernel symbol resolve: {e}"))?;
let kernel_bwd = module
.load_function("mamba2_alpha_scan_bwd")
.map_err(|e| anyhow!("Mamba2Block: backward kernel symbol resolve: {e}"))?;
let kernel_fwd_seq = module
.load_function("mamba2_alpha_scan_fwd_seq")
.map_err(|e| anyhow!("Mamba2Block: per-step forward kernel resolve: {e}"))?;
let kernel_fwd_step = module
.load_function("mamba2_alpha_scan_fwd_step")
.map_err(|e| anyhow!("Mamba2Block: single-step forward kernel resolve: {e}"))?;
let kernel_bwd_seq = module
.load_function("mamba2_alpha_scan_bwd_seq")
.map_err(|e| anyhow!("Mamba2Block: per-step backward kernel resolve: {e}"))?;
let kernel_reduce_d_proj = module
.load_function("mamba2_alpha_reduce_d_proj")
.map_err(|e| anyhow!("Mamba2Block: d_proj reduction kernel resolve: {e}"))?;
let kernel_reduce_d_w_c = module
.load_function("mamba2_alpha_reduce_d_w_c")
.map_err(|e| anyhow!("Mamba2Block: d_w_c reduction kernel resolve: {e}"))?;
let kernel_adamw = module
.load_function("mamba2_alpha_adamw_step")
.map_err(|e| anyhow!("Mamba2Block: AdamW kernel resolve: {e}"))?;
// cuBLAS handle for the four GEMM projections, with a pre-
// allocated user-managed workspace so subsequent gemm calls
// don't trigger internal allocations during CUDA Graph capture.
// NVIDIA recommends 4 MiB minimum for general use, larger for
// batched ops on Ampere+; 8 MiB is comfortable headroom for
// our gemm shapes (largest is hidden_dim×in_dim ≈ 128×32).
let cublas = CudaBlas::new(Arc::clone(&stream))
.map_err(|e| anyhow!("Mamba2Block: cuBLAS init failed: {e}"))?;
// Determinism foundation (spec 2026-06-02 §2.B):
// Disable non-deterministic GEMM algorithms. Per the Phase 2.2
// mamba2 investigation, the 3 backward GEMMs in
// backward_from_h_enriched_seq_full_into were the root cause of
// the eval-pnl drift between same-seed runs.
let cublas = crate::cublas_determinism::apply_deterministic_math_mode(cublas)
.map_err(|e| anyhow!("Mamba2Block: cublas determinism setup: {e}"))?;
const CUBLAS_WORKSPACE_BYTES: usize = 8 * 1024 * 1024;
let cublas_workspace = stream
.alloc_zeros::<u8>(CUBLAS_WORKSPACE_BYTES)
.map_err(|e| anyhow!("Mamba2Block: cuBLAS workspace alloc: {e}"))?;
unsafe {
let ws_ptr = cublas_workspace.raw_ptr();
cudarc::cublas::sys::cublasSetWorkspace_v2(
*cublas.handle(),
ws_ptr as *mut std::ffi::c_void,
CUBLAS_WORKSPACE_BYTES,
)
.result()
.map_err(|e| anyhow!("Mamba2Block: cublasSetWorkspace_v2: {e:?}"))?;
}
// ── Parameter allocation + initialisation. ──────────────────────
// All on GPU; init helpers in ml-core::cuda_autograd::init use
// pinned host buffers for the Glorot/Xavier seed transfer.
let w_in = OwnedGpuLinear::xavier("mamba2.w_in", config.in_dim, config.hidden_dim, &stream)
.map_err(|e| anyhow!("init w_in: {e}"))?;
let w_a = OwnedGpuLinear::xavier(
"mamba2.w_a", config.hidden_dim, config.state_dim, &stream,
)
.map_err(|e| anyhow!("init w_a: {e}"))?;
let w_b = OwnedGpuLinear::xavier(
"mamba2.w_b", config.hidden_dim, config.state_dim, &stream,
)
.map_err(|e| anyhow!("init w_b: {e}"))?;
// W_c is a raw kernel-fed weight (no bias). Initialise via
// OwnedGpuLinear::xavier then keep only the weight slice.
let w_c_linear = OwnedGpuLinear::xavier(
"mamba2.w_c", config.state_dim, config.hidden_dim, &stream,
)
.map_err(|e| anyhow!("init w_c: {e}"))?;
let w_c = w_c_linear.weight; // discard bias for kernel-direct use
let w_out = OwnedGpuLinear::xavier("mamba2.w_out", config.hidden_dim, 1, &stream)
.map_err(|e| anyhow!("init w_out: {e}"))?;
let raw_stream = stream.cu_stream();
Ok(Self {
config,
stream,
raw_stream,
w_in,
w_a,
w_b,
w_c,
w_out,
_module: module,
kernel_fwd,
kernel_bwd,
kernel_fwd_seq,
kernel_fwd_step,
kernel_bwd_seq,
kernel_reduce_d_proj,
kernel_reduce_d_w_c,
kernel_adamw,
cublas,
_cublas_workspace: cublas_workspace,
})
}
// ── Forward pass ───────────────────────────────────────────────────
/// GPU-native forward pass with full activation cache returned alongside
/// the output logits. Use this when training (backward needs the cache).
///
/// Steps (all on GPU; no host roundtrip):
/// 1. `x = input @ Wᵢₙ.T + bᵢₙ` (cuBLAS sgemm + bias add)
/// 2. `a = x @ Wₐ.T + bₐ` (cuBLAS sgemm + bias add)
/// 3. `b = x @ Wᵦ.T + bᵦ` (cuBLAS sgemm + bias add)
/// 4. zero-init `h_s2[B, hidden_dim]`, scratch `h_enriched[B, hidden_dim]`
/// 5. launch `mamba2_alpha_scan_fwd(a, b, w_c, h_s2, h_enriched, B, K, H, S)`
/// 6. `logit = h_enriched @ Wₒᵤₜ.T + bₒᵤₜ` (cuBLAS sgemm + bias add)
pub fn forward_train(&self, input: &GpuTensor) -> Result<(GpuTensor, Mamba2ForwardCache)> {
let c = &self.config;
let n_batch = match input.shape() {
[b, k, d] if *k == c.seq_len && *d == c.in_dim => *b,
shape => {
return Err(anyhow!(
"Mamba2Block::forward_train: expected input shape [B, {}, {}], got {:?}",
c.seq_len, c.in_dim, shape
));
}
};
let n_rows = n_batch * c.seq_len; // flattened over time for the per-step projections
// Reshape input to [N*K, in_dim] (logical reshape — same storage).
let input_2d = GpuTensor::new(
input.cuda_data().clone(),
vec![n_rows, c.in_dim],
)
.map_err(|e| anyhow!("reshape input → 2D: {e}"))?;
// (1) input projection: x = input @ W_in.T + b_in → [N*K, hidden_dim]
let (x, _) = self.w_in.inner.forward_with_slices(
&input_2d, &self.w_in.weight, &self.w_in.bias, &self.cublas, &self.stream,
).map_err(|e| anyhow!("w_in forward: {e}"))?;
// (2) a_proj = x @ W_a.T + b_a → [N*K, state_dim]
let (a_proj, _) = self.w_a.inner.forward_with_slices(
&x, &self.w_a.weight, &self.w_a.bias, &self.cublas, &self.stream,
).map_err(|e| anyhow!("w_a forward: {e}"))?;
// (3) b_proj = x @ W_b.T + b_b → [N*K, state_dim]
let (b_proj, _) = self.w_b.inner.forward_with_slices(
&x, &self.w_b.weight, &self.w_b.bias, &self.cublas, &self.stream,
).map_err(|e| anyhow!("w_b forward: {e}"))?;
// (4) initial hidden h_s2 (zero residual; no carry from previous chunk)
// and output buffer h_enriched, both [N, hidden_dim].
let h_s2 = GpuTensor::zeros(&[n_batch, c.hidden_dim], &self.stream)
.map_err(|e| anyhow!("alloc h_s2: {e}"))?;
let mut h_enriched = GpuTensor::zeros(&[n_batch, c.hidden_dim], &self.stream)
.map_err(|e| anyhow!("alloc h_enriched: {e}"))?;
// (5) launch the scan kernel. Grid: (N, ceil(H/32)), Block: 32.
let block_threads: u32 = 32;
let grid_x: u32 = n_batch as u32;
let grid_y: u32 =
((c.hidden_dim + block_threads as usize - 1) / block_threads as usize) as u32;
let n_i32 = n_batch as i32;
let k_i32 = c.seq_len as i32;
let sh2_i32 = c.hidden_dim as i32;
let st_i32 = c.state_dim as i32;
{
let mut args = RawArgs::new();
args.push_ptr(a_proj.cuda_data().raw_ptr());
args.push_ptr(b_proj.cuda_data().raw_ptr());
args.push_ptr(self.w_c.raw_ptr());
args.push_ptr(h_s2.cuda_data().raw_ptr());
args.push_ptr(h_enriched.data_mut().raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_fwd.cu_function(),
(grid_x, grid_y, 1), (block_threads, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("mamba2_alpha_scan_fwd launch: {e:?}"))?;
}
}
// (6) output projection: logit = h_enriched @ W_out.T + b_out → [N, 1]
let (logit, _) = self.w_out.inner.forward_with_slices(
&h_enriched, &self.w_out.weight, &self.w_out.bias, &self.cublas, &self.stream,
).map_err(|e| anyhow!("w_out forward: {e}"))?;
let cache = Mamba2ForwardCache { input_2d, x, a_proj, b_proj, h_enriched };
Ok((logit, cache))
}
/// Forward inference only — runs `forward_train` and discards the cache.
/// Use when you only need predictions (eval, calibration set scoring).
#[inline]
pub fn forward(&self, input: &GpuTensor) -> Result<GpuTensor> {
self.forward_train(input).map(|(logit, _cache)| logit)
}
// ── Backward pass ─────────────────────────────────────────────────
/// GPU-native backward pass. `d_logit` is the upstream gradient
/// `[n_batch, 1]` flowing back from the loss; `cache` is the
/// `Mamba2ForwardCache` returned by [`forward_train`]. Returns all
/// nine parameter gradients in a [`Mamba2BackwardGrads`].
///
/// Chain (mirrors forward in reverse):
/// 6. W_out backward : d_logit + h_enriched_act → d_h_enriched, dw_out, db_out
/// 5. scan backward : (a_proj, b_proj, d_h_enriched, W_c)
/// → d_a_per_channel, d_b_per_channel,
/// d_w_c_per_sample, d_h_s2 (=d_h_enriched)
/// ↳ reduce d_a_per_channel across j → d_a_proj [N, K, state]
/// ↳ reduce d_b_per_channel across j → d_b_proj [N, K, state]
/// ↳ reduce d_w_c_per_sample across i → dw_c [sh2, state]
/// 3. W_b backward : d_b_proj[N*K, state] + x_act → d_x_from_b, dw_b, db_b
/// 2. W_a backward : d_a_proj[N*K, state] + x_act → d_x_from_a, dw_a, db_a
/// ↳ d_x = d_x_from_a + d_x_from_b
/// 1. W_in backward : d_x[N*K, hidden] + input_act → (d_input dropped), dw_in, db_in
pub fn backward(
&self,
cache: &Mamba2ForwardCache,
d_logit: &GpuTensor,
) -> Result<Mamba2BackwardGrads> {
let c = &self.config;
let n_batch = cache.h_enriched.shape()[0];
let n_rows = n_batch * c.seq_len;
if d_logit.shape() != [n_batch, 1] {
return Err(anyhow!(
"Mamba2Block::backward: d_logit shape {:?} != [{}, 1]",
d_logit.shape(), n_batch
));
}
// ── 6. Output projection backward ────────────────────────────
let h_enriched_act = LinearActivations { input: cache.h_enriched.clone() };
let LinearGrads { dw: dw_out, db: db_out, dx: d_h_enriched } = self
.w_out
.inner
.backward_with_slices(d_logit, &h_enriched_act, &self.w_out.weight,
&self.cublas, &self.stream)
.map_err(|e| anyhow!("w_out backward: {e}"))?;
// ── 5. Allocate scan-backward scratch buffers ────────────────
let per_chan_n = n_batch * c.hidden_dim * c.seq_len * c.state_dim;
let per_sample_n = n_batch * c.hidden_dim * c.state_dim;
let d_a_per_channel = self.stream
.alloc_zeros::<f32>(per_chan_n)
.map_err(|e| anyhow!("alloc d_a_per_channel ({} floats): {e}", per_chan_n))?;
let d_b_per_channel = self.stream
.alloc_zeros::<f32>(per_chan_n)
.map_err(|e| anyhow!("alloc d_b_per_channel: {e}"))?;
let d_w_c_per_sample = self.stream
.alloc_zeros::<f32>(per_sample_n)
.map_err(|e| anyhow!("alloc d_w_c_per_sample: {e}"))?;
let d_h_s2 = self.stream
.alloc_zeros::<f32>(n_batch * c.hidden_dim)
.map_err(|e| anyhow!("alloc d_h_s2: {e}"))?;
// ── Launch scan backward kernel ───────────────────────────────
let block_threads: u32 = 32;
let grid_y_h: u32 =
((c.hidden_dim + block_threads as usize - 1) / block_threads as usize) as u32;
let n_i32 = n_batch as i32;
let k_i32 = c.seq_len as i32;
let sh2_i32 = c.hidden_dim as i32;
let st_i32 = c.state_dim as i32;
{
let mut args = RawArgs::new();
args.push_ptr(cache.a_proj.cuda_data().raw_ptr());
args.push_ptr(cache.b_proj.cuda_data().raw_ptr());
args.push_ptr(d_h_enriched.cuda_data().raw_ptr());
args.push_ptr(self.w_c.raw_ptr());
args.push_ptr(d_a_per_channel.raw_ptr());
args.push_ptr(d_b_per_channel.raw_ptr());
args.push_ptr(d_w_c_per_sample.raw_ptr());
args.push_ptr(d_h_s2.raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_bwd.cu_function(),
(n_batch as u32, grid_y_h, 1), (block_threads, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("mamba2_alpha_scan_bwd launch: {e:?}"))?;
}
}
// ── Reduce d_a_per_channel and d_b_per_channel across j ───────
let red_grid_z: u32 =
((c.state_dim + block_threads as usize - 1) / block_threads as usize) as u32;
let red_cfg = (
(n_batch as u32, c.seq_len as u32, red_grid_z),
(block_threads, 1, 1),
);
let d_a_proj_flat: CudaSlice<f32> = self.stream
.alloc_zeros::<f32>(n_rows * c.state_dim)
.map_err(|e| anyhow!("alloc d_a_proj_flat: {e}"))?;
let d_b_proj_flat: CudaSlice<f32> = self.stream
.alloc_zeros::<f32>(n_rows * c.state_dim)
.map_err(|e| anyhow!("alloc d_b_proj_flat: {e}"))?;
{
let mut args = RawArgs::new();
args.push_ptr(d_a_per_channel.raw_ptr());
args.push_ptr(d_a_proj_flat.raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_reduce_d_proj.cu_function(),
red_cfg.0, red_cfg.1, 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("reduce_d_proj (a) launch: {e:?}"))?;
}
}
{
let mut args = RawArgs::new();
args.push_ptr(d_b_per_channel.raw_ptr());
args.push_ptr(d_b_proj_flat.raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_reduce_d_proj.cu_function(),
red_cfg.0, red_cfg.1, 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("reduce_d_proj (b) launch: {e:?}"))?;
}
}
// ── Reduce d_w_c_per_sample across i → dw_c[sh2, state] ───────
let red_w_c_grid_y: u32 = red_grid_z;
let red_w_c_cfg = (
(c.hidden_dim as u32, red_w_c_grid_y, 1),
(block_threads, 1, 1),
);
let dw_c: CudaSlice<f32> = self.stream
.alloc_zeros::<f32>(c.hidden_dim * c.state_dim)
.map_err(|e| anyhow!("alloc dw_c: {e}"))?;
{
let mut args = RawArgs::new();
args.push_ptr(d_w_c_per_sample.raw_ptr());
args.push_ptr(dw_c.raw_ptr());
args.push_i32(n_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_reduce_d_w_c.cu_function(),
red_w_c_cfg.0, red_w_c_cfg.1, 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("reduce_d_w_c launch: {e:?}"))?;
}
}
// ── 3. W_b backward (uses x_act = cache.x) ───────────────────
let d_b_proj_2d = GpuTensor::new(d_b_proj_flat, vec![n_rows, c.state_dim])
.map_err(|e| anyhow!("reshape d_b_proj: {e}"))?;
let x_act = LinearActivations { input: cache.x.clone() };
let LinearGrads { dw: dw_b, db: db_b, dx: d_x_from_b } = self
.w_b
.inner
.backward_with_slices(&d_b_proj_2d, &x_act, &self.w_b.weight,
&self.cublas, &self.stream)
.map_err(|e| anyhow!("w_b backward: {e}"))?;
// ── 2. W_a backward ─────────────────────────────────────────
let d_a_proj_2d = GpuTensor::new(d_a_proj_flat, vec![n_rows, c.state_dim])
.map_err(|e| anyhow!("reshape d_a_proj: {e}"))?;
let LinearGrads { dw: dw_a, db: db_a, dx: d_x_from_a } = self
.w_a
.inner
.backward_with_slices(&d_a_proj_2d, &x_act, &self.w_a.weight,
&self.cublas, &self.stream)
.map_err(|e| anyhow!("w_a backward: {e}"))?;
// d_x = d_x_from_a + d_x_from_b
let d_x = d_x_from_a.add(&d_x_from_b, &self.stream)
.map_err(|e| anyhow!("sum d_x branches: {e}"))?;
// ── 1. W_in backward ─────────────────────────────────────────
let input_act = LinearActivations { input: cache.input_2d.clone() };
let LinearGrads { dw: dw_in, db: db_in, dx: _d_input } = self
.w_in
.inner
.backward_with_slices(&d_x, &input_act, &self.w_in.weight,
&self.cublas, &self.stream)
.map_err(|e| anyhow!("w_in backward: {e}"))?;
Ok(Mamba2BackwardGrads {
dw_in, db_in,
dw_a, db_a,
dw_b, db_b,
dw_c,
dw_out, db_out,
})
}
/// Phase E.4.A T10 (2026-05-15): backward variant that BYPASSES the
/// W_out projection. Phase E uses Mamba2's `h_enriched` directly as
/// input to a downstream classifier (C51 head); the `d_logit`-based
/// `backward` is unsuitable because Phase E never computes a
/// `d_logit` (there is no scalar logit in its loss path).
///
/// `d_h_enriched` MUST have shape `[B, hidden_dim]` matching
/// `cache.h_enriched`. Returns `Mamba2BackwardGrads` where
/// `dw_out` and `db_out` are zero-initialised tensors of the
/// correct shapes (AdamW step on zero grad is a no-op with the
/// correct moment-decay; W_out parameters effectively freeze —
/// correct semantics since Phase E never uses them).
pub fn backward_from_h_enriched(
&self,
cache: &Mamba2ForwardCache,
d_h_enriched: &GpuTensor,
) -> Result<Mamba2BackwardGrads> {
let c = &self.config;
let n_batch = cache.h_enriched.shape()[0];
let n_rows = n_batch * c.seq_len;
if d_h_enriched.shape() != [n_batch, c.hidden_dim] {
return Err(anyhow!(
"Mamba2Block::backward_from_h_enriched: \
d_h_enriched shape {:?} != [{}, {}]",
d_h_enriched.shape(), n_batch, c.hidden_dim
));
}
// ── 6 SKIPPED. Phase E doesn't go through W_out. ────────────
// (Zero w_out grads are constructed at return.)
// ── 5. Allocate scan-backward scratch buffers ────────────────
let per_chan_n = n_batch * c.hidden_dim * c.seq_len * c.state_dim;
let per_sample_n = n_batch * c.hidden_dim * c.state_dim;
let d_a_per_channel = self.stream
.alloc_zeros::<f32>(per_chan_n)
.map_err(|e| anyhow!("alloc d_a_per_channel: {e}"))?;
let d_b_per_channel = self.stream
.alloc_zeros::<f32>(per_chan_n)
.map_err(|e| anyhow!("alloc d_b_per_channel: {e}"))?;
let d_w_c_per_sample = self.stream
.alloc_zeros::<f32>(per_sample_n)
.map_err(|e| anyhow!("alloc d_w_c_per_sample: {e}"))?;
let d_h_s2 = self.stream
.alloc_zeros::<f32>(n_batch * c.hidden_dim)
.map_err(|e| anyhow!("alloc d_h_s2: {e}"))?;
let block_threads: u32 = 32;
let grid_y_h: u32 =
((c.hidden_dim + block_threads as usize - 1) / block_threads as usize) as u32;
let n_i32 = n_batch as i32;
let k_i32 = c.seq_len as i32;
let sh2_i32 = c.hidden_dim as i32;
let st_i32 = c.state_dim as i32;
{
let mut args = RawArgs::new();
args.push_ptr(cache.a_proj.cuda_data().raw_ptr());
args.push_ptr(cache.b_proj.cuda_data().raw_ptr());
args.push_ptr(d_h_enriched.cuda_data().raw_ptr());
args.push_ptr(self.w_c.raw_ptr());
args.push_ptr(d_a_per_channel.raw_ptr());
args.push_ptr(d_b_per_channel.raw_ptr());
args.push_ptr(d_w_c_per_sample.raw_ptr());
args.push_ptr(d_h_s2.raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_bwd.cu_function(),
(n_batch as u32, grid_y_h, 1), (block_threads, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("mamba2_alpha_scan_bwd launch: {e:?}"))?;
}
}
// ── Reduce d_a_per_channel and d_b_per_channel ───────────────
let red_grid_z: u32 =
((c.state_dim + block_threads as usize - 1) / block_threads as usize) as u32;
let red_cfg = (
(n_batch as u32, c.seq_len as u32, red_grid_z),
(block_threads, 1, 1),
);
let d_a_proj_flat: CudaSlice<f32> = self.stream
.alloc_zeros::<f32>(n_rows * c.state_dim)
.map_err(|e| anyhow!("alloc d_a_proj_flat: {e}"))?;
let d_b_proj_flat: CudaSlice<f32> = self.stream
.alloc_zeros::<f32>(n_rows * c.state_dim)
.map_err(|e| anyhow!("alloc d_b_proj_flat: {e}"))?;
{
let mut args = RawArgs::new();
args.push_ptr(d_a_per_channel.raw_ptr());
args.push_ptr(d_a_proj_flat.raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_reduce_d_proj.cu_function(),
red_cfg.0, red_cfg.1, 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("reduce d_a_proj: {e:?}"))?;
}
}
{
let mut args = RawArgs::new();
args.push_ptr(d_b_per_channel.raw_ptr());
args.push_ptr(d_b_proj_flat.raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_reduce_d_proj.cu_function(),
red_cfg.0, red_cfg.1, 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("reduce d_b_proj: {e:?}"))?;
}
}
// ── Reduce d_w_c_per_sample → dw_c ──────────────────────────
let dw_c: CudaSlice<f32> = self.stream
.alloc_zeros::<f32>(c.hidden_dim * c.state_dim)
.map_err(|e| anyhow!("alloc dw_c: {e}"))?;
{
let mut args = RawArgs::new();
args.push_ptr(d_w_c_per_sample.raw_ptr());
args.push_ptr(dw_c.raw_ptr());
args.push_i32(n_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_reduce_d_w_c.cu_function(),
(c.hidden_dim as u32, red_grid_z, 1), (block_threads, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("reduce dw_c: {e:?}"))?;
}
}
// ── 3. W_b backward ─────────────────────────────────────────
let d_b_proj_2d = GpuTensor::new(d_b_proj_flat, vec![n_rows, c.state_dim])
.map_err(|e| anyhow!("reshape d_b_proj: {e}"))?;
let x_act = LinearActivations { input: cache.x.clone() };
let LinearGrads { dw: dw_b, db: db_b, dx: d_x_from_b } = self
.w_b.inner
.backward_with_slices(&d_b_proj_2d, &x_act, &self.w_b.weight,
&self.cublas, &self.stream)
.map_err(|e| anyhow!("w_b backward: {e}"))?;
// ── 2. W_a backward ─────────────────────────────────────────
let d_a_proj_2d = GpuTensor::new(d_a_proj_flat, vec![n_rows, c.state_dim])
.map_err(|e| anyhow!("reshape d_a_proj: {e}"))?;
let LinearGrads { dw: dw_a, db: db_a, dx: d_x_from_a } = self
.w_a.inner
.backward_with_slices(&d_a_proj_2d, &x_act, &self.w_a.weight,
&self.cublas, &self.stream)
.map_err(|e| anyhow!("w_a backward: {e}"))?;
let d_x = d_x_from_a.add(&d_x_from_b, &self.stream)
.map_err(|e| anyhow!("sum d_x branches: {e}"))?;
// ── 1. W_in backward ────────────────────────────────────────
let input_act = LinearActivations { input: cache.input_2d.clone() };
let LinearGrads { dw: dw_in, db: db_in, dx: _d_input } = self
.w_in.inner
.backward_with_slices(&d_x, &input_act, &self.w_in.weight,
&self.cublas, &self.stream)
.map_err(|e| anyhow!("w_in backward: {e}"))?;
// ── 6 replacement: zero W_out grads ────────────────────────
// AdamW will see zero grad → effectively freezes w_out
// parameters. Correct semantics — Phase E doesn't use them.
let dw_out = GpuTensor::zeros(&[1, c.hidden_dim], &self.stream)
.map_err(|e| anyhow!("alloc zero dw_out: {e}"))?;
let db_out = GpuTensor::zeros(&[1], &self.stream)
.map_err(|e| anyhow!("alloc zero db_out: {e}"))?;
Ok(Mamba2BackwardGrads {
dw_in, db_in, dw_a, db_a, dw_b, db_b, dw_c, dw_out, db_out,
})
}
/// Per-step variant of [`Mamba2Block::forward_train`] — emits the SSM
/// output at EVERY timestep instead of only the final position. Used
/// by ml-alpha PerceptionTrainer to supervise the model at every
/// snapshot in a sequence (denser gradient signal than final-step
/// only).
///
/// Skips the W_out projection — callers route `h_enriched_seq`
/// directly into a downstream head. Caller pairs this with
/// [`Mamba2Block::backward_from_h_enriched_seq`] for the backward
/// chain.
pub fn forward_train_seq(
&self,
input: &GpuTensor,
) -> Result<(GpuTensor, Mamba2ForwardCacheSeq)> {
let c = &self.config;
let n_batch = match input.shape() {
[b, k, d] if *k == c.seq_len && *d == c.in_dim => *b,
shape => {
return Err(anyhow!(
"Mamba2Block::forward_train_seq: expected [B, {}, {}], got {:?}",
c.seq_len, c.in_dim, shape
));
}
};
let n_rows = n_batch * c.seq_len;
let input_2d = GpuTensor::new(input.cuda_data().clone(), vec![n_rows, c.in_dim])
.map_err(|e| anyhow!("reshape input → 2D: {e}"))?;
let (x, _) = self.w_in.inner.forward_with_slices(
&input_2d, &self.w_in.weight, &self.w_in.bias, &self.cublas, &self.stream,
).map_err(|e| anyhow!("w_in forward: {e}"))?;
let (a_proj, _) = self.w_a.inner.forward_with_slices(
&x, &self.w_a.weight, &self.w_a.bias, &self.cublas, &self.stream,
).map_err(|e| anyhow!("w_a forward: {e}"))?;
let (b_proj, _) = self.w_b.inner.forward_with_slices(
&x, &self.w_b.weight, &self.w_b.bias, &self.cublas, &self.stream,
).map_err(|e| anyhow!("w_b forward: {e}"))?;
let h_s2 = GpuTensor::zeros(&[n_batch, c.hidden_dim], &self.stream)
.map_err(|e| anyhow!("alloc h_s2: {e}"))?;
let mut h_enriched_seq = GpuTensor::zeros(
&[n_batch, c.seq_len, c.hidden_dim],
&self.stream,
)
.map_err(|e| anyhow!("alloc h_enriched_seq: {e}"))?;
let block_threads: u32 = 32;
let grid_y: u32 =
((c.hidden_dim + block_threads as usize - 1) / block_threads as usize) as u32;
let n_i32 = n_batch as i32;
let k_i32 = c.seq_len as i32;
let sh2_i32 = c.hidden_dim as i32;
let st_i32 = c.state_dim as i32;
{
let mut args = RawArgs::new();
args.push_ptr(a_proj.cuda_data().raw_ptr());
args.push_ptr(b_proj.cuda_data().raw_ptr());
args.push_ptr(self.w_c.raw_ptr());
args.push_ptr(h_s2.cuda_data().raw_ptr());
args.push_ptr(h_enriched_seq.data_mut().raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_fwd_seq.cu_function(),
(n_batch as u32, grid_y, 1), (block_threads, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("scan_fwd_seq launch: {e:?}"))?;
}
}
let cache = Mamba2ForwardCacheSeq {
input_2d, x, a_proj, b_proj,
h_enriched_seq: h_enriched_seq.clone(),
};
Ok((h_enriched_seq, cache))
}
/// Zero-allocation variant of [`forward_train_seq`] — writes all
/// intermediates into caller-provided pre-allocated buffers
/// ([`Mamba2BlockForwardScratch`]). No `GpuTensor::zeros` / cuBLAS
/// alloc per call, no `cudaMalloc`. The scratch's `h_enriched_seq`
/// IS the output (no fresh tensor returned).
///
/// Designed for the PerceptionTrainer hot path and CUDA Graph
/// capture. The scratch's contents are valid until the next
/// `forward_train_seq_into` call.
pub fn forward_train_seq_into(
&self,
input: &GpuTensor,
scratch: &mut Mamba2BlockForwardScratch,
) -> Result<()> {
let c = &self.config;
let n_batch = match input.shape() {
[b, k, d] if *k == c.seq_len && *d == c.in_dim => *b,
shape => {
return Err(anyhow!(
"forward_train_seq_into: expected [B, {}, {}], got {:?}",
c.seq_len, c.in_dim, shape
));
}
};
anyhow::ensure!(
scratch.n_batch == n_batch
&& scratch.seq_len == c.seq_len
&& scratch.in_dim == c.in_dim
&& scratch.hidden_dim == c.hidden_dim
&& scratch.state_dim == c.state_dim,
"fwd scratch shape mismatch: expected ({},{},{},{},{}) got ({},{},{},{},{})",
n_batch, c.seq_len, c.in_dim, c.hidden_dim, c.state_dim,
scratch.n_batch, scratch.seq_len, scratch.in_dim,
scratch.hidden_dim, scratch.state_dim
);
let n_rows = n_batch * c.seq_len;
// 1. x = input @ W_in.T + b_in — input is [B, K, in_dim], viewed
// as [B*K, in_dim] via raw slice + batch dim (no GpuTensor wrap,
// no CudaSlice::clone — cuMemAlloc inside capture is forbidden).
self.w_in.inner.forward_with_slices_into(
input.cuda_data(), n_rows,
&self.w_in.weight, &self.w_in.bias,
&self.cublas, &self.stream, &mut scratch.x,
).map_err(|e| anyhow!("w_in fwd_into: {e}"))?;
// 2. a_proj = x @ W_a.T + b_a.
self.w_a.inner.forward_with_slices_into(
scratch.x.cuda_data(), n_rows,
&self.w_a.weight, &self.w_a.bias,
&self.cublas, &self.stream, &mut scratch.a_proj,
).map_err(|e| anyhow!("w_a fwd_into: {e}"))?;
// 3. b_proj = x @ W_b.T + b_b.
self.w_b.inner.forward_with_slices_into(
scratch.x.cuda_data(), n_rows,
&self.w_b.weight, &self.w_b.bias,
&self.cublas, &self.stream, &mut scratch.b_proj,
).map_err(|e| anyhow!("w_b fwd_into: {e}"))?;
// 4. scan_fwd_seq → scratch.h_enriched_seq.
// h_s2 stays zero from construction (never written in supervised path).
let block_threads: u32 = 32;
let grid_y: u32 =
((c.hidden_dim + block_threads as usize - 1) / block_threads as usize) as u32;
let n_i32 = n_batch as i32;
let k_i32 = c.seq_len as i32;
let sh2_i32 = c.hidden_dim as i32;
let st_i32 = c.state_dim as i32;
{
let mut args = RawArgs::new();
args.push_ptr(scratch.a_proj.cuda_data().raw_ptr());
args.push_ptr(scratch.b_proj.cuda_data().raw_ptr());
args.push_ptr(self.w_c.raw_ptr());
args.push_ptr(scratch.h_s2.cuda_data().raw_ptr());
args.push_ptr(scratch.h_enriched_seq.data_mut().raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_fwd_seq.cu_function(),
(n_batch as u32, grid_y, 1), (block_threads, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("scan_fwd_seq_into launch: {e:?}"))?;
}
}
Ok(())
}
/// CRT Phase A0.5: single-step incremental forward pass.
///
/// Advances the SSM register state (`scratch.x_state`) by exactly one
/// timestep using the snapshot held in `input` (shape `[n_batch, in_dim]`).
/// Writes the enriched output to `scratch.h_out` (shape
/// `[n_batch, hidden_dim]`). The recurrent state in `scratch.x_state`
/// is updated in-place so the next call continues from the new state.
///
/// Sequence equivalence: calling step_into N times on snapshots
/// `(x_0, x_1, … x_{N-1})` produces the same h_out at step N-1 as
/// `forward_train_seq_into` over the same N snapshots with K=N would
/// produce at the final timestep position (within float-summation
/// tolerance — the per-step kernel does the same arithmetic in the
/// same order, the only differences are GEMM-batch granularity and
/// the absence of intermediate per-step h_enriched writes).
pub fn step_into(
&self,
input: &GpuTensor,
scratch: &mut Mamba2BlockStepScratch,
) -> Result<()> {
let c = &self.config;
let n_batch = match input.shape() {
[b, d] if *d == c.in_dim => *b,
// Accept [B, 1, in_dim] too — keeps callers free to use the
// same staging tensor shape as forward_train_seq_into.
[b, k, d] if *k == 1 && *d == c.in_dim => *b,
shape => {
return Err(anyhow!(
"step_into: expected input shape [B, {0}] or [B, 1, {0}], got {1:?}",
c.in_dim, shape
));
}
};
anyhow::ensure!(
scratch.n_batch == n_batch
&& scratch.in_dim == c.in_dim
&& scratch.hidden_dim == c.hidden_dim
&& scratch.state_dim == c.state_dim,
"step scratch shape mismatch: expected ({},{},{},{}) got ({},{},{},{})",
n_batch, c.in_dim, c.hidden_dim, c.state_dim,
scratch.n_batch, scratch.in_dim, scratch.hidden_dim, scratch.state_dim
);
// 1. x = input @ W_in.T + b_in — single row per batch entry.
self.w_in.inner.forward_with_slices_into(
input.cuda_data(), n_batch,
&self.w_in.weight, &self.w_in.bias,
&self.cublas, &self.stream, &mut scratch.x,
).map_err(|e| anyhow!("w_in step fwd_into: {e}"))?;
// 2. a_proj = x @ W_a.T + b_a.
self.w_a.inner.forward_with_slices_into(
scratch.x.cuda_data(), n_batch,
&self.w_a.weight, &self.w_a.bias,
&self.cublas, &self.stream, &mut scratch.a_proj,
).map_err(|e| anyhow!("w_a step fwd_into: {e}"))?;
// 3. b_proj = x @ W_b.T + b_b.
self.w_b.inner.forward_with_slices_into(
scratch.x.cuda_data(), n_batch,
&self.w_b.weight, &self.w_b.bias,
&self.cublas, &self.stream, &mut scratch.b_proj,
).map_err(|e| anyhow!("w_b step fwd_into: {e}"))?;
// 4. scan_fwd_step — reads + writes scratch.x_state in-place,
// writes scratch.h_out. h_s2 stays zero (matches supervised
// forward_train_seq_into convention).
let block_threads: u32 = 32;
let grid_y: u32 =
((c.hidden_dim + block_threads as usize - 1) / block_threads as usize) as u32;
let n_i32 = n_batch as i32;
let sh2_i32 = c.hidden_dim as i32;
let st_i32 = c.state_dim as i32;
{
let mut args = RawArgs::new();
args.push_ptr(scratch.x_state.raw_ptr());
args.push_ptr(scratch.a_proj.cuda_data().raw_ptr());
args.push_ptr(scratch.b_proj.cuda_data().raw_ptr());
args.push_ptr(self.w_c.raw_ptr());
args.push_ptr(scratch.h_s2.cuda_data().raw_ptr());
args.push_ptr(scratch.h_out.data_mut().raw_ptr());
args.push_i32(n_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_fwd_step.cu_function(),
(n_batch as u32, grid_y, 1), (block_threads, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("scan_fwd_step launch: {e:?}"))?;
}
}
Ok(())
}
/// CRT Phase A0.5 (corrective) — bit-identical seeding helper.
/// Advances `scratch.x_state` by one timestep reading per-step
/// `a_proj` / `b_proj` row pointers from an EXTERNAL seq-path scratch
/// (typically the trainer's `mamba2_fwd_scratch.a_proj/b_proj` after
/// a `forward_only` call). Same scan kernel as `step_into`, but skips
/// the `w_in`/`w_a`/`w_b` GEMMs because the caller already has those
/// projections from a one-shot batched cuBLAS call.
///
/// This is the key to bit-identity vs forward_only: the seq path's
/// W_in→W_a/W_b GEMMs are computed once over all K rows in one cuBLAS
/// call. Re-running them per-row through `step_into` would not match
/// bit-for-bit (cuBLAS picks different algorithms for different
/// matrix shapes). By replaying scan_fwd_step over the seq path's
/// already-computed a_proj/b_proj, the x_state evolution is bit-
/// identical to scan_fwd_seq's internal register-state trajectory.
///
/// Args:
/// - `a_proj_row_ptr` / `b_proj_row_ptr`: device pointers into
/// `[N=n_batch, state_d]` slices of the seq scratch at the k-th row
/// (caller computes the byte offset).
/// - `scratch`: step scratch; `x_state` is read + written in place.
pub fn step_advance_from_seq_row(
&self,
a_proj_row_ptr: u64,
b_proj_row_ptr: u64,
scratch: &mut Mamba2BlockStepScratch,
) -> Result<()> {
let c = &self.config;
anyhow::ensure!(
scratch.hidden_dim == c.hidden_dim && scratch.state_dim == c.state_dim,
"step scratch shape mismatch: hidden_dim {} vs {}, state_dim {} vs {}",
scratch.hidden_dim, c.hidden_dim, scratch.state_dim, c.state_dim
);
let n_batch = scratch.n_batch;
let block_threads: u32 = 32;
let grid_y: u32 =
((c.hidden_dim + block_threads as usize - 1) / block_threads as usize) as u32;
let n_i32 = n_batch as i32;
let sh2_i32 = c.hidden_dim as i32;
let st_i32 = c.state_dim as i32;
{
let mut args = RawArgs::new();
args.push_ptr(scratch.x_state.raw_ptr());
args.push_ptr(a_proj_row_ptr);
args.push_ptr(b_proj_row_ptr);
args.push_ptr(self.w_c.raw_ptr());
args.push_ptr(scratch.h_s2.cuda_data().raw_ptr());
args.push_ptr(scratch.h_out.data_mut().raw_ptr());
args.push_i32(n_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_fwd_step.cu_function(),
(n_batch as u32, grid_y, 1), (block_threads, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("scan_fwd_step (seed) launch: {e:?}"))?;
}
}
Ok(())
}
/// Backward chain paired with [`forward_train_seq`]. `d_h_enriched_seq`
/// has shape `[N, K, hidden_dim]` matching `cache.h_enriched_seq`.
/// Returns all nine parameter gradients (`dw_out` / `db_out` zeroed,
/// since this path skips W_out entirely).
pub fn backward_from_h_enriched_seq(
&self,
cache: &Mamba2ForwardCacheSeq,
d_h_enriched_seq: &GpuTensor,
) -> Result<Mamba2BackwardGrads> {
let c = &self.config;
let n_batch = cache.h_enriched_seq.shape()[0];
let n_rows = n_batch * c.seq_len;
if d_h_enriched_seq.shape() != [n_batch, c.seq_len, c.hidden_dim] {
return Err(anyhow!(
"Mamba2Block::backward_from_h_enriched_seq: d_h_enriched_seq \
shape {:?} != [{}, {}, {}]",
d_h_enriched_seq.shape(), n_batch, c.seq_len, c.hidden_dim
));
}
let per_chan_n = n_batch * c.hidden_dim * c.seq_len * c.state_dim;
let per_sample_n = n_batch * c.hidden_dim * c.state_dim;
let d_a_per_channel = self.stream
.alloc_zeros::<f32>(per_chan_n)
.map_err(|e| anyhow!("alloc d_a_per_channel: {e}"))?;
let d_b_per_channel = self.stream
.alloc_zeros::<f32>(per_chan_n)
.map_err(|e| anyhow!("alloc d_b_per_channel: {e}"))?;
let d_w_c_per_sample = self.stream
.alloc_zeros::<f32>(per_sample_n)
.map_err(|e| anyhow!("alloc d_w_c_per_sample: {e}"))?;
let d_h_s2 = self.stream
.alloc_zeros::<f32>(n_batch * c.hidden_dim)
.map_err(|e| anyhow!("alloc d_h_s2: {e}"))?;
let block_threads: u32 = 32;
let grid_y_h: u32 =
((c.hidden_dim + block_threads as usize - 1) / block_threads as usize) as u32;
let n_i32 = n_batch as i32;
let k_i32 = c.seq_len as i32;
let sh2_i32 = c.hidden_dim as i32;
let st_i32 = c.state_dim as i32;
{
let mut args = RawArgs::new();
args.push_ptr(cache.a_proj.cuda_data().raw_ptr());
args.push_ptr(cache.b_proj.cuda_data().raw_ptr());
args.push_ptr(d_h_enriched_seq.cuda_data().raw_ptr());
args.push_ptr(self.w_c.raw_ptr());
args.push_ptr(d_a_per_channel.raw_ptr());
args.push_ptr(d_b_per_channel.raw_ptr());
args.push_ptr(d_w_c_per_sample.raw_ptr());
args.push_ptr(d_h_s2.raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_bwd_seq.cu_function(),
(n_batch as u32, grid_y_h, 1), (block_threads, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("scan_bwd_seq launch: {e:?}"))?;
}
}
// Reductions are identical to the final-step variant — d_a/d_b
// tensors have the same [N, sh2, K, state_d] / [N, sh2, state_d]
// shapes regardless of how many steps contributed to them.
let red_grid_z: u32 =
((c.state_dim + block_threads as usize - 1) / block_threads as usize) as u32;
let red_cfg = (
(n_batch as u32, c.seq_len as u32, red_grid_z),
(block_threads, 1, 1),
);
let d_a_proj_flat: CudaSlice<f32> = self.stream
.alloc_zeros::<f32>(n_rows * c.state_dim)
.map_err(|e| anyhow!("alloc d_a_proj_flat: {e}"))?;
let d_b_proj_flat: CudaSlice<f32> = self.stream
.alloc_zeros::<f32>(n_rows * c.state_dim)
.map_err(|e| anyhow!("alloc d_b_proj_flat: {e}"))?;
{
let mut args = RawArgs::new();
args.push_ptr(d_a_per_channel.raw_ptr());
args.push_ptr(d_a_proj_flat.raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_reduce_d_proj.cu_function(),
red_cfg.0, red_cfg.1, 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("reduce d_a_proj seq: {e:?}"))?;
}
}
{
let mut args = RawArgs::new();
args.push_ptr(d_b_per_channel.raw_ptr());
args.push_ptr(d_b_proj_flat.raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_reduce_d_proj.cu_function(),
red_cfg.0, red_cfg.1, 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("reduce d_b_proj seq: {e:?}"))?;
}
}
let dw_c: CudaSlice<f32> = self.stream
.alloc_zeros::<f32>(c.hidden_dim * c.state_dim)
.map_err(|e| anyhow!("alloc dw_c seq: {e}"))?;
{
let mut args = RawArgs::new();
args.push_ptr(d_w_c_per_sample.raw_ptr());
args.push_ptr(dw_c.raw_ptr());
args.push_i32(n_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_reduce_d_w_c.cu_function(),
(c.hidden_dim as u32, red_grid_z, 1), (block_threads, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("reduce dw_c seq: {e:?}"))?;
}
}
let d_b_proj_2d = GpuTensor::new(d_b_proj_flat, vec![n_rows, c.state_dim])
.map_err(|e| anyhow!("reshape d_b_proj seq: {e}"))?;
let x_act = LinearActivations { input: cache.x.clone() };
let LinearGrads { dw: dw_b, db: db_b, dx: d_x_from_b } = self
.w_b.inner
.backward_with_slices(&d_b_proj_2d, &x_act, &self.w_b.weight,
&self.cublas, &self.stream)
.map_err(|e| anyhow!("w_b backward seq: {e}"))?;
let d_a_proj_2d = GpuTensor::new(d_a_proj_flat, vec![n_rows, c.state_dim])
.map_err(|e| anyhow!("reshape d_a_proj seq: {e}"))?;
let LinearGrads { dw: dw_a, db: db_a, dx: d_x_from_a } = self
.w_a.inner
.backward_with_slices(&d_a_proj_2d, &x_act, &self.w_a.weight,
&self.cublas, &self.stream)
.map_err(|e| anyhow!("w_a backward seq: {e}"))?;
let d_x = d_x_from_a.add(&d_x_from_b, &self.stream)
.map_err(|e| anyhow!("sum d_x branches seq: {e}"))?;
let input_act = LinearActivations { input: cache.input_2d.clone() };
let LinearGrads { dw: dw_in, db: db_in, dx: _d_input } = self
.w_in.inner
.backward_with_slices(&d_x, &input_act, &self.w_in.weight,
&self.cublas, &self.stream)
.map_err(|e| anyhow!("w_in backward seq: {e}"))?;
// W_out is unused by callers of forward_train_seq — zero grads
// freeze those parameters under AdamW.
let dw_out = GpuTensor::zeros(&[1, c.hidden_dim], &self.stream)
.map_err(|e| anyhow!("alloc zero dw_out seq: {e}"))?;
let db_out = GpuTensor::zeros(&[1], &self.stream)
.map_err(|e| anyhow!("alloc zero db_out seq: {e}"))?;
Ok(Mamba2BackwardGrads {
dw_in, db_in, dw_a, db_a, dw_b, db_b, dw_c, dw_out, db_out,
})
}
/// Perf-tuned backward variant — same math as
/// [`backward_from_h_enriched_seq`] but uses caller-provided
/// pre-allocated scratch buffers instead of allocating 7 fresh
/// CudaSlices per call. Cuts ~10-20ms/step on the training hot
/// path by eliminating `cudaMalloc` churn.
///
/// Callers MUST pre-allocate [`Mamba2BackwardScratch`] with shapes
/// matching the (n_batch, seq_len, hidden_dim, state_dim) used
/// here. The scratch buffer contents are overwritten by every call
/// (no carry between calls; each backward pass is self-contained).
pub fn backward_from_h_enriched_seq_into(
&self,
cache: &Mamba2ForwardCacheSeq,
d_h_enriched_seq: &GpuTensor,
scratch: &mut Mamba2BackwardScratch,
) -> Result<Mamba2BackwardGrads> {
let c = &self.config;
let n_batch = cache.h_enriched_seq.shape()[0];
let n_rows = n_batch * c.seq_len;
// Validate scratch shapes match — wrong scratch silently
// produces wrong gradients (size mismatch → out-of-bounds
// writes). Assert eagerly.
anyhow::ensure!(
scratch.n_batch == n_batch
&& scratch.seq_len == c.seq_len
&& scratch.hidden_dim == c.hidden_dim
&& scratch.state_dim == c.state_dim,
"Mamba2BackwardScratch shape mismatch: expected ({},{},{},{}) got ({},{},{},{})",
n_batch, c.seq_len, c.hidden_dim, c.state_dim,
scratch.n_batch, scratch.seq_len, scratch.hidden_dim, scratch.state_dim
);
anyhow::ensure!(
d_h_enriched_seq.shape() == [n_batch, c.seq_len, c.hidden_dim],
"d_h_enriched_seq shape {:?} != [{}, {}, {}]",
d_h_enriched_seq.shape(), n_batch, c.seq_len, c.hidden_dim
);
let block_threads: u32 = 32;
let grid_y_h: u32 = ((c.hidden_dim + block_threads as usize - 1) / block_threads as usize) as u32;
let n_i32 = n_batch as i32;
let k_i32 = c.seq_len as i32;
let sh2_i32 = c.hidden_dim as i32;
let st_i32 = c.state_dim as i32;
{
let mut args = RawArgs::new();
args.push_ptr(cache.a_proj.cuda_data().raw_ptr());
args.push_ptr(cache.b_proj.cuda_data().raw_ptr());
args.push_ptr(d_h_enriched_seq.cuda_data().raw_ptr());
args.push_ptr(self.w_c.raw_ptr());
args.push_ptr(scratch.d_a_per_channel.raw_ptr());
args.push_ptr(scratch.d_b_per_channel.raw_ptr());
args.push_ptr(scratch.d_w_c_per_sample.raw_ptr());
args.push_ptr(scratch.d_h_s2.raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_bwd_seq.cu_function(),
(n_batch as u32, grid_y_h, 1), (block_threads, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("scan_bwd_seq launch: {e:?}"))?;
}
}
// Small per-call buffers (~few KB each, no measurable cost).
let red_grid_z: u32 = ((c.state_dim + block_threads as usize - 1) / block_threads as usize) as u32;
let red_cfg = (
(n_batch as u32, c.seq_len as u32, red_grid_z),
(block_threads, 1, 1),
);
let d_a_proj_flat: CudaSlice<f32> = self.stream
.alloc_zeros::<f32>(n_rows * c.state_dim)
.map_err(|e| anyhow!("alloc d_a_proj_flat into: {e}"))?;
let d_b_proj_flat: CudaSlice<f32> = self.stream
.alloc_zeros::<f32>(n_rows * c.state_dim)
.map_err(|e| anyhow!("alloc d_b_proj_flat into: {e}"))?;
{
let mut args = RawArgs::new();
args.push_ptr(scratch.d_a_per_channel.raw_ptr());
args.push_ptr(d_a_proj_flat.raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_reduce_d_proj.cu_function(),
red_cfg.0, red_cfg.1, 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("reduce d_a_proj seq into: {e:?}"))?;
}
}
{
let mut args = RawArgs::new();
args.push_ptr(scratch.d_b_per_channel.raw_ptr());
args.push_ptr(d_b_proj_flat.raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_reduce_d_proj.cu_function(),
red_cfg.0, red_cfg.1, 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("reduce d_b_proj seq into: {e:?}"))?;
}
}
let dw_c: CudaSlice<f32> = self.stream
.alloc_zeros::<f32>(c.hidden_dim * c.state_dim)
.map_err(|e| anyhow!("alloc dw_c into: {e}"))?;
{
let mut args = RawArgs::new();
args.push_ptr(scratch.d_w_c_per_sample.raw_ptr());
args.push_ptr(dw_c.raw_ptr());
args.push_i32(n_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_reduce_d_w_c.cu_function(),
(c.hidden_dim as u32, red_grid_z, 1), (block_threads, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("reduce dw_c seq into: {e:?}"))?;
}
}
let d_b_proj_2d = GpuTensor::new(d_b_proj_flat, vec![n_rows, c.state_dim])
.map_err(|e| anyhow!("reshape d_b_proj seq into: {e}"))?;
let x_act = LinearActivations { input: cache.x.clone() };
let LinearGrads { dw: dw_b, db: db_b, dx: d_x_from_b } = self
.w_b.inner
.backward_with_slices(&d_b_proj_2d, &x_act, &self.w_b.weight,
&self.cublas, &self.stream)
.map_err(|e| anyhow!("w_b backward into: {e}"))?;
let d_a_proj_2d = GpuTensor::new(d_a_proj_flat, vec![n_rows, c.state_dim])
.map_err(|e| anyhow!("reshape d_a_proj seq into: {e}"))?;
let LinearGrads { dw: dw_a, db: db_a, dx: d_x_from_a } = self
.w_a.inner
.backward_with_slices(&d_a_proj_2d, &x_act, &self.w_a.weight,
&self.cublas, &self.stream)
.map_err(|e| anyhow!("w_a backward into: {e}"))?;
let d_x = d_x_from_a.add(&d_x_from_b, &self.stream)
.map_err(|e| anyhow!("sum d_x branches into: {e}"))?;
let input_act = LinearActivations { input: cache.input_2d.clone() };
let LinearGrads { dw: dw_in, db: db_in, dx: _d_input } = self
.w_in.inner
.backward_with_slices(&d_x, &input_act, &self.w_in.weight,
&self.cublas, &self.stream)
.map_err(|e| anyhow!("w_in backward into: {e}"))?;
let dw_out = GpuTensor::zeros(&[1, c.hidden_dim], &self.stream)
.map_err(|e| anyhow!("alloc zero dw_out seq into: {e}"))?;
let db_out = GpuTensor::zeros(&[1], &self.stream)
.map_err(|e| anyhow!("alloc zero db_out seq into: {e}"))?;
Ok(Mamba2BackwardGrads {
dw_in, db_in, dw_a, db_a, dw_b, db_b, dw_c, dw_out, db_out,
})
}
/// Fully-pre-allocated backward — companion to [`forward_train_seq_into`].
/// Uses [`Mamba2BackwardScratch`] for the big per-channel scan
/// buffers, [`Mamba2BackwardGradsBuffers`] for the cuBLAS linear
/// backward outputs + reduction-result tensors. Zero allocation in
/// the call. Caller reads the final grads from `grads_buffers`.
///
/// `input` is the same `[B, K, in_dim]` tensor passed to the
/// matching `forward_train_seq_into` call. Needed for the W_in
/// backward (which dots dY^T against the original X).
pub fn backward_from_h_enriched_seq_full_into(
&self,
input: &GpuTensor,
fwd_scratch: &Mamba2BlockForwardScratch,
d_h_enriched_seq: &GpuTensor,
bwd_scratch: &mut Mamba2BackwardScratch,
grads_buffers: &mut Mamba2BackwardGradsBuffers,
) -> Result<()> {
let c = &self.config;
let n_batch = fwd_scratch.n_batch;
anyhow::ensure!(
n_batch == bwd_scratch.n_batch
&& n_batch == grads_buffers.n_batch
&& c.seq_len == bwd_scratch.seq_len
&& c.seq_len == grads_buffers.seq_len
&& c.hidden_dim == bwd_scratch.hidden_dim
&& c.hidden_dim == grads_buffers.hidden_dim
&& c.state_dim == bwd_scratch.state_dim
&& c.state_dim == grads_buffers.state_dim,
"backward_full_into: scratch shape mismatch"
);
anyhow::ensure!(
d_h_enriched_seq.shape() == [n_batch, c.seq_len, c.hidden_dim],
"d_h_enriched_seq shape {:?} != [{}, {}, {}]",
d_h_enriched_seq.shape(), n_batch, c.seq_len, c.hidden_dim
);
// ── 5. Scan backward kernel → bwd_scratch slots ──────────────
let block_threads: u32 = 32;
let grid_y_h: u32 =
((c.hidden_dim + block_threads as usize - 1) / block_threads as usize) as u32;
let n_i32 = n_batch as i32;
let k_i32 = c.seq_len as i32;
let sh2_i32 = c.hidden_dim as i32;
let st_i32 = c.state_dim as i32;
{
let mut args = RawArgs::new();
args.push_ptr(fwd_scratch.a_proj.cuda_data().raw_ptr());
args.push_ptr(fwd_scratch.b_proj.cuda_data().raw_ptr());
args.push_ptr(d_h_enriched_seq.cuda_data().raw_ptr());
args.push_ptr(self.w_c.raw_ptr());
args.push_ptr(bwd_scratch.d_a_per_channel.raw_ptr());
args.push_ptr(bwd_scratch.d_b_per_channel.raw_ptr());
args.push_ptr(bwd_scratch.d_w_c_per_sample.raw_ptr());
args.push_ptr(bwd_scratch.d_h_s2.raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_bwd_seq.cu_function(),
(n_batch as u32, grid_y_h, 1), (block_threads, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("scan_bwd_seq_full_into launch: {e:?}"))?;
}
}
// ── Reductions: per-channel → flat. Writes directly into
// grads_buffers.{d_a_proj_2d, d_b_proj_2d, dw_c} since
// their flat layouts match [N, K, state_d] / [sh2, state_d].
let red_grid_z: u32 =
((c.state_dim + block_threads as usize - 1) / block_threads as usize) as u32;
let red_cfg = (
(n_batch as u32, c.seq_len as u32, red_grid_z),
(block_threads, 1, 1),
);
{
let mut args = RawArgs::new();
args.push_ptr(bwd_scratch.d_a_per_channel.raw_ptr());
args.push_ptr(grads_buffers.d_a_proj_2d.data_mut().raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_reduce_d_proj.cu_function(),
red_cfg.0, red_cfg.1, 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("reduce d_a_proj full_into: {e:?}"))?;
}
}
{
let mut args = RawArgs::new();
args.push_ptr(bwd_scratch.d_b_per_channel.raw_ptr());
args.push_ptr(grads_buffers.d_b_proj_2d.data_mut().raw_ptr());
args.push_i32(n_i32);
args.push_i32(k_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_reduce_d_proj.cu_function(),
red_cfg.0, red_cfg.1, 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("reduce d_b_proj full_into: {e:?}"))?;
}
}
{
let mut args = RawArgs::new();
args.push_ptr(bwd_scratch.d_w_c_per_sample.raw_ptr());
args.push_ptr(grads_buffers.dw_c.data_mut().raw_ptr());
args.push_i32(n_i32);
args.push_i32(sh2_i32);
args.push_i32(st_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_reduce_d_w_c.cu_function(),
(c.hidden_dim as u32, red_grid_z, 1), (block_threads, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("reduce dw_c full_into: {e:?}"))?;
}
}
// ── Linear backward: w_b, w_a, w_in (all _into variants).
// Each takes the saved forward input as a raw slice + batch
// dim (NO GpuTensor wrapping, NO CudaSlice::clone — cuMemAlloc
// inside capture is forbidden). w_b/w_a use fwd_scratch.x as
// their input; w_in uses the original input tensor.
let n_rows = n_batch * c.seq_len;
self.w_b.inner.backward_with_slices_into(
&grads_buffers.d_b_proj_2d,
fwd_scratch.x.cuda_data(), n_rows,
&self.w_b.weight,
&self.cublas, &self.stream,
&mut grads_buffers.dw_b, &mut grads_buffers.db_b, &mut grads_buffers.d_x_from_b,
).map_err(|e| anyhow!("w_b bwd_into: {e}"))?;
self.w_a.inner.backward_with_slices_into(
&grads_buffers.d_a_proj_2d,
fwd_scratch.x.cuda_data(), n_rows,
&self.w_a.weight,
&self.cublas, &self.stream,
&mut grads_buffers.dw_a, &mut grads_buffers.db_a, &mut grads_buffers.d_x_from_a,
).map_err(|e| anyhow!("w_a bwd_into: {e}"))?;
// d_x = d_x_from_a + d_x_from_b (in-place add — zero alloc).
grads_buffers.d_x_from_a
.add_into(&grads_buffers.d_x_from_b, &grads_buffers.d_x, &self.stream)
.map_err(|e| anyhow!("d_x add_into: {e}"))?;
self.w_in.inner.backward_with_slices_into(
&grads_buffers.d_x,
input.cuda_data(), n_rows,
&self.w_in.weight,
&self.cublas, &self.stream,
&mut grads_buffers.dw_in, &mut grads_buffers.db_in, &mut grads_buffers.d_x_from_in,
).map_err(|e| anyhow!("w_in bwd_into: {e}"))?;
// dw_out / db_out stay zero — W_out is unused in the seq path.
// (The grads_buffers init already created them zero; no action.)
Ok(())
}
/// Total trainable parameter count (sum of all projections + W_c).
pub fn param_count(&self) -> usize {
let c = &self.config;
// W_in: in*hidden + hidden_bias
let n_w_in = c.in_dim * c.hidden_dim + c.hidden_dim;
// W_a / W_b: hidden*state + state_bias
let n_w_ab = (c.hidden_dim * c.state_dim + c.state_dim) * 2;
// W_c: hidden*state (no bias)
let n_w_c = c.hidden_dim * c.state_dim;
// W_out: hidden*1 + 1
let n_w_out = c.hidden_dim + 1;
n_w_in + n_w_ab + n_w_c + n_w_out
}
}
// =====================================================================
// AdamW optimizer for Mamba2Block
// =====================================================================
/// AdamW hyperparameters.
#[derive(Debug, Clone)]
pub struct Mamba2AdamWConfig {
pub lr: f32,
pub beta1: f32,
pub beta2: f32,
pub epsilon: f32,
pub weight_decay: f32,
pub grad_clip_max_norm: Option<f32>,
}
impl Default for Mamba2AdamWConfig {
fn default() -> Self {
Self {
lr: 1e-3,
beta1: 0.9,
beta2: 0.999,
epsilon: 1e-8,
weight_decay: 1e-2,
grad_clip_max_norm: Some(1.0),
}
}
}
/// Per-parameter Adam moment buffers (m, v) on GPU. Allocated once at
/// optimizer construction; reused across all training steps.
struct AdamState {
m: CudaSlice<f32>,
v: CudaSlice<f32>,
}
/// GPU-native AdamW optimizer specialized for Mamba2Block's nine parameter
/// tensors. Holds the (m, v) moment state for each parameter; `step` applies
/// the AdamW update kernel to every weight + bias in place.
pub struct Mamba2AdamW {
pub config: Mamba2AdamWConfig,
pub step_count: i32,
stream: Arc<CudaStream>,
raw_stream: CUstream,
kernel: CudaFunction,
// ─── GPU-side grad-clip support ─────────────────────────────────
// Eliminates the 9× per-step memcpy_dtoh that the host grad-norm
// path required. norm_sq_phase1 → block_partials → norm_sq_phase2
// accumulates into grad_norm_sq_d; clip_scale converts → grad_scale_d
// which the devscale AdamW kernel reads as a device pointer.
kernel_norm_sq_phase1: CudaFunction,
kernel_norm_sq_phase2: CudaFunction,
kernel_clip_scale: CudaFunction,
kernel_adamw_devscale: CudaFunction,
/// 1-thread kernel that advances `step_count_d` by 1. Launched
/// inside the captured graph so each replay increments the counter
/// before the AdamW kernels read it.
kernel_increment_step: CudaFunction,
/// Per-block partial sums for grad-norm reduction phase 1.
/// Sized for the largest parameter tensor's grid: max(n)/256 blocks.
block_partials_d: CudaSlice<f32>,
/// Accumulator across all 9 tensors — single scalar.
grad_norm_sq_d: CudaSlice<f32>,
/// Final grad-clip scale (= min(1, max_norm/sqrt(norm_sq))). Read
/// by mamba2_alpha_adamw_step_devscale as a device pointer arg.
grad_scale_d: CudaSlice<f32>,
/// Device-resident 1-indexed step counter for the gpu_clip path.
/// Advanced via `kernel_increment_step` inside the captured graph,
/// so each replay sees the correct bias-correction step. Legacy
/// non-captured paths (`step`, `step_from_buffers`) keep using
/// the host-side `step_count` field.
step_count_d: CudaSlice<i32>,
/// max(grid_blocks) across all 9 tensors — capacity check for
/// block_partials_d.
max_grid_blocks: usize,
// (m, v) state for each of the 9 Mamba2 parameter tensors.
s_w_in: AdamState,
s_b_in: AdamState,
s_w_a: AdamState,
s_b_a: AdamState,
s_w_b: AdamState,
s_b_b: AdamState,
s_w_c: AdamState,
s_w_out: AdamState,
s_b_out: AdamState,
}
impl Mamba2AdamW {
/// Allocate optimizer state matching the shapes of `block`'s parameters.
/// State buffers are zero-initialised; first AdamW step is unbiased.
pub fn new(block: &Mamba2Block, config: Mamba2AdamWConfig) -> Result<Self> {
let stream = Arc::clone(&block.stream);
let kernel = block.kernel_adamw.clone();
// Build all AdamStates first (immutable borrow of `stream` during
// allocation), then move `stream` into Self.
let alloc = |n: usize, name: &str| -> Result<AdamState> {
let m = stream.alloc_zeros::<f32>(n)
.map_err(|e| anyhow!("alloc Adam m for {name}: {e}"))?;
let v = stream.alloc_zeros::<f32>(n)
.map_err(|e| anyhow!("alloc Adam v for {name}: {e}"))?;
Ok(AdamState { m, v })
};
let s_w_in = alloc(block.w_in.weight.len(), "w_in.weight")?;
let s_b_in = alloc(block.w_in.bias.len(), "w_in.bias")?;
let s_w_a = alloc(block.w_a.weight.len(), "w_a.weight")?;
let s_b_a = alloc(block.w_a.bias.len(), "w_a.bias")?;
let s_w_b = alloc(block.w_b.weight.len(), "w_b.weight")?;
let s_b_b = alloc(block.w_b.bias.len(), "w_b.bias")?;
let s_w_c = alloc(block.w_c.len(), "w_c")?;
let s_w_out = alloc(block.w_out.weight.len(), "w_out.weight")?;
let s_b_out = alloc(block.w_out.bias.len(), "w_out.bias")?;
// Load grad-norm kernel handles (compiled by build.rs into a
// dedicated grad_norm.cubin) + the devscale AdamW variant
// (compiled into mamba2_alpha_kernel.cubin alongside the
// host-scalar version).
static GRAD_NORM_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/grad_norm.cubin"));
let grad_norm_module = stream.context()
.load_cubin(GRAD_NORM_CUBIN.to_vec())
.map_err(|e| anyhow!("Mamba2AdamW: grad_norm cubin load: {e}"))?;
let kernel_norm_sq_phase1 = grad_norm_module
.load_function("grad_norm_sq_phase1")
.map_err(|e| anyhow!("grad_norm_sq_phase1 symbol: {e}"))?;
let kernel_norm_sq_phase2 = grad_norm_module
.load_function("grad_norm_sq_phase2")
.map_err(|e| anyhow!("grad_norm_sq_phase2 symbol: {e}"))?;
let kernel_clip_scale = grad_norm_module
.load_function("grad_clip_scale")
.map_err(|e| anyhow!("grad_clip_scale symbol: {e}"))?;
let kernel_adamw_devscale = block
._module
.load_function("mamba2_alpha_adamw_step_devscale")
.map_err(|e| anyhow!("mamba2_alpha_adamw_step_devscale symbol: {e}"))?;
let kernel_increment_step = block
._module
.load_function("mamba2_alpha_increment_step_counter")
.map_err(|e| anyhow!("mamba2_alpha_increment_step_counter symbol: {e}"))?;
// Sizing: largest parameter tensor's grid dim. With block=256,
// the largest tensor is w_in.weight = hidden_dim * in_dim.
let max_n = [
block.w_in.weight.len(), block.w_in.bias.len(),
block.w_a.weight.len(), block.w_a.bias.len(),
block.w_b.weight.len(), block.w_b.bias.len(),
block.w_c.len(),
block.w_out.weight.len(), block.w_out.bias.len(),
].into_iter().max().unwrap_or(0);
let max_grid_blocks = (max_n + 255) / 256;
let block_partials_d = stream.alloc_zeros::<f32>(max_grid_blocks.max(1))
.map_err(|e| anyhow!("block_partials alloc: {e}"))?;
let grad_norm_sq_d = stream.alloc_zeros::<f32>(1)
.map_err(|e| anyhow!("grad_norm_sq alloc: {e}"))?;
let grad_scale_d = stream.alloc_zeros::<f32>(1)
.map_err(|e| anyhow!("grad_scale alloc: {e}"))?;
let step_count_d = stream.alloc_zeros::<i32>(1)
.map_err(|e| anyhow!("step_count_d alloc: {e}"))?;
let raw_stream = stream.cu_stream();
Ok(Self {
stream,
raw_stream,
kernel,
kernel_norm_sq_phase1,
kernel_norm_sq_phase2,
kernel_clip_scale,
kernel_adamw_devscale,
kernel_increment_step,
block_partials_d,
grad_norm_sq_d,
grad_scale_d,
step_count_d,
max_grid_blocks,
step_count: 0,
s_w_in, s_b_in, s_w_a, s_b_a, s_w_b, s_b_b, s_w_c, s_w_out, s_b_out,
config,
})
}
/// One AdamW step. Applies grad_clip_max_norm if configured (computed
/// from the L2 norm of the concatenated parameter gradient vector).
pub fn step(&mut self, block: &mut Mamba2Block, grads: &Mamba2BackwardGrads) -> Result<()> {
self.step_count += 1;
let t = self.step_count;
// Compute grad-clip scale on host. The L2 norm requires a sum-of-
// squares reduction across all 9 tensors; cheapest is a host-side
// reduction over the 9 individual norms (since each is small).
let grad_scale = if let Some(max_norm) = self.config.grad_clip_max_norm {
// Each tensor's L2 squared norm = sum of (g_i * g_i). For Phase
// 1d.1 sizes the total parameter count is ~10k, so the host
// dtoh + sum is ~tens of microseconds. Re-evaluate at scale if
// it shows up in the training profile.
let mut total_sq = 0.0_f32;
for slice in [
grads.dw_in.cuda_data(), grads.db_in.cuda_data(),
grads.dw_a.cuda_data(), grads.db_a.cuda_data(),
grads.dw_b.cuda_data(), grads.db_b.cuda_data(),
&grads.dw_c,
grads.dw_out.cuda_data(),grads.db_out.cuda_data(),
] {
let mut host = vec![0.0_f32; slice.len()];
self.stream.memcpy_dtoh(slice, &mut host)
.map_err(|e| anyhow!("grad-norm dtoh: {e}"))?;
for g in &host {
total_sq += g * g;
}
}
let norm = total_sq.sqrt();
if norm > max_norm { max_norm / norm } else { 1.0 }
} else {
1.0
};
// Borrow the immutable bits as locals so we can mutably borrow
// self.s_* fields independently.
let kernel = &self.kernel;
let cfg = &self.config;
let rs = self.raw_stream;
// Launch the AdamW kernel for each parameter tensor.
adamw_apply(rs, kernel, cfg, block.w_in.weight.len(),
&mut block.w_in.weight, grads.dw_in.cuda_data(), &mut self.s_w_in, t, grad_scale)?;
adamw_apply(rs, kernel, cfg, block.w_in.bias.len(),
&mut block.w_in.bias, grads.db_in.cuda_data(), &mut self.s_b_in, t, grad_scale)?;
adamw_apply(rs, kernel, cfg, block.w_a.weight.len(),
&mut block.w_a.weight, grads.dw_a.cuda_data(), &mut self.s_w_a, t, grad_scale)?;
adamw_apply(rs, kernel, cfg, block.w_a.bias.len(),
&mut block.w_a.bias, grads.db_a.cuda_data(), &mut self.s_b_a, t, grad_scale)?;
adamw_apply(rs, kernel, cfg, block.w_b.weight.len(),
&mut block.w_b.weight, grads.dw_b.cuda_data(), &mut self.s_w_b, t, grad_scale)?;
adamw_apply(rs, kernel, cfg, block.w_b.bias.len(),
&mut block.w_b.bias, grads.db_b.cuda_data(), &mut self.s_b_b, t, grad_scale)?;
adamw_apply(rs, kernel, cfg, block.w_c.len(),
&mut block.w_c, &grads.dw_c, &mut self.s_w_c, t, grad_scale)?;
adamw_apply(rs, kernel, cfg, block.w_out.weight.len(),
&mut block.w_out.weight, grads.dw_out.cuda_data(), &mut self.s_w_out, t, grad_scale)?;
adamw_apply(rs, kernel, cfg, block.w_out.bias.len(),
&mut block.w_out.bias, grads.db_out.cuda_data(), &mut self.s_b_out, t, grad_scale)?;
Ok(())
}
pub fn set_learning_rate(&mut self, lr: f32) {
self.config.lr = lr;
}
/// FULLY GPU-RESIDENT AdamW step — no host roundtrips, no per-step
/// `memcpy_dtoh`. Grad-norm reduction + clip-scale computed on
/// device; the devscale AdamW kernel reads `grad_scale_d` as a
/// device pointer.
///
/// Replaces [`step_from_buffers`] (which did 9× `memcpy_dtoh` per
/// step — each a stream sync barrier costing ~ms on the hot path)
/// for the production training path. Same math, zero pipeline stalls.
///
/// Requires `config.grad_clip_max_norm` to be Some at every call —
/// the no-clip path would need a kernel-write to init grad_scale_d
/// to 1.0 which the trainer doesn't currently set up. (Callers
/// who want no clipping should set max_norm to a very large value
/// like f32::INFINITY; the clip becomes a no-op since grad_scale=1.)
pub fn step_from_buffers_gpu_clip(
&mut self,
block: &mut Mamba2Block,
grads: &Mamba2BackwardGradsBuffers,
) -> Result<()> {
// Device step counter is the canonical step number for this
// path. Increment kernel advances it BEFORE the adamw kernels
// read it, so the first call observes step=1.
{
let mut args = RawArgs::new();
args.push_ptr(self.step_count_d.raw_ptr());
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_increment_step.cu_function(),
(1, 1, 1), (1, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("inc_step launch: {e:?}"))?;
}
}
// ── 1. Compute grad_scale_d on device.
if let Some(max_norm) = self.config.grad_clip_max_norm {
// Zero the cross-tensor accumulator.
self.stream
.memset_zeros(&mut self.grad_norm_sq_d)
.map_err(|e| anyhow!("zero grad_norm_sq: {e}"))?;
// Reduce each gradient tensor's sum-of-squares INTO the accumulator.
let grad_slices: [&CudaSlice<f32>; 9] = [
grads.dw_in.cuda_data(), grads.db_in.cuda_data(),
grads.dw_a.cuda_data(), grads.db_a.cuda_data(),
grads.dw_b.cuda_data(), grads.db_b.cuda_data(),
grads.dw_c.cuda_data(),
grads.dw_out.cuda_data(), grads.db_out.cuda_data(),
];
for grad in grad_slices.iter() {
let n = grad.len();
if n == 0 { continue; }
let block_threads: u32 = 256;
let n_blocks: u32 = (n as u32).div_ceil(block_threads);
debug_assert!((n_blocks as usize) <= self.max_grid_blocks,
"block_partials capacity {} < n_blocks {}", self.max_grid_blocks, n_blocks);
let n_i32 = n as i32;
// Phase 1: per-block tree-reduce → block_partials_d.
{
let mut args = RawArgs::new();
args.push_ptr(grad.raw_ptr());
args.push_i32(n_i32);
args.push_ptr(self.block_partials_d.raw_ptr());
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_norm_sq_phase1.cu_function(),
(n_blocks, 1, 1), (block_threads, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("grad_norm phase1: {e:?}"))?;
}
}
// Phase 2: single-block reduce of partials → ACCUMULATE into norm_sq_d.
{
let nb_i32 = n_blocks as i32;
let accumulate_i32: i32 = 1; // ALWAYS accumulate (zero was done before loop)
let mut args = RawArgs::new();
args.push_ptr(self.block_partials_d.raw_ptr());
args.push_i32(nb_i32);
args.push_ptr(self.grad_norm_sq_d.raw_ptr());
args.push_i32(accumulate_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_norm_sq_phase2.cu_function(),
(1, 1, 1), (block_threads, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("grad_norm phase2: {e:?}"))?;
}
}
}
// Convert norm_sq → grad_scale_d = min(1, max_norm / sqrt(norm_sq + eps)).
{
let mut args = RawArgs::new();
args.push_ptr(self.grad_norm_sq_d.raw_ptr());
args.push_f32(max_norm);
args.push_ptr(self.grad_scale_d.raw_ptr());
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.kernel_clip_scale.cu_function(),
(1, 1, 1), (1, 1, 1), 0,
self.raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("grad_clip_scale: {e:?}"))?;
}
}
} else {
return Err(anyhow!(
"step_from_buffers_gpu_clip requires grad_clip_max_norm to be Some \
(set to f32::INFINITY to disable clipping in practice)"
));
}
// ── 2. AdamW per param via the devscale kernel (reads
// grad_scale_d AND step_count_d as device pointers).
let kernel = &self.kernel_adamw_devscale;
let cfg = &self.config;
let grad_scale = &self.grad_scale_d;
let step_d = &self.step_count_d;
let rs = self.raw_stream;
adamw_apply_devscale(rs, kernel, cfg, block.w_in.weight.len(),
&mut block.w_in.weight, grads.dw_in.cuda_data(), &mut self.s_w_in, step_d, grad_scale)?;
adamw_apply_devscale(rs, kernel, cfg, block.w_in.bias.len(),
&mut block.w_in.bias, grads.db_in.cuda_data(), &mut self.s_b_in, step_d, grad_scale)?;
adamw_apply_devscale(rs, kernel, cfg, block.w_a.weight.len(),
&mut block.w_a.weight, grads.dw_a.cuda_data(), &mut self.s_w_a, step_d, grad_scale)?;
adamw_apply_devscale(rs, kernel, cfg, block.w_a.bias.len(),
&mut block.w_a.bias, grads.db_a.cuda_data(), &mut self.s_b_a, step_d, grad_scale)?;
adamw_apply_devscale(rs, kernel, cfg, block.w_b.weight.len(),
&mut block.w_b.weight, grads.dw_b.cuda_data(), &mut self.s_w_b, step_d, grad_scale)?;
adamw_apply_devscale(rs, kernel, cfg, block.w_b.bias.len(),
&mut block.w_b.bias, grads.db_b.cuda_data(), &mut self.s_b_b, step_d, grad_scale)?;
adamw_apply_devscale(rs, kernel, cfg, block.w_c.len(),
&mut block.w_c, grads.dw_c.cuda_data(), &mut self.s_w_c, step_d, grad_scale)?;
adamw_apply_devscale(rs, kernel, cfg, block.w_out.weight.len(),
&mut block.w_out.weight, grads.dw_out.cuda_data(), &mut self.s_w_out, step_d, grad_scale)?;
adamw_apply_devscale(rs, kernel, cfg, block.w_out.bias.len(),
&mut block.w_out.bias, grads.db_out.cuda_data(), &mut self.s_b_out, step_d, grad_scale)?;
Ok(())
}
/// AdamW step that reads gradients directly from a
/// [`Mamba2BackwardGradsBuffers`] (the pre-allocated buffer set
/// produced by [`Mamba2Block::backward_from_h_enriched_seq_full_into`]).
/// Avoids constructing a temporary [`Mamba2BackwardGrads`] wrapper
/// every step.
pub fn step_from_buffers(
&mut self,
block: &mut Mamba2Block,
grads: &Mamba2BackwardGradsBuffers,
) -> Result<()> {
self.step_count += 1;
let t = self.step_count;
let grad_scale = if let Some(max_norm) = self.config.grad_clip_max_norm {
let mut total_sq = 0.0_f32;
for slice in [
grads.dw_in.cuda_data(), grads.db_in.cuda_data(),
grads.dw_a.cuda_data(), grads.db_a.cuda_data(),
grads.dw_b.cuda_data(), grads.db_b.cuda_data(),
grads.dw_c.cuda_data(),
grads.dw_out.cuda_data(),grads.db_out.cuda_data(),
] {
let mut host = vec![0.0_f32; slice.len()];
self.stream.memcpy_dtoh(slice, &mut host)
.map_err(|e| anyhow!("grad-norm dtoh: {e}"))?;
for g in &host {
total_sq += g * g;
}
}
let norm = total_sq.sqrt();
if norm > max_norm { max_norm / norm } else { 1.0 }
} else {
1.0
};
let kernel = &self.kernel;
let cfg = &self.config;
let rs = self.raw_stream;
adamw_apply(rs, kernel, cfg, block.w_in.weight.len(),
&mut block.w_in.weight, grads.dw_in.cuda_data(), &mut self.s_w_in, t, grad_scale)?;
adamw_apply(rs, kernel, cfg, block.w_in.bias.len(),
&mut block.w_in.bias, grads.db_in.cuda_data(), &mut self.s_b_in, t, grad_scale)?;
adamw_apply(rs, kernel, cfg, block.w_a.weight.len(),
&mut block.w_a.weight, grads.dw_a.cuda_data(), &mut self.s_w_a, t, grad_scale)?;
adamw_apply(rs, kernel, cfg, block.w_a.bias.len(),
&mut block.w_a.bias, grads.db_a.cuda_data(), &mut self.s_b_a, t, grad_scale)?;
adamw_apply(rs, kernel, cfg, block.w_b.weight.len(),
&mut block.w_b.weight, grads.dw_b.cuda_data(), &mut self.s_w_b, t, grad_scale)?;
adamw_apply(rs, kernel, cfg, block.w_b.bias.len(),
&mut block.w_b.bias, grads.db_b.cuda_data(), &mut self.s_b_b, t, grad_scale)?;
adamw_apply(rs, kernel, cfg, block.w_c.len(),
&mut block.w_c, grads.dw_c.cuda_data(), &mut self.s_w_c, t, grad_scale)?;
adamw_apply(rs, kernel, cfg, block.w_out.weight.len(),
&mut block.w_out.weight, grads.dw_out.cuda_data(), &mut self.s_w_out, t, grad_scale)?;
adamw_apply(rs, kernel, cfg, block.w_out.bias.len(),
&mut block.w_out.bias, grads.db_out.cuda_data(), &mut self.s_b_out, t, grad_scale)?;
Ok(())
}
}
/// One AdamW kernel launch for a single parameter tensor. Free function
/// (rather than a method) so the caller can mutably borrow distinct fields
/// of `Mamba2AdamW` (the per-param Adam state) while sharing immutable
/// references to the stream + kernel + hyperparameters.
/// Companion to [`adamw_apply`] — launches `mamba2_alpha_adamw_step_devscale`
/// which reads `grad_scale` from a device pointer instead of a host
/// scalar. Used by [`Mamba2AdamW::step_from_buffers_gpu_clip`] to keep
/// the entire AdamW step host-roundtrip-free.
fn adamw_apply_devscale(
raw_stream: CUstream,
kernel: &CudaFunction,
cfg: &Mamba2AdamWConfig,
n: usize,
param: &mut CudaSlice<f32>,
grad: &CudaSlice<f32>,
state: &mut AdamState,
step_d: &CudaSlice<i32>,
grad_scale_d: &CudaSlice<f32>,
) -> Result<()> {
let block_threads: u32 = 256;
let blocks: u32 = (n as u32).div_ceil(block_threads);
let n_i32 = n as i32;
let mut args = RawArgs::new();
args.push_ptr(param.raw_ptr());
args.push_ptr(grad.raw_ptr());
args.push_ptr(state.m.raw_ptr());
args.push_ptr(state.v.raw_ptr());
args.push_f32(cfg.lr);
args.push_f32(cfg.beta1);
args.push_f32(cfg.beta2);
args.push_f32(cfg.epsilon);
args.push_f32(cfg.weight_decay);
args.push_ptr(grad_scale_d.raw_ptr()); // DEVICE pointer instead of host scalar
args.push_ptr(step_d.raw_ptr()); // DEVICE pointer instead of host scalar
args.push_i32(n_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
kernel.cu_function(),
(blocks, 1, 1), (block_threads, 1, 1), 0,
raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("mamba2_alpha_adamw_step_devscale launch (n={n}): {e:?}"))?;
}
Ok(())
}
fn adamw_apply(
raw_stream: CUstream,
kernel: &CudaFunction,
cfg: &Mamba2AdamWConfig,
n: usize,
param: &mut CudaSlice<f32>,
grad: &CudaSlice<f32>,
state: &mut AdamState,
t: i32,
grad_scale: f32,
) -> Result<()> {
let block_threads: u32 = 256;
let blocks: u32 = ((n + block_threads as usize - 1) / block_threads as usize) as u32;
let n_i32 = n as i32;
let mut args = RawArgs::new();
args.push_ptr(param.raw_ptr());
args.push_ptr(grad.raw_ptr());
args.push_ptr(state.m.raw_ptr());
args.push_ptr(state.v.raw_ptr());
args.push_f32(cfg.lr);
args.push_f32(cfg.beta1);
args.push_f32(cfg.beta2);
args.push_f32(cfg.epsilon);
args.push_f32(cfg.weight_decay);
args.push_f32(grad_scale);
args.push_i32(t);
args.push_i32(n_i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
kernel.cu_function(),
(blocks, 1, 1), (block_threads, 1, 1), 0,
raw_stream, &mut ptrs[..args.len()],
).map_err(|e| anyhow!("mamba2_alpha_adamw_step launch (n={n}): {e:?}"))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use cudarc::driver::CudaContext;
fn cuda_stream_or_skip() -> Option<Arc<CudaStream>> {
match CudaContext::new(0) {
Ok(ctx) => Some(ctx.default_stream()),
Err(_) => None,
}
}
#[test]
fn test_mamba2_config_rejects_state_over_32() {
let cfg = Mamba2BlockConfig {
in_dim: 81, hidden_dim: 64, state_dim: 33, seq_len: 32,
};
assert!(cfg.validate().is_err(), "state_dim > 32 must be rejected");
}
#[test]
fn test_mamba2_config_rejects_seq_len_over_kernel_max() {
let cfg = Mamba2BlockConfig {
in_dim: 81, hidden_dim: 64, state_dim: 16, seq_len: MAMBA2_KERNEL_SEQ_MAX + 1,
};
assert!(
cfg.validate().is_err(),
"seq_len > {} must be rejected (backward x_hist limit)",
MAMBA2_KERNEL_SEQ_MAX
);
}
/// THE end-to-end correctness check: build a tiny Mamba2Block, run
/// 20 AdamW steps on a fixed input with a fixed binary label, and
/// assert mean BCE loss falls monotonically. If analytical backward
/// has a sign flip or wrong scale somewhere, this test fails by
/// observing the loss either flatten or rise.
#[test]
fn test_mamba2_block_training_loop_decreases_loss() {
let stream = match cuda_stream_or_skip() {
Some(s) => s,
None => return,
};
let cfg = Mamba2BlockConfig {
in_dim: 4, hidden_dim: 8, state_dim: 4, seq_len: 8,
};
let n_batch = 4;
let mut block = Mamba2Block::new(cfg.clone(), Arc::clone(&stream)).expect("init");
let mut opt = Mamba2AdamW::new(&block, Mamba2AdamWConfig {
lr: 1e-2, // bigger lr — small model, 20-step convergence
..Default::default()
}).expect("opt init");
// Fixed input + fixed binary labels (half +1, half 0).
let n = n_batch * cfg.seq_len * cfg.in_dim;
let h: Vec<f32> = (0..n).map(|i| ((i as f32) * 0.137).sin()).collect();
let dev_in = stream.clone_htod(&h).expect("htod");
let input = GpuTensor::new(dev_in, vec![n_batch, cfg.seq_len, cfg.in_dim]).expect("input");
let labels: Vec<f32> = (0..n_batch).map(|i| if i < n_batch / 2 { 1.0 } else { 0.0 }).collect();
let bce_loss = |logits_host: &[f32], labels_host: &[f32]| -> f32 {
// Numerically-stable BCE-with-logits, mean over batch.
let mut s = 0.0_f32;
let eps = 1e-7_f32;
for (&z, &y) in logits_host.iter().zip(labels_host.iter()) {
let z = z.clamp(-50.0, 50.0);
let p = (1.0 / (1.0 + (-z).exp())).clamp(eps, 1.0 - eps);
s += -(y * p.ln() + (1.0 - y) * (1.0 - p).ln());
}
s / logits_host.len() as f32
};
let mut last_loss = f32::INFINITY;
let mut decrease_streak = 0;
let mut total_decreases = 0;
for step in 0..20 {
let (logit, cache) = block.forward_train(&input).expect("forward_train");
let logit_host = logit.to_host(&stream).expect("logit dtoh");
let loss = bce_loss(&logit_host, &labels);
// d_logit = (sigmoid(z) - y) / N for mean-BCE-with-logits.
let d_logit_host: Vec<f32> = logit_host.iter().zip(labels.iter())
.map(|(&z, &y)| {
let p = 1.0 / (1.0 + (-z.clamp(-50.0, 50.0)).exp());
(p - y) / (n_batch as f32)
})
.collect();
let d_logit_dev = stream.clone_htod(&d_logit_host).expect("htod d_logit");
let d_logit = GpuTensor::new(d_logit_dev, vec![n_batch, 1]).expect("d_logit");
let grads = block.backward(&cache, &d_logit).expect("backward");
opt.step(&mut block, &grads).expect("opt step");
// Track convergence behaviour.
if loss < last_loss {
decrease_streak += 1;
total_decreases += 1;
} else {
decrease_streak = 0;
}
eprintln!("step={step:2} loss={loss:.5} streak={decrease_streak}");
last_loss = loss;
}
// Strict invariants:
// - Loss must have decreased a majority of the steps (allow some
// non-monotonicity from the AdamW momentum + small batch noise).
assert!(
total_decreases >= 15,
"expected ≥15 decreasing steps out of 20, got {total_decreases} — backward likely broken"
);
// - Final loss must be meaningfully below the initial random-init loss
// (which starts near ln(2) ≈ 0.693 for balanced labels). Initial
// loss after random Xavier init varies; we just require we got
// below the chance baseline.
let (final_logit, _) = block.forward_train(&input).expect("final forward");
let final_logit_host = final_logit.to_host(&stream).expect("final dtoh");
let final_loss = bce_loss(&final_logit_host, &labels);
assert!(
final_loss < 0.65,
"final loss {final_loss} did not drop below 0.65 — training did not converge"
);
}
#[test]
fn test_mamba2_block_backward_returns_finite_grads_with_correct_shapes() {
let stream = match cuda_stream_or_skip() {
Some(s) => s,
None => return,
};
let cfg = Mamba2BlockConfig {
in_dim: 8, hidden_dim: 8, state_dim: 4, seq_len: 8,
};
let n_batch = 3;
let block = Mamba2Block::new(cfg.clone(), Arc::clone(&stream)).expect("init");
// Forward with cache.
let n_in = n_batch * cfg.seq_len * cfg.in_dim;
let h: Vec<f32> = (0..n_in).map(|i| ((i as f32) * 0.137).sin()).collect();
let d = stream.clone_htod(&h).expect("htod");
let input = GpuTensor::new(d, vec![n_batch, cfg.seq_len, cfg.in_dim]).expect("input");
let (logit, cache) = block.forward_train(&input).expect("forward_train");
// Pretend d_logit is just sigmoid(logit) - 0.5 (a BCE-shaped scalar).
let logit_h = logit.to_host(&stream).expect("logit dtoh");
let d_logit_h: Vec<f32> = logit_h.iter()
.map(|z| 1.0 / (1.0 + (-z).exp()) - 0.5)
.collect();
let d_logit_dev = stream.clone_htod(&d_logit_h).expect("htod");
let d_logit = GpuTensor::new(d_logit_dev, vec![n_batch, 1]).expect("d_logit tensor");
let grads = block.backward(&cache, &d_logit).expect("backward");
// Shape assertions match each weight's forward shape.
assert_eq!(grads.dw_in.shape(), &[cfg.hidden_dim, cfg.in_dim], "dw_in shape");
assert_eq!(grads.db_in.shape(), &[cfg.hidden_dim], "db_in shape");
assert_eq!(grads.dw_a.shape(), &[cfg.state_dim, cfg.hidden_dim], "dw_a shape");
assert_eq!(grads.db_a.shape(), &[cfg.state_dim], "db_a shape");
assert_eq!(grads.dw_b.shape(), &[cfg.state_dim, cfg.hidden_dim], "dw_b shape");
assert_eq!(grads.db_b.shape(), &[cfg.state_dim], "db_b shape");
assert_eq!(grads.dw_c.len(), cfg.hidden_dim * cfg.state_dim, "dw_c numel");
assert_eq!(grads.dw_out.shape(), &[1, cfg.hidden_dim], "dw_out shape");
assert_eq!(grads.db_out.shape(), &[1], "db_out shape");
// Finiteness for every gradient tensor.
for (name, g) in [
("dw_in", &grads.dw_in), ("db_in", &grads.db_in),
("dw_a", &grads.dw_a), ("db_a", &grads.db_a),
("dw_b", &grads.dw_b), ("db_b", &grads.db_b),
("dw_out",&grads.dw_out),("db_out",&grads.db_out),
] {
let h = g.to_host(&stream).expect("dtoh");
for (i, &v) in h.iter().enumerate() {
assert!(v.is_finite(), "{} grad index {} non-finite: {}", name, i, v);
}
}
// dw_c is a raw CudaSlice<f32> (no GpuTensor wrapper); read directly.
let mut dw_c_host = vec![0.0_f32; grads.dw_c.len()];
stream.memcpy_dtoh(&grads.dw_c, &mut dw_c_host).expect("dw_c dtoh");
for (i, &v) in dw_c_host.iter().enumerate() {
assert!(v.is_finite(), "dw_c grad index {} non-finite: {}", i, v);
}
// At least one element of dw_in should be non-zero (gradient
// actually flowed through the chain — not silently zero).
let dw_in_h = grads.dw_in.to_host(&stream).expect("dw_in dtoh");
let any_nonzero = dw_in_h.iter().any(|&v| v.abs() > 1e-12);
assert!(any_nonzero, "dw_in entirely zero — gradient didn't propagate to W_in");
}
#[test]
fn test_mamba2_block_backward_rejects_wrong_d_logit_shape() {
let stream = match cuda_stream_or_skip() {
Some(s) => s,
None => return,
};
let cfg = Mamba2BlockConfig {
in_dim: 8, hidden_dim: 8, state_dim: 4, seq_len: 8,
};
let n_batch = 3;
let block = Mamba2Block::new(cfg.clone(), Arc::clone(&stream)).expect("init");
let input = GpuTensor::zeros(&[n_batch, cfg.seq_len, cfg.in_dim], &stream).expect("input");
let (_logit, cache) = block.forward_train(&input).expect("forward_train");
// Wrong d_logit shape: [n_batch, 2] instead of [n_batch, 1].
let bad = GpuTensor::zeros(&[n_batch, 2], &stream).expect("bad d_logit");
assert!(block.backward(&cache, &bad).is_err());
}
#[test]
fn test_mamba2_block_forward_train_returns_cache() {
let stream = match cuda_stream_or_skip() {
Some(s) => s,
None => return,
};
let cfg = Mamba2BlockConfig {
in_dim: 81, hidden_dim: 32, state_dim: 8, seq_len: 16,
};
let n_batch = 4;
let block = Mamba2Block::new(cfg.clone(), Arc::clone(&stream)).expect("init");
let n = n_batch * cfg.seq_len * cfg.in_dim;
let h: Vec<f32> = (0..n).map(|i| (i as f32 * 1e-3).cos()).collect();
let d = stream.clone_htod(&h).expect("htod");
let input = GpuTensor::new(d, vec![n_batch, cfg.seq_len, cfg.in_dim]).expect("input");
let (logit, cache) = block.forward_train(&input).expect("forward_train");
assert_eq!(logit.shape(), &[n_batch, 1]);
assert_eq!(cache.input_2d.shape(), &[n_batch * cfg.seq_len, cfg.in_dim]);
assert_eq!(cache.x.shape(), &[n_batch * cfg.seq_len, cfg.hidden_dim]);
assert_eq!(cache.a_proj.shape(), &[n_batch * cfg.seq_len, cfg.state_dim]);
assert_eq!(cache.b_proj.shape(), &[n_batch * cfg.seq_len, cfg.state_dim]);
assert_eq!(cache.h_enriched.shape(), &[n_batch, cfg.hidden_dim]);
}
#[test]
fn test_mamba2_config_rejects_zero_dims() {
let cfg = Mamba2BlockConfig {
in_dim: 0, hidden_dim: 64, state_dim: 16, seq_len: 32,
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_mamba2_block_forward_shape_and_finite() {
let stream = match cuda_stream_or_skip() {
Some(s) => s,
None => {
eprintln!("CUDA unavailable; skipping forward smoke");
return;
}
};
let cfg = Mamba2BlockConfig {
in_dim: 81, hidden_dim: 32, state_dim: 8, seq_len: 16,
};
let n_batch = 4;
let block = Mamba2Block::new(cfg.clone(), Arc::clone(&stream)).expect("Mamba2Block::new");
// Build deterministic synthetic input [N, K, in_dim] = [4, 16, 81] = 5184 f32.
let n_input = n_batch * cfg.seq_len * cfg.in_dim;
let host_input: Vec<f32> = (0..n_input).map(|i| ((i as f32) * 1e-3).sin()).collect();
let dev_input = stream.clone_htod(&host_input).expect("htod");
let input_tensor = GpuTensor::new(dev_input, vec![n_batch, cfg.seq_len, cfg.in_dim])
.expect("input tensor");
let logit = block.forward(&input_tensor).expect("forward");
// Output shape must be [N, 1].
assert_eq!(logit.shape(), &[n_batch, 1]);
// All logits must be finite (no NaN / Inf — sanity check on the
// selective scan + projections).
let host_logit = logit.to_host(&stream).expect("dtoh");
assert_eq!(host_logit.len(), n_batch);
for (i, &z) in host_logit.iter().enumerate() {
assert!(z.is_finite(), "logit[{i}] non-finite: {z}");
}
}
#[test]
fn test_mamba2_block_forward_rejects_wrong_shape() {
let stream = match cuda_stream_or_skip() {
Some(s) => s,
None => return,
};
let cfg = Mamba2BlockConfig {
in_dim: 81, hidden_dim: 32, state_dim: 8, seq_len: 16,
};
let block = Mamba2Block::new(cfg.clone(), Arc::clone(&stream)).expect("init");
// Wrong in_dim.
let bad = GpuTensor::zeros(&[2, 16, 99], &stream).expect("bad alloc");
assert!(block.forward(&bad).is_err());
// Wrong seq_len.
let bad2 = GpuTensor::zeros(&[2, 8, 81], &stream).expect("bad alloc 2");
assert!(block.forward(&bad2).is_err());
}
#[test]
fn test_mamba2_block_constructs_and_loads_kernels() {
let stream = match cuda_stream_or_skip() {
Some(s) => s,
None => {
eprintln!("CUDA unavailable; skipping GPU construction test");
return;
}
};
let cfg = Mamba2BlockConfig {
in_dim: 81, hidden_dim: 64, state_dim: 16, seq_len: 32,
};
let block = Mamba2Block::new(cfg, stream).expect("Mamba2Block::new");
// Both kernel handles must be resolved.
let _ = &block.kernel_fwd;
let _ = &block.kernel_bwd;
// Parameter count sanity: 81*64 + 64 + (64*16 + 16)*2 + 64*16 + 64 + 1
// = 5184 + 64 + 2080 + 1024 + 65 = 8417
let expected = 81 * 64 + 64 + (64 * 16 + 16) * 2 + 64 * 16 + 64 + 1;
assert_eq!(block.param_count(), expected);
}
}