mamba-rs 0.4.0

Mamba SSM and Mamba-3 SISO in Rust with optional CUDA GPU acceleration. Inference and training (BPTT through SSM state, AdamW), CPU + GPU paths, custom CUDA kernels, CUDA Graph capture, f32 / bf16 / f16. Opt-in deterministic training (bit-identical runs, batch-invariant inference) with a tensor-core tier that beats cuBLAS on LLM-sized models.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
//! GPU weight and gradient storage for Mamba SSM.
//!
//! Three storage patterns:
//! - **Inference weights** (`GpuMambaWeights`): flat buffer + WeightSlice views.
//!   One cuMemAlloc, one H2D copy, CUDA Graph safe.
//! - **Training weights** (`GpuMambaTrainWeights`): per-tensor GpuBuffer.
//!   Standard PyTorch/standard pattern for optimizer compatibility.
//! - **Gradients** (`GpuMambaGrads`): flat buffer + GradSlice views.
//!   One memset zeros all grads. Industry standard (PyTorch DDP, FSDP2).

use super::buffers::{GpuBuffer, GpuByteBuffer, GradSlice, WeightSlice, WeightSliceDyn};
use super::dtype::WeightDtype;
use crate::config::MambaConfig;
use crate::weights::MambaWeights;
use std::sync::Arc;

// ---------------------------------------------------------------------------
// Unified weight view trait: abstracts over f32-only (GpuMambaWeights) vs
// mixed-precision (GpuMambaMixedWeights) for inference.
// ---------------------------------------------------------------------------

/// Abstraction over per-layer weights for inference.
/// - Bulk weights return `(ptr, dtype)` — dispatch to sgemm or gemm_ex.
/// - Always-f32 weights return only `ptr`.
pub trait MambaLayerWeightsView {
    // Bulk (dispatches to sgemm if F32, gemm_ex otherwise).
    fn in_proj_w(&self) -> (cudarc::driver::sys::CUdeviceptr, WeightDtype);
    fn x_proj_w(&self) -> (cudarc::driver::sys::CUdeviceptr, WeightDtype);
    fn dt_proj_w(&self) -> (cudarc::driver::sys::CUdeviceptr, WeightDtype);
    fn out_proj_w(&self) -> (cudarc::driver::sys::CUdeviceptr, WeightDtype);
    // Always-f32
    fn norm_weight(&self) -> cudarc::driver::sys::CUdeviceptr;
    fn conv1d_weight(&self) -> cudarc::driver::sys::CUdeviceptr;
    fn conv1d_bias(&self) -> cudarc::driver::sys::CUdeviceptr;
    fn dt_proj_b(&self) -> cudarc::driver::sys::CUdeviceptr;
    fn a_log(&self) -> cudarc::driver::sys::CUdeviceptr;
    fn d_param(&self) -> cudarc::driver::sys::CUdeviceptr;
}

/// Abstraction over backbone-level weights.
pub trait MambaWeightsView {
    type Layer: MambaLayerWeightsView;
    fn input_proj_w(&self) -> (cudarc::driver::sys::CUdeviceptr, WeightDtype);
    fn input_proj_b(&self) -> cudarc::driver::sys::CUdeviceptr;
    fn norm_f_weight(&self) -> cudarc::driver::sys::CUdeviceptr;
    fn n_layers(&self) -> usize;
    fn layer(&self, i: usize) -> &Self::Layer;
}

impl MambaLayerWeightsView for GpuMambaLayerWeights {
    fn in_proj_w(&self) -> (cudarc::driver::sys::CUdeviceptr, WeightDtype) {
        (self.in_proj_w.ptr(), WeightDtype::F32)
    }
    fn x_proj_w(&self) -> (cudarc::driver::sys::CUdeviceptr, WeightDtype) {
        (self.x_proj_w.ptr(), WeightDtype::F32)
    }
    fn dt_proj_w(&self) -> (cudarc::driver::sys::CUdeviceptr, WeightDtype) {
        (self.dt_proj_w.ptr(), WeightDtype::F32)
    }
    fn out_proj_w(&self) -> (cudarc::driver::sys::CUdeviceptr, WeightDtype) {
        (self.out_proj_w.ptr(), WeightDtype::F32)
    }
    fn norm_weight(&self) -> cudarc::driver::sys::CUdeviceptr {
        self.norm_weight.ptr()
    }
    fn conv1d_weight(&self) -> cudarc::driver::sys::CUdeviceptr {
        self.conv1d_weight.ptr()
    }
    fn conv1d_bias(&self) -> cudarc::driver::sys::CUdeviceptr {
        self.conv1d_bias.ptr()
    }
    fn dt_proj_b(&self) -> cudarc::driver::sys::CUdeviceptr {
        self.dt_proj_b.ptr()
    }
    fn a_log(&self) -> cudarc::driver::sys::CUdeviceptr {
        self.a_log.ptr()
    }
    fn d_param(&self) -> cudarc::driver::sys::CUdeviceptr {
        self.d_param.ptr()
    }
}

impl MambaWeightsView for GpuMambaWeights {
    type Layer = GpuMambaLayerWeights;
    fn input_proj_w(&self) -> (cudarc::driver::sys::CUdeviceptr, WeightDtype) {
        (self.input_proj_w.ptr(), WeightDtype::F32)
    }
    fn input_proj_b(&self) -> cudarc::driver::sys::CUdeviceptr {
        self.input_proj_b.ptr()
    }
    fn norm_f_weight(&self) -> cudarc::driver::sys::CUdeviceptr {
        self.norm_f_weight.ptr()
    }
    fn n_layers(&self) -> usize {
        self.layers.len()
    }
    fn layer(&self, i: usize) -> &Self::Layer {
        &self.layers[i]
    }
}

impl MambaLayerWeightsView for GpuMambaMixedLayerWeights {
    fn in_proj_w(&self) -> (cudarc::driver::sys::CUdeviceptr, WeightDtype) {
        (self.in_proj_w.ptr(), self.in_proj_w.dtype())
    }
    fn x_proj_w(&self) -> (cudarc::driver::sys::CUdeviceptr, WeightDtype) {
        (self.x_proj_w.ptr(), self.x_proj_w.dtype())
    }
    fn dt_proj_w(&self) -> (cudarc::driver::sys::CUdeviceptr, WeightDtype) {
        (self.dt_proj_w.ptr(), self.dt_proj_w.dtype())
    }
    fn out_proj_w(&self) -> (cudarc::driver::sys::CUdeviceptr, WeightDtype) {
        (self.out_proj_w.ptr(), self.out_proj_w.dtype())
    }
    fn norm_weight(&self) -> cudarc::driver::sys::CUdeviceptr {
        self.norm_weight.ptr()
    }
    fn conv1d_weight(&self) -> cudarc::driver::sys::CUdeviceptr {
        self.conv1d_weight.ptr()
    }
    fn conv1d_bias(&self) -> cudarc::driver::sys::CUdeviceptr {
        self.conv1d_bias.ptr()
    }
    fn dt_proj_b(&self) -> cudarc::driver::sys::CUdeviceptr {
        self.dt_proj_b.ptr()
    }
    fn a_log(&self) -> cudarc::driver::sys::CUdeviceptr {
        self.a_log.ptr()
    }
    fn d_param(&self) -> cudarc::driver::sys::CUdeviceptr {
        self.d_param.ptr()
    }
}

impl MambaWeightsView for GpuMambaMixedWeights {
    type Layer = GpuMambaMixedLayerWeights;
    fn input_proj_w(&self) -> (cudarc::driver::sys::CUdeviceptr, WeightDtype) {
        (self.input_proj_w.ptr(), self.input_proj_w.dtype())
    }
    fn input_proj_b(&self) -> cudarc::driver::sys::CUdeviceptr {
        self.input_proj_b.ptr()
    }
    fn norm_f_weight(&self) -> cudarc::driver::sys::CUdeviceptr {
        self.norm_f_weight.ptr()
    }
    fn n_layers(&self) -> usize {
        self.layers.len()
    }
    fn layer(&self, i: usize) -> &Self::Layer {
        &self.layers[i]
    }
}

// ---------------------------------------------------------------------------
// Inference weights — flat buffer + WeightSlice views (read-only, CUDA Graph safe)
// ---------------------------------------------------------------------------

/// GPU weights for a single Mamba layer (inference — flat buffer views).
pub struct GpuMambaLayerWeights {
    pub norm_weight: WeightSlice,
    pub in_proj_w: WeightSlice,
    pub conv1d_weight: WeightSlice,
    pub conv1d_bias: WeightSlice,
    pub x_proj_w: WeightSlice,
    pub dt_proj_w: WeightSlice,
    pub dt_proj_b: WeightSlice,
    pub a_log: WeightSlice,
    pub d_param: WeightSlice,
    pub out_proj_w: WeightSlice,
}

/// GPU weights for the full Mamba backbone (inference — flat buffer).
pub struct GpuMambaWeights {
    pub flat: GpuBuffer,
    pub input_proj_w: WeightSlice,
    pub input_proj_b: WeightSlice,
    pub layers: Vec<GpuMambaLayerWeights>,
    pub norm_f_weight: WeightSlice,
}

impl GpuMambaWeights {
    /// Upload CPU weights to GPU as a single contiguous allocation.
    pub fn from_cpu(
        stream: &Arc<cudarc::driver::CudaStream>,
        cpu: &MambaWeights,
        cfg: &MambaConfig,
    ) -> Result<Self, String> {
        let d_model = cfg.d_model;
        let d_inner = cfg.d_inner();
        let d_state = cfg.d_state;
        let d_conv = cfg.d_conv;
        let dt_rank = cfg.dt_rank();
        let xdbl_dim = cfg.xdbl_dim();

        let per_layer = d_model
            + d_model * 2 * d_inner
            + d_inner * d_conv
            + d_inner
            + d_inner * xdbl_dim
            + dt_rank * d_inner
            + d_inner
            + d_inner * d_state
            + d_inner
            + d_inner * d_model;

        // Use the CPU weights' actual lengths. For HF Mamba (identity_proj),
        // both input_proj_w and input_proj_b are empty; the formula must not
        // assume d_model-length bias when the whole projection is skipped.
        let total = cpu.input_proj_w.len()
            + cpu.input_proj_b.len()
            + cfg.n_layers * per_layer
            + cpu.norm_f_weight.len();

        let flat = GpuBuffer::zeros(stream, total)?;
        // Wait for the async zero-memset to finish before host-sync uploads
        // (cuMemcpyHtoD_v2 on default stream does NOT serialize with custom
        // streams under per-thread default-stream semantics). See
        // `GpuMambaMixedWeights::from_cpu` for details.
        stream
            .synchronize()
            .map_err(|e| format!("sync after f32 weight alloc: {e:?}"))?;
        let base = flat.cached_ptr();

        let mut off = 0usize;
        macro_rules! ws {
            ($data:expr) => {{
                let len = $data.len();
                let slice = WeightSlice::from_offset(base, off, len);
                slice.upload_from_cpu($data)?;
                off += len;
                slice
            }};
        }

        let input_proj_w = ws!(&cpu.input_proj_w);
        let input_proj_b = ws!(&cpu.input_proj_b);

        let mut layers = Vec::with_capacity(cfg.n_layers);
        for lw in &cpu.layers {
            layers.push(GpuMambaLayerWeights {
                norm_weight: ws!(&lw.norm_weight),
                in_proj_w: ws!(&lw.in_proj_w),
                conv1d_weight: ws!(&lw.conv1d_weight),
                conv1d_bias: ws!(&lw.conv1d_bias),
                x_proj_w: ws!(&lw.x_proj_w),
                dt_proj_w: ws!(&lw.dt_proj_w),
                dt_proj_b: ws!(&lw.dt_proj_b),
                a_log: ws!(&lw.a_log),
                d_param: ws!(&lw.d_param),
                out_proj_w: ws!(&lw.out_proj_w),
            });
        }

        let norm_f_weight = ws!(&cpu.norm_f_weight);
        debug_assert_eq!(
            off, total,
            "weight layout mismatch: off={off} total={total}"
        );

        Ok(Self {
            flat,
            input_proj_w,
            input_proj_b,
            layers,
            norm_f_weight,
        })
    }
}

// ---------------------------------------------------------------------------
// Mixed-precision inference weights — two arenas:
//   - bulk_arena (bf16/f16): linear projection weights (in_proj, x_proj,
//     dt_proj, out_proj, input_proj)
//   - f32_arena: always-f32 tensors (norms, biases, a_log, D, dt_proj_b,
//     conv1d_bias, conv1d_weight, input_proj_b) — critical for numerical
//     stability (exp, softplus, rsqrt, SSM recurrence).
//
// Use this for LLM inference. Training keeps GpuMambaTrainWeights (f32 only).
// ---------------------------------------------------------------------------

pub struct GpuMambaMixedLayerWeights {
    pub norm_weight: WeightSliceDyn,   // f32
    pub in_proj_w: WeightSliceDyn,     // bulk (bf16/f16)
    pub conv1d_weight: WeightSliceDyn, // f32 (small, custom kernel reads directly)
    pub conv1d_bias: WeightSliceDyn,   // f32
    pub x_proj_w: WeightSliceDyn,      // bulk
    pub dt_proj_w: WeightSliceDyn,     // bulk
    pub dt_proj_b: WeightSliceDyn,     // f32
    pub a_log: WeightSliceDyn,         // f32 (used in exp, critical)
    pub d_param: WeightSliceDyn,       // f32 (SSM skip, critical)
    pub out_proj_w: WeightSliceDyn,    // bulk
}

pub struct GpuMambaMixedWeights {
    /// Arena holding bulk weights in bulk_dtype (bf16/f16).
    pub bulk_arena: GpuByteBuffer,
    /// Arena holding always-f32 weights.
    pub f32_arena: GpuByteBuffer,
    /// Dtype of bulk_arena.
    pub bulk_dtype: WeightDtype,
    pub input_proj_w: WeightSliceDyn, // bulk
    pub input_proj_b: WeightSliceDyn, // f32
    pub layers: Vec<GpuMambaMixedLayerWeights>,
    pub norm_f_weight: WeightSliceDyn, // f32
}

impl GpuMambaMixedWeights {
    pub fn from_cpu(
        stream: &Arc<cudarc::driver::CudaStream>,
        cpu: &MambaWeights,
        cfg: &MambaConfig,
        bulk_dtype: WeightDtype,
    ) -> Result<Self, String> {
        let d_model = cfg.d_model;
        let d_inner = cfg.d_inner();
        let d_state = cfg.d_state;
        let d_conv = cfg.d_conv;
        let dt_rank = cfg.dt_rank();
        let xdbl_dim = cfg.xdbl_dim();

        // bulk (per layer): in_proj_w + x_proj_w + dt_proj_w + out_proj_w
        let per_layer_bulk =
            d_model * 2 * d_inner + d_inner * xdbl_dim + dt_rank * d_inner + d_inner * d_model;
        // f32 (per layer): norm + conv1d_w + conv1d_b + dt_proj_b + a_log + d_param
        let per_layer_f32 =
            d_model + d_inner * d_conv + d_inner + d_inner + d_inner * d_state + d_inner;

        // Use actual CPU weight lengths — HF Mamba has empty input_proj_w/b
        // (identity_proj), while MambaBackbone::init populates both to d_model.
        let bulk_elems = cpu.input_proj_w.len() + cfg.n_layers * per_layer_bulk;
        let f32_elems =
            cpu.input_proj_b.len() + cfg.n_layers * per_layer_f32 + cpu.norm_f_weight.len();

        let bulk_arena = GpuByteBuffer::zeros(stream, bulk_elems * bulk_dtype.size_bytes())?;
        let f32_arena = GpuByteBuffer::zeros(stream, f32_elems * 4)?;

        // Wait for the async zero-memsets queued by `alloc_zeros` on the custom
        // stream to finish before uploading weight data via `cuMemcpyHtoD_v2`
        // (which runs on the default stream). Without this barrier, under
        // per-thread default-stream semantics (CUDA 12+), the default-stream
        // sync memcpy does NOT serialize with custom-stream async ops; the
        // memset then races with the copy and zeros out just-uploaded data.
        // Observed on mamba-130m-hf bf16 (d_model=768) where layer-0
        // norm_weight was silently overwritten with zeros between upload and
        // first RMSNorm kernel launch, producing zero output and stuck-token
        // decoding.
        stream
            .synchronize()
            .map_err(|e| format!("sync after arena zero-init: {e:?}"))?;

        let bulk_base = bulk_arena.cached_ptr();
        let f32_base = f32_arena.cached_ptr();

        let mut bulk_off = 0usize; // byte offset in bulk_arena
        let mut f32_off = 0usize; // byte offset in f32_arena

        let mut alloc_bulk = |data: &[f32]| -> Result<WeightSliceDyn, String> {
            let len = data.len();
            let slice = WeightSliceDyn::from_byte_offset(bulk_base, bulk_off, len, bulk_dtype);
            slice.upload_from_cpu_f32(stream, data)?;
            bulk_off += len * bulk_dtype.size_bytes();
            Ok(slice)
        };
        let mut alloc_f32 = |data: &[f32]| -> Result<WeightSliceDyn, String> {
            let len = data.len();
            let slice = WeightSliceDyn::from_byte_offset(f32_base, f32_off, len, WeightDtype::F32);
            slice.upload_from_cpu_f32(stream, data)?;
            f32_off += len * 4;
            Ok(slice)
        };

        let input_proj_w = alloc_bulk(&cpu.input_proj_w)?;
        let input_proj_b = alloc_f32(&cpu.input_proj_b)?;

        let mut layers = Vec::with_capacity(cfg.n_layers);
        for lw in &cpu.layers {
            layers.push(GpuMambaMixedLayerWeights {
                norm_weight: alloc_f32(&lw.norm_weight)?,
                in_proj_w: alloc_bulk(&lw.in_proj_w)?,
                conv1d_weight: alloc_f32(&lw.conv1d_weight)?,
                conv1d_bias: alloc_f32(&lw.conv1d_bias)?,
                x_proj_w: alloc_bulk(&lw.x_proj_w)?,
                dt_proj_w: alloc_bulk(&lw.dt_proj_w)?,
                dt_proj_b: alloc_f32(&lw.dt_proj_b)?,
                a_log: alloc_f32(&lw.a_log)?,
                d_param: alloc_f32(&lw.d_param)?,
                out_proj_w: alloc_bulk(&lw.out_proj_w)?,
            });
        }

        let norm_f_weight = alloc_f32(&cpu.norm_f_weight)?;

        debug_assert_eq!(bulk_off, bulk_elems * bulk_dtype.size_bytes());
        debug_assert_eq!(f32_off, f32_elems * 4);

        Ok(Self {
            bulk_arena,
            f32_arena,
            bulk_dtype,
            input_proj_w,
            input_proj_b,
            layers,
            norm_f_weight,
        })
    }
}

// ---------------------------------------------------------------------------
// Training weights — per-tensor GpuBuffer (PyTorch/standard standard)
// ---------------------------------------------------------------------------

/// GPU training weights for a single Mamba layer (per-tensor allocation).
pub struct GpuMambaTrainLayerWeights {
    pub norm_weight: GpuBuffer,
    pub in_proj_w: GpuBuffer,
    pub conv1d_weight: GpuBuffer,
    pub conv1d_bias: GpuBuffer,
    pub x_proj_w: GpuBuffer,
    pub dt_proj_w: GpuBuffer,
    pub dt_proj_b: GpuBuffer,
    pub a_log: GpuBuffer,
    pub d_param: GpuBuffer,
    pub out_proj_w: GpuBuffer,
}

/// GPU training weights for the full Mamba backbone (per-tensor allocation).
pub struct GpuMambaTrainWeights {
    pub input_proj_w: GpuBuffer,
    pub input_proj_b: GpuBuffer,
    pub layers: Vec<GpuMambaTrainLayerWeights>,
    pub norm_f_weight: GpuBuffer,
}

impl GpuMambaTrainWeights {
    /// Upload CPU weights to GPU as per-tensor allocations.
    pub fn from_cpu(
        stream: &Arc<cudarc::driver::CudaStream>,
        cpu: &MambaWeights,
    ) -> Result<Self, String> {
        let mut layers = Vec::with_capacity(cpu.layers.len());
        for lw in &cpu.layers {
            layers.push(GpuMambaTrainLayerWeights {
                norm_weight: GpuBuffer::from_cpu(stream, &lw.norm_weight)?,
                in_proj_w: GpuBuffer::from_cpu(stream, &lw.in_proj_w)?,
                conv1d_weight: GpuBuffer::from_cpu(stream, &lw.conv1d_weight)?,
                conv1d_bias: GpuBuffer::from_cpu(stream, &lw.conv1d_bias)?,
                x_proj_w: GpuBuffer::from_cpu(stream, &lw.x_proj_w)?,
                dt_proj_w: GpuBuffer::from_cpu(stream, &lw.dt_proj_w)?,
                dt_proj_b: GpuBuffer::from_cpu(stream, &lw.dt_proj_b)?,
                a_log: GpuBuffer::from_cpu(stream, &lw.a_log)?,
                d_param: GpuBuffer::from_cpu(stream, &lw.d_param)?,
                out_proj_w: GpuBuffer::from_cpu(stream, &lw.out_proj_w)?,
            });
        }

        Ok(Self {
            input_proj_w: GpuBuffer::from_cpu(stream, &cpu.input_proj_w)?,
            input_proj_b: GpuBuffer::from_cpu(stream, &cpu.input_proj_b)?,
            layers,
            norm_f_weight: GpuBuffer::from_cpu(stream, &cpu.norm_f_weight)?,
        })
    }
}

// ---------------------------------------------------------------------------
// Gradients — flat buffer + GradSlice views (one memset zeros all)
// ---------------------------------------------------------------------------

/// Per-layer gradient views into the flat gradient buffer.
pub struct GpuMambaLayerGrads {
    pub norm_weight: GradSlice,
    pub in_proj_w: GradSlice,
    pub conv1d_weight: GradSlice,
    pub conv1d_bias: GradSlice,
    pub x_proj_w: GradSlice,
    pub dt_proj_w: GradSlice,
    pub dt_proj_b: GradSlice,
    pub a_log: GradSlice,
    pub d_param: GradSlice,
    pub out_proj_w: GradSlice,
}

/// Flat gradient buffer with GradSlice views for all Mamba parameters.
///
/// One `zero()` call clears all gradients. Industry standard layout
/// (PyTorch DDP, FSDP2, standard).
pub struct GpuMambaGrads {
    pub flat: GpuBuffer,
    pub input_proj_w: GradSlice,
    pub input_proj_b: GradSlice,
    pub layers: Vec<GpuMambaLayerGrads>,
    pub norm_f_weight: GradSlice,
}

impl GpuMambaGrads {
    /// Allocate zeroed flat gradient buffer with per-tensor views.
    pub fn new(
        stream: &Arc<cudarc::driver::CudaStream>,
        cfg: &MambaConfig,
        input_dim: usize,
    ) -> Result<Self, String> {
        let dm = cfg.d_model;
        let di = cfg.d_inner();
        let ds = cfg.d_state;
        let dc = cfg.d_conv;
        let dr = cfg.dt_rank();
        let xd = cfg.xdbl_dim();

        let per_layer =
            dm + dm * 2 * di + di * dc + di + di * xd + dr * di + di + di * ds + di + di * dm;
        let total = input_dim * dm + dm + cfg.n_layers * per_layer + dm;

        let flat = GpuBuffer::zeros(stream, total)?;
        let base = flat.cached_ptr();

        let mut off = 0usize;
        macro_rules! gs {
            ($len:expr) => {{
                let len = $len;
                let slice = GradSlice::from_offset(base, off, len);
                off += len;
                slice
            }};
        }

        let input_proj_w = gs!(input_dim * dm);
        let input_proj_b = gs!(dm);

        let mut layers = Vec::with_capacity(cfg.n_layers);
        for _ in 0..cfg.n_layers {
            layers.push(GpuMambaLayerGrads {
                norm_weight: gs!(dm),
                in_proj_w: gs!(dm * 2 * di),
                conv1d_weight: gs!(di * dc),
                conv1d_bias: gs!(di),
                x_proj_w: gs!(di * xd),
                dt_proj_w: gs!(dr * di),
                dt_proj_b: gs!(di),
                a_log: gs!(di * ds),
                d_param: gs!(di),
                out_proj_w: gs!(di * dm),
            });
        }

        let norm_f_weight = gs!(dm);
        debug_assert_eq!(off, total, "grad layout mismatch: off={off} total={total}");

        Ok(Self {
            flat,
            input_proj_w,
            input_proj_b,
            layers,
            norm_f_weight,
        })
    }

    /// Zero all gradients with a single memset (async on stream).
    pub fn zero(&mut self, stream: &Arc<cudarc::driver::CudaStream>) -> Result<(), String> {
        self.flat.zero(stream)
    }
}