Skip to main content

gglib_core/domain/
kv_estimate.rs

1//! KV-cache size estimation from GGUF metadata.
2//!
3//! Estimates how many *elements* of KV cache a model consumes per token of
4//! context. Element counts are type-agnostic (derived purely from model
5//! architecture); converting to bytes happens separately, once the launch's
6//! resolved K/V cache types are known (`--cache-type-k`/`--cache-type-v` may
7//! quantize K and V differently — see [`crate::cache_config::KvCacheType`]).
8//! Callers multiply the resulting bytes-per-token by a context size to size
9//! memory budgets (see `crate::domain::cache_budget::compute_auto_cache_ram_mb`).
10//!
11//! Inputs come from the raw GGUF key/value map that `gglib-gguf` copies
12//! verbatim into [`crate::domain::Model::metadata`], so no re-parse of the
13//! `.gguf` file is needed. Every key is architecture-prefixed
14//! (`qwen3.block_count`, `llama.attention.head_count_kv`, …).
15//!
16//! This is deliberately an *estimate*: it models the standard transformer
17//! KV-cache layout and ignores architecture-specific extras (sliding-window
18//! layers, MLA compression, per-layer overrides). It is used only for
19//! conservative memory budgeting, never for correctness, and returns `None`
20//! rather than guessing when the metadata doesn't carry what it needs.
21
22use std::collections::HashMap;
23use std::hash::BuildHasher;
24
25use crate::cache_config::KvCacheType;
26
27/// Per-token K and V element counts, type-agnostic.
28///
29/// K and V element counts are tracked separately (not just summed) because
30/// callers may quantize K and V to different types (e.g. `q8_0` K with `f16`
31/// V to sidestep the Flash Attention requirement on quantized V), so the byte
32/// cost of each side must be computed independently.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct KvElemsPerToken {
35    pub k: u64,
36    pub v: u64,
37}
38
39/// Look up an architecture-prefixed GGUF key (`{arch}.{suffix}`), falling back
40/// to the bare suffix for the occasional file that omits the prefix.
41fn lookup<S: BuildHasher>(
42    metadata: &HashMap<String, String, S>,
43    arch: &str,
44    suffix: &str,
45) -> Option<u64> {
46    metadata
47        .get(&format!("{arch}.{suffix}"))
48        .or_else(|| metadata.get(suffix))
49        .and_then(|v| v.trim().parse::<u64>().ok())
50}
51
52/// Estimate K and V element counts consumed per token of context.
53///
54/// Formula (standard transformer KV cache):
55///
56/// ```text
57/// k_elems/token = block_count × head_count_kv × key_length
58/// v_elems/token = block_count × head_count_kv × value_length
59/// ```
60///
61/// `key_length`/`value_length` are the per-head dimensions. When absent, both
62/// fall back to `embedding_length / head_count` (the standard derivation).
63/// `head_count_kv` falls back to `head_count` for models without grouped-query
64/// attention.
65///
66/// # Arguments
67///
68/// * `metadata` — raw GGUF key/value map (see [`crate::domain::Model::metadata`]).
69/// * `architecture` — the model's architecture, used as the key prefix. When
70///   `None`, falls back to the `general.architecture` metadata key.
71///
72/// # Returns
73///
74/// `None` when the metadata lacks the layer/head counts needed to compute a
75/// meaningful figure (or carries non-numeric values) — callers should treat
76/// that as "unknown" and substitute their own conservative allowance rather
77/// than assuming zero.
78#[must_use]
79pub fn estimate_kv_elems_per_token<S: BuildHasher>(
80    metadata: &HashMap<String, String, S>,
81    architecture: Option<&str>,
82) -> Option<KvElemsPerToken> {
83    let arch = architecture
84        .map(str::to_owned)
85        .or_else(|| metadata.get("general.architecture").cloned())?;
86    let arch = arch.trim().to_ascii_lowercase();
87
88    let block_count = lookup(metadata, &arch, "block_count")?;
89    let head_count = lookup(metadata, &arch, "attention.head_count");
90    // Grouped-query attention shrinks the KV cache: prefer head_count_kv.
91    let head_count_kv = lookup(metadata, &arch, "attention.head_count_kv").or(head_count)?;
92
93    // Per-head K/V dimensions, explicit when present (some architectures use
94    // asymmetric or non-derivable head dims), else derived from the hidden size.
95    let derived_head_dim = || {
96        let embedding_length = lookup(metadata, &arch, "embedding_length")?;
97        let heads = head_count?;
98        (heads > 0).then(|| embedding_length / heads)
99    };
100    let key_length = lookup(metadata, &arch, "attention.key_length").or_else(derived_head_dim)?;
101    let value_length =
102        lookup(metadata, &arch, "attention.value_length").or_else(derived_head_dim)?;
103
104    if block_count == 0 || head_count_kv == 0 {
105        return None;
106    }
107
108    let per_head = block_count.saturating_mul(head_count_kv);
109    Some(KvElemsPerToken {
110        k: per_head.saturating_mul(key_length),
111        v: per_head.saturating_mul(value_length),
112    })
113}
114
115/// Convert per-token K/V element counts to bytes at the given cache types.
116#[must_use]
117pub const fn kv_bytes_per_token(elems: KvElemsPerToken, k: KvCacheType, v: KvCacheType) -> u64 {
118    k.bytes_for_elems(elems.k) + v.bytes_for_elems(elems.v)
119}
120
121/// Estimate total KV cache bytes for a given context size.
122///
123/// Convenience wrapper taking an already-computed bytes-per-token figure
124/// (see [`kv_bytes_per_token`]); saturating so an absurd context size can
125/// never overflow into a small (and therefore dangerously permissive) budget.
126#[must_use]
127pub const fn estimate_kv_bytes_for_context(kv_bytes_per_token: u64, context_size: u64) -> u64 {
128    kv_bytes_per_token.saturating_mul(context_size)
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    /// Qwen3-shaped metadata with grouped-query attention and explicit head dims.
136    fn qwen_metadata() -> HashMap<String, String> {
137        HashMap::from([
138            ("general.architecture".to_string(), "qwen3".to_string()),
139            ("qwen3.block_count".to_string(), "64".to_string()),
140            ("qwen3.attention.head_count".to_string(), "40".to_string()),
141            ("qwen3.attention.head_count_kv".to_string(), "8".to_string()),
142            ("qwen3.embedding_length".to_string(), "5120".to_string()),
143            ("qwen3.attention.key_length".to_string(), "128".to_string()),
144            (
145                "qwen3.attention.value_length".to_string(),
146                "128".to_string(),
147            ),
148        ])
149    }
150
151    /// Qwen3 fixture: 64 layers × 8 kv heads × 128 head dim = 65536 elems/side.
152    const QWEN_ELEMS: u64 = 64 * 8 * 128;
153
154    #[test]
155    fn computes_from_explicit_head_dims() {
156        let got = estimate_kv_elems_per_token(&qwen_metadata(), Some("qwen3"));
157        assert_eq!(
158            got,
159            Some(KvElemsPerToken {
160                k: QWEN_ELEMS,
161                v: QWEN_ELEMS
162            })
163        );
164    }
165
166    #[test]
167    fn architecture_falls_back_to_general_architecture_key() {
168        // No explicit architecture passed — read it from the metadata itself.
169        let got = estimate_kv_elems_per_token(&qwen_metadata(), None);
170        assert_eq!(
171            got,
172            Some(KvElemsPerToken {
173                k: QWEN_ELEMS,
174                v: QWEN_ELEMS
175            })
176        );
177    }
178
179    #[test]
180    fn architecture_lookup_is_case_insensitive() {
181        let got = estimate_kv_elems_per_token(&qwen_metadata(), Some("QWEN3"));
182        assert_eq!(
183            got,
184            Some(KvElemsPerToken {
185                k: QWEN_ELEMS,
186                v: QWEN_ELEMS
187            })
188        );
189    }
190
191    #[test]
192    fn derives_head_dim_from_embedding_length_when_absent() {
193        let mut md = qwen_metadata();
194        md.remove("qwen3.attention.key_length");
195        md.remove("qwen3.attention.value_length");
196        // head_dim = 5120 / 40 = 128, so the result matches the explicit case.
197        let got = estimate_kv_elems_per_token(&md, Some("qwen3"));
198        assert_eq!(
199            got,
200            Some(KvElemsPerToken {
201                k: QWEN_ELEMS,
202                v: QWEN_ELEMS
203            })
204        );
205    }
206
207    /// Without GQA metadata the full head count is the KV head count — a much
208    /// larger cache, which the estimate must reflect.
209    #[test]
210    fn falls_back_to_head_count_without_gqa() {
211        let mut md = qwen_metadata();
212        md.remove("qwen3.attention.head_count_kv");
213        let got = estimate_kv_elems_per_token(&md, Some("qwen3"));
214        let expected = 64 * 40 * 128;
215        assert_eq!(
216            got,
217            Some(KvElemsPerToken {
218                k: expected,
219                v: expected
220            })
221        );
222    }
223
224    #[test]
225    fn none_when_block_count_missing() {
226        let mut md = qwen_metadata();
227        md.remove("qwen3.block_count");
228        assert_eq!(estimate_kv_elems_per_token(&md, Some("qwen3")), None);
229    }
230
231    #[test]
232    fn none_when_head_counts_missing() {
233        let mut md = qwen_metadata();
234        md.remove("qwen3.attention.head_count");
235        md.remove("qwen3.attention.head_count_kv");
236        assert_eq!(estimate_kv_elems_per_token(&md, Some("qwen3")), None);
237    }
238
239    /// Head dims are neither explicit nor derivable without `embedding_length`.
240    #[test]
241    fn none_when_head_dim_underivable() {
242        let mut md = qwen_metadata();
243        md.remove("qwen3.attention.key_length");
244        md.remove("qwen3.attention.value_length");
245        md.remove("qwen3.embedding_length");
246        assert_eq!(estimate_kv_elems_per_token(&md, Some("qwen3")), None);
247    }
248
249    #[test]
250    fn none_on_non_numeric_values() {
251        let mut md = qwen_metadata();
252        md.insert("qwen3.block_count".to_string(), "sixty-four".to_string());
253        assert_eq!(estimate_kv_elems_per_token(&md, Some("qwen3")), None);
254    }
255
256    #[test]
257    fn none_when_metadata_empty() {
258        assert_eq!(
259            estimate_kv_elems_per_token(&HashMap::new(), Some("llama")),
260            None
261        );
262        assert_eq!(estimate_kv_elems_per_token(&HashMap::new(), None), None);
263    }
264
265    /// A zero layer/head count would produce a nonsense zero-elem estimate,
266    /// which downstream would read as "KV is free" — reject it instead.
267    #[test]
268    fn none_on_degenerate_zero_counts() {
269        let mut md = qwen_metadata();
270        md.insert("qwen3.block_count".to_string(), "0".to_string());
271        assert_eq!(estimate_kv_elems_per_token(&md, Some("qwen3")), None);
272
273        let mut md = qwen_metadata();
274        md.insert("qwen3.attention.head_count_kv".to_string(), "0".to_string());
275        assert_eq!(estimate_kv_elems_per_token(&md, Some("qwen3")), None);
276    }
277
278    #[test]
279    fn unprefixed_keys_are_accepted_as_a_fallback() {
280        let md = HashMap::from([
281            ("block_count".to_string(), "32".to_string()),
282            ("attention.head_count".to_string(), "32".to_string()),
283            ("attention.head_count_kv".to_string(), "8".to_string()),
284            ("embedding_length".to_string(), "4096".to_string()),
285        ]);
286        // head_dim = 4096 / 32 = 128
287        let expected = 32 * 8 * 128;
288        assert_eq!(
289            estimate_kv_elems_per_token(&md, Some("llama")),
290            Some(KvElemsPerToken {
291                k: expected,
292                v: expected
293            })
294        );
295    }
296
297    // ── Elems -> bytes conversion ────────────────────────────────────────
298
299    #[test]
300    fn kv_bytes_per_token_at_f16_matches_the_old_formula() {
301        // f16 = 2 bytes/elem, both sides: (k + v) * 2, matching the original
302        // single-type formula this function replaced.
303        let elems = KvElemsPerToken {
304            k: QWEN_ELEMS,
305            v: QWEN_ELEMS,
306        };
307        let got = kv_bytes_per_token(elems, KvCacheType::F16, KvCacheType::F16);
308        assert_eq!(got, (QWEN_ELEMS + QWEN_ELEMS) * 2);
309    }
310
311    #[test]
312    fn kv_bytes_per_token_at_q8_0_is_smaller_than_f16() {
313        let elems = KvElemsPerToken {
314            k: QWEN_ELEMS,
315            v: QWEN_ELEMS,
316        };
317        let f16 = kv_bytes_per_token(elems, KvCacheType::F16, KvCacheType::F16);
318        let q8_0 = kv_bytes_per_token(elems, KvCacheType::Q8_0, KvCacheType::Q8_0);
319        assert!(q8_0 < f16);
320    }
321
322    #[test]
323    fn kv_bytes_per_token_supports_asymmetric_k_v_types() {
324        // q8_0 K with f16 V (sidesteps the Flash Attention requirement on
325        // quantized V) must sum each side's own type independently.
326        let elems = KvElemsPerToken {
327            k: QWEN_ELEMS,
328            v: QWEN_ELEMS,
329        };
330        let mixed = kv_bytes_per_token(elems, KvCacheType::Q8_0, KvCacheType::F16);
331        let expected = KvCacheType::Q8_0.bytes_for_elems(QWEN_ELEMS)
332            + KvCacheType::F16.bytes_for_elems(QWEN_ELEMS);
333        assert_eq!(mixed, expected);
334    }
335
336    #[test]
337    fn context_multiplication_saturates() {
338        assert_eq!(estimate_kv_bytes_for_context(1024, 100), 102_400);
339        assert_eq!(estimate_kv_bytes_for_context(u64::MAX, u64::MAX), u64::MAX);
340    }
341}