Skip to main content

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}