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}