Skip to main content

ferrox_core/
expert_budget.rs

1//! Splitting a memory budget between the GPU expert cache and the KV
2//! pool.
3//!
4//! # Why the expert cache gets first claim
5//!
6//! Both pools compete for the same bytes, but they do not degrade the
7//! same way. A KV pool one page short means one fewer concurrent
8//! request or a shorter context -- a scheduling limit, felt as a queue.
9//! An expert cache one slot short means every step that routes to the
10//! missing expert pays a PCIe transfer or a CPU detour -- a *per-token*
11//! tax on every request, forever. So [`plan_cache_budget`] fills the
12//! expert cache first, up to full residency, and gives the remainder to
13//! KV -- with a floor of `kv_reserve_pages`, because a server that
14//! cannot hold a context serves nothing at all.
15//!
16//! # A byte budget becomes a slot count
17//!
18//! A user says a number of bytes; a bounded pool of fixed-size slots is
19//! the only thing you can actually cap. [`expert_bytes_per_slot`] prices
20//! one slot as the sum of its row across every weight bank, and
21//! [`plan_cache_budget`] divides. It lives beside
22//! [`expert_store`](crate::expert_store), which holds the budget it
23//! sizes, and beside [`expert_cache`](crate::expert_cache), whose slots
24//! it counts.
25//!
26//! Ported 1:1 from FreeToken's `engine/cache_budget.py` (Apache-2.0);
27//! see `docs/THIRD_PARTY_NOTICES.md`.
28
29/// The VRAM a rebuild may spend: a fraction of what was free before the
30/// weights loaded, minus the weights, minus whatever the pools need
31/// unconditionally.
32///
33/// The fraction is not padding for its own sake -- it is the room the
34/// activations, the workspace, and the captured graphs occupy, none of
35/// which is in `weights_bytes`. Spending it produces an allocation
36/// failure at the first long prompt rather than at startup.
37///
38/// Signed on purpose: an over-committed deployment gets a negative
39/// budget, which every consumer below then refuses, rather than a
40/// wrapped-around enormous one.
41pub fn net_cache_budget_bytes(
42    memory_ratio: f64,
43    baseline_free_bytes: u64,
44    weights_bytes: u64,
45    fixed_cache_bytes: u64,
46) -> i64 {
47    (memory_ratio * baseline_free_bytes as f64) as i64
48        - weights_bytes as i64
49        - fixed_cache_bytes as i64
50}
51
52/// What a given split would actually cost.
53pub fn required_bytes(
54    moe_cache_slots: u64,
55    kv_pages: u64,
56    bytes_per_expert: u64,
57    bytes_per_page: u64,
58) -> i64 {
59    (moe_cache_slots * bytes_per_expert + kv_pages * bytes_per_page) as i64
60}
61
62/// The KV budget at startup, when the weights have just been loaded.
63///
64/// `init_free - new_free` is what loading actually consumed, measured
65/// rather than predicted -- which is why this is not the same
66/// expression as [`net_cache_budget_bytes`], and why it is the one used
67/// before any pool exists.
68pub fn startup_kv_budget(memory_ratio: f64, init_free_bytes: u64, new_free_bytes: u64) -> i64 {
69    (memory_ratio * init_free_bytes as f64) as i64
70        - (init_free_bytes as i64 - new_free_bytes as i64)
71}
72
73/// A pool split.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct PoolSizes {
76    /// Slots in the GPU expert cache.
77    pub moe_cache_slots: u64,
78    /// Pages in the KV pool.
79    pub kv_pages: u64,
80    /// Whether the prefill double buffer survived the split. It needs
81    /// two layers' worth of slots, and a tight budget can take that
82    /// away.
83    pub prefill_overlap: bool,
84}
85
86/// A split that does not fit, refused before anything was freed.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct BudgetTooSmall {
89    pub needed_bytes: i64,
90    pub budget_bytes: i64,
91    /// What the arithmetic wanted to allocate.
92    pub sizes: PoolSizes,
93}
94
95impl std::fmt::Display for BudgetTooSmall {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        write!(
98            f,
99            "requested cache (moe={} slots, kv={} pages) needs {} bytes but the budget is {}; \
100             the old cache is kept and still serving",
101            self.sizes.moe_cache_slots, self.sizes.kv_pages, self.needed_bytes, self.budget_bytes
102        )
103    }
104}
105
106impl std::error::Error for BudgetTooSmall {}
107
108/// What one expert costs in the GPU cache: the sum of its row across
109/// every weight bank.
110///
111/// Fixed per-model tensors that do **not** scale with the number of
112/// cached experts (folded quantization scales, for instance) are
113/// deliberately excluded -- counting them here would make each slot
114/// look more expensive than it is and under-size the cache.
115pub fn expert_bytes_per_slot(bank_row_bytes: &[u64]) -> u64 {
116    bank_row_bytes.iter().sum()
117}
118
119/// Split `budget_bytes` between the expert cache and the KV pool.
120///
121/// `max_slots` is a backend ceiling (some fused kernels cannot address
122/// more than a fixed number of experts); `total_experts` is full
123/// residency, past which more slots buy nothing.
124#[allow(clippy::too_many_arguments)]
125pub fn plan_cache_budget(
126    budget_bytes: i64,
127    bytes_per_expert: u64,
128    bytes_per_page: u64,
129    num_experts: u64,
130    total_experts: u64,
131    prefill_overlap: bool,
132    kv_reserve_pages: u64,
133    max_slots: u64,
134) -> Result<PoolSizes, BudgetTooSmall> {
135    assert!(
136        bytes_per_expert > 0 && bytes_per_page > 0,
137        "an unpriced pool cannot be sized"
138    );
139    let hi = total_experts.min(max_slots);
140    // The double buffer needs two layers of slots; without room for it
141    // the split is planned without it rather than failing.
142    let mut overlap = prefill_overlap && hi >= 2 * num_experts;
143    let lo = if overlap {
144        2 * num_experts
145    } else {
146        num_experts
147    };
148    assert!(
149        hi >= lo,
150        "the expert-cache ceiling of {hi} slots cannot hold the {lo} slots a layer needs"
151    );
152
153    // Fill the cache first, but never at the cost of the KV floor.
154    let spare = budget_bytes - (kv_reserve_pages * bytes_per_page) as i64;
155    let raw = if spare <= 0 {
156        0
157    } else {
158        (spare as u64) / bytes_per_expert
159    };
160    let moe_cache_slots = raw.min(hi).max(lo);
161    overlap = overlap && moe_cache_slots >= 2 * num_experts;
162
163    let remaining = budget_bytes - (moe_cache_slots * bytes_per_expert) as i64;
164    let kv_pages = if remaining <= 0 {
165        kv_reserve_pages
166    } else {
167        ((remaining as u64) / bytes_per_page).max(kv_reserve_pages)
168    };
169
170    let sizes = PoolSizes {
171        moe_cache_slots,
172        kv_pages,
173        prefill_overlap: overlap,
174    };
175    let needed = required_bytes(moe_cache_slots, kv_pages, bytes_per_expert, bytes_per_page);
176    if needed > budget_bytes || kv_pages <= 1 {
177        return Err(BudgetTooSmall {
178            needed_bytes: needed,
179            budget_bytes,
180            sizes,
181        });
182    }
183    Ok(sizes)
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    const GIB: u64 = 1 << 30;
191    const MIB: u64 = 1 << 20;
192
193    #[test]
194    fn the_budget_is_free_vram_less_the_weights_and_the_headroom() {
195        assert_eq!(
196            net_cache_budget_bytes(0.9, 10 * GIB, 4 * GIB, 0),
197            (0.9 * (10 * GIB) as f64) as i64 - (4 * GIB) as i64
198        );
199        // An over-committed deployment gets a negative budget, not a
200        // wrapped-around enormous one.
201        assert!(net_cache_budget_bytes(0.5, GIB, 4 * GIB, 0) < 0);
202    }
203
204    #[test]
205    fn the_startup_budget_prices_what_loading_actually_consumed() {
206        assert_eq!(startup_kv_budget(0.9, 1000, 400), 900 - 600);
207        assert_eq!(startup_kv_budget(1.0, 1000, 1000), 1000);
208    }
209
210    /// The expert cache is filled first: it is the pool whose shortfall
211    /// is paid per token rather than per request.
212    #[test]
213    fn the_expert_cache_is_filled_before_kv_gets_the_remainder() {
214        let sizes = plan_cache_budget(
215            (8 * GIB) as i64,
216            16 * MIB, // per expert
217            MIB,      // per page
218            32,       // experts per layer
219            256,      // total experts
220            false,
221            64,
222            u64::MAX,
223        )
224        .expect("fits");
225        assert_eq!(sizes.moe_cache_slots, 256, "full residency");
226        let spent = 256 * 16 * MIB;
227        assert_eq!(sizes.kv_pages, (8 * GIB - spent) / MIB);
228    }
229
230    /// ... but never past full residency: extra slots buy nothing, so
231    /// the bytes go to KV.
232    #[test]
233    fn the_expert_cache_is_capped_at_full_residency() {
234        let sizes = plan_cache_budget((64 * GIB) as i64, MIB, MIB, 8, 64, false, 16, u64::MAX)
235            .expect("fits");
236        assert_eq!(sizes.moe_cache_slots, 64);
237        assert!(sizes.kv_pages > 60_000);
238    }
239
240    /// A backend ceiling rolls the freed bytes into KV rather than
241    /// wasting them.
242    #[test]
243    fn a_backend_slot_ceiling_gives_its_bytes_to_kv() {
244        let capped =
245            plan_cache_budget((4 * GIB) as i64, MIB, MIB, 8, 4096, false, 16, 992).expect("fits");
246        assert_eq!(capped.moe_cache_slots, 992);
247        let uncapped = plan_cache_budget((4 * GIB) as i64, MIB, MIB, 8, 4096, false, 16, u64::MAX)
248            .expect("fits");
249        assert!(uncapped.moe_cache_slots > capped.moe_cache_slots);
250        assert!(capped.kv_pages > uncapped.kv_pages);
251    }
252
253    /// The KV floor is not negotiable: a server that cannot hold a
254    /// context serves nothing, however good its expert residency is.
255    #[test]
256    fn the_kv_reserve_is_taken_out_before_the_cache_is_sized() {
257        let reserve = 1024;
258        let sizes = plan_cache_budget(
259            (2 * GIB) as i64,
260            MIB,
261            MIB,
262            8,
263            4096,
264            false,
265            reserve,
266            u64::MAX,
267        )
268        .expect("fits");
269        assert!(sizes.kv_pages >= reserve);
270        assert!(sizes.moe_cache_slots <= 2048 - reserve);
271    }
272
273    /// The prefill double buffer costs two layers of slots. A budget
274    /// that cannot pay for it loses the buffer rather than the split.
275    #[test]
276    fn a_tight_budget_drops_the_prefill_double_buffer() {
277        let roomy =
278            plan_cache_budget((4 * GIB) as i64, MIB, MIB, 8, 64, true, 16, u64::MAX).unwrap();
279        assert!(roomy.prefill_overlap);
280
281        // A ceiling below two layers cannot host the buffer at all.
282        let cramped = plan_cache_budget(
283            (4 * GIB) as i64,
284            MIB,
285            MIB,
286            8,
287            64,
288            true,
289            16,
290            12, // fewer than 2 * 8 slots
291        )
292        .unwrap();
293        assert!(!cramped.prefill_overlap);
294    }
295
296    #[test]
297    fn a_budget_that_cannot_hold_one_layer_is_refused() {
298        let err = plan_cache_budget(MIB as i64, MIB, MIB, 8, 64, false, 16, u64::MAX).unwrap_err();
299        assert!(err.needed_bytes > err.budget_bytes);
300        assert_eq!(err.sizes.moe_cache_slots, 8, "one layer is the minimum");
301    }
302
303    #[test]
304    fn expert_slot_cost_is_the_sum_over_banks() {
305        assert_eq!(expert_bytes_per_slot(&[512, 256]), 768);
306        assert_eq!(expert_bytes_per_slot(&[]), 0);
307    }
308}