Skip to main content

gam_runtime/
resource.rs

1/// The library-default streamed row-chunk target (8 MiB), shared as a `const`
2/// so compile-time consumers (e.g. device tile geometry) stay in lockstep with
3/// [`ResourcePolicy::default_library`] without a runtime policy query.
4pub const LIBRARY_ROW_CHUNK_TARGET_BYTES: usize = 8 * 1024 * 1024;
5
6#[derive(Clone, Debug)]
7pub struct ResourcePolicy {
8    pub max_single_materialization_bytes: usize,
9    pub max_operator_cache_bytes: usize,
10    pub max_spatial_distance_cache_bytes: usize,
11    pub max_owned_data_cache_bytes: usize,
12    pub row_chunk_target_bytes: usize,
13    pub derivative_storage_mode: DerivativeStorageMode,
14}
15
16pub const OWNED_DATA_CACHE_MAX_ENTRIES: usize = 2;
17
18// ─────────────────────────────────────────────────────────────────────────────
19// Process-wide memory governor
20// ─────────────────────────────────────────────────────────────────────────────
21
22/// Fraction of the *detected available* memory the governor is allowed to hand
23/// out as reservations: 3/4.
24///
25/// The ledger only accounts for the large, planned allocations that route
26/// through [`MemoryGovernor::try_reserve`] (dense design materializations,
27/// covariance blocks, sampler design assemblies). Everything else — allocator
28/// slack, thread stacks, code, and small per-iteration temporaries, plus
29/// whatever the rest of the machine does concurrently — lives in the remaining
30/// quarter. The base quantity is *available* (not total) memory, so memory
31/// already committed by other processes is excluded before the fraction is
32/// applied.
33const GOVERNOR_BUDGET_NUMERATOR: u128 = 3;
34const GOVERNOR_BUDGET_DENOMINATOR: u128 = 4;
35
36/// Convert detected host/cgroup availability to the one process budget.
37///
38/// A reported zero is a real exhausted-memory signal and deliberately yields
39/// a zero budget.  In particular, it must never be replaced by a guessed
40/// positive allowance: doing so is exactly how a process in an exhausted
41/// cgroup gets killed by the kernel.
42fn governor_budget_from_available(host_available: u64, cgroup_available: Option<u64>) -> usize {
43    let available = cgroup_available
44        .map(|cgroup| host_available.min(cgroup))
45        .unwrap_or(host_available);
46    let scaled = u128::from(available) * GOVERNOR_BUDGET_NUMERATOR / GOVERNOR_BUDGET_DENOMINATOR;
47    usize::try_from(scaled).unwrap_or(usize::MAX)
48}
49
50fn detect_governor_budget_bytes() -> usize {
51    let mut sys = sysinfo::System::new();
52    sys.refresh_memory();
53    // Containers: the cgroup allowance is the real ceiling regardless of what
54    // the host machine has available. `Some(0)` is intentionally preserved.
55    governor_budget_from_available(
56        sys.available_memory(),
57        sys.cgroup_limits().map(|limits| limits.free_memory),
58    )
59}
60
61/// Typed refusal from [`MemoryGovernor::try_reserve`].
62///
63/// Carries the full ledger evidence so callers can route to a chunked or
64/// matrix-free strategy (and so error messages explain *why* dense was
65/// refused). This is a routing signal, never an abort: the process still has
66/// its unreserved headroom, the requested allocation just does not fit the
67/// joint budget.
68#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
69pub enum MemoryReservationError {
70    #[error(
71        "{context}: cannot reserve {requested_bytes} bytes; {reserved_bytes} of {budget_bytes} bytes already reserved process-wide"
72    )]
73    BudgetExceeded {
74        context: Box<str>,
75        requested_bytes: usize,
76        reserved_bytes: usize,
77        budget_bytes: usize,
78    },
79
80    #[error(
81        "{context}: dense allocation size overflow for {copies} copies of a {nrows}x{ncols} f64 matrix"
82    )]
83    SizeOverflow {
84        context: Box<str>,
85        nrows: usize,
86        ncols: usize,
87        copies: usize,
88    },
89}
90
91#[derive(Debug)]
92struct GovernorLedger {
93    budget_bytes: usize,
94    reserved_bytes: std::sync::atomic::AtomicUsize,
95}
96
97/// Process-wide byte-accounting governor for large allocations.
98///
99/// Every large planned allocation (dense design materialization, covariance
100/// block, sampler design assembly) reserves its byte footprint against one
101/// shared ledger via [`try_reserve`](Self::try_reserve) and holds the returned
102/// RAII [`MemoryReservation`] for as long as the allocation is live. Because
103/// the ledger is shared, allocations that are each individually acceptable can
104/// no longer *jointly* exceed memory: whichever request would tip the ledger
105/// past the budget gets a typed [`MemoryReservationError`] and routes to a
106/// chunked or matrix-free strategy instead. Strategy selection is thereby a
107/// continuous function of predicted live bytes vs remaining budget, not of
108/// row/column thresholds.
109///
110/// The global budget is sized once from actually-available memory (host
111/// `available_memory`, clamped by cgroup limits inside containers) — see
112/// [`GOVERNOR_BUDGET_NUMERATOR`] for the headroom rationale.
113#[derive(Debug, Clone)]
114pub struct MemoryGovernor {
115    ledger: Arc<GovernorLedger>,
116}
117
118impl MemoryGovernor {
119    /// The process-wide governor. Budget detection runs once, on first use.
120    pub fn global() -> &'static MemoryGovernor {
121        static GLOBAL: OnceLock<MemoryGovernor> = OnceLock::new();
122        GLOBAL.get_or_init(|| MemoryGovernor::with_budget(detect_governor_budget_bytes()))
123    }
124
125    /// Construct a ledger with an explicit budget. Private: production code
126    /// cannot create independent budgets — every production reservation shares
127    /// [`global`](Self::global), which calls this exactly once with the
128    /// detected budget. Unit tests use it for isolated ledgers.
129    fn with_budget(budget_bytes: usize) -> Self {
130        Self {
131            ledger: Arc::new(GovernorLedger {
132                budget_bytes,
133                reserved_bytes: std::sync::atomic::AtomicUsize::new(0),
134            }),
135        }
136    }
137
138    pub fn budget_bytes(&self) -> usize {
139        self.ledger.budget_bytes
140    }
141
142    pub fn reserved_bytes(&self) -> usize {
143        self.ledger
144            .reserved_bytes
145            .load(std::sync::atomic::Ordering::Acquire)
146    }
147
148    pub fn remaining_bytes(&self) -> usize {
149        self.ledger
150            .budget_bytes
151            .saturating_sub(self.reserved_bytes())
152    }
153
154    /// Absolute ceiling for one governed operation. Consumers reserve the
155    /// operation's complete predicted live set (matrix plus simultaneous
156    /// workspaces/copies), so the shared ledger itself is the policy.
157    pub fn single_materialization_cap_bytes(&self) -> usize {
158        self.ledger.budget_bytes
159    }
160
161    /// Reserve `bytes` against the joint ledger.
162    ///
163    /// On success the returned [`MemoryReservation`] must be held for as long
164    /// as the allocation it accounts for is live; dropping it releases the
165    /// bytes. On failure the caller receives the ledger evidence and is
166    /// expected to fall back to a chunked or matrix-free strategy.
167    pub fn try_reserve(
168        &self,
169        bytes: usize,
170        context: &str,
171    ) -> Result<MemoryReservation, MemoryReservationError> {
172        use std::sync::atomic::Ordering;
173        let mut current = self.ledger.reserved_bytes.load(Ordering::Relaxed);
174        loop {
175            let next = match current.checked_add(bytes) {
176                Some(next) if next <= self.ledger.budget_bytes => next,
177                _ => {
178                    return Err(MemoryReservationError::BudgetExceeded {
179                        context: context.into(),
180                        requested_bytes: bytes,
181                        reserved_bytes: current,
182                        budget_bytes: self.ledger.budget_bytes,
183                    });
184                }
185            };
186            match self.ledger.reserved_bytes.compare_exchange_weak(
187                current,
188                next,
189                Ordering::AcqRel,
190                Ordering::Relaxed,
191            ) {
192                Ok(_) => {
193                    return Ok(MemoryReservation {
194                        ledger: Arc::clone(&self.ledger),
195                        bytes,
196                    });
197                }
198                Err(observed) => current = observed,
199            }
200        }
201    }
202
203    /// Reserve the footprint of a dense `nrows × ncols` `f64` matrix.
204    /// Dimension-product overflow is reported as a budget refusal (an
205    /// allocation whose size cannot even be computed certainly does not fit).
206    pub fn try_reserve_dense_f64(
207        &self,
208        nrows: usize,
209        ncols: usize,
210        context: &str,
211    ) -> Result<MemoryReservation, MemoryReservationError> {
212        self.try_reserve_dense_f64_copies(nrows, ncols, 1, context)
213    }
214
215    /// Reserve the predicted live footprint of `copies` simultaneous dense
216    /// matrices with one atomic ledger charge.
217    pub fn try_reserve_dense_f64_copies(
218        &self,
219        nrows: usize,
220        ncols: usize,
221        copies: usize,
222        context: &str,
223    ) -> Result<MemoryReservation, MemoryReservationError> {
224        let bytes = dense_f64_bytes(nrows, ncols)
225            .and_then(|one| one.checked_mul(copies))
226            .ok_or_else(|| MemoryReservationError::SizeOverflow {
227                context: context.into(),
228                nrows,
229                ncols,
230                copies,
231            })?;
232        self.try_reserve(bytes, context)
233    }
234}
235
236/// Checked byte footprint of a dense `nrows × ncols` `f64` matrix.
237pub const fn dense_f64_bytes(nrows: usize, ncols: usize) -> Option<usize> {
238    match nrows.checked_mul(ncols) {
239        Some(cells) => cells.checked_mul(std::mem::size_of::<f64>()),
240        None => None,
241    }
242}
243
244/// RAII guard for bytes reserved on a [`MemoryGovernor`] ledger; dropping it
245/// releases the reservation. Hold it exactly as long as the accounted
246/// allocation is live.
247#[derive(Debug)]
248#[must_use = "dropping a memory reservation immediately releases its ledger charge"]
249pub struct MemoryReservation {
250    ledger: Arc<GovernorLedger>,
251    bytes: usize,
252}
253
254impl MemoryReservation {
255    pub fn bytes(&self) -> usize {
256        self.bytes
257    }
258
259    /// Couple this reservation to the value whose memory it accounts for.
260    pub fn bind<T>(self, value: T) -> Governed<T> {
261        Governed {
262            value,
263            reservation: self,
264        }
265    }
266}
267
268/// A value whose live memory is coupled to a process-wide reservation.
269///
270/// Large fallible materializations return this owner so the allocation cannot
271/// outlive its ledger charge. It dereferences to the wrapped value for normal
272/// ndarray and collection operations.
273#[derive(Debug)]
274#[must_use = "the governed value owns a live process-wide memory reservation"]
275pub struct Governed<T> {
276    value: T,
277    reservation: MemoryReservation,
278}
279
280impl<T> Governed<T> {
281    pub fn reserved_bytes(&self) -> usize {
282        self.reservation.bytes()
283    }
284}
285
286impl<T> std::ops::Deref for Governed<T> {
287    type Target = T;
288
289    fn deref(&self) -> &Self::Target {
290        &self.value
291    }
292}
293
294impl<T> std::ops::DerefMut for Governed<T> {
295    fn deref_mut(&mut self) -> &mut Self::Target {
296        &mut self.value
297    }
298}
299
300impl<T> AsRef<T> for Governed<T> {
301    fn as_ref(&self) -> &T {
302        &self.value
303    }
304}
305
306impl<T> AsMut<T> for Governed<T> {
307    fn as_mut(&mut self) -> &mut T {
308        &mut self.value
309    }
310}
311
312impl Drop for MemoryReservation {
313    fn drop(&mut self) {
314        self.ledger
315            .reserved_bytes
316            .fetch_sub(self.bytes, std::sync::atomic::Ordering::AcqRel);
317    }
318}
319
320/// Hints that flip strict mode on regardless of n/p — used when a code path
321/// is structurally operator-only and any dense fallback would be a bug.
322#[derive(Clone, Copy, Debug, Default)]
323pub struct ProblemHints {
324    pub marginal_slope_large_scale_active: bool,
325}
326
327#[derive(Clone, Copy, Debug, PartialEq, Eq)]
328pub enum DerivativeStorageMode {
329    /// Production exact-math: operator-backed, no dense fallback.
330    AnalyticOperatorRequired,
331    /// Allow dense materialization if under the single-materialization budget.
332    MaterializeIfSmall,
333    /// Dense materialization only permitted for diagnostic code paths.
334    DiagnosticsOnly,
335}
336
337#[derive(Clone, Debug)]
338pub struct MaterializationPolicy {
339    pub max_single_dense_bytes: usize,
340    pub max_cached_dense_bytes: usize,
341    pub row_chunk_target_bytes: usize,
342    pub allow_operator_materialization: bool,
343    pub allow_diagnostic_materialization: bool,
344}
345
346#[derive(Debug, thiserror::Error)]
347pub enum MatrixMaterializationError {
348    #[error(
349        "{context}: dense materialization of {nrows}x{ncols} requires {bytes} bytes (limit {limit_bytes})"
350    )]
351    TooLarge {
352        context: &'static str,
353        nrows: usize,
354        ncols: usize,
355        bytes: usize,
356        limit_bytes: usize,
357    },
358
359    #[error("{context}: operator does not implement chunked row access")]
360    MissingRowChunk { context: &'static str },
361
362    #[error("{context}: row materialization failed: {reason}")]
363    RowMaterializationFailed {
364        context: &'static str,
365        reason: String,
366    },
367
368    #[error("{context}: materialization forbidden by policy (mode={mode:?})")]
369    Forbidden {
370        context: &'static str,
371        mode: DerivativeStorageMode,
372    },
373
374    /// The process-wide [`MemoryGovernor`] could not reserve the requested live
375    /// footprint (or its checked byte size overflowed). Callers route to a
376    /// chunked or matrix-free strategy.
377    #[error(transparent)]
378    Reservation(#[from] MemoryReservationError),
379}
380
381pub trait ResidentBytes {
382    fn resident_bytes(&self) -> usize;
383}
384
385impl ResourcePolicy {
386    /// Conservative default suitable for general-purpose use.
387    ///
388    /// Uses `MaterializeIfSmall`: dense materialization is allowed only when
389    /// the matrix fits under `max_single_materialization_bytes`. This lets
390    /// small-data families that lack an implicit operator work out of the box,
391    /// while problems whose dense footprint does not fit real memory get a
392    /// typed refusal that forces the analytic-operator path. Set
393    /// `derivative_storage_mode = AnalyticOperatorRequired` explicitly to
394    /// reject all dense fallback.
395    ///
396    /// Scalar caps expose the governor's full allowance; they are admission
397    /// hints, not independent budgets. Actual materializations and caches must
398    /// reserve their complete live footprint against the shared governor, so
399    /// any combination of categories is bounded by one ledger.
400    pub fn default_library() -> Self {
401        let governor = MemoryGovernor::global();
402        let single_cap = governor.single_materialization_cap_bytes();
403        Self {
404            max_single_materialization_bytes: single_cap,
405            max_operator_cache_bytes: single_cap,
406            max_spatial_distance_cache_bytes: single_cap,
407            max_owned_data_cache_bytes: single_cap,
408            row_chunk_target_bytes: LIBRARY_ROW_CHUNK_TARGET_BYTES,
409            derivative_storage_mode: DerivativeStorageMode::MaterializeIfSmall,
410        }
411    }
412
413    /// Strict mode that rejects every dense fallback. Use when you intend to
414    /// run only on operator-backed bases (large-scale Duchon/TPS, exact
415    /// GAMLSS marginal slope, CTN, etc.). The byte caps only govern the
416    /// residual diagnostic surfaces (materialization itself is forbidden by
417    /// the mode).
418    pub fn analytic_operator_required() -> Self {
419        let base = Self::default_library();
420        Self {
421            derivative_storage_mode: DerivativeStorageMode::AnalyticOperatorRequired,
422            ..base
423        }
424    }
425
426    /// Auto-derive the resource policy from the shape of the problem rather
427    /// than from an explicit CLI flag.
428    ///
429    /// Shape alone never flips a policy mode: doing so merely moves the old
430    /// row/column cliff to a byte threshold. Every non-structural path starts
431    /// permissive and makes its strategy decision from the operation's checked
432    /// predicted live bytes versus the governor's current remaining budget.
433    ///
434    /// `hints.marginal_slope_large_scale_active` forces strict mode regardless
435    /// of shape: that path is structurally operator-only and any dense
436    /// fallback would be a bug, not a memory question.
437    pub fn for_problem(hints: ProblemHints) -> Self {
438        if hints.marginal_slope_large_scale_active {
439            return Self::analytic_operator_required();
440        }
441        Self::default_library()
442    }
443
444    /// Permissive mode for small-data usage and tests. Admission still uses
445    /// the same process ledger; only the streaming chunk geometry differs.
446    pub fn permissive_small_data() -> Self {
447        let base = Self::default_library();
448        Self {
449            row_chunk_target_bytes: 64 * 1024 * 1024,
450            ..base
451        }
452    }
453
454    pub const fn material_policy(&self) -> MaterializationPolicy {
455        MaterializationPolicy {
456            max_single_dense_bytes: self.max_single_materialization_bytes,
457            max_cached_dense_bytes: self.max_operator_cache_bytes,
458            row_chunk_target_bytes: self.row_chunk_target_bytes,
459            allow_operator_materialization: matches!(
460                self.derivative_storage_mode,
461                DerivativeStorageMode::MaterializeIfSmall
462            ),
463            allow_diagnostic_materialization: !matches!(
464                self.derivative_storage_mode,
465                DerivativeStorageMode::AnalyticOperatorRequired
466            ),
467        }
468    }
469}
470
471/// Returns how many rows to stream per chunk so that each chunk uses approximately
472/// `target_bytes` given a row width of `cols` f64 entries.
473pub const fn rows_for_target_bytes(target_bytes: usize, cols: usize) -> usize {
474    let raw_bytes_per_row = cols.saturating_mul(std::mem::size_of::<f64>());
475    let bytes_per_row = if raw_bytes_per_row == 0 {
476        1
477    } else {
478        raw_bytes_per_row
479    };
480    let rows = target_bytes / bytes_per_row;
481    if rows == 0 { 1 } else { rows }
482}
483
484/// Select the row count for prediction-time covariance work.
485///
486/// A prediction row can keep roughly four `parameter_dim × local_dim`
487/// `f64` workspaces live while gradients and covariance solves are assembled.
488/// This central policy prevents predictors from drifting to different memory
489/// budgets and chunk bounds for the same operation.
490pub fn prediction_chunk_rows(parameter_dim: usize, local_dim: usize, total_rows: usize) -> usize {
491    const MIN_ROWS: usize = 16;
492    const MAX_ROWS: usize = 4096;
493
494    if total_rows == 0 {
495        return 1;
496    }
497    let live_f64_values_per_row = parameter_dim
498        .max(1)
499        .saturating_mul(local_dim.max(1))
500        .saturating_mul(4);
501    rows_for_target_bytes(
502        ResourcePolicy::default_library().row_chunk_target_bytes,
503        live_f64_values_per_row,
504    )
505    .clamp(MIN_ROWS, MAX_ROWS)
506    .min(total_rows)
507}
508
509use std::collections::{HashMap, VecDeque};
510use std::hash::{Hash, Hasher};
511use std::sync::{Arc, Mutex, OnceLock};
512
513/// Byte-limited LRU cache with an optional entry cap.
514///
515/// Unlike an entry-count-limited LRU, this cache tracks the resident byte cost
516/// of each value (via [`ResidentBytes`]) and evicts the least-recently-used
517/// entries until the total resident bytes fit under `max_bytes`. This is the
518/// correct policy for large-scale payloads where a single cache entry (e.g.
519/// an n*K distance matrix) can itself be multiple gigabytes and an entry-count
520/// cap would silently blow the memory budget. Small entry caps are still useful
521/// for payloads with known shape, such as owned PC data matrices shared across
522/// model blocks.
523pub struct ByteLruCache<K: Eq + Hash + Clone, V> {
524    /// One independent LRU partition per shard. A single shard (the default)
525    /// is byte-for-byte equivalent to the original single-`Mutex` cache; with
526    /// `shard_count > 1` the key hash selects the shard, so concurrent traffic
527    /// on distinct keys contends `1/shard_count` as often and each shard's
528    /// recency `VecDeque` is `1/shard_count` as long (the hit-path rescan is a
529    /// linear `position` lookup, so shrinking the per-shard order also cuts
530    /// per-access cost). Sharding is opt-in (`new_sharded`) precisely because
531    /// the byte budget is split across shards — that is correct for caches of
532    /// many small entries (e.g. cell-moment memos) but wrong for caches of a
533    /// few multi-GiB entries (distance matrices), which keep `shard_count == 1`.
534    shards: Box<[Mutex<ByteLruInner<K, V>>]>,
535    /// Per-shard byte budget. `shard_bytes * shards.len() >= max_bytes`.
536    shard_bytes: usize,
537    /// Per-shard entry budget, if any (`0` disables caching, as before).
538    shard_entries: Option<usize>,
539    max_bytes: usize,
540}
541
542struct ByteLruInner<K, V> {
543    // The reservation is stored beside the value, so eviction and clear drop
544    // the process-wide charge at exactly the same time as cache ownership.
545    map: HashMap<K, (V, usize, MemoryReservation)>,
546    order: VecDeque<K>,
547    resident_bytes: usize,
548}
549
550impl<K: Eq + Hash + Clone, V: Clone + ResidentBytes> ByteLruCache<K, V> {
551    pub fn new(max_bytes: usize) -> Self {
552        Self::build(max_bytes, None, 1)
553    }
554
555    pub fn with_max_entries(max_bytes: usize, max_entries: usize) -> Self {
556        Self::build(max_bytes, Some(max_entries), 1)
557    }
558
559    /// Like [`new`](Self::new) but partitions the cache across `shard_count`
560    /// independently-locked LRU shards to cut lock contention under heavy
561    /// concurrent access. The byte budget is divided evenly across shards, so
562    /// this is only appropriate for caches holding many small entries.
563    pub fn new_sharded(max_bytes: usize, shard_count: usize) -> Self {
564        Self::build(max_bytes, None, shard_count)
565    }
566
567    /// Like [`with_max_entries`](Self::with_max_entries) but sharded; see
568    /// [`new_sharded`](Self::new_sharded).
569    pub fn with_max_entries_sharded(
570        max_bytes: usize,
571        max_entries: usize,
572        shard_count: usize,
573    ) -> Self {
574        Self::build(max_bytes, Some(max_entries), shard_count)
575    }
576
577    fn build(max_bytes: usize, max_entries: Option<usize>, shard_count: usize) -> Self {
578        let shard_count = shard_count.max(1);
579        // Split the global budgets across shards, rounding up so the aggregate
580        // capacity never falls below the requested budget. With a single shard
581        // these equal the global budgets exactly (legacy behavior). A `0`
582        // entry budget still disables caching and must not be rounded up to 1.
583        let shard_bytes = max_bytes.div_ceil(shard_count);
584        let shard_entries = max_entries.map(|m| {
585            if m == 0 {
586                0
587            } else {
588                m.div_ceil(shard_count).max(1)
589            }
590        });
591        let shards = (0..shard_count)
592            .map(|_| {
593                Mutex::new(ByteLruInner {
594                    map: HashMap::new(),
595                    order: VecDeque::new(),
596                    resident_bytes: 0,
597                })
598            })
599            .collect::<Vec<_>>()
600            .into_boxed_slice();
601        Self {
602            shards,
603            shard_bytes,
604            shard_entries,
605            max_bytes,
606        }
607    }
608
609    #[inline]
610    fn shard(&self, key: &K) -> &Mutex<ByteLruInner<K, V>> {
611        if self.shards.len() == 1 {
612            return &self.shards[0];
613        }
614        let mut hasher = std::collections::hash_map::DefaultHasher::new();
615        key.hash(&mut hasher);
616        &self.shards[(hasher.finish() as usize) % self.shards.len()]
617    }
618
619    pub fn get(&self, key: &K) -> Option<V> {
620        // recover from poison
621        let mut g = self.shard(key).lock().unwrap_or_else(|p| p.into_inner());
622        let v = g.map.get(key)?.0.clone();
623        // move to back (most-recently-used)
624        if let Some(pos) = g.order.iter().position(|k| k == key) {
625            let k = g.order.remove(pos).unwrap();
626            g.order.push_back(k);
627        }
628        Some(v)
629    }
630
631    pub fn insert(&self, key: K, value: V) {
632        let charge = value.resident_bytes();
633        let mut g = self.shard(&key).lock().unwrap_or_else(|p| p.into_inner());
634
635        // If already present, remove the old entry first so resident bytes stay
636        // accurate and the LRU ordering reflects this insertion.
637        if let Some((_old, old_charge, _reservation)) = g.map.remove(&key) {
638            g.resident_bytes = g.resident_bytes.saturating_sub(old_charge);
639            if let Some(pos) = g.order.iter().position(|k| k == &key) {
640                g.order.remove(pos);
641            }
642        }
643
644        if charge > self.shard_bytes {
645            // Too large to cache; skip insertion.
646            return;
647        }
648
649        if let Some(max_entries) = self.shard_entries {
650            if max_entries == 0 {
651                return;
652            }
653            while g.map.len() >= max_entries {
654                if let Some(evict_key) = g.order.pop_front() {
655                    if let Some((_v, c, _reservation)) = g.map.remove(&evict_key) {
656                        g.resident_bytes = g.resident_bytes.saturating_sub(c);
657                    }
658                } else {
659                    break;
660                }
661            }
662        }
663
664        while g.resident_bytes + charge > self.shard_bytes {
665            if let Some(evict_key) = g.order.pop_front() {
666                if let Some((_v, c, _reservation)) = g.map.remove(&evict_key) {
667                    g.resident_bytes = g.resident_bytes.saturating_sub(c);
668                }
669            } else {
670                break;
671            }
672        }
673
674        let reservation =
675            match MemoryGovernor::global().try_reserve(charge, "ByteLruCache resident entry") {
676                Ok(reservation) => reservation,
677                Err(_) => return,
678            };
679        g.map.insert(key.clone(), (value, charge, reservation));
680        g.order.push_back(key);
681        g.resident_bytes = g.resident_bytes.saturating_add(charge);
682    }
683
684    pub fn resident_bytes(&self) -> usize {
685        self.shards
686            .iter()
687            .map(|shard| {
688                shard
689                    .lock()
690                    .unwrap_or_else(|p| p.into_inner())
691                    .resident_bytes
692            })
693            .sum()
694    }
695
696    pub const fn max_bytes(&self) -> usize {
697        self.max_bytes
698    }
699
700    pub fn len(&self) -> usize {
701        self.shards
702            .iter()
703            .map(|shard| shard.lock().unwrap_or_else(|p| p.into_inner()).map.len())
704            .sum()
705    }
706
707    pub fn is_empty(&self) -> bool {
708        self.len() == 0
709    }
710
711    pub fn clear(&self) {
712        for shard in self.shards.iter() {
713            let mut g = shard.lock().unwrap_or_else(|p| p.into_inner());
714            g.map.clear();
715            g.order.clear();
716            g.resident_bytes = 0;
717        }
718    }
719}
720
721impl<K: Eq + Hash + Clone, V: Clone + ResidentBytes> std::fmt::Debug for ByteLruCache<K, V> {
722    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
723        f.debug_struct("ByteLruCache")
724            .field("resident_bytes", &self.resident_bytes())
725            .field("max_bytes", &self.max_bytes)
726            .field("shard_count", &self.shards.len())
727            .field("shard_bytes", &self.shard_bytes)
728            .field("shard_entries", &self.shard_entries)
729            .finish()
730    }
731}
732
733/// Byte-accounting for `Arc<Array2<f64>>`.
734///
735/// Reports the full dense footprint of the owned array. Multiple `Arc`s
736/// pointing to the same allocation will each report the full size; this is
737/// the conservative accounting the caches want because a single residency in
738/// the cache is what we are budgeting for.
739impl ResidentBytes for Arc<ndarray::Array2<f64>> {
740    fn resident_bytes(&self) -> usize {
741        std::mem::size_of::<f64>()
742            .saturating_mul(self.nrows())
743            .saturating_mul(self.ncols())
744    }
745}
746
747/// Lazy-init cache safe to call from inside rayon par_iter.
748///
749/// `std::sync::OnceLock::get_or_init` parks racing threads on an OS
750/// condition variable until the leader's init closure finishes. If the
751/// leader's init closure itself dispatches a nested `into_par_iter`, the
752/// parked threads are now unavailable as rayon workers, and the leader
753/// blocks waiting for chunks that no one can service. Classic deadlock.
754///
755/// `RayonSafeOnce` removes the trap by computing the value *outside* any
756/// lock. Concurrent racers may produce duplicate values; the first to
757/// publish wins, the rest drop their result. No thread ever parks waiting
758/// for another thread's init to finish, so nested rayon par_iter inside
759/// the init closure is safe.
760///
761/// Use this in place of `OnceLock` whenever the init closure transitively
762/// runs rayon work *and* the cache may be entered concurrently from
763/// inside another rayon par_iter. The redundant-work cost on first race
764/// is the price for never deadlocking; in practice the loser threads
765/// throw away one round of work and steady-state is identical to
766/// `OnceLock`.
767pub struct RayonSafeOnce<T> {
768    slot: std::sync::OnceLock<T>,
769}
770
771impl<T> RayonSafeOnce<T> {
772    pub const fn new() -> Self {
773        Self {
774            slot: std::sync::OnceLock::new(),
775        }
776    }
777
778    /// Returns the cached value if already populated.
779    #[inline]
780    pub fn get(&self) -> Option<&T> {
781        self.slot.get()
782    }
783
784    /// Returns the cached value, computing it if absent.
785    ///
786    /// The init closure runs WITHOUT holding any lock — calls from
787    /// concurrent rayon workers may all run it, and all but the first
788    /// to call `set` discard their result. This is the contract that
789    /// keeps nested `into_par_iter` inside `init` from deadlocking on
790    /// other workers parked on a `OnceLock`.
791    ///
792    /// Named `get_or_compute` (not `get_or_init`) so the codebase-level
793    /// lint that bans `OnceLock::get_or_init` near rayon `par_iter` does
794    /// not flag this safe-by-construction path.
795    pub fn get_or_compute<F>(&self, init: F) -> &T
796    where
797        F: FnOnce() -> T,
798    {
799        if let Some(v) = self.slot.get() {
800            return v;
801        }
802        let candidate = init();
803        self.slot.set(candidate).ok();
804        self.slot
805            .get()
806            .expect("RayonSafeOnce slot populated by set() above")
807    }
808}
809
810impl<T> Default for RayonSafeOnce<T> {
811    fn default() -> Self {
812        Self::new()
813    }
814}
815
816impl<T: Clone> Clone for RayonSafeOnce<T> {
817    fn clone(&self) -> Self {
818        let cloned = Self::new();
819        if let Some(value) = self.slot.get() {
820            cloned.slot.set(value.clone()).ok();
821        }
822        cloned
823    }
824}
825
826impl<T: std::fmt::Debug> std::fmt::Debug for RayonSafeOnce<T> {
827    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
828        f.debug_struct("RayonSafeOnce")
829            .field("slot", &self.slot.get())
830            .finish()
831    }
832}
833
834#[cfg(test)]
835mod byte_lru_tests {
836    use super::*;
837
838    /// Fixed-charge value so byte-budget arithmetic in the tests is exact.
839    #[derive(Clone, PartialEq, Debug)]
840    struct Payload(u64);
841    impl ResidentBytes for Payload {
842        fn resident_bytes(&self) -> usize {
843            8
844        }
845    }
846
847    #[test]
848    fn single_shard_round_trips_and_evicts_by_bytes() {
849        // 3 entries' worth of budget; a single shard preserves strict global LRU.
850        let cache: ByteLruCache<u64, Payload> = ByteLruCache::new(24);
851        for k in 0..3 {
852            cache.insert(k, Payload(k));
853        }
854        assert_eq!(cache.len(), 3);
855        assert_eq!(cache.resident_bytes(), 24);
856        // Touch key 0 so it is most-recently-used, then overflow by one.
857        assert_eq!(cache.get(&0), Some(Payload(0)));
858        cache.insert(3, Payload(3));
859        // Key 1 (now least-recently-used) is evicted; 0 survives the touch.
860        assert_eq!(cache.len(), 3);
861        assert_eq!(cache.get(&1), None);
862        assert_eq!(cache.get(&0), Some(Payload(0)));
863        assert_eq!(cache.get(&3), Some(Payload(3)));
864    }
865
866    #[test]
867    fn zero_entry_budget_disables_caching_in_every_shard() {
868        let single: ByteLruCache<u64, Payload> = ByteLruCache::with_max_entries(1 << 20, 0);
869        single.insert(7, Payload(7));
870        assert_eq!(single.get(&7), None);
871        let sharded: ByteLruCache<u64, Payload> =
872            ByteLruCache::with_max_entries_sharded(1 << 20, 0, 16);
873        sharded.insert(7, Payload(7));
874        assert_eq!(sharded.get(&7), None);
875    }
876
877    #[test]
878    fn sharded_cache_retrieves_all_keys_and_respects_aggregate_budget() {
879        // Generous budget split across 8 shards; every inserted key must be
880        // retrievable and the aggregate residency must never exceed the global
881        // budget (shard_bytes * shard_count, rounded up).
882        let shard_count = 8usize;
883        let max_bytes = 8 * 64; // 64 entries' worth, 8 per shard on average.
884        let cache: ByteLruCache<u64, Payload> = ByteLruCache::new_sharded(max_bytes, shard_count);
885        for k in 0..64u64 {
886            cache.insert(k, Payload(k));
887        }
888        // Per-shard budgets sum to >= the requested global budget.
889        assert!(cache.resident_bytes() <= max_bytes.div_ceil(shard_count) * shard_count);
890        // Re-inserting then reading back a key returns the stored payload.
891        cache.insert(123, Payload(123));
892        assert_eq!(cache.get(&123), Some(Payload(123)));
893        assert!(!cache.is_empty());
894        cache.clear();
895        assert_eq!(cache.len(), 0);
896        assert_eq!(cache.resident_bytes(), 0);
897    }
898}
899
900#[cfg(test)]
901mod resource_policy_tests {
902    use super::*;
903
904    // ── rows_for_target_bytes ─────────────────────────────────────────────────
905
906    #[test]
907    fn rows_for_target_bytes_exact_fit() {
908        // 1 col × 8 bytes/f64; target 8 bytes → 1 row
909        assert_eq!(rows_for_target_bytes(8, 1), 1);
910    }
911
912    #[test]
913    fn rows_for_target_bytes_multiple_rows() {
914        // 1 col × 8 bytes/f64; target 80 bytes → 10 rows
915        assert_eq!(rows_for_target_bytes(80, 1), 10);
916    }
917
918    #[test]
919    fn rows_for_target_bytes_multiple_cols() {
920        // 4 cols × 8 = 32 bytes/row; target 128 → 4 rows
921        assert_eq!(rows_for_target_bytes(128, 4), 4);
922    }
923
924    #[test]
925    fn rows_for_target_bytes_zero_target_returns_one() {
926        // Zero target cannot give 0 rows — floor to 1
927        assert_eq!(rows_for_target_bytes(0, 1), 1);
928    }
929
930    #[test]
931    fn rows_for_target_bytes_zero_cols_returns_non_zero() {
932        // Zero cols → bytes_per_row falls back to 1 → rows = target
933        assert_eq!(rows_for_target_bytes(100, 0), 100);
934    }
935
936    #[test]
937    fn rows_for_target_bytes_large_target() {
938        // 8 MiB target, 1024 cols → 1024 bytes/row → 8192 rows
939        let target = 8 * 1024 * 1024;
940        let cols = 1024_usize;
941        let expected = target / (cols * std::mem::size_of::<f64>());
942        assert_eq!(rows_for_target_bytes(target, cols), expected);
943    }
944
945    #[test]
946    fn prediction_chunks_share_the_runtime_byte_budget() {
947        assert_eq!(prediction_chunk_rows(1024, 1, 100_000), 256);
948        assert_eq!(prediction_chunk_rows(32, 2, 100_000), 4096);
949    }
950
951    #[test]
952    fn prediction_chunks_respect_dataset_bounds() {
953        assert_eq!(prediction_chunk_rows(1, 1, 7), 7);
954        assert_eq!(prediction_chunk_rows(1, 1, 0), 1);
955    }
956
957    // ── ResourcePolicy::for_problem ──────────────────────────────────────────
958
959    #[test]
960    fn for_problem_small_data_uses_materialize_if_small() {
961        let p = ResourcePolicy::for_problem(ProblemHints::default());
962        assert_eq!(
963            p.derivative_storage_mode,
964            DerivativeStorageMode::MaterializeIfSmall
965        );
966    }
967
968    #[test]
969    fn for_problem_has_no_row_or_column_cliff() {
970        let narrow = ResourcePolicy::for_problem(ProblemHints::default());
971        let wide = ResourcePolicy::for_problem(ProblemHints::default());
972        assert_eq!(
973            narrow.derivative_storage_mode,
974            DerivativeStorageMode::MaterializeIfSmall
975        );
976        assert_eq!(
977            wide.derivative_storage_mode,
978            DerivativeStorageMode::MaterializeIfSmall
979        );
980    }
981
982    #[test]
983    fn for_problem_dimension_overflow_defers_to_typed_reservation() {
984        let policy = ResourcePolicy::for_problem(ProblemHints::default());
985        assert_eq!(
986            policy.derivative_storage_mode,
987            DerivativeStorageMode::MaterializeIfSmall
988        );
989    }
990
991    #[test]
992    fn for_problem_marginal_slope_hint_is_strict() {
993        let p = ResourcePolicy::for_problem(ProblemHints {
994            marginal_slope_large_scale_active: true,
995        });
996        assert_eq!(
997            p.derivative_storage_mode,
998            DerivativeStorageMode::AnalyticOperatorRequired
999        );
1000    }
1001
1002    // ── ResourcePolicy::material_policy ─────────────────────────────────────
1003
1004    #[test]
1005    fn material_policy_default_library_allows_operator_and_diagnostics() {
1006        let mp = ResourcePolicy::default_library().material_policy();
1007        assert!(mp.allow_operator_materialization);
1008        assert!(mp.allow_diagnostic_materialization);
1009    }
1010
1011    #[test]
1012    fn material_policy_analytic_operator_required_blocks_both() {
1013        let mp = ResourcePolicy::analytic_operator_required().material_policy();
1014        assert!(!mp.allow_operator_materialization);
1015        assert!(!mp.allow_diagnostic_materialization);
1016    }
1017
1018    #[test]
1019    fn material_policy_propagates_byte_limits() {
1020        let policy = ResourcePolicy::default_library();
1021        let mp = policy.material_policy();
1022        assert_eq!(
1023            mp.max_single_dense_bytes,
1024            policy.max_single_materialization_bytes
1025        );
1026        assert_eq!(mp.max_cached_dense_bytes, policy.max_operator_cache_bytes);
1027        assert_eq!(mp.row_chunk_target_bytes, policy.row_chunk_target_bytes);
1028    }
1029
1030    // ── MemoryGovernor ledger ────────────────────────────────────────────────
1031
1032    #[test]
1033    fn reservations_account_and_release_on_drop() {
1034        let governor = MemoryGovernor::with_budget(1_000);
1035        assert_eq!(governor.remaining_bytes(), 1_000);
1036        let first = governor.try_reserve(600, "test-first").expect("fits");
1037        assert_eq!(governor.reserved_bytes(), 600);
1038        assert_eq!(governor.remaining_bytes(), 400);
1039        assert_eq!(first.bytes(), 600);
1040        drop(first);
1041        assert_eq!(governor.reserved_bytes(), 0);
1042        assert_eq!(governor.remaining_bytes(), 1_000);
1043    }
1044
1045    #[test]
1046    fn jointly_excessive_reservations_are_refused_with_evidence() {
1047        // Two allocations that each fit alone must not be jointly grantable —
1048        // this is exactly the independent-budgets failure the ledger exists
1049        // to prevent.
1050        let governor = MemoryGovernor::with_budget(1_000);
1051        let held = governor.try_reserve(600, "test-held").expect("fits alone");
1052        let refusal = governor
1053            .try_reserve(600, "test-joint")
1054            .expect_err("600 + 600 exceeds the 1000-byte budget");
1055        assert_eq!(
1056            refusal,
1057            MemoryReservationError::BudgetExceeded {
1058                context: "test-joint".into(),
1059                requested_bytes: 600,
1060                reserved_bytes: 600,
1061                budget_bytes: 1_000,
1062            }
1063        );
1064        // After releasing the holder, the same request succeeds: refusal is a
1065        // routing signal, not a terminal state.
1066        drop(held);
1067        let refreshed = governor
1068            .try_reserve(600, "test-joint")
1069            .expect("fits after release");
1070        assert_eq!(refreshed.bytes(), 600);
1071    }
1072
1073    #[test]
1074    fn dense_reservation_uses_checked_footprint() {
1075        let governor = MemoryGovernor::with_budget(1 << 20);
1076        let ok = governor
1077            .try_reserve_dense_f64(1024, 64, "test-dense")
1078            .expect("512 KiB fits in 1 MiB");
1079        assert_eq!(ok.bytes(), 1024 * 64 * 8);
1080        drop(ok);
1081        // Dimension-product overflow must refuse, never wrap into a tiny
1082        // spurious reservation.
1083        governor
1084            .try_reserve_dense_f64(usize::MAX, 2, "test-overflow")
1085            .expect_err("overflowing footprint cannot be reserved");
1086        let unlimited = MemoryGovernor::with_budget(usize::MAX);
1087        assert!(matches!(
1088            unlimited.try_reserve_dense_f64(usize::MAX, 2, "test-overflow"),
1089            Err(MemoryReservationError::SizeOverflow { .. })
1090        ));
1091    }
1092
1093    #[test]
1094    fn concurrent_reservations_never_oversubscribe() {
1095        let governor = std::sync::Arc::new(MemoryGovernor::with_budget(1_000));
1096        let granted = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1097        let barrier = std::sync::Arc::new(std::sync::Barrier::new(9));
1098        std::thread::scope(|scope| {
1099            for _ in 0..8 {
1100                let governor = std::sync::Arc::clone(&governor);
1101                let granted = std::sync::Arc::clone(&granted);
1102                let barrier = std::sync::Arc::clone(&barrier);
1103                scope.spawn(move || {
1104                    let held = governor.try_reserve(200, "test-race").ok();
1105                    if held.is_some() {
1106                        granted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1107                    }
1108                    barrier.wait();
1109                    assert!(governor.reserved_bytes() <= governor.budget_bytes());
1110                    barrier.wait();
1111                    drop(held);
1112                });
1113            }
1114            barrier.wait();
1115            assert_eq!(granted.load(std::sync::atomic::Ordering::SeqCst), 5);
1116            assert_eq!(governor.reserved_bytes(), 1_000);
1117            barrier.wait();
1118        });
1119        assert_eq!(governor.reserved_bytes(), 0);
1120    }
1121
1122    #[test]
1123    fn global_policy_caps_are_one_shared_admission_ceiling() {
1124        let governor = MemoryGovernor::global();
1125        assert_eq!(
1126            governor.single_materialization_cap_bytes(),
1127            governor.budget_bytes()
1128        );
1129        let policy = ResourcePolicy::default_library();
1130        assert_eq!(
1131            policy.max_single_materialization_bytes,
1132            governor.single_materialization_cap_bytes()
1133        );
1134        let strict = ResourcePolicy::analytic_operator_required();
1135        assert_eq!(
1136            strict.max_single_materialization_bytes,
1137            policy.max_single_materialization_bytes
1138        );
1139    }
1140
1141    #[test]
1142    fn governed_value_holds_and_releases_its_charge() {
1143        let governor = MemoryGovernor::with_budget(64);
1144        let governed = governor
1145            .try_reserve(32, "governed-value")
1146            .expect("reservation fits")
1147            .bind(vec![0_u8; 32]);
1148        assert_eq!(governed.len(), 32);
1149        assert_eq!(governed.reserved_bytes(), 32);
1150        assert_eq!(governor.reserved_bytes(), 32);
1151        drop(governed);
1152        assert_eq!(governor.reserved_bytes(), 0);
1153    }
1154
1155    #[test]
1156    fn budget_derivation_honors_zero_and_cgroup_limits() {
1157        assert_eq!(governor_budget_from_available(1_000, None), 750);
1158        assert_eq!(governor_budget_from_available(1_000, Some(400)), 300);
1159        assert_eq!(governor_budget_from_available(1_000, Some(0)), 0);
1160        assert_eq!(governor_budget_from_available(0, None), 0);
1161    }
1162}