Skip to main content

krishiv_common/
memory_budget.rs

1#![forbid(unsafe_code)]
2
3//! Runtime memory accounting shared across operators within one task.
4
5use std::sync::Arc;
6use std::sync::atomic::{AtomicU64, Ordering};
7
8/// Per-task memory accounting.  Created from the coordinator-supplied
9/// `memory_limit_bytes` and shared (via `Arc`) across every operator that
10/// runs inside the same task slot.
11///
12/// All operations use `Relaxed` ordering: the counters are advisory limits,
13/// not synchronisation primitives. False-positives (accepting a reservation
14/// that briefly exceeds the limit due to a race) are tolerable; the
15/// hard OOM killer remains the OS fallback.
16#[derive(Debug)]
17pub struct MemoryBudget {
18    used_bytes: AtomicU64,
19    /// High-water mark of `used_bytes` over the budget's lifetime.
20    peak_bytes: AtomicU64,
21    limit_bytes: Option<u64>,
22}
23
24impl MemoryBudget {
25    /// Create a budget with no limit (used when the assignment carries no
26    /// `memory_limit_bytes`).
27    pub fn unlimited() -> Arc<Self> {
28        Arc::new(Self {
29            used_bytes: AtomicU64::new(0),
30            peak_bytes: AtomicU64::new(0),
31            limit_bytes: None,
32        })
33    }
34
35    /// Create a budget capped at `limit_bytes`.
36    pub fn limited(limit_bytes: u64) -> Arc<Self> {
37        Arc::new(Self {
38            used_bytes: AtomicU64::new(0),
39            peak_bytes: AtomicU64::new(0),
40            limit_bytes: Some(limit_bytes),
41        })
42    }
43
44    /// Build from the optional proto field (None → unlimited).
45    pub fn from_limit(limit_bytes: Option<u64>) -> Arc<Self> {
46        match limit_bytes {
47            Some(b) if b > 0 => Self::limited(b),
48            _ => Self::unlimited(),
49        }
50    }
51
52    /// Return the configured limit, or `None` for unlimited budgets.
53    pub fn limit(&self) -> Option<u64> {
54        self.limit_bytes
55    }
56
57    /// Current byte count tracked by this budget.
58    pub fn used_bytes(&self) -> u64 {
59        self.used_bytes.load(Ordering::Relaxed)
60    }
61
62    /// High-water mark of `used_bytes` over this budget's lifetime.
63    pub fn peak_bytes(&self) -> u64 {
64        self.peak_bytes.load(Ordering::Relaxed)
65    }
66
67    /// Try to reserve `bytes` bytes of memory.
68    ///
69    /// Returns `true` if the reservation was accepted (or the budget is
70    /// unlimited).  Returns `false` if doing so would exceed `limit_bytes`; the
71    /// caller should spill or return an OOM error.
72    pub fn try_reserve(&self, bytes: u64) -> bool {
73        let Some(limit) = self.limit_bytes else {
74            let new = self
75                .used_bytes
76                .fetch_add(bytes, Ordering::Relaxed)
77                .saturating_add(bytes);
78            self.peak_bytes.fetch_max(new, Ordering::Relaxed);
79            return true;
80        };
81        let prev = self.used_bytes.fetch_add(bytes, Ordering::Relaxed);
82        if prev + bytes > limit {
83            // Roll back the speculative add.
84            self.used_bytes.fetch_sub(bytes, Ordering::Relaxed);
85            false
86        } else {
87            self.peak_bytes
88                .fetch_max(prev.saturating_add(bytes), Ordering::Relaxed);
89            true
90        }
91    }
92
93    /// Release previously reserved `bytes`. Saturates at zero on underflow.
94    pub fn release(&self, bytes: u64) {
95        let _ = self
96            .used_bytes
97            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |cur| {
98                Some(cur.saturating_sub(bytes))
99            });
100    }
101
102    /// Remaining bytes before the limit is hit, or `None` for unlimited.
103    pub fn remaining(&self) -> Option<u64> {
104        self.limit_bytes
105            .map(|l| l.saturating_sub(self.used_bytes()))
106    }
107}
108
109/// Read this process's cgroup memory limit in bytes (v2 first, then v1).
110///
111/// Returns `None` when no limit applies: file absent (not in a container),
112/// v2 `max`, or a v1 sentinel of effectively-unlimited (≥ 2^60).
113pub fn cgroup_memory_limit_bytes() -> Option<u64> {
114    const V1_UNLIMITED_FLOOR: u64 = 1 << 60;
115    for path in [
116        "/sys/fs/cgroup/memory.max",
117        "/sys/fs/cgroup/memory/memory.limit_in_bytes",
118    ] {
119        if let Ok(raw) = std::fs::read_to_string(path) {
120            let trimmed = raw.trim();
121            if trimmed == "max" {
122                return None;
123            }
124            return trimmed
125                .parse::<u64>()
126                .ok()
127                .filter(|&n| n > 0 && n < V1_UNLIMITED_FLOOR);
128        }
129    }
130    None
131}
132
133/// What the cgroup is actually charged, split by kind.
134///
135/// The budgets in this crate all track *anonymous* memory — heap the process
136/// asked for. The kernel charges a container for more than that, and the
137/// difference is invisible to every `MemoryBudget` and to DataFusion's
138/// `MemoryPool`. On the SF100 benchmark that difference was 1.5–1.8 GB of a
139/// 4.5 GB limit, and three separate heap-side "fixes" failed to stop the OOM
140/// kills because the memory was never on the heap.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub struct CgroupMemoryUsage {
143    /// Total charged to the cgroup — what `memory.max` is compared against.
144    pub current_bytes: u64,
145    /// Anonymous memory: heap, stacks. What the budgets model.
146    pub anon_bytes: u64,
147    /// Page cache charged to this cgroup, including cache for files *this*
148    /// container wrote. Reclaimable while clean, but not while dirty — and a
149    /// burst of dirty pages is what turns this into an OOM kill rather than
150    /// into reclaim.
151    pub file_bytes: u64,
152}
153
154impl CgroupMemoryUsage {
155    /// Bytes charged to the cgroup that anonymous accounting cannot explain.
156    pub fn unaccounted_bytes(&self) -> u64 {
157        self.current_bytes.saturating_sub(self.anon_bytes)
158    }
159}
160
161/// Read the cgroup v2 memory breakdown, if one is present.
162///
163/// Returns `None` outside a cgroup v2 container (including on cgroup v1, whose
164/// `memory.stat` uses different keys) — callers treat that as "unconstrained"
165/// exactly like [`cgroup_memory_limit_bytes`] does.
166pub fn cgroup_memory_usage() -> Option<CgroupMemoryUsage> {
167    let current = std::fs::read_to_string("/sys/fs/cgroup/memory.current")
168        .ok()?
169        .trim()
170        .parse::<u64>()
171        .ok()?;
172    let stat = std::fs::read_to_string("/sys/fs/cgroup/memory.stat").ok()?;
173    let mut anon = 0u64;
174    let mut file = 0u64;
175    for line in stat.lines() {
176        let mut parts = line.split_ascii_whitespace();
177        match (parts.next(), parts.next()) {
178            (Some("anon"), Some(v)) => anon = v.parse().unwrap_or(0),
179            (Some("file"), Some(v)) => file = v.parse().unwrap_or(0),
180            _ => {}
181        }
182    }
183    Some(CgroupMemoryUsage {
184        current_bytes: current,
185        anon_bytes: anon,
186        file_bytes: file,
187    })
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    /// The reader must degrade to `None` rather than panicking or inventing
195    /// numbers when there is no cgroup v2 to read — the same contract as
196    /// `cgroup_memory_limit_bytes`, and the reason callers can log it blindly.
197    #[test]
198    fn cgroup_usage_is_absent_or_internally_consistent() {
199        if let Some(usage) = cgroup_memory_usage() {
200            assert!(
201                usage.current_bytes >= usage.anon_bytes,
202                "anon ({}) cannot exceed total charge ({})",
203                usage.anon_bytes,
204                usage.current_bytes
205            );
206            assert_eq!(
207                usage.unaccounted_bytes(),
208                usage.current_bytes - usage.anon_bytes
209            );
210        }
211    }
212
213    #[test]
214    fn unlimited_always_accepts() {
215        let b = MemoryBudget::unlimited();
216        assert!(b.try_reserve(u64::MAX / 2));
217        assert!(b.try_reserve(u64::MAX / 2));
218        assert!(b.limit().is_none());
219        assert!(b.remaining().is_none());
220    }
221
222    #[test]
223    fn limited_rejects_over_limit() {
224        let b = MemoryBudget::limited(100);
225        assert!(b.try_reserve(60));
226        assert!(!b.try_reserve(60)); // would be 120 > 100
227        assert_eq!(b.used_bytes(), 60); // rolled back
228    }
229
230    #[test]
231    fn release_reduces_counter() {
232        let b = MemoryBudget::limited(100);
233        assert!(b.try_reserve(80));
234        b.release(40);
235        assert_eq!(b.used_bytes(), 40);
236        assert!(b.try_reserve(60)); // 40 + 60 = 100 ≤ 100
237    }
238
239    #[test]
240    fn release_saturates_at_zero() {
241        let b = MemoryBudget::unlimited();
242        b.release(999); // no panic
243        assert_eq!(b.used_bytes(), 0);
244    }
245
246    #[test]
247    fn remaining_tracks_available() {
248        let b = MemoryBudget::limited(200);
249        assert_eq!(b.remaining(), Some(200));
250        b.try_reserve(50);
251        assert_eq!(b.remaining(), Some(150));
252    }
253
254    #[test]
255    fn from_limit_none_is_unlimited() {
256        let b = MemoryBudget::from_limit(None);
257        assert!(b.limit().is_none());
258    }
259
260    #[test]
261    fn from_limit_zero_is_unlimited() {
262        let b = MemoryBudget::from_limit(Some(0));
263        assert!(b.limit().is_none());
264    }
265
266    #[test]
267    fn peak_tracks_high_water_mark() {
268        let b = MemoryBudget::limited(100);
269        assert!(b.try_reserve(80));
270        b.release(80);
271        assert!(b.try_reserve(30));
272        assert_eq!(b.used_bytes(), 30);
273        assert_eq!(b.peak_bytes(), 80, "peak must survive releases");
274    }
275
276    #[test]
277    fn peak_ignores_rejected_reservations() {
278        let b = MemoryBudget::limited(100);
279        assert!(b.try_reserve(50));
280        assert!(!b.try_reserve(60)); // rejected
281        assert_eq!(b.peak_bytes(), 50);
282    }
283
284    #[test]
285    fn peak_tracked_for_unlimited_budget() {
286        let b = MemoryBudget::unlimited();
287        b.try_reserve(1000);
288        b.release(500);
289        assert_eq!(b.peak_bytes(), 1000);
290    }
291}