Skip to main content

studio_worker/
admission.rs

1//! Admission: does a load or transient job fit in device memory now?
2//! (see `docs/runtime/model-lifecycle.md`).
3//!
4//! Free memory is probed on the first GPU (where the engines place
5//! models), not summed across GPUs: a model must fit on one device.
6
7const TRACE_TARGET: &str = "studio_worker::lifecycle";
8
9/// Device memory kept free for runtime overheads the catalogue estimates
10/// do not cover (CUDA context, KV growth, allocator slack).
11/// Measured: Nemotron streaming ran at 3.5 GiB on CUDA against ~2.6 GiB of weights.
12/// Safe range 0.5..=2.0.
13pub const ADMISSION_MARGIN_GIB: f32 = 1.0;
14
15/// Reads the device's memory.  `SystemProbe` in production; faked in tests.
16pub trait MemoryProbe {
17    fn free_gib(&self) -> Option<f32>;
18    fn total_gib(&self) -> Option<f32>;
19}
20
21/// How much device memory is free, and how we know.
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub enum FreeMemory {
24    /// Measured on the device now.
25    Probed { gib: f32 },
26    /// No live probe: total minus what the worker itself has loaded.
27    Accounted { total_gib: f32, loaded_gib: f32 },
28    /// Neither probe nor total: nothing can be admitted safely.
29    Unknown,
30}
31
32impl FreeMemory {
33    pub fn gib(&self) -> f32 {
34        match *self {
35            Self::Probed { gib } => gib,
36            Self::Accounted {
37                total_gib,
38                loaded_gib,
39            } => (total_gib - loaded_gib).max(0.0),
40            Self::Unknown => 0.0,
41        }
42    }
43}
44
45/// A load or job that does not fit.
46#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
47#[error(
48    "insufficient memory: needs {needed_gib:.2} GiB, {free_gib:.2} GiB free \
49     ({margin_gib:.2} GiB kept in reserve)"
50)]
51pub struct Refused {
52    pub needed_gib: f32,
53    pub free_gib: f32,
54    pub margin_gib: f32,
55}
56
57/// Measure free memory now, falling back to accounting when the device
58/// cannot be probed.  `loaded_gib` is the sum of loaded estimates.
59pub fn free_now(probe: &dyn MemoryProbe, loaded_gib: f32) -> FreeMemory {
60    if let Some(gib) = probe.free_gib() {
61        return FreeMemory::Probed { gib };
62    }
63    match probe.total_gib() {
64        Some(total_gib) => {
65            tracing::warn!(
66                target: TRACE_TARGET,
67                op = "admit",
68                total_gib,
69                loaded_gib,
70                "free memory probe unavailable; admitting against total minus loaded estimates"
71            );
72            FreeMemory::Accounted {
73                total_gib,
74                loaded_gib,
75            }
76        }
77        None => {
78            tracing::warn!(
79                target: TRACE_TARGET,
80                op = "admit",
81                "no device memory probe available; nothing can be admitted"
82            );
83            FreeMemory::Unknown
84        }
85    }
86}
87
88/// Admit `needed_gib` if it fits in `free` minus the reserve.
89pub fn admit(needed_gib: f32, free: &FreeMemory) -> Result<(), Refused> {
90    let free_gib = free.gib();
91    if *free != FreeMemory::Unknown && needed_gib <= free_gib - ADMISSION_MARGIN_GIB {
92        Ok(())
93    } else {
94        Err(Refused {
95            needed_gib,
96            free_gib,
97            margin_gib: ADMISSION_MARGIN_GIB,
98        })
99    }
100}
101
102/// First GPU's free memory from `nvidia-smi --query-gpu=memory.free
103/// --format=csv,noheader,nounits` (MiB per line), in GiB.
104pub fn first_gpu_free_gib(stdout: &str) -> Option<f32> {
105    let mib: f32 = stdout.lines().next()?.trim().parse().ok()?;
106    Some(mib / 1024.0)
107}
108
109/// AMD DRM sysfs: free = `mem_info_vram_total` - `mem_info_vram_used`.
110pub fn amd_free_gib(total: &str, used: &str) -> Option<f32> {
111    let total: u64 = total.trim().parse().ok()?;
112    let used: u64 = used.trim().parse().ok()?;
113    Some((total.saturating_sub(used) as f64 / (1024.0 * 1024.0 * 1024.0)) as f32)
114}
115
116/// The host's real probe: `nvidia-smi` first, then AMD DRM sysfs; total
117/// from the existing VRAM detection.
118pub struct SystemProbe;
119
120impl MemoryProbe for SystemProbe {
121    // Host-dependent IO (CI has no GPU); the parsers it delegates to are
122    // unit-tested above.
123    #[cfg_attr(coverage_nightly, coverage(off))]
124    fn free_gib(&self) -> Option<f32> {
125        let smi = std::process::Command::new("nvidia-smi")
126            .args(["--query-gpu=memory.free", "--format=csv,noheader,nounits"])
127            .output()
128            .ok()
129            .filter(|o| o.status.success())
130            .and_then(|o| first_gpu_free_gib(&String::from_utf8_lossy(&o.stdout)));
131        smi.or_else(|| {
132            let dev = std::path::Path::new("/sys/class/drm/card0/device");
133            amd_free_gib(
134                &std::fs::read_to_string(dev.join("mem_info_vram_total")).ok()?,
135                &std::fs::read_to_string(dev.join("mem_info_vram_used")).ok()?,
136            )
137        })
138    }
139
140    #[cfg_attr(coverage_nightly, coverage(off))]
141    fn total_gib(&self) -> Option<f32> {
142        crate::sys::detect_vram_gb().ok().filter(|g| *g > 0.0)
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    struct FakeProbe {
151        free: Option<f32>,
152        total: Option<f32>,
153    }
154
155    impl MemoryProbe for FakeProbe {
156        fn free_gib(&self) -> Option<f32> {
157            self.free
158        }
159        fn total_gib(&self) -> Option<f32> {
160            self.total
161        }
162    }
163
164    #[test]
165    fn admits_when_the_estimate_fits_inside_free_minus_margin() {
166        let free = FreeMemory::Probed { gib: 5.0 };
167        assert_eq!(admit(3.9, &free), Ok(()));
168        assert_eq!(admit(4.0, &free), Ok(()));
169    }
170
171    #[test]
172    fn refuses_when_the_margin_would_be_eaten() {
173        let free = FreeMemory::Probed { gib: 5.0 };
174        assert_eq!(
175            admit(4.1, &free),
176            Err(Refused {
177                needed_gib: 4.1,
178                free_gib: 5.0,
179                margin_gib: ADMISSION_MARGIN_GIB,
180            })
181        );
182    }
183
184    #[test]
185    fn refusal_says_what_was_needed_and_what_was_free() {
186        let r = Refused {
187            needed_gib: 3.5,
188            free_gib: 2.25,
189            margin_gib: 1.0,
190        };
191        assert_eq!(
192            r.to_string(),
193            "insufficient memory: needs 3.50 GiB, 2.25 GiB free (1.00 GiB kept in reserve)"
194        );
195    }
196
197    #[test]
198    fn prefers_the_probed_free_memory() {
199        let probe = FakeProbe {
200            free: Some(7.5),
201            total: Some(24.0),
202        };
203        assert_eq!(free_now(&probe, 10.0), FreeMemory::Probed { gib: 7.5 });
204    }
205
206    #[test]
207    fn falls_back_to_accounting_and_logs_it() {
208        let logs = crate::test_support::capture(|| {
209            let probe = FakeProbe {
210                free: None,
211                total: Some(24.0),
212            };
213            assert_eq!(
214                free_now(&probe, 10.0),
215                FreeMemory::Accounted {
216                    total_gib: 24.0,
217                    loaded_gib: 10.0
218                }
219            );
220        });
221        assert!(logs.contains("free memory probe unavailable"), "{logs}");
222    }
223
224    #[test]
225    fn with_no_probe_at_all_nothing_is_admitted() {
226        let probe = FakeProbe {
227            free: None,
228            total: None,
229        };
230        let free = free_now(&probe, 0.0);
231        assert_eq!(free, FreeMemory::Unknown);
232        assert!(admit(0.1, &free).is_err());
233    }
234
235    #[test]
236    fn accounted_free_is_total_minus_loaded_never_negative() {
237        assert_eq!(
238            FreeMemory::Accounted {
239                total_gib: 24.0,
240                loaded_gib: 10.0
241            }
242            .gib(),
243            14.0
244        );
245        assert_eq!(
246            FreeMemory::Accounted {
247                total_gib: 8.0,
248                loaded_gib: 10.0
249            }
250            .gib(),
251            0.0
252        );
253        assert_eq!(FreeMemory::Unknown.gib(), 0.0);
254    }
255
256    #[test]
257    fn parses_the_first_gpu_free_mib_from_nvidia_smi() {
258        assert_eq!(first_gpu_free_gib("3703\n12000\n"), Some(3703.0 / 1024.0));
259        assert_eq!(first_gpu_free_gib(" 1024 \n"), Some(1.0));
260        assert_eq!(first_gpu_free_gib(""), None);
261        assert_eq!(first_gpu_free_gib("[N/A]\n"), None);
262    }
263
264    #[test]
265    fn parses_amd_free_from_total_and_used_bytes() {
266        let gib = 1024.0 * 1024.0 * 1024.0;
267        assert_eq!(
268            amd_free_gib("17179869184\n", "4294967296\n"),
269            Some((17179869184.0 - 4294967296.0) / gib)
270        );
271        assert_eq!(amd_free_gib("garbage", "1"), None);
272        assert_eq!(amd_free_gib("1", "2"), Some(0.0));
273    }
274}