Mamba M1: real selective-scan forward (replaces the passthrough stub)

The MambaBlock forward was a stub — SelectiveScan::forward passed input
through, discretize returned zeros, conv1d was a no-op, and B/C were
randn per call, leaving conv1d_weight/dt_proj/A_log as dead weights with
zero temporal mixing. This implements a genuine S6 selective-scan:

- New params: x_proj [d_inner, dt_rank+2*d_state] (data-dependent dt,B,C),
  dt_bias [d_inner], D [d_inner] (skip). Added to new()/new_seeded() and
  the persistence contract (persistence_tensors/from_persistence_tensors).
- Real forward (CPU f32, looped — backbone is small): in_proj -> causal
  depthwise conv1d -> SiLU -> x_proj->(dt,B,C) -> delta=softplus(dt.dt_proj
  +dt_bias) -> A=-exp(A_log) -> sequential scan h=dA.h+dBu, y=sum C.h + D.u
  -> gate by SiLU(z) -> out_proj. Residual moved OUT (canonical).
  Numerically-stable silu_f32/softplus_f32 helpers.
- The scan runs inline (not via the immature rtx-tensor autograd tape);
  the analytic backward lands in M2 per docs/phase3_real_ssm_spec.md.

New tests/real_selective_scan.rs (4 cases, all green): liveness (each
formerly-dead weight now moves the output), causality (no future
leakage), seeded determinism, and finite/non-constant output.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
omar sobh
2026-06-03 11:02:46 +00:00
co-authored by Claude Opus 4.8
parent 96106cf888
commit 00cd527ed4
2 changed files with 296 additions and 60 deletions
@@ -264,11 +264,23 @@ pub struct MambaBlock {
conv1d_bias: Option<Tensor>,
/// State space parameter A
A_log: Tensor,
/// Data-dependent projection of the activated branch into
/// `(dt, B, C)` — `[d_inner, dt_rank + 2*d_state]`. This is what
/// makes the scan *selective*: `B` and `C` are functions of the
/// input rather than the fabricated `randn` of the old stub.
x_proj: Tensor,
/// Time step projection
dt_proj: Tensor,
/// Output projection
/// Time-step bias added before softplus — `[d_inner]`.
dt_bias: Tensor,
/// Per-channel skip connection `D` — `[d_inner]`.
d_skip: Tensor,
/// Output projection
out_proj: Tensor,
/// Selective scan operation
/// Selective scan operation. Retained for API/cache compatibility
/// (`SelectiveScan` is public and exercised by tests); the
/// `MambaBlock` forward now runs the scan inline in f32.
#[allow(dead_code)]
selective_scan: SelectiveScan,
}
@@ -287,7 +299,10 @@ impl MambaBlock {
None
};
let A_log = Tensor::randn(&[d_inner, config.d_state], device)?;
let x_proj = Tensor::randn(&[d_inner, dt_rank + 2 * config.d_state], device)?;
let dt_proj = Tensor::randn(&[dt_rank, d_inner], device)?;
let dt_bias = Tensor::randn(&[d_inner], device)?;
let d_skip = Tensor::randn(&[d_inner], device)?;
let out_proj = Tensor::randn(&[d_inner, config.d_model], device)?;
let selective_scan = SelectiveScan::new(d_inner, config.d_state);
@@ -299,7 +314,10 @@ impl MambaBlock {
conv1d_weight,
conv1d_bias,
A_log,
x_proj,
dt_proj,
dt_bias,
d_skip,
out_proj,
selective_scan,
})
@@ -341,7 +359,11 @@ impl MambaBlock {
None
};
let A_log = Tensor::randn_seeded(&[d_inner, config.d_state], device, next_seed())?;
let x_proj =
Tensor::randn_seeded(&[d_inner, dt_rank + 2 * config.d_state], device, next_seed())?;
let dt_proj = Tensor::randn_seeded(&[dt_rank, d_inner], device, next_seed())?;
let dt_bias = Tensor::randn_seeded(&[d_inner], device, next_seed())?;
let d_skip = Tensor::randn_seeded(&[d_inner], device, next_seed())?;
let out_proj = Tensor::randn_seeded(&[d_inner, config.d_model], device, next_seed())?;
let selective_scan = SelectiveScan::new(d_inner, config.d_state);
@@ -353,7 +375,10 @@ impl MambaBlock {
conv1d_weight,
conv1d_bias,
A_log,
x_proj,
dt_proj,
dt_bias,
d_skip,
out_proj,
selective_scan,
})
@@ -371,7 +396,10 @@ impl MambaBlock {
/// - `conv1d_weight`
/// - `conv1d_bias` (only present when `config.conv_bias`)
/// - `A_log`
/// - `x_proj`
/// - `dt_proj`
/// - `dt_bias`
/// - `D`
/// - `out_proj`
pub fn persistence_tensors(&self) -> Vec<(&'static str, &Tensor)> {
let mut out: Vec<(&'static str, &Tensor)> = vec![
@@ -382,7 +410,10 @@ impl MambaBlock {
out.push(("conv1d_bias", bias));
}
out.push(("A_log", &self.A_log));
out.push(("x_proj", &self.x_proj));
out.push(("dt_proj", &self.dt_proj));
out.push(("dt_bias", &self.dt_bias));
out.push(("D", &self.d_skip));
out.push(("out_proj", &self.out_proj));
out
}
@@ -439,9 +470,18 @@ impl MambaBlock {
let A_log = take(&mut tensors, "A_log")?;
assert_shape(&A_log, "A_log", &[d_inner, config.d_state])?;
let x_proj = take(&mut tensors, "x_proj")?;
assert_shape(&x_proj, "x_proj", &[d_inner, dt_rank + 2 * config.d_state])?;
let dt_proj = take(&mut tensors, "dt_proj")?;
assert_shape(&dt_proj, "dt_proj", &[dt_rank, d_inner])?;
let dt_bias = take(&mut tensors, "dt_bias")?;
assert_shape(&dt_bias, "dt_bias", &[d_inner])?;
let d_skip = take(&mut tensors, "D")?;
assert_shape(&d_skip, "D", &[d_inner])?;
let out_proj = take(&mut tensors, "out_proj")?;
assert_shape(&out_proj, "out_proj", &[d_inner, config.d_model])?;
@@ -454,7 +494,10 @@ impl MambaBlock {
conv1d_weight,
conv1d_bias,
A_log,
x_proj,
dt_proj,
dt_bias,
d_skip,
out_proj,
selective_scan,
})
@@ -462,74 +505,143 @@ impl MambaBlock {
/// Forward pass through `MambaBlock`
pub fn forward(&self, x: &Tensor) -> Result<MambaOutput> {
let batch_size = x.shape().dims()[0];
let seq_len = x.shape().dims()[1];
let d_model = x.shape().dims()[2];
let dims = x.shape().dims().to_vec();
let (b, l, d_model) = (dims[0], dims[1], dims[2]);
let d = self.config.get_d_inner();
let n = self.config.d_state;
let dt_rank = self.config.get_dt_rank();
let kc = self.config.d_conv;
let dbc = dt_rank + 2 * n;
// Input projection
let projected = x.matmul(&self.in_proj)?; // [B, L, 2*D]
let (x_proj, res_proj) = self.split_projection(&projected)?;
// Pull every parameter + the input to CPU f32 once. The
// backbone is small (d_inner = expand·d_model), so a direct
// loop nest is both correct and fast, and keeps the forward
// analytically differentiable by hand (see the Phase-3 spec:
// we do not route the selective scan through the autograd
// tape).
let xv = x.to_vec()?; // [b, l, d_model]
let in_proj = self.in_proj.to_vec()?; // [d_model, 2d]
let conv_w = self.conv1d_weight.to_vec()?; // [d, 1, kc] = [d, kc]
let conv_b = match &self.conv1d_bias {
Some(t) => Some(t.to_vec()?),
None => None,
};
let a_log = self.A_log.to_vec()?; // [d, n]
let x_proj = self.x_proj.to_vec()?; // [d, dbc]
let dt_proj = self.dt_proj.to_vec()?; // [dt_rank, d]
let dt_bias = self.dt_bias.to_vec()?; // [d]
let d_skip = self.d_skip.to_vec()?; // [d]
let out_proj = self.out_proj.to_vec()?; // [d, d_model]
// Convolution (simplified 1D conv)
let x_conv = self.apply_conv1d(&x_proj)?;
let mut out = vec![0.0f32; b * l * d_model];
// Activation (SiLU)
let x_activated = self.silu(&x_conv)?;
// Prepare state space parameters
let A = self.A_log.exp()?; // Convert log to actual values
let delta = Tensor::randn(
&[batch_size, seq_len, self.config.get_d_inner()],
&self.device,
)?;
let B = Tensor::randn(&[batch_size, seq_len, self.config.d_state], &self.device)?;
let C = Tensor::randn(&[batch_size, seq_len, self.config.d_state], &self.device)?;
// Selective scan operation
let ssm_output = self
.selective_scan
.forward(&x_activated, &delta, &A, &B, &C, None)?;
// Gating with residual projection
let gated = ssm_output.mul(&self.silu(&res_proj)?)?;
// Output projection
let output = gated.matmul(&self.out_proj)?;
// Residual connection
let final_output = output.add(x)?;
for bi in 0..b {
// (1) in_proj → x_in (selective branch) + z (gate branch).
let mut x_in = vec![0.0f32; l * d];
let mut z = vec![0.0f32; l * d];
for li in 0..l {
let xr = &xv[(bi * l + li) * d_model..(bi * l + li) * d_model + d_model];
for j in 0..d {
let (mut sx, mut sz) = (0.0f32, 0.0f32);
for m in 0..d_model {
let w = xr[m];
sx += w * in_proj[m * (2 * d) + j];
sz += w * in_proj[m * (2 * d) + d + j];
}
x_in[li * d + j] = sx;
z[li * d + j] = sz;
}
}
// (2-3) causal depthwise conv1d (left-pad kc-1) → SiLU → u.
let mut u = vec![0.0f32; l * d];
for li in 0..l {
for j in 0..d {
let mut acc = conv_b.as_ref().map_or(0.0f32, |cb| cb[j]);
for kk in 0..kc {
let src = li as isize - (kc as isize - 1) + kk as isize;
if src >= 0 {
acc += x_in[(src as usize) * d + j] * conv_w[j * kc + kk];
}
}
u[li * d + j] = silu_f32(acc);
}
}
// (4-5) x_proj → (dt, B, C); delta = softplus(dt·dt_proj + dt_bias).
let mut bmat = vec![0.0f32; l * n];
let mut cmat = vec![0.0f32; l * n];
let mut delta = vec![0.0f32; l * d];
for li in 0..l {
let mut xdbl = vec![0.0f32; dbc];
for q in 0..dbc {
let mut s = 0.0f32;
for j in 0..d {
s += u[li * d + j] * x_proj[j * dbc + q];
}
xdbl[q] = s;
}
for nn in 0..n {
bmat[li * n + nn] = xdbl[dt_rank + nn];
cmat[li * n + nn] = xdbl[dt_rank + n + nn];
}
for j in 0..d {
let mut s = dt_bias[j];
for r in 0..dt_rank {
s += xdbl[r] * dt_proj[r * d + j];
}
delta[li * d + j] = softplus_f32(s);
}
}
// (6-9) selective scan with A = -exp(A_log); gate by SiLU(z);
// accumulate through out_proj. No residual here (the caller
// adds it — canonical Mamba placement).
let mut h = vec![0.0f32; d * n];
for li in 0..l {
for j in 0..d {
let dj = delta[li * d + j];
let uj = u[li * d + j];
let mut y = d_skip[j] * uj;
for nn in 0..n {
let a = -a_log[j * n + nn].exp();
let da = (dj * a).exp();
let dbu = dj * bmat[li * n + nn] * uj;
let hv = da * h[j * n + nn] + dbu;
h[j * n + nn] = hv;
y += cmat[li * n + nn] * hv;
}
let yg = y * silu_f32(z[li * d + j]);
let orow = &out_proj[j * d_model..j * d_model + d_model];
let obase = (bi * l + li) * d_model;
for m in 0..d_model {
out[obase + m] += yg * orow[m];
}
}
}
}
let output = Tensor::from_vec(out, &[b, l, d_model], &self.device)?;
Ok(MambaOutput {
output: final_output,
output,
aux_info: None,
})
}
}
/// Split projection into main and residual parts
fn split_projection(&self, projected: &Tensor) -> Result<(Tensor, Tensor)> {
let d_inner = self.config.get_d_inner();
let x_proj = projected.narrow(2, 0, d_inner)?;
let res_proj = projected.narrow(2, d_inner, d_inner)?;
Ok((x_proj, res_proj))
}
/// Numerically-stable SiLU (a.k.a. swish): `x · σ(x)`.
#[inline]
fn silu_f32(x: f32) -> f32 {
let s = if x >= 0.0 {
1.0 / (1.0 + (-x).exp())
} else {
let e = x.exp();
e / (1.0 + e)
};
x * s
}
/// Apply 1D convolution (simplified)
fn apply_conv1d(&self, x: &Tensor) -> Result<Tensor> {
// Simplified conv1d - in practice would use proper convolution
let output = x.clone();
if let Some(bias) = &self.conv1d_bias {
Ok(output.add(bias)?)
} else {
Ok(output)
}
}
/// `SiLU` activation function
fn silu(&self, x: &Tensor) -> Result<Tensor> {
// SiLU(x) = x * sigmoid(x)
let sigmoid_x = x.sigmoid()?;
Ok(x.mul(&sigmoid_x)?)
}
/// Numerically-stable softplus: `ln(1 + eˣ) = max(x,0) + ln(1 + e^-|x|)`.
#[inline]
fn softplus_f32(x: f32) -> f32 {
x.max(0.0) + (1.0 + (-x.abs()).exp()).ln()
}
impl Layer for MambaBlock {