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