// Parallel Selective Scan WGSL Kernel // Implements the S6 (Selective Scan) core of the Mamba architecture. // Uses a Kogge-Stone parallel prefix-sum approach for O(log N) time on GPU. // // Forward pass recurrence: // h_t = A_t * h_{t-1} + B_t * x_t // y_t = C_t * h_t + D * x_t // // where A_t, B_t, C_t are input-dependent (selective) gate matrices. export const SELECTIVE_SCAN_FORWARD_WGSL: string = /* wgsl */` // ---- Binding layout ---- // group 0: sequence data // group 1: SSM parameters struct ScanParams { seq_len : u32, // L – sequence length d_state : u32, // N – state dimension d_inner : u32, // D – inner (expanded) channel dimension batch : u32, // B – batch size }; @group(0) @binding(0) var params : ScanParams; // u (B, L, D) – projected input after conv @group(0) @binding(1) var u : array; // delta (B, L, D) – time-step (Δ) after softplus @group(0) @binding(2) var delta : array; // A (D, N) – log-space diagonal state matrix (fixed, learned) @group(0) @binding(3) var A : array; // B (B, L, N) – input projection (selective) @group(0) @binding(4) var B : array; // C (B, L, N) – output projection (selective) @group(0) @binding(5) var C : array; // D (D,) – skip-connection scale @group(0) @binding(6) var D_vec : array; // y (B, L, D) – output (written by this kernel) @group(0) @binding(7) var y : array; // h_cache (B, L, D*N) – hidden states cache (for backward pass) @group(0) @binding(8) var h_cache : array; // ---- Workgroup shared memory ---- // Each workgroup processes one (batch, channel) slice across all time steps. // We store the associative pair (a_bar, bu_bar) per time step so we can run // a Kogge-Stone scan across the workgroup tile. var wg_a : array; // discretised A values var wg_bu : array; // B*u values // ---- Helpers ---- // Softplus: numerically stable softplus(x) = log(1 + exp(x)) fn softplus(x: f32) -> f32 { // Numerically stable: max(x,0) + log1p(exp(-|x|)). The naive log(1+exp(x)) // overflows to +Inf for x ≳ 88 (f32 exp range); this form never does. (EVM-8) return max(x, 0.0) + log(1.0 + exp(-abs(x))); } // ZerO-Order Hold discretisation of continuous A, Δ: // A_bar = exp(Δ * A) // B_bar = (A_bar - 1) / A * B ≈ Δ * B (first-order for simplicity) fn discretise_A(delta_val: f32, a_log: f32) -> f32 { // A is stored as -exp(a_log) to ensure A_bar < 1 (stable) // Clamp log-decay so -exp(a_log) can't overflow to -Inf (A_bar→0, state // death) nor collapse toward 0 (A_bar→1, no decay). Keeps A_bar strictly in // (0,1) across repeated adapts. Belt-and-suspenders: WSLA also freezes A_log. let a_cont = -exp(clamp(a_log, -10.0, 5.0)); return exp(delta_val * a_cont); } fn discretise_B(delta_val: f32, a_log: f32, b_val: f32) -> f32 { let a_cont = -exp(clamp(a_log, -10.0, 5.0)); let a_bar = exp(delta_val * a_cont); // (A_bar - 1) / A_cont * B let b_bar = (a_bar - 1.0) / a_cont * b_val; return b_bar; } // ---- Main kernel ---- // Dispatch: (ceil(D/8), ceil(N/8), B) // Each invocation is responsible for one (d, n, batch) triplet and scans // the entire sequence using a two-pass Kogge-Stone scan within workgroup tiles. @compute @workgroup_size(64, 1, 1) fn forward_scan( @builtin(global_invocation_id) gid : vec3, @builtin(local_invocation_index) lid : u32, @builtin(workgroup_id) wgid : vec3, ) { let L = params.seq_len; let N = params.d_state; let D = params.d_inner; let B = params.batch; // Each workgroup handles one (batch b, channel d, state n) combination. // We pack d and n into the x dimension: global d = wgid.x, global n = wgid.y let d = wgid.x; let n = wgid.y; let b = gid.z; if (d >= D || n >= N || b >= B) { return; } // Tile size equals workgroup size (64). We process TILE_SIZE steps at once. let TILE: u32 = 64u; // Running state h for this (b, d, n) var h: f32 = 0.0; var tile_start: u32 = 0u; loop { if (tile_start >= L) { break; } let t = tile_start + lid; // absolute time step handled by this lane var a_bar: f32 = 1.0; var bu: f32 = 0.0; if (t < L) { // Indices let delta_idx = b * L * D + t * D + d; let u_idx = b * L * D + t * D + d; let A_idx = d * N + n; let B_idx = b * L * N + t * N + n; let dv = softplus(delta[delta_idx]); a_bar = discretise_A(dv, A[A_idx]); bu = discretise_B(dv, A[A_idx], B[B_idx]) * u[u_idx]; } wg_a[lid] = a_bar; wg_bu[lid] = bu; workgroupBarrier(); // ---- Kogge-Stone inclusive prefix scan within tile ---- // Associative operator: (a1, b1) ∘ (a2, b2) = (a1*a2, a1*b2 + b1) // This computes cumulative state recurrence in log2(TILE) steps. var stride: u32 = 1u; loop { if (stride >= TILE) { break; } if (lid >= stride) { let prev_a = wg_a[lid - stride]; let prev_bu = wg_bu[lid - stride]; // Combine: new_state = prev_a * cur_a (product of A_bars) // new_bu = prev_a * cur_bu + prev_bu let new_a = prev_a * wg_a[lid]; let new_bu = prev_a * wg_bu[lid] + prev_bu; workgroupBarrier(); wg_a[lid] = new_a; wg_bu[lid] = new_bu; } workgroupBarrier(); stride = stride << 1u; } // Incorporate the carry-in state from the previous tile. // After the scan wg_bu[lid] holds the intra-tile inclusive sum. // The actual h at position t = h_carry * wg_a[lid] + wg_bu[lid] let h_t = h * wg_a[lid] + wg_bu[lid]; if (t < L) { // Cache hidden state for backward pass let h_idx = b * L * D * N + t * D * N + d * N + n; h_cache[h_idx] = h_t; // Accumulate y contribution: y_t += C_t[n] * h_t (over all n) // We use an atomic-style accumulation: each (d, n) lane adds its // contribution to the same y[b, t, d]. This races without atomics, // so we instead write to a full h_cache and reduce in a second pass. // Here we perform direct accumulation using atomicAdd approximation: // (safe because each lane writes a unique n, which is stride 1 in mem) let C_idx = b * L * N + t * N + n; let y_idx = b * L * D + t * D + d; // Direct write for n == 0 (first state dim), add for the rest. // Since all workgroups for the same (b,d) run concurrently we must // accumulate safely: we write each partial into h_cache and reduce // in a subsequent lightweight kernel (forward_reduce). // (For simplicity and correctness here we directly atomically add via // f32 emulation – real deployment uses atomicAdd on f32 with spirv ext.) // We store C*h contribution separately so forward_reduce can sum them. // Layout: y_partial (B, L, D, N) – one slot per state dim // y reused as y_partial in this kernel; forward_reduce collapses N dim. let y_partial_idx = b * L * D * N + t * D * N + d * N + n; // Reuse h_cache second half as y_partial (offset by B*L*D*N) let offset = B * L * D * N; h_cache[offset + y_partial_idx] = C[C_idx] * h_t; } // Update carry: last lane's h_t is the tile's final state let last = min(TILE, L - tile_start) - 1u; h = wg_a[last] * h + wg_bu[last]; // recombine carry workgroupBarrier(); tile_start = tile_start + TILE; } } // ---- Reduction kernel ---- // Collapses the N (d_state) dimension of y_partial into y. // Adds the D (skip connection) term: y_t[d] += D_vec[d] * u_t[d] // Dispatch: (ceil(L/64), D, B) @compute @workgroup_size(64, 1, 1) fn forward_reduce( @builtin(global_invocation_id) gid : vec3, ) { let L = params.seq_len; let N = params.d_state; let D = params.d_inner; let B = params.batch; let t = gid.x; let d = gid.y; let b = gid.z; if (t >= L || d >= D || b >= B) { return; } let offset = B * L * D * N; var sum: f32 = 0.0; for (var n: u32 = 0u; n < N; n = n + 1u) { let idx = offset + b * L * D * N + t * D * N + d * N + n; sum = sum + h_cache[idx]; } // Add skip connection let u_idx = b * L * D + t * D + d; sum = sum + D_vec[d] * u[u_idx]; let y_idx = b * L * D + t * D + d; y[y_idx] = sum; } `; // ---- Backward scan kernel (for autograd) ---- // Computes gradients w.r.t. Δ, A, B, C using the cached hidden states. export const SELECTIVE_SCAN_BACKWARD_WGSL: string = /* wgsl */` struct ScanParams { seq_len : u32, d_state : u32, d_inner : u32, batch : u32, }; @group(0) @binding(0) var params : ScanParams; @group(0) @binding(1) var u : array; @group(0) @binding(2) var delta : array; @group(0) @binding(3) var A : array; @group(0) @binding(4) var B : array; @group(0) @binding(5) var C : array; @group(0) @binding(6) var h_cache : array; @group(0) @binding(7) var dy : array; // upstream gradient @group(0) @binding(8) var dA : array; @group(0) @binding(9) var dB : array; @group(0) @binding(10) var dC : array; @group(0) @binding(11) var dDelta : array; @group(0) @binding(12) var du : array; fn softplus(x: f32) -> f32 { // Numerically stable: max(x,0) + log1p(exp(-|x|)). The naive log(1+exp(x)) // overflows to +Inf for x ≳ 88 (f32 exp range); this form never does. (EVM-8) return max(x, 0.0) + log(1.0 + exp(-abs(x))); } fn softplus_grad(x: f32) -> f32 { // d/dx softplus(x) = sigmoid(x) return 1.0 / (1.0 + exp(-x)); } fn discretise_A(delta_val: f32, a_log: f32) -> f32 { // Clamp log-decay so -exp(a_log) can't overflow to -Inf (A_bar→0, state // death) nor collapse toward 0 (A_bar→1, no decay). Keeps A_bar strictly in // (0,1) across repeated adapts. Belt-and-suspenders: WSLA also freezes A_log. let a_cont = -exp(clamp(a_log, -10.0, 5.0)); return exp(delta_val * a_cont); } // Reverse scan (backward pass) – processes time from T-1 down to 0. // Dispatch: (D, N, B) @compute @workgroup_size(1, 1, 1) fn backward_scan( @builtin(global_invocation_id) gid : vec3, ) { let L = params.seq_len; let N = params.d_state; let D = params.d_inner; let B = params.batch; let d = gid.x; let n = gid.y; let b = gid.z; if (d >= D || n >= N || b >= B) { return; } var dh: f32 = 0.0; // gradient of loss w.r.t. h_t, accumulated backwards var t: u32 = L; loop { if (t == 0u) { break; } t = t - 1u; let delta_raw_idx = b * L * D + t * D + d; let A_idx = d * N + n; let B_idx = b * L * N + t * N + n; let C_idx = b * L * N + t * N + n; let u_idx = b * L * D + t * D + d; let h_idx = b * L * D * N + t * D * N + d * N + n; let delta_raw = delta[delta_raw_idx]; let dv = softplus(delta_raw); let a_log = A[A_idx]; let a_cont = -exp(a_log); let a_bar = exp(dv * a_cont); let b_val = B[B_idx]; let c_val = C[C_idx]; let u_val = u[u_idx]; let h_t = h_cache[h_idx]; // dy_t contribution to dh (from C * h_t in the output) // y_t[d] = sum_n C[n] * h_t[n] + D * u => dh_t[n] += C[n] * dy_t[d] let dy_val = dy[b * L * D + t * D + d]; dh = dh + c_val * dy_val; // dC[b, t, n] += dy_t[d] * h_t dC[C_idx] = dC[C_idx] + dy_val * h_t; // h_t = a_bar * h_{t-1} + b_bar * u_t // b_bar = (a_bar - 1) / a_cont * b_val let b_bar = (a_bar - 1.0) / a_cont * b_val; let h_prev = (t > 0u) ? h_cache[b * L * D * N + (t - 1u) * D * N + d * N + n] : 0.0; // dh_{t-1} += a_bar * dh_t // (accumulated in next iteration; here dh already contains upstream) let dh_cur = dh; // dA[d,n] += dh_t * (d a_bar/d a_cont) * (d a_cont/d a_log) * h_{t-1} // + dh_t * (d b_bar/d a_cont) * ... * b_val * u_val // d(a_bar)/d(a_log) = a_bar * (-exp(a_log)) * dv = a_bar * a_cont * dv let da_bar_da_log = a_bar * a_cont * dv; dA[A_idx] = dA[A_idx] + dh_cur * (da_bar_da_log * h_prev); // dB[b,t,n] += dh_t * b_bar / b_val * u_val (since b_bar is linear in b) dB[B_idx] = dB[B_idx] + dh_cur * ((a_bar - 1.0) / a_cont) * u_val; // du[b,t,d] += dh_t * b_bar (accumulate over n in separate kernel) du[u_idx] = du[u_idx] + dh_cur * b_bar; // dDelta[b,t,d]: chain rule through softplus and discretisation // d(b_bar)/d(dv) = d/d(dv)[(a_bar-1)/a_cont * b] = a_bar * b / (a_cont ... ) // actually: d(a_bar)/d(dv) = a_bar * a_cont, d(b_bar)/d(dv) = a_bar * b_val let da_bar_ddv = a_bar * a_cont; let db_bar_ddv = a_bar * b_val; let dLoss_ddv = dh_cur * (da_bar_ddv * h_prev + db_bar_ddv * u_val); let ddv_ddelta = softplus_grad(delta_raw); dDelta[delta_raw_idx] = dDelta[delta_raw_idx] + dLoss_ddv * ddv_ddelta; // Propagate dh to previous timestep dh = a_bar * dh_cur; } } `;