Skip to main content

ferrox_models/
kv_budget.rs

1//! Pre-load KV budget arithmetic: answer "will this fit" *before*
2//! allocating anything, from terms that are all exact in the GGUF
3//! header.
4//!
5//! ```text
6//! weights + n_ctx * per_token_kv + activation_headroom  <=  device_budget
7//! per_token_kv = n_layers * n_kv_heads * head_dim * bytes_per_elem * 2
8//! ```
9//!
10//! Everything here is a pure function of a shape plus a byte budget --
11//! no I/O, no device handles, no allocation -- so the arithmetic can be
12//! unit-tested against hand-computed numbers. The device side (how many
13//! bytes a backend actually offers) lives in
14//! [`crate::device_budget`]; the whole-checkpoint report that consumes
15//! both is [`crate::residency_report`].
16//!
17//! # Where this is approximate, stated up front
18//!
19//! - **Weights.** ferrox mmaps quantized tensors and reads them in
20//!   place, so "weights resident" is not a number ferrox controls: the
21//!   kernel can evict those pages under pressure and fault them back in
22//!   later. `weights_bytes` is therefore the *checkpoint's* byte count,
23//!   an upper bound on resident cost and a lower bound on the I/O the
24//!   run will do -- not a measurement of RSS. A model can exceed this
25//!   budget and still run (slowly, page-faulting), and it can fit this
26//!   budget and still be killed by something else on the machine.
27//! - **Activations.** `activation_headroom_bytes` is a caller-supplied
28//!   reserve, not a derived quantity. Nothing here models scratch
29//!   buffers, the logits vector, tokenizer state or allocator slack.
30//! - **KV element width.** [`KvElem`] is the width of the store that
31//!   the *selected backend* keeps. With Metal attention on, the device
32//!   holds an f16 KV while the host may still hold an f32 mirror
33//!   (`FERROX_CPU_KV_OFFLOAD`); budget the tier you are checking
34//!   against, and do not assume the two add up to one number.
35//!
36//! A conservative, explainable number beats a clever one: none of this
37//! tries to track real resident bytes over time.
38
39use crate::config::ModelConfig;
40
41/// Element width of one cached K/V scalar, per backend store.
42///
43/// The block-quantized variants are the ggml/TurboQuant wire formats
44/// `ferrox-metal` writes for `FERROX_CTK` (see
45/// `ferrox_metal::attn::MetalKvDtype`), so their cost is per 32-element
46/// block, not per scalar.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum KvElem {
49    /// Host `ferrox_core::cache::KvCache`, which stores `Vec<f32>`.
50    F32,
51    /// Metal device KV default (`FERROX_CTK=f16`, llama.cpp `-ctk f16`).
52    F16,
53    /// ggml Q8_0 wire: 32 elems -> 2-byte scale + 32 int8 = 34 bytes.
54    /// `FERROX_CTK=q8_0|turbo8|fp8` all land on this width.
55    Q8_0,
56    /// TurboQuant 4-bit: 32 elems -> 2-byte scale + 16 nibble bytes.
57    Turbo4,
58}
59
60impl KvElem {
61    /// Bytes needed to store `elems` cached scalars, rounding up to a
62    /// whole block for the block-quantized wires (a partial block still
63    /// costs a full one).
64    pub fn bytes_for(self, elems: u64) -> u64 {
65        match self {
66            KvElem::F32 => elems * 4,
67            KvElem::F16 => elems * 2,
68            KvElem::Q8_0 => {
69                let blocks = elems.div_ceil(ferrox_quant::Q8_0_BLOCK_ELEMS as u64);
70                blocks * ferrox_quant::Q8_0_BLOCK_BYTES as u64
71            }
72            KvElem::Turbo4 => {
73                let blocks = elems.div_ceil(ferrox_quant::TURBO4_KV_GROUP as u64);
74                blocks * ferrox_quant::TURBO4_KV_BLOCK_BYTES as u64
75            }
76        }
77    }
78
79    pub fn as_str(self) -> &'static str {
80        match self {
81            KvElem::F32 => "f32",
82            KvElem::F16 => "f16",
83            KvElem::Q8_0 => "q8_0",
84            KvElem::Turbo4 => "turbo4",
85        }
86    }
87
88    /// Maps a `FERROX_CTK` / `--ctk` value onto the width the Metal KV
89    /// store really keeps. Mirrors
90    /// `ferrox_metal::attn::effective_metal_kv_dtype`: `turbo8` and
91    /// `fp8` share Q8_0's 34-byte wire, and anything unrecognised or
92    /// unimplemented (`turbo3`) falls back to f16 rather than being
93    /// budgeted at a width no kernel writes.
94    ///
95    /// Note this does *not* check the block alignment that function
96    /// also checks (`n_kv_heads * head_dim` divisible by 32), so a
97    /// misaligned shape is budgeted at the requested width while the
98    /// runtime silently uses f16 -- an under-estimate, called out here
99    /// rather than papered over.
100    pub fn from_ctk(value: &str) -> Self {
101        match value.trim().to_ascii_lowercase().as_str() {
102            // llama.cpp's `-ctk f32`, and the width of ferrox's own
103            // host `KvCache`.
104            "f32" => KvElem::F32,
105            "q8_0" | "turbo8" | "fp8" => KvElem::Q8_0,
106            "turbo4" => KvElem::Turbo4,
107            _ => KvElem::F16,
108        }
109    }
110}
111
112/// How one layer's KV cache is shaped. Which variant applies is a
113/// property of the *decoder that will run*, not of the architecture
114/// name -- see [`KvLayout::MlaLatent`]'s doc comment for the one place
115/// that distinction bites.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum KvLayout {
118    /// Multi-head / grouped-query attention: one K vector and one V
119    /// vector of `n_kv_heads * head_dim` per token, per layer. MHA is
120    /// just the `n_kv_heads == n_heads` case -- there is no separate
121    /// variant for it, and the halving GQA buys shows up entirely in
122    /// `n_kv_heads`.
123    Gqa { n_kv_heads: usize, head_dim: usize },
124    /// MLA in its *absorbed* form: the cache holds only the compressed
125    /// latent plus the decoupled RoPE slice, `kv_lora_rank + rope_dim`
126    /// scalars per token per layer, and K/V are reconstructed from it
127    /// on the fly. One vector, not two -- there is no `* 2` here.
128    ///
129    /// **ferrox does not run this form today.** `mla::mla_forward_token`
130    /// (and therefore `kimi_decoder`, `glm_dsa`, `glm52_decoder`)
131    /// caches the *expanded* per-head K and V, so a real ferrox MLA run
132    /// costs [`KvLayout::MlaExpanded`]. This variant is what the
133    /// absorbed form would cost, and is the right number to plan
134    /// against only once a decoder actually caches the latent.
135    MlaLatent {
136        kv_lora_rank: usize,
137        qk_rope_head_dim: usize,
138    },
139    /// MLA as ferrox actually caches it: per-head K of
140    /// `qk_nope_head_dim + qk_rope_head_dim` and per-head V of
141    /// `v_head_dim`, both materialised (`mla::mla_forward_token`'s
142    /// `k_cache`/`v_cache`). K and V head dims differ, which is exactly
143    /// why this cannot reuse the `Gqa` arm.
144    MlaExpanded {
145        n_heads: usize,
146        k_head_dim: usize,
147        v_head_dim: usize,
148    },
149}
150
151impl KvLayout {
152    /// Cached scalars one token contributes to one layer.
153    pub fn elems_per_token_per_layer(self) -> u64 {
154        match self {
155            KvLayout::Gqa {
156                n_kv_heads,
157                head_dim,
158            } => 2 * n_kv_heads as u64 * head_dim as u64,
159            KvLayout::MlaLatent {
160                kv_lora_rank,
161                qk_rope_head_dim,
162            } => kv_lora_rank as u64 + qk_rope_head_dim as u64,
163            KvLayout::MlaExpanded {
164                n_heads,
165                k_head_dim,
166                v_head_dim,
167            } => n_heads as u64 * (k_head_dim as u64 + v_head_dim as u64),
168        }
169    }
170
171    /// One-line description of the arithmetic, for the report a user
172    /// reads when they want to know why they got the context they got.
173    pub fn describe(self) -> String {
174        match self {
175            KvLayout::Gqa {
176                n_kv_heads,
177                head_dim,
178            } => format!("2 (K+V) x {n_kv_heads} kv-heads x {head_dim} head-dim"),
179            KvLayout::MlaLatent {
180                kv_lora_rank,
181                qk_rope_head_dim,
182            } => format!(
183                "MLA latent: {kv_lora_rank} kv_lora_rank + {qk_rope_head_dim} rope-dim \
184                 (one vector, no K/V doubling)"
185            ),
186            KvLayout::MlaExpanded {
187                n_heads,
188                k_head_dim,
189                v_head_dim,
190            } => format!(
191                "MLA expanded: {n_heads} heads x ({k_head_dim} K head-dim + \
192                 {v_head_dim} V head-dim)"
193            ),
194        }
195    }
196}
197
198/// Sliding-window attention, which caps how many positions a layer ever
199/// keeps.
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub struct SlidingWindow {
202    /// Positions a query may attend back over.
203    pub window: usize,
204    /// Prefill chunk size. A chunk of `chunk` tokens is processed
205    /// against one cache state, so the *first* token of the chunk still
206    /// needs its whole window live while the *last* one is being
207    /// processed: `window + chunk - 1` positions, not `window`.
208    pub chunk: usize,
209    /// Gemma 2+/3 alternating pattern (`ModelConfig::swa_pattern`):
210    /// layer `il` is sliding iff `(il + 1) % period != 0`, so every
211    /// `period`-th layer keeps the full context. `None` means every
212    /// layer slides.
213    pub pattern: Option<usize>,
214}
215
216impl SlidingWindow {
217    /// Positions a sliding layer keeps once the sequence is long
218    /// enough to saturate it.
219    pub fn resident_positions(&self, tokens: usize) -> usize {
220        tokens.min(self.window + self.chunk.max(1) - 1)
221    }
222}
223
224/// The KV shape of a whole model: enough to price any context length.
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub struct KvShape {
227    pub n_layers: usize,
228    pub layout: KvLayout,
229    pub elem: KvElem,
230    /// `None` = every layer keeps the full causal history.
231    pub sliding: Option<SlidingWindow>,
232}
233
234impl KvShape {
235    /// Reads the shape off a config. `chunk` is the prefill chunk size
236    /// the run will use (`FERROX_CHUNKED_PREFILL`, or 1 when prefill is
237    /// token-at-a-time); it only ever matters for sliding layers.
238    ///
239    /// Always produces a [`KvLayout::Gqa`] layout, because
240    /// `ModelConfig` describes the generic GQA decoder -- the MLA
241    /// stacks carry their own hyperparameters (`Deepseek2Hparams`,
242    /// `MlaConfig`) and should build their shape with
243    /// [`KvShape::mla_expanded`].
244    pub fn from_config(config: &ModelConfig, elem: KvElem, chunk: usize) -> Self {
245        KvShape {
246            n_layers: config.n_layers,
247            layout: KvLayout::Gqa {
248                n_kv_heads: config.n_kv_heads,
249                head_dim: config.head_dim,
250            },
251            elem,
252            sliding: config.sliding_window.map(|window| SlidingWindow {
253                window,
254                chunk,
255                pattern: config.swa_pattern,
256            }),
257        }
258    }
259
260    /// The shape a ferrox MLA decoder really allocates -- see
261    /// [`KvLayout::MlaExpanded`].
262    pub fn mla_expanded(
263        n_layers: usize,
264        n_heads: usize,
265        qk_nope_head_dim: usize,
266        qk_rope_head_dim: usize,
267        v_head_dim: usize,
268        elem: KvElem,
269    ) -> Self {
270        KvShape {
271            n_layers,
272            layout: KvLayout::MlaExpanded {
273                n_heads,
274                k_head_dim: qk_nope_head_dim + qk_rope_head_dim,
275                v_head_dim,
276            },
277            elem,
278            sliding: None,
279        }
280    }
281
282    /// How many layers slide, given the alternating pattern.
283    pub fn sliding_layers(&self) -> usize {
284        match self.sliding {
285            None => 0,
286            Some(SlidingWindow { pattern: None, .. }) => self.n_layers,
287            Some(SlidingWindow {
288                pattern: Some(period),
289                ..
290            }) => {
291                if period <= 1 {
292                    self.n_layers
293                } else {
294                    // llama.cpp `set_swa_pattern`: full attention iff
295                    // `(il + 1) % period == 0`.
296                    self.n_layers - self.n_layers / period
297                }
298            }
299        }
300    }
301
302    /// Layers that keep the full causal history.
303    pub fn full_attention_layers(&self) -> usize {
304        self.n_layers - self.sliding_layers()
305    }
306
307    /// The plan's headline number: bytes one token costs across every
308    /// layer, ignoring any sliding-window cap. Exact for f32/f16;
309    /// for the block-quantized wires it is exact whenever a layer's
310    /// per-token element count is a multiple of the 32-element block
311    /// (true for every real head-dim/kv-head combination), and rounds
312    /// up otherwise.
313    pub fn per_token_kv_bytes(&self) -> u64 {
314        self.n_layers as u64 * self.elem.bytes_for(self.layout.elems_per_token_per_layer())
315    }
316
317    /// Bytes each *additional* context token costs once the sliding
318    /// layers have saturated: only the full-attention layers keep
319    /// growing. This is the divisor [`KvBudget::max_context`] uses,
320    /// and it is `0` for a model whose every layer slides -- such a
321    /// model's KV is bounded no matter how long the context is.
322    pub fn marginal_per_token_bytes(&self) -> u64 {
323        self.full_attention_layers() as u64
324            * self.elem.bytes_for(self.layout.elems_per_token_per_layer())
325    }
326
327    /// Bytes one request's KV costs at `tokens` of context, applying
328    /// the sliding-window cap per layer class.
329    pub fn kv_bytes_for_tokens(&self, tokens: usize) -> u64 {
330        let per_layer = self.layout.elems_per_token_per_layer();
331        let full =
332            self.full_attention_layers() as u64 * self.elem.bytes_for(per_layer * tokens as u64);
333        let sliding = match self.sliding {
334            None => 0,
335            Some(w) => {
336                self.sliding_layers() as u64
337                    * self
338                        .elem
339                        .bytes_for(per_layer * w.resident_positions(tokens) as u64)
340            }
341        };
342        full + sliding
343    }
344
345    /// The sentence a user should be able to read and reproduce with a
346    /// calculator.
347    pub fn describe(&self) -> String {
348        let base = format!(
349            "{} layers x [{}] x {} = {} bytes/token",
350            self.n_layers,
351            self.layout.describe(),
352            self.elem.as_str(),
353            self.per_token_kv_bytes()
354        );
355        match self.sliding {
356            None => base,
357            Some(w) => format!(
358                "{base}; {} of {} layers slide and cap at min(tokens, {} window + {} chunk - 1) \
359                 = {} positions, leaving {} bytes/token marginal",
360                self.sliding_layers(),
361                self.n_layers,
362                w.window,
363                w.chunk,
364                w.window + w.chunk.max(1) - 1,
365                self.marginal_per_token_bytes(),
366            ),
367        }
368    }
369}
370
371/// Which ceiling a rejection hit. The point of naming it is that the
372/// two send an operator to different knobs: `ContextLength` is the
373/// request's fault and shrinking the prompt fixes it, `DeviceMemory`
374/// is the machine's and only a smaller model / smaller `n_ctx` /
375/// bigger box does.
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377pub enum Ceiling {
378    /// The request asked for more context than this deployment admitted.
379    ContextLength,
380    /// weights + KV + headroom does not fit the backend's budget.
381    DeviceMemory,
382}
383
384impl Ceiling {
385    /// Stable machine-readable code, safe to match on in a client.
386    pub fn code(self) -> &'static str {
387        match self {
388            Ceiling::ContextLength => "context_length_exceeded",
389            Ceiling::DeviceMemory => "device_memory_budget_exceeded",
390        }
391    }
392}
393
394/// A structured refusal: what it would have cost, what the ceiling was,
395/// and which ceiling. Deliberately *not* an allocation failure -- the
396/// whole point of computing this before the load is that nobody has to
397/// read an OOM to find out.
398#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
399#[error("{code}: {detail} (estimated {estimated_bytes} bytes vs limit {limit_bytes} bytes)",
400        code = self.binding.code())]
401pub struct KvBudgetError {
402    pub binding: Ceiling,
403    pub estimated_bytes: u64,
404    pub limit_bytes: u64,
405    pub detail: String,
406}
407
408impl KvBudgetError {
409    pub fn code(&self) -> &'static str {
410        self.binding.code()
411    }
412
413    /// Bytes over the ceiling (saturating, so a fit reads as `0`).
414    pub fn overage_bytes(&self) -> u64 {
415        self.estimated_bytes.saturating_sub(self.limit_bytes)
416    }
417}
418
419/// A priced plan: every term of the inequality, kept separately so the
420/// report can show the arithmetic rather than just the verdict.
421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
422pub struct KvBudget {
423    /// Checkpoint bytes. See the module doc on why this is an
424    /// approximation for mmap'd weights.
425    pub weights_bytes: u64,
426    /// Caller-supplied reserve for activations/scratch/allocator slack.
427    pub activation_headroom_bytes: u64,
428    /// What the backend says it can give us (see
429    /// [`crate::device_budget::DeviceBudget::usable_bytes`]).
430    pub device_budget_bytes: u64,
431    pub shape: KvShape,
432    /// KV caches are per request; concurrency multiplies them.
433    pub concurrent_requests: usize,
434}
435
436impl KvBudget {
437    /// Bytes left for KV after weights and headroom, or `0` when those
438    /// two alone already overflow the budget.
439    pub fn kv_bytes_available(&self) -> u64 {
440        self.device_budget_bytes
441            .saturating_sub(self.weights_bytes)
442            .saturating_sub(self.activation_headroom_bytes)
443    }
444
445    /// Total estimated resident bytes at `tokens` of context.
446    pub fn estimated_bytes(&self, tokens: usize) -> u64 {
447        self.weights_bytes
448            + self.activation_headroom_bytes
449            + self.shape.kv_bytes_for_tokens(tokens) * self.concurrent_requests.max(1) as u64
450    }
451
452    /// The one-line check the plan is named for. `Ok` carries the
453    /// estimate so a caller can log it on the happy path too.
454    pub fn check(&self, tokens: usize) -> Result<u64, KvBudgetError> {
455        let estimated = self.estimated_bytes(tokens);
456        if estimated <= self.device_budget_bytes {
457            return Ok(estimated);
458        }
459        Err(KvBudgetError {
460            binding: Ceiling::DeviceMemory,
461            estimated_bytes: estimated,
462            limit_bytes: self.device_budget_bytes,
463            detail: format!(
464                "{} weight bytes + {} KV bytes at {tokens} tokens x{} concurrent + {} \
465                 activation headroom exceeds the {} byte device budget",
466                self.weights_bytes,
467                self.shape.kv_bytes_for_tokens(tokens) * self.concurrent_requests.max(1) as u64,
468                self.concurrent_requests.max(1),
469                self.activation_headroom_bytes,
470                self.device_budget_bytes,
471            ),
472        })
473    }
474
475    /// Largest context that fits, closed form:
476    /// `(budget - weights - headroom) / (per_token_kv * concurrency)`,
477    /// floored to `granularity` and clamped to `cap` (the model's own
478    /// trained context length).
479    ///
480    /// Sliding-window layers are subtracted out of the divisor (they
481    /// stop growing once saturated) and added back as a constant, so a
482    /// model whose every layer slides is limited only by `cap`.
483    pub fn max_context(&self, cap: usize, granularity: usize) -> ContextFit {
484        let granularity = granularity.max(1);
485        let concurrency = self.concurrent_requests.max(1) as u64;
486        let available = self.kv_bytes_available();
487        let marginal = self.shape.marginal_per_token_bytes() * concurrency;
488
489        // The sliding layers' saturated cost is a constant that has to
490        // come out of the budget before the full-attention layers get
491        // to divide what's left. Priced at `cap` (their worst case).
492        let saturated_sliding = {
493            let mut shape = self.shape;
494            shape.n_layers = shape.sliding_layers();
495            match shape.sliding {
496                None => 0,
497                Some(w) => {
498                    shape.n_layers as u64
499                        * shape.elem.bytes_for(
500                            shape.layout.elems_per_token_per_layer()
501                                * w.resident_positions(cap) as u64,
502                        )
503                        * concurrency
504                }
505            }
506        };
507        let for_full_layers = available.saturating_sub(saturated_sliding);
508
509        let (tokens, capped_by) = if available == 0 || for_full_layers == 0 && marginal > 0 {
510            (0, ContextCap::DeviceBudget)
511        } else {
512            // `checked_div` rather than a `marginal == 0` guard around
513            // a bare `/`: the zero case is not an error here, it is a
514            // real configuration -- every layer slides, so KV is
515            // bounded and only the model's own context length limits
516            // us -- and expressing it as `None` keeps that meaning in
517            // one place instead of splitting it across a check and a
518            // division that clippy then has to re-associate.
519            match for_full_layers.checked_div(marginal) {
520                None => (cap, ContextCap::ModelContextLength),
521                Some(raw) => {
522                    let raw = raw as usize;
523                    // Flooring must never turn a real answer into
524                    // "nothing fits": under one granularity step,
525                    // report the exact number of tokens rather than
526                    // rounding it away.
527                    let floored = if raw >= granularity {
528                        (raw / granularity) * granularity
529                    } else {
530                        raw
531                    };
532                    if floored >= cap {
533                        (cap, ContextCap::ModelContextLength)
534                    } else {
535                        (floored, ContextCap::DeviceBudget)
536                    }
537                }
538            }
539        };
540
541        ContextFit {
542            tokens,
543            cap,
544            granularity,
545            capped_by,
546            kv_available_bytes: available,
547            marginal_per_token_bytes: self.shape.marginal_per_token_bytes(),
548            concurrent_requests: concurrency as usize,
549            kv_bytes: self.shape.kv_bytes_for_tokens(tokens) * concurrency,
550            weights_bytes: self.weights_bytes,
551            activation_headroom_bytes: self.activation_headroom_bytes,
552            device_budget_bytes: self.device_budget_bytes,
553        }
554    }
555}
556
557/// Why `--ctx auto` chose the number it chose.
558#[derive(Debug, Clone, Copy, PartialEq, Eq)]
559pub enum ContextCap {
560    /// The model's own trained context length was the smaller ceiling.
561    ModelContextLength,
562    /// Memory ran out first.
563    DeviceBudget,
564}
565
566/// The answer `--ctx auto` produces, with every term that went into it
567/// so the user can check the division by hand.
568#[derive(Debug, Clone, Copy, PartialEq, Eq)]
569pub struct ContextFit {
570    pub tokens: usize,
571    pub cap: usize,
572    pub granularity: usize,
573    pub capped_by: ContextCap,
574    pub kv_available_bytes: u64,
575    pub marginal_per_token_bytes: u64,
576    pub concurrent_requests: usize,
577    pub kv_bytes: u64,
578    pub weights_bytes: u64,
579    pub activation_headroom_bytes: u64,
580    pub device_budget_bytes: u64,
581}
582
583impl std::fmt::Display for ContextFit {
584    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
585        write!(
586            f,
587            "ctx auto = {} tokens ({}): ({} device budget - {} weights - {} activation headroom) \
588             = {} for KV; / {} bytes/token/request / {} request(s) -> rounded down to a multiple \
589             of {} (reported exactly below one step), capped at the model's {} trained context. \
590             KV at the chosen context: {} bytes.",
591            self.tokens,
592            match self.capped_by {
593                ContextCap::ModelContextLength => "limited by the model's context length",
594                ContextCap::DeviceBudget => "limited by the device memory budget",
595            },
596            self.device_budget_bytes,
597            self.weights_bytes,
598            self.activation_headroom_bytes,
599            self.kv_available_bytes,
600            self.marginal_per_token_bytes,
601            self.concurrent_requests,
602            self.granularity,
603            self.cap,
604            self.kv_bytes,
605        )
606    }
607}
608
609/// Granularity `--ctx auto` floors to. Small enough that the rounding
610/// never costs a meaningful amount of context, round enough that the
611/// reported number looks chosen rather than computed.
612pub const CTX_AUTO_GRANULARITY: usize = 256;
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    /// Llama-3.1-8B's real shape: 32 layers, 8 kv-heads (GQA 4:1),
619    /// head_dim 128. llama.cpp reports 1 MiB/token at f32 for exactly
620    /// this model, which is the number reproduced here by hand:
621    /// 32 * 2 * 8 * 128 * 4 = 262144 bytes.
622    fn llama31_8b() -> KvShape {
623        KvShape {
624            n_layers: 32,
625            layout: KvLayout::Gqa {
626                n_kv_heads: 8,
627                head_dim: 128,
628            },
629            elem: KvElem::F32,
630            sliding: None,
631        }
632    }
633
634    #[test]
635    fn gqa_per_token_kv_matches_the_hand_computed_byte_count() {
636        let shape = llama31_8b();
637        assert_eq!(shape.layout.elems_per_token_per_layer(), 2 * 8 * 128);
638        assert_eq!(shape.per_token_kv_bytes(), 32 * 2 * 8 * 128 * 4);
639        assert_eq!(shape.per_token_kv_bytes(), 262_144);
640        // f16 is exactly half; a block-quantized store is 34/32 of the
641        // element count, not 1 byte flat.
642        assert_eq!(
643            KvShape {
644                elem: KvElem::F16,
645                ..shape
646            }
647            .per_token_kv_bytes(),
648            131_072
649        );
650        assert_eq!(
651            KvShape {
652                elem: KvElem::Q8_0,
653                ..shape
654            }
655            .per_token_kv_bytes(),
656            32 * (2 * 8 * 128 / 32) * 34
657        );
658        assert_eq!(
659            KvShape {
660                elem: KvElem::Turbo4,
661                ..shape
662            }
663            .per_token_kv_bytes(),
664            32 * (2 * 8 * 128 / 32) * 18
665        );
666    }
667
668    #[test]
669    fn ctk_names_map_onto_the_widths_metal_really_writes() {
670        assert_eq!(KvElem::from_ctk("f16"), KvElem::F16);
671        assert_eq!(KvElem::from_ctk("f32"), KvElem::F32);
672        assert_eq!(KvElem::from_ctk("Q8_0"), KvElem::Q8_0);
673        // turbo8 and fp8 share Q8_0's wire, per MetalKvDtype.
674        assert_eq!(KvElem::from_ctk("turbo8"), KvElem::Q8_0);
675        assert_eq!(KvElem::from_ctk("fp8"), KvElem::Q8_0);
676        assert_eq!(KvElem::from_ctk("turbo4"), KvElem::Turbo4);
677        // turbo3 is unimplemented and falls back to f16, as does junk.
678        assert_eq!(KvElem::from_ctk("turbo3"), KvElem::F16);
679        assert_eq!(KvElem::from_ctk("  nonsense "), KvElem::F16);
680    }
681
682    #[test]
683    fn mha_costs_exactly_the_gqa_ratio_more_than_gqa() {
684        // Same model with n_kv_heads == n_heads (32) instead of 8: MHA
685        // is 4x the KV of 4:1 GQA, and nothing else changes.
686        let gqa = llama31_8b();
687        let mha = KvShape {
688            layout: KvLayout::Gqa {
689                n_kv_heads: 32,
690                head_dim: 128,
691            },
692            ..gqa
693        };
694        assert_eq!(mha.per_token_kv_bytes(), 4 * gqa.per_token_kv_bytes());
695        assert_eq!(mha.per_token_kv_bytes(), 32 * 2 * 32 * 128 * 4);
696    }
697
698    #[test]
699    fn sliding_window_layers_saturate_and_full_layers_do_not() {
700        // Mistral-7B shape with a 4096 window, every layer sliding,
701        // prefill one token at a time (chunk = 1 -> cap is exactly the
702        // window).
703        let shape = KvShape {
704            n_layers: 32,
705            layout: KvLayout::Gqa {
706                n_kv_heads: 8,
707                head_dim: 128,
708            },
709            elem: KvElem::F16,
710            sliding: Some(SlidingWindow {
711                window: 4096,
712                chunk: 1,
713                pattern: None,
714            }),
715        };
716        assert_eq!(shape.sliding_layers(), 32);
717        assert_eq!(shape.full_attention_layers(), 0);
718        // Below the window it costs the same as full attention.
719        assert_eq!(
720            shape.kv_bytes_for_tokens(1024),
721            shape.per_token_kv_bytes() * 1024
722        );
723        // Above it, cost stops growing.
724        let at_window = shape.kv_bytes_for_tokens(4096);
725        assert_eq!(shape.kv_bytes_for_tokens(32_768), at_window);
726        assert_eq!(shape.kv_bytes_for_tokens(1_000_000), at_window);
727        // Marginal cost per extra token is zero once every layer slides.
728        assert_eq!(shape.marginal_per_token_bytes(), 0);
729    }
730
731    #[test]
732    fn chunked_prefill_widens_the_sliding_cap_by_chunk_minus_one() {
733        let base = SlidingWindow {
734            window: 512,
735            chunk: 1,
736            pattern: None,
737        };
738        assert_eq!(base.resident_positions(100_000), 512);
739        let chunked = SlidingWindow { chunk: 256, ..base };
740        // window + chunk - 1, per the plan: the first token of a chunk
741        // still needs its full window when the last one runs.
742        assert_eq!(chunked.resident_positions(100_000), 512 + 256 - 1);
743        assert_eq!(chunked.resident_positions(300), 300);
744    }
745
746    #[test]
747    fn gemma_alternating_pattern_leaves_every_sixth_layer_full_attention() {
748        // Gemma 3's real 5:1 pattern: layer `il` slides unless
749        // `(il + 1) % 6 == 0`, so 26 layers slide and 5 do not out of 31.
750        let shape = KvShape {
751            n_layers: 30,
752            layout: KvLayout::Gqa {
753                n_kv_heads: 4,
754                head_dim: 256,
755            },
756            elem: KvElem::F16,
757            sliding: Some(SlidingWindow {
758                window: 1024,
759                chunk: 1,
760                pattern: Some(6),
761            }),
762        };
763        assert_eq!(shape.full_attention_layers(), 5);
764        assert_eq!(shape.sliding_layers(), 25);
765        // Cross-check against ModelConfig's own per-layer answer, so
766        // the two SWA implementations cannot drift apart.
767        let mut cfg = crate::config::test_dense_fixture();
768        cfg.n_layers = 30;
769        cfg.sliding_window = Some(1024);
770        cfg.swa_pattern = Some(6);
771        let per_layer_full = (0..30)
772            .filter(|&il| cfg.layer_sliding_window(il).is_none())
773            .count();
774        assert_eq!(per_layer_full, shape.full_attention_layers());
775
776        // Only the 5 full-attention layers keep growing with context.
777        let per_layer_token = shape.elem.bytes_for(2 * 4 * 256);
778        assert_eq!(shape.marginal_per_token_bytes(), 5 * per_layer_token);
779        // At 8192 tokens: 5 full layers at 8192 positions, 25 sliding
780        // layers pinned at 1024.
781        assert_eq!(
782            shape.kv_bytes_for_tokens(8192),
783            5 * shape.elem.bytes_for(2 * 4 * 256 * 8192)
784                + 25 * shape.elem.bytes_for(2 * 4 * 256 * 1024)
785        );
786    }
787
788    #[test]
789    fn mla_latent_is_one_vector_and_far_cheaper_than_the_expanded_form() {
790        // DeepSeek-V2's real MLA numbers: kv_lora_rank 512,
791        // qk_rope_head_dim 64, qk_nope_head_dim 128, v_head_dim 128,
792        // 128 heads, 60 layers.
793        let latent = KvShape {
794            n_layers: 60,
795            layout: KvLayout::MlaLatent {
796                kv_lora_rank: 512,
797                qk_rope_head_dim: 64,
798            },
799            elem: KvElem::F32,
800            sliding: None,
801        };
802        // 512 + 64 = 576 scalars per token per layer -- one vector, no
803        // K/V doubling.
804        assert_eq!(latent.layout.elems_per_token_per_layer(), 576);
805        assert_eq!(latent.per_token_kv_bytes(), 60 * 576 * 4);
806
807        let expanded = KvShape::mla_expanded(60, 128, 128, 64, 128, KvElem::F32);
808        // 128 heads x (192 K + 128 V) = 40960 scalars per token/layer.
809        assert_eq!(
810            expanded.layout.elems_per_token_per_layer(),
811            128 * (192 + 128)
812        );
813        assert_eq!(expanded.per_token_kv_bytes(), 60 * 40_960 * 4);
814        // The absorbed form is ~71x cheaper; this is exactly why the
815        // distinction is worth carrying rather than assuming.
816        assert!(expanded.per_token_kv_bytes() / latent.per_token_kv_bytes() > 70);
817
818        // A same-sized GQA model for scale: 128 kv-heads x 128 head_dim.
819        let gqa = KvShape {
820            layout: KvLayout::Gqa {
821                n_kv_heads: 128,
822                head_dim: 128,
823            },
824            ..latent
825        };
826        assert_eq!(gqa.per_token_kv_bytes(), 60 * 2 * 128 * 128 * 4);
827    }
828
829    #[test]
830    fn from_config_reads_layers_heads_and_the_sliding_window() {
831        let mut cfg = crate::config::test_dense_fixture();
832        cfg.n_layers = 12;
833        cfg.n_kv_heads = 2;
834        cfg.head_dim = 64;
835        cfg.sliding_window = None;
836        let shape = KvShape::from_config(&cfg, KvElem::F32, 1);
837        assert_eq!(shape.n_layers, 12);
838        assert_eq!(shape.per_token_kv_bytes(), 12 * 2 * 2 * 64 * 4);
839        assert!(shape.sliding.is_none());
840
841        cfg.sliding_window = Some(256);
842        cfg.swa_pattern = None;
843        let swa = KvShape::from_config(&cfg, KvElem::F32, 64);
844        assert_eq!(
845            swa.sliding,
846            Some(SlidingWindow {
847                window: 256,
848                chunk: 64,
849                pattern: None
850            })
851        );
852        assert_eq!(swa.sliding_layers(), 12);
853    }
854
855    fn budget(weights: u64, device: u64, shape: KvShape) -> KvBudget {
856        KvBudget {
857            weights_bytes: weights,
858            activation_headroom_bytes: 0,
859            device_budget_bytes: device,
860            shape,
861            concurrent_requests: 1,
862        }
863    }
864
865    #[test]
866    fn check_accepts_a_fitting_context_and_names_the_binding_ceiling_otherwise() {
867        let shape = llama31_8b(); // 262144 bytes/token
868        let b = budget(1_000_000, 1_000_000 + 262_144 * 10, shape);
869        assert_eq!(b.check(10).unwrap(), 1_000_000 + 262_144 * 10);
870        let err = b.check(11).expect_err("one token past the budget");
871        assert_eq!(err.binding, Ceiling::DeviceMemory);
872        assert_eq!(err.code(), "device_memory_budget_exceeded");
873        assert_eq!(err.estimated_bytes, 1_000_000 + 262_144 * 11);
874        assert_eq!(err.limit_bytes, 1_000_000 + 262_144 * 10);
875        assert_eq!(err.overage_bytes(), 262_144);
876    }
877
878    #[test]
879    fn concurrency_multiplies_kv_but_not_weights() {
880        let shape = llama31_8b();
881        let one = budget(1_000, 1 << 40, shape);
882        let four = KvBudget {
883            concurrent_requests: 4,
884            ..one
885        };
886        assert_eq!(
887            four.estimated_bytes(100) - 1_000,
888            4 * (one.estimated_bytes(100) - 1_000)
889        );
890    }
891
892    #[test]
893    fn max_context_is_the_closed_form_division_floored_to_granularity() {
894        let shape = llama31_8b(); // 262144 bytes/token
895                                  // Room for exactly 1000 tokens of KV after weights.
896        let b = budget(5_000_000, 5_000_000 + 262_144 * 1000, shape);
897        let fit = b.max_context(131_072, 256);
898        assert_eq!(fit.capped_by, ContextCap::DeviceBudget);
899        // 1000 floored to a 256-token step is 768.
900        assert_eq!(fit.tokens, 768);
901        assert_eq!(fit.kv_available_bytes, 262_144 * 1000);
902        assert_eq!(fit.marginal_per_token_bytes, 262_144);
903        // The chosen context really does fit.
904        assert!(b.check(fit.tokens).is_ok());
905        // One granularity step further does not.
906        assert!(b.check(fit.tokens + 256).is_err());
907    }
908
909    #[test]
910    fn max_context_clamps_to_the_models_trained_context_when_memory_is_plentiful() {
911        let b = budget(1_000, 1 << 40, llama31_8b());
912        let fit = b.max_context(8192, 256);
913        assert_eq!(fit.tokens, 8192);
914        assert_eq!(fit.capped_by, ContextCap::ModelContextLength);
915    }
916
917    /// Flooring must not round a small-but-real answer down to "nothing
918    /// fits" -- found by running `--ctx-size auto` under a tight
919    /// `FERROX_DEVICE_BUDGET_BYTES`, where 227 tokens genuinely fitted
920    /// and the 256-token granularity reported 0.
921    #[test]
922    fn a_context_under_one_granularity_step_is_reported_exactly_not_floored_away() {
923        let shape = llama31_8b(); // 262144 bytes/token
924        let b = budget(1_000, 1_000 + 262_144 * 100, shape);
925        let fit = b.max_context(131_072, 256);
926        assert_eq!(fit.tokens, 100);
927        assert_eq!(fit.capped_by, ContextCap::DeviceBudget);
928        assert!(b.check(fit.tokens).is_ok());
929        assert!(b.check(fit.tokens + 1).is_err());
930    }
931
932    #[test]
933    fn max_context_is_zero_when_the_weights_alone_do_not_fit() {
934        let b = budget(10_000_000, 1_000_000, llama31_8b());
935        let fit = b.max_context(8192, 256);
936        assert_eq!(fit.tokens, 0);
937        assert_eq!(fit.capped_by, ContextCap::DeviceBudget);
938        assert_eq!(fit.kv_available_bytes, 0);
939        assert!(b.check(0).is_err(), "weights alone already overflow");
940    }
941
942    #[test]
943    fn an_all_sliding_model_is_limited_only_by_its_context_length() {
944        let shape = KvShape {
945            n_layers: 32,
946            layout: KvLayout::Gqa {
947                n_kv_heads: 8,
948                head_dim: 128,
949            },
950            elem: KvElem::F16,
951            sliding: Some(SlidingWindow {
952                window: 4096,
953                chunk: 1,
954                pattern: None,
955            }),
956        };
957        // Budget covers the saturated window with room to spare.
958        let saturated = shape.kv_bytes_for_tokens(4096);
959        let b = budget(1_000, 1_000 + saturated * 2, shape);
960        let fit = b.max_context(1_000_000, 256);
961        assert_eq!(fit.capped_by, ContextCap::ModelContextLength);
962        assert_eq!(fit.tokens, 1_000_000);
963        assert!(b.check(fit.tokens).is_ok());
964    }
965
966    #[test]
967    fn a_mixed_swa_model_prices_the_saturated_sliding_layers_before_dividing() {
968        // 6 layers, every 3rd full-attention (2 full, 4 sliding).
969        let shape = KvShape {
970            n_layers: 6,
971            layout: KvLayout::Gqa {
972                n_kv_heads: 1,
973                head_dim: 16,
974            },
975            elem: KvElem::F32,
976            sliding: Some(SlidingWindow {
977                window: 128,
978                chunk: 1,
979                pattern: Some(3),
980            }),
981        };
982        assert_eq!(shape.full_attention_layers(), 2);
983        // 2 (K+V) x 1 kv-head x 16 head-dim x 4 bytes.
984        let per_layer_token = 2 * 16 * 4;
985        let sliding_saturated = 4 * per_layer_token * 128;
986        let full_marginal = 2 * per_layer_token;
987        // Give the budget the saturated sliding cost plus exactly 512
988        // tokens of full-attention growth.
989        let b = budget(0, (sliding_saturated + full_marginal * 512) as u64, shape);
990        let fit = b.max_context(4096, 256);
991        assert_eq!(fit.tokens, 512);
992        assert_eq!(fit.capped_by, ContextCap::DeviceBudget);
993        assert!(b.check(512).is_ok());
994    }
995
996    #[test]
997    fn ctx_auto_explanation_names_every_term_it_divided() {
998        let b = budget(5_000_000, 5_000_000 + 262_144 * 1000, llama31_8b());
999        let text = b.max_context(131_072, CTX_AUTO_GRANULARITY).to_string();
1000        assert!(text.contains("ctx auto = 768 tokens"), "{text}");
1001        assert!(text.contains("262144"), "per-token divisor missing: {text}");
1002        assert!(text.contains("5000000"), "weights term missing: {text}");
1003        assert!(text.contains("131072"), "model cap missing: {text}");
1004    }
1005}