Skip to main content

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;