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}