spark_runtime/
buffers.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Pre-allocated GPU buffer arena for intermediate tensors.
4//!
5//! All buffer sizes derive from [`ModelConfig`] (SSOT). The arena is
6//! allocated once during initialization and reused across decode steps.
7
8use crate::gpu::{DevicePtr, GpuBackend};
9use anyhow::Result;
10use atlas_core::config::ModelConfig;
11
12mod accessors;
13pub mod decode_meta;
14mod sizes;
15mod sizes_q12;
16mod sizes_q2;
17pub use decode_meta::{DECODE_META_MAX_ROWS, DECODE_META_MIN_ROWS, DecodeMetaLayout};
18pub use sizes::BufferSizes;
19pub use sizes_q2::q2_dequant_scratch_bytes;
20pub use sizes_q12::{
21    Q12_SIZING_STREAMS, q12_batched_scratch_bytes, q12_batched_scratch_bytes_varlen,
22};
23
24/// Pre-allocated GPU buffers for a single forward pass.
25///
26/// Each buffer is sized for `max_batch_tokens` tokens through the model.
27/// Buffers are reused across steps — no per-step allocation.
28///
29/// Expert output buffers are sized for max(k_max, max_batch_tokens) to
30/// support both speculative decode (K=3) and batched MoE prefill. At N=512,
31/// this adds ~31 MB (vs the old grouped-GEMM approach that needed 260 MB
32/// and caused a 15% decode regression). The GEMV-based prefill kernels
33/// only touch k_max slots during decode, so the extra pages don't affect
34/// decode bandwidth on unified memory.
35pub struct BufferArena {
36    /// Hidden states: [M, hidden_size] in BF16.
37    hidden_states: DevicePtr,
38    /// Residual stream: [M, hidden_size] in BF16.
39    residual: DevicePtr,
40    /// Post-norm output: [M, hidden_size] in BF16.
41    norm_output: DevicePtr,
42    /// QKV projection output for full attention: [M, (Hq + 2*Hkv) * D] in BF16.
43    qkv_output: DevicePtr,
44    /// Attention output: [M, Hq * D] in BF16.
45    attn_output: DevicePtr,
46    /// MoE gate logits: [M, num_experts] in BF16.
47    gate_logits: DevicePtr,
48    /// MoE gate logits: [M, num_experts] in FP32 (ATLAS_FP32_GATE path).
49    gate_logits_f32: DevicePtr,
50    /// MoE-input norm output: [M, hidden_size] in FP32 (ATLAS_FP32_ROUTING).
51    moe_router_in_f32: DevicePtr,
52    /// MoE output: [M, hidden_size] in BF16.
53    moe_output: DevicePtr,
54    /// Logits: [M, vocab_size] in BF16.
55    logits: DevicePtr,
56    /// SSM QKVZ projection: [M, ssm_qkvz_size] in BF16.
57    ssm_qkvz: DevicePtr,
58    /// SSM beta-alpha projection: [M, ssm_ba_size] in BF16.
59    ssm_ba: DevicePtr,
60    /// SSM deinterleaved QKVZ: [M, ssm_qkvz_size] in BF16 (sequential [Q|K|V|Z]).
61    ssm_deinterleaved: DevicePtr,
62    /// SSM FP32 gates: [num_v_heads * 2] as FP32 (gate + beta for GDN).
63    ssm_gates: DevicePtr,
64    /// SSM conv1d output in FP32: [M, conv_dim] as FP32.
65    /// Prevents BF16 truncation in the SSM recurrent path (conv → GDN).
66    /// Without this, ~7 bits of precision are lost every token, causing
67    /// coherence degradation after 8k+ tokens.
68    ssm_conv_out_f32: DevicePtr,
69    /// Scratch space for kernel metadata (positions, slot_mapping, block_tables).
70    scratch: DevicePtr,
71    /// Expert gate projection output: [k2 * top_k, moe_intermediate_size] BF16.
72    expert_gate_out: DevicePtr,
73    /// Expert up projection output: [k2 * top_k, moe_intermediate_size] BF16.
74    expert_up_out: DevicePtr,
75    /// Expert down projection output: [k2 * top_k, hidden_size] BF16.
76    expert_down_out: DevicePtr,
77    /// Split-K decode attention workspace: partials from split CTAs (F32).
78    splitk_workspace: DevicePtr,
79    /// Grouped O-projection latent: [M, o_groups*o_lora_rank] BF16 (V4-Flash).
80    o_latent: DevicePtr,
81    /// Zero-filled BF16 weight (max_dim) for unweighted RMSNorm under the
82    /// offset-from-1 kernel convention (scale = 1+weight → 1.0). Used by q_b_norm.
83    norm_unit_w: DevicePtr,
84    /// HC residual streams: [M, hc_mult, hidden] BF16 (DeepSeek-V4 mHC).
85    hc_streams: DevicePtr,
86    /// HC `post` mixing weights: [M, hc_mult] F32.
87    hc_post: DevicePtr,
88    /// HC `comb` Sinkhorn matrix: [M, hc_mult, hc_mult] F32.
89    hc_comb: DevicePtr,
90    hc_lowrank_scratch: DevicePtr,
91    qsa_select_scratch: DevicePtr,
92    /// GDN FLA chunked-prefill scratch (W|U|S|uc sub-divided). NULL unless the
93    /// model is a 128-dim-linear-head GDN model (ATLAS_GDN_FLA path).
94    gdn_fla_scratch: DevicePtr,
95    /// Mamba-2 SSD chunked-scan scratch (dt | dA_cumsum | CB). NULL unless the model
96    /// has Mamba-2 SSM layers.
97    ssd_scratch: DevicePtr,
98    /// Token IDs `[M]` u32 — stable across the layer loop so DeepSeek-V4
99    /// hash-MoE layers can read `tid2eid[token_id]`.
100    token_ids: DevicePtr,
101    /// Shared FFN activation-quant scratch (dense-FFN MMQ/int8 prefill path).
102    /// Allocated once here instead of per-DenseFfnLayer (64× would leak ~18GB).
103    /// NULL unless the model is dense (`num_experts == 0`).
104    /// `ffn_act_q8`: q8_1 activations for the Q4_K MMQ gate/up GEMM.
105    /// `ffn_act_a` / `ffn_act_scale`: int8 (a_i8 / a_scale) — reused for NVFP4 packed/scale.
106    ffn_act_q8: DevicePtr,
107    ffn_act_a: DevicePtr,
108    ffn_act_scale: DevicePtr,
109    /// Persistent FP8 block-scaled activation scratch for prefill projections.
110    fp8_act: DevicePtr,
111    /// Persistent per-128-block FP32 scales paired with `fp8_act`.
112    fp8_act_scale: DevicePtr,
113    /// Persistent BF16 transient-dequant scratch for native keep-packed Q2_0
114    /// prefill. Reused per projection — replaces a per-matmul alloc/sync/free.
115    q2_dequant_scratch: DevicePtr,
116    /// LoRA shrink scratch `xa = x@Aᵀ`: [M, adapter_max_rank] BF16.
117    /// NULL when no adapter is configured.
118    lora_xa: DevicePtr,
119    /// LoRA expand scratch `delta = xa@Bᵀ`: [M, max(hidden, intermediate)]
120    /// BF16. NULL when no adapter is configured.
121    lora_delta: DevicePtr,
122    /// LoRA hidden-activation scratch: [M, intermediate_size] BF16 for the
123    /// runtime FFN delta path. NULL when no adapter is configured.
124    lora_hact: DevicePtr,
125    /// LoRA per-request routing slots `[M]` i32 for the prefill path (one
126    /// adapter SLOT index per prefilling token). NULL when no adapter.
127    lora_seq_slot: DevicePtr,
128    /// Persistent q8_1_mmq activation scratch for native Q2_0 MMQ prefill
129    /// (`ATLAS_GGUF_NATIVE_Q2_MMQ`). Shared by every kept-packed projection;
130    /// each seam quantizes its activation here then runs the packed MMQ GEMM.
131    q2_act_q8: DevicePtr,
132    /// Maximum batch tokens this arena was sized for.
133    max_batch_tokens: usize,
134    /// Derived batched-decode metadata layout (rows = max(32, serve
135    /// max_batch_size)); byte-identical to the legacy fixed 32-row gaps for
136    /// every bs <= 32. SSOT consumed by `upload_batch_metadata_fixed`/`_at`.
137    decode_meta: DecodeMetaLayout,
138    /// Sizes in bytes for each buffer (for debug/logging).
139    sizes: BufferSizes,
140}
141
142impl BufferArena {
143    /// Allocate all intermediate buffers on the GPU.
144    pub fn new(
145        config: &ModelConfig,
146        max_batch_tokens: usize,
147        max_seq_len: usize,
148        kv_block_size: usize,
149        max_batch_size: usize,
150        gpu: &dyn GpuBackend,
151    ) -> Result<Self> {
152        let decode_meta = DecodeMetaLayout::for_max_batch_size(max_batch_size);
153        let sizes = BufferSizes::from_config(
154            config,
155            max_batch_tokens,
156            max_seq_len,
157            kv_block_size,
158            max_batch_size,
159        );
160
161        let hidden_states = gpu.alloc(sizes.hidden_states)?;
162        let residual = gpu.alloc(sizes.residual)?;
163        let norm_output = gpu.alloc(sizes.norm_output)?;
164        let qkv_output = gpu.alloc(sizes.qkv_output)?;
165        let attn_output = gpu.alloc(sizes.attn_output)?;
166        let gate_logits = gpu.alloc(sizes.gate_logits)?;
167        let gate_logits_f32 = gpu.alloc(sizes.gate_logits_f32)?;
168        let moe_router_in_f32 = gpu.alloc(sizes.moe_router_in_f32)?;
169        let moe_output = gpu.alloc(sizes.moe_output)?;
170        let logits = gpu.alloc(sizes.logits)?;
171        let ssm_qkvz = gpu.alloc(sizes.ssm_qkvz)?;
172        let ssm_ba = gpu.alloc(sizes.ssm_ba)?;
173        let ssm_deinterleaved = gpu.alloc(sizes.ssm_deinterleaved)?;
174        let ssm_gates = gpu.alloc(sizes.ssm_gates)?;
175        let ssm_conv_out_f32 = gpu.alloc(sizes.ssm_conv_out_f32)?;
176        let scratch = gpu.alloc(sizes.scratch)?;
177        let expert_gate_out = gpu.alloc(sizes.expert_gate_out)?;
178        let expert_up_out = gpu.alloc(sizes.expert_up_out)?;
179        let expert_down_out = gpu.alloc(sizes.expert_down_out)?;
180        let splitk_workspace = gpu.alloc(sizes.splitk_workspace)?;
181        let o_latent = gpu.alloc(sizes.o_latent)?;
182        // Zero-filled "weight" for unweighted RMSNorm under the offset-from-1
183        // convention used by the rms_norm kernel (scale = 1 + weight). Weight = 0
184        // → scale = 1.0, i.e. a pure normalize (DeepSeek-V4 q_b_norm).
185        let norm_unit_w = gpu.alloc(sizes.norm_unit_w)?;
186        gpu.memset(norm_unit_w, 0, sizes.norm_unit_w)?;
187        let hc_streams = gpu.alloc(sizes.hc_streams)?;
188        let hc_post = gpu.alloc(sizes.hc_post)?;
189        let hc_comb = gpu.alloc(sizes.hc_comb)?;
190        let hc_lowrank_scratch = gpu.alloc(sizes.hc_lowrank_scratch)?;
191        let qsa_select_scratch = gpu.alloc(sizes.qsa_select_scratch)?;
192        // GDN FLA scratch: only allocate for the 128-dim-linear-head GDN path
193        // (size 0 → NULL → ATLAS_GDN_FLA dispatch stays disabled).
194        let ssd_scratch = if sizes.ssd_scratch > 0 {
195            gpu.alloc(sizes.ssd_scratch)?
196        } else {
197            DevicePtr::NULL
198        };
199        let gdn_fla_scratch = if sizes.gdn_fla_scratch > 0 {
200            gpu.alloc(sizes.gdn_fla_scratch)?
201        } else {
202            DevicePtr::NULL
203        };
204        let token_ids = gpu.alloc(sizes.token_ids)?;
205        // Shared dense-FFN activation-quant scratch (MMQ/int8 prefill). Sized 0
206        // for MoE models → NULL → per-layer ensure_* path stays inert.
207        let ffn_act_q8 = if sizes.ffn_act_q8 > 0 {
208            gpu.alloc(sizes.ffn_act_q8)?
209        } else {
210            DevicePtr::NULL
211        };
212        let ffn_act_a = if sizes.ffn_act_a > 0 {
213            gpu.alloc(sizes.ffn_act_a)?
214        } else {
215            DevicePtr::NULL
216        };
217        let ffn_act_scale = if sizes.ffn_act_scale > 0 {
218            gpu.alloc(sizes.ffn_act_scale)?
219        } else {
220            DevicePtr::NULL
221        };
222        let fp8_act = gpu.alloc(sizes.fp8_act)?;
223        let fp8_act_scale = gpu.alloc(sizes.fp8_act_scale)?;
224        // Q2_0 prefill dequant scratch. 0 → NULL unless ATLAS_GGUF_NATIVE_Q2.
225        let q2_dequant_scratch = if sizes.q2_dequant_scratch > 0 {
226            gpu.alloc(sizes.q2_dequant_scratch)?
227        } else {
228            DevicePtr::NULL
229        };
230        // LoRA scratch: only allocate when an adapter is configured
231        // (size 0 → NULL; cuMemAlloc rejects 0-byte allocations).
232        let lora_xa = if sizes.lora_xa > 0 {
233            gpu.alloc(sizes.lora_xa)?
234        } else {
235            DevicePtr::NULL
236        };
237        let lora_delta = if sizes.lora_delta > 0 {
238            gpu.alloc(sizes.lora_delta)?
239        } else {
240            DevicePtr::NULL
241        };
242        let lora_hact = if sizes.lora_hact > 0 {
243            gpu.alloc(sizes.lora_hact)?
244        } else {
245            DevicePtr::NULL
246        };
247        let lora_seq_slot = if sizes.lora_seq_slot > 0 {
248            gpu.alloc(sizes.lora_seq_slot)?
249        } else {
250            DevicePtr::NULL
251        };
252        // Q2_0 MMQ prefill q8_1 activation scratch. 0 → NULL unless ATLAS_GGUF_NATIVE_Q2_MMQ.
253        let q2_act_q8 = if sizes.q2_act_q8 > 0 {
254            gpu.alloc(sizes.q2_act_q8)?
255        } else {
256            DevicePtr::NULL
257        };
258
259        tracing::info!(
260            "Buffer arena: {} tokens × {:.1} MB total (attn_out={:.1}MB, ssm_deint={:.1}MB, kv_lora_rank={})",
261            max_batch_tokens,
262            sizes.total_bytes() as f64 / (1024.0 * 1024.0),
263            sizes.attn_output as f64 / (1024.0 * 1024.0),
264            sizes.ssm_deinterleaved as f64 / (1024.0 * 1024.0),
265            config.kv_lora_rank,
266        );
267
268        Ok(Self {
269            hidden_states,
270            residual,
271            norm_output,
272            qkv_output,
273            attn_output,
274            gate_logits,
275            gate_logits_f32,
276            moe_router_in_f32,
277            moe_output,
278            logits,
279            ssm_qkvz,
280            ssm_ba,
281            ssm_deinterleaved,
282            ssm_gates,
283            ssm_conv_out_f32,
284            scratch,
285            expert_gate_out,
286            expert_up_out,
287            expert_down_out,
288            splitk_workspace,
289            o_latent,
290            norm_unit_w,
291            hc_streams,
292            hc_post,
293            hc_comb,
294            hc_lowrank_scratch,
295            qsa_select_scratch,
296            gdn_fla_scratch,
297            ssd_scratch,
298            token_ids,
299            ffn_act_q8,
300            ffn_act_a,
301            ffn_act_scale,
302            fp8_act,
303            fp8_act_scale,
304            q2_dequant_scratch,
305            lora_xa,
306            lora_delta,
307            lora_hact,
308            lora_seq_slot,
309            q2_act_q8,
310            max_batch_tokens,
311            decode_meta,
312            sizes,
313        })
314    }
315}
316
317/// Release every buffer this arena owns.
318///
319/// The destructure below is **exhaustive on purpose — no `..`**. A buffer added
320/// to `BufferArena` without a matching free is a leak that only shows up as the
321/// next model failing to fit, so the compiler is made to refuse the addition
322/// instead. If this line stops compiling, the fix is to free the new field, not
323/// to add a wildcard.
324impl atlas_core::scope::ModelResource<dyn GpuBackend> for BufferArena {
325    fn label(&self) -> &'static str {
326        "buffer arena"
327    }
328
329    fn release(&mut self, gpu: &dyn GpuBackend) -> anyhow::Result<()> {
330        let Self {
331            // Not allocations — named rather than wildcarded so the
332            // exhaustiveness check above keeps its teeth.
333            sizes: _,
334            max_batch_tokens: _,
335            // Layout, not an allocation — derived from `--max-batch-size`.
336            decode_meta: _,
337            hidden_states,
338            residual,
339            norm_output,
340            qkv_output,
341            attn_output,
342            gate_logits,
343            gate_logits_f32,
344            moe_router_in_f32,
345            moe_output,
346            logits,
347            ssm_qkvz,
348            ssm_ba,
349            ssm_deinterleaved,
350            ssm_gates,
351            ssm_conv_out_f32,
352            scratch,
353            expert_gate_out,
354            expert_up_out,
355            expert_down_out,
356            splitk_workspace,
357            o_latent,
358            norm_unit_w,
359            hc_streams,
360            hc_post,
361            hc_comb,
362            hc_lowrank_scratch,
363            qsa_select_scratch,
364            gdn_fla_scratch,
365            ssd_scratch,
366            token_ids,
367            ffn_act_q8,
368            ffn_act_a,
369            ffn_act_scale,
370            fp8_act,
371            fp8_act_scale,
372            lora_xa,
373            lora_delta,
374            lora_hact,
375            lora_seq_slot,
376            q2_dequant_scratch,
377            q2_act_q8,
378        } = self;
379        // Every pointer, then NULL it: `release` must be idempotent because a
380        // `Drop` backstop may call it again, and `free` already no-ops on NULL.
381        let owned = [
382            *hidden_states,
383            *residual,
384            *norm_output,
385            *qkv_output,
386            *attn_output,
387            *gate_logits,
388            *gate_logits_f32,
389            *moe_router_in_f32,
390            *moe_output,
391            *logits,
392            *ssm_qkvz,
393            *ssm_ba,
394            *ssm_deinterleaved,
395            *ssm_gates,
396            *ssm_conv_out_f32,
397            *scratch,
398            *expert_gate_out,
399            *expert_up_out,
400            *expert_down_out,
401            *splitk_workspace,
402            *o_latent,
403            *norm_unit_w,
404            *hc_streams,
405            *hc_lowrank_scratch,
406            *qsa_select_scratch,
407            *hc_post,
408            *hc_comb,
409            *gdn_fla_scratch,
410            *ssd_scratch,
411            *token_ids,
412            *ffn_act_q8,
413            *ffn_act_a,
414            *ffn_act_scale,
415            *fp8_act,
416            *fp8_act_scale,
417            *lora_xa,
418            *lora_delta,
419            *lora_hact,
420            *lora_seq_slot,
421            *q2_dequant_scratch,
422            *q2_act_q8,
423        ];
424        let mut first_error = None;
425        for ptr in owned {
426            if let Err(e) = gpu.free(ptr)
427                && first_error.is_none()
428            {
429                first_error = Some(e);
430            }
431        }
432        *hidden_states = DevicePtr::NULL;
433        *residual = DevicePtr::NULL;
434        *norm_output = DevicePtr::NULL;
435        *qkv_output = DevicePtr::NULL;
436        *attn_output = DevicePtr::NULL;
437        *gate_logits = DevicePtr::NULL;
438        *gate_logits_f32 = DevicePtr::NULL;
439        *moe_router_in_f32 = DevicePtr::NULL;
440        *moe_output = DevicePtr::NULL;
441        *logits = DevicePtr::NULL;
442        *ssm_qkvz = DevicePtr::NULL;
443        *ssm_ba = DevicePtr::NULL;
444        *ssm_deinterleaved = DevicePtr::NULL;
445        *ssm_gates = DevicePtr::NULL;
446        *ssm_conv_out_f32 = DevicePtr::NULL;
447        *scratch = DevicePtr::NULL;
448        *expert_gate_out = DevicePtr::NULL;
449        *expert_up_out = DevicePtr::NULL;
450        *expert_down_out = DevicePtr::NULL;
451        *splitk_workspace = DevicePtr::NULL;
452        *o_latent = DevicePtr::NULL;
453        *norm_unit_w = DevicePtr::NULL;
454        *hc_streams = DevicePtr::NULL;
455        *hc_lowrank_scratch = DevicePtr::NULL;
456        *qsa_select_scratch = DevicePtr::NULL;
457        *hc_post = DevicePtr::NULL;
458        *hc_comb = DevicePtr::NULL;
459        *gdn_fla_scratch = DevicePtr::NULL;
460        *ssd_scratch = DevicePtr::NULL;
461        *token_ids = DevicePtr::NULL;
462        *ffn_act_q8 = DevicePtr::NULL;
463        *ffn_act_a = DevicePtr::NULL;
464        *ffn_act_scale = DevicePtr::NULL;
465        *fp8_act = DevicePtr::NULL;
466        *fp8_act_scale = DevicePtr::NULL;
467        *lora_xa = DevicePtr::NULL;
468        *lora_delta = DevicePtr::NULL;
469        *lora_hact = DevicePtr::NULL;
470        *lora_seq_slot = DevicePtr::NULL;
471        *q2_dequant_scratch = DevicePtr::NULL;
472        *q2_act_q8 = DevicePtr::NULL;
473        match first_error {
474            Some(e) => Err(e),
475            None => Ok(()),
476        }
477    }
478}
479
480#[cfg(test)]
481mod tests;