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