gam_runtime/resource.rs
1use crate::cgroup_memory::detect_cgroup_memory;
2pub use crate::cgroup_memory::{
3 CgroupMemoryAvailability, CgroupMemoryLimit, CgroupMemoryObservation, CgroupMemoryProbeFailure,
4 CgroupMemoryProbeFailureKind,
5};
6
7/// The library-default streamed row-chunk target (8 MiB), shared as a `const`
8/// so compile-time consumers (e.g. device tile geometry) stay in lockstep with
9/// [`ResourcePolicy::default_library`] without a runtime policy query.
10///
11/// **The 8 MiB value is unmeasured.** The paragraph above justifies there being
12/// ONE copy; it does not justify the number. No cache level, measured
13/// throughput, or consequence-for-being-wrong is recorded anywhere for it.
14/// Twelve transcribed copies across six crates were collapsed onto this
15/// definition (#2704) purely to remove duplication — that consolidation moved
16/// no value and measured nothing, and a single canonical copy of an arbitrary
17/// number must not be read as a decided one. Anyone retuning it is retuning an
18/// unsupported literal, not overriding a derived budget.
19///
20/// Consumers should size chunks with [`rows_for_target_bytes`], which performs
21/// the `target / (cols * 8)` division and floors the result at one row.
22///
23/// Re-declaring this value — as a module-local `const`, an inline literal, or
24/// any other spelling of 8 MiB — is caught by the workspace guard
25/// `tests/row_chunk_target_bytes_single_source_2704.rs`, which fails on any
26/// transcription of this value outside the exemption table it carries for the
27/// three sites that are a genuinely DIFFERENT quantity coinciding at 8 MiB.
28/// Import this constant instead, or derive from it under a name.
29pub const LIBRARY_ROW_CHUNK_TARGET_BYTES: usize = 8 * 1024 * 1024;
30
31/// Row-block size that keeps each streamed `rows × cols` `f64` chunk near the
32/// library row-chunk target, with a 512-row floor so a wide design still makes
33/// useful BLAS-3 progress per block, capped at the total row count and never
34/// zero (a zero-row chunk is not a chunk; every consumer strides by it). The
35/// ONE row-chunk rule for streamed dense kernels — CPU (`gam-solve`) and GPU
36/// (`gam-gpu`) alike — so the two cannot drift apart again (#2469, #2704).
37pub fn byte_balanced_row_chunk(cols: usize, n_rows: usize) -> usize {
38 const MIN_CHUNK_ROWS: usize = 512;
39 let bytes_per_row = cols.max(1) * std::mem::size_of::<f64>();
40 (LIBRARY_ROW_CHUNK_TARGET_BYTES / bytes_per_row)
41 .max(MIN_CHUNK_ROWS)
42 .min(n_rows.max(1))
43}
44
45#[derive(Clone, Debug)]
46pub struct ResourcePolicy {
47 pub max_single_materialization_bytes: usize,
48 pub max_operator_cache_bytes: usize,
49 pub max_spatial_distance_cache_bytes: usize,
50 pub max_owned_data_cache_bytes: usize,
51 pub row_chunk_target_bytes: usize,
52 pub derivative_storage_mode: DerivativeStorageMode,
53}
54
55pub const OWNED_DATA_CACHE_MAX_ENTRIES: usize = 2;
56
57// ─────────────────────────────────────────────────────────────────────────────
58// Process-wide memory governor
59// ─────────────────────────────────────────────────────────────────────────────
60
61/// Fraction of this process's memory **capacity** the governor is allowed to
62/// hand out as reservations: 3/4.
63///
64/// The ledger only accounts for the large, planned allocations that route
65/// through [`MemoryGovernor::try_reserve`] (dense design materializations,
66/// covariance blocks, sampler design assemblies). Everything else — allocator
67/// slack, thread stacks, code, and small per-iteration temporaries — lives in
68/// the remaining quarter.
69///
70/// The base quantity is *capacity* (`min(host total, binding cgroup hard
71/// limit)`), not free space, and that is the same separation #2684 established
72/// for the materialization ceiling — see
73/// `governor_budget_from_availability` for why the ledger cannot be an
74/// exception to it (#2702).
75const GOVERNOR_BUDGET_NUMERATOR: u128 = 3;
76const GOVERNOR_BUDGET_DENOMINATOR: u128 = 4;
77
78/// Which observation is the binding ceiling on memory available to this
79/// process.
80#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81pub enum MemoryAvailabilitySource {
82 Host,
83 Cgroup,
84 HostAndCgroup,
85 CgroupProbeFailure,
86}
87
88impl std::fmt::Display for MemoryAvailabilitySource {
89 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 match self {
91 Self::Host => formatter.write_str("host"),
92 Self::Cgroup => formatter.write_str("cgroup"),
93 Self::HostAndCgroup => formatter.write_str("host and cgroup equally"),
94 Self::CgroupProbeFailure => formatter.write_str("cgroup probe failure"),
95 }
96 }
97}
98
99/// Provenance-preserving memory availability for the current process.
100///
101/// Two quantities live here and they answer different questions.
102///
103/// * **Available** (`available_bytes`) is *free space right now*: a finite
104/// cgroup-v1 or cgroup-v2 ceiling admits exactly `min(host available,
105/// reclaim-aware cgroup available)`. A v2 literal `memory.max = max` remains
106/// typed as unbounded and therefore defers to the host; v1's numeric
107/// unlimited sentinel participates exactly and likewise loses to a tighter
108/// host value. This quantity *moves*, and under pressure it goes to zero.
109/// * **Capacity** (`capacity_bytes`) is *how much memory this process could
110/// ever address*: `min(host total, the binding cgroup's hard limit)`. It is
111/// a property of the box and the cgroup configuration, not of the schedule,
112/// so it does not move when a sibling allocates.
113///
114/// The distinction is load-bearing (#2684). "Does this dense footprint fit
115/// here at all?" is a *capacity* question and its answer must be stationary —
116/// see [`process_memory_availability`] on why a route derived from a moving
117/// quantity is a defect. "Does this allocation fit *right now*?" is an
118/// availability question, and the joint ledger
119/// ([`MemoryGovernor::try_reserve`]) is the instrument for it. Reading the
120/// second where the first was meant is what let a cgroup a few pages from its
121/// limit refuse a 28,800-byte design on a host with 448 GB free.
122///
123/// If an active controller cannot be parsed exactly, admission fails closed
124/// with zero bytes — both available *and* capacity — while retaining the typed
125/// probe failure; it never silently inherits host capacity.
126#[derive(Clone, Debug, PartialEq, Eq)]
127pub struct MemoryAvailability {
128 host_available_bytes: u64,
129 host_total_bytes: u64,
130 cgroup: CgroupMemoryObservation,
131 available_bytes: u64,
132 capacity_bytes: u64,
133 limiting_source: MemoryAvailabilitySource,
134}
135
136impl MemoryAvailability {
137 pub(crate) fn from_observation(
138 host_available_bytes: u64,
139 host_total_bytes: u64,
140 cgroup: CgroupMemoryObservation,
141 ) -> Self {
142 use std::cmp::Ordering;
143
144 // Capacity never depends on what is currently resident: a finite
145 // cgroup ceiling clamps the host's total, an unbounded or absent
146 // controller defers to it, and a probe that failed closed admits
147 // nothing at all. A host whose reported available exceeds its reported
148 // total (a torn /proc/meminfo read) must not shrink capacity below
149 // what is demonstrably reachable, hence the max.
150 let capacity_bytes = match &cgroup {
151 CgroupMemoryObservation::NotPresent | CgroupMemoryObservation::V2Unbounded { .. } => {
152 host_total_bytes.max(host_available_bytes)
153 }
154 CgroupMemoryObservation::V2Limited(observation)
155 | CgroupMemoryObservation::V1Limited(observation) => host_total_bytes
156 .max(host_available_bytes)
157 .min(observation.limit_bytes()),
158 CgroupMemoryObservation::ProbeFailed(_) => 0,
159 };
160
161 let (available_bytes, limiting_source) = match &cgroup {
162 CgroupMemoryObservation::NotPresent | CgroupMemoryObservation::V2Unbounded { .. } => {
163 (host_available_bytes, MemoryAvailabilitySource::Host)
164 }
165 CgroupMemoryObservation::V2Limited(observation)
166 | CgroupMemoryObservation::V1Limited(observation) => {
167 match observation.available_bytes().cmp(&host_available_bytes) {
168 Ordering::Less => (
169 observation.available_bytes(),
170 MemoryAvailabilitySource::Cgroup,
171 ),
172 Ordering::Equal => (
173 host_available_bytes,
174 MemoryAvailabilitySource::HostAndCgroup,
175 ),
176 Ordering::Greater => (host_available_bytes, MemoryAvailabilitySource::Host),
177 }
178 }
179 CgroupMemoryObservation::ProbeFailed(_) => {
180 (0, MemoryAvailabilitySource::CgroupProbeFailure)
181 }
182 };
183 Self {
184 host_available_bytes,
185 host_total_bytes,
186 cgroup,
187 available_bytes,
188 capacity_bytes,
189 limiting_source,
190 }
191 }
192
193 /// The stationary ceiling on memory this process could ever address:
194 /// `min(host total, binding cgroup hard limit)`, or zero when the cgroup
195 /// probe failed closed. Unlike [`Self::available_bytes`] this does not move
196 /// when other work allocates, which is what makes it usable as a *routing*
197 /// threshold (#2684).
198 pub const fn capacity_bytes(&self) -> u64 {
199 self.capacity_bytes
200 }
201
202 pub const fn available_bytes(&self) -> u64 {
203 self.available_bytes
204 }
205
206 pub fn available_bytes_usize(&self) -> usize {
207 usize::try_from(self.available_bytes).unwrap_or(usize::MAX)
208 }
209
210}
211
212impl std::fmt::Display for MemoryAvailability {
213 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214 match &self.cgroup {
215 CgroupMemoryObservation::ProbeFailed(failure) => write!(
216 formatter,
217 "0 bytes admitted because the active cgroup probe failed closed (host_available={}, failure={})",
218 self.host_available_bytes, failure,
219 ),
220 observation => write!(
221 formatter,
222 "{} bytes limited by {} (capacity={}, host_available={}, host_total={}, {})",
223 self.available_bytes,
224 self.limiting_source,
225 self.capacity_bytes,
226 self.host_available_bytes,
227 self.host_total_bytes,
228 observation,
229 ),
230 }
231 }
232}
233
234static MEMORY_AVAILABILITY_PROBES: std::sync::atomic::AtomicU64 =
235 std::sync::atomic::AtomicU64::new(0);
236
237/// Take a **fresh** reading of the OS and cgroup memory observations.
238///
239/// This is a syscall-bearing probe, not an accessor: it refreshes `sysinfo`
240/// (`/proc/meminfo`) and re-reads the cgroup limit/usage files on every call,
241/// and the value it returns *moves* — available memory is a live, shared
242/// quantity owned by the whole machine, not by this process.
243///
244/// Call this only where a live reading is the point (an OOM guard immediately
245/// before committing an allocation). Anything that *routes* — picks a
246/// strategy, sizes a plan, chooses an operator — must use
247/// [`process_memory_availability`] instead, because a route derived from a
248/// moving quantity makes the fitted answer depend on what else the box was
249/// doing (SPEC-20: a fit object must only ever come from a converged
250/// optimization, and reproducibly so).
251pub fn resample_memory_availability() -> MemoryAvailability {
252 static SYSTEM: OnceLock<Mutex<sysinfo::System>> = OnceLock::new();
253 let system = SYSTEM.get_or_init(|| Mutex::new(sysinfo::System::new()));
254 let mut system = system.lock().expect("sysinfo system mutex poisoned");
255 system.refresh_memory();
256 let cgroup = detect_cgroup_memory();
257 MEMORY_AVAILABILITY_PROBES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
258 MemoryAvailability::from_observation(
259 system.available_memory(),
260 system.total_memory(),
261 cgroup,
262 )
263}
264
265/// The process's memory availability: **one** observation, taken once, shared
266/// by every planner in the process.
267///
268/// This is the same reading the [`MemoryGovernor`] sized its budget from, so a
269/// planner that admits a plan against this figure and the governor that then
270/// accounts the allocation cannot disagree about how much memory the process
271/// believes it has. Two consequences follow, and both are the point:
272///
273/// * **Free.** It is an atomic-free borrow of an already-initialized static —
274/// no syscalls, no parsing, no allocation — so it can sit on the hottest
275/// inner loop without a caching layer of its own.
276/// * **Constant.** Two runs of the same fit in one process see byte-identical
277/// budgets, so any route chosen from it is reproducible. Planners used to
278/// defend against the movement themselves (a monotone high-water floor in
279/// BMS, a per-term captured field in the SAE); a stationary primitive is what
280/// makes those compensations unnecessary rather than load-bearing.
281pub fn process_memory_availability() -> &'static MemoryAvailability {
282 &MemoryGovernor::global().ledger.availability
283}
284
285/// The process's available-memory figure in bytes, saturating to `usize`.
286/// Convenience over [`process_memory_availability`] for planners that only
287/// need the scalar.
288pub fn process_available_memory_bytes() -> usize {
289 process_memory_availability().available_bytes_usize()
290}
291
292/// Convert one provenance-preserving availability observation to the process
293/// ledger budget: 3/4 of the observation's stationary **capacity**. A probe
294/// that failed closed reports zero capacity and therefore admits nothing, which
295/// is the authoritative exhausted-memory signal.
296///
297/// gam#2702. This used to read `available_bytes` — free space — and that made
298/// every reservation verdict in the process a function of what the rest of the
299/// box happened to be doing, in exactly the way #2684 removed one layer up:
300///
301/// * the observation behind it is taken **once**, in the [`MemoryGovernor`]'s
302/// `OnceLock`, so it never tracked exhaustion in the first place. It froze
303/// whatever free memory existed at the instant of first governed allocation
304/// and used that number for the rest of the process's life. A frozen sample
305/// of a moving quantity is neither a live guard nor a reproducible
306/// threshold;
307/// * two processes launched from one job cgroup therefore derived different
308/// budgets from the same configuration, because page cache and sibling
309/// processes charge that cgroup continuously. Measured consequence: three
310/// `gam` inference tests passed in a 71-test run and failed in a 6-test
311/// subset of the same binary at the same commit, with
312/// `resource policy refused exact coefficient-SE columns 0..1` — a refusal of
313/// a few kilobytes.
314///
315/// The ledger's job is to bound **this process's own** jointly-live governed
316/// allocations against the ceiling this process could ever address. Memory
317/// already committed elsewhere on the box is not memory this ledger can account
318/// for, release, or route around; reading it only makes a fit's verdict a
319/// function of its neighbours, which SPEC-20 forbids (see
320/// [`process_memory_availability`]). Under a job scheduler the cgroup hard
321/// limit *is* the per-job ceiling, so capacity is the bound that actually binds.
322fn governor_budget_from_availability(availability: &MemoryAvailability) -> usize {
323 stationary_headroom_of_capacity(availability)
324}
325
326/// Convert the same observation to the process's **stationary** materialization
327/// ceiling: the answer to "could a dense footprint this large ever live here?",
328/// which must not move when a sibling allocates (#2684). A probe that failed
329/// closed reports zero capacity and therefore admits nothing.
330fn governor_materialization_cap_from_availability(availability: &MemoryAvailability) -> usize {
331 stationary_headroom_of_capacity(availability)
332}
333
334/// The one arithmetic both process-wide ceilings are derived from: the headroom
335/// fraction applied to stationary capacity.
336///
337/// The ledger budget and the materialization cap are therefore equal by
338/// construction rather than by coincidence, and that equality is the point:
339/// they are two questions about the same ceiling ("does this fit alongside what
340/// this process already has live" and "could this ever fit here at all"), so a
341/// routing decision taken from the cap can never be contradicted by the ledger
342/// for a reason that is not another live reservation. Keeping the arithmetic in
343/// one place is what stops the two from drifting back apart (#2684, #2702).
344fn stationary_headroom_of_capacity(availability: &MemoryAvailability) -> usize {
345 let scaled = u128::from(availability.capacity_bytes()) * GOVERNOR_BUDGET_NUMERATOR
346 / GOVERNOR_BUDGET_DENOMINATOR;
347 usize::try_from(scaled).unwrap_or(usize::MAX)
348}
349
350/// Typed refusal from [`MemoryGovernor::try_reserve`].
351///
352/// Carries the full ledger evidence so callers can route to a chunked or
353/// matrix-free strategy (and so error messages explain *why* dense was
354/// refused). This is a routing signal, never an abort: the process still has
355/// its unreserved headroom, the requested allocation just does not fit the
356/// joint budget.
357#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
358pub enum MemoryReservationError {
359 #[error(
360 "{context}: cannot reserve {requested_bytes} bytes; {reserved_bytes} of {budget_bytes} bytes already reserved process-wide; detected availability: {availability}"
361 )]
362 BudgetExceeded {
363 context: Box<str>,
364 requested_bytes: usize,
365 reserved_bytes: usize,
366 budget_bytes: usize,
367 availability: MemoryAvailability,
368 },
369
370 #[error(
371 "{context}: dense allocation size overflow for {copies} copies of a {nrows}x{ncols} f64 matrix"
372 )]
373 SizeOverflow {
374 context: Box<str>,
375 nrows: usize,
376 ncols: usize,
377 copies: usize,
378 },
379}
380
381#[derive(Debug)]
382struct GovernorLedger {
383 budget_bytes: usize,
384 materialization_cap_bytes: usize,
385 availability: MemoryAvailability,
386 reserved_bytes: std::sync::atomic::AtomicUsize,
387}
388
389/// Process-wide byte-accounting governor for large allocations.
390///
391/// Every large planned allocation (dense design materialization, covariance
392/// block, sampler design assembly) reserves its byte footprint against one
393/// shared ledger via [`try_reserve`](Self::try_reserve) and holds the returned
394/// RAII [`MemoryReservation`] for as long as the allocation is live. Because
395/// the ledger is shared, allocations that are each individually acceptable can
396/// no longer *jointly* exceed memory: whichever request would tip the ledger
397/// past the budget gets a typed [`MemoryReservationError`] and routes to a
398/// chunked or matrix-free strategy instead. Strategy selection is thereby a
399/// continuous function of predicted live bytes vs remaining budget, not of
400/// row/column thresholds.
401///
402/// The global budget is sized once from this process's stationary capacity
403/// (host total memory, clamped by the binding cgroup's hard limit inside
404/// containers or under a job scheduler) — see `GOVERNOR_BUDGET_NUMERATOR` for
405/// the headroom rationale and `governor_budget_from_availability` for why it
406/// is not denominated in free memory (#2702).
407#[derive(Debug, Clone)]
408pub struct MemoryGovernor {
409 ledger: Arc<GovernorLedger>,
410}
411
412impl MemoryGovernor {
413 /// The process-wide governor. Budget detection runs once, on first use.
414 ///
415 /// This `OnceLock` is where the process's single availability observation
416 /// is taken; [`process_memory_availability`] hands the same observation to
417 /// every other planner rather than probing again.
418 pub fn global() -> &'static MemoryGovernor {
419 static GLOBAL: OnceLock<MemoryGovernor> = OnceLock::new();
420 GLOBAL.get_or_init(|| {
421 let availability = resample_memory_availability();
422 MemoryGovernor::with_detected_availability(availability)
423 })
424 }
425
426 fn with_detected_availability(availability: MemoryAvailability) -> Self {
427 let budget_bytes = governor_budget_from_availability(&availability);
428 let materialization_cap_bytes = governor_materialization_cap_from_availability(&availability);
429 Self {
430 ledger: Arc::new(GovernorLedger {
431 budget_bytes,
432 materialization_cap_bytes,
433 availability,
434 reserved_bytes: std::sync::atomic::AtomicUsize::new(0),
435 }),
436 }
437 }
438
439 pub fn reserved_bytes(&self) -> usize {
440 self.ledger
441 .reserved_bytes
442 .load(std::sync::atomic::Ordering::Acquire)
443 }
444
445 /// Bytes still admissible: the budget less what is reserved *by this
446 /// process* right now. The only quantity here that moves is this process's
447 /// own live governed footprint — which is the one thing a caller can
448 /// actually route around.
449 pub fn remaining_bytes(&self) -> usize {
450 self.ledger
451 .budget_bytes
452 .saturating_sub(self.reserved_bytes())
453 }
454
455 /// Absolute ceiling for one governed operation: 3/4 of this process's
456 /// memory **capacity** (`min(host total, binding cgroup limit)`).
457 ///
458 /// This is a *routing* threshold — the question it answers is "could a
459 /// dense footprint this large ever live in this process?" — so it is
460 /// deliberately stationary. It is **not** the live budget: whether an
461 /// allocation fits *right now* is decided by [`Self::try_reserve`] against
462 /// the joint ledger, which returns a typed, routable refusal instead of an
463 /// abort. Since #2702 both are denominated in the same capacity, so the two
464 /// can only disagree because of a live reservation.
465 ///
466 /// Returning the live budget here was gam#2684: the budget was 3/4 of
467 /// *available* memory, so a cgroup sitting at its limit drove this ceiling
468 /// continuously to zero (measured at 53,248 bytes available with 448 GB
469 /// free on the host), and every caller comparing a request against it —
470 /// including `DenseDesignMatrix::to_dense`, which panics on refusal —
471 /// refused allocations as small as a 300x12 design. Threshold-shaped
472 /// routing decisions taken from a moving quantity also make the chosen
473 /// route depend on what else the box was doing, which SPEC-20 forbids
474 /// (see [`process_memory_availability`]); one consumer even hashes this
475 /// cap into a basis cache key.
476 pub fn single_materialization_cap_bytes(&self) -> usize {
477 self.ledger.materialization_cap_bytes
478 }
479
480 /// Reserve `bytes` against the joint ledger.
481 ///
482 /// On success the returned [`MemoryReservation`] must be held for as long
483 /// as the allocation it accounts for is live; dropping it releases the
484 /// bytes. On failure the caller receives the ledger evidence and is
485 /// expected to fall back to a chunked or matrix-free strategy.
486 pub fn try_reserve(
487 &self,
488 bytes: usize,
489 context: &str,
490 ) -> Result<MemoryReservation, MemoryReservationError> {
491 use std::sync::atomic::Ordering;
492 let mut current = self.ledger.reserved_bytes.load(Ordering::Relaxed);
493 loop {
494 let next = match current.checked_add(bytes) {
495 Some(next) if next <= self.ledger.budget_bytes => next,
496 _ => {
497 return Err(MemoryReservationError::BudgetExceeded {
498 context: context.into(),
499 requested_bytes: bytes,
500 reserved_bytes: current,
501 budget_bytes: self.ledger.budget_bytes,
502 availability: self.ledger.availability.clone(),
503 });
504 }
505 };
506 match self.ledger.reserved_bytes.compare_exchange_weak(
507 current,
508 next,
509 Ordering::AcqRel,
510 Ordering::Relaxed,
511 ) {
512 Ok(_) => {
513 return Ok(MemoryReservation {
514 ledger: Arc::clone(&self.ledger),
515 bytes,
516 });
517 }
518 Err(observed) => current = observed,
519 }
520 }
521 }
522
523 /// Reserve the footprint of a dense `nrows × ncols` `f64` matrix.
524 /// Dimension-product overflow is reported as a budget refusal (an
525 /// allocation whose size cannot even be computed certainly does not fit).
526 pub fn try_reserve_dense_f64(
527 &self,
528 nrows: usize,
529 ncols: usize,
530 context: &str,
531 ) -> Result<MemoryReservation, MemoryReservationError> {
532 self.try_reserve_dense_f64_copies(nrows, ncols, 1, context)
533 }
534
535 /// Reserve the predicted live footprint of `copies` simultaneous dense
536 /// matrices with one atomic ledger charge.
537 pub fn try_reserve_dense_f64_copies(
538 &self,
539 nrows: usize,
540 ncols: usize,
541 copies: usize,
542 context: &str,
543 ) -> Result<MemoryReservation, MemoryReservationError> {
544 let bytes = dense_f64_bytes(nrows, ncols)
545 .and_then(|one| one.checked_mul(copies))
546 .ok_or_else(|| MemoryReservationError::SizeOverflow {
547 context: context.into(),
548 nrows,
549 ncols,
550 copies,
551 })?;
552 self.try_reserve(bytes, context)
553 }
554}
555
556/// Checked byte footprint of a dense `nrows × ncols` `f64` matrix.
557pub const fn dense_f64_bytes(nrows: usize, ncols: usize) -> Option<usize> {
558 match nrows.checked_mul(ncols) {
559 Some(cells) => cells.checked_mul(std::mem::size_of::<f64>()),
560 None => None,
561 }
562}
563
564/// RAII guard for bytes reserved on a [`MemoryGovernor`] ledger; dropping it
565/// releases the reservation. Hold it exactly as long as the accounted
566/// allocation is live.
567#[derive(Debug)]
568#[must_use = "dropping a memory reservation immediately releases its ledger charge"]
569pub struct MemoryReservation {
570 ledger: Arc<GovernorLedger>,
571 bytes: usize,
572}
573
574impl MemoryReservation {
575 pub fn bytes(&self) -> usize {
576 self.bytes
577 }
578
579 /// Couple this reservation to the value whose memory it accounts for.
580 pub fn bind<T>(self, value: T) -> Governed<T> {
581 Governed {
582 value,
583 }
584 }
585}
586
587/// A value whose live memory is coupled to a process-wide reservation.
588///
589/// Large fallible materializations return this owner so the allocation cannot
590/// outlive its ledger charge. It dereferences to the wrapped value for normal
591/// ndarray and collection operations.
592#[derive(Debug)]
593#[must_use = "the governed value owns a live process-wide memory reservation"]
594pub struct Governed<T> {
595 value: T,
596}
597
598impl<T> std::ops::Deref for Governed<T> {
599 type Target = T;
600
601 fn deref(&self) -> &Self::Target {
602 &self.value
603 }
604}
605
606impl<T> std::ops::DerefMut for Governed<T> {
607 fn deref_mut(&mut self) -> &mut Self::Target {
608 &mut self.value
609 }
610}
611
612impl<T> AsRef<T> for Governed<T> {
613 fn as_ref(&self) -> &T {
614 &self.value
615 }
616}
617
618impl<T> AsMut<T> for Governed<T> {
619 fn as_mut(&mut self) -> &mut T {
620 &mut self.value
621 }
622}
623
624impl Drop for MemoryReservation {
625 fn drop(&mut self) {
626 self.ledger
627 .reserved_bytes
628 .fetch_sub(self.bytes, std::sync::atomic::Ordering::AcqRel);
629 }
630}
631
632/// Hints that flip strict mode on regardless of n/p — used when a code path
633/// is structurally operator-only and any dense fallback would be a bug.
634#[derive(Clone, Copy, Debug, Default)]
635pub struct ProblemHints {
636 pub marginal_slope_large_scale_active: bool,
637}
638
639#[derive(Clone, Copy, Debug, PartialEq, Eq)]
640pub enum DerivativeStorageMode {
641 /// Production exact-math: operator-backed, no dense fallback.
642 AnalyticOperatorRequired,
643 /// Allow dense materialization if under the single-materialization budget.
644 MaterializeIfSmall,
645 /// Dense materialization only permitted for diagnostic code paths.
646 DiagnosticsOnly,
647}
648
649#[derive(Clone, Debug)]
650pub struct MaterializationPolicy {
651 pub max_single_dense_bytes: usize,
652 pub max_cached_dense_bytes: usize,
653 pub row_chunk_target_bytes: usize,
654 pub allow_operator_materialization: bool,
655 pub allow_diagnostic_materialization: bool,
656}
657
658#[derive(Debug, thiserror::Error)]
659pub enum MatrixMaterializationError {
660 #[error(
661 "{context}: dense materialization of {nrows}x{ncols} requires {bytes} bytes (limit {limit_bytes})"
662 )]
663 TooLarge {
664 context: &'static str,
665 nrows: usize,
666 ncols: usize,
667 bytes: usize,
668 limit_bytes: usize,
669 },
670
671 #[error("{context}: operator does not implement chunked row access")]
672 MissingRowChunk { context: &'static str },
673
674 #[error("{context}: row materialization failed: {reason}")]
675 RowMaterializationFailed {
676 context: &'static str,
677 reason: String,
678 },
679
680 #[error("{context}: materialization forbidden by policy (mode={mode:?})")]
681 Forbidden {
682 context: &'static str,
683 mode: DerivativeStorageMode,
684 },
685
686 /// The process-wide [`MemoryGovernor`] could not reserve the requested live
687 /// footprint (or its checked byte size overflowed). Callers route to a
688 /// chunked or matrix-free strategy.
689 #[error(transparent)]
690 Reservation(#[from] MemoryReservationError),
691}
692
693pub trait ResidentBytes {
694 fn resident_bytes(&self) -> usize;
695}
696
697impl ResourcePolicy {
698 /// Conservative default suitable for general-purpose use.
699 ///
700 /// Uses `MaterializeIfSmall`: dense materialization is allowed only when
701 /// the matrix fits under `max_single_materialization_bytes`. This lets
702 /// small-data families that lack an implicit operator work out of the box,
703 /// while problems whose dense footprint does not fit real memory get a
704 /// typed refusal that forces the analytic-operator path. Set
705 /// `derivative_storage_mode = AnalyticOperatorRequired` explicitly to
706 /// reject all dense fallback.
707 ///
708 /// Scalar caps expose the governor's stationary capacity ceiling; they are
709 /// routing thresholds, not independent budgets, and they do not move with
710 /// load. Actual materializations and caches must reserve their complete
711 /// live footprint against the shared governor, so any combination of
712 /// categories is bounded by one ledger — that reservation, not these caps,
713 /// is what enforces "fits right now" (#2684).
714 pub fn default_library() -> Self {
715 Self::for_observed_memory(process_memory_availability())
716 }
717
718 /// The library-default policy a process would derive from `availability`.
719 ///
720 /// [`Self::default_library`] is exactly this function applied to the
721 /// process's own observation, so the routing thresholds a fit sees are a
722 /// pure function of the *observed environment* — and, because every scalar
723 /// below is taken from
724 /// [`MemoryAvailability::capacity_bytes`] rather than
725 /// [`MemoryAvailability::available_bytes`], a pure function of that
726 /// environment's stationary CAPACITY. Two observations of the same box that
727 /// differ only in how much memory happened to be free at the instant of the
728 /// probe therefore produce byte-identical policies, and any route selected
729 /// from one of these caps is reproducible across processes and across load
730 /// (#2684).
731 ///
732 /// Exposed rather than kept private so a test can hold capacity fixed and
733 /// vary free memory without racing the real machine; the live path uses the
734 /// same code, so the property under test is the shipped one.
735 pub fn for_observed_memory(availability: &MemoryAvailability) -> Self {
736 let single_cap = governor_materialization_cap_from_availability(availability);
737 Self {
738 max_single_materialization_bytes: single_cap,
739 max_operator_cache_bytes: single_cap,
740 max_spatial_distance_cache_bytes: single_cap,
741 max_owned_data_cache_bytes: single_cap,
742 row_chunk_target_bytes: LIBRARY_ROW_CHUNK_TARGET_BYTES,
743 derivative_storage_mode: DerivativeStorageMode::MaterializeIfSmall,
744 }
745 }
746
747 /// Strict mode that rejects every dense fallback. Use when you intend to
748 /// run only on operator-backed bases (large-scale Duchon/TPS, exact
749 /// GAMLSS marginal slope, CTN, etc.). The byte caps only govern the
750 /// residual diagnostic surfaces (materialization itself is forbidden by
751 /// the mode).
752 pub fn analytic_operator_required() -> Self {
753 let base = Self::default_library();
754 Self {
755 derivative_storage_mode: DerivativeStorageMode::AnalyticOperatorRequired,
756 ..base
757 }
758 }
759
760 /// Auto-derive the resource policy from the shape of the problem rather
761 /// than from an explicit CLI flag.
762 ///
763 /// Shape alone never flips a policy mode: doing so merely moves the old
764 /// row/column cliff to a byte threshold. Every non-structural path starts
765 /// permissive and makes its strategy decision from the operation's checked
766 /// predicted live bytes versus the governor's current remaining budget.
767 ///
768 /// `hints.marginal_slope_large_scale_active` forces strict mode regardless
769 /// of shape: that path is structurally operator-only and any dense
770 /// fallback would be a bug, not a memory question.
771 pub fn for_problem(hints: ProblemHints) -> Self {
772 if hints.marginal_slope_large_scale_active {
773 return Self::analytic_operator_required();
774 }
775 Self::default_library()
776 }
777
778 pub const fn material_policy(&self) -> MaterializationPolicy {
779 MaterializationPolicy {
780 max_single_dense_bytes: self.max_single_materialization_bytes,
781 max_cached_dense_bytes: self.max_operator_cache_bytes,
782 row_chunk_target_bytes: self.row_chunk_target_bytes,
783 allow_operator_materialization: matches!(
784 self.derivative_storage_mode,
785 DerivativeStorageMode::MaterializeIfSmall
786 ),
787 allow_diagnostic_materialization: !matches!(
788 self.derivative_storage_mode,
789 DerivativeStorageMode::AnalyticOperatorRequired
790 ),
791 }
792 }
793}
794
795/// Returns how many rows to stream per chunk so that each chunk uses approximately
796/// `target_bytes` given a row width of `cols` f64 entries.
797pub const fn rows_for_target_bytes(target_bytes: usize, cols: usize) -> usize {
798 let raw_bytes_per_row = cols.saturating_mul(std::mem::size_of::<f64>());
799 let bytes_per_row = if raw_bytes_per_row == 0 {
800 1
801 } else {
802 raw_bytes_per_row
803 };
804 let rows = target_bytes / bytes_per_row;
805 if rows == 0 { 1 } else { rows }
806}
807
808/// Select the row count for prediction-time covariance work.
809///
810/// A prediction row can keep roughly four `parameter_dim × local_dim`
811/// `f64` workspaces live while gradients and covariance solves are assembled.
812/// This central policy prevents predictors from drifting to different memory
813/// budgets and chunk bounds for the same operation.
814pub fn prediction_chunk_rows(parameter_dim: usize, local_dim: usize, total_rows: usize) -> usize {
815 const MIN_ROWS: usize = 16;
816 const MAX_ROWS: usize = 4096;
817
818 if total_rows == 0 {
819 return 1;
820 }
821 let live_f64_values_per_row = parameter_dim
822 .max(1)
823 .saturating_mul(local_dim.max(1))
824 .saturating_mul(4);
825 rows_for_target_bytes(
826 ResourcePolicy::default_library().row_chunk_target_bytes,
827 live_f64_values_per_row,
828 )
829 .clamp(MIN_ROWS, MAX_ROWS)
830 .min(total_rows)
831}
832
833use std::collections::{HashMap, VecDeque};
834use std::hash::{Hash, Hasher};
835use std::sync::{Arc, Mutex, OnceLock};
836
837/// Byte-limited LRU cache with an optional entry cap.
838///
839/// Unlike an entry-count-limited LRU, this cache tracks the resident byte cost
840/// of each value (via [`ResidentBytes`]) and evicts the least-recently-used
841/// entries until the total resident bytes fit under `max_bytes`. This is the
842/// correct policy for large-scale payloads where a single cache entry (e.g.
843/// an n*K distance matrix) can itself be multiple gigabytes and an entry-count
844/// cap would silently blow the memory budget. Small entry caps are still useful
845/// for payloads with known shape, such as owned PC data matrices shared across
846/// model blocks.
847pub struct ByteLruCache<K: Eq + Hash + Clone, V> {
848 /// One independent LRU partition per shard. A single shard (the default)
849 /// is byte-for-byte equivalent to the original single-`Mutex` cache; with
850 /// `shard_count > 1` the key hash selects the shard, so concurrent traffic
851 /// on distinct keys contends `1/shard_count` as often and each shard's
852 /// recency `VecDeque` is `1/shard_count` as long (the hit-path rescan is a
853 /// linear `position` lookup, so shrinking the per-shard order also cuts
854 /// per-access cost). Sharding is opt-in (`new_sharded`) precisely because
855 /// the byte budget is split across shards — that is correct for caches of
856 /// many small entries (e.g. cell-moment memos) but wrong for caches of a
857 /// few multi-GiB entries (distance matrices), which keep `shard_count == 1`.
858 shards: Box<[Mutex<ByteLruInner<K, V>>]>,
859 /// Per-shard byte budget. `shard_bytes * shards.len() >= max_bytes`.
860 shard_bytes: usize,
861 /// Per-shard entry budget, if any (`0` disables caching, as before).
862 shard_entries: Option<usize>,
863 max_bytes: usize,
864 governor: MemoryGovernor,
865}
866
867struct ByteLruInner<K, V> {
868 // The reservation is stored beside the value, so eviction and clear drop
869 // the process-wide charge at exactly the same time as cache ownership.
870 map: HashMap<K, (V, usize, MemoryReservation)>,
871 order: VecDeque<K>,
872 resident_bytes: usize,
873}
874
875impl<K: Eq + Hash + Clone, V: Clone + ResidentBytes> ByteLruCache<K, V> {
876 pub fn new(max_bytes: usize) -> Self {
877 Self::build(max_bytes, None, 1)
878 }
879
880 pub fn with_max_entries(max_bytes: usize, max_entries: usize) -> Self {
881 Self::build(max_bytes, Some(max_entries), 1)
882 }
883
884 /// Like [`new`](Self::new) but partitions the cache across `shard_count`
885 /// independently-locked LRU shards to cut lock contention under heavy
886 /// concurrent access. The byte budget is divided evenly across shards, so
887 /// this is only appropriate for caches holding many small entries.
888 pub fn new_sharded(max_bytes: usize, shard_count: usize) -> Self {
889 Self::build(max_bytes, None, shard_count)
890 }
891
892 /// Like [`with_max_entries`](Self::with_max_entries) but sharded; see
893 /// [`new_sharded`](Self::new_sharded).
894 pub fn with_max_entries_sharded(
895 max_bytes: usize,
896 max_entries: usize,
897 shard_count: usize,
898 ) -> Self {
899 Self::build(max_bytes, Some(max_entries), shard_count)
900 }
901
902 fn build(max_bytes: usize, max_entries: Option<usize>, shard_count: usize) -> Self {
903 Self::build_with_governor(
904 max_bytes,
905 max_entries,
906 shard_count,
907 MemoryGovernor::global().clone(),
908 )
909 }
910
911 fn build_with_governor(
912 max_bytes: usize,
913 max_entries: Option<usize>,
914 shard_count: usize,
915 governor: MemoryGovernor,
916 ) -> Self {
917 let shard_count = shard_count.max(1);
918 // Split the global budgets across shards, rounding up so the aggregate
919 // capacity never falls below the requested budget. With a single shard
920 // these equal the global budgets exactly (legacy behavior). A `0`
921 // entry budget still disables caching and must not be rounded up to 1.
922 let shard_bytes = max_bytes.div_ceil(shard_count);
923 let shard_entries = max_entries.map(|m| {
924 if m == 0 {
925 0
926 } else {
927 m.div_ceil(shard_count).max(1)
928 }
929 });
930 let shards = (0..shard_count)
931 .map(|_| {
932 Mutex::new(ByteLruInner {
933 map: HashMap::new(),
934 order: VecDeque::new(),
935 resident_bytes: 0,
936 })
937 })
938 .collect::<Vec<_>>()
939 .into_boxed_slice();
940 Self {
941 shards,
942 shard_bytes,
943 shard_entries,
944 max_bytes,
945 governor,
946 }
947 }
948
949 #[inline]
950 fn shard(&self, key: &K) -> &Mutex<ByteLruInner<K, V>> {
951 if self.shards.len() == 1 {
952 return &self.shards[0];
953 }
954 let mut hasher = std::collections::hash_map::DefaultHasher::new();
955 key.hash(&mut hasher);
956 let shard_count =
957 u64::try_from(self.shards.len()).expect("shard count must fit in u64");
958 let shard = usize::try_from(hasher.finish() % shard_count)
959 .expect("shard index is bounded by the usize shard count");
960 &self.shards[shard]
961 }
962
963 pub fn get(&self, key: &K) -> Option<V> {
964 // recover from poison
965 let mut g = self.shard(key).lock().unwrap_or_else(|p| p.into_inner());
966 let v = g.map.get(key)?.0.clone();
967 // move to back (most-recently-used)
968 if let Some(pos) = g.order.iter().position(|k| k == key) {
969 let k = g
970 .order
971 .remove(pos)
972 .expect("position() returned an in-bounds index into this same deque");
973 g.order.push_back(k);
974 }
975 Some(v)
976 }
977
978 pub fn insert(&self, key: K, value: V) {
979 let charge = value.resident_bytes();
980 let mut g = self.shard(&key).lock().unwrap_or_else(|p| p.into_inner());
981
982 // If already present, remove the old entry first so resident bytes stay
983 // accurate and the LRU ordering reflects this insertion.
984 if let Some((_old, old_charge, _reservation)) = g.map.remove(&key) {
985 g.resident_bytes = g.resident_bytes.saturating_sub(old_charge);
986 if let Some(pos) = g.order.iter().position(|k| k == &key) {
987 g.order.remove(pos);
988 }
989 }
990
991 if charge > self.shard_bytes {
992 // Too large to cache; skip insertion.
993 return;
994 }
995
996 if let Some(max_entries) = self.shard_entries {
997 if max_entries == 0 {
998 return;
999 }
1000 while g.map.len() >= max_entries {
1001 if let Some(evict_key) = g.order.pop_front() {
1002 if let Some((_v, c, _reservation)) = g.map.remove(&evict_key) {
1003 g.resident_bytes = g.resident_bytes.saturating_sub(c);
1004 }
1005 } else {
1006 break;
1007 }
1008 }
1009 }
1010
1011 while g.resident_bytes + charge > self.shard_bytes {
1012 if let Some(evict_key) = g.order.pop_front() {
1013 if let Some((_v, c, _reservation)) = g.map.remove(&evict_key) {
1014 g.resident_bytes = g.resident_bytes.saturating_sub(c);
1015 }
1016 } else {
1017 break;
1018 }
1019 }
1020
1021 let reservation = match self
1022 .governor
1023 .try_reserve(charge, "ByteLruCache resident entry")
1024 {
1025 Ok(reservation) => reservation,
1026 Err(_) => return,
1027 };
1028 g.map.insert(key.clone(), (value, charge, reservation));
1029 g.order.push_back(key);
1030 g.resident_bytes = g.resident_bytes.saturating_add(charge);
1031 }
1032
1033 pub fn resident_bytes(&self) -> usize {
1034 self.shards
1035 .iter()
1036 .map(|shard| {
1037 shard
1038 .lock()
1039 .unwrap_or_else(|p| p.into_inner())
1040 .resident_bytes
1041 })
1042 .sum()
1043 }
1044
1045 pub const fn max_bytes(&self) -> usize {
1046 self.max_bytes
1047 }
1048
1049 pub fn len(&self) -> usize {
1050 self.shards
1051 .iter()
1052 .map(|shard| shard.lock().unwrap_or_else(|p| p.into_inner()).map.len())
1053 .sum()
1054 }
1055
1056 pub fn is_empty(&self) -> bool {
1057 self.len() == 0
1058 }
1059
1060 pub fn clear(&self) {
1061 for shard in self.shards.iter() {
1062 let mut g = shard.lock().unwrap_or_else(|p| p.into_inner());
1063 g.map.clear();
1064 g.order.clear();
1065 g.resident_bytes = 0;
1066 }
1067 }
1068}
1069
1070impl<K: Eq + Hash + Clone, V: Clone + ResidentBytes> std::fmt::Debug for ByteLruCache<K, V> {
1071 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1072 f.debug_struct("ByteLruCache")
1073 .field("resident_bytes", &self.resident_bytes())
1074 .field("max_bytes", &self.max_bytes)
1075 .field("shard_count", &self.shards.len())
1076 .field("shard_bytes", &self.shard_bytes)
1077 .field("shard_entries", &self.shard_entries)
1078 .finish()
1079 }
1080}
1081
1082/// Byte-accounting for `Arc<Array2<f64>>`.
1083///
1084/// Reports the full dense footprint of the owned array. Multiple `Arc`s
1085/// pointing to the same allocation will each report the full size; this is
1086/// the conservative accounting the caches want because a single residency in
1087/// the cache is what we are budgeting for.
1088impl ResidentBytes for Arc<ndarray::Array2<f64>> {
1089 fn resident_bytes(&self) -> usize {
1090 std::mem::size_of::<f64>()
1091 .saturating_mul(self.nrows())
1092 .saturating_mul(self.ncols())
1093 }
1094}
1095
1096/// Lazy-init cache safe to call from inside rayon par_iter.
1097///
1098/// `std::sync::OnceLock::get_or_init` parks racing threads on an OS
1099/// condition variable until the leader's init closure finishes. If the
1100/// leader's init closure itself dispatches a nested `into_par_iter`, the
1101/// parked threads are now unavailable as rayon workers, and the leader
1102/// blocks waiting for chunks that no one can service. Classic deadlock.
1103///
1104/// `RayonSafeOnce` removes the trap by computing the value *outside* any
1105/// lock. Concurrent racers may produce duplicate values; the first to
1106/// publish wins, the rest drop their result. No thread ever parks waiting
1107/// for another thread's init to finish, so nested rayon par_iter inside
1108/// the init closure is safe.
1109///
1110/// Use this in place of `OnceLock` whenever the init closure transitively
1111/// runs rayon work *and* the cache may be entered concurrently from
1112/// inside another rayon par_iter. The redundant-work cost on first race
1113/// is the price for never deadlocking; in practice the loser threads
1114/// throw away one round of work and steady-state is identical to
1115/// `OnceLock`.
1116pub struct RayonSafeOnce<T> {
1117 slot: std::sync::OnceLock<T>,
1118}
1119
1120impl<T> RayonSafeOnce<T> {
1121 pub const fn new() -> Self {
1122 Self {
1123 slot: std::sync::OnceLock::new(),
1124 }
1125 }
1126
1127 /// Returns the cached value if already populated.
1128 #[inline]
1129 pub fn get(&self) -> Option<&T> {
1130 self.slot.get()
1131 }
1132
1133 /// Returns the cached value, computing it if absent.
1134 ///
1135 /// The init closure runs WITHOUT holding any lock — calls from
1136 /// concurrent rayon workers may all run it, and all but the first
1137 /// to call `set` discard their result. This is the contract that
1138 /// keeps nested `into_par_iter` inside `init` from deadlocking on
1139 /// other workers parked on a `OnceLock`.
1140 ///
1141 /// Named `get_or_compute` (not `get_or_init`) so the codebase-level
1142 /// lint that bans `OnceLock::get_or_init` near rayon `par_iter` does
1143 /// not flag this safe-by-construction path.
1144 pub fn get_or_compute<F>(&self, init: F) -> &T
1145 where
1146 F: FnOnce() -> T,
1147 {
1148 if let Some(v) = self.slot.get() {
1149 return v;
1150 }
1151 let candidate = init();
1152 if self.slot.set(candidate).is_err() {
1153 log::trace!(
1154 "RayonSafeOnce: a concurrent initializer won the race; \
1155 keeping its value and discarding this candidate"
1156 );
1157 }
1158 self.slot
1159 .get()
1160 .expect("RayonSafeOnce slot populated by set() above")
1161 }
1162}
1163
1164impl<T> Default for RayonSafeOnce<T> {
1165 fn default() -> Self {
1166 Self::new()
1167 }
1168}
1169
1170impl<T: Clone> Clone for RayonSafeOnce<T> {
1171 fn clone(&self) -> Self {
1172 let cloned = Self::new();
1173 if let Some(value) = self.slot.get() {
1174 // `get_or_init` rather than `set(..).expect(..)`: the slot of a
1175 // freshly constructed `Self` is empty, so the initializer always
1176 // runs — and this spelling has no `Result` to discard, which keeps
1177 // the impl's bound at `T: Clone` instead of forcing `T: Debug` on
1178 // every caller just to name a panic that cannot happen.
1179 cloned.slot.get_or_init(|| value.clone());
1180 }
1181 cloned
1182 }
1183}
1184
1185impl<T: std::fmt::Debug> std::fmt::Debug for RayonSafeOnce<T> {
1186 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1187 f.debug_struct("RayonSafeOnce")
1188 .field("slot", &self.slot.get())
1189 .finish()
1190 }
1191}
1192
1193#[cfg(test)]
1194mod byte_lru_tests {
1195 use super::*;
1196
1197 fn cache_test_governor(budget_bytes: usize) -> MemoryGovernor {
1198 let available_bytes = (budget_bytes as u128 * GOVERNOR_BUDGET_DENOMINATOR)
1199 .div_ceil(GOVERNOR_BUDGET_NUMERATOR);
1200 let available_bytes =
1201 u64::try_from(available_bytes).expect("test cache budget must fit in u64");
1202 MemoryGovernor::with_detected_availability(MemoryAvailability::from_observation(
1203 available_bytes,
1204 available_bytes,
1205 CgroupMemoryObservation::NotPresent,
1206 ))
1207 }
1208
1209 /// Fixed-charge value so byte-budget arithmetic in the tests is exact.
1210 #[derive(Clone, PartialEq, Debug)]
1211 struct Payload(u64);
1212 impl ResidentBytes for Payload {
1213 fn resident_bytes(&self) -> usize {
1214 8
1215 }
1216 }
1217
1218 #[test]
1219 fn single_shard_round_trips_and_evicts_by_bytes() {
1220 // 3 entries' worth of budget; a single shard preserves strict global LRU.
1221 let cache: ByteLruCache<u64, Payload> =
1222 ByteLruCache::build_with_governor(24, None, 1, cache_test_governor(24));
1223 for k in 0..3 {
1224 cache.insert(k, Payload(k));
1225 }
1226 assert_eq!(cache.len(), 3);
1227 assert_eq!(cache.resident_bytes(), 24);
1228 // Touch key 0 so it is most-recently-used, then overflow by one.
1229 assert_eq!(cache.get(&0), Some(Payload(0)));
1230 cache.insert(3, Payload(3));
1231 // Key 1 (now least-recently-used) is evicted; 0 survives the touch.
1232 assert_eq!(cache.len(), 3);
1233 assert_eq!(cache.get(&1), None);
1234 assert_eq!(cache.get(&0), Some(Payload(0)));
1235 assert_eq!(cache.get(&3), Some(Payload(3)));
1236 }
1237
1238 #[test]
1239 fn zero_entry_budget_disables_caching_in_every_shard() {
1240 let single: ByteLruCache<u64, Payload> = ByteLruCache::with_max_entries(1 << 20, 0);
1241 single.insert(7, Payload(7));
1242 assert_eq!(single.get(&7), None);
1243 let sharded: ByteLruCache<u64, Payload> =
1244 ByteLruCache::with_max_entries_sharded(1 << 20, 0, 16);
1245 sharded.insert(7, Payload(7));
1246 assert_eq!(sharded.get(&7), None);
1247 }
1248
1249 #[test]
1250 fn sharded_cache_retrieves_all_keys_and_respects_aggregate_budget() {
1251 // Generous budget split across 8 shards; every inserted key must be
1252 // retrievable and the aggregate residency must never exceed the global
1253 // budget (shard_bytes * shard_count, rounded up).
1254 let shard_count = 8usize;
1255 let max_bytes = 8 * 64; // 64 entries' worth, 8 per shard on average.
1256 let cache: ByteLruCache<u64, Payload> = ByteLruCache::build_with_governor(
1257 max_bytes,
1258 None,
1259 shard_count,
1260 cache_test_governor(max_bytes),
1261 );
1262 for k in 0..64u64 {
1263 cache.insert(k, Payload(k));
1264 }
1265 // Per-shard budgets sum to >= the requested global budget.
1266 assert!(cache.resident_bytes() <= max_bytes.div_ceil(shard_count) * shard_count);
1267 // Re-inserting then reading back a key returns the stored payload.
1268 cache.insert(123, Payload(123));
1269 assert_eq!(cache.get(&123), Some(Payload(123)));
1270 assert!(!cache.is_empty());
1271 cache.clear();
1272 assert_eq!(cache.len(), 0);
1273 assert_eq!(cache.resident_bytes(), 0);
1274 }
1275}
1276
1277#[cfg(test)]
1278mod resource_policy_tests {
1279 use super::*;
1280
1281 // ── rows_for_target_bytes ─────────────────────────────────────────────────
1282
1283 #[test]
1284 fn rows_for_target_bytes_exact_fit() {
1285 // 1 col × 8 bytes/f64; target 8 bytes → 1 row
1286 assert_eq!(rows_for_target_bytes(8, 1), 1);
1287 }
1288
1289 #[test]
1290 fn rows_for_target_bytes_multiple_rows() {
1291 // 1 col × 8 bytes/f64; target 80 bytes → 10 rows
1292 assert_eq!(rows_for_target_bytes(80, 1), 10);
1293 }
1294
1295 #[test]
1296 fn rows_for_target_bytes_multiple_cols() {
1297 // 4 cols × 8 = 32 bytes/row; target 128 → 4 rows
1298 assert_eq!(rows_for_target_bytes(128, 4), 4);
1299 }
1300
1301 #[test]
1302 fn rows_for_target_bytes_zero_target_returns_one() {
1303 // Zero target cannot give 0 rows — floor to 1
1304 assert_eq!(rows_for_target_bytes(0, 1), 1);
1305 }
1306
1307 #[test]
1308 fn rows_for_target_bytes_zero_cols_returns_non_zero() {
1309 // Zero cols → bytes_per_row falls back to 1 → rows = target
1310 assert_eq!(rows_for_target_bytes(100, 0), 100);
1311 }
1312
1313 #[test]
1314 fn rows_for_target_bytes_large_target() {
1315 // Canonical target, 1024 cols → 8192 bytes/row → rows = target/8192.
1316 // Taken by name rather than transcribed, so the case stays pinned to the
1317 // library target if that target ever moves (#2704).
1318 let target = LIBRARY_ROW_CHUNK_TARGET_BYTES;
1319 let cols = 1024_usize;
1320 let expected = target / (cols * std::mem::size_of::<f64>());
1321 assert_eq!(rows_for_target_bytes(target, cols), expected);
1322 }
1323
1324 #[test]
1325 fn prediction_chunks_share_the_runtime_byte_budget() {
1326 assert_eq!(prediction_chunk_rows(1024, 1, 100_000), 256);
1327 assert_eq!(prediction_chunk_rows(32, 2, 100_000), 4096);
1328 }
1329
1330 #[test]
1331 fn prediction_chunks_respect_dataset_bounds() {
1332 assert_eq!(prediction_chunk_rows(1, 1, 7), 7);
1333 assert_eq!(prediction_chunk_rows(1, 1, 0), 1);
1334 }
1335
1336 // ── ResourcePolicy::for_problem ──────────────────────────────────────────
1337
1338 #[test]
1339 fn for_problem_small_data_uses_materialize_if_small() {
1340 let p = ResourcePolicy::for_problem(ProblemHints::default());
1341 assert_eq!(
1342 p.derivative_storage_mode,
1343 DerivativeStorageMode::MaterializeIfSmall
1344 );
1345 }
1346
1347 #[test]
1348 fn for_problem_has_no_row_or_column_cliff() {
1349 let narrow = ResourcePolicy::for_problem(ProblemHints::default());
1350 let wide = ResourcePolicy::for_problem(ProblemHints::default());
1351 assert_eq!(
1352 narrow.derivative_storage_mode,
1353 DerivativeStorageMode::MaterializeIfSmall
1354 );
1355 assert_eq!(
1356 wide.derivative_storage_mode,
1357 DerivativeStorageMode::MaterializeIfSmall
1358 );
1359 }
1360
1361 #[test]
1362 fn for_problem_dimension_overflow_defers_to_typed_reservation() {
1363 let policy = ResourcePolicy::for_problem(ProblemHints::default());
1364 assert_eq!(
1365 policy.derivative_storage_mode,
1366 DerivativeStorageMode::MaterializeIfSmall
1367 );
1368 }
1369
1370 #[test]
1371 fn for_problem_marginal_slope_hint_is_strict() {
1372 let p = ResourcePolicy::for_problem(ProblemHints {
1373 marginal_slope_large_scale_active: true,
1374 });
1375 assert_eq!(
1376 p.derivative_storage_mode,
1377 DerivativeStorageMode::AnalyticOperatorRequired
1378 );
1379 }
1380
1381 // ── ResourcePolicy::material_policy ─────────────────────────────────────
1382
1383 #[test]
1384 fn material_policy_default_library_allows_operator_and_diagnostics() {
1385 let mp = ResourcePolicy::default_library().material_policy();
1386 assert!(mp.allow_operator_materialization);
1387 assert!(mp.allow_diagnostic_materialization);
1388 }
1389
1390 #[test]
1391 fn material_policy_analytic_operator_required_blocks_both() {
1392 let mp = ResourcePolicy::analytic_operator_required().material_policy();
1393 assert!(!mp.allow_operator_materialization);
1394 assert!(!mp.allow_diagnostic_materialization);
1395 }
1396
1397 #[test]
1398 fn material_policy_propagates_byte_limits() {
1399 let policy = ResourcePolicy::default_library();
1400 let mp = policy.material_policy();
1401 assert_eq!(
1402 mp.max_single_dense_bytes,
1403 policy.max_single_materialization_bytes
1404 );
1405 assert_eq!(mp.max_cached_dense_bytes, policy.max_operator_cache_bytes);
1406 assert_eq!(mp.row_chunk_target_bytes, policy.row_chunk_target_bytes);
1407 }
1408
1409 // ── MemoryGovernor ledger ────────────────────────────────────────────────
1410
1411 #[test]
1412 fn compressed_macos_observation_keeps_xnu_available_memory_positive() {
1413 // #2316's healthy 8 GiB macOS host had more compressed than
1414 // free+inactive pages. sysinfo 0.33 subtracted compressor pages and
1415 // saturated to zero; 0.38 follows XNU and reports
1416 // (active + inactive + free) * page_size.
1417 let xnu_available = (75_514_u64 + 69_056 + 3_802) * 16_384;
1418 assert_eq!(xnu_available, 2_430_926_848);
1419 let availability = MemoryAvailability::from_observation(
1420 xnu_available,
1421 8 * 1024 * 1024 * 1024,
1422 CgroupMemoryObservation::NotPresent,
1423 );
1424 assert_eq!(availability.available_bytes(), xnu_available);
1425 // Capacity is the host's 8 GiB, so the budget is 6 GiB (#2702); the
1426 // 2.43 GB free figure decides nothing but the availability provenance.
1427 assert_eq!(availability.capacity_bytes(), 8 * 1024 * 1024 * 1024);
1428 assert_eq!(
1429 governor_budget_from_availability(&availability),
1430 6 * 1024 * 1024 * 1024
1431 );
1432 }
1433
1434 #[test]
1435 fn literal_unlimited_cgroup_defers_to_host_available_memory_2317() {
1436 let availability = MemoryAvailability::from_observation(
1437 2_430_926_848,
1438 4_000_000_000,
1439 CgroupMemoryObservation::V2Unbounded {
1440 cgroup_path: "/fixture/leaf".into(),
1441 inspected_levels: 3,
1442 },
1443 );
1444 assert_eq!(availability.available_bytes(), 2_430_926_848);
1445 assert_eq!(availability.capacity_bytes(), 4_000_000_000);
1446 assert_eq!(availability.limiting_source, MemoryAvailabilitySource::Host);
1447 assert!(format!("{availability}").contains("unbounded cgroup-v2"));
1448 let governor = MemoryGovernor::with_detected_availability(availability);
1449 assert_eq!(governor.remaining_bytes(), 3_000_000_000);
1450 assert_eq!(governor.single_materialization_cap_bytes(), 3_000_000_000);
1451 // Exercise the actual ledger. A literal unlimited controller delegates
1452 // to the finite host; it does not authorize unbounded reservations.
1453 let reservation = governor
1454 .try_reserve(3_000_000_000, "host budget")
1455 .unwrap();
1456 assert_eq!(governor.remaining_bytes(), 0);
1457 assert!(matches!(
1458 governor.try_reserve(1, "one byte beyond the host budget"),
1459 Err(MemoryReservationError::BudgetExceeded { .. })
1460 ));
1461 drop(reservation);
1462 assert_eq!(governor.remaining_bytes(), 3_000_000_000);
1463 }
1464}
1465
1466/// gam#2702: a reservation verdict is a function of the request and this
1467/// process's own live footprint — never of what else was on the box, and never
1468/// of what this process happened to do earlier.
1469///
1470/// The filed incident: three `gam` inference tests passed in a 71-test run of
1471/// the `inference` binary and failed in a 6-test subset of that same binary at
1472/// that same commit, all three with
1473/// `resource policy refused exact coefficient-SE columns 0..1`. The refused
1474/// allocation was two dense f64 copies of one column — kilobytes. The ledger
1475/// budget was `3/4 x FREE memory` sampled once, at whichever moment this
1476/// process first touched the governor, so processes launched from one job
1477/// cgroup derived different budgets according to how much page cache and how
1478/// many sibling test processes were charged to that cgroup at their own instant
1479/// of first touch.
1480///
1481/// #2684 had already removed exactly this mechanism from the materialization
1482/// ceiling. What follows asserts the ledger is not an exception, on the shipped
1483/// derivation, with the pre-fix arithmetic spelled out as the falsification
1484/// control.
1485#[cfg(test)]
1486mod governor_budget_is_capacity_determined_2702_tests {
1487 use super::*;
1488
1489 /// The MSI compute node the incident was measured on: a `--mem=8g` job on a
1490 /// box with hundreds of GB free.
1491 const HOST_AVAILABLE_BYTES: u64 = 448_648_040_448;
1492 const HOST_TOTAL_BYTES: u64 = 527_799_400 * 1024;
1493 const JOB_LIMIT_BYTES: u64 = 8 * 1024 * 1024 * 1024;
1494
1495 /// The refused allocation, restated from the failing path
1496 /// (`gam-solve` `optimizer.rs`): the exact factorized coefficient-SE solve
1497 /// reserves two dense `p_t x chunk` f64 workspaces at once. `p_t = 512` and
1498 /// `chunk = 1` is the smallest request that path can ever make on a model of
1499 /// this width — one column at a time.
1500 const SE_TRANSFORMED_ROWS: usize = 512;
1501 /// One column, two copies, eight bytes per f64.
1502 const SE_CHUNK_BYTES: usize = SE_TRANSFORMED_ROWS * 8 * 2;
1503
1504 /// One cgroup read at load `charged`, everything else held fixed.
1505 fn one_job_cgroup_at_load(charged: u64) -> MemoryAvailability {
1506 crate::test_support::simulated_cgroup_memory_environment(
1507 HOST_AVAILABLE_BYTES,
1508 HOST_TOTAL_BYTES,
1509 JOB_LIMIT_BYTES,
1510 charged,
1511 )
1512 }
1513
1514 #[test]
1515 fn a_cgroup_at_its_limit_moves_neither_the_budget_nor_the_materialization_cap_2684_2702() {
1516 const LIMIT: u64 = 6 * 1024 * 1024 * 1024;
1517 const DESIGN_BYTES: usize = 300 * 12 * 8;
1518 let budget = LIMIT as usize / 4 * 3;
1519 // The incident reading had 53,248 bytes available in a 6 GiB job.
1520 // Also cover a fully charged job and an idle one: only availability
1521 // changes. These reserve ledger entries, never actual GiB allocations.
1522 for charged in [0, LIMIT - 53_248, LIMIT] {
1523 let availability = crate::test_support::simulated_cgroup_memory_environment(
1524 HOST_AVAILABLE_BYTES,
1525 HOST_TOTAL_BYTES,
1526 LIMIT,
1527 charged,
1528 );
1529 assert_eq!(availability.available_bytes(), LIMIT - charged);
1530 assert_eq!(availability.capacity_bytes(), LIMIT);
1531 let governor = MemoryGovernor::with_detected_availability(availability);
1532 assert_eq!(governor.remaining_bytes(), budget);
1533 assert_eq!(governor.single_materialization_cap_bytes(), budget);
1534 assert!(governor.single_materialization_cap_bytes() > DESIGN_BYTES);
1535 assert!(matches!(
1536 governor.try_reserve(8 << 30, "larger than the job"),
1537 Err(MemoryReservationError::BudgetExceeded { .. })
1538 ));
1539 let chunk = governor
1540 .try_reserve_dense_f64_copies(64, 1, 2, "coefficient-SE solve chunk")
1541 .expect("the incident's 1,024-byte chunk fits at every ambient load");
1542 assert_eq!(chunk.bytes(), 1_024);
1543 assert_eq!(governor.remaining_bytes(), budget - 1_024);
1544 assert_eq!(governor.single_materialization_cap_bytes(), budget);
1545 drop(chunk);
1546 assert_eq!(governor.remaining_bytes(), budget);
1547 }
1548 // A governor that always grants requests must fail this control.
1549 let tiny = crate::test_support::simulated_cgroup_memory_environment(
1550 HOST_AVAILABLE_BYTES,
1551 HOST_TOTAL_BYTES,
1552 1_024,
1553 0,
1554 );
1555 let governor = MemoryGovernor::with_detected_availability(tiny);
1556 assert_eq!(governor.single_materialization_cap_bytes(), 768);
1557 assert!(matches!(
1558 governor.try_reserve(DESIGN_BYTES, "300 by 12 design"),
1559 Err(MemoryReservationError::BudgetExceeded { .. })
1560 ));
1561 }
1562
1563 #[test]
1564 fn a_request_larger_than_the_job_is_still_refused_at_every_load() {
1565 // The ceiling must keep saying no, or the test above is satisfied by a
1566 // governor that admits everything.
1567 for charged in [0, JOB_LIMIT_BYTES / 2, JOB_LIMIT_BYTES - 4_096] {
1568 let governor = MemoryGovernor::with_detected_availability(one_job_cgroup_at_load(charged));
1569 let refusal = governor
1570 .try_reserve(16 * 1024 * 1024 * 1024, "twice the job's ceiling")
1571 .expect_err("16 GiB cannot be admitted in an 8 GiB job");
1572 match refusal {
1573 MemoryReservationError::BudgetExceeded { budget_bytes, .. } => {
1574 assert_eq!(budget_bytes, (JOB_LIMIT_BYTES as usize) / 4 * 3);
1575 }
1576 other => panic!("expected a budget refusal naming the ceiling, got {other:?}"),
1577 }
1578 }
1579 }
1580
1581 #[test]
1582 fn the_verdict_does_not_depend_on_what_this_process_did_earlier() {
1583 // The property the issue asks for, stated as history-independence: a
1584 // request's verdict must be the same whether or not the process has
1585 // already built and dropped something large. Reservations are the only
1586 // process state the ledger has, and a released one must leave no trace.
1587 let governor = MemoryGovernor::with_detected_availability(one_job_cgroup_at_load(0));
1588 let request = || {
1589 governor
1590 .try_reserve_dense_f64_copies(
1591 SE_TRANSFORMED_ROWS,
1592 1,
1593 2,
1594 "factorized coefficient-SE solve chunk",
1595 )
1596 .map(|reservation| reservation.bytes())
1597 };
1598
1599 let before = request().expect("admissible on a fresh ledger");
1600 {
1601 // Something big enough that a free-denominated ledger would have
1602 // been left visibly poorer by it: all but one SE chunk of the budget.
1603 let bulk = governor
1604 .try_reserve(
1605 governor.remaining_bytes() - SE_CHUNK_BYTES,
1606 "prior work in this process",
1607 )
1608 .expect("the bulk reservation is exactly the remaining budget");
1609 assert_eq!(governor.remaining_bytes(), SE_CHUNK_BYTES);
1610 // While it is live the ledger honestly reports the pressure: this is
1611 // the one thing that MAY change a verdict, and the next request still
1612 // fits because the bulk left exactly one chunk.
1613 assert!(request().is_ok());
1614 drop(bulk);
1615 }
1616 assert_eq!(governor.reserved_bytes(), 0);
1617 assert_eq!(request().expect("admissible again"), before);
1618 }
1619}