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_kernels::attn_splitk;
7
8use super::sizes_q12::{Q12_SIZING_STREAMS, q12_batched_scratch_bytes};
9
10/// The widest `M` the FUSED dense-FFN gate+up decode GEMM serves (#927), and
11/// therefore the row extent `ffn_gate_up_fused` is sized for.
12///
13/// 16 — the top of the decode band. The fused arm is a per-LAUNCH saving, and
14/// the launch overhead it removes is only material while the GEMM is
15/// weight-bandwidth bound; at the prefill widths the same two projections
16/// already run at 68.6% of FP8 peak (nsys round 13, M=4576), where a launch
17/// costs nothing measurable. 16 is also the largest batch H100 round 13
18/// captured (`Captured CUDA graph for batch size 16`).
19///
20/// DECLARED HERE because the arena is sized in this crate and the dispatch
21/// rule lives above it; `spark_model::layers::dense_ffn_gateup_fused` reads
22/// THIS constant rather than restating it, so the band and the buffer cannot
23/// disagree.
24pub const GATEUP_FUSED_MAX_M: usize = 16;
25
26/// Byte sizes of each buffer, derived from ModelConfig.
27#[derive(Debug, Clone)]
28pub struct BufferSizes {
29    pub hidden_states: usize,
30    pub residual: usize,
31    pub norm_output: usize,
32    pub qkv_output: usize,
33    pub attn_output: usize,
34    pub gate_logits: usize,
35    /// FP32 gate logits [m, num_experts] for the ATLAS_FP32_GATE routing path.
36    /// Keeps the router GEMM accumulator unrounded into top-K so near-tied
37    /// experts don't flip on a BF16 store. Allocated whenever num_experts > 0.
38    pub gate_logits_f32: usize,
39    /// FP32 MoE-input norm output [m, hidden] for ATLAS_FP32_ROUTING — the
40    /// full-precision router_in the gate GEMM consumes. Allocated when experts > 0.
41    pub moe_router_in_f32: usize,
42    pub moe_output: usize,
43    pub logits: usize,
44    pub ssm_qkvz: usize,
45    pub ssm_ba: usize,
46    pub ssm_deinterleaved: usize,
47    pub ssm_gates: usize,
48    pub ssm_conv_out_f32: usize,
49    pub scratch: usize,
50    pub expert_gate_out: usize,
51    pub expert_up_out: usize,
52    pub expert_down_out: usize,
53    pub splitk_workspace: usize,
54    /// GDN FLA chunked-prefill scratch (single buffer, sub-divided W|U|S|uc).
55    /// 0 unless the model is a 128-dim-linear-head GDN model (ATLAS_GDN_FLA path).
56    pub gdn_fla_scratch: usize,
57    /// Mamba-2 SSD chunked-scan scratch (single buffer, sub-divided dt | dA_cumsum | CB).
58    /// 0 unless the model has Mamba-2 SSM layers. Shared across layers: they run
59    /// sequentially on one stream, so one allocation serves all 40.
60    pub ssd_scratch: usize,
61    /// Grouped O-projection latent: `[M, o_groups*o_lora_rank]` BF16 (V4-Flash).
62    /// 256 (placeholder) when `o_groups == 0`.
63    pub o_latent: usize,
64    /// Zero-filled BF16 weight (length max_dim) for unweighted RMSNorm under the
65    /// offset-from-1 kernel convention (scale = 1+weight → 1.0). DeepSeek-V4 q_b_norm.
66    pub norm_unit_w: usize,
67    /// HC residual streams: `[M, hc_mult, hidden]` BF16 (DeepSeek-V4 mHC).
68    /// 256 (placeholder) when `hc_mult == 0`.
69    pub hc_streams: usize,
70    /// HC `post` mixing weights: `[M, hc_mult]` F32.
71    pub hc_post: usize,
72    /// HC `comb` Sinkhorn matrix: `[M, hc_mult, hc_mult]` F32.
73    pub hc_comb: usize,
74    /// Low-rank mHC split-collapse scratch (Qwen3.8-Flash-Next): the staged
75    /// normed vector `[T, hc_mult*hidden]` F32 plus the rank vector
76    /// `[T, hc_lowrank]` F32, for SMALL T only — decode runs the collapse as
77    /// three multi-block launches because `grid=[1]` starves the fused kernel
78    /// (measured 2.0 ms/call, one SM's bandwidth). Sized for 64 tokens; the
79    /// dispatcher falls back to the fused kernel above that.
80    pub hc_lowrank_scratch: usize,
81    /// QSA stage-2 prefill-selection scratch (Qwen3.8-Flash-Next), SHARED
82    /// across the 12 indexer layers (they run serially). Layout, slabbed at
83    /// 2048 selective rows: qk [2048, (n_heads+1)*hd] BF16, q_post
84    /// [2048, n_heads, hd] F32, scores [2048, max_seq/ratio] F32, lists
85    /// [2048, topk] i32. 256 (placeholder) when no indexer.
86    pub qsa_select_scratch: usize,
87    /// Token IDs `[M]` u32 for the current pass — stable across the layer loop
88    /// so DeepSeek-V4 hash-MoE layers can read `tid2eid[token_id]`. Always
89    /// allocated (small); unused by models without hash routing.
90    pub token_ids: usize,
91    /// Dense-FFN activation-quant scratch, SHARED across all layers by the
92    /// MMQ (Q4_K), int8 (W4A8), and NVFP4 (W4A4) prefill paths. Was previously a
93    /// per-`DenseFfnLayer` field → 64× duplication (18 GB on Qwen3.6-27B) that
94    /// OOM'd chunked prefill layer-by-layer. Sized for the largest projection K.
95    /// `ffn_act_q8`: q8_1_mmq activations `m*kpad*4 + 1MB` (Q4_K path).
96    /// `ffn_act_a`: int8 `[m,K]` / NVFP4 packed `[m,K/2]` activations.
97    /// `ffn_act_scale`: int8 `[m,K/32]*4` / NVFP4 `[m,K/16]` group scales.
98    /// 0 for MoE models (dense FFN prefill path is Dense-only).
99    pub ffn_act_q8: usize,
100    pub ffn_act_a: usize,
101    pub ffn_act_scale: usize,
102    /// `[K/128, ceil16(M)]` FP32 copy of `ffn_act_scale` in the layout cuBLASLt
103    /// documents for a VEC128 B operand (token index contiguous). Written by
104    /// `fp8_act_scale_to_kmajor` on every cuBLASLt block-scaled FFN GEMM; the
105    /// quantizer's own `[M, K/128]` output stays in `ffn_act_scale` because the
106    /// in-tree kernel reads that order. 0 for MoE models, like its siblings.
107    pub ffn_act_scale_kmajor: usize,
108    /// `[GATEUP_FUSED_MAX_M, 2 * intermediate]` BF16 output of the FUSED
109    /// dense-FFN gate+up decode GEMM (#927) — the single cuBLASLt call at
110    /// `N = 2 * intermediate` whose row is `[gate | up]`. Its own buffer and
111    /// not a widened `expert_gate_out` because the fused arm serves the DECODE
112    /// band only (5..=16 rows, `layers/dense_ffn_gateup_fused.rs`): sizing it
113    /// for the band is ~2.2 MB at Qwen3.8-27B, sizing `expert_gate_out` for
114    /// `[max_batch_tokens, 2 * inter]` would be ~41 MB of prefill rows the arm
115    /// never writes.
116    ///
117    /// Allocated for every DENSE model rather than behind the lever: the arena
118    /// is built from `ModelConfig` and a target's serving levers are resolved
119    /// above this crate, and 2.2 MB is not worth a second resolution that
120    /// could disagree with the dispatch site's.
121    pub ffn_gate_up_fused: usize,
122    /// FP8 block-scaled activation scratch for prefill projections (qkv / o /
123    /// ssm-qkvz). Persistent so the W8A8+FP32-epilogue path stops doing a
124    /// per-projection cuMemAlloc + cuStreamSynchronize + cuMemFree. 1 byte/elem.
125    pub fp8_act: usize,
126    /// Per-128-block FP32 scales paired with `fp8_act` (one f32 per 128 elems).
127    pub fp8_act_scale: usize,
128    /// `[K/128, ceil16(M)]` FP32 transpose of `fp8_act_scale` — the VEC128
129    /// B-scale layout cuBLASLt documents (token index contiguous). The
130    /// prefill-projection sibling of `ffn_act_scale_kmajor`, allocated for
131    /// every model rather than dense-only because the SSM `in_proj_qkvz`
132    /// cuBLASLt arm consumes it. Same element count as `fp8_act_scale`.
133    pub fp8_act_scale_kmajor: usize,
134    /// LoRA shrink output `xa = x@Aᵀ`: [m, adapter_max_rank] BF16.
135    /// 0 (→ NULL alloc) when no adapter is configured (adapter_max_rank == 0).
136    pub lora_xa: usize,
137    /// LoRA expand output `delta = xa@Bᵀ`: [m, max target n_out] BF16, where
138    /// max n_out = max(hidden, intermediate) — covers k/v/o/gate/up/down in
139    /// v0 (q_proj is excluded). 0 (→ NULL) when no adapter.
140    pub lora_delta: usize,
141    /// LoRA hidden-activation scratch [m, intermediate_size] BF16 for the
142    /// runtime delta path on FFN projections. 0 (→ NULL) when no adapter.
143    pub lora_hact: usize,
144    /// LoRA per-request routing slots `[m]` i32 — one adapter SLOT index per
145    /// prefilling token (all equal for a single-request prefill; resolves
146    /// `-1`→active before upload). Dedicated buffer (not a packed meta offset)
147    /// so the m-element prefill slot array never collides with the per-path
148    /// positions/slots/block_table region. 0 (→ NULL) when no adapter
149    /// (adapter_max_rank == 0).
150    pub lora_seq_slot: usize,
151    /// Native keep-packed Q2_0 prefill transient-dequant scratch
152    /// (`ATLAS_GGUF_NATIVE_Q2=1`). ONE persistent BF16 `[N,K]` buffer sized to
153    /// the LARGEST keep-packed projection, REUSED for every per-projection
154    /// dequant so prefill stops doing a per-matmul cuMemAlloc +
155    /// cuStreamSynchronize + cuMemFree (the multi-second fixed cost behind the
156    /// 3.7 s / 28-token TTFT regression). 0 (→ NULL) unless the flag is set.
157    pub q2_dequant_scratch: usize,
158    /// Native Q2_0 MMQ prefill q8_1 activation scratch (`ATLAS_GGUF_NATIVE_Q2_MMQ=1`).
159    /// ONE persistent q8_1_mmq buffer (`m*kpad*4 + 1MB`) shared by every kept-packed
160    /// projection (FFN gate/up/down, attn q/k/v/o, GDN qkvz): each seam quantizes
161    /// its BF16 activation into this buffer then runs the packed MMQ GEMM — so the
162    /// 2-bit weight is never dequantized to a BF16 scratch (kills the ~2s dequant
163    /// tax AND the shared-`q2_dequant_scratch` co-dispatch race). Sized to the
164    /// widest projection K = max(hidden, intermediate, q_heads*head_dim).
165    /// 0 (→ NULL) unless the MMQ sub-flag is set.
166    pub q2_act_q8: usize,
167    /// Row-wise FP8 GDN prefill BF16-weight slab (`ATLAS_FP8_ROWWISE=1`).
168    /// ONE arena allocation holding the BF16 dequant of EVERY GDN layer's
169    /// per-row `in_proj_qkvz` + `out_proj`, bump-carved one slice per layer
170    /// on that layer's first prefill and never freed. Replaces the lazy
171    /// `gpu.alloc` memoised by weight pointer that the #917 H100 receipt
172    /// caught at `167772160` B per layer outside this ledger. 0 (→ NULL)
173    /// unless the lever is armed; sizing lives in `sizes_rowwise.rs`.
174    pub ssm_rowwise_w_bf16: usize,
175}
176
177impl BufferSizes {
178    /// Compute all buffer sizes from model config and max batch tokens.
179    ///
180    /// All sizes in bytes. BF16 = 2 bytes per element.
181    /// Logits buffer is capped: only needed for decode (1 token) or
182    /// speculative verification (K tokens), never for full prefill.
183    ///
184    /// `max_seq_len` and `kv_block_size` are needed to size the scratch
185    /// buffer for block table metadata during batched decode / verify.
186    pub fn from_config(
187        config: &ModelConfig,
188        max_batch_tokens: usize,
189        max_seq_len: usize,
190        kv_block_size: usize,
191        max_batch_size: usize,
192    ) -> Self {
193        // Derived batched-decode metadata layout (rows = max(32, bs)).
194        // Byte-identical sizing for every bs <= 32; see `decode_meta.rs`.
195        let decode_meta = super::DecodeMetaLayout::for_max_batch_size(max_batch_size);
196        let bf16 = 2;
197        let m = max_batch_tokens;
198        let h = config.hidden_size;
199
200        // Q projection output: gated models produce [Q, gate] (2× nq*hd),
201        // ungated models (VL) produce only [Q] (nq*hd).
202        let q_heads = config.num_attention_heads;
203        let kv_heads = config.num_key_value_heads;
204        let hd = config.head_dim;
205        let q_proj_mul = if config.attn_gated { 2 } else { 1 };
206        let qkv_dim = (q_heads * q_proj_mul + 2 * kv_heads) * hd;
207
208        let top_k = config.num_experts_per_tok;
209
210        // Scratch layout (two users, take max):
211        //
212        // A) Prefill chunk metadata (after MoE routing data):
213        //   [0 .. moe_scratch): MoE topK routing indices+weights
214        //   [moe_scratch .. ): positions(m*4) + slots(m*8) + block_table(max_blocks*4) + seq_len(4)
215        //
216        // B) Batched decode/verify metadata:
217        //   [0 .. 32768): fixed metadata region
218        //   [32768 .. 32768+24R): decode metadata (positions, seq_slot,
219        //     slots, seq_lens; R = decode-meta rows, `decode_meta.rs` —
220        //     24R = 768 at the 32-row floor)
221        //   [32768+24R .. ): decode block table (padded_n × max_blocks × 4 B)
222        //   Batched MTP verify (verify_e.rs) overlays the SAME base with
223        //   VERIFY_ROW_CAP-row gaps at derived offsets (verify_e.rs VMETA_*),
224        //   bt at +24R (bt_rows mirrors the cap).
225        //   Each path re-uploads its own layout pre-dispatch; sizing takes
226        //   the wider (verify) envelope.
227        //
228        // MoE scratch: 2 * M * top_k * 4 (indices [M*top_k] u32 + weights [M*top_k] f32)
229        let moe_scratch = 2 * m * top_k * 4;
230        let max_blocks = max_seq_len
231            .checked_div(kv_block_size)
232            .map(|q| q + 1)
233            .unwrap_or(256);
234        // Prefill metadata: mirrors exact layout in prefill_chunk(). MRoPE
235        // (Qwen3-VL / Qwen3.6) uploads THREE u32 position streams packed
236        // back-to-back (T, H, W); every other model uploads ONE. Sizing the
237        // scratch region for 1× with MRoPE active caused `cuMemcpyHtoDAsync_v2
238        // status 1` failures on long-context prefills (observed: 16k Qwen3.6
239        // failed, 8k passed because the extra 64 KB of write overflow happened
240        // to still land inside the over-provisioned `moe_scratch + meta`
241        // aggregate).
242        let pos_streams = if config.mrope_interleaved { 3 } else { 1 };
243        let pos_bytes = m * 4 * pos_streams;
244        let slot_offset = (pos_bytes + 7) & !7;
245        let slot_end = slot_offset + m * 8;
246        let bt_offset = (slot_end + 3) & !3;
247        let bt_end = bt_offset + max_blocks * 4;
248        let sl_offset = (bt_end + 3) & !3;
249        let prefill_meta = sl_offset + 4;
250        // Block table metadata: the widest user is the batched MTP verify
251        // (verify_e.rs) at R = bt_rows (mirrors VERIFY_ROW_CAP; was 96, the wave-11
252        // depth-at-width envelope — 32:2), whose bt staging sits at
253        // meta_base+2048 (wider 96-row gaps: positions 384 | seq_slot 384 |
254        // slots 768 | seq_lens 384). Batched decode (padded_n ≤ 32) and
255        // DFlash K=γ+1=17 verify keep the narrow +768 layout — strictly
256        // inside this envelope.
257        let bt_rows = 160usize; // batched verify R cap (VERIFY_ROW_CAP, verify_e2.rs)
258        // Envelope = max(verify 96-row overlay, DERIVED decode layout).
259        // The decode layout (`decode_meta.rs`, rows = max(32, bs)) sits
260        // strictly inside the verify overlay for every rows <= 64 (bt at
261        // 24R <= 1536 < 2048, rows <= 96), so this max() changes NOTHING
262        // for bs <= 64; it only grows the scratch once rows > ~85.
263        let bt_meta = 32768
264            + (bt_rows * 24 + bt_rows * max_blocks * 4).max(decode_meta.meta_bytes(max_blocks));
265        let scratch_min = 64 * 1024;
266        // Q12 kernel-batched prefill stages N per-stream meta blocks plus a
267        // stacked BatchedAttnMetadata block — a strictly larger footprint than
268        // the single-stream `prefill_meta`. Provision for `Q12_SIZING_STREAMS`
269        // streams splitting the full token arena so the fast path stays
270        // available for deep-context concurrent prefills without overrunning
271        // scratch (#110: the unprovisioned N-stream multiplication overran the
272        // buffer, producing an out-of-range HtoD → sticky CUDA-700).
273        let q12_chunk = m.div_ceil(Q12_SIZING_STREAMS).max(1);
274        let q12_batched = q12_batched_scratch_bytes(
275            Q12_SIZING_STREAMS,
276            q12_chunk,
277            top_k,
278            config.mrope_interleaved,
279        );
280        let scratch = scratch_min
281            .max(moe_scratch + prefill_meta)
282            .max(bt_meta)
283            .max(q12_batched);
284
285        // Batched expert output buffers for MoE (or dense FFN).
286        // Sized for max(K=3 verify, prefill chunk) × top_k experts.
287        //
288        // The row extent is rounded UP to a multiple of 16 because the FP8
289        // block-scaled cuBLASLt GEMM (`ops::cublas_fp8_proj*`, used by the
290        // W8A8 dense-FFN prefill added for #917/#928) cannot be handed a raw
291        // M: cuBLASLt rejects a scale-tensor M extent that is not a multiple
292        // of 4, so the helper pads M to 16 and the matmul writes those phantom
293        // rows into the output. Without the pad here, a prefill chunk that is
294        // exactly `max_batch_tokens` would write up to 15 rows PAST
295        // `expert_gate_out` — straight into the neighbouring arena buffer.
296        // Costs <= 15 * intermediate * 2 B per buffer (~0.5 MB on a 27B), which
297        // is cheaper than a second output allocation or a per-call bounce.
298        // The row extent every buffer a cuBLASLt block-scaled FP8 GEMM touches
299        // must be sized for. `ops::cublas_fp8_proj_prequant` hands the library
300        // `ceil16(M)`: the phantom activation/scale rows are READ and the
301        // phantom output rows are WRITTEN. SSOT for the pads below.
302        let m_pad = m.div_ceil(16) * 16;
303        let k_max = m.max(3).div_ceil(16) * 16; // prefill chunk or K=3 verify, +cuBLASLt M-pad
304        let expert_inter = if config.num_experts > 0 {
305            let routed = config.num_experts_per_tok * config.moe_intermediate_size;
306            k_max * routed.max(config.intermediate_size)
307        } else {
308            k_max * config.intermediate_size
309        };
310        let expert_gate_out = expert_inter * bf16;
311        let expert_up_out = expert_inter * bf16;
312        // Routed expert down output: [k_max * top_k, moe_input_size].
313        // For LatentMoE (Super 120B), routed experts output in latent space.
314        let moe_out_dim = config.moe_input_size();
315        let expert_down_out = if config.num_experts > 0 {
316            k_max * config.num_experts_per_tok * moe_out_dim * bf16
317        } else {
318            k_max * h * bf16
319        };
320
321        // Logits: only last token used during prefill. Cap at 160 tokens —
322        // the batched MTP verify's R = Σ ks row cap (n=32 × k=3 rows, the
323        // wave-11 depth-at-width envelope; VERIFY_ROW_CAP in verify_e2.rs).
324        // This also covers decode=1, batched decode padded_n<=32 PLUS the
325        // run_standard mixed path (`decode_b2`) parking prefill logits at
326        // row `padded_n` = 32 (the old 33-row bound), spec_verify≤5, and
327        // DFlash K=γ+1=17. ~45 MB at vocab 248320 (was ~30 MB at 64 rows,
328        // ~16 MB at 33).
329        // Derived floor for wide native batches: the run_standard mixed path
330        // (`decode_b2`) parks prefill logits at row `padded_n`, which can be
331        // as high as `decode_meta.rows()` — so the arena must hold rows+1.
332        // Inert (160) for every rows <= 159, i.e. all bs <= 159.
333        let logits_tokens = m.min(160.max(decode_meta.rows() + 1));
334
335        // Mamba-2 d_inner may exceed hidden_size; norm_output and attn_output must fit.
336        let mamba2_d_inner = config.mamba2_d_inner();
337        let max_dim = h.max(mamba2_d_inner);
338
339        // Split-K decode workspace: one `[o[head_dim], m, l]` F32 slot per
340        // (sequence, q head, split). The split-K kernel addresses
341        // `((seq * q_heads) + head) * num_splits + split`, so a short
342        // allocation here is an out-of-bounds DEVICE WRITE with no error —
343        // which is why the slot count comes from the same pure function the
344        // dispatch picks `num_splits` with (`atlas_kernels::attn_splitk`,
345        // #928) rather than from a literal restated here.
346        //
347        // The bound is `DecodeMetaLayout::rows()`, not the pinned max batch:
348        // rows is the widest batch the metadata upload accepts and therefore
349        // the real ceiling on `num_seqs`.
350        //
351        // Under the `legacy` policy — every target but Hopper — this is
352        // `sm_count` slots, i.e. the ~48 KB it has always been: that rule
353        // divides the SM count by `q_heads * reference batch`, so the product
354        // can never exceed it. Under `auto` it is `rows * q_heads * splits`
355        // (3.2 MB at the H100 27B shape), which buys the C=1 occupancy the
356        // whole lever is for.
357        let splitk_slots = attn_splitk::workspace_slots(
358            attn_splitk::policy_from_env(),
359            atlas_kernels::TARGET_SM_COUNT,
360            q_heads as u32,
361            decode_meta.rows() as u32,
362            (max_batch_size as u32).max(1),
363        ) as usize;
364        let splitk_workspace = splitk_slots * (hd + 2) * 4;
365
366        // The residual stream is always BF16.
367        let residual_elem = bf16;
368
369        // FP8 block-scaled activation scratch for prefill projections. The
370        // widest contract dim across call sites is hidden (qkv / ssm-qkvz) or
371        // q_heads*head_dim (o_proj). 1 byte/elem fp8 + one f32 per 128-block.
372        // Mamba-2 out_proj contracts over d_inner (may exceed hidden), and its
373        // prefill input is FP8-precast into this buffer.
374        // ...and the GDN `out_proj`, which contracts over `value_dim`. It
375        // happens to equal `q_heads * hd` on Qwen3.8-27B (6144), so naming it
376        // changes no allocation there — but the W8A8 cuBLASLt arm added in
377        // #928 quantizes into this buffer, and a model whose value_dim is the
378        // widest contract would otherwise size it short and silently fall back.
379        let max_proj_k = h
380            .max(q_heads * hd)
381            .max(mamba2_d_inner)
382            .max(config.linear_num_value_heads * config.linear_value_head_dim);
383        // Padded to 16 rows: `ops::cublas_fp8_proj` hands cuBLASLt `ceil16(M)`
384        // and the matmul reads those phantom activation/scale rows.
385        let fp8_act = m_pad * max_proj_k;
386        let fp8_act_scale = m_pad * max_proj_k.div_ceil(128) * 4;
387        // The cuBLASLt arm reads the SAME scales transposed, so both layouts are
388        // live at once and cannot share a buffer. ~0.2 MB at m=2048, K=5120 —
389        // against the 167772160 B/layer off-ledger BF16 weight dequant it
390        // replaces (#917 H100 receipt, 2026-09-11).
391        let fp8_act_scale_kmajor = fp8_act_scale;
392        // LoRA scratch — only when an adapter is configured (adapter_max_rank
393        // set programmatically pre-build). Widest target n_out =
394        // max(hidden, intermediate, q_proj): covers k/v, o/down (hidden),
395        // gate/up (intermediate), and gated q_proj (2*q_heads*head_dim, which
396        // can exceed both — e.g. 35B 2*16*256=8192 > hidden 4096).
397        let (lora_xa, lora_delta, lora_hact, lora_seq_slot) = if config.adapter_max_rank > 0 {
398            let max_n = h
399                .max(config.intermediate_size)
400                .max(q_proj_mul * q_heads * hd);
401            (
402                m * config.adapter_max_rank * bf16,
403                m * max_n * bf16,
404                m * config.intermediate_size * bf16,
405                m * 4, // [m] i32 per-request routing slots (prefill path)
406            )
407        } else {
408            (0, 0, 0, 0)
409        };
410
411        // GDN FLA chunked-prefill scratch — ONE buffer holding W|U|S|uc back-to-back,
412        // sized for the chunked-prefill arena (nt = ceil(max_batch_tokens / CHUNK)).
413        // Only the 128-dim-linear-head GDN path uses it (the FLA kernels are compiled
414        // for K_DIM=V_DIM=128); 0 otherwise so BufferArena allocs NULL and the
415        // ATLAS_GDN_FLA dispatch stays disabled. Layout per region:
416        //   W  [nt*nv][CHUNK][kd] bf16 ; U,uc [nt*nv][CHUNK][vd] bf16 ;
417        //   S  [nt*nv][kd][vd] bf16 ; gc [nt*nv][CHUNK] f32.
418        const FLA_CHUNK: usize = 64;
419        // SSD chunked scan (mamba2_ssd_*): dt[H][nc][L] f32 + dA_cs[H][nc][L] f32
420        //                                 + CB[nc][G][L][L] f32,  L = 64.
421        const SSD_L: usize = 64;
422        let ssd_scratch = if config.mamba_num_heads > 0 && config.ssm_state_size > 0 {
423            let nc = m.div_ceil(SSD_L) + 1;
424            let hh = config.mamba_num_heads;
425            let gg = config.n_groups.max(1);
426            (hh * nc * SSD_L * 4) * 2 + nc * gg * SSD_L * SSD_L * 4
427        } else {
428            0
429        };
430
431        let gdn_fla_scratch = if config.linear_num_value_heads > 0
432            && config.linear_key_head_dim == 128
433            && config.linear_value_head_dim == 128
434        {
435            // +margin: the batched FLA path (ATLAS_GDN_BATCHED_FLA) sizes its
436            // regions by total_nt = batch*ceil(chunk_len/64), which can exceed
437            // ceil(m/64) by up to `batch` chunks due to per-stream last-chunk
438            // rounding. 16 covers the co-dispatch max-seqs.
439            let nt = m.div_ceil(FLA_CHUNK) + 16;
440            let nv = config.linear_num_value_heads;
441            let kd = config.linear_key_head_dim;
442            let vd = config.linear_value_head_dim;
443            let w = nt * nv * FLA_CHUNK * kd * bf16;
444            let u = nt * nv * FLA_CHUNK * vd * bf16;
445            let s = nt * nv * kd * vd * bf16;
446            let uc = nt * nv * FLA_CHUNK * vd * bf16;
447            let gc = nt * nv * FLA_CHUNK * 4;
448            w + u + s + uc + gc
449        } else {
450            0
451        };
452
453        // Native keep-packed Q2_0 prefill scratch (Tier-1 transient-dequant +
454        // Tier-2 MMQ q8_1 activation); env-gated, 0 unless the flags are set.
455        // Sizing rationale + bounds live on `sizes_q2::q2_scratch_sizes`.
456        let (q2_dequant_scratch, q2_act_q8) = super::sizes_q2::q2_scratch_sizes(config, m, h, hd);
457
458        // Row-wise FP8 GDN prefill BF16-weight slab; env-gated, 0 unless
459        // `ATLAS_FP8_ROWWISE=1`. Sizing + the #917 receipt live on
460        // `sizes_rowwise::ssm_rowwise_w_bf16_bytes`.
461        let ssm_rowwise_w_bf16 = super::sizes_rowwise::ssm_rowwise_w_bf16_bytes(config);
462
463        // Dense-FFN activation-quant scratch, shared across all layers (SSOT).
464        // Sized for the largest projection K = max(hidden, intermediate); the
465        // dense_ffn prefill paths pass `h.max(inter)` to the requant kernels.
466        // 0 for MoE (num_experts>0) — those never take the dense_ffn MMQ path.
467        // Fused gate+up decode GEMM output (#927): `[ceil16(MAX_M), 2*inter]`
468        // BF16. `ceil16` because `cublas_fp8_proj_prequant` hands cuBLASLt
469        // `ceil16(M)` and the phantom rows are WRITTEN — the same headroom
470        // `expert_gate_out` carries, for the same reason. Dense models only;
471        // MoE never reaches the dense-FFN arm.
472        let ffn_gate_up_fused = if config.num_experts == 0 {
473            let rows = GATEUP_FUSED_MAX_M.div_ceil(16) * 16;
474            rows * 2 * config.intermediate_size * bf16
475        } else {
476            0
477        };
478
479        let (ffn_act_q8, ffn_act_a, ffn_act_scale, ffn_act_scale_kmajor) =
480            if config.num_experts == 0 {
481                let kmax = h.max(config.intermediate_size);
482                let kpad = kmax.div_ceil(256) * 256;
483                // `m_pad` (above) is the cuBLASLt row extent: the W8A8
484                // dense-FFN prefill (#917/#928) hands cuBLASLt `ceil16(M)` and
485                // the matmul READS the phantom activation rows (they are
486                // zeroed, but they are read). It also covers every unpadded
487                // consumer of this scratch.
488                (
489                    m * kpad * 4 + (1 << 20), // q8_1_mmq: m*kpad*4 + 1MB (matches q8_1_scratch_bytes)
490                    m_pad * kmax,             // int8 a_i8 [m,K] ≥ NVFP4 packed [m,K/2] ≥ fp8 [m,K]
491                    m_pad * (kmax / 32) * 4,  // int8 a_scale [m,K/32]*4 ≥ fp8 [m,K/128]*4
492                    // Transposed VEC128 activation scales for the cuBLASLt arm:
493                    // one f32 per (128-of-K group, padded token). Same element
494                    // count as the fp8 use of `ffn_act_scale`, a quarter of the
495                    // int8 one — ~0.65 MB at max_batch_tokens=1193, K=17408.
496                    m_pad * (kmax / 128) * 4,
497                )
498            } else {
499                (0, 0, 0, 0)
500            };
501
502        Self {
503            hidden_states: m * h * residual_elem,
504            residual: m * h * residual_elem,
505            norm_output: m * max_dim * bf16,
506            // `m_pad`, not `m`: the cache-skip Q/K/V prefill's cuBLASLt arm
507            // WRITES `ceil16(M)` rows of `q_proj` here (readers still touch
508            // only the real M). Same headroom `ssm_qkvz` and `moe_output`
509            // already carry, and the reason is the same one (#928). ~0.4 MB on
510            // a 27B.
511            qkv_output: m_pad * qkv_dim * bf16,
512            attn_output: (m * config.num_attention_heads * config.head_dim * bf16)
513                .max(m * mamba2_d_inner * bf16)
514                // MLA absorbed: attention output is [M, nq, mla_cache_dim=kv_lora+rope]
515                .max(if config.kv_lora_rank > 0 {
516                    m * config.num_attention_heads
517                        * (config.kv_lora_rank + config.qk_rope_head_dim)
518                        * bf16
519                } else {
520                    0
521                }),
522            gate_logits: if config.num_experts > 0 {
523                // LongCat zero-experts: the router scores (routed + zero)
524                // logits even though only `num_experts` expert FFNs exist.
525                m * (config.num_experts + config.zero_expert_num) * bf16
526            } else {
527                256
528            },
529            gate_logits_f32: if config.num_experts > 0 {
530                m * (config.num_experts + config.zero_expert_num) * 4
531            } else {
532                256
533            },
534            moe_router_in_f32: if config.num_experts > 0 {
535                m * h * 4
536            } else {
537                256
538            },
539            // Same cuBLASLt FP8 M-pad headroom as `k_max` above: the dense-FFN
540            // down projection writes its [M, hidden] result here.
541            moe_output: m.div_ceil(16) * 16 * h * bf16,
542            logits: logits_tokens * config.vocab_size * bf16, // BF16 from LM head kernel
543            // SSM buffers are also reused by attention prefill/multi-seq as scratch:
544            //   ssm_qkvz: K+V contiguous storage in prefill [M, 2*kv_dim]
545            //             Mamba-2 in_proj output [M, in_proj_size]
546            //   ssm_deinterleaved: Q contiguous copy [M, nq*hd]
547            //                      Mamba-2 conv1d output [M, d_xBC]
548            // Use max across all uses with minimum 256 to avoid 0-byte alloc.
549            // `m_pad`, not `m`: the SSM `in_proj_qkvz` cuBLASLt arm WRITES
550            // `ceil16(M)` output rows here (readers still touch only the real
551            // M). Without it a chunk exactly `max_batch_tokens` wide spills up
552            // to 15 rows into the NEXT arena buffer. ~0.4 MB on a 27B.
553            ssm_qkvz: (m_pad * config.ssm_qkvz_size() * bf16)
554                .max(m * config.mamba2_in_proj_size() * bf16)
555                // `k` at row 0 and `v` at row `m`, each `ceil16(M)` rows
556                // tall on the cuBLASLt arm: the furthest byte is
557                // `(m + m_pad) * kv_dim` (#928, `prefill_qkv_w8a8.rs`). Was
558                // `m * 2 * kv_dim`, which this is never smaller than.
559                .max((m + m_pad) * kv_heads * hd * bf16)
560                .max(m * config.shared_expert_intermediate_size * bf16) // MoE shared up scratch
561                .max(256),
562            ssm_ba: (m * config.ssm_ba_size() * bf16)
563                .max(m * config.moe_latent_size * bf16) // LatentMoE latent buffer
564                // MLA reuses ssm_ba for two separate buffers:
565                //   - q_latent    [M, q_lora_rank]    BF16 — output of wq_a GEMM
566                //   - k_rope_buf  [M, qk_rope_head_dim] BF16 — output of wkv_a_rope GEMM
567                // Both are written sequentially (q_latent is consumed before
568                // k_rope_buf is allocated). Size for the larger of the two.
569                .max(if config.kv_lora_rank > 0 {
570                    (m * config.qk_rope_head_dim * bf16).max(m * config.q_lora_rank * bf16)
571                } else {
572                    0
573                })
574                .max(256),
575            // Same cuBLASLt M-pad as `ssm_qkvz`: on a `sequential_qkvz` model
576            // THIS is the projection's destination buffer.
577            ssm_deinterleaved: (m_pad * config.ssm_qkvz_size() * bf16)
578                .max(m * config.mamba2_d_xbc() * bf16)
579                .max(m * q_heads * hd * bf16)
580                // MLA absorbed: Q_absorbed buffer is [M, nq, mla_cache_dim=kv_lora+rope]
581                .max(if config.kv_lora_rank > 0 {
582                    m * q_heads * (config.kv_lora_rank + config.qk_rope_head_dim) * bf16
583                } else {
584                    0
585                })
586                .max(256),
587            ssm_gates: (m * config.linear_num_value_heads * 2 * 4).max(256),
588            // FP32 conv output for SSM recurrent path precision (4 bytes/element).
589            // Uses ssm_qkvz_size as upper bound (includes Q+K+V+Z).
590            // Also reused by MLA as q_rope contiguous buffer: [M, nq * qk_rope_head_dim] BF16.
591            ssm_conv_out_f32: (m * config.ssm_qkvz_size() * 4)
592                .max(if config.kv_lora_rank > 0 {
593                    m * q_heads * config.qk_rope_head_dim * bf16
594                } else {
595                    0
596                })
597                .max(256),
598            scratch,
599            expert_gate_out,
600            expert_up_out,
601            expert_down_out,
602            splitk_workspace,
603            gdn_fla_scratch,
604            ssd_scratch,
605            // Grouped O-projection latent (V4-Flash): [M, o_groups*o_lora_rank].
606            o_latent: (m * config.o_groups * config.o_lora_rank * bf16).max(256),
607            // Zero-filled weight for unweighted RMSNorm (q_b_norm).
608            norm_unit_w: max_dim * bf16,
609            // HC buffers: only allocated for DeepSeek-V4 (hc_mult > 0).
610            hc_streams: if config.hc_mult > 0 {
611                // FP32 mHC highway: the residual streams grow large across the
612                // blocks (the manifold-mixing is norm-preserving, eigenvalue 1),
613                // so BF16 storage swamps the small per-layer signal at scale and
614                // collapses generation. Store the streams in FP32 (4 bytes).
615                m * config.hc_mult * h * 4
616            } else {
617                256
618            },
619            hc_post: if config.hc_mult > 0 {
620                (m * config.hc_mult * 4).max(256)
621            } else {
622                256
623            },
624            hc_comb: if config.hc_mult > 0 {
625                (m * config.hc_mult * config.hc_mult * 4).max(256)
626            } else {
627                256
628            },
629            hc_lowrank_scratch: if config.hc_mult > 0 && config.hc_lowrank > 0 {
630                // Two exclusive layouts share this region:
631                // - decode split path (T <= 64): normed FP32 [64, hc*H] then
632                //   low FP32 [64, rank];
633                // - prefill GEMM path (T > 64, slabbed at <= 2048 tokens):
634                //   normed BF16 [Ts, hc*H], up_pre BF16 [Ts, hc*H],
635                //   low BF16 [Ts, rank], inj_pre BF16 [Ts, hc].
636                let t = m.min(64);
637                let split = t * (config.hc_mult * h + config.hc_lowrank) * 4;
638                let ts = m.min(2048);
639                let gemm = ts * (2 * config.hc_mult * h + config.hc_lowrank + config.hc_mult) * 2;
640                split.max(gemm)
641            } else {
642                256
643            },
644            qsa_select_scratch: if config.index_topk > 0 && config.index_compress_ratio > 0 {
645                const ROWS: usize = 2048;
646                let qkw = (config.index_n_heads + 1) * config.index_head_dim;
647                let n_blocks = max_seq_len.div_ceil(config.index_compress_ratio);
648                let topk = config.index_topk / config.index_compress_ratio;
649                ROWS * qkw * 2
650                    + ROWS * config.index_n_heads * config.index_head_dim * 4
651                    + ROWS * n_blocks * 4
652                    + ROWS * topk * 4
653            } else {
654                256
655            },
656            // Token IDs [M] u32 (stable across the layer loop for hash-MoE).
657            token_ids: (m * 4).max(256),
658            ffn_act_q8,
659            ffn_act_a,
660            ffn_act_scale,
661            ffn_act_scale_kmajor,
662            ffn_gate_up_fused,
663            fp8_act,
664            fp8_act_scale,
665            fp8_act_scale_kmajor,
666            lora_xa,
667            lora_delta,
668            lora_hact,
669            lora_seq_slot,
670            q2_dequant_scratch,
671            q2_act_q8,
672            ssm_rowwise_w_bf16,
673        }
674    }
675
676    /// Total bytes across all buffers.
677    pub fn total_bytes(&self) -> usize {
678        self.hidden_states
679            + self.residual
680            + self.norm_output
681            + self.qkv_output
682            + self.attn_output
683            + self.gate_logits
684            + self.gate_logits_f32
685            + self.moe_router_in_f32
686            + self.moe_output
687            + self.logits
688            + self.ssm_qkvz
689            + self.ssm_ba
690            + self.ssm_deinterleaved
691            + self.ssm_gates
692            + self.ssm_conv_out_f32
693            + self.scratch
694            + self.expert_gate_out
695            + self.expert_up_out
696            + self.hc_lowrank_scratch
697            + self.qsa_select_scratch
698            + self.expert_down_out
699            + self.splitk_workspace
700            + self.gdn_fla_scratch
701            + self.ssd_scratch
702            + self.hc_streams
703            + self.hc_post
704            + self.hc_comb
705            + self.token_ids
706            + self.ffn_act_q8
707            + self.ffn_act_a
708            + self.ffn_gate_up_fused
709            + self.ffn_act_scale
710            + self.ffn_act_scale_kmajor
711            + self.fp8_act
712            + self.fp8_act_scale
713            + self.fp8_act_scale_kmajor
714            + self.lora_xa
715            + self.lora_delta
716            + self.lora_hact
717            + self.lora_seq_slot
718            + self.q2_dequant_scratch
719            + self.q2_act_q8
720            + self.ssm_rowwise_w_bf16
721    }
722}