spark_runtime/buffers/
sizes.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Byte sizes for the per-pass GPU buffer arena.
4
5use atlas_core::config::ModelConfig;
6use atlas_core::device::sm121::NUM_SMS;
7
8use super::sizes_q12::{Q12_SIZING_STREAMS, q12_batched_scratch_bytes};
9
10/// Byte sizes of each buffer, derived from ModelConfig.
11#[derive(Debug, Clone)]
12pub struct BufferSizes {
13    pub hidden_states: usize,
14    pub residual: usize,
15    pub norm_output: usize,
16    pub qkv_output: usize,
17    pub attn_output: usize,
18    pub gate_logits: usize,
19    /// FP32 gate logits [m, num_experts] for the ATLAS_FP32_GATE routing path.
20    /// Keeps the router GEMM accumulator unrounded into top-K so near-tied
21    /// experts don't flip on a BF16 store. Allocated whenever num_experts > 0.
22    pub gate_logits_f32: usize,
23    /// FP32 MoE-input norm output [m, hidden] for ATLAS_FP32_ROUTING — the
24    /// full-precision router_in the gate GEMM consumes. Allocated when experts > 0.
25    pub moe_router_in_f32: usize,
26    pub moe_output: usize,
27    pub logits: usize,
28    pub ssm_qkvz: usize,
29    pub ssm_ba: usize,
30    pub ssm_deinterleaved: usize,
31    pub ssm_gates: usize,
32    pub ssm_conv_out_f32: usize,
33    pub scratch: usize,
34    pub expert_gate_out: usize,
35    pub expert_up_out: usize,
36    pub expert_down_out: usize,
37    pub splitk_workspace: usize,
38    /// GDN FLA chunked-prefill scratch (single buffer, sub-divided W|U|S|uc).
39    /// 0 unless the model is a 128-dim-linear-head GDN model (ATLAS_GDN_FLA path).
40    pub gdn_fla_scratch: usize,
41    /// Mamba-2 SSD chunked-scan scratch (single buffer, sub-divided dt | dA_cumsum | CB).
42    /// 0 unless the model has Mamba-2 SSM layers. Shared across layers: they run
43    /// sequentially on one stream, so one allocation serves all 40.
44    pub ssd_scratch: usize,
45    /// Grouped O-projection latent: `[M, o_groups*o_lora_rank]` BF16 (V4-Flash).
46    /// 256 (placeholder) when `o_groups == 0`.
47    pub o_latent: usize,
48    /// Zero-filled BF16 weight (length max_dim) for unweighted RMSNorm under the
49    /// offset-from-1 kernel convention (scale = 1+weight → 1.0). DeepSeek-V4 q_b_norm.
50    pub norm_unit_w: usize,
51    /// HC residual streams: `[M, hc_mult, hidden]` BF16 (DeepSeek-V4 mHC).
52    /// 256 (placeholder) when `hc_mult == 0`.
53    pub hc_streams: usize,
54    /// HC `post` mixing weights: `[M, hc_mult]` F32.
55    pub hc_post: usize,
56    /// HC `comb` Sinkhorn matrix: `[M, hc_mult, hc_mult]` F32.
57    pub hc_comb: usize,
58    /// Low-rank mHC split-collapse scratch (Qwen3.8-Flash-Next): the staged
59    /// normed vector `[T, hc_mult*hidden]` F32 plus the rank vector
60    /// `[T, hc_lowrank]` F32, for SMALL T only — decode runs the collapse as
61    /// three multi-block launches because `grid=[1]` starves the fused kernel
62    /// (measured 2.0 ms/call, one SM's bandwidth). Sized for 64 tokens; the
63    /// dispatcher falls back to the fused kernel above that.
64    pub hc_lowrank_scratch: usize,
65    /// QSA stage-2 prefill-selection scratch (Qwen3.8-Flash-Next), SHARED
66    /// across the 12 indexer layers (they run serially). Layout, slabbed at
67    /// 2048 selective rows: qk [2048, (n_heads+1)*hd] BF16, q_post
68    /// [2048, n_heads, hd] F32, scores [2048, max_seq/ratio] F32, lists
69    /// [2048, topk] i32. 256 (placeholder) when no indexer.
70    pub qsa_select_scratch: usize,
71    /// Token IDs `[M]` u32 for the current pass — stable across the layer loop
72    /// so DeepSeek-V4 hash-MoE layers can read `tid2eid[token_id]`. Always
73    /// allocated (small); unused by models without hash routing.
74    pub token_ids: usize,
75    /// Dense-FFN activation-quant scratch, SHARED across all layers by the
76    /// MMQ (Q4_K), int8 (W4A8), and NVFP4 (W4A4) prefill paths. Was previously a
77    /// per-`DenseFfnLayer` field → 64× duplication (18 GB on Qwen3.6-27B) that
78    /// OOM'd chunked prefill layer-by-layer. Sized for the largest projection K.
79    /// `ffn_act_q8`: q8_1_mmq activations `m*kpad*4 + 1MB` (Q4_K path).
80    /// `ffn_act_a`: int8 `[m,K]` / NVFP4 packed `[m,K/2]` activations.
81    /// `ffn_act_scale`: int8 `[m,K/32]*4` / NVFP4 `[m,K/16]` group scales.
82    /// 0 for MoE models (dense FFN prefill path is Dense-only).
83    pub ffn_act_q8: usize,
84    pub ffn_act_a: usize,
85    pub ffn_act_scale: usize,
86    /// FP8 block-scaled activation scratch for prefill projections (qkv / o /
87    /// ssm-qkvz). Persistent so the W8A8+FP32-epilogue path stops doing a
88    /// per-projection cuMemAlloc + cuStreamSynchronize + cuMemFree. 1 byte/elem.
89    pub fp8_act: usize,
90    /// Per-128-block FP32 scales paired with `fp8_act` (one f32 per 128 elems).
91    pub fp8_act_scale: usize,
92    /// LoRA shrink output `xa = x@Aᵀ`: [m, adapter_max_rank] BF16.
93    /// 0 (→ NULL alloc) when no adapter is configured (adapter_max_rank == 0).
94    pub lora_xa: usize,
95    /// LoRA expand output `delta = xa@Bᵀ`: [m, max target n_out] BF16, where
96    /// max n_out = max(hidden, intermediate) — covers k/v/o/gate/up/down in
97    /// v0 (q_proj is excluded). 0 (→ NULL) when no adapter.
98    pub lora_delta: usize,
99    /// LoRA hidden-activation scratch [m, intermediate_size] BF16 for the
100    /// runtime delta path on FFN projections. 0 (→ NULL) when no adapter.
101    pub lora_hact: usize,
102    /// LoRA per-request routing slots `[m]` i32 — one adapter SLOT index per
103    /// prefilling token (all equal for a single-request prefill; resolves
104    /// `-1`→active before upload). Dedicated buffer (not a packed meta offset)
105    /// so the m-element prefill slot array never collides with the per-path
106    /// positions/slots/block_table region. 0 (→ NULL) when no adapter
107    /// (adapter_max_rank == 0).
108    pub lora_seq_slot: usize,
109    /// Native keep-packed Q2_0 prefill transient-dequant scratch
110    /// (`ATLAS_GGUF_NATIVE_Q2=1`). ONE persistent BF16 `[N,K]` buffer sized to
111    /// the LARGEST keep-packed projection, REUSED for every per-projection
112    /// dequant so prefill stops doing a per-matmul cuMemAlloc +
113    /// cuStreamSynchronize + cuMemFree (the multi-second fixed cost behind the
114    /// 3.7 s / 28-token TTFT regression). 0 (→ NULL) unless the flag is set.
115    pub q2_dequant_scratch: usize,
116    /// Native Q2_0 MMQ prefill q8_1 activation scratch (`ATLAS_GGUF_NATIVE_Q2_MMQ=1`).
117    /// ONE persistent q8_1_mmq buffer (`m*kpad*4 + 1MB`) shared by every kept-packed
118    /// projection (FFN gate/up/down, attn q/k/v/o, GDN qkvz): each seam quantizes
119    /// its BF16 activation into this buffer then runs the packed MMQ GEMM — so the
120    /// 2-bit weight is never dequantized to a BF16 scratch (kills the ~2s dequant
121    /// tax AND the shared-`q2_dequant_scratch` co-dispatch race). Sized to the
122    /// widest projection K = max(hidden, intermediate, q_heads*head_dim).
123    /// 0 (→ NULL) unless the MMQ sub-flag is set.
124    pub q2_act_q8: usize,
125}
126
127impl BufferSizes {
128    /// Compute all buffer sizes from model config and max batch tokens.
129    ///
130    /// All sizes in bytes. BF16 = 2 bytes per element.
131    /// Logits buffer is capped: only needed for decode (1 token) or
132    /// speculative verification (K tokens), never for full prefill.
133    ///
134    /// `max_seq_len` and `kv_block_size` are needed to size the scratch
135    /// buffer for block table metadata during batched decode / verify.
136    pub fn from_config(
137        config: &ModelConfig,
138        max_batch_tokens: usize,
139        max_seq_len: usize,
140        kv_block_size: usize,
141        max_batch_size: usize,
142    ) -> Self {
143        // Derived batched-decode metadata layout (rows = max(32, bs)).
144        // Byte-identical sizing for every bs <= 32; see `decode_meta.rs`.
145        let decode_meta = super::DecodeMetaLayout::for_max_batch_size(max_batch_size);
146        let bf16 = 2;
147        let m = max_batch_tokens;
148        let h = config.hidden_size;
149
150        // Q projection output: gated models produce [Q, gate] (2× nq*hd),
151        // ungated models (VL) produce only [Q] (nq*hd).
152        let q_heads = config.num_attention_heads;
153        let kv_heads = config.num_key_value_heads;
154        let hd = config.head_dim;
155        let q_proj_mul = if config.attn_gated { 2 } else { 1 };
156        let qkv_dim = (q_heads * q_proj_mul + 2 * kv_heads) * hd;
157
158        let top_k = config.num_experts_per_tok;
159
160        // Scratch layout (two users, take max):
161        //
162        // A) Prefill chunk metadata (after MoE routing data):
163        //   [0 .. moe_scratch): MoE topK routing indices+weights
164        //   [moe_scratch .. ): positions(m*4) + slots(m*8) + block_table(max_blocks*4) + seq_len(4)
165        //
166        // B) Batched decode/verify metadata:
167        //   [0 .. 32768): fixed metadata region
168        //   [32768 .. 32768+24R): decode metadata (positions, seq_slot,
169        //     slots, seq_lens; R = decode-meta rows, `decode_meta.rs` —
170        //     24R = 768 at the 32-row floor)
171        //   [32768+24R .. ): decode block table (padded_n × max_blocks × 4 B)
172        //   Batched MTP verify (verify_e.rs) overlays the SAME base with
173        //   VERIFY_ROW_CAP-row gaps at derived offsets (verify_e.rs VMETA_*),
174        //   bt at +24R (bt_rows mirrors the cap).
175        //   Each path re-uploads its own layout pre-dispatch; sizing takes
176        //   the wider (verify) envelope.
177        //
178        // MoE scratch: 2 * M * top_k * 4 (indices [M*top_k] u32 + weights [M*top_k] f32)
179        let moe_scratch = 2 * m * top_k * 4;
180        let max_blocks = max_seq_len
181            .checked_div(kv_block_size)
182            .map(|q| q + 1)
183            .unwrap_or(256);
184        // Prefill metadata: mirrors exact layout in prefill_chunk(). MRoPE
185        // (Qwen3-VL / Qwen3.6) uploads THREE u32 position streams packed
186        // back-to-back (T, H, W); every other model uploads ONE. Sizing the
187        // scratch region for 1× with MRoPE active caused `cuMemcpyHtoDAsync_v2
188        // status 1` failures on long-context prefills (observed: 16k Qwen3.6
189        // failed, 8k passed because the extra 64 KB of write overflow happened
190        // to still land inside the over-provisioned `moe_scratch + meta`
191        // aggregate).
192        let pos_streams = if config.mrope_interleaved { 3 } else { 1 };
193        let pos_bytes = m * 4 * pos_streams;
194        let slot_offset = (pos_bytes + 7) & !7;
195        let slot_end = slot_offset + m * 8;
196        let bt_offset = (slot_end + 3) & !3;
197        let bt_end = bt_offset + max_blocks * 4;
198        let sl_offset = (bt_end + 3) & !3;
199        let prefill_meta = sl_offset + 4;
200        // Block table metadata: the widest user is the batched MTP verify
201        // (verify_e.rs) at R = bt_rows (mirrors VERIFY_ROW_CAP; was 96, the wave-11
202        // depth-at-width envelope — 32:2), whose bt staging sits at
203        // meta_base+2048 (wider 96-row gaps: positions 384 | seq_slot 384 |
204        // slots 768 | seq_lens 384). Batched decode (padded_n ≤ 32) and
205        // DFlash K=γ+1=17 verify keep the narrow +768 layout — strictly
206        // inside this envelope.
207        let bt_rows = 160usize; // batched verify R cap (VERIFY_ROW_CAP, verify_e2.rs)
208        // Envelope = max(verify 96-row overlay, DERIVED decode layout).
209        // The decode layout (`decode_meta.rs`, rows = max(32, bs)) sits
210        // strictly inside the verify overlay for every rows <= 64 (bt at
211        // 24R <= 1536 < 2048, rows <= 96), so this max() changes NOTHING
212        // for bs <= 64; it only grows the scratch once rows > ~85.
213        let bt_meta = 32768
214            + (bt_rows * 24 + bt_rows * max_blocks * 4).max(decode_meta.meta_bytes(max_blocks));
215        let scratch_min = 64 * 1024;
216        // Q12 kernel-batched prefill stages N per-stream meta blocks plus a
217        // stacked BatchedAttnMetadata block — a strictly larger footprint than
218        // the single-stream `prefill_meta`. Provision for `Q12_SIZING_STREAMS`
219        // streams splitting the full token arena so the fast path stays
220        // available for deep-context concurrent prefills without overrunning
221        // scratch (#110: the unprovisioned N-stream multiplication overran the
222        // buffer, producing an out-of-range HtoD → sticky CUDA-700).
223        let q12_chunk = m.div_ceil(Q12_SIZING_STREAMS).max(1);
224        let q12_batched = q12_batched_scratch_bytes(
225            Q12_SIZING_STREAMS,
226            q12_chunk,
227            top_k,
228            config.mrope_interleaved,
229        );
230        let scratch = scratch_min
231            .max(moe_scratch + prefill_meta)
232            .max(bt_meta)
233            .max(q12_batched);
234
235        // Batched expert output buffers for MoE (or dense FFN).
236        // Sized for max(K=3 verify, prefill chunk) × top_k experts.
237        let k_max = m.max(3); // prefill chunk or K=3 verify, whichever larger
238        let expert_inter = if config.num_experts > 0 {
239            let routed = config.num_experts_per_tok * config.moe_intermediate_size;
240            k_max * routed.max(config.intermediate_size)
241        } else {
242            k_max * config.intermediate_size
243        };
244        let expert_gate_out = expert_inter * bf16;
245        let expert_up_out = expert_inter * bf16;
246        // Routed expert down output: [k_max * top_k, moe_input_size].
247        // For LatentMoE (Super 120B), routed experts output in latent space.
248        let moe_out_dim = config.moe_input_size();
249        let expert_down_out = if config.num_experts > 0 {
250            k_max * config.num_experts_per_tok * moe_out_dim * bf16
251        } else {
252            k_max * h * bf16
253        };
254
255        // Logits: only last token used during prefill. Cap at 160 tokens —
256        // the batched MTP verify's R = Σ ks row cap (n=32 × k=3 rows, the
257        // wave-11 depth-at-width envelope; VERIFY_ROW_CAP in verify_e2.rs).
258        // This also covers decode=1, batched decode padded_n<=32 PLUS the
259        // run_standard mixed path (`decode_b2`) parking prefill logits at
260        // row `padded_n` = 32 (the old 33-row bound), spec_verify≤5, and
261        // DFlash K=γ+1=17. ~45 MB at vocab 248320 (was ~30 MB at 64 rows,
262        // ~16 MB at 33).
263        // Derived floor for wide native batches: the run_standard mixed path
264        // (`decode_b2`) parks prefill logits at row `padded_n`, which can be
265        // as high as `decode_meta.rows()` — so the arena must hold rows+1.
266        // Inert (160) for every rows <= 159, i.e. all bs <= 159.
267        let logits_tokens = m.min(160.max(decode_meta.rows() + 1));
268
269        // Mamba-2 d_inner may exceed hidden_size; norm_output and attn_output must fit.
270        let mamba2_d_inner = config.mamba2_d_inner();
271        let max_dim = h.max(mamba2_d_inner);
272
273        // Split-K decode workspace: NUM_SMS * (head_dim + 2) * sizeof(f32).
274        // Partials from split CTAs are stored as [o[head_dim], m, l] per split.
275        // Total slots = num_seqs * num_splits ≤ NUM_SMS, so this is constant ~48 KB.
276        // Read NUM_SMS rather than repeating its value: run_paged_decode derives
277        // num_splits from the same constant, so a literal here is a second source
278        // of truth that under-allocates — silently, into out-of-bounds device
279        // writes — the moment the constant moves.
280        let splitk_workspace = NUM_SMS as usize * (hd + 2) * 4;
281
282        // The residual stream is always BF16.
283        let residual_elem = bf16;
284
285        // FP8 block-scaled activation scratch for prefill projections. The
286        // widest contract dim across call sites is hidden (qkv / ssm-qkvz) or
287        // q_heads*head_dim (o_proj). 1 byte/elem fp8 + one f32 per 128-block.
288        // Mamba-2 out_proj contracts over d_inner (may exceed hidden), and its
289        // prefill input is FP8-precast into this buffer.
290        let max_proj_k = h.max(q_heads * hd).max(mamba2_d_inner);
291        let fp8_act = m * max_proj_k;
292        let fp8_act_scale = m * max_proj_k.div_ceil(128) * 4;
293        // LoRA scratch — only when an adapter is configured (adapter_max_rank
294        // set programmatically pre-build). Widest target n_out =
295        // max(hidden, intermediate, q_proj): covers k/v, o/down (hidden),
296        // gate/up (intermediate), and gated q_proj (2*q_heads*head_dim, which
297        // can exceed both — e.g. 35B 2*16*256=8192 > hidden 4096).
298        let (lora_xa, lora_delta, lora_hact, lora_seq_slot) = if config.adapter_max_rank > 0 {
299            let max_n = h
300                .max(config.intermediate_size)
301                .max(q_proj_mul * q_heads * hd);
302            (
303                m * config.adapter_max_rank * bf16,
304                m * max_n * bf16,
305                m * config.intermediate_size * bf16,
306                m * 4, // [m] i32 per-request routing slots (prefill path)
307            )
308        } else {
309            (0, 0, 0, 0)
310        };
311
312        // GDN FLA chunked-prefill scratch — ONE buffer holding W|U|S|uc back-to-back,
313        // sized for the chunked-prefill arena (nt = ceil(max_batch_tokens / CHUNK)).
314        // Only the 128-dim-linear-head GDN path uses it (the FLA kernels are compiled
315        // for K_DIM=V_DIM=128); 0 otherwise so BufferArena allocs NULL and the
316        // ATLAS_GDN_FLA dispatch stays disabled. Layout per region:
317        //   W  [nt*nv][CHUNK][kd] bf16 ; U,uc [nt*nv][CHUNK][vd] bf16 ;
318        //   S  [nt*nv][kd][vd] bf16 ; gc [nt*nv][CHUNK] f32.
319        const FLA_CHUNK: usize = 64;
320        // SSD chunked scan (mamba2_ssd_*): dt[H][nc][L] f32 + dA_cs[H][nc][L] f32
321        //                                 + CB[nc][G][L][L] f32,  L = 64.
322        const SSD_L: usize = 64;
323        let ssd_scratch = if config.mamba_num_heads > 0 && config.ssm_state_size > 0 {
324            let nc = m.div_ceil(SSD_L) + 1;
325            let hh = config.mamba_num_heads;
326            let gg = config.n_groups.max(1);
327            (hh * nc * SSD_L * 4) * 2 + nc * gg * SSD_L * SSD_L * 4
328        } else {
329            0
330        };
331
332        let gdn_fla_scratch = if config.linear_num_value_heads > 0
333            && config.linear_key_head_dim == 128
334            && config.linear_value_head_dim == 128
335        {
336            // +margin: the batched FLA path (ATLAS_GDN_BATCHED_FLA) sizes its
337            // regions by total_nt = batch*ceil(chunk_len/64), which can exceed
338            // ceil(m/64) by up to `batch` chunks due to per-stream last-chunk
339            // rounding. 16 covers the co-dispatch max-seqs.
340            let nt = m.div_ceil(FLA_CHUNK) + 16;
341            let nv = config.linear_num_value_heads;
342            let kd = config.linear_key_head_dim;
343            let vd = config.linear_value_head_dim;
344            let w = nt * nv * FLA_CHUNK * kd * bf16;
345            let u = nt * nv * FLA_CHUNK * vd * bf16;
346            let s = nt * nv * kd * vd * bf16;
347            let uc = nt * nv * FLA_CHUNK * vd * bf16;
348            let gc = nt * nv * FLA_CHUNK * 4;
349            w + u + s + uc + gc
350        } else {
351            0
352        };
353
354        // Native keep-packed Q2_0 prefill scratch (Tier-1 transient-dequant +
355        // Tier-2 MMQ q8_1 activation); env-gated, 0 unless the flags are set.
356        // Sizing rationale + bounds live on `sizes_q2::q2_scratch_sizes`.
357        let (q2_dequant_scratch, q2_act_q8) = super::sizes_q2::q2_scratch_sizes(config, m, h, hd);
358
359        // Dense-FFN activation-quant scratch, shared across all layers (SSOT).
360        // Sized for the largest projection K = max(hidden, intermediate); the
361        // dense_ffn prefill paths pass `h.max(inter)` to the requant kernels.
362        // 0 for MoE (num_experts>0) — those never take the dense_ffn MMQ path.
363        let (ffn_act_q8, ffn_act_a, ffn_act_scale) = if config.num_experts == 0 {
364            let kmax = h.max(config.intermediate_size);
365            let kpad = kmax.div_ceil(256) * 256;
366            (
367                m * kpad * 4 + (1 << 20), // q8_1_mmq: m*kpad*4 + 1MB (matches q8_1_scratch_bytes)
368                m * kmax,                 // int8 a_i8 [m,K] ≥ NVFP4 packed [m,K/2]
369                m * (kmax / 32) * 4,      // int8 a_scale [m,K/32]*4 ≥ NVFP4 scale [m,K/16]
370            )
371        } else {
372            (0, 0, 0)
373        };
374
375        Self {
376            hidden_states: m * h * residual_elem,
377            residual: m * h * residual_elem,
378            norm_output: m * max_dim * bf16,
379            qkv_output: m * qkv_dim * bf16,
380            attn_output: (m * config.num_attention_heads * config.head_dim * bf16)
381                .max(m * mamba2_d_inner * bf16)
382                // MLA absorbed: attention output is [M, nq, mla_cache_dim=kv_lora+rope]
383                .max(if config.kv_lora_rank > 0 {
384                    m * config.num_attention_heads
385                        * (config.kv_lora_rank + config.qk_rope_head_dim)
386                        * bf16
387                } else {
388                    0
389                }),
390            gate_logits: if config.num_experts > 0 {
391                // LongCat zero-experts: the router scores (routed + zero)
392                // logits even though only `num_experts` expert FFNs exist.
393                m * (config.num_experts + config.zero_expert_num) * bf16
394            } else {
395                256
396            },
397            gate_logits_f32: if config.num_experts > 0 {
398                m * (config.num_experts + config.zero_expert_num) * 4
399            } else {
400                256
401            },
402            moe_router_in_f32: if config.num_experts > 0 {
403                m * h * 4
404            } else {
405                256
406            },
407            moe_output: m * h * bf16,
408            logits: logits_tokens * config.vocab_size * bf16, // BF16 from LM head kernel
409            // SSM buffers are also reused by attention prefill/multi-seq as scratch:
410            //   ssm_qkvz: K+V contiguous storage in prefill [M, 2*kv_dim]
411            //             Mamba-2 in_proj output [M, in_proj_size]
412            //   ssm_deinterleaved: Q contiguous copy [M, nq*hd]
413            //                      Mamba-2 conv1d output [M, d_xBC]
414            // Use max across all uses with minimum 256 to avoid 0-byte alloc.
415            ssm_qkvz: (m * config.ssm_qkvz_size() * bf16)
416                .max(m * config.mamba2_in_proj_size() * bf16)
417                .max(m * 2 * kv_heads * hd * bf16)
418                .max(m * config.shared_expert_intermediate_size * bf16) // MoE shared up scratch
419                .max(256),
420            ssm_ba: (m * config.ssm_ba_size() * bf16)
421                .max(m * config.moe_latent_size * bf16) // LatentMoE latent buffer
422                // MLA reuses ssm_ba for two separate buffers:
423                //   - q_latent    [M, q_lora_rank]    BF16 — output of wq_a GEMM
424                //   - k_rope_buf  [M, qk_rope_head_dim] BF16 — output of wkv_a_rope GEMM
425                // Both are written sequentially (q_latent is consumed before
426                // k_rope_buf is allocated). Size for the larger of the two.
427                .max(if config.kv_lora_rank > 0 {
428                    (m * config.qk_rope_head_dim * bf16).max(m * config.q_lora_rank * bf16)
429                } else {
430                    0
431                })
432                .max(256),
433            ssm_deinterleaved: (m * config.ssm_qkvz_size() * bf16)
434                .max(m * config.mamba2_d_xbc() * bf16)
435                .max(m * q_heads * hd * bf16)
436                // MLA absorbed: Q_absorbed buffer is [M, nq, mla_cache_dim=kv_lora+rope]
437                .max(if config.kv_lora_rank > 0 {
438                    m * q_heads * (config.kv_lora_rank + config.qk_rope_head_dim) * bf16
439                } else {
440                    0
441                })
442                .max(256),
443            ssm_gates: (m * config.linear_num_value_heads * 2 * 4).max(256),
444            // FP32 conv output for SSM recurrent path precision (4 bytes/element).
445            // Uses ssm_qkvz_size as upper bound (includes Q+K+V+Z).
446            // Also reused by MLA as q_rope contiguous buffer: [M, nq * qk_rope_head_dim] BF16.
447            ssm_conv_out_f32: (m * config.ssm_qkvz_size() * 4)
448                .max(if config.kv_lora_rank > 0 {
449                    m * q_heads * config.qk_rope_head_dim * bf16
450                } else {
451                    0
452                })
453                .max(256),
454            scratch,
455            expert_gate_out,
456            expert_up_out,
457            expert_down_out,
458            splitk_workspace,
459            gdn_fla_scratch,
460            ssd_scratch,
461            // Grouped O-projection latent (V4-Flash): [M, o_groups*o_lora_rank].
462            o_latent: (m * config.o_groups * config.o_lora_rank * bf16).max(256),
463            // Zero-filled weight for unweighted RMSNorm (q_b_norm).
464            norm_unit_w: max_dim * bf16,
465            // HC buffers: only allocated for DeepSeek-V4 (hc_mult > 0).
466            hc_streams: if config.hc_mult > 0 {
467                // FP32 mHC highway: the residual streams grow large across the
468                // blocks (the manifold-mixing is norm-preserving, eigenvalue 1),
469                // so BF16 storage swamps the small per-layer signal at scale and
470                // collapses generation. Store the streams in FP32 (4 bytes).
471                m * config.hc_mult * h * 4
472            } else {
473                256
474            },
475            hc_post: if config.hc_mult > 0 {
476                (m * config.hc_mult * 4).max(256)
477            } else {
478                256
479            },
480            hc_comb: if config.hc_mult > 0 {
481                (m * config.hc_mult * config.hc_mult * 4).max(256)
482            } else {
483                256
484            },
485            hc_lowrank_scratch: if config.hc_mult > 0 && config.hc_lowrank > 0 {
486                // Two exclusive layouts share this region:
487                // - decode split path (T <= 64): normed FP32 [64, hc*H] then
488                //   low FP32 [64, rank];
489                // - prefill GEMM path (T > 64, slabbed at <= 2048 tokens):
490                //   normed BF16 [Ts, hc*H], up_pre BF16 [Ts, hc*H],
491                //   low BF16 [Ts, rank], inj_pre BF16 [Ts, hc].
492                let t = m.min(64);
493                let split = t * (config.hc_mult * h + config.hc_lowrank) * 4;
494                let ts = m.min(2048);
495                let gemm = ts * (2 * config.hc_mult * h + config.hc_lowrank + config.hc_mult) * 2;
496                split.max(gemm)
497            } else {
498                256
499            },
500            qsa_select_scratch: if config.index_topk > 0 && config.index_compress_ratio > 0 {
501                const ROWS: usize = 2048;
502                let qkw = (config.index_n_heads + 1) * config.index_head_dim;
503                let n_blocks = max_seq_len.div_ceil(config.index_compress_ratio);
504                let topk = config.index_topk / config.index_compress_ratio;
505                ROWS * qkw * 2
506                    + ROWS * config.index_n_heads * config.index_head_dim * 4
507                    + ROWS * n_blocks * 4
508                    + ROWS * topk * 4
509            } else {
510                256
511            },
512            // Token IDs [M] u32 (stable across the layer loop for hash-MoE).
513            token_ids: (m * 4).max(256),
514            ffn_act_q8,
515            ffn_act_a,
516            ffn_act_scale,
517            fp8_act,
518            fp8_act_scale,
519            lora_xa,
520            lora_delta,
521            lora_hact,
522            lora_seq_slot,
523            q2_dequant_scratch,
524            q2_act_q8,
525        }
526    }
527
528    /// Total bytes across all buffers.
529    pub fn total_bytes(&self) -> usize {
530        self.hidden_states
531            + self.residual
532            + self.norm_output
533            + self.qkv_output
534            + self.attn_output
535            + self.gate_logits
536            + self.gate_logits_f32
537            + self.moe_router_in_f32
538            + self.moe_output
539            + self.logits
540            + self.ssm_qkvz
541            + self.ssm_ba
542            + self.ssm_deinterleaved
543            + self.ssm_gates
544            + self.ssm_conv_out_f32
545            + self.scratch
546            + self.expert_gate_out
547            + self.expert_up_out
548            + self.hc_lowrank_scratch
549            + self.qsa_select_scratch
550            + self.expert_down_out
551            + self.splitk_workspace
552            + self.gdn_fla_scratch
553            + self.ssd_scratch
554            + self.hc_streams
555            + self.hc_post
556            + self.hc_comb
557            + self.token_ids
558            + self.ffn_act_q8
559            + self.ffn_act_a
560            + self.ffn_act_scale
561            + self.fp8_act
562            + self.fp8_act_scale
563            + self.lora_xa
564            + self.lora_delta
565            + self.lora_hact
566            + self.lora_seq_slot
567            + self.q2_dequant_scratch
568            + self.q2_act_q8
569    }
570}