Skip to main content

gglib_core/domain/
kv_memory.rs

1//! Detection of partial-KV-memory architectures from GGUF metadata.
2//!
3//! Some model architectures do not retain the full token history in their KV
4//! memory: sliding-window attention (SWA) layers keep only a recent window,
5//! hybrid-attention models interleave SWA layers with full-attention layers,
6//! and recurrent (SSM/Mamba-family) models keep only a compressed state.
7//!
8//! This matters for llama-server's disk slot persistence
9//! (`/slots?action=save|restore`): the save path serializes only the sequence
10//! KV state and token list — **not** the server's context checkpoints — and
11//! the restore path clears the slot's checkpoint list. On a full-attention
12//! model that's fine (the KV state alone is sufficient to resume). On a
13//! partial-memory model, resuming from position `n_past` requires history the
14//! SWA/recurrent layers no longer hold, which llama-server bridges with
15//! context checkpoints; with the checkpoint list empty after a disk restore,
16//! it falls back to `n_past = 0` and reprocesses the *entire* prompt. A disk
17//! "restore" on such a model therefore costs a full re-prefill — worse than
18//! useless, since the in-RAM prompt cache (`--cache-ram`), which *does* carry
19//! checkpoints, would have resumed cheaply had the slot not been pre-filled
20//! by the restore.
21//!
22//! Inputs come from the raw GGUF key/value map that `gglib-gguf` copies
23//! verbatim into [`crate::domain::Model::metadata`] (see
24//! [`crate::domain::estimate_kv_elems_per_token`] for the same pattern).
25//!
26//! Detection is deliberately *sensitive*: a false positive merely forgoes the
27//! disk-cache layer (the in-RAM cache still works), while a false negative
28//! silently costs minutes of TTFT per restore. Some older GGUFs carry a
29//! `sliding_window` key the runtime ignores; treating them as partial is the
30//! safe direction.
31
32use std::collections::HashMap;
33use std::hash::BuildHasher;
34
35/// Look up an architecture-prefixed GGUF key (`{arch}.{suffix}`), falling back
36/// to the bare suffix for the occasional file that omits the prefix.
37fn lookup_raw<'m, S: BuildHasher>(
38    metadata: &'m HashMap<String, String, S>,
39    arch: &str,
40    suffix: &str,
41) -> Option<&'m str> {
42    metadata
43        .get(&format!("{arch}.{suffix}"))
44        .or_else(|| metadata.get(suffix))
45        .map(|v| v.trim())
46}
47
48/// Numeric variant of [`lookup_raw`].
49fn lookup_u64<S: BuildHasher>(
50    metadata: &HashMap<String, String, S>,
51    arch: &str,
52    suffix: &str,
53) -> Option<u64> {
54    lookup_raw(metadata, arch, suffix).and_then(|v| v.parse::<u64>().ok())
55}
56
57/// Whether the model's KV memory retains only part of the token history
58/// (sliding-window, hybrid, or recurrent attention).
59///
60/// Checks, in order:
61///
62/// * `{arch}.full_attention_interval` > 1 — hybrid interleaved attention
63///   (e.g. `qwen35.full_attention_interval = 4`: every 4th layer is full
64///   attention, the rest sliding-window).
65/// * `{arch}.attention.sliding_window` > 0 — sliding-window attention
66///   (Gemma 2/3, Cohere 2, GPT-OSS, …).
67/// * `{arch}.ssm.conv_kernel` present — recurrent / hybrid-recurrent state
68///   (Mamba, Jamba, Granite-H, Falcon-H, …), which is inherently partial.
69///
70/// # Arguments
71///
72/// * `metadata` — raw GGUF key/value map (see [`crate::domain::Model::metadata`]).
73/// * `architecture` — the model's architecture, used as the key prefix. When
74///   `None`, falls back to the `general.architecture` metadata key.
75///
76/// # Returns
77///
78/// `false` when the metadata carries none of the marker keys — including when
79/// the architecture can't be determined at all, since an unprefixed lookup
80/// still runs and full-attention is the common case.
81#[must_use]
82pub fn kv_memory_is_partial<S: BuildHasher>(
83    metadata: &HashMap<String, String, S>,
84    architecture: Option<&str>,
85) -> bool {
86    let arch = architecture
87        .map(str::to_owned)
88        .or_else(|| metadata.get("general.architecture").cloned())
89        .unwrap_or_default();
90    let arch = arch.trim().to_ascii_lowercase();
91
92    if lookup_u64(metadata, &arch, "full_attention_interval").is_some_and(|v| v > 1) {
93        return true;
94    }
95    if lookup_u64(metadata, &arch, "attention.sliding_window").is_some_and(|v| v > 0) {
96        return true;
97    }
98    if lookup_raw(metadata, &arch, "ssm.conv_kernel").is_some() {
99        return true;
100    }
101
102    false
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    /// Qwen3.6-shaped metadata: hybrid attention, every 4th layer full.
110    fn qwen36_metadata() -> HashMap<String, String> {
111        HashMap::from([
112            ("general.architecture".to_string(), "qwen35".to_string()),
113            (
114                "qwen35.full_attention_interval".to_string(),
115                "4".to_string(),
116            ),
117            ("qwen35.attention.head_count".to_string(), "24".to_string()),
118        ])
119    }
120
121    #[test]
122    fn detects_hybrid_full_attention_interval() {
123        assert!(kv_memory_is_partial(&qwen36_metadata(), Some("qwen35")));
124    }
125
126    #[test]
127    fn architecture_falls_back_to_general_architecture_key() {
128        assert!(kv_memory_is_partial(&qwen36_metadata(), None));
129    }
130
131    #[test]
132    fn architecture_lookup_is_case_insensitive() {
133        assert!(kv_memory_is_partial(&qwen36_metadata(), Some("QWEN35")));
134    }
135
136    /// Interval of 1 means every layer is full attention — not partial.
137    #[test]
138    fn interval_of_one_is_full_attention() {
139        let mut md = qwen36_metadata();
140        md.insert(
141            "qwen35.full_attention_interval".to_string(),
142            "1".to_string(),
143        );
144        assert!(!kv_memory_is_partial(&md, Some("qwen35")));
145    }
146
147    #[test]
148    fn detects_sliding_window_attention() {
149        let md = HashMap::from([
150            ("general.architecture".to_string(), "gemma3".to_string()),
151            (
152                "gemma3.attention.sliding_window".to_string(),
153                "1024".to_string(),
154            ),
155        ]);
156        assert!(kv_memory_is_partial(&md, Some("gemma3")));
157    }
158
159    /// A zero-size window means SWA is effectively disabled.
160    #[test]
161    fn zero_sliding_window_is_full_attention() {
162        let md = HashMap::from([(
163            "gemma3.attention.sliding_window".to_string(),
164            "0".to_string(),
165        )]);
166        assert!(!kv_memory_is_partial(&md, Some("gemma3")));
167    }
168
169    #[test]
170    fn detects_recurrent_ssm_state() {
171        let md = HashMap::from([
172            ("general.architecture".to_string(), "mamba".to_string()),
173            ("mamba.ssm.conv_kernel".to_string(), "4".to_string()),
174        ]);
175        assert!(kv_memory_is_partial(&md, Some("mamba")));
176    }
177
178    /// Plain full-attention transformer metadata (the Qwen3 fixture shape
179    /// from `kv_estimate`) must not trip the detector.
180    #[test]
181    fn full_attention_model_is_not_partial() {
182        let md = HashMap::from([
183            ("general.architecture".to_string(), "qwen3".to_string()),
184            ("qwen3.block_count".to_string(), "64".to_string()),
185            ("qwen3.attention.head_count".to_string(), "40".to_string()),
186            ("qwen3.attention.head_count_kv".to_string(), "8".to_string()),
187        ]);
188        assert!(!kv_memory_is_partial(&md, Some("qwen3")));
189    }
190
191    #[test]
192    fn empty_metadata_is_not_partial() {
193        assert!(!kv_memory_is_partial(&HashMap::new(), Some("llama")));
194        assert!(!kv_memory_is_partial(&HashMap::new(), None));
195    }
196
197    #[test]
198    fn unprefixed_keys_are_accepted_as_a_fallback() {
199        let md = HashMap::from([("attention.sliding_window".to_string(), "512".to_string())]);
200        assert!(kv_memory_is_partial(&md, Some("gemma2")));
201    }
202
203    #[test]
204    fn non_numeric_marker_values_are_ignored() {
205        let md = HashMap::from([(
206            "qwen35.full_attention_interval".to_string(),
207            "four".to_string(),
208        )]);
209        assert!(!kv_memory_is_partial(&md, Some("qwen35")));
210    }
211}