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    std::env::var("RLX_SOFT_MEMORY_FRACTION")
221        .ok()
222        .and_then(|s| s.parse::<f64>().ok())
223        .filter(|&f| f > 0.0 && f <= 1.0)
224        .unwrap_or(DEFAULT_SOFT_MEMORY_FRACTION)
225}
226
227/// Soft RSS budget: `physical_ram * soft_memory_fraction()`.
228/// Returns `None` when physical RAM is unknown (non-macOS without override).
229pub fn soft_memory_budget_bytes() -> Option<usize> {
230    if let Ok(v) = std::env::var("RLX_SOFT_MEMORY_BUDGET_BYTES") {
231        if let Ok(n) = v.parse::<usize>() {
232            return Some(n);
233        }
234    }
235    available_unified_memory()
236        .map(|total| ((total as f64) * soft_memory_fraction()).floor() as usize)
237}
238
239/// Current process resident set size, when available.
240pub fn process_rss_bytes() -> Option<usize> {
241    #[cfg(target_vendor = "apple")]
242    {
243        use std::mem::MaybeUninit;
244        unsafe extern "C" {
245            fn mach_task_self() -> u32;
246            fn task_info(
247                target_task: u32,
248                flavor: u32,
249                task_info_out: *mut std::os::raw::c_char,
250                task_info_out_cnt: *mut u32,
251            ) -> i32;
252        }
253        const TASK_BASIC_INFO: u32 = 5;
254        #[repr(C)]
255        struct TaskBasicInfo {
256            suspend_count: u32,
257            virtual_size: u64,
258            resident_size: u64,
259            user_time: u64,
260            system_time: u64,
261            policy: i32,
262        }
263        let mut info = MaybeUninit::<TaskBasicInfo>::uninit();
264        let mut count = (std::mem::size_of::<TaskBasicInfo>() / std::mem::size_of::<u32>()) as u32;
265        let rc = unsafe {
266            task_info(
267                mach_task_self(),
268                TASK_BASIC_INFO,
269                info.as_mut_ptr().cast(),
270                &mut count,
271            )
272        };
273        if rc == 0 {
274            Some(unsafe { info.assume_init() }.resident_size as usize)
275        } else {
276            None
277        }
278    }
279    #[cfg(target_os = "linux")]
280    {
281        let statm = std::fs::read_to_string("/proc/self/statm").ok()?;
282        let resident_pages = statm.split_whitespace().nth(1)?.parse::<usize>().ok()?;
283        Some(resident_pages.saturating_mul(page_size()))
284    }
285    #[cfg(not(any(target_vendor = "apple", target_os = "linux")))]
286    {
287        None
288    }
289}
290
291#[cfg(target_os = "linux")]
292fn page_size() -> usize {
293    unsafe extern "C" {
294        fn sysconf(name: i32) -> i64;
295    }
296    const _SC_PAGESIZE: i32 = 30;
297    let ps = unsafe { sysconf(_SC_PAGESIZE) };
298    if ps > 0 { ps as usize } else { 4096 }
299}
300
301/// Bytes remaining before the soft budget (`budget - current RSS`).
302pub fn memory_headroom_bytes() -> Option<usize> {
303    let budget = soft_memory_budget_bytes()?;
304    let rss = process_rss_bytes().unwrap_or(0);
305    Some(budget.saturating_sub(rss))
306}
307
308/// True when `current_rss + additional` would exceed the soft budget.
309/// Unknown budget → false (do not block).
310pub fn would_exceed_soft_budget(additional_bytes: usize) -> bool {
311    let Some(budget) = soft_memory_budget_bytes() else {
312        return false;
313    };
314    let rss = process_rss_bytes().unwrap_or(0);
315    exceeds_budget(rss, additional_bytes, budget)
316}
317
318/// Pure boundary predicate behind [`would_exceed_soft_budget`]: would a process
319/// already using `rss` bytes exceed `budget` after allocating `additional`
320/// more? Split out from the live RSS/budget probes so it is deterministically
321/// testable (the live values fluctuate under concurrent allocation).
322fn exceeds_budget(rss: usize, additional: usize, budget: usize) -> bool {
323    rss.saturating_add(additional) > budget
324}
325
326/// Conservative peak for one LLaMA decode graph compile with F32 params (3B class).
327/// Computed in `u64` and clamped — the raw constants exceed 32-bit `usize` (wasm).
328pub fn llama_decode_bucket_compile_peak_bytes() -> usize {
329    std::env::var("RLX_DECODE_BUCKET_PEAK_BYTES")
330        .ok()
331        .and_then(|s| s.parse::<u64>().ok())
332        .unwrap_or(12 * 1024 * 1024 * 1024)
333        .min(usize::MAX as u64) as usize
334}
335
336/// Lazy per-step decode compile (GGUF on demand, no resident param cache).
337pub fn llama_decode_oneshot_compile_peak_bytes() -> usize {
338    std::env::var("RLX_DECODE_ONESHOT_PEAK_BYTES")
339        .ok()
340        .and_then(|s| s.parse::<u64>().ok())
341        .unwrap_or(4 * 1024 * 1024 * 1024)
342        .min(usize::MAX as u64) as usize
343}
344
345/// Steady-state resident bytes of ONE compiled decode-bucket graph — the
346/// packed (quantized) weights held in its arena after `set_param`. For a
347/// 3B-class Q4_K_M model this is ~2 GiB. This is distinct from
348/// [`llama_decode_bucket_compile_peak_bytes`], which is the transient
349/// compile-time spike (released once compilation finishes). Callers use
350/// this to decide how many decode buckets to keep resident across
351/// utterances under the soft memory budget: on a roomy machine the whole
352/// bucket ladder stays cached (no cross-utterance recompile / re-upload),
353/// while a memory-constrained machine trims buckets to fit. Override with
354/// `RLX_DECODE_BUCKET_RESIDENT_BYTES` to match a different model size.
355pub fn llama_decode_bucket_resident_bytes() -> usize {
356    std::env::var("RLX_DECODE_BUCKET_RESIDENT_BYTES")
357        .ok()
358        .and_then(|s| s.parse::<u64>().ok())
359        .unwrap_or(2 * 1024 * 1024 * 1024)
360        .min(usize::MAX as u64) as usize
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::weight_registry::{WeightKind, WeightRegistry};
367    use rlx_ir::*;
368    use std::sync::Arc;
369
370    fn small_graph() -> Graph {
371        let f = DType::F32;
372        let mut g = Graph::new("est");
373        let x = g.input("x", Shape::new(&[2, 16], f)); // 128 B
374        let w = g.param("w", Shape::new(&[16, 32], f)); // weight via registry
375        let mm = g.matmul(x, w, Shape::new(&[2, 32], f)); // 256 B activation
376        g.set_outputs(vec![mm]);
377        g
378    }
379
380    #[test]
381    fn estimate_sums_components() {
382        let g = small_graph();
383        let mut reg = WeightRegistry::new();
384        reg.register(
385            "w",
386            Shape::new(&[16, 32], DType::F32),
387            Arc::from(vec![0u8; 16 * 32 * 4]),
388            WeightKind::Base,
389        );
390        let est = estimate(&g, &reg);
391        assert!(
392            est.activation_bytes >= 256,
393            "activation arena should hold mm output"
394        );
395        assert_eq!(est.weight_bytes, 16 * 32 * 4);
396        assert_eq!(est.input_bytes, 2 * 16 * 4);
397        assert!(
398            est.peak_bytes() >= est.activation_bytes + est.weight_bytes + est.input_bytes
399                || est.peak_bytes() == est.activation_bytes + est.weight_bytes + est.input_bytes
400        );
401    }
402
403    #[test]
404    fn fits_in_passes_with_room() {
405        let g = small_graph();
406        let mut reg = WeightRegistry::new();
407        reg.register(
408            "w",
409            Shape::new(&[16, 32], DType::F32),
410            Arc::from(vec![0u8; 2048]),
411            WeightKind::Base,
412        );
413        let est = estimate(&g, &reg);
414        assert!(
415            est.fits_in(1 << 30).is_ok(),
416            "1 GiB budget should fit a tiny graph"
417        );
418    }
419
420    #[test]
421    fn fits_in_reports_deficit() {
422        let g = small_graph();
423        let mut reg = WeightRegistry::new();
424        reg.register(
425            "w",
426            Shape::new(&[16, 32], DType::F32),
427            Arc::from(vec![0u8; 100_000_000]),
428            WeightKind::Base,
429        );
430        let est = estimate(&g, &reg);
431        let err = est.fits_in(1024).unwrap_err();
432        assert!(err.peak_bytes > err.budget_bytes);
433        assert!(format!("{err}").contains("exceeds"));
434    }
435
436    #[test]
437    fn available_memory_returns_something_on_macos() {
438        // basic test — on macOS / iOS this should return Some(>0)
439        // (both read `hw.memsize`). Elsewhere (CI) we accept None.
440        if cfg!(target_vendor = "apple") {
441            let mem = available_unified_memory();
442            assert!(mem.is_some());
443            assert!(mem.unwrap() > 0);
444        }
445    }
446
447    #[test]
448    fn soft_budget_is_fraction_of_physical() {
449        if let Some(total) = available_unified_memory() {
450            let budget = soft_memory_budget_bytes().unwrap();
451            let expect = ((total as f64) * DEFAULT_SOFT_MEMORY_FRACTION).floor() as usize;
452            assert_eq!(budget, expect);
453        }
454    }
455
456    #[test]
457    fn would_exceed_respects_headroom() {
458        // Deterministic boundary check on the pure predicate — independent of
459        // the live process RSS (which fluctuates under concurrent test
460        // execution and previously made this assertion flaky).
461        let budget = 1_000_usize;
462        let rss = 600_usize;
463        let headroom = budget - rss; // 400
464
465        // Exactly filling the headroom does NOT exceed; one byte over does.
466        assert!(!exceeds_budget(rss, headroom, budget));
467        assert!(exceeds_budget(rss, headroom + 1, budget));
468
469        // Allocating nothing never exceeds, even at the budget edge.
470        assert!(!exceeds_budget(budget, 0, budget));
471        assert!(exceeds_budget(budget, 1, budget));
472
473        // Saturating add: a huge request can't wrap to a small value.
474        assert!(exceeds_budget(usize::MAX, 1, budget));
475    }
476
477    #[test]
478    fn would_exceed_soft_budget_smoke() {
479        // The live path must not panic and must agree with the pure predicate
480        // for an obviously-too-large request.
481        if soft_memory_budget_bytes().is_some() {
482            assert!(would_exceed_soft_budget(usize::MAX));
483        }
484    }
485}