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//! # ...unless the store evicts, which it now can
69//!
70//! #61 step 2 taught the contiguous `KvCache` to drop rows behind a
71//! layer's sliding window, behind `FERROX_KV_WINDOW`. So the paragraph
72//! above is still the default and no longer the only case, and the
73//! difference is expressed the way #33 said it had to be: **the number
74//! the store keeps belongs to the store.** [`KvResidency`] carries the
75//! per-layer windows, [`KvShape::resident_kv_bytes_for_tokens`] prices
76//! them through `ferrox_core::kv_swa::KvWindow::rows_after`, and
77//! `KvCache::evict_behind_window` calls the same function to decide what
78//! to drop. There is no second statement of the rule here to drift.
79//!
80//! Two numbers, not one, and admission wants the larger:
81//! [`KvShape::peak_kv_bytes_for_tokens`] adds the one layer that is
82//! still mid-prefill and holding the whole prompt, because
83//! `Decoder::forward_batch` evicts per layer rather than after the
84//! stack. `resident_` is what a measurement of the caches finds at rest;
85//! `peak_` is what the machine has to survive.
86//!
87//! What is NOT priced here, because no store does it yet: eviction
88//! inside the paged store (#61 step 4) and eviction of the prompt region
89//! while the prompt is still being written (#61 step 5). Both stay at
90//! the full every-layer-every-position number.
91
92use ferrox_core::kv_swa::KvWindow;
93
94use crate::config::ModelConfig;
95use crate::decoder::KvWindowPolicy;
96
97/// Element width of one cached K/V scalar, per backend store.
98///
99/// The block-quantized variants are the ggml/TurboQuant wire formats
100/// `ferrox-metal` writes for `FERROX_CTK` (see
101/// `ferrox_metal::attn::MetalKvDtype`), so their cost is per 32-element
102/// block, not per scalar.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum KvElem {
105    /// Host `ferrox_core::cache::KvCache`, which stores `Vec<f32>`.
106    F32,
107    /// Metal device KV default (`FERROX_CTK=f16`, llama.cpp `-ctk f16`).
108    F16,
109    /// ggml Q8_0 wire: 32 elems -> 2-byte scale + 32 int8 = 34 bytes.
110    /// `FERROX_CTK=q8_0|turbo8|fp8` all land on this width.
111    Q8_0,
112    /// TurboQuant 4-bit: 32 elems -> 2-byte scale + 16 nibble bytes.
113    Turbo4,
114}
115
116impl KvElem {
117    /// Bytes needed to store `elems` cached scalars, rounding up to a
118    /// whole block for the block-quantized wires (a partial block still
119    /// costs a full one).
120    ///
121    /// Saturating rather than wrapping or panicking. This is a
122    /// REPORTING number: it exists to put bytes in a refusal message,
123    /// and it is reached with position counts that came off an HTTP
124    /// body. `max_tokens: u64::MAX / 64` does not overflow the position
125    /// sum, so it reaches here and multiplied past `u64::MAX`, panicking
126    /// the request thread while computing the text of the very refusal
127    /// that was about to reject it (#36).
128    ///
129    /// Saturating is right HERE and wrong for a bound. A saturated byte
130    /// count still reports "astronomically large", which is the only
131    /// thing the message needs to convey. A saturated position bound
132    /// would silently turn a nonsense request into a plausible one and
133    /// serve it.
134    pub fn bytes_for(self, elems: u64) -> u64 {
135        match self {
136            KvElem::F32 => elems.saturating_mul(4),
137            KvElem::F16 => elems.saturating_mul(2),
138            KvElem::Q8_0 => {
139                let blocks = elems.div_ceil(ferrox_quant::Q8_0_BLOCK_ELEMS as u64);
140                blocks.saturating_mul(ferrox_quant::Q8_0_BLOCK_BYTES as u64)
141            }
142            KvElem::Turbo4 => {
143                let blocks = elems.div_ceil(ferrox_quant::TURBO4_KV_GROUP as u64);
144                blocks.saturating_mul(ferrox_quant::TURBO4_KV_BLOCK_BYTES as u64)
145            }
146        }
147    }
148
149    pub fn as_str(self) -> &'static str {
150        match self {
151            KvElem::F32 => "f32",
152            KvElem::F16 => "f16",
153            KvElem::Q8_0 => "q8_0",
154            KvElem::Turbo4 => "turbo4",
155        }
156    }
157
158    /// Maps a `FERROX_CTK` / `--ctk` value onto the width the Metal KV
159    /// store really keeps. Mirrors
160    /// `ferrox_metal::attn::effective_metal_kv_dtype`: `turbo8` and
161    /// `fp8` share Q8_0's 34-byte wire, and anything unrecognised or
162    /// unimplemented (`turbo3`) falls back to f16 rather than being
163    /// budgeted at a width no kernel writes.
164    ///
165    /// Note this does *not* check the block alignment that function
166    /// also checks (`n_kv_heads * head_dim` divisible by 32), so a
167    /// misaligned shape is budgeted at the requested width while the
168    /// runtime silently uses f16 -- an under-estimate, called out here
169    /// rather than papered over.
170    pub fn from_ctk(value: &str) -> Self {
171        match value.trim().to_ascii_lowercase().as_str() {
172            // llama.cpp's `-ctk f32`, and the width of ferrox's own
173            // host `KvCache`.
174            "f32" => KvElem::F32,
175            "q8_0" | "turbo8" | "fp8" => KvElem::Q8_0,
176            "turbo4" => KvElem::Turbo4,
177            _ => KvElem::F16,
178        }
179    }
180}
181
182/// How one layer's KV cache is shaped. Which variant applies is a
183/// property of the *decoder that will run*, not of the architecture
184/// name -- see [`KvLayout::MlaLatent`]'s doc comment for the one place
185/// that distinction bites.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum KvLayout {
188    /// Multi-head / grouped-query attention: one K vector and one V
189    /// vector of `n_kv_heads * head_dim` per token, per layer. MHA is
190    /// just the `n_kv_heads == n_heads` case -- there is no separate
191    /// variant for it, and the halving GQA buys shows up entirely in
192    /// `n_kv_heads`.
193    Gqa { n_kv_heads: usize, head_dim: usize },
194    /// MLA in its *absorbed* form: the cache holds only the compressed
195    /// latent plus the decoupled RoPE slice, `kv_lora_rank + rope_dim`
196    /// scalars per token per layer, and K/V are reconstructed from it
197    /// on the fly. One vector, not two -- there is no `* 2` here.
198    ///
199    /// **ferrox does not run this form today.** `mla::mla_forward_token`
200    /// (and therefore `kimi_decoder`, `glm_dsa`, `glm52_decoder`)
201    /// caches the *expanded* per-head K and V, so a real ferrox MLA run
202    /// costs [`KvLayout::MlaExpanded`]. This variant is what the
203    /// absorbed form would cost, and is the right number to plan
204    /// against only once a decoder actually caches the latent.
205    MlaLatent {
206        kv_lora_rank: usize,
207        qk_rope_head_dim: usize,
208    },
209    /// MLA as ferrox actually caches it: per-head K of
210    /// `qk_nope_head_dim + qk_rope_head_dim` and per-head V of
211    /// `v_head_dim`, both materialised (`mla::mla_forward_token`'s
212    /// `k_cache`/`v_cache`). K and V head dims differ, which is exactly
213    /// why this cannot reuse the `Gqa` arm.
214    MlaExpanded {
215        n_heads: usize,
216        k_head_dim: usize,
217        v_head_dim: usize,
218    },
219}
220
221impl KvLayout {
222    /// Cached scalars one token contributes to one layer.
223    pub fn elems_per_token_per_layer(self) -> u64 {
224        match self {
225            KvLayout::Gqa {
226                n_kv_heads,
227                head_dim,
228            } => 2 * n_kv_heads as u64 * head_dim as u64,
229            KvLayout::MlaLatent {
230                kv_lora_rank,
231                qk_rope_head_dim,
232            } => kv_lora_rank as u64 + qk_rope_head_dim as u64,
233            KvLayout::MlaExpanded {
234                n_heads,
235                k_head_dim,
236                v_head_dim,
237            } => n_heads as u64 * (k_head_dim as u64 + v_head_dim as u64),
238        }
239    }
240
241    /// One-line description of the arithmetic, for the report a user
242    /// reads when they want to know why they got the context they got.
243    pub fn describe(self) -> String {
244        match self {
245            KvLayout::Gqa {
246                n_kv_heads,
247                head_dim,
248            } => format!("2 (K+V) x {n_kv_heads} kv-heads x {head_dim} head-dim"),
249            KvLayout::MlaLatent {
250                kv_lora_rank,
251                qk_rope_head_dim,
252            } => format!(
253                "MLA latent: {kv_lora_rank} kv_lora_rank + {qk_rope_head_dim} rope-dim \
254                 (one vector, no K/V doubling)"
255            ),
256            KvLayout::MlaExpanded {
257                n_heads,
258                k_head_dim,
259                v_head_dim,
260            } => format!(
261                "MLA expanded: {n_heads} heads x ({k_head_dim} K head-dim + \
262                 {v_head_dim} V head-dim)"
263            ),
264        }
265    }
266}
267
268/// The KV shape of a whole model: enough to price any context length.
269///
270/// How big one position is, times how many layers. How many positions
271/// each of those layers still HOLDS is [`KvResidency`], and it is a
272/// separate value because it is a property of the run rather than of
273/// the model: by default every layer keeps every position, and behind
274/// `FERROX_KV_WINDOW` a windowed layer does not (#61).
275///
276/// That is a statement about the STORES this engine allocates, not
277/// about the architectures it runs -- see the module doc, and the two
278/// tests that measure real `ferrox_core::cache::KvCache`s rather than
279/// restating this multiplication.
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub struct KvShape {
282    pub n_layers: usize,
283    pub layout: KvLayout,
284    pub elem: KvElem,
285}
286
287impl KvShape {
288    /// Reads the shape off a config.
289    ///
290    /// `config.sliding_window` / `config.swa_pattern` are deliberately
291    /// NOT read HERE: they describe what attention *reads*, and this
292    /// module prices what the store *keeps*. The default store keeps
293    /// everything (#33), so a windowed layer costs exactly what a
294    /// full-attention one does. When a run evicts, that is what
295    /// [`KvResidency::from_config`] is for -- and it reaches the window
296    /// through the same `KvWindowPolicy` the decoder evicts with, not by
297    /// reading those two fields a second time.
298    ///
299    /// Always produces a [`KvLayout::Gqa`] layout, because
300    /// `ModelConfig` describes the generic GQA decoder -- the MLA
301    /// stacks carry their own hyperparameters (`Deepseek2Hparams`,
302    /// `MlaConfig`) and should build their shape with
303    /// [`KvShape::mla_expanded`].
304    pub fn from_config(config: &ModelConfig, elem: KvElem) -> Self {
305        KvShape {
306            n_layers: config.n_layers,
307            layout: KvLayout::Gqa {
308                n_kv_heads: config.n_kv_heads,
309                head_dim: config.head_dim,
310            },
311            elem,
312        }
313    }
314
315    /// The shape a ferrox MLA decoder really allocates -- see
316    /// [`KvLayout::MlaExpanded`].
317    pub fn mla_expanded(
318        n_layers: usize,
319        n_heads: usize,
320        qk_nope_head_dim: usize,
321        qk_rope_head_dim: usize,
322        v_head_dim: usize,
323        elem: KvElem,
324    ) -> Self {
325        KvShape {
326            n_layers,
327            layout: KvLayout::MlaExpanded {
328                n_heads,
329                k_head_dim: qk_nope_head_dim + qk_rope_head_dim,
330                v_head_dim,
331            },
332            elem,
333        }
334    }
335
336    /// The plan's headline number, and the only per-token number there
337    /// is: bytes one token costs across every layer. Exact for f32/f16;
338    /// for the block-quantized wires it is exact whenever a layer's
339    /// per-token element count is a multiple of the 32-element block
340    /// (true for every real head-dim/kv-head combination), and rounds
341    /// up otherwise.
342    ///
343    /// This is also the divisor [`KvBudget::max_context`] uses. There is
344    /// no separate "marginal" number any more: a marginal cost below the
345    /// per-token cost would mean some layer stops growing, and none
346    /// does.
347    pub fn per_token_kv_bytes(&self) -> u64 {
348        (self.n_layers as u64)
349            .saturating_mul(self.elem.bytes_for(self.layout.elems_per_token_per_layer()))
350    }
351
352    /// Bytes one request's KV costs at `tokens` of context.
353    pub fn kv_bytes_for_tokens(&self, tokens: usize) -> u64 {
354        // Every multiplication here saturates, for the reason on
355        // `KvElem::bytes_for`: `tokens` can arrive from an HTTP body.
356        let per_layer = self.layout.elems_per_token_per_layer();
357        (self.n_layers as u64)
358            .saturating_mul(self.elem.bytes_for(per_layer.saturating_mul(tokens as u64)))
359    }
360
361    /// Bytes one request's KV costs at `tokens` of context when the
362    /// stores EVICT behind a window (#61 step 2), once every layer has
363    /// been through -- the number a measurement of the caches finds.
364    ///
365    /// The row counts come from [`KvWindow::rows_after`], which is the
366    /// store's own rule and not a restatement of it: `KvCache` calls the
367    /// same function to decide what to drop. Equal to
368    /// [`Self::kv_bytes_for_tokens`] when `residency` keeps everything,
369    /// which is what the default policy produces and what a test below
370    /// asserts rather than assumes.
371    ///
372    /// [`Self::peak_kv_bytes_for_tokens`] is the number to ADMIT on;
373    /// this one is smaller, and the difference is prefill.
374    pub fn resident_kv_bytes_for_tokens(&self, tokens: usize, residency: &KvResidency) -> u64 {
375        let per_layer = self.layout.elems_per_token_per_layer();
376        residency
377            .rows_per_layer(self.n_layers, tokens)
378            .map(|rows| self.elem.bytes_for(per_layer.saturating_mul(rows as u64)))
379            .fold(0u64, |acc, b| acc.saturating_add(b))
380    }
381
382    /// The number an admission decision must use: the resting cost, plus
383    /// the one layer that is still mid-prefill.
384    ///
385    /// `Decoder::forward_batch` writes a whole prompt into layer `l`'s
386    /// cache, attends over it, and only then hands the rows behind the
387    /// window back -- before layer `l + 1` allocates any. So a long
388    /// prompt costs ONE windowed layer's full history at a time rather
389    /// than every windowed layer's at once, and that transient is real
390    /// memory that has to be budgeted for. Charging only the resting
391    /// number would be #33 in the other direction: an admitted request
392    /// whose peak exceeds the estimate arrives as an OOM.
393    ///
394    /// The extra term is the largest single windowed layer's shortfall,
395    /// because layers are prefilled one at a time.
396    pub fn peak_kv_bytes_for_tokens(&self, tokens: usize, residency: &KvResidency) -> u64 {
397        let per_layer = self.layout.elems_per_token_per_layer();
398        let full = self.elem.bytes_for(per_layer.saturating_mul(tokens as u64));
399        let transient = residency
400            .rows_per_layer(self.n_layers, tokens)
401            .map(|rows| {
402                full.saturating_sub(self.elem.bytes_for(per_layer.saturating_mul(rows as u64)))
403            })
404            .max()
405            .unwrap_or(0);
406        self.resident_kv_bytes_for_tokens(tokens, residency)
407            .saturating_add(transient)
408    }
409
410    /// The sentence a user should be able to read and reproduce with a
411    /// calculator.
412    pub fn describe(&self) -> String {
413        format!(
414            "{} layers x [{}] x {} = {} bytes/token",
415            self.n_layers,
416            self.layout.describe(),
417            self.elem.as_str(),
418            self.per_token_kv_bytes()
419        )
420    }
421}
422
423/// What the stores really keep, per layer.
424///
425/// [`KvShape`] answers "how big is one position, times how many layers".
426/// This answers "how many positions does each of those layers still
427/// hold", which used to be "all of them" for every layer of every model
428/// and now depends on whether `FERROX_KV_WINDOW` is on (#61).
429///
430/// **Deliberately not a field on `KvShape`.** `KvShape` is `Copy`, is
431/// built by struct literal in more than one crate, and is the thing
432/// every existing caller already has; a new field there would make the
433/// no-eviction default a thing every caller restates. A residency is
434/// asked for by the callers that price an evicting run, and the ones
435/// that do not keep the number they always had.
436#[derive(Debug, Clone, PartialEq, Eq)]
437pub struct KvResidency {
438    /// One entry per layer, in layer order. `None` means that layer
439    /// keeps every position it was ever given.
440    per_layer: Vec<Option<KvWindow>>,
441}
442
443impl KvResidency {
444    /// Every layer keeps every position: the engine before #61, and the
445    /// engine today unless the switch is on.
446    pub fn keeps_everything(n_layers: usize) -> Self {
447        KvResidency {
448            per_layer: vec![None; n_layers],
449        }
450    }
451
452    /// What `policy` will make the stores of `config` keep.
453    ///
454    /// Goes through [`KvWindowPolicy::layer_window`], which is the same
455    /// call `Decoder::kv_window_for_layer` makes to decide what to
456    /// evict. One expression, so there is nothing for the budget and the
457    /// store to disagree about -- the disagreement being #33, where the
458    /// budget capped a sliding layer no store ever capped and `-c auto`
459    /// approved a context that did not fit.
460    pub fn from_config(config: &ModelConfig, policy: KvWindowPolicy) -> Self {
461        KvResidency {
462            per_layer: (0..config.n_layers)
463                .map(|l| policy.layer_window(config, l))
464                .collect(),
465        }
466    }
467
468    /// True when no layer evicts, i.e. this prices exactly what
469    /// [`KvShape::kv_bytes_for_tokens`] prices.
470    pub fn keeps_every_position(&self) -> bool {
471        self.per_layer.iter().all(Option::is_none)
472    }
473
474    /// The window layer `layer_idx` evicts behind, if any.
475    pub fn layer_window(&self, layer_idx: usize) -> Option<KvWindow> {
476        self.per_layer.get(layer_idx).copied().flatten()
477    }
478
479    /// Rows each of `n_layers` layers holds at `tokens` of context.
480    ///
481    /// `n_layers` comes from the [`KvShape`] being priced rather than
482    /// from `self`, and a layer this residency says nothing about keeps
483    /// everything. A shape and a residency built from different configs
484    /// is a caller error; charging the full cost is the safe way to be
485    /// wrong about it.
486    fn rows_per_layer(&self, n_layers: usize, tokens: usize) -> impl Iterator<Item = usize> + '_ {
487        (0..n_layers).map(move |l| match self.layer_window(l) {
488            Some(w) => w.rows_after(tokens),
489            None => tokens,
490        })
491    }
492}
493
494/// Which ceiling a rejection hit. The point of naming it is that the
495/// two send an operator to different knobs: `ContextLength` is the
496/// request's fault and shrinking the prompt fixes it, `DeviceMemory`
497/// is the machine's and only a smaller model / smaller `n_ctx` /
498/// bigger box does.
499#[derive(Debug, Clone, Copy, PartialEq, Eq)]
500pub enum Ceiling {
501    /// The request asked for more context than this deployment admitted.
502    ContextLength,
503    /// weights + KV + headroom does not fit the backend's budget.
504    DeviceMemory,
505}
506
507impl Ceiling {
508    /// Stable machine-readable code, safe to match on in a client.
509    pub fn code(self) -> &'static str {
510        match self {
511            Ceiling::ContextLength => "context_length_exceeded",
512            Ceiling::DeviceMemory => "device_memory_budget_exceeded",
513        }
514    }
515}
516
517/// A structured refusal: what it would have cost, what the ceiling was,
518/// and which ceiling. Deliberately *not* an allocation failure -- the
519/// whole point of computing this before the load is that nobody has to
520/// read an OOM to find out.
521#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
522#[error("{code}: {detail} (estimated {estimated_bytes} bytes vs limit {limit_bytes} bytes)",
523        code = self.binding.code())]
524pub struct KvBudgetError {
525    pub binding: Ceiling,
526    pub estimated_bytes: u64,
527    pub limit_bytes: u64,
528    pub detail: String,
529}
530
531impl KvBudgetError {
532    pub fn code(&self) -> &'static str {
533        self.binding.code()
534    }
535
536    /// Bytes over the ceiling (saturating, so a fit reads as `0`).
537    pub fn overage_bytes(&self) -> u64 {
538        self.estimated_bytes.saturating_sub(self.limit_bytes)
539    }
540}
541
542/// A priced plan: every term of the inequality, kept separately so the
543/// report can show the arithmetic rather than just the verdict.
544#[derive(Debug, Clone, Copy, PartialEq, Eq)]
545pub struct KvBudget {
546    /// Checkpoint bytes. See the module doc on why this is an
547    /// approximation for mmap'd weights.
548    pub weights_bytes: u64,
549    /// Caller-supplied reserve for activations/scratch/allocator slack.
550    pub activation_headroom_bytes: u64,
551    /// What the backend says it can give us (see
552    /// [`crate::device_budget::DeviceBudget::usable_bytes`]).
553    pub device_budget_bytes: u64,
554    pub shape: KvShape,
555    /// KV caches are per request; concurrency multiplies them.
556    pub concurrent_requests: usize,
557}
558
559impl KvBudget {
560    /// Bytes left for KV after weights and headroom, or `0` when those
561    /// two alone already overflow the budget.
562    pub fn kv_bytes_available(&self) -> u64 {
563        self.device_budget_bytes
564            .saturating_sub(self.weights_bytes)
565            .saturating_sub(self.activation_headroom_bytes)
566    }
567
568    /// Total estimated resident bytes at `tokens` of context.
569    pub fn estimated_bytes(&self, tokens: usize) -> u64 {
570        self.weights_bytes
571            + self.activation_headroom_bytes
572            + self.shape.kv_bytes_for_tokens(tokens) * self.concurrent_requests.max(1) as u64
573    }
574
575    /// The one-line check the plan is named for. `Ok` carries the
576    /// estimate so a caller can log it on the happy path too.
577    pub fn check(&self, tokens: usize) -> Result<u64, KvBudgetError> {
578        let estimated = self.estimated_bytes(tokens);
579        if estimated <= self.device_budget_bytes {
580            return Ok(estimated);
581        }
582        Err(KvBudgetError {
583            binding: Ceiling::DeviceMemory,
584            estimated_bytes: estimated,
585            limit_bytes: self.device_budget_bytes,
586            detail: format!(
587                "{} weight bytes + {} KV bytes at {tokens} tokens x{} concurrent + {} \
588                 activation headroom exceeds the {} byte device budget",
589                self.weights_bytes,
590                self.shape.kv_bytes_for_tokens(tokens) * self.concurrent_requests.max(1) as u64,
591                self.concurrent_requests.max(1),
592                self.activation_headroom_bytes,
593                self.device_budget_bytes,
594            ),
595        })
596    }
597
598    /// Largest context that fits, closed form:
599    /// `(budget - weights - headroom) / (per_token_kv * concurrency)`,
600    /// floored to `granularity` and clamped to `cap` (the model's own
601    /// trained context length).
602    ///
603    /// Every layer is in the divisor. A sliding-window model used to
604    /// have its windowed layers subtracted out of it and added back as a
605    /// saturated constant, which is the #33 under-estimate: nothing
606    /// evicts, so nothing saturates.
607    pub fn max_context(&self, cap: usize, granularity: usize) -> ContextFit {
608        let granularity = granularity.max(1);
609        let concurrency = self.concurrent_requests.max(1) as u64;
610        let available = self.kv_bytes_available();
611        let per_token = self.shape.per_token_kv_bytes().saturating_mul(concurrency);
612
613        let (tokens, capped_by) = if available == 0 {
614            (0, ContextCap::DeviceBudget)
615        } else {
616            // `checked_div` rather than a `per_token == 0` guard around
617            // a bare `/`: a model with no KV at all (no layers, or a
618            // zero-width layout) is not an error here, it is just
619            // unbounded by memory, and expressing it as `None` keeps
620            // that meaning in one place instead of splitting it across
621            // a check and a division that clippy then has to
622            // re-associate.
623            match available.checked_div(per_token) {
624                None => (cap, ContextCap::ModelContextLength),
625                Some(raw) => {
626                    let raw = raw as usize;
627                    // Flooring must never turn a real answer into
628                    // "nothing fits": under one granularity step,
629                    // report the exact number of tokens rather than
630                    // rounding it away.
631                    let floored = if raw >= granularity {
632                        (raw / granularity) * granularity
633                    } else {
634                        raw
635                    };
636                    if floored >= cap {
637                        (cap, ContextCap::ModelContextLength)
638                    } else {
639                        (floored, ContextCap::DeviceBudget)
640                    }
641                }
642            }
643        };
644
645        ContextFit {
646            tokens,
647            cap,
648            granularity,
649            capped_by,
650            kv_available_bytes: available,
651            per_token_kv_bytes: self.shape.per_token_kv_bytes(),
652            concurrent_requests: concurrency as usize,
653            kv_bytes: self.shape.kv_bytes_for_tokens(tokens) * concurrency,
654            weights_bytes: self.weights_bytes,
655            activation_headroom_bytes: self.activation_headroom_bytes,
656            device_budget_bytes: self.device_budget_bytes,
657        }
658    }
659}
660
661/// Why `--ctx auto` chose the number it chose.
662#[derive(Debug, Clone, Copy, PartialEq, Eq)]
663pub enum ContextCap {
664    /// The model's own trained context length was the smaller ceiling.
665    ModelContextLength,
666    /// Memory ran out first.
667    DeviceBudget,
668}
669
670/// The answer `--ctx auto` produces, with every term that went into it
671/// so the user can check the division by hand.
672#[derive(Debug, Clone, Copy, PartialEq, Eq)]
673pub struct ContextFit {
674    pub tokens: usize,
675    pub cap: usize,
676    pub granularity: usize,
677    pub capped_by: ContextCap,
678    pub kv_available_bytes: u64,
679    /// The divisor: bytes one token of context costs across every layer.
680    pub per_token_kv_bytes: u64,
681    pub concurrent_requests: usize,
682    pub kv_bytes: u64,
683    pub weights_bytes: u64,
684    pub activation_headroom_bytes: u64,
685    pub device_budget_bytes: u64,
686}
687
688impl std::fmt::Display for ContextFit {
689    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
690        write!(
691            f,
692            "ctx auto = {} tokens ({}): ({} device budget - {} weights - {} activation headroom) \
693             = {} for KV; / {} bytes/token/request / {} request(s) -> rounded down to a multiple \
694             of {} (reported exactly below one step), capped at the model's {} trained context. \
695             KV at the chosen context: {} bytes.",
696            self.tokens,
697            match self.capped_by {
698                ContextCap::ModelContextLength => "limited by the model's context length",
699                ContextCap::DeviceBudget => "limited by the device memory budget",
700            },
701            self.device_budget_bytes,
702            self.weights_bytes,
703            self.activation_headroom_bytes,
704            self.kv_available_bytes,
705            self.per_token_kv_bytes,
706            self.concurrent_requests,
707            self.granularity,
708            self.cap,
709            self.kv_bytes,
710        )
711    }
712}
713
714/// Granularity `--ctx auto` floors to. Small enough that the rounding
715/// never costs a meaningful amount of context, round enough that the
716/// reported number looks chosen rather than computed.
717pub const CTX_AUTO_GRANULARITY: usize = 256;
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    /// Llama-3.1-8B's real shape: 32 layers, 8 kv-heads (GQA 4:1),
724    /// head_dim 128. llama.cpp reports 1 MiB/token at f32 for exactly
725    /// this model, which is the number reproduced here by hand:
726    /// 32 * 2 * 8 * 128 * 4 = 262144 bytes.
727    fn llama31_8b() -> KvShape {
728        KvShape {
729            n_layers: 32,
730            layout: KvLayout::Gqa {
731                n_kv_heads: 8,
732                head_dim: 128,
733            },
734            elem: KvElem::F32,
735        }
736    }
737
738    #[test]
739    fn gqa_per_token_kv_matches_the_hand_computed_byte_count() {
740        let shape = llama31_8b();
741        assert_eq!(shape.layout.elems_per_token_per_layer(), 2 * 8 * 128);
742        assert_eq!(shape.per_token_kv_bytes(), 32 * 2 * 8 * 128 * 4);
743        assert_eq!(shape.per_token_kv_bytes(), 262_144);
744        // f16 is exactly half; a block-quantized store is 34/32 of the
745        // element count, not 1 byte flat.
746        assert_eq!(
747            KvShape {
748                elem: KvElem::F16,
749                ..shape
750            }
751            .per_token_kv_bytes(),
752            131_072
753        );
754        assert_eq!(
755            KvShape {
756                elem: KvElem::Q8_0,
757                ..shape
758            }
759            .per_token_kv_bytes(),
760            32 * (2 * 8 * 128 / 32) * 34
761        );
762        assert_eq!(
763            KvShape {
764                elem: KvElem::Turbo4,
765                ..shape
766            }
767            .per_token_kv_bytes(),
768            32 * (2 * 8 * 128 / 32) * 18
769        );
770    }
771
772    #[test]
773    fn ctk_names_map_onto_the_widths_metal_really_writes() {
774        assert_eq!(KvElem::from_ctk("f16"), KvElem::F16);
775        assert_eq!(KvElem::from_ctk("f32"), KvElem::F32);
776        assert_eq!(KvElem::from_ctk("Q8_0"), KvElem::Q8_0);
777        // turbo8 and fp8 share Q8_0's wire, per MetalKvDtype.
778        assert_eq!(KvElem::from_ctk("turbo8"), KvElem::Q8_0);
779        assert_eq!(KvElem::from_ctk("fp8"), KvElem::Q8_0);
780        assert_eq!(KvElem::from_ctk("turbo4"), KvElem::Turbo4);
781        // turbo3 is unimplemented and falls back to f16, as does junk.
782        assert_eq!(KvElem::from_ctk("turbo3"), KvElem::F16);
783        assert_eq!(KvElem::from_ctk("  nonsense "), KvElem::F16);
784    }
785
786    #[test]
787    fn mha_costs_exactly_the_gqa_ratio_more_than_gqa() {
788        // Same model with n_kv_heads == n_heads (32) instead of 8: MHA
789        // is 4x the KV of 4:1 GQA, and nothing else changes.
790        let gqa = llama31_8b();
791        let mha = KvShape {
792            layout: KvLayout::Gqa {
793                n_kv_heads: 32,
794                head_dim: 128,
795            },
796            ..gqa
797        };
798        assert_eq!(mha.per_token_kv_bytes(), 4 * gqa.per_token_kv_bytes());
799        assert_eq!(mha.per_token_kv_bytes(), 32 * 2 * 32 * 128 * 4);
800    }
801
802    /// A small alternating-SWA config: 6 layers, every 3rd of them full
803    /// attention, a 4-position window. Small enough that a test can
804    /// allocate the real stores; alternating, which is the case
805    /// `ModelConfig::uniform_sliding_window` refuses to let any store
806    /// recycle.
807    fn alternating_swa_config() -> ModelConfig {
808        let mut cfg = crate::config::test_dense_fixture();
809        cfg.n_layers = 6;
810        cfg.n_kv_heads = 1;
811        cfg.head_dim = 8;
812        cfg.sliding_window = Some(4);
813        cfg.swa_pattern = Some(3);
814        cfg
815    }
816
817    /// **The property this module got wrong, measured rather than
818    /// restated.**
819    ///
820    /// The old budget capped a sliding layer at `window + chunk - 1`
821    /// positions, but `ferrox_core::cache::KvCache` -- the store the CLI
822    /// allocates and the store the server allocates on every non-paged
823    /// path -- has no window concept: `push` extends `k`/`v` for every
824    /// position, in every layer. So the budget under-priced gpt-oss by
825    /// 2x and Gemma-3-4B by 5.8x, `-c auto` approved a context that did
826    /// not fit, and the failure arrived as an OOM instead of a refusal
827    /// (#33).
828    ///
829    /// This pushes real positions into the real caches and compares the
830    /// bytes they hold against the budget's number. Recomputing the
831    /// budget's own multiplication here would assert nothing: the code
832    /// was not wrong about arithmetic, it was wrong about the world.
833    #[test]
834    fn the_budget_prices_exactly_what_the_kv_store_allocates_for_an_alternating_swa_model() {
835        let cfg = alternating_swa_config();
836        // Well past the 4-position window, which is the whole point:
837        // under the old cap the sliding layers stopped being charged
838        // here.
839        let tokens = 64;
840        assert!(
841            cfg.sliding_window.is_some() && cfg.uniform_sliding_window().is_none(),
842            "the fixture must be an alternating-SWA model, or this proves nothing"
843        );
844
845        let mut caches: Vec<ferrox_core::cache::KvCache> = (0..cfg.n_layers)
846            .map(|_| ferrox_core::cache::KvCache::new(cfg.n_kv_heads, cfg.head_dim))
847            .collect();
848        let step = vec![0f32; cfg.n_kv_heads * cfg.head_dim];
849        for _ in 0..tokens {
850            for cache in caches.iter_mut() {
851                cache
852                    .push(&step, &step)
853                    .expect("a cache built with `new` always accepts a push");
854            }
855        }
856        let allocated: u64 = caches
857            .iter()
858            .map(|c| (c.k.len() + c.v.len()) as u64 * std::mem::size_of::<f32>() as u64)
859            .sum();
860
861        let shape = KvShape::from_config(&cfg, KvElem::F32);
862        assert_eq!(
863            shape.kv_bytes_for_tokens(tokens),
864            allocated,
865            "the budget must price what the store holds"
866        );
867        // The store kept every position in every layer, window or not.
868        assert_eq!(allocated, shape.per_token_kv_bytes() * tokens as u64);
869        // And the two entry points agree when nothing evicts, rather
870        // than being two independent multiplications that happen to
871        // match today.
872        assert_eq!(
873            shape
874                .resident_kv_bytes_for_tokens(tokens, &KvResidency::keeps_everything(cfg.n_layers)),
875            allocated
876        );
877    }
878
879    /// **The same property, measured again, now that a store evicts.**
880    ///
881    /// The sibling above is the default and stays the default. This is
882    /// the `FERROX_KV_WINDOW` case, and it is asserted the same way for
883    /// the same reason: by pushing real positions into real
884    /// `ferrox_core::cache::KvCache`s, evicting them the way
885    /// `Decoder::evict_layer_kv` does, and comparing the bytes they hold
886    /// against the budget's number. If the budget restated the window
887    /// rule instead of taking it from `KvWindow::rows_after`, this test
888    /// would pass while the two drifted -- which is exactly how #33
889    /// survived long enough to approve a context that did not fit.
890    #[test]
891    fn the_budget_prices_exactly_what_an_evicting_kv_store_holds() {
892        let cfg = alternating_swa_config();
893        let tokens = 64;
894        let residency = KvResidency::from_config(&cfg, KvWindowPolicy::on());
895        assert!(
896            !residency.keeps_every_position(),
897            "the fixture must have windowed layers, or this proves nothing"
898        );
899        assert!(
900            (0..cfg.n_layers).any(|l| residency.layer_window(l).is_none()),
901            "the fixture must ALSO have dense layers: they are the half that keeps costing"
902        );
903
904        let mut caches: Vec<ferrox_core::cache::KvCache> = (0..cfg.n_layers)
905            .map(|_| ferrox_core::cache::KvCache::new(cfg.n_kv_heads, cfg.head_dim))
906            .collect();
907        for (l, cache) in caches.iter_mut().enumerate() {
908            if let Some(w) = residency.layer_window(l) {
909                cache.arm_window(w);
910            }
911        }
912        let step = vec![0f32; cfg.n_kv_heads * cfg.head_dim];
913        for _ in 0..tokens {
914            for cache in caches.iter_mut() {
915                cache
916                    .push(&step, &step)
917                    .expect("a cache built with `new` always accepts a push");
918                cache.evict_behind_window();
919            }
920        }
921        let held: u64 = caches
922            .iter()
923            .map(|c| (c.k.len() + c.v.len()) as u64 * std::mem::size_of::<f32>() as u64)
924            .sum();
925
926        let shape = KvShape::from_config(&cfg, KvElem::F32);
927        assert_eq!(
928            shape.resident_kv_bytes_for_tokens(tokens, &residency),
929            held,
930            "the budget must price what the evicting store holds"
931        );
932        // The saving is real: strictly less than pricing every position.
933        assert!(
934            held < shape.kv_bytes_for_tokens(tokens),
935            "eviction saved nothing: {held} vs {}",
936            shape.kv_bytes_for_tokens(tokens)
937        );
938        // And the number to admit on is above the number at rest, because
939        // one layer holds the whole prompt while it is being prefilled.
940        assert!(shape.peak_kv_bytes_for_tokens(tokens, &residency) > held);
941        // ...but never above pricing every layer at every position,
942        // which is what the engine costs today.
943        assert!(
944            shape.peak_kv_bytes_for_tokens(tokens, &residency) <= shape.kv_bytes_for_tokens(tokens)
945        );
946    }
947
948    /// The default policy prices exactly what it always did. A switch
949    /// that is off must be invisible to the arithmetic.
950    #[test]
951    fn the_default_policy_prices_every_layer_at_every_position() {
952        let cfg = alternating_swa_config();
953        let residency = KvResidency::from_config(&cfg, KvWindowPolicy::off());
954        assert!(residency.keeps_every_position());
955        let shape = KvShape::from_config(&cfg, KvElem::F32);
956        for tokens in [0usize, 1, 63, 64, 4096] {
957            assert_eq!(
958                shape.resident_kv_bytes_for_tokens(tokens, &residency),
959                shape.kv_bytes_for_tokens(tokens)
960            );
961            assert_eq!(
962                shape.peak_kv_bytes_for_tokens(tokens, &residency),
963                shape.kv_bytes_for_tokens(tokens)
964            );
965        }
966    }
967
968    /// The headline number from #61, priced through the residency rather
969    /// than asserted: Gemma-3-4B at a 32k context.
970    ///
971    /// 34 layers, 4 kv-heads, head_dim 256, host f32, a 1024-position
972    /// window on five layers out of every six. The full price is the
973    /// 9.13 GB the issue measured; the windowed one is what the store
974    /// now holds.
975    #[test]
976    fn gemma3_4b_at_32k_costs_a_fraction_of_what_it_did() {
977        let mut cfg = crate::config::test_dense_fixture();
978        cfg.n_layers = 34;
979        cfg.n_kv_heads = 4;
980        cfg.head_dim = 256;
981        cfg.sliding_window = Some(1024);
982        cfg.swa_pattern = Some(6);
983        let shape = KvShape::from_config(&cfg, KvElem::F32);
984        let tokens = 32_768;
985
986        let full = shape.kv_bytes_for_tokens(tokens);
987        assert_eq!(full, 9_126_805_504, "the number #61 measured");
988
989        let residency = KvResidency::from_config(&cfg, KvWindowPolicy::on());
990        let resting = shape.resident_kv_bytes_for_tokens(tokens, &residency);
991        let peak = shape.peak_kv_bytes_for_tokens(tokens, &residency);
992        // Pinned rather than bounded, so a change to the default slack
993        // shows up as a memory number moving rather than as nothing.
994        // 5 of the 34 layers are full attention (`swa_pattern` 6) and
995        // still hold every position; at 1.34 GB they are most of what is
996        // left. The 29 windowed ones hold 1475 rows each instead of
997        // 32768.
998        assert_eq!(resting, 1_692_590_080, "5.4x less than the 9.13 GB above");
999        assert_eq!(
1000            peak, 1_948_942_336,
1001            "resting plus the one windowed layer still mid-prefill"
1002        );
1003        assert!(peak < full && peak > resting);
1004    }
1005
1006    /// The pool-backed store is the other thing a server allocates, and
1007    /// it reserves `max_seq_len` positions for EVERY layer up front
1008    /// (`KvCache::with_pool`), rounded up to whole blocks. The budget
1009    /// must never be under that either -- an admitted request whose
1010    /// reservation exceeds the estimate is exactly the OOM #33 is about.
1011    #[test]
1012    fn the_pool_backed_store_never_reserves_more_positions_than_the_budget_priced() {
1013        use ferrox_core::cache::{KvBlockPool, KvCache};
1014        use std::sync::{Arc, Mutex};
1015
1016        let cfg = alternating_swa_config();
1017        let tokens = 64usize;
1018        let block_size = 16usize;
1019        let pool = Arc::new(Mutex::new(KvBlockPool::new(
1020            block_size,
1021            tokens.div_ceil(block_size) * cfg.n_layers,
1022        )));
1023        let caches: Vec<KvCache> = (0..cfg.n_layers)
1024            .map(|_| {
1025                KvCache::with_pool(cfg.n_kv_heads, cfg.head_dim, Arc::clone(&pool), tokens)
1026                    .expect("the pool was sized for exactly this")
1027            })
1028            .collect();
1029        let reserved: u64 = caches
1030            .iter()
1031            .map(|c| c.k.capacity() as u64 + c.v.capacity() as u64)
1032            .sum::<u64>()
1033            * std::mem::size_of::<f32>() as u64;
1034
1035        let priced = KvShape::from_config(&cfg, KvElem::F32).kv_bytes_for_tokens(tokens);
1036        // Equal here because `tokens` is a whole number of blocks; the
1037        // assertion that matters is the direction, which holds for any
1038        // block size.
1039        assert!(
1040            priced >= reserved,
1041            "budget priced {priced} bytes, the pool reserved {reserved}"
1042        );
1043        assert_eq!(priced, reserved);
1044    }
1045
1046    /// The two checkpoints #33 measured, at their own byte counts.
1047    ///
1048    /// These constants are what the stores allocate, taken from the
1049    /// issue, not from this module's formula. The numbers the old code
1050    /// produced were 6,448,742,400 for gpt-oss (half) and 1,585,446,912
1051    /// for Gemma-3-4B (a sixth).
1052    #[test]
1053    fn gpt_oss_and_gemma3_cost_what_the_issue_measured() {
1054        // gpt-oss-20b: 24 layers, 8 kv-heads, head_dim 64, host f32,
1055        // 131072 context. Alternating 128-position window, priced at 0.
1056        let mut gpt_oss = crate::config::test_dense_fixture();
1057        gpt_oss.n_layers = 24;
1058        gpt_oss.n_kv_heads = 8;
1059        gpt_oss.head_dim = 64;
1060        gpt_oss.sliding_window = Some(128);
1061        gpt_oss.swa_pattern = Some(2);
1062        assert_eq!(
1063            KvShape::from_config(&gpt_oss, KvElem::F32).kv_bytes_for_tokens(131_072),
1064            12_884_901_888
1065        );
1066
1067        // Gemma-3-4B: 34 layers, 4 kv-heads, head_dim 256, 32768 tokens.
1068        let mut gemma3 = crate::config::test_dense_fixture();
1069        gemma3.n_layers = 34;
1070        gemma3.n_kv_heads = 4;
1071        gemma3.head_dim = 256;
1072        gemma3.sliding_window = Some(1024);
1073        gemma3.swa_pattern = Some(6);
1074        assert_eq!(
1075            KvShape::from_config(&gemma3, KvElem::F32).kv_bytes_for_tokens(32_768),
1076            9_126_805_504
1077        );
1078    }
1079
1080    /// A window changes what attention READS, not what the store KEEPS,
1081    /// so it may not change the price. Stated as an equality between two
1082    /// configs rather than as a comment, so re-introducing a cap fails
1083    /// here.
1084    #[test]
1085    fn a_windowed_config_is_priced_identically_to_the_same_config_without_a_window() {
1086        let windowed = alternating_swa_config();
1087        let mut full = windowed.clone();
1088        full.sliding_window = None;
1089        full.swa_pattern = None;
1090        for tokens in [1, 3, 4, 5, 64, 100_000] {
1091            assert_eq!(
1092                KvShape::from_config(&windowed, KvElem::F32).kv_bytes_for_tokens(tokens),
1093                KvShape::from_config(&full, KvElem::F32).kv_bytes_for_tokens(tokens),
1094                "tokens={tokens}"
1095            );
1096        }
1097    }
1098
1099    #[test]
1100    fn mla_latent_is_one_vector_and_far_cheaper_than_the_expanded_form() {
1101        // DeepSeek-V2's real MLA numbers: kv_lora_rank 512,
1102        // qk_rope_head_dim 64, qk_nope_head_dim 128, v_head_dim 128,
1103        // 128 heads, 60 layers.
1104        let latent = KvShape {
1105            n_layers: 60,
1106            layout: KvLayout::MlaLatent {
1107                kv_lora_rank: 512,
1108                qk_rope_head_dim: 64,
1109            },
1110            elem: KvElem::F32,
1111        };
1112        // 512 + 64 = 576 scalars per token per layer -- one vector, no
1113        // K/V doubling.
1114        assert_eq!(latent.layout.elems_per_token_per_layer(), 576);
1115        assert_eq!(latent.per_token_kv_bytes(), 60 * 576 * 4);
1116
1117        let expanded = KvShape::mla_expanded(60, 128, 128, 64, 128, KvElem::F32);
1118        // 128 heads x (192 K + 128 V) = 40960 scalars per token/layer.
1119        assert_eq!(
1120            expanded.layout.elems_per_token_per_layer(),
1121            128 * (192 + 128)
1122        );
1123        assert_eq!(expanded.per_token_kv_bytes(), 60 * 40_960 * 4);
1124        // The absorbed form is ~71x cheaper; this is exactly why the
1125        // distinction is worth carrying rather than assuming.
1126        assert!(expanded.per_token_kv_bytes() / latent.per_token_kv_bytes() > 70);
1127
1128        // A same-sized GQA model for scale: 128 kv-heads x 128 head_dim.
1129        let gqa = KvShape {
1130            layout: KvLayout::Gqa {
1131                n_kv_heads: 128,
1132                head_dim: 128,
1133            },
1134            ..latent
1135        };
1136        assert_eq!(gqa.per_token_kv_bytes(), 60 * 2 * 128 * 128 * 4);
1137    }
1138
1139    #[test]
1140    fn from_config_reads_layers_heads_and_head_dim() {
1141        let mut cfg = crate::config::test_dense_fixture();
1142        cfg.n_layers = 12;
1143        cfg.n_kv_heads = 2;
1144        cfg.head_dim = 64;
1145        cfg.sliding_window = None;
1146        let shape = KvShape::from_config(&cfg, KvElem::F32);
1147        assert_eq!(shape.n_layers, 12);
1148        assert_eq!(shape.per_token_kv_bytes(), 12 * 2 * 2 * 64 * 4);
1149
1150        // A uniform window changes nothing either: the paged store that
1151        // could recycle for one still holds the whole prompt, and it is
1152        // a context length this prices.
1153        cfg.sliding_window = Some(256);
1154        cfg.swa_pattern = None;
1155        assert_eq!(KvShape::from_config(&cfg, KvElem::F32), shape);
1156    }
1157
1158    fn budget(weights: u64, device: u64, shape: KvShape) -> KvBudget {
1159        KvBudget {
1160            weights_bytes: weights,
1161            activation_headroom_bytes: 0,
1162            device_budget_bytes: device,
1163            shape,
1164            concurrent_requests: 1,
1165        }
1166    }
1167
1168    #[test]
1169    fn check_accepts_a_fitting_context_and_names_the_binding_ceiling_otherwise() {
1170        let shape = llama31_8b(); // 262144 bytes/token
1171        let b = budget(1_000_000, 1_000_000 + 262_144 * 10, shape);
1172        assert_eq!(b.check(10).unwrap(), 1_000_000 + 262_144 * 10);
1173        let err = b.check(11).expect_err("one token past the budget");
1174        assert_eq!(err.binding, Ceiling::DeviceMemory);
1175        assert_eq!(err.code(), "device_memory_budget_exceeded");
1176        assert_eq!(err.estimated_bytes, 1_000_000 + 262_144 * 11);
1177        assert_eq!(err.limit_bytes, 1_000_000 + 262_144 * 10);
1178        assert_eq!(err.overage_bytes(), 262_144);
1179    }
1180
1181    #[test]
1182    fn concurrency_multiplies_kv_but_not_weights() {
1183        let shape = llama31_8b();
1184        let one = budget(1_000, 1 << 40, shape);
1185        let four = KvBudget {
1186            concurrent_requests: 4,
1187            ..one
1188        };
1189        assert_eq!(
1190            four.estimated_bytes(100) - 1_000,
1191            4 * (one.estimated_bytes(100) - 1_000)
1192        );
1193    }
1194
1195    #[test]
1196    fn max_context_is_the_closed_form_division_floored_to_granularity() {
1197        let shape = llama31_8b(); // 262144 bytes/token
1198                                  // Room for exactly 1000 tokens of KV after weights.
1199        let b = budget(5_000_000, 5_000_000 + 262_144 * 1000, shape);
1200        let fit = b.max_context(131_072, 256);
1201        assert_eq!(fit.capped_by, ContextCap::DeviceBudget);
1202        // 1000 floored to a 256-token step is 768.
1203        assert_eq!(fit.tokens, 768);
1204        assert_eq!(fit.kv_available_bytes, 262_144 * 1000);
1205        assert_eq!(fit.per_token_kv_bytes, 262_144);
1206        // The chosen context really does fit.
1207        assert!(b.check(fit.tokens).is_ok());
1208        // One granularity step further does not.
1209        assert!(b.check(fit.tokens + 256).is_err());
1210    }
1211
1212    #[test]
1213    fn max_context_clamps_to_the_models_trained_context_when_memory_is_plentiful() {
1214        let b = budget(1_000, 1 << 40, llama31_8b());
1215        let fit = b.max_context(8192, 256);
1216        assert_eq!(fit.tokens, 8192);
1217        assert_eq!(fit.capped_by, ContextCap::ModelContextLength);
1218    }
1219
1220    /// Flooring must not round a small-but-real answer down to "nothing
1221    /// fits" -- found by running `--ctx-size auto` under a tight
1222    /// `FERROX_DEVICE_BUDGET_BYTES`, where 227 tokens genuinely fitted
1223    /// and the 256-token granularity reported 0.
1224    #[test]
1225    fn a_context_under_one_granularity_step_is_reported_exactly_not_floored_away() {
1226        let shape = llama31_8b(); // 262144 bytes/token
1227        let b = budget(1_000, 1_000 + 262_144 * 100, shape);
1228        let fit = b.max_context(131_072, 256);
1229        assert_eq!(fit.tokens, 100);
1230        assert_eq!(fit.capped_by, ContextCap::DeviceBudget);
1231        assert!(b.check(fit.tokens).is_ok());
1232        assert!(b.check(fit.tokens + 1).is_err());
1233    }
1234
1235    #[test]
1236    fn max_context_is_zero_when_the_weights_alone_do_not_fit() {
1237        let b = budget(10_000_000, 1_000_000, llama31_8b());
1238        let fit = b.max_context(8192, 256);
1239        assert_eq!(fit.tokens, 0);
1240        assert_eq!(fit.capped_by, ContextCap::DeviceBudget);
1241        assert_eq!(fit.kv_available_bytes, 0);
1242        assert!(b.check(0).is_err(), "weights alone already overflow");
1243    }
1244
1245    /// `--ctx auto` on a windowed model used to answer "the model's own
1246    /// context length" however small the budget was, because the
1247    /// divisor had every sliding layer taken out of it and a model whose
1248    /// every layer slid divided by zero bytes per token. It is now
1249    /// bounded by memory like any other model, and the context it picks
1250    /// has to survive `check` -- which is the assertion that would have
1251    /// caught the OOM.
1252    #[test]
1253    fn a_windowed_model_is_bounded_by_memory_like_any_other() {
1254        let mut cfg = alternating_swa_config();
1255        cfg.swa_pattern = Some(1); // every layer slides: the old zero divisor
1256        let shape = KvShape::from_config(&cfg, KvElem::F32);
1257        // Room for 1024 tokens, against a model that would like 1e6.
1258        let b = budget(1_000, 1_000 + shape.per_token_kv_bytes() * 1024, shape);
1259        let fit = b.max_context(1_000_000, 256);
1260        assert_eq!(fit.capped_by, ContextCap::DeviceBudget);
1261        assert_eq!(fit.tokens, 1024);
1262        assert!(b.check(fit.tokens).is_ok());
1263        assert!(
1264            b.check(fit.tokens + 1).is_err(),
1265            "the chosen context must be the largest that fits"
1266        );
1267    }
1268
1269    #[test]
1270    fn ctx_auto_explanation_names_every_term_it_divided() {
1271        let b = budget(5_000_000, 5_000_000 + 262_144 * 1000, llama31_8b());
1272        let text = b.max_context(131_072, CTX_AUTO_GRANULARITY).to_string();
1273        assert!(text.contains("ctx auto = 768 tokens"), "{text}");
1274        assert!(text.contains("262144"), "per-token divisor missing: {text}");
1275        assert!(text.contains("5000000"), "weights term missing: {text}");
1276        assert!(text.contains("131072"), "model cap missing: {text}");
1277    }
1278}