bge_m3_embedding_server/sysinfo.rs
1// Copyright (c) 2026 J. Patrick Fulton
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Memory detection for auto-budget computation.
16//!
17//! Production target is Linux (Fargate/ECS). On Linux we walk the cgroup
18//! hierarchy to find the container memory limit, then fall back to host RAM
19//! reported by `/proc/meminfo`. On macOS we read host RAM via `sysctl`;
20//! cgroup support requires unsafe FFI so it is deferred.
21//!
22//! ## cgroup-v2 detection on ECS Managed Instances (Bottlerocket)
23//!
24//! ECS Managed Instances launch containers **without** `--cgroupns=private`,
25//! so `/sys/fs/cgroup/memory.max` resolves to the unified-hierarchy root,
26//! which reads `"max"` (no limit). The actual container memory limit is
27//! set at a deeper path whose last component is recorded in
28//! `/proc/self/cgroup` (unified-hierarchy format: a single line
29//! `0::<path>`, e.g. `0::/ecs.slice/ecs-…-task.scope/<id>`).
30//!
31//! `cgroup_memory()` reads `/proc/self/cgroup`, extracts that path, then
32//! reads `memory.max` at each ancestor (deepest first) until it finds a
33//! numeric limit or exhausts the tree. Falls through to `host_ram` only
34//! when the entire walk yields `"max"` (truly unconstrained host).
35//!
36//! RSS tracking (`read_process_rss_bytes`) is Linux-only (parses
37//! `/proc/self/statm`). On macOS it returns `None`; the auto-budget logic
38//! treats `None` as "cannot measure model footprint" and uses conservative
39//! defaults.
40use tracing::warn;
41
42// ---------------------------------------------------------------------------
43// Public types
44// ---------------------------------------------------------------------------
45
46/// Where the available-memory reading came from.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48#[allow(dead_code)] // CgroupV2/CgroupV1 are constructed only on Linux; macOS sees them as unused.
49pub enum MemorySource {
50 /// `BGE_M3_AVAILABLE_MEMORY_BYTES` env override.
51 Override,
52 /// Linux cgroup v2 `memory.max`.
53 CgroupV2,
54 /// Linux cgroup v1 `memory.limit_in_bytes`.
55 CgroupV1,
56 /// `/proc/meminfo` `MemAvailable` (Linux) or `sysctl hw.memsize` (macOS).
57 HostRam,
58}
59
60impl std::fmt::Display for MemorySource {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 Self::Override => f.write_str("override"),
64 Self::CgroupV2 => f.write_str("cgroup_v2"),
65 Self::CgroupV1 => f.write_str("cgroup_v1"),
66 Self::HostRam => f.write_str("host_ram"),
67 }
68 }
69}
70
71/// A memory reading with its provenance.
72#[derive(Debug, Clone, Copy)]
73pub struct MemoryReading {
74 /// Total available memory bytes detected from the source.
75 pub available_bytes: usize,
76 /// Detection method that produced this reading.
77 pub source: MemorySource,
78}
79
80// ---------------------------------------------------------------------------
81// Public API
82// ---------------------------------------------------------------------------
83
84/// Detects available memory for the process.
85///
86/// Detection chain (first success wins):
87/// 1. `BGE_M3_AVAILABLE_MEMORY_BYTES` env override.
88/// 2. Linux cgroup v2: `/sys/fs/cgroup/memory.max`.
89/// 3. Linux cgroup v1: `/sys/fs/cgroup/memory/memory.limit_in_bytes`.
90/// 4. Linux: `/proc/meminfo` `MemAvailable`.
91/// 5. macOS: `sysctl hw.memsize` (total host RAM; no cgroup support).
92/// 6. Fallback: 4 GiB constant with a warning log.
93pub(crate) fn detect_available_memory() -> MemoryReading {
94 // --- step 1: explicit override ---
95 if let Some(bytes) = env_override() {
96 return MemoryReading {
97 available_bytes: bytes,
98 source: MemorySource::Override,
99 };
100 }
101
102 // --- step 2 / 3: Linux cgroup ---
103 #[cfg(target_os = "linux")]
104 if let Some(r) = cgroup_memory() {
105 return r;
106 }
107
108 // --- step 4 / 5: OS-level RAM ---
109 if let Some(bytes) = host_ram() {
110 return MemoryReading {
111 available_bytes: bytes,
112 source: MemorySource::HostRam,
113 };
114 }
115
116 // --- fallback ---
117 let fallback: usize = 4 * 1024 * 1024 * 1024; // 4 GiB
118 warn!(
119 available_bytes = fallback,
120 "Memory detection failed on all paths; using 4 GiB fallback. \
121 Set BGE_M3_AVAILABLE_MEMORY_BYTES to override."
122 );
123 MemoryReading {
124 available_bytes: fallback,
125 source: MemorySource::HostRam,
126 }
127}
128
129/// Detects the number of NVIDIA GPU devices available on this instance.
130///
131/// Detection order (first success wins):
132/// 1. `override_val` — the parsed value of `BGE_M3_GPU_COUNT` env var, if set.
133/// 2. Linux `/proc/driver/nvidia/gpus/` directory entry count (compiled out on
134/// non-Linux targets).
135/// 3. Default: `1` (macOS `CoreML` is always single-device; Linux fallback when
136/// the NVIDIA driver proc path is absent or empty).
137///
138/// Returns at least `1` regardless of the detection path. Logs the chosen
139/// count and source at `INFO`.
140pub(crate) fn detect_gpu_count(override_val: Option<usize>) -> usize {
141 if let Some(n) = override_val {
142 let n = n.max(1);
143 tracing::info!(
144 gpu_count = n,
145 source = "env_override",
146 "detected GPU(s) on this instance"
147 );
148 return n;
149 }
150
151 #[cfg(target_os = "linux")]
152 if let Some(n) = count_nvidia_gpus_from_proc() {
153 tracing::info!(
154 gpu_count = n,
155 source = "proc_driver",
156 "detected GPU(s) on this instance"
157 );
158 return n;
159 }
160
161 tracing::info!(
162 gpu_count = 1_usize,
163 source = "default",
164 "detected GPU(s) on this instance"
165 );
166 1
167}
168
169/// Counts NVIDIA GPU entries in `/proc/driver/nvidia/gpus/`.
170///
171/// Returns `None` when the directory does not exist, is unreadable, or
172/// contains no entries (indicating no NVIDIA driver is loaded). Returns
173/// `Some(n)` with `n ≥ 1` when entries are found.
174#[cfg(target_os = "linux")]
175fn count_nvidia_gpus_from_proc() -> Option<usize> {
176 let count = std::fs::read_dir("/proc/driver/nvidia/gpus")
177 .ok()?
178 .filter_map(std::result::Result::ok)
179 .count();
180 if count > 0 { Some(count) } else { None }
181}
182
183/// Returns the current process's RSS (Resident Set Size) in bytes, or `None`
184/// if measurement is not supported on this platform.
185///
186/// Linux: parses `/proc/self/statm`. Field 1 (index 1) is RSS in pages;
187/// multiplied by the system page size (typically 4096).
188///
189/// macOS: returns `None` — requires `task_info` FFI which conflicts with
190/// `unsafe_code = "forbid"`. A future release can add it via the `mach2`
191/// crate.
192pub(crate) fn read_process_rss_bytes() -> Option<usize> {
193 #[cfg(target_os = "linux")]
194 return linux_rss();
195
196 #[cfg(not(target_os = "linux"))]
197 None
198}
199
200// ---------------------------------------------------------------------------
201// Private helpers
202// ---------------------------------------------------------------------------
203
204fn env_override() -> Option<usize> {
205 std::env::var("BGE_M3_AVAILABLE_MEMORY_BYTES")
206 .ok()
207 .and_then(|v| {
208 v.parse::<usize>().ok().or_else(|| {
209 warn!(
210 value = %v,
211 "BGE_M3_AVAILABLE_MEMORY_BYTES is not a valid usize; ignoring"
212 );
213 None
214 })
215 })
216}
217
218#[cfg(target_os = "linux")]
219fn cgroup_memory() -> Option<MemoryReading> {
220 // Sentinel threshold: the cgroup v1 kernel uses a near-i64::MAX value when
221 // no limit is configured. Treat any value ≥ 1 TiB as "unlimited".
222 const ONE_TIB: usize = 1024 * 1024 * 1024 * 1024;
223
224 // --- cgroup v2: path-walk from /proc/self/cgroup ---
225 //
226 // ECS Managed Instances (Bottlerocket) do NOT set --cgroupns=private, so
227 // /sys/fs/cgroup/memory.max resolves to the host root where value is "max".
228 // The container's actual limit lives at a deeper path recorded in
229 // /proc/self/cgroup (unified v2 format: `0::<path>`).
230 //
231 // Walk ancestors deepest-first until a numeric limit < 1 TiB is found.
232 // If the entire walk yields "max", fall through to cgroup v1 then host_ram.
233 if let Some(reading) = cgroup_v2_walk("/sys/fs/cgroup", "/proc/self/cgroup") {
234 return Some(reading);
235 }
236
237 // --- cgroup v1: /sys/fs/cgroup/memory/memory.limit_in_bytes ---
238 if let Ok(raw) = std::fs::read_to_string("/sys/fs/cgroup/memory/memory.limit_in_bytes") {
239 let trimmed = raw.trim();
240 if let Ok(bytes) = trimmed.parse::<usize>()
241 && bytes < ONE_TIB
242 {
243 tracing::debug!(bytes, source = "cgroup_v1", "Detected memory limit");
244 return Some(MemoryReading {
245 available_bytes: bytes,
246 source: MemorySource::CgroupV1,
247 });
248 }
249 }
250
251 None
252}
253
254/// Reads the cgroup v2 memory limit by walking ancestors of the container's
255/// cgroup path.
256///
257/// # Arguments
258///
259/// - `cgroup_fs_root`: the mountpoint of the cgroup v2 filesystem (normally
260/// `/sys/fs/cgroup`; injectable for unit tests).
261/// - `proc_self_cgroup`: path to the per-process cgroup file (normally
262/// `/proc/self/cgroup`; injectable for unit tests).
263///
264/// Parses the unified-hierarchy line (`0::<path>`), then iterates from the
265/// deepest ancestor up to the root, reading `memory.max` at each level.
266/// Returns the first numeric limit found that is below 1 TiB, or `None`
267/// when the entire walk yields `"max"` or the file is unreadable.
268#[cfg(target_os = "linux")]
269pub(crate) fn cgroup_v2_walk(
270 cgroup_fs_root: &str,
271 proc_self_cgroup: &str,
272) -> Option<MemoryReading> {
273 const ONE_TIB: usize = 1024 * 1024 * 1024 * 1024;
274
275 let cgroup_content = std::fs::read_to_string(proc_self_cgroup).ok()?;
276
277 // Unified hierarchy: exactly one line, format `0::<path>` (e.g. `0::/ecs.slice/…`)
278 // Legacy v1 has multiple lines, each with `<hierarchy_id>:<controllers>:<path>`.
279 // We only attempt v2 if we find the unified `0::` prefix.
280 let cgroup_rel_path = cgroup_content
281 .lines()
282 .find_map(|line| line.strip_prefix("0::"))?;
283
284 // Build the absolute cgroup directory path.
285 let cgroup_dir = std::path::PathBuf::from(cgroup_fs_root).join(
286 // Strip the leading '/' so PathBuf::join doesn't replace the root.
287 cgroup_rel_path.trim_start_matches('/'),
288 );
289
290 // Walk ancestors from deepest to shallowest (inclusive of the container
291 // cgroup itself, exclusive of the root mountpoint).
292 let mut current = cgroup_dir.as_path();
293 let fs_root = std::path::Path::new(cgroup_fs_root);
294
295 loop {
296 let memory_max = current.join("memory.max");
297 if let Ok(raw) = std::fs::read_to_string(&memory_max) {
298 let trimmed = raw.trim();
299 if trimmed != "max"
300 && let Ok(bytes) = trimmed.parse::<usize>()
301 && bytes < ONE_TIB
302 {
303 tracing::debug!(
304 bytes,
305 source = "cgroup_v2",
306 path = %memory_max.display(),
307 "Detected memory limit"
308 );
309 return Some(MemoryReading {
310 available_bytes: bytes,
311 source: MemorySource::CgroupV2,
312 });
313 }
314 }
315
316 // Stop at the cgroup filesystem root — don't walk above it.
317 if current == fs_root {
318 break;
319 }
320
321 match current.parent() {
322 Some(parent) => current = parent,
323 None => break,
324 }
325 }
326
327 None
328}
329
330/// Linux: parse `MemAvailable` from `/proc/meminfo` (kB → bytes).
331/// macOS: read total host RAM via `sysctl hw.memsize`.
332fn host_ram() -> Option<usize> {
333 #[cfg(target_os = "linux")]
334 return linux_meminfo_available();
335
336 #[cfg(target_os = "macos")]
337 return macos_host_ram();
338
339 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
340 None
341}
342
343#[cfg(target_os = "linux")]
344fn linux_meminfo_available() -> Option<usize> {
345 let content = std::fs::read_to_string("/proc/meminfo").ok()?;
346 for line in content.lines() {
347 if line.starts_with("MemAvailable:") {
348 let kb: usize = line.split_whitespace().nth(1)?.parse().ok()?;
349 return Some(kb * 1024);
350 }
351 }
352 None
353}
354
355#[cfg(target_os = "macos")]
356fn macos_host_ram() -> Option<usize> {
357 // `sysctl -n hw.memsize` returns an integer in bytes printed to stdout.
358 let output = std::process::Command::new("sysctl")
359 .args(["-n", "hw.memsize"])
360 .output()
361 .ok()?;
362 let stdout = std::str::from_utf8(&output.stdout).ok()?.trim();
363 stdout.parse::<usize>().ok()
364}
365
366#[cfg(target_os = "linux")]
367fn linux_rss() -> Option<usize> {
368 // /proc/self/statm: all values in pages.
369 // Fields: size, rss, shared, text, lib, data, dt
370 let raw = std::fs::read_to_string("/proc/self/statm").ok()?;
371 let rss_pages: usize = raw.split_whitespace().nth(1)?.parse().ok()?;
372 // SAFETY: page_size is a compile-time constant on Linux (4096 on x86_64/arm64).
373 // We use sysconf(SC_PAGESIZE) via libc-free approach: fallback to 4096.
374 let page_size = page_size_bytes();
375 Some(rss_pages * page_size)
376}
377
378#[cfg(target_os = "linux")]
379fn page_size_bytes() -> usize {
380 // Read from /proc/self/auxv would be ideal but requires parsing ELF aux
381 // vectors. Parsing /proc/$pid/smaps is too heavy. sysconf(SC_PAGESIZE)
382 // requires libc. The practical answer on Linux/x86_64 and Linux/aarch64
383 // is always 4096; we hard-code that to avoid any unsafe.
384 4096
385}
386
387#[cfg(test)]
388mod tests;