Files
foxhunt/crates/ml-alpha/src/mamba2_block.rs
jgrusewski c3e769b4b6 feat(ml-alpha): Mamba2 forward pass — GPU-pure end-to-end (Phase 1d.1, session 2)
Forward inference for the supervised snapshot stream — no ISV, no
temporal_weight, no NULL-pointer dispatch. Clean rewrite of the DQN
mamba2 kernel into a purpose-built alpha kernel.

New kernel `crates/ml-alpha/cuda/mamba2_alpha_kernel.cu` with three
extern "C" symbols:
  - mamba2_alpha_scan_fwd   — selective SSM scan over K timesteps with
                              sigmoid-gated state update; cheaper than
                              the DQN variant (no ISV stability scaling,
                              no per-position temporal_weight)
  - mamba2_alpha_scan_bwd   — analytical backward (scaffolded; full
                              gradient wiring lands in session 3)
  - mamba2_alpha_reduce_d_w_c — block tree-reduce over batch for the
                              W_c gradient (no atomicAdd — per
                              feedback_no_atomicadd)

build.rs swapped from ../ml/src/cuda_pipeline/mamba2_temporal_kernel.cu
to the local cuda/mamba2_alpha_kernel.cu. ml-alpha no longer depends
on ml's CUDA source — fully self-contained alpha-stack.

Forward pipeline:
  1. cuBLAS sgemm: input [B,K,in] @ W_in.T + b_in  → x [B,K,hidden]
  2. cuBLAS sgemm: x @ W_a.T + b_a                  → a_proj [B,K,state]
  3. cuBLAS sgemm: x @ W_b.T + b_b                  → b_proj [B,K,state]
  4. zero-init h_s2, h_enriched [B, hidden]
  5. scan kernel: (a_proj, b_proj, W_c, h_s2) → h_enriched
  6. cuBLAS sgemm: h_enriched @ W_out.T + b_out     → logit [B, 1]

All on GPU; output is a [N] CudaSlice<f32> of raw logits. Caller
sigmoids + thresholds (or feeds directly into BCE-with-logits).

Tests (5 passing on real GPU):
- forward [4, 16, 81] → logit [4, 1], all finite
- reject wrong in_dim
- reject wrong seq_len
- reject state_dim > 16
- reject zero dims
- + parameter-count sanity

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 01:40:18 +02:00

404 lines
17 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 ≤ 16 in the kernel (`float x[16]`); we expose
//! it as a config field but the constructor rejects values > 16.
use std::sync::Arc;
use anyhow::{anyhow, Result};
use cudarc::cublas::CudaBlas;
use cudarc::driver::{CudaFunction, CudaModule, CudaSlice, CudaStream, LaunchConfig, PushKernelArg};
use ml_core::cuda_autograd::gpu_tensor::GpuTensor;
use ml_core::cuda_autograd::linear::OwnedGpuLinear;
/// Mamba2 hidden state dim is hardcoded at 16 floats per (sample, batch) in
/// the kernel (`float x[16]`). Configurations exceeding this are rejected.
pub const MAMBA2_KERNEL_STATE_MAX: usize = 16;
/// 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_temporal_kernel.cu \
hardcodes `float x[{}]` per thread)",
self.state_dim, MAMBA2_KERNEL_STATE_MAX, MAMBA2_KERNEL_STATE_MAX
));
}
Ok(())
}
}
/// 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>,
// ── 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,
pub kernel_reduce_d_w_c: CudaFunction,
/// cuBLAS handle for the four linear projections (input / A / B / output).
pub cublas: CudaBlas,
}
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_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}"))?;
// cuBLAS handle for the four GEMM projections.
let cublas = CudaBlas::new(Arc::clone(&stream))
.map_err(|e| anyhow!("Mamba2Block: cuBLAS init failed: {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}"))?;
Ok(Self {
config,
stream,
w_in,
w_a,
w_b,
w_c,
w_out,
_module: module,
kernel_fwd,
kernel_bwd,
kernel_reduce_d_w_c,
cublas,
})
}
// ── Forward pass ───────────────────────────────────────────────────
/// GPU-native forward inference. `input` is a flat `[n_batch × seq_len × in_dim]`
/// `GpuTensor` (row-major); returns a flat `[n_batch]` tensor of raw logits
/// (one per sequence, predicted from the final-position state).
///
/// Steps:
/// 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)
///
/// All intermediate tensors live on GPU; no host roundtrip. The caller is
/// responsible for shuttling input/output between host and GPU at the
/// pipeline boundary (pinned-memory transfers handled by ml-core helpers
/// elsewhere in the stack).
pub fn forward(&self, input: &GpuTensor) -> Result<GpuTensor> {
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: 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, _act_in) = 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, _act_a) = 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, _act_b) = 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 and output buffer h_enriched, both [N, hidden_dim].
// h_s2 is the residual the kernel adds to the scan context; for the
// pure-supervised pipeline we start with zero residual (no carry from
// a previous chunk).
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 cfg = LaunchConfig {
grid_dim: (grid_x, grid_y, 1),
block_dim: (block_threads, 1, 1),
shared_mem_bytes: 0,
};
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;
unsafe {
self.stream
.launch_builder(&self.kernel_fwd)
.arg(a_proj.cuda_data())
.arg(b_proj.cuda_data())
.arg(&self.w_c)
.arg(h_s2.cuda_data())
.arg(h_enriched.data_mut())
.arg(&n_i32)
.arg(&k_i32)
.arg(&sh2_i32)
.arg(&st_i32)
.launch(cfg)
.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, _act_out) = 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}"))?;
Ok(logit)
}
/// 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
}
}
#[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_16() {
let cfg = Mamba2BlockConfig {
in_dim: 81, hidden_dim: 64, state_dim: 17, seq_len: 32,
};
assert!(cfg.validate().is_err(), "state_dim > 16 must be rejected");
}
#[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);
}
}