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