metrale_circuit/memory/
budget.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-10-02: The memory budget of a device class and the queries over it: the driver's share
4//! (`kernels/<class>/HARDWARE.toml [memory]`, calibrated per class), the util budget, and the
5//! inverse questions "the most sequences at this length" and "the longest length at this many
6//! sequences", answered by bisection over the one evaluator the forward report uses.
7//!
8//! Owner: metrale-circuit (memory).
9//! Invariants:
10//! - Every driver term is read from the class's `[memory]` table; a missing key is an error,
11//!   never a default (#72's `runtime_headroom.rs` constants are this table's first source).
12//! - The inverse queries assume the footprint never shrinks as sequences or tokens grow, and
13//!   they check the answer: the bound they return fits, the next value does not (or is the cap).
14
15use serde::Deserialize;
16
17/// 2026-10-02: What the driver holds beyond the allocation ledger, for one device class.
18#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct DriverTerms {
21    /// 2026-10-02: The CUDA context, loaded modules, the local-memory reservation and the
22    /// graphs, bytes.
23    pub driver_fixed_bytes: u64,
24    /// 2026-10-02: The driver's per-allocation bookkeeping, per mille of the util budget.
25    pub driver_budget_per_mille: u64,
26    /// 2026-10-02: Host memory is the device's memory (a unified-memory SoC): host caches compete
27    /// for the same pool, outside the util budget.
28    pub unified: bool,
29    /// 2026-10-02: The largest `--gpu-memory-utilization` a serve of this class may run at.
30    pub util_ceiling: f64,
31}
32
33/// 2026-10-02: Why the class's memory terms could not be read.
34#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
35pub enum BudgetError {
36    /// 2026-10-02: The HARDWARE.toml text, or its `[memory]` table.
37    #[error("kernels/{class}/HARDWARE.toml [memory]: {detail}")]
38    Terms {
39        /// 2026-10-02: The class.
40        class: String,
41        /// 2026-10-02: What was wrong.
42        detail: String,
43    },
44    /// 2026-10-02: An inverse query whose lower bound does not fit, or whose evaluator failed.
45    #[error("{0}")]
46    Query(String),
47}
48
49impl DriverTerms {
50    /// 2026-10-02: The `[memory]` table of `class`'s HARDWARE.toml text.
51    pub fn parse(class: &str, hardware_toml: &str) -> Result<Self, BudgetError> {
52        let err = |detail: String| BudgetError::Terms {
53            class: class.to_string(),
54            detail,
55        };
56        let mut t: toml::Table = toml::from_str(hardware_toml).map_err(|e| err(e.to_string()))?;
57        let mem = t
58            .remove("memory")
59            .ok_or_else(|| err("the class declares no [memory] table".into()))?;
60        let terms: Self = mem
61            .try_into()
62            .map_err(|e: toml::de::Error| err(e.to_string()))?;
63        if terms.driver_budget_per_mille > 1000
64            || !(terms.util_ceiling > 0.0 && terms.util_ceiling <= 1.0)
65        {
66            return Err(err(
67                "driver_budget_per_mille is at most 1000 and util_ceiling in (0, 1]".into(),
68            ));
69        }
70        Ok(terms)
71    }
72
73    /// 2026-10-02: The driver's bytes under a util budget of `budget_bytes`, with
74    /// `chunk_slack` bytes of small-allocation chunks (measured from a ledger, or 0 when no
75    /// ledger is known).
76    pub fn bytes(&self, budget_bytes: u64, chunk_slack: u64) -> u64 {
77        self.driver_fixed_bytes
78            .saturating_add(budget_bytes / 1000 * self.driver_budget_per_mille)
79            .saturating_add(chunk_slack)
80    }
81}
82
83/// 2026-10-02: The util budget: `device_bytes x util`, as the engine computes it (truncated).
84pub fn util_budget(device_bytes: u64, util: f64) -> u64 {
85    (device_bytes as f64 * util) as u64
86}
87
88/// 2026-10-02: The largest `x` in `lo..=hi` with `fits(x)`, by bisection; `None` when `lo` does
89/// not fit. `fits` must be monotone (true up to some `x`, false after).
90pub fn largest_fitting(
91    lo: u64,
92    hi: u64,
93    fits: &mut dyn FnMut(u64) -> Result<bool, BudgetError>,
94) -> Result<Option<u64>, BudgetError> {
95    if lo > hi {
96        return Err(BudgetError::Query(format!("empty range {lo}..={hi}")));
97    }
98    if !fits(lo)? {
99        return Ok(None);
100    }
101    if fits(hi)? {
102        return Ok(Some(hi));
103    }
104    // 2026-10-02: `good` fits, `bad` does not; halve the gap.
105    let (mut good, mut bad) = (lo, hi);
106    while bad - good > 1 {
107        let mid = good + (bad - good) / 2;
108        if fits(mid)? {
109            good = mid;
110        } else {
111            bad = mid;
112        }
113    }
114    Ok(Some(good))
115}
116
117#[cfg(test)]
118#[path = "budget_tests.rs"]
119mod budget_tests;