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(crate) 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/// The interaction between a fitted context and this slot's hard ceiling.
228#[cfg(test)]
229#[path = "residency_fit_tests.rs"]
230mod fit_tests;
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 const GIB: u64 = 1024 * 1024 * 1024;
237 const MIB: u64 = 1024 * 1024;
238
239 /// A `nomic-embed-text`-shaped candidate: tiny weights, tiny KV.
240 fn embedder() -> SlotFootprint {
241 SlotFootprint {
242 weights_bytes: 275 * MIB,
243 kv_bytes: 32 * MIB,
244 }
245 }
246
247 #[test]
248 fn a_small_model_with_room_to_spare_is_granted() {
249 let decision = decide_secondary_slot(Some(embedder()), Some(8 * GIB));
250
251 match decision {
252 SecondarySlotDecision::Grant {
253 footprint_bytes,
254 headroom_bytes,
255 } => {
256 assert_eq!(footprint_bytes, 307 * MIB);
257 assert_eq!(headroom_bytes, 8 * GIB - 307 * MIB);
258 }
259 other => panic!("expected a grant, got {other:?}"),
260 }
261 assert!(decision.is_grant());
262 assert_eq!(decision.label(), "grant");
263 }
264
265 /// The ceiling is absolute: free VRAM cannot buy a large model into the
266 /// second slot, because the second slot is not what large models are for.
267 #[test]
268 fn a_large_model_is_refused_even_on_a_card_with_room() {
269 let big = SlotFootprint {
270 weights_bytes: 9 * GIB,
271 kv_bytes: GIB,
272 };
273
274 match decide_secondary_slot(Some(big), Some(40 * GIB)) {
275 SecondarySlotDecision::RefuseTooLarge {
276 footprint_bytes,
277 ceiling_bytes,
278 } => {
279 assert_eq!(footprint_bytes, 10 * GIB);
280 assert_eq!(ceiling_bytes, SECONDARY_MAX_BYTES);
281 }
282 other => panic!("expected RefuseTooLarge, got {other:?}"),
283 }
284 }
285
286 /// Ordering matters: a model that is both over the ceiling *and* over
287 /// budget reports the ceiling, because that reason survives the card
288 /// emptying out.
289 #[test]
290 fn the_ceiling_is_reported_before_the_live_budget() {
291 let big = SlotFootprint {
292 weights_bytes: 9 * GIB,
293 kv_bytes: 0,
294 };
295
296 assert!(matches!(
297 decide_secondary_slot(Some(big), Some(128 * MIB)),
298 SecondarySlotDecision::RefuseTooLarge { .. }
299 ));
300 }
301
302 #[test]
303 fn a_small_model_is_refused_when_the_card_is_nearly_full() {
304 match decide_secondary_slot(Some(embedder()), Some(200 * MIB)) {
305 SecondarySlotDecision::RefuseNoHeadroom {
306 footprint_bytes,
307 free_bytes,
308 } => {
309 assert_eq!(footprint_bytes, 307 * MIB);
310 assert_eq!(free_bytes, 200 * MIB);
311 }
312 other => panic!("expected RefuseNoHeadroom, got {other:?}"),
313 }
314 }
315
316 /// The utilisation margin is the point: a candidate that fits the raw free
317 /// figure but not the scaled one must be refused, or it "almost fits" —
318 /// the slowest possible outcome, since llama.cpp spills to host memory
319 /// rather than failing.
320 #[test]
321 fn a_candidate_that_only_fits_without_the_margin_is_refused() {
322 let footprint = SlotFootprint {
323 weights_bytes: 950 * MIB,
324 kv_bytes: 0,
325 };
326 // 950 MiB fits inside 1000 MiB free, but not inside 0.9 x 1000 = 900.
327 assert!(matches!(
328 decide_secondary_slot(Some(footprint), Some(1000 * MIB)),
329 SecondarySlotDecision::RefuseNoHeadroom { .. }
330 ));
331 }
332
333 /// The boundary itself is inclusive — exactly the usable budget is a fit.
334 #[test]
335 fn a_candidate_at_exactly_the_usable_budget_is_granted() {
336 let free = 1000 * MIB;
337 #[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
338 #[allow(clippy::cast_possible_truncation)]
339 let usable = (free as f64 * RESIDENCY_UTILISATION) as u64;
340 let footprint = SlotFootprint {
341 weights_bytes: usable,
342 kv_bytes: 0,
343 };
344
345 assert!(decide_secondary_slot(Some(footprint), Some(free)).is_grant());
346 }
347
348 /// Every Vulkan-only, AMD, Intel, and CPU-only host lands here. Refusing
349 /// keeps single-slot behaviour byte-for-byte identical to before M9.
350 #[test]
351 fn an_unreadable_vram_budget_refuses_rather_than_assuming() {
352 assert_eq!(
353 decide_secondary_slot(Some(embedder()), None),
354 SecondarySlotDecision::RefuseUnknownBudget
355 );
356 }
357
358 /// A model whose KV shape could not be estimated has an unknown true cost.
359 /// Treating the missing half as zero would co-load it against a budget it
360 /// does not actually fit.
361 #[test]
362 fn an_unknown_footprint_refuses_rather_than_undercounting() {
363 assert_eq!(
364 decide_secondary_slot(None, Some(64 * GIB)),
365 SecondarySlotDecision::RefuseUnknownFootprint
366 );
367 }
368
369 // ── SlotFootprint::new ────────────────────────────────────────────────
370
371 #[test]
372 fn footprint_sums_weights_and_kv_at_the_launch_context() {
373 let elems = KvElemsPerToken { k: 1024, v: 1024 };
374 let footprint = SlotFootprint::new(
375 500 * MIB,
376 Some(elems),
377 KvCacheType::Q8_0,
378 KvCacheType::Q8_0,
379 8192,
380 )
381 .expect("known KV shape yields a footprint");
382
383 let per_token = kv_bytes_per_token(elems, KvCacheType::Q8_0, KvCacheType::Q8_0);
384 assert_eq!(footprint.weights_bytes, 500 * MIB);
385 assert_eq!(
386 footprint.kv_bytes,
387 estimate_kv_bytes_for_context(per_token, 8192)
388 );
389 assert_eq!(footprint.total(), 500 * MIB + footprint.kv_bytes);
390 }
391
392 #[test]
393 fn footprint_is_unknown_when_the_kv_shape_is() {
394 assert_eq!(
395 SlotFootprint::new(500 * MIB, None, KvCacheType::Q8_0, KvCacheType::Q8_0, 8192),
396 None
397 );
398 }
399
400 /// Quantized KV is what the runtime actually launches with, so a footprint
401 /// computed against `f16` would over-reserve by roughly the KV cache again
402 /// and refuse co-loads that would have fitted.
403 #[test]
404 fn kv_cache_type_changes_the_footprint() {
405 let elems = KvElemsPerToken { k: 4096, v: 4096 };
406 let quantized =
407 SlotFootprint::new(0, Some(elems), KvCacheType::Q8_0, KvCacheType::Q8_0, 32_768)
408 .unwrap();
409 let full =
410 SlotFootprint::new(0, Some(elems), KvCacheType::F16, KvCacheType::F16, 32_768).unwrap();
411
412 assert!(
413 quantized.total() < full.total(),
414 "q8_0 {} should be smaller than f16 {}",
415 quantized.total(),
416 full.total()
417 );
418 }
419}