1const TRACE_TARGET: &str = "studio_worker::lifecycle";
8
9pub const ADMISSION_MARGIN_GIB: f32 = 1.0;
14
15pub trait MemoryProbe {
17 fn free_gib(&self) -> Option<f32>;
18 fn total_gib(&self) -> Option<f32>;
19}
20
21#[derive(Debug, Clone, Copy, PartialEq)]
23pub enum FreeMemory {
24 Probed { gib: f32 },
26 Accounted { total_gib: f32, loaded_gib: f32 },
28 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#[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
57pub 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
88pub 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
102pub 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
109pub 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
116pub struct SystemProbe;
119
120impl MemoryProbe for SystemProbe {
121 #[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}