Skip to main content

gglib_core/domain/
kv_memory.rs

1//! The shape of a model's KV memory, read from GGUF metadata: whether it
2//! retains the full token history, and how many layers hold a cache at all.
3//!
4//! Some model architectures do not retain the full token history in their KV
5//! memory: sliding-window attention (SWA) layers keep only a recent window,
6//! hybrid-attention models interleave SWA layers with full-attention layers,
7//! and recurrent (SSM/Mamba-family) models keep only a compressed state.
8//!
9//! This matters for llama-server's disk slot persistence
10//! (`/slots?action=save|restore`): the save path serializes only the sequence
11//! KV state and token list — **not** the server's context checkpoints — and
12//! the restore path clears the slot's checkpoint list. On a full-attention
13//! model that's fine (the KV state alone is sufficient to resume). On a
14//! partial-memory model, resuming from position `n_past` requires history the
15//! SWA/recurrent layers no longer hold, which llama-server bridges with
16//! context checkpoints; with the checkpoint list empty after a disk restore,
17//! it falls back to `n_past = 0` and reprocesses the *entire* prompt. A disk
18//! "restore" on such a model therefore costs a full re-prefill — worse than
19//! useless, since the in-RAM prompt cache (`--cache-ram`), which *does* carry
20//! checkpoints, would have resumed cheaply had the slot not been pre-filled
21//! by the restore.
22//!
23//! Inputs come from the raw GGUF key/value map that `gglib-gguf` copies
24//! verbatim into [`crate::domain::Model::metadata`] (see
25//! [`crate::domain::estimate_kv_elems_per_token`] for the same pattern).
26//!
27//! Detection is deliberately *sensitive*: a false positive merely forgoes the
28//! disk-cache layer (the in-RAM cache still works), while a false negative
29//! silently costs minutes of TTFT per restore. Some older GGUFs carry a
30//! `sliding_window` key the runtime ignores; treating them as partial is the
31//! safe direction.
32//!
33//! [`kv_cache_layer_count`] answers the quantitative half of the same
34//! question — how many layers hold a per-token cache — and is what
35//! [`crate::domain::estimate_kv_elems_per_token`] sizes its budget from.
36
37use std::collections::HashMap;
38use std::hash::BuildHasher;
39
40/// Look up an architecture-prefixed GGUF key (`{arch}.{suffix}`), falling back
41/// to the bare suffix for the occasional file that omits the prefix.
42fn lookup_raw<'m, S: BuildHasher>(
43    metadata: &'m HashMap<String, String, S>,
44    arch: &str,
45    suffix: &str,
46) -> Option<&'m str> {
47    metadata
48        .get(&format!("{arch}.{suffix}"))
49        .or_else(|| metadata.get(suffix))
50        .map(|v| v.trim())
51}
52
53/// Numeric variant of [`lookup_raw`].
54fn lookup_u64<S: BuildHasher>(
55    metadata: &HashMap<String, String, S>,
56    arch: &str,
57    suffix: &str,
58) -> Option<u64> {
59    lookup_raw(metadata, arch, suffix).and_then(|v| v.parse::<u64>().ok())
60}
61
62/// Resolve the GGUF key prefix: the caller's architecture when it knows one,
63/// else the file's own `general.architecture`, normalised for lookup. Empty
64/// when neither is available, which is harmless — [`lookup_raw`]'s unprefixed
65/// fallback still runs.
66fn resolve_arch<S: BuildHasher>(
67    metadata: &HashMap<String, String, S>,
68    architecture: Option<&str>,
69) -> String {
70    architecture
71        .map(str::to_owned)
72        .or_else(|| metadata.get("general.architecture").cloned())
73        .unwrap_or_default()
74        .trim()
75        .to_ascii_lowercase()
76}
77
78/// Whether the model's KV memory retains only part of the token history
79/// (sliding-window, hybrid, or recurrent attention).
80///
81/// Checks, in order:
82///
83/// * `{arch}.full_attention_interval` > 1 — hybrid interleaved attention
84///   (e.g. `qwen35.full_attention_interval = 4`: every 4th layer is full
85///   attention, the rest sliding-window).
86/// * `{arch}.attention.sliding_window` > 0 — sliding-window attention
87///   (Gemma 2/3, Cohere 2, GPT-OSS, …).
88/// * `{arch}.ssm.conv_kernel` present — recurrent / hybrid-recurrent state
89///   (Mamba, Jamba, Granite-H, Falcon-H, …), which is inherently partial.
90///
91/// # Arguments
92///
93/// * `metadata` — raw GGUF key/value map (see [`crate::domain::Model::metadata`]).
94/// * `architecture` — the model's architecture, used as the key prefix. When
95///   `None`, falls back to the `general.architecture` metadata key.
96///
97/// # Returns
98///
99/// `false` when the metadata carries none of the marker keys — including when
100/// the architecture can't be determined at all, since an unprefixed lookup
101/// still runs and full-attention is the common case.
102#[must_use]
103pub fn kv_memory_is_partial<S: BuildHasher>(
104    metadata: &HashMap<String, String, S>,
105    architecture: Option<&str>,
106) -> bool {
107    let arch = resolve_arch(metadata, architecture);
108
109    if lookup_u64(metadata, &arch, "full_attention_interval").is_some_and(|v| v > 1) {
110        return true;
111    }
112    if lookup_u64(metadata, &arch, "attention.sliding_window").is_some_and(|v| v > 0) {
113        return true;
114    }
115    if lookup_raw(metadata, &arch, "ssm.conv_kernel").is_some() {
116        return true;
117    }
118
119    false
120}
121
122/// How many of the model's layers hold a per-token KV cache.
123///
124/// On a plain transformer that is every layer, so this is `{arch}.block_count`
125/// unchanged. Hybrid-attention architectures interleave two kinds of layer:
126/// `{arch}.full_attention_interval = 4` means every 4th layer is full
127/// attention, so of Qwen3.8's 64 blocks only 16 keep a KV cache.
128///
129/// The other 48 are linear/SSM layers, and they contribute **zero** here on
130/// purpose. Their state is a fixed-size summary — constant in context length,
131/// not proportional to it — so its cost belongs in a weights-side allowance,
132/// not in a per-token figure. A per-token figure is a slope: whatever it
133/// carries gets multiplied by the context size. Folding those layers in
134/// therefore over-counts them by the entire context — 256 KiB/token instead of
135/// 64, i.e. 64 GiB rather than 16 GiB at Qwen3.8's 262144-token context.
136///
137/// Division rounds **up**. When the interval does not divide the block count
138/// evenly the metadata alone cannot say which side the remainder falls on, and
139/// counting one layer too many over-states the budget — the safe direction for
140/// a figure the launcher plans memory against.
141///
142/// # Arguments
143///
144/// * `metadata` — raw GGUF key/value map (see [`crate::domain::Model::metadata`]).
145/// * `architecture` — the model's architecture, used as the key prefix. When
146///   `None`, falls back to the `general.architecture` metadata key.
147///
148/// # Returns
149///
150/// `None` when `block_count` is absent or non-numeric, so the "we don't know"
151/// signal keeps travelling rather than collapsing into a zero that would read
152/// as "KV is free" — [`crate::domain::estimate_kv_elems_per_token`] propagates
153/// it with `?`. An absent, non-numeric, or `1` interval leaves `block_count`
154/// untouched, so every full-attention model is bit-identical to before this
155/// function existed.
156#[must_use]
157pub fn kv_cache_layer_count<S: BuildHasher>(
158    metadata: &HashMap<String, String, S>,
159    architecture: Option<&str>,
160) -> Option<u64> {
161    let arch = resolve_arch(metadata, architecture);
162    let block_count = lookup_u64(metadata, &arch, "block_count")?;
163
164    // An interval of 1 (or none at all) means every layer is full attention,
165    // so there is nothing to divide out.
166    match lookup_u64(metadata, &arch, "full_attention_interval") {
167        Some(interval) if interval > 1 => Some(block_count.div_ceil(interval)),
168        _ => Some(block_count),
169    }
170}
171
172#[cfg(test)]
173#[path = "kv_memory_tests.rs"]
174mod kv_memory_tests;