Skip to main content

rlx_runtime/
memory_estimate.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Pre-load memory estimation (plan #35).
6//!
7//! Borrowed from MAX's `max/python/max/pipelines/` pattern: model
8//! peak memory is estimated *before* weights load. On Apple
9//! Silicon this matters disproportionately — unified memory is
10//! shared with the OS, so a model that "would fit on a 96 GB
11//! Mac" can still OOM if you spawn it during a heavy Spotlight
12//! re-index.
13//!
14//! Three components:
15//!
16//!   - **Activation working set** — peak arena bytes. Already
17//!     computed by `rlx_opt::memory::plan_memory(&graph)`; we
18//!     just expose it.
19//!   - **Weight bytes** — sum of registered weights from a
20//!     [`WeightRegistry`]. Aliases (tied embeddings) don't
21//!     double-count.
22//!   - **Per-batch input bytes** — bytes the user is going to
23//!     hand in via `compiled.run()`. Driven by graph inputs.
24//!
25//! [`MemoryEstimate::peak_bytes`] is the sum. [`MemoryEstimate::
26//! fits_in`] takes a budget and returns the gating decision plus
27//! a structured reason.
28
29use crate::expert_pool::gpu_expert_budget_from_vram;
30use crate::weight_registry::WeightRegistry;
31use rlx_ir::Graph;
32use rlx_opt::memory::plan_memory;
33
34#[derive(Debug, Clone)]
35pub struct MemoryEstimate {
36    /// Peak working-set during one forward pass — output of the
37    /// memory planner.
38    pub activation_bytes: usize,
39    /// Total weight bytes, deduplicated against tied aliases.
40    pub weight_bytes: usize,
41    /// Sum of input tensor sizes for one call (computed from
42    /// graph inputs, ignoring outputs since they overlap with
43    /// the activation arena).
44    pub input_bytes: usize,
45}
46
47impl MemoryEstimate {
48    pub fn peak_bytes(&self) -> usize {
49        self.activation_bytes + self.weight_bytes + self.input_bytes
50    }
51
52    /// True if the estimate fits within `budget_bytes`. The
53    /// `Result` carries the deficit on failure so callers can
54    /// surface a useful error message.
55    pub fn fits_in(&self, budget_bytes: usize) -> Result<(), MemoryDeficit> {
56        let peak = self.peak_bytes();
57        if peak <= budget_bytes {
58            Ok(())
59        } else {
60            Err(MemoryDeficit {
61                budget_bytes,
62                peak_bytes: peak,
63                activation_bytes: self.activation_bytes,
64                weight_bytes: self.weight_bytes,
65                input_bytes: self.input_bytes,
66            })
67        }
68    }
69}
70
71#[derive(Debug, Clone)]
72pub struct MemoryDeficit {
73    pub budget_bytes: usize,
74    pub peak_bytes: usize,
75    pub activation_bytes: usize,
76    pub weight_bytes: usize,
77    pub input_bytes: usize,
78}
79
80impl std::fmt::Display for MemoryDeficit {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        let over = self.peak_bytes - self.budget_bytes;
83        write!(
84            f,
85            "estimated peak {peak_mb:.1} MiB exceeds budget {budget_mb:.1} MiB by {over_mb:.1} MiB \
86             (activation {act_mb:.1}, weights {w_mb:.1}, inputs {in_mb:.1})",
87            peak_mb = self.peak_bytes as f64 / 1024.0 / 1024.0,
88            budget_mb = self.budget_bytes as f64 / 1024.0 / 1024.0,
89            over_mb = over as f64 / 1024.0 / 1024.0,
90            act_mb = self.activation_bytes as f64 / 1024.0 / 1024.0,
91            w_mb = self.weight_bytes as f64 / 1024.0 / 1024.0,
92            in_mb = self.input_bytes as f64 / 1024.0 / 1024.0,
93        )
94    }
95}
96
97impl std::error::Error for MemoryDeficit {}
98
99/// Estimate peak memory for running `graph` on a session bound to
100/// `registry`. Pure analysis — runs the memory planner internally
101/// and queries the registry for weight bytes; doesn't compile or
102/// execute.
103/// MoE offload sizing (TIDE `enable_predictive_expert_offload`).
104#[derive(Debug, Clone)]
105pub struct MoeOffloadEstimate {
106    /// Bytes for one expert FFN (gate+up+down) at runtime dtype.
107    pub expert_param_bytes: usize,
108    pub num_moe_layers: usize,
109    pub num_experts: usize,
110    /// Experts pinned on device per layer after budget clamp.
111    pub gpu_expert_budget_per_layer: usize,
112    /// All experts resident on host+device (upper bound).
113    pub all_expert_weight_bytes: usize,
114    /// Only `gpu_expert_budget_per_layer` experts per layer on device.
115    pub resident_expert_weight_bytes: usize,
116}
117
118impl MoeOffloadEstimate {
119    /// Resident expert weights + non-expert peak from [`MemoryEstimate`].
120    pub fn peak_with_offload(&self, base: &MemoryEstimate) -> usize {
121        base.activation_bytes
122            + base.input_bytes
123            + (base.weight_bytes - self.all_expert_weight_bytes)
124            + self.resident_expert_weight_bytes
125    }
126}
127
128/// Compute GPU expert budget from a memory budget (unified RAM or VRAM).
129pub fn estimate_moe_offload(
130    expert_param_bytes: usize,
131    num_moe_layers: usize,
132    num_experts: usize,
133    max_gpu_experts_per_layer: usize,
134    memory_budget_bytes: usize,
135    reserve_fraction: f32,
136) -> MoeOffloadEstimate {
137    let reserve_bytes = (memory_budget_bytes as f64 * reserve_fraction as f64) as usize;
138    let gpu_budget = gpu_expert_budget_from_vram(
139        memory_budget_bytes,
140        reserve_bytes,
141        expert_param_bytes,
142        num_moe_layers,
143        max_gpu_experts_per_layer,
144        num_experts,
145    );
146    let all_expert = expert_param_bytes
147        .saturating_mul(num_experts)
148        .saturating_mul(num_moe_layers);
149    let resident_expert = expert_param_bytes
150        .saturating_mul(gpu_budget)
151        .saturating_mul(num_moe_layers);
152    MoeOffloadEstimate {
153        expert_param_bytes,
154        num_moe_layers,
155        num_experts,
156        gpu_expert_budget_per_layer: gpu_budget,
157        all_expert_weight_bytes: all_expert,
158        resident_expert_weight_bytes: resident_expert,
159    }
160}
161
162pub fn estimate(graph: &Graph, registry: &WeightRegistry) -> MemoryEstimate {
163    let plan = plan_memory(graph);
164    // Inputs: walk the graph once; sum each Op::Input's shape.
165    let mut input_bytes = 0usize;
166    for node in graph.nodes() {
167        if matches!(node.op, rlx_ir::Op::Input { .. }) {
168            input_bytes += node.shape.size_bytes().unwrap_or(0);
169        }
170    }
171    MemoryEstimate {
172        activation_bytes: plan.arena_size,
173        weight_bytes: registry.total_bytes(),
174        input_bytes,
175    }
176}
177
178/// Available unified-memory budget on the running machine. On
179/// macOS / iOS reads `hw.memsize` via sysctl; everywhere else returns
180/// `None` so callers can fall back to a user-supplied budget.
181pub fn available_unified_memory() -> Option<usize> {
182    #[cfg(target_vendor = "apple")]
183    {
184        use std::ffi::CString;
185        let cname = CString::new("hw.memsize").ok()?;
186        let mut val: u64 = 0;
187        let mut len = std::mem::size_of::<u64>();
188        unsafe extern "C" {
189            fn sysctlbyname(
190                name: *const std::os::raw::c_char,
191                oldp: *mut std::os::raw::c_void,
192                oldlenp: *mut usize,
193                newp: *mut std::os::raw::c_void,
194                newlen: usize,
195            ) -> std::os::raw::c_int;
196        }
197        let rc = unsafe {
198            sysctlbyname(
199                cname.as_ptr(),
200                &mut val as *mut u64 as *mut _,
201                &mut len,
202                std::ptr::null_mut(),
203                0,
204            )
205        };
206        if rc == 0 { Some(val as usize) } else { None }
207    }
208    #[cfg(not(target_vendor = "apple"))]
209    {
210        None
211    }
212}
213
214/// Default soft cap as a fraction of physical RAM (stay below OOM).
215pub const DEFAULT_SOFT_MEMORY_FRACTION: f64 = 0.80;
216
217/// Fraction of physical RAM treated as a soft working-set cap.
218/// Override with `RLX_SOFT_MEMORY_FRACTION` (e.g. `0.8`).
219pub fn soft_memory_fraction() -> f64 {
220    rlx_ir::env::var("RLX_SOFT_MEMORY_FRACTION")
221        .and_then(|s| s.parse::<f64>().ok())
222        .filter(|&f| f > 0.0 && f <= 1.0)
223        .unwrap_or(DEFAULT_SOFT_MEMORY_FRACTION)
224}
225
226/// Soft RSS budget: `physical_ram * soft_memory_fraction()`.
227/// Returns `None` when physical RAM is unknown (non-macOS without override).
228pub fn soft_memory_budget_bytes() -> Option<usize> {
229    if let Some(v) = rlx_ir::env::var("RLX_SOFT_MEMORY_BUDGET_BYTES") {
230        if let Ok(n) = v.parse::<usize>() {
231            return Some(n);
232        }
233    }
234    available_unified_memory()
235        .map(|total| ((total as f64) * soft_memory_fraction()).floor() as usize)
236}
237
238/// Current process resident set size, when available.
239pub fn process_rss_bytes() -> Option<usize> {
240    #[cfg(target_vendor = "apple")]
241    {
242        use std::mem::MaybeUninit;
243        unsafe extern "C" {
244            fn mach_task_self() -> u32;
245            fn task_info(
246                target_task: u32,
247                flavor: u32,
248                task_info_out: *mut std::os::raw::c_char,
249                task_info_out_cnt: *mut u32,
250            ) -> i32;
251        }
252        const TASK_BASIC_INFO: u32 = 5;
253        #[repr(C)]
254        struct TaskBasicInfo {
255            suspend_count: u32,
256            virtual_size: u64,
257            resident_size: u64,
258            user_time: u64,
259            system_time: u64,
260            policy: i32,
261        }
262        let mut info = MaybeUninit::<TaskBasicInfo>::uninit();
263        let mut count = (std::mem::size_of::<TaskBasicInfo>() / std::mem::size_of::<u32>()) as u32;
264        let rc = unsafe {
265            task_info(
266                mach_task_self(),
267                TASK_BASIC_INFO,
268                info.as_mut_ptr().cast(),
269                &mut count,
270            )
271        };
272        if rc == 0 {
273            Some(unsafe { info.assume_init() }.resident_size as usize)
274        } else {
275            None
276        }
277    }
278    #[cfg(target_os = "linux")]
279    {
280        let statm = std::fs::read_to_string("/proc/self/statm").ok()?;
281        let resident_pages = statm.split_whitespace().nth(1)?.parse::<usize>().ok()?;
282        Some(resident_pages.saturating_mul(page_size()))
283    }
284    #[cfg(not(any(target_vendor = "apple", target_os = "linux")))]
285    {
286        None
287    }
288}
289
290#[cfg(target_os = "linux")]
291fn page_size() -> usize {
292    unsafe extern "C" {
293        fn sysconf(name: i32) -> i64;
294    }
295    const _SC_PAGESIZE: i32 = 30;
296    let ps = unsafe { sysconf(_SC_PAGESIZE) };
297    if ps > 0 { ps as usize } else { 4096 }
298}
299
300/// Bytes remaining before the soft budget (`budget - current RSS`).
301pub fn memory_headroom_bytes() -> Option<usize> {
302    let budget = soft_memory_budget_bytes()?;
303    let rss = process_rss_bytes().unwrap_or(0);
304    Some(budget.saturating_sub(rss))
305}
306
307/// True when `current_rss + additional` would exceed the soft budget.
308/// Unknown budget → false (do not block).
309pub fn would_exceed_soft_budget(additional_bytes: usize) -> bool {
310    let Some(budget) = soft_memory_budget_bytes() else {
311        return false;
312    };
313    let rss = process_rss_bytes().unwrap_or(0);
314    exceeds_budget(rss, additional_bytes, budget)
315}
316
317/// Pure boundary predicate behind [`would_exceed_soft_budget`]: would a process
318/// already using `rss` bytes exceed `budget` after allocating `additional`
319/// more? Split out from the live RSS/budget probes so it is deterministically
320/// testable (the live values fluctuate under concurrent allocation).
321fn exceeds_budget(rss: usize, additional: usize, budget: usize) -> bool {
322    rss.saturating_add(additional) > budget
323}
324
325/// Conservative peak for one LLaMA decode graph compile with F32 params (3B class).
326/// Computed in `u64` and clamped — the raw constants exceed 32-bit `usize` (wasm).
327pub fn llama_decode_bucket_compile_peak_bytes() -> usize {
328    rlx_ir::env::var("RLX_DECODE_BUCKET_PEAK_BYTES")
329        .and_then(|s| s.parse::<u64>().ok())
330        .unwrap_or(12 * 1024 * 1024 * 1024)
331        .min(usize::MAX as u64) as usize
332}
333
334/// Lazy per-step decode compile (GGUF on demand, no resident param cache).
335pub fn llama_decode_oneshot_compile_peak_bytes() -> usize {
336    rlx_ir::env::var("RLX_DECODE_ONESHOT_PEAK_BYTES")
337        .and_then(|s| s.parse::<u64>().ok())
338        .unwrap_or(4 * 1024 * 1024 * 1024)
339        .min(usize::MAX as u64) as usize
340}
341
342/// Steady-state resident bytes of ONE compiled decode-bucket graph — the
343/// packed (quantized) weights held in its arena after `set_param`. For a
344/// 3B-class Q4_K_M model this is ~2 GiB. This is distinct from
345/// [`llama_decode_bucket_compile_peak_bytes`], which is the transient
346/// compile-time spike (released once compilation finishes). Callers use
347/// this to decide how many decode buckets to keep resident across
348/// utterances under the soft memory budget: on a roomy machine the whole
349/// bucket ladder stays cached (no cross-utterance recompile / re-upload),
350/// while a memory-constrained machine trims buckets to fit. Override with
351/// `RLX_DECODE_BUCKET_RESIDENT_BYTES` to match a different model size.
352pub fn llama_decode_bucket_resident_bytes() -> usize {
353    rlx_ir::env::var("RLX_DECODE_BUCKET_RESIDENT_BYTES")
354        .and_then(|s| s.parse::<u64>().ok())
355        .unwrap_or(2 * 1024 * 1024 * 1024)
356        .min(usize::MAX as u64) as usize
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use crate::weight_registry::{WeightKind, WeightRegistry};
363    use rlx_ir::*;
364    use std::sync::Arc;
365
366    fn small_graph() -> Graph {
367        let f = DType::F32;
368        let mut g = Graph::new("est");
369        let x = g.input("x", Shape::new(&[2, 16], f)); // 128 B
370        let w = g.param("w", Shape::new(&[16, 32], f)); // weight via registry
371        let mm = g.matmul(x, w, Shape::new(&[2, 32], f)); // 256 B activation
372        g.set_outputs(vec![mm]);
373        g
374    }
375
376    #[test]
377    fn estimate_sums_components() {
378        let g = small_graph();
379        let mut reg = WeightRegistry::new();
380        reg.register(
381            "w",
382            Shape::new(&[16, 32], DType::F32),
383            Arc::from(vec![0u8; 16 * 32 * 4]),
384            WeightKind::Base,
385        );
386        let est = estimate(&g, &reg);
387        assert!(
388            est.activation_bytes >= 256,
389            "activation arena should hold mm output"
390        );
391        assert_eq!(est.weight_bytes, 16 * 32 * 4);
392        assert_eq!(est.input_bytes, 2 * 16 * 4);
393        assert!(
394            est.peak_bytes() >= est.activation_bytes + est.weight_bytes + est.input_bytes
395                || est.peak_bytes() == est.activation_bytes + est.weight_bytes + est.input_bytes
396        );
397    }
398
399    #[test]
400    fn fits_in_passes_with_room() {
401        let g = small_graph();
402        let mut reg = WeightRegistry::new();
403        reg.register(
404            "w",
405            Shape::new(&[16, 32], DType::F32),
406            Arc::from(vec![0u8; 2048]),
407            WeightKind::Base,
408        );
409        let est = estimate(&g, &reg);
410        assert!(
411            est.fits_in(1 << 30).is_ok(),
412            "1 GiB budget should fit a tiny graph"
413        );
414    }
415
416    #[test]
417    fn fits_in_reports_deficit() {
418        let g = small_graph();
419        let mut reg = WeightRegistry::new();
420        reg.register(
421            "w",
422            Shape::new(&[16, 32], DType::F32),
423            Arc::from(vec![0u8; 100_000_000]),
424            WeightKind::Base,
425        );
426        let est = estimate(&g, &reg);
427        let err = est.fits_in(1024).unwrap_err();
428        assert!(err.peak_bytes > err.budget_bytes);
429        assert!(format!("{err}").contains("exceeds"));
430    }
431
432    #[test]
433    fn available_memory_returns_something_on_macos() {
434        // basic test — on macOS / iOS this should return Some(>0)
435        // (both read `hw.memsize`). Elsewhere (CI) we accept None.
436        if cfg!(target_vendor = "apple") {
437            let mem = available_unified_memory();
438            assert!(mem.is_some());
439            assert!(mem.unwrap() > 0);
440        }
441    }
442
443    #[test]
444    fn soft_budget_is_fraction_of_physical() {
445        if let Some(total) = available_unified_memory() {
446            let budget = soft_memory_budget_bytes().unwrap();
447            let expect = ((total as f64) * DEFAULT_SOFT_MEMORY_FRACTION).floor() as usize;
448            assert_eq!(budget, expect);
449        }
450    }
451
452    #[test]
453    fn would_exceed_respects_headroom() {
454        // Deterministic boundary check on the pure predicate — independent of
455        // the live process RSS (which fluctuates under concurrent test
456        // execution and previously made this assertion flaky).
457        let budget = 1_000_usize;
458        let rss = 600_usize;
459        let headroom = budget - rss; // 400
460
461        // Exactly filling the headroom does NOT exceed; one byte over does.
462        assert!(!exceeds_budget(rss, headroom, budget));
463        assert!(exceeds_budget(rss, headroom + 1, budget));
464
465        // Allocating nothing never exceeds, even at the budget edge.
466        assert!(!exceeds_budget(budget, 0, budget));
467        assert!(exceeds_budget(budget, 1, budget));
468
469        // Saturating add: a huge request can't wrap to a small value.
470        assert!(exceeds_budget(usize::MAX, 1, budget));
471    }
472
473    #[test]
474    fn would_exceed_soft_budget_smoke() {
475        // The live path must not panic and must agree with the pure predicate
476        // for an obviously-too-large request.
477        if soft_memory_budget_bytes().is_some() {
478            assert!(would_exceed_soft_budget(usize::MAX));
479        }
480    }
481}