gglib_core/domain/cache_budget.rs
1//! Auto-sizing math for llama-server's host-RAM prompt cache (`--cache-ram`).
2//!
3//! Extracted from `server_config` so the pure budget arithmetic lives
4//! alongside the rest of the domain's pure calculations, with its own
5//! focused test suite.
6
7/// RAM reserved for the OS, other applications, and llama.cpp's own
8/// compute/scratch buffers — never handed to the prompt cache.
9pub const CACHE_RAM_HEADROOM_BYTES: u64 = 16 * 1024 * 1024 * 1024;
10
11/// Below this, a prompt cache holds too little to be worth the memory
12/// pressure, so the budget collapses to `0` (explicitly disabled).
13pub const CACHE_RAM_FLOOR_BYTES: u64 = 1024 * 1024 * 1024;
14
15/// KV allowance assumed when the model's metadata doesn't permit an estimate.
16/// Deliberately generous: over-reserving shrinks the cache (safe), whereas
17/// under-reserving risks memory pressure.
18pub const CACHE_RAM_UNKNOWN_KV_ALLOWANCE_BYTES: u64 = 8 * 1024 * 1024 * 1024;
19
20/// Threshold below which a working prompt cache counts as cramped.
21///
22/// At or below this the cache holds too few conversations to reliably survive
23/// switching between them, so a resumed conversation will often re-prefill
24/// from scratch. Sits above [`CACHE_RAM_FLOOR_BYTES`], so it describes a cache
25/// that is working but tight — not one that was switched off.
26pub const CACHE_RAM_LOW_WATERMARK_BYTES: u64 = 4 * 1024 * 1024 * 1024;
27
28// The two thresholds must not overlap: if the watermark ever dropped to or
29// below the floor, `classify_cache_ram` could never return `Low`, silently
30// emptying the warning band. Enforced at compile time rather than in a test,
31// since both operands are constants and the mistake would be a source edit.
32const _: () = assert!(CACHE_RAM_FLOOR_BYTES < CACHE_RAM_LOW_WATERMARK_BYTES);
33
34/// How healthy a resolved `--cache-ram` budget is, for user-facing display.
35///
36/// Exists so surfaces (dashboard, CLI) don't re-derive the thresholds from
37/// magic numbers. In particular, a `0` budget is genuinely ambiguous at the
38/// call site — [`compute_auto_cache_ram_mb`] returns `0` when the machine
39/// can't afford a cache, and a user can also pass `--cache-ram-mb 0` — and
40/// those need different messages, since only one of them is a problem.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum CacheRamHealth {
43 /// No `--cache-ram` flag emitted; llama-server's built-in default applies.
44 LlamaDefault,
45 /// The user asked for `0`. Working as intended — not a warning.
46 DisabledByUser,
47 /// Auto-sizing found no room after weights, KV, and headroom. The machine
48 /// cannot afford a prompt cache at this model and context size.
49 DisabledInsufficientRam,
50 /// Working, but at or under [`CACHE_RAM_LOW_WATERMARK_BYTES`] — expect
51 /// conversation switches to re-prefill more often than not.
52 Low { mb: u64 },
53 /// Comfortably sized.
54 Healthy { mb: u64 },
55}
56
57impl CacheRamHealth {
58 /// Whether this state is worth drawing the user's attention to.
59 ///
60 /// `false` for both healthy budgets and a deliberately disabled one —
61 /// warning someone about a setting they chose is noise.
62 #[must_use]
63 pub const fn needs_attention(&self) -> bool {
64 matches!(self, Self::DisabledInsufficientRam | Self::Low { .. })
65 }
66
67 /// The resolved budget in MiB, or `None` when no flag was emitted and
68 /// llama-server's own default applies.
69 ///
70 /// Both disabled states report `Some(0)` rather than `None`: zero is a
71 /// budget that was decided, and callers that treat "no flag" and "zero"
72 /// alike would lose the distinction the enum exists to keep.
73 #[must_use]
74 pub const fn budget_mb(&self) -> Option<u64> {
75 match self {
76 Self::Healthy { mb } | Self::Low { mb } => Some(*mb),
77 Self::DisabledByUser | Self::DisabledInsufficientRam => Some(0),
78 Self::LlamaDefault => None,
79 }
80 }
81}
82
83/// Classify a resolved `--cache-ram` budget.
84///
85/// # Arguments
86///
87/// * `cache_ram_mb` — the resolved budget, or `None` when no flag is emitted.
88/// * `was_explicit` — whether the value came from the user rather than
89/// auto-sizing. Only consulted to disambiguate `0`; a small-but-nonzero
90/// budget is reported as [`CacheRamHealth::Low`] either way, because the
91/// consequence (switches re-prefill) is the same regardless of who chose it.
92#[must_use]
93pub const fn classify_cache_ram(cache_ram_mb: Option<u64>, was_explicit: bool) -> CacheRamHealth {
94 let Some(mb) = cache_ram_mb else {
95 return CacheRamHealth::LlamaDefault;
96 };
97 if mb == 0 {
98 return if was_explicit {
99 CacheRamHealth::DisabledByUser
100 } else {
101 CacheRamHealth::DisabledInsufficientRam
102 };
103 }
104 if mb.saturating_mul(1024 * 1024) <= CACHE_RAM_LOW_WATERMARK_BYTES {
105 return CacheRamHealth::Low { mb };
106 }
107 CacheRamHealth::Healthy { mb }
108}
109
110/// Compute the auto `--cache-ram` budget, in MiB.
111///
112/// ```text
113/// budget = total_ram − model_weights − kv_bytes − HEADROOM
114/// result = if budget < FLOOR { 0 } else { budget }
115/// ```
116///
117/// Claims all RAM safely available after weights, KV, and headroom — no
118/// fractional cap. Saturating throughout: a model larger than RAM yields `0`
119/// (cache disabled) rather than wrapping into a huge budget.
120///
121/// # Arguments
122///
123/// * `total_ram_bytes` — total physical system RAM.
124/// * `model_bytes` — on-disk size of the model weights (all shards).
125/// * `kv_bytes` — estimated KV cache at the launch context size; pass
126/// [`CACHE_RAM_UNKNOWN_KV_ALLOWANCE_BYTES`] when unknown.
127#[must_use]
128pub const fn compute_auto_cache_ram_mb(
129 total_ram_bytes: u64,
130 model_bytes: u64,
131 kv_bytes: u64,
132) -> u64 {
133 let reserved = model_bytes
134 .saturating_add(kv_bytes)
135 .saturating_add(CACHE_RAM_HEADROOM_BYTES);
136 let budget = total_ram_bytes.saturating_sub(reserved);
137 if budget < CACHE_RAM_FLOOR_BYTES {
138 return 0;
139 }
140 budget / (1024 * 1024)
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 const GIB: u64 = 1024 * 1024 * 1024;
148
149 /// The reference case: 128 GiB machine, 27 GiB weights, ~9 GiB KV.
150 /// No fractional cap — the full ~76 GiB remainder is claimed.
151 #[test]
152 fn auto_budget_claims_all_safely_available_ram() {
153 let got = compute_auto_cache_ram_mb(128 * GIB, 27 * GIB, 9 * GIB);
154 assert_eq!(got, 76 * 1024);
155 }
156
157 /// A large machine where the old 25% cap would have bound (128 GiB) no
158 /// longer loses that headroom — the full remainder is claimed.
159 #[test]
160 fn auto_budget_uncapped_on_a_large_machine() {
161 // reserved = 27 + 9 + 16 = 52; usable = 512 - 52 = 460 GiB.
162 // The old 25% cap (128 GiB) would have bound here; it no longer does.
163 let got = compute_auto_cache_ram_mb(512 * GIB, 27 * GIB, 9 * GIB);
164 assert_eq!(got, 460 * 1024);
165 }
166
167 /// Straightforward subtraction case, well above the floor.
168 #[test]
169 fn auto_budget_is_total_minus_reserved() {
170 // 64 - 30 - 4 - 16 = 14 GiB usable.
171 let got = compute_auto_cache_ram_mb(64 * GIB, 30 * GIB, 4 * GIB);
172 assert_eq!(got, 14 * 1024);
173 }
174
175 /// A model that leaves under the 1 GiB floor disables the cache outright
176 /// rather than letting llama-server apply its 8 GiB default.
177 #[test]
178 fn auto_budget_collapses_to_zero_under_the_floor() {
179 // 36 - 20 - 4 - 16 = saturates to 0.
180 assert_eq!(compute_auto_cache_ram_mb(36 * GIB, 20 * GIB, 4 * GIB), 0);
181 }
182
183 /// A model larger than total RAM must saturate to 0, never wrap around
184 /// into an enormous budget.
185 #[test]
186 fn auto_budget_saturates_when_model_exceeds_ram() {
187 assert_eq!(compute_auto_cache_ram_mb(16 * GIB, 64 * GIB, 8 * GIB), 0);
188 }
189
190 /// 8 GiB laptop: headroom (16 GiB) alone exceeds total RAM, so reserved
191 /// saturates past the machine's capacity → budget collapses to 0.
192 #[test]
193 fn auto_budget_is_zero_on_small_ram_laptop() {
194 // reserved = 3 + 0 + 16 = 19 > 8 → usable = 0
195 assert_eq!(compute_auto_cache_ram_mb(8 * GIB, 3 * GIB, 0), 0);
196 }
197
198 /// 24 GiB machine: subtraction lands exactly on the 1 GiB floor.
199 #[test]
200 fn auto_budget_hits_floor_boundary_at_24_gib() {
201 // reserved = 7 + 0 + 16 = 23; usable = 24 - 23 = 1 GiB → 1024 MiB
202 assert_eq!(compute_auto_cache_ram_mb(24 * GIB, 7 * GIB, 0), 1024);
203 }
204
205 /// 32 GiB machine: comfortably above the floor.
206 #[test]
207 fn auto_budget_above_floor_on_32_gib_machine() {
208 // reserved = 10 + 0 + 16 = 26; usable = 32 - 26 = 6 GiB → 6144 MiB
209 assert_eq!(compute_auto_cache_ram_mb(32 * GIB, 10 * GIB, 0), 6144);
210 }
211
212 // ── Budget health classification ─────────────────────────────────────
213
214 /// No flag at all is llama-server's own default, not a disabled cache.
215 #[test]
216 fn classify_none_is_llama_default() {
217 assert_eq!(
218 classify_cache_ram(None, false),
219 CacheRamHealth::LlamaDefault
220 );
221 assert_eq!(classify_cache_ram(None, true), CacheRamHealth::LlamaDefault);
222 }
223
224 /// The whole point of the enum: a `0` the user asked for and a `0` the
225 /// machine forced must not read the same to a surface.
226 #[test]
227 fn classify_distinguishes_chosen_zero_from_forced_zero() {
228 assert_eq!(
229 classify_cache_ram(Some(0), true),
230 CacheRamHealth::DisabledByUser
231 );
232 assert_eq!(
233 classify_cache_ram(Some(0), false),
234 CacheRamHealth::DisabledInsufficientRam
235 );
236 }
237
238 /// Only the forced zero is a problem; a chosen one is working as asked.
239 #[test]
240 fn only_forced_zero_needs_attention() {
241 assert!(!CacheRamHealth::DisabledByUser.needs_attention());
242 assert!(CacheRamHealth::DisabledInsufficientRam.needs_attention());
243 assert!(!CacheRamHealth::LlamaDefault.needs_attention());
244 assert!(CacheRamHealth::Low { mb: 2048 }.needs_attention());
245 assert!(!CacheRamHealth::Healthy { mb: 70_000 }.needs_attention());
246 }
247
248 #[test]
249 fn classify_flags_a_cramped_budget_as_low() {
250 assert_eq!(
251 classify_cache_ram(Some(2048), false),
252 CacheRamHealth::Low { mb: 2048 }
253 );
254 }
255
256 /// The watermark is inclusive, and one MiB past it is healthy.
257 #[test]
258 fn low_watermark_boundary_is_inclusive() {
259 let at = CACHE_RAM_LOW_WATERMARK_BYTES / (1024 * 1024);
260 assert_eq!(
261 classify_cache_ram(Some(at), false),
262 CacheRamHealth::Low { mb: at }
263 );
264 assert_eq!(
265 classify_cache_ram(Some(at + 1), false),
266 CacheRamHealth::Healthy { mb: at + 1 }
267 );
268 }
269
270 /// A small budget is equally cramped whichever way it was chosen — unlike
271 /// zero, the consequence doesn't depend on intent.
272 #[test]
273 fn low_classification_ignores_explicitness() {
274 assert_eq!(
275 classify_cache_ram(Some(1024), true),
276 classify_cache_ram(Some(1024), false)
277 );
278 }
279
280 /// The smallest budget `compute_auto_cache_ram_mb` can emit without
281 /// collapsing to zero must classify as Low, not Healthy — otherwise the
282 /// warning band has a gap right where it matters most.
283 #[test]
284 fn smallest_nonzero_auto_budget_classifies_as_low() {
285 // 24 GiB machine lands exactly on the floor: 1024 MiB.
286 let mb = compute_auto_cache_ram_mb(24 * GIB, 7 * GIB, 0);
287 assert_eq!(mb, 1024);
288 assert_eq!(
289 classify_cache_ram(Some(mb), false),
290 CacheRamHealth::Low { mb }
291 );
292 }
293
294 /// The unknown-KV allowance is generous enough to shrink, never inflate,
295 /// the budget relative to a known small KV.
296 #[test]
297 fn unknown_kv_allowance_is_conservative() {
298 let known_small = compute_auto_cache_ram_mb(64 * GIB, 10 * GIB, GIB);
299 let unknown =
300 compute_auto_cache_ram_mb(64 * GIB, 10 * GIB, CACHE_RAM_UNKNOWN_KV_ALLOWANCE_BYTES);
301 assert!(
302 unknown <= known_small,
303 "unknown-KV budget {unknown} should not exceed known-KV {known_small}"
304 );
305 }
306}