Skip to main content

gglib_core/domain/
residency.rs

1//! Whether a second model may stay resident in VRAM alongside the first.
2//!
3//! One model at a time is the safe default, not the good one. An embedding
4//! model is often two orders of magnitude smaller than the chat model it keeps
5//! displacing: `nomic-embed-text` is ~275 MB against a 9 GB coder model, and on
6//! a 16–24 GB card there is room for both several times over. Every swap
7//! between them costs a full process teardown, weight reload, and prompt
8//! re-prefill — paid twice per alternation, for want of a few hundred megabytes.
9//!
10//! This module answers the one question that decides whether that cost is paid:
11//! *given what is free right now, can this candidate simply stay loaded too?*
12//!
13//! It is deliberately pure. The live VRAM figure is supplied by the caller
14//! (`gglib_runtime::system::free_gpu_memory_bytes`), so the arithmetic is
15//! testable on a machine with no GPU at all — which is every CI runner this
16//! workspace has.
17//!
18//! ## What it does not do
19//!
20//! It does not decide *which* model is a good co-resident, and it consults no
21//! tags. A model earns the second slot by fitting, full stop. That keeps the
22//! rule honest: a 275 MB embedding model and a 900 MB title generator are the
23//! same problem, and a 7B chat model is refused by the ceiling rather than by a
24//! category judgement that would be wrong as often as it was right.
25//!
26//! It also does not model host RAM. The secondary's `--cache-ram` budget is the
27//! caller's problem (see `gglib_runtime`'s residency module), because that
28//! figure depends on what the *primary* already took.
29
30use crate::cache_config::KvCacheType;
31use crate::domain::kv_estimate::{
32    KvElemsPerToken, estimate_kv_bytes_for_context, kv_bytes_per_token,
33};
34
35/// Fraction of free VRAM a co-resident candidate is allowed to claim.
36///
37/// The remainder absorbs what this estimate deliberately does not model: the
38/// compute buffer llama-server allocates per batch, allocator fragmentation,
39/// and whatever the desktop compositor takes while the process is starting.
40/// The same 0.9 that
41/// [`recommendation`](crate::domain::recommendation) sizes first-run model
42/// suggestions against — one convention for "do not fill the card to the brim",
43/// not two.
44pub const RESIDENCY_UTILISATION: f64 = 0.9;
45
46/// Hard ceiling on a co-resident model's footprint, regardless of free VRAM.
47///
48/// Free VRAM alone is not a sufficient test. A 48 GB card running a 7 B model
49/// has room to co-load a second 7 B model, and doing so would be wrong: the
50/// second slot exists to keep *small auxiliary* models out of the swap path,
51/// not to become a general-purpose multi-model server. Two large models sharing
52/// a card contend for bandwidth and compute buffers in ways this estimate does
53/// not capture, and the request queue already handles that case correctly by
54/// swapping.
55///
56/// 2 GiB comfortably covers every embedding model, reranker, and small
57/// title/summary generator in common use, and excludes essentially every
58/// instruct model worth chatting with.
59pub const SECONDARY_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024;
60
61/// What one resident model is expected to occupy in VRAM.
62///
63/// Weights and KV are tracked separately rather than pre-summed because they
64/// come from different places and fail differently: weights are a measured
65/// file size, KV is an estimate from GGUF metadata that may be missing
66/// entirely. A caller reporting a refusal wants to say which half was the
67/// problem.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct SlotFootprint {
70    /// Model weights on disk, summed across shards. `0` when unknown.
71    pub weights_bytes: u64,
72    /// KV cache at the context this launch will use.
73    pub kv_bytes: u64,
74}
75
76impl SlotFootprint {
77    /// Assemble a footprint from a model's launch inputs.
78    ///
79    /// `kv_elems_per_token` is `None` for models whose GGUF metadata does not
80    /// carry the layer/head counts (see
81    /// [`estimate_kv_elems_per_token`](crate::domain::estimate_kv_elems_per_token)).
82    /// That yields a footprint of weights alone, which understates the true
83    /// cost — so [`decide_secondary_slot`] treats an unknown KV as
84    /// disqualifying rather than free. See
85    /// [`SecondarySlotDecision::RefuseUnknownFootprint`].
86    #[must_use]
87    pub const fn new(
88        weights_bytes: u64,
89        kv_elems_per_token: Option<KvElemsPerToken>,
90        cache_type_k: KvCacheType,
91        cache_type_v: KvCacheType,
92        context_size: u64,
93    ) -> Option<Self> {
94        // `const fn` cannot use `?` on Option in a match arm position here, so
95        // this is spelled out.
96        match kv_elems_per_token {
97            Some(elems) => {
98                let per_token = kv_bytes_per_token(elems, cache_type_k, cache_type_v);
99                Some(Self {
100                    weights_bytes,
101                    kv_bytes: estimate_kv_bytes_for_context(per_token, context_size),
102                })
103            }
104            None => None,
105        }
106    }
107
108    /// Total VRAM this model is expected to occupy.
109    #[must_use]
110    pub const fn total(&self) -> u64 {
111        self.weights_bytes.saturating_add(self.kv_bytes)
112    }
113}
114
115/// Whether a candidate may take the second resident slot, and why not when it
116/// may not.
117///
118/// A reason-carrying enum rather than a `bool` for the same reason
119/// [`CacheRamHealth`](crate::domain::CacheRamHealth) is one: the dashboard has
120/// to explain an empty second slot to a user who can see they have 12 GB free,
121/// and "no" on its own is the least useful answer available.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum SecondarySlotDecision {
124    /// The candidate fits with headroom to spare and may be co-loaded.
125    Grant {
126        /// What the candidate is expected to occupy.
127        footprint_bytes: u64,
128        /// Free VRAM left over after it, before the utilisation margin.
129        headroom_bytes: u64,
130    },
131    /// The candidate exceeds [`SECONDARY_MAX_BYTES`]. The second slot is for
132    /// auxiliary models; this one belongs in the swap path.
133    RefuseTooLarge {
134        /// What the candidate is expected to occupy.
135        footprint_bytes: u64,
136        /// The ceiling it exceeded.
137        ceiling_bytes: u64,
138    },
139    /// Small enough in principle, but there is not enough free VRAM right now.
140    RefuseNoHeadroom {
141        /// What the candidate is expected to occupy.
142        footprint_bytes: u64,
143        /// Free VRAM at the moment of the decision.
144        free_bytes: u64,
145    },
146    /// The candidate's KV footprint could not be estimated, so its true cost is
147    /// unknown. Refused rather than guessed: an under-estimate co-loads a model
148    /// that then OOMs the primary mid-generation.
149    RefuseUnknownFootprint,
150    /// gglib cannot read this machine's free VRAM — every non-NVIDIA,
151    /// non-Apple-Silicon GPU, and every CPU-only host. Single-slot behaviour is
152    /// preserved exactly.
153    RefuseUnknownBudget,
154}
155
156impl SecondarySlotDecision {
157    /// Whether the candidate may be co-loaded.
158    #[must_use]
159    pub const fn is_grant(&self) -> bool {
160        matches!(self, Self::Grant { .. })
161    }
162
163    /// Stable machine-readable label, for styling and for telemetry that should
164    /// not have to parse prose.
165    #[must_use]
166    pub const fn label(&self) -> &'static str {
167        match self {
168            Self::Grant { .. } => "grant",
169            Self::RefuseTooLarge { .. } => "too_large",
170            Self::RefuseNoHeadroom { .. } => "no_headroom",
171            Self::RefuseUnknownFootprint => "unknown_footprint",
172            Self::RefuseUnknownBudget => "unknown_budget",
173        }
174    }
175}
176
177/// Decide whether `candidate` may stay resident alongside what is already
178/// loaded.
179///
180/// `free_vram_bytes` is the *live* figure — what is actually free on the device
181/// now, with the primary model already loaded — not the card's nominal
182/// capacity. `None` means gglib could not read it, which is a refusal rather
183/// than an assumption in either direction.
184///
185/// The candidate must clear both tests: the absolute ceiling
186/// ([`SECONDARY_MAX_BYTES`]) and the live budget scaled by
187/// [`RESIDENCY_UTILISATION`]. The ceiling is checked first so a large model on
188/// a large card reports the reason that will still be true tomorrow.
189#[must_use]
190pub fn decide_secondary_slot(
191    candidate: Option<SlotFootprint>,
192    free_vram_bytes: Option<u64>,
193) -> SecondarySlotDecision {
194    let Some(candidate) = candidate else {
195        return SecondarySlotDecision::RefuseUnknownFootprint;
196    };
197    let footprint_bytes = candidate.total();
198
199    if footprint_bytes > SECONDARY_MAX_BYTES {
200        return SecondarySlotDecision::RefuseTooLarge {
201            footprint_bytes,
202            ceiling_bytes: SECONDARY_MAX_BYTES,
203        };
204    }
205
206    let Some(free_bytes) = free_vram_bytes else {
207        return SecondarySlotDecision::RefuseUnknownBudget;
208    };
209
210    #[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
211    #[allow(clippy::cast_possible_truncation)]
212    let usable = (free_bytes as f64 * RESIDENCY_UTILISATION) as u64;
213
214    if footprint_bytes > usable {
215        return SecondarySlotDecision::RefuseNoHeadroom {
216            footprint_bytes,
217            free_bytes,
218        };
219    }
220
221    SecondarySlotDecision::Grant {
222        footprint_bytes,
223        headroom_bytes: free_bytes.saturating_sub(footprint_bytes),
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    const GIB: u64 = 1024 * 1024 * 1024;
232    const MIB: u64 = 1024 * 1024;
233
234    /// A `nomic-embed-text`-shaped candidate: tiny weights, tiny KV.
235    fn embedder() -> SlotFootprint {
236        SlotFootprint {
237            weights_bytes: 275 * MIB,
238            kv_bytes: 32 * MIB,
239        }
240    }
241
242    #[test]
243    fn a_small_model_with_room_to_spare_is_granted() {
244        let decision = decide_secondary_slot(Some(embedder()), Some(8 * GIB));
245
246        match decision {
247            SecondarySlotDecision::Grant {
248                footprint_bytes,
249                headroom_bytes,
250            } => {
251                assert_eq!(footprint_bytes, 307 * MIB);
252                assert_eq!(headroom_bytes, 8 * GIB - 307 * MIB);
253            }
254            other => panic!("expected a grant, got {other:?}"),
255        }
256        assert!(decision.is_grant());
257        assert_eq!(decision.label(), "grant");
258    }
259
260    /// The ceiling is absolute: free VRAM cannot buy a large model into the
261    /// second slot, because the second slot is not what large models are for.
262    #[test]
263    fn a_large_model_is_refused_even_on_a_card_with_room() {
264        let big = SlotFootprint {
265            weights_bytes: 9 * GIB,
266            kv_bytes: GIB,
267        };
268
269        match decide_secondary_slot(Some(big), Some(40 * GIB)) {
270            SecondarySlotDecision::RefuseTooLarge {
271                footprint_bytes,
272                ceiling_bytes,
273            } => {
274                assert_eq!(footprint_bytes, 10 * GIB);
275                assert_eq!(ceiling_bytes, SECONDARY_MAX_BYTES);
276            }
277            other => panic!("expected RefuseTooLarge, got {other:?}"),
278        }
279    }
280
281    /// Ordering matters: a model that is both over the ceiling *and* over
282    /// budget reports the ceiling, because that reason survives the card
283    /// emptying out.
284    #[test]
285    fn the_ceiling_is_reported_before_the_live_budget() {
286        let big = SlotFootprint {
287            weights_bytes: 9 * GIB,
288            kv_bytes: 0,
289        };
290
291        assert!(matches!(
292            decide_secondary_slot(Some(big), Some(128 * MIB)),
293            SecondarySlotDecision::RefuseTooLarge { .. }
294        ));
295    }
296
297    #[test]
298    fn a_small_model_is_refused_when_the_card_is_nearly_full() {
299        match decide_secondary_slot(Some(embedder()), Some(200 * MIB)) {
300            SecondarySlotDecision::RefuseNoHeadroom {
301                footprint_bytes,
302                free_bytes,
303            } => {
304                assert_eq!(footprint_bytes, 307 * MIB);
305                assert_eq!(free_bytes, 200 * MIB);
306            }
307            other => panic!("expected RefuseNoHeadroom, got {other:?}"),
308        }
309    }
310
311    /// The utilisation margin is the point: a candidate that fits the raw free
312    /// figure but not the scaled one must be refused, or it "almost fits" —
313    /// the slowest possible outcome, since llama.cpp spills to host memory
314    /// rather than failing.
315    #[test]
316    fn a_candidate_that_only_fits_without_the_margin_is_refused() {
317        let footprint = SlotFootprint {
318            weights_bytes: 950 * MIB,
319            kv_bytes: 0,
320        };
321        // 950 MiB fits inside 1000 MiB free, but not inside 0.9 x 1000 = 900.
322        assert!(matches!(
323            decide_secondary_slot(Some(footprint), Some(1000 * MIB)),
324            SecondarySlotDecision::RefuseNoHeadroom { .. }
325        ));
326    }
327
328    /// The boundary itself is inclusive — exactly the usable budget is a fit.
329    #[test]
330    fn a_candidate_at_exactly_the_usable_budget_is_granted() {
331        let free = 1000 * MIB;
332        #[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
333        #[allow(clippy::cast_possible_truncation)]
334        let usable = (free as f64 * RESIDENCY_UTILISATION) as u64;
335        let footprint = SlotFootprint {
336            weights_bytes: usable,
337            kv_bytes: 0,
338        };
339
340        assert!(decide_secondary_slot(Some(footprint), Some(free)).is_grant());
341    }
342
343    /// Every Vulkan-only, AMD, Intel, and CPU-only host lands here. Refusing
344    /// keeps single-slot behaviour byte-for-byte identical to before M9.
345    #[test]
346    fn an_unreadable_vram_budget_refuses_rather_than_assuming() {
347        assert_eq!(
348            decide_secondary_slot(Some(embedder()), None),
349            SecondarySlotDecision::RefuseUnknownBudget
350        );
351    }
352
353    /// A model whose KV shape could not be estimated has an unknown true cost.
354    /// Treating the missing half as zero would co-load it against a budget it
355    /// does not actually fit.
356    #[test]
357    fn an_unknown_footprint_refuses_rather_than_undercounting() {
358        assert_eq!(
359            decide_secondary_slot(None, Some(64 * GIB)),
360            SecondarySlotDecision::RefuseUnknownFootprint
361        );
362    }
363
364    // ── SlotFootprint::new ────────────────────────────────────────────────
365
366    #[test]
367    fn footprint_sums_weights_and_kv_at_the_launch_context() {
368        let elems = KvElemsPerToken { k: 1024, v: 1024 };
369        let footprint = SlotFootprint::new(
370            500 * MIB,
371            Some(elems),
372            KvCacheType::Q8_0,
373            KvCacheType::Q8_0,
374            8192,
375        )
376        .expect("known KV shape yields a footprint");
377
378        let per_token = kv_bytes_per_token(elems, KvCacheType::Q8_0, KvCacheType::Q8_0);
379        assert_eq!(footprint.weights_bytes, 500 * MIB);
380        assert_eq!(
381            footprint.kv_bytes,
382            estimate_kv_bytes_for_context(per_token, 8192)
383        );
384        assert_eq!(footprint.total(), 500 * MIB + footprint.kv_bytes);
385    }
386
387    #[test]
388    fn footprint_is_unknown_when_the_kv_shape_is() {
389        assert_eq!(
390            SlotFootprint::new(500 * MIB, None, KvCacheType::Q8_0, KvCacheType::Q8_0, 8192),
391            None
392        );
393    }
394
395    /// Quantized KV is what the runtime actually launches with, so a footprint
396    /// computed against `f16` would over-reserve by roughly the KV cache again
397    /// and refuse co-loads that would have fitted.
398    #[test]
399    fn kv_cache_type_changes_the_footprint() {
400        let elems = KvElemsPerToken { k: 4096, v: 4096 };
401        let quantized =
402            SlotFootprint::new(0, Some(elems), KvCacheType::Q8_0, KvCacheType::Q8_0, 32_768)
403                .unwrap();
404        let full =
405            SlotFootprint::new(0, Some(elems), KvCacheType::F16, KvCacheType::F16, 32_768).unwrap();
406
407        assert!(
408            quantized.total() < full.total(),
409            "q8_0 {} should be smaller than f16 {}",
410            quantized.total(),
411            full.total()
412        );
413    }
414}