This repository has no description
28 kB
827 lines
1//! # Memory sensing & sizing.
2//!
3//! Everything is derived from:
4//! - `MemoryBudget`: how much mem this process may use in total,
5//! which we detect once then cache.
6//! - `AvailableBytes`: how much is free right now,
7//! which we read afresh every call.
8//!
9//! Sizing decisions that have to stay fixed for the lifetime of
10//! a given process will refer to the mem budget,
11//! while decisions that react to load will take a live reading
12//! on the fly like a pulse.
13//!
14//! ```text
15//! MemoryBudget, which is detected then cached
16//!
17//! configured cgroup MemTotal the budget source
18//! ---------- ------ -------- ---------------- --------------------
19//! set any any configured Configured
20//! - reads reads the smaller whichever won
21//! - reads - the cgroup limit CgroupV2 or CgroupV1
22//! - - reads MemTotal ProcMeminfo
23//! - - - None Unconstrained
24//!
25//! AvailableBytes, read every call
26//!
27//! cgroup v2 max -> anon, else v1 limit -> usage, else MemAvailable, else None
28//!
29//! MemoryBudget ---> object_cache_bytes, pack_cache_bytes,
30//! \ ingest_base_budget
31//! +--------> target_decay
32//! /
33//! AvailableBytes -> ingest_thread_limit, ingest_admits,
34//! ingest_admits_churn, externalize_connectivity
35//! ```
36//!
37//! *Figure 1: where `MemoryBudget` & `AvailableBytes` originate, along with receivers.*
38//!
39//! In Figure 1,
40//! a `-` means that source is absent or unreadable,
41//! and `any` means we don't consult it at all.
42//! The `cgroup` column is cgroup v2 `memory.max`,
43//! or cgroup v1 `memory.limit_in_bytes` when the v2 file is
44//! missing or just reads `max`.
45//!
46//! Every cgroup v2 reading reads this cgroup
47//! up to root and takes the smallest `memory.max` it finds up the chain.
48//! A `None` budget means unconstrained,
49//! so every clamp returns its caller's
50//! ceiling and `target_decay` reports a healthy interval.
51//!
52//! A `None` live-reading means the sensor is unavailable,
53//! so every check takes the most permissive variant:
54//! admission returns true, the thread limit stays at the CPU ceiling,
55//! and `externalize_connectivity` returns false such that the
56//! connectivity-map stays in RAM.
57//!
58//! Long story short, if the knot can't detect any limits it'll assume
59//! it's allowed everything it can handle.
60//!
61//! In contrast,
62//! `memory_high_target` uses neither of the above,
63//! since `try_set_memory_high` passes it the cgroup v2 max directly,
64//! which means a configured budget,
65//! a `MemTotal` budget, and a cgroup v1 limit all don't affect it.
66//!
67//! `clamp_to_budget` will choose its cache size from a percentage of the budget,
68//! between a floor and a ceiling.
69//! Each of the following steps wins in some situation,
70//! which Table 1 traces with the `object_cache_bytes` constants of
71//! ceiling 64M, percent 2, floor 8M:
72//!
73//! ```text
74//! ceiling.min(max(budget / 100 * percent, floor)).min(budget)
75//!
76//! budget budget*pct max(.,floor) min(ceiling,.) min(.,budget) winner
77//! ------ ---------- ------------ -------------- ------------- -------
78//! 4M 0.08M 8M 8M 4M budget
79//! 256M 5.1M 8M 8M 8M floor
80//! 1G 20.5M 20.5M 20.5M 20.5M percent
81//! 8G 163.8M 163.8M 64M 64M ceiling
82//! ```
83//!
84//! *Table 1: a budget per row, and which step decided it.*
85//!
86//! The 4M row is the only one where trailing `min` actually does anything,
87//! since it covers a host whose entire budget is below the floor.
88//!
89//! `decay_for_headroom` maps headroom, meaning available over-budget,
90//! onto the jemalloc dirty-page decay interval.
91//!
92//! // TODO: research if I can do this with mimalloc.
93//!
94//! When there's a lot of headroom, pages will stay cached for ten seconds,
95//! but while under pressure the decay drops to zero such that pages go
96//! back to the OS immediately.
97//! Between those thresholds it interpolates like:
98//!
99//! ```text
100//! decay ms
101//! 10000 | ------------------
102//! | ,-'
103//! | ,-'
104//! | ,-'
105//! | ,-'
106//! | ,-'
107//! 0 +===+-------------+-----------------+
108//! 0% 10% 50% 100%
109//! headroom = available / budget
110//! ```
111//!
112//! *Figure 2: headroom mapped onto dirty-page decay interval.*
113//!
114//! That `=` run below 10% in Figure 2 is the curve itself,
115//! I meant flat at zero, not the axis. :P
116//! Between the thresholds the ramp climbs 250ms per point of headroom.
117//!
118//! `decay_warrants_apply` judges writes against the above curve.
119//! Any move smaller than one second will be ignored,
120//! so the reading has to shift like 4 points of
121//! headroom before we rewrite the setting.
122//! A target of 0 is exempt and always applies,
123//! unless it happens to be the applied value already.
124
125use std::path::{Path, PathBuf};
126use std::sync::OnceLock;
127
128const CGROUP_V2_ROOT: &str = "/sys/fs/cgroup";
129const PROC_SELF_CGROUP: &str = "/proc/self/cgroup";
130const MEMORY_V1_LIMIT_PATH: &str = "/sys/fs/cgroup/memory/memory.limit_in_bytes";
131const MEMORY_V1_USAGE_PATH: &str = "/sys/fs/cgroup/memory/memory.usage_in_bytes";
132const MEMINFO_PATH: &str = "/proc/meminfo";
133
134const CGROUP_V1_UNLIMITED: u64 = 0x7FFF_FFFF_FFFF_F000;
135
136const HIGH_HEADROOM_PERCENT: u64 = 10;
137const HIGH_HEADROOM_LIMIT: u64 = 1024 * 1024 * 1024;
138
139const OBJECT_CACHE_CEILING: u64 = 64 * 1024 * 1024;
140const OBJECT_CACHE_PERCENT: Percent = Percent::new(2);
141const OBJECT_CACHE_FLOOR: u64 = 8 * 1024 * 1024;
142
143const PACK_CACHE_PERCENT: Percent = Percent::new(25);
144const PACK_CACHE_FLOOR: u64 = 32 * 1024 * 1024;
145
146const ADVERT_CACHE_CEILING: u64 = 128 * 1024 * 1024;
147const ADVERT_CACHE_PERCENT: Percent = Percent::new(5);
148const ADVERT_CACHE_FLOOR: u64 = 8 * 1024 * 1024;
149
150const CACHE_SHED_PERCENT: u64 = 10;
151
152const INGEST_BASE_PERCENT: u64 = 40;
153
154const DECAY_HEALTHY_MS: isize = 10_000;
155const DECAY_PRESSURE_MS: isize = 0;
156const DECAY_HYSTERESIS_MS: isize = 1_000;
157const HEADROOM_RELAXED_PERCENT: u64 = 50;
158const HEADROOM_TIGHT_PERCENT: u64 = 10;
159
160const CONNECTIVITY_BYTES_PER_OBJECT: u64 = 96;
161const INGEST_FIXED_BYTES: u64 = 16 * 1024 * 1024;
162const INGEST_THREAD_WORKING_BYTES: u64 = 12 * 1024 * 1024;
163const INGEST_CONCURRENCY_BYTES: u64 = 256 * 1024 * 1024;
164const INGEST_CHURN_MULTIPLE: u64 = 4;
165
166knot_types::scalar_newtype! {
167 pub struct MemoryBudget(u64);
168}
169
170#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
171pub struct Percent(u64);
172
173impl Percent {
174 pub const fn new(percent: u64) -> Self {
175 // Const so a `250` would fail build for example.
176 // `clamp_to_budget` would otherwise have major problems
177 // at runtime.
178 assert!(percent <= 100, "percent exceeds 100");
179 Self(percent)
180 }
181
182 pub const fn get(self) -> u64 {
183 self.0
184 }
185}
186
187#[derive(Clone, Copy, Debug, PartialEq, Eq)]
188pub enum BudgetSource {
189 Configured,
190 CgroupV2,
191 CgroupV1,
192 ProcMeminfo,
193 Unconstrained,
194}
195
196static BUDGET: OnceLock<(Option<MemoryBudget>, BudgetSource)> = OnceLock::new();
197
198pub(crate) fn install(configured: Option<MemoryBudget>) -> (Option<MemoryBudget>, BudgetSource) {
199 let computed = match configured {
200 Some(budget) => (Some(budget), BudgetSource::Configured),
201 None => detect_memory_budget(),
202 };
203 *BUDGET.get_or_init(|| computed)
204}
205
206fn resolved() -> Option<MemoryBudget> {
207 BUDGET.get_or_init(detect_memory_budget).0
208}
209
210fn detect_cgroup_limit() -> Option<(u64, BudgetSource)> {
211 read_cgroup_max()
212 .map(|limit| (limit, BudgetSource::CgroupV2))
213 .or_else(|| read_cgroup_v1_max().map(|limit| (limit, BudgetSource::CgroupV1)))
214}
215
216fn detect_memory_budget() -> (Option<MemoryBudget>, BudgetSource) {
217 match (detect_cgroup_limit(), read_meminfo_total()) {
218 (Some((cgroup, source)), Some(total)) => {
219 if cgroup <= total {
220 (Some(MemoryBudget::new(cgroup)), source)
221 } else {
222 (Some(MemoryBudget::new(total)), BudgetSource::ProcMeminfo)
223 }
224 }
225 (Some((cgroup, source)), None) => (Some(MemoryBudget::new(cgroup)), source),
226 (None, Some(total)) => (Some(MemoryBudget::new(total)), BudgetSource::ProcMeminfo),
227 (None, None) => (None, BudgetSource::Unconstrained),
228 }
229}
230
231fn cgroup_v2_dir() -> Option<PathBuf> {
232 let content = std::fs::read_to_string(PROC_SELF_CGROUP).ok()?;
233 let relative = content
234 .lines()
235 .find_map(|line| line.strip_prefix("0::"))?
236 .trim();
237 Some(Path::new(CGROUP_V2_ROOT).join(relative.trim_start_matches('/')))
238}
239
240fn read_cgroup_max() -> Option<u64> {
241 let root = Path::new(CGROUP_V2_ROOT);
242 let mut dir = cgroup_v2_dir()?;
243 let mut effective: Option<u64> = None;
244 loop {
245 if let Some(limit) = std::fs::read_to_string(dir.join("memory.max"))
246 .ok()
247 .and_then(|raw| parse_cgroup_max(&raw))
248 {
249 effective = Some(effective.map_or(limit, |current| current.min(limit)));
250 }
251 if dir == root {
252 break;
253 }
254 match dir.parent() {
255 Some(parent) if parent.starts_with(root) => dir = parent.to_path_buf(),
256 _ => break,
257 }
258 }
259 effective
260}
261
262fn parse_cgroup_max(raw: &str) -> Option<u64> {
263 match raw.trim() {
264 "max" => None,
265 bytes => bytes.parse::<u64>().ok(),
266 }
267}
268
269fn read_meminfo_total() -> Option<u64> {
270 parse_meminfo_field(&std::fs::read_to_string(MEMINFO_PATH).ok()?, "MemTotal:")
271}
272
273fn parse_meminfo_field(raw: &str, key: &str) -> Option<u64> {
274 raw.lines()
275 .find_map(|line| line.strip_prefix(key))
276 .and_then(|rest| rest.trim().strip_suffix("kB"))
277 .and_then(|kb| kb.trim().parse::<u64>().ok())
278 .map(|kb| kb.saturating_mul(1024))
279}
280
281fn read_u64_file(path: &str) -> Option<u64> {
282 std::fs::read_to_string(path)
283 .ok()?
284 .trim()
285 .parse::<u64>()
286 .ok()
287}
288
289fn read_memory_stat_field(stat: &str, key: &str) -> Option<u64> {
290 stat.lines().find_map(|line| {
291 let mut parts = line.split_whitespace();
292 match (parts.next(), parts.next()) {
293 (Some(name), Some(value)) if name == key => value.parse::<u64>().ok(),
294 _ => None,
295 }
296 })
297}
298
299fn cgroup_v2_available() -> Option<u64> {
300 let dir = cgroup_v2_dir()?;
301 let stat = std::fs::read_to_string(dir.join("memory.stat")).ok()?;
302 let anon = read_memory_stat_field(&stat, "anon")?;
303 Some(read_cgroup_max()?.saturating_sub(anon))
304}
305
306fn read_cgroup_v1_max() -> Option<u64> {
307 read_u64_file(MEMORY_V1_LIMIT_PATH).filter(|&limit| limit < CGROUP_V1_UNLIMITED)
308}
309
310fn cgroup_v1_available() -> Option<u64> {
311 let limit = read_cgroup_v1_max()?;
312 Some(limit.saturating_sub(read_u64_file(MEMORY_V1_USAGE_PATH)?))
313}
314
315fn meminfo_available() -> Option<u64> {
316 parse_meminfo_field(
317 &std::fs::read_to_string(MEMINFO_PATH).ok()?,
318 "MemAvailable:",
319 )
320}
321
322pub fn available_bytes() -> Option<AvailableBytes> {
323 cgroup_v2_available()
324 .or_else(cgroup_v1_available)
325 .or_else(meminfo_available)
326 .map(AvailableBytes::new)
327}
328
329fn clamp_to_budget(
330 budget: Option<MemoryBudget>,
331 ceiling: u64,
332 percent: Percent,
333 floor: u64,
334) -> u64 {
335 match budget {
336 None => ceiling,
337 Some(budget) => ceiling
338 .min((budget.get() / 100 * percent.get()).max(floor))
339 .min(budget.get()),
340 }
341}
342
343pub fn object_cache_bytes() -> usize {
344 let sized = clamp_to_budget(
345 resolved(),
346 OBJECT_CACHE_CEILING,
347 OBJECT_CACHE_PERCENT,
348 OBJECT_CACHE_FLOOR,
349 );
350 usize::try_from(sized).unwrap_or(usize::MAX)
351}
352
353pub fn pack_cache_bytes(configured: u64) -> u64 {
354 clamp_to_budget(resolved(), configured, PACK_CACHE_PERCENT, PACK_CACHE_FLOOR)
355}
356
357pub fn advert_cache_bytes() -> u64 {
358 clamp_to_budget(
359 resolved(),
360 ADVERT_CACHE_CEILING,
361 ADVERT_CACHE_PERCENT,
362 ADVERT_CACHE_FLOOR,
363 )
364}
365
366pub fn cache_shed_warranted() -> bool {
367 shed_warranted_at(available_bytes(), resolved())
368}
369
370fn shed_warranted_at(available: Option<AvailableBytes>, budget: Option<MemoryBudget>) -> bool {
371 match (available, budget) {
372 (Some(available), Some(budget)) if budget.get() > 0 => {
373 available.get().saturating_mul(100) / budget.get() < CACHE_SHED_PERCENT
374 }
375 _ => false,
376 }
377}
378
379fn ingest_base_budget_for(budget: MemoryBudget) -> usize {
380 let sized = budget.get() / 100 * INGEST_BASE_PERCENT;
381 usize::try_from(sized).unwrap_or(usize::MAX)
382}
383
384pub fn ingest_base_budget() -> Option<usize> {
385 resolved().map(ingest_base_budget_for)
386}
387
388#[derive(Clone, Copy, Debug, PartialEq, Eq)]
389pub struct DecayMs(isize);
390
391impl DecayMs {
392 pub const fn ms(self) -> isize {
393 self.0
394 }
395}
396
397fn decay_for_headroom(available: Option<AvailableBytes>, budget: Option<MemoryBudget>) -> DecayMs {
398 let (available, budget) = match (available, budget) {
399 (Some(available), Some(budget)) if budget.get() > 0 => (available.get(), budget.get()),
400 _ => return DecayMs(DECAY_HEALTHY_MS),
401 };
402 let headroom_percent = available.saturating_mul(100) / budget;
403 let decay = if headroom_percent >= HEADROOM_RELAXED_PERCENT {
404 DECAY_HEALTHY_MS
405 } else if headroom_percent <= HEADROOM_TIGHT_PERCENT {
406 DECAY_PRESSURE_MS
407 } else {
408 let span = (HEADROOM_RELAXED_PERCENT - HEADROOM_TIGHT_PERCENT) as isize;
409 let above = (headroom_percent - HEADROOM_TIGHT_PERCENT) as isize;
410 DECAY_HEALTHY_MS * above / span
411 };
412 DecayMs(decay)
413}
414
415pub fn target_decay() -> DecayMs {
416 decay_for_headroom(available_bytes(), resolved())
417}
418
419pub fn decay_warrants_apply(applied: DecayMs, target: DecayMs) -> bool {
420 if applied == target {
421 false
422 } else if target.ms() == DECAY_PRESSURE_MS {
423 true
424 } else {
425 (target.ms() - applied.ms()).abs() >= DECAY_HYSTERESIS_MS
426 }
427}
428
429fn connectivity_fits(count: ConnectivityObjects, available: Option<AvailableBytes>) -> bool {
430 match available {
431 Some(available) => {
432 count.get().saturating_mul(CONNECTIVITY_BYTES_PER_OBJECT) <= available.get() / 2
433 }
434 None => true,
435 }
436}
437
438pub fn externalize_connectivity(count: ConnectivityObjects) -> bool {
439 !connectivity_fits(count, available_bytes())
440}
441
442fn ingest_threads_for(ceiling: usize, available: Option<AvailableBytes>) -> usize {
443 match available {
444 Some(available) => {
445 let funded =
446 available.get().saturating_sub(INGEST_FIXED_BYTES) / INGEST_CONCURRENCY_BYTES;
447 ceiling
448 .min(usize::try_from(funded).unwrap_or(ceiling))
449 .max(1)
450 }
451 None => ceiling,
452 }
453}
454
455fn ingest_floor_for(ceiling: usize, available: AvailableBytes) -> u64 {
456 INGEST_FIXED_BYTES
457 + ingest_threads_for(ceiling, Some(available)) as u64 * INGEST_THREAD_WORKING_BYTES
458}
459
460fn ingest_admits_for(
461 ceiling: usize,
462 available: Option<AvailableBytes>,
463 payload_bytes: PayloadBytes,
464) -> bool {
465 match available {
466 Some(available) => {
467 ingest_floor_for(ceiling, available).saturating_add(payload_bytes.get())
468 <= available.get()
469 }
470 None => true,
471 }
472}
473
474pub fn ingest_thread_limit() -> usize {
475 ingest_threads_for(crate::cpu::ceiling(), available_bytes())
476}
477
478pub fn ingest_admits(payload: PayloadBytes) -> bool {
479 ingest_admits_for(crate::cpu::ceiling(), available_bytes(), payload)
480}
481
482knot_types::scalar_newtype! {
483 pub struct WorkingSetBytes(u64);
484 pub struct ChurnBytes(u64);
485 pub struct AvailableBytes(u64);
486 pub struct PayloadBytes(u64);
487 pub struct ConnectivityObjects(u64);
488 pub struct MemoryHighBytes(u64);
489}
490
491fn ingest_admits_churn_for(
492 ceiling: usize,
493 available: Option<AvailableBytes>,
494 working_set: WorkingSetBytes,
495 churn: ChurnBytes,
496) -> bool {
497 match available {
498 Some(available) => {
499 ingest_admits_for(ceiling, Some(available), PayloadBytes::new(working_set.0))
500 && churn.0 <= available.get().saturating_mul(INGEST_CHURN_MULTIPLE)
501 }
502 None => true,
503 }
504}
505
506pub fn ingest_admits_churn(working_set: WorkingSetBytes, churn: ChurnBytes) -> bool {
507 ingest_admits_churn_for(crate::cpu::ceiling(), available_bytes(), working_set, churn)
508}
509
510fn memory_high_target(max: u64) -> u64 {
511 let headroom = (max / 100 * HIGH_HEADROOM_PERCENT).min(HIGH_HEADROOM_LIMIT);
512 max.saturating_sub(headroom)
513}
514
515pub(crate) fn try_set_memory_high() -> Option<MemoryHighBytes> {
516 let dir = cgroup_v2_dir()?;
517 let high = memory_high_target(read_cgroup_max()?);
518 std::fs::write(dir.join("memory.high"), high.to_string())
519 .ok()
520 .map(|()| MemoryHighBytes::new(high))
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 const GIB: u64 = 1024 * 1024 * 1024;
528 const MIB: u64 = 1024 * 1024;
529
530 #[test]
531 fn an_unlimited_cgroup_reads_as_no_budget() {
532 assert_eq!(parse_cgroup_max("max\n"), None);
533 assert_eq!(parse_cgroup_max("104857600\n"), Some(104_857_600));
534 assert_eq!(parse_cgroup_max("garbage"), None);
535 }
536
537 #[test]
538 fn meminfo_fields_parse_kilobytes_into_bytes() {
539 let sample = "MemTotal: 16384 kB\nMemFree: 100 kB\nMemAvailable: 8192 kB\n";
540 assert_eq!(parse_meminfo_field(sample, "MemTotal:"), Some(16384 * 1024));
541 assert_eq!(
542 parse_meminfo_field(sample, "MemAvailable:"),
543 Some(8192 * 1024)
544 );
545 assert_eq!(parse_meminfo_field(sample, "Nothing:"), None);
546 }
547
548 #[test]
549 fn memory_stat_matches_the_whole_key_not_a_prefix() {
550 let sample = "anon 2097152\nfile 8388608\nanon_thp 0\nkernel 65536\n";
551 assert_eq!(read_memory_stat_field(sample, "anon"), Some(2_097_152));
552 assert_eq!(read_memory_stat_field(sample, "file"), Some(8_388_608));
553 assert_eq!(read_memory_stat_field(sample, "anon_thp"), Some(0));
554 assert_eq!(read_memory_stat_field(sample, "missing"), None);
555 }
556
557 #[test]
558 fn the_sensor_reads_live_memory_on_this_host() {
559 let available = available_bytes()
560 .expect("a Linux host must expose live memory availability")
561 .get();
562 assert!(
563 available > 0,
564 "available memory must be positive, got {available}"
565 );
566 }
567
568 #[test]
569 fn an_unconstrained_host_keeps_the_ceiling() {
570 assert_eq!(
571 clamp_to_budget(None, 500_000_000, Percent::new(25), 32),
572 500_000_000
573 );
574 }
575
576 #[test]
577 fn a_constrained_host_clamps_to_the_fraction() {
578 let budget = Some(MemoryBudget::new(400 * MIB));
579 assert_eq!(
580 clamp_to_budget(budget, 4 * GIB, PACK_CACHE_PERCENT, PACK_CACHE_FLOOR),
581 100 * MIB
582 );
583 }
584
585 #[test]
586 fn a_tiny_host_holds_the_floor_but_never_exceeds_the_budget() {
587 let budget = Some(MemoryBudget::new(16 * MIB));
588 assert_eq!(
589 clamp_to_budget(budget, 4 * GIB, PACK_CACHE_PERCENT, PACK_CACHE_FLOOR),
590 16 * MIB
591 );
592 }
593
594 #[test]
595 fn a_small_host_reserves_the_headroom_percent() {
596 let max = 4 * GIB;
597 let headroom = max / 100 * HIGH_HEADROOM_PERCENT;
598 assert!(headroom < HIGH_HEADROOM_LIMIT);
599 assert_eq!(memory_high_target(max), max - headroom);
600 }
601
602 #[test]
603 fn a_large_host_bounds_the_reclaim_headroom() {
604 let max = 128 * GIB;
605 assert!(max / 100 * HIGH_HEADROOM_PERCENT > HIGH_HEADROOM_LIMIT);
606 assert_eq!(memory_high_target(max), max - HIGH_HEADROOM_LIMIT);
607 }
608
609 #[test]
610 fn a_healthy_host_keeps_the_allocator_lazy_and_a_squeezed_one_reclaims() {
611 let budget = Some(MemoryBudget::new(4 * GIB));
612 assert_eq!(
613 decay_for_headroom(Some(AvailableBytes::new(3 * GIB)), budget).ms(),
614 DECAY_HEALTHY_MS,
615 "ample headroom stays fast"
616 );
617 assert_eq!(
618 decay_for_headroom(Some(AvailableBytes::new(GIB / 4)), budget).ms(),
619 DECAY_PRESSURE_MS,
620 "near-exhaustion reclaims at once"
621 );
622 assert_eq!(
623 decay_for_headroom(Some(AvailableBytes::new(2 * GIB)), budget).ms(),
624 DECAY_HEALTHY_MS,
625 "half-free sits at the relaxed threshold"
626 );
627 }
628
629 #[test]
630 fn cache_shedding_triggers_only_under_tight_headroom() {
631 let budget = Some(MemoryBudget::new(4 * GIB));
632 assert!(
633 !shed_warranted_at(Some(AvailableBytes::new(2 * GIB)), budget),
634 "ample headroom keeps caches"
635 );
636 assert!(
637 shed_warranted_at(Some(AvailableBytes::new(GIB / 4)), budget),
638 "tight headroom sheds caches"
639 );
640 assert!(
641 !shed_warranted_at(Some(AvailableBytes::new(GIB)), None),
642 "an unmeasured budget never sheds"
643 );
644 }
645
646 #[test]
647 fn the_decay_interpolates_across_the_pressure_band() {
648 let budget = Some(MemoryBudget::new(100 * MIB));
649 assert_eq!(
650 decay_for_headroom(Some(AvailableBytes::new(30 * MIB)), budget).ms(),
651 DECAY_HEALTHY_MS * 20 / 40,
652 "30% headroom is halfway through the 10..50 band"
653 );
654 }
655
656 #[test]
657 fn ingest_parallelism_backs_off_as_memory_tightens() {
658 assert_eq!(
659 ingest_threads_for(8, Some(AvailableBytes::new(4 * GIB))),
660 8,
661 "a roomy host keeps the full cpu ceiling"
662 );
663 assert_eq!(
664 ingest_threads_for(8, Some(AvailableBytes::new(GIB))),
665 3,
666 "a 1GB limit funds only ~3 ingest threads, far below a many-core ceiling, so \
667 decompression churn cannot outrun the munmap-on-free page return and grow unbounded"
668 );
669 assert_eq!(
670 ingest_threads_for(8, Some(AvailableBytes::new(176 * MIB))),
671 1,
672 "a squeezed host drops to a single ingest thread, shrinking the working set"
673 );
674 assert_eq!(
675 ingest_threads_for(8, None),
676 8,
677 "an unmeasurable host keeps the ceiling"
678 );
679 }
680
681 #[test]
682 fn the_base_spill_budget_stays_a_fraction_so_it_can_bound_a_small_host() {
683 assert_eq!(
684 ingest_base_budget_for(MemoryBudget::new(64 * GIB)),
685 (64 * GIB / 100 * INGEST_BASE_PERCENT) as usize,
686 "a roomy host spills only after the working set passes 40% of its RAM"
687 );
688 assert!(
689 (ingest_base_budget_for(MemoryBudget::new(300 * MIB)) as u64) < 300 * MIB,
690 "a squeezed host keeps the spill threshold under its total, or it OOMs before paging"
691 );
692 }
693
694 #[test]
695 fn ingest_admission_scales_its_floor_with_the_threads_it_will_actually_use() {
696 assert!(
697 ingest_admits_for(
698 8,
699 Some(AvailableBytes::new(32 * MIB)),
700 PayloadBytes::new(MIB)
701 ),
702 "a small push fits a 32MB host by running a single ~28MB-floor ingest thread"
703 );
704 assert!(
705 !ingest_admits_for(
706 8,
707 Some(AvailableBytes::new(20 * MIB)),
708 PayloadBytes::new(MIB)
709 ),
710 "below the one-thread floor the push is shed, never OOM-ed part way through"
711 );
712 assert!(
713 !ingest_admits_for(
714 8,
715 Some(AvailableBytes::new(64 * MIB)),
716 PayloadBytes::new(200 * MIB)
717 ),
718 "a payload that dwarfs free memory is declined"
719 );
720 assert!(
721 ingest_admits_for(8, None, PayloadBytes::new(u64::MAX)),
722 "an unmeasurable host proceeds optimistically"
723 );
724 }
725
726 #[test]
727 fn ingest_churn_sheds_a_pack_whose_decompressed_volume_dwarfs_free_memory() {
728 assert!(
729 ingest_admits_churn_for(
730 8,
731 Some(AvailableBytes::new(GIB)),
732 WorkingSetBytes(MIB),
733 ChurnBytes(3 * GIB)
734 ),
735 "churn within a few multiples of free memory rides on the working-set floor"
736 );
737 assert!(
738 !ingest_admits_churn_for(
739 8,
740 Some(AvailableBytes::new(GIB)),
741 WorkingSetBytes(MIB),
742 ChurnBytes(5 * GIB)
743 ),
744 "decompression volume past the multiple of free memory is shed, never OOM-ed"
745 );
746 assert!(
747 !ingest_admits_churn_for(
748 8,
749 Some(AvailableBytes::new(20 * MIB)),
750 WorkingSetBytes(MIB),
751 ChurnBytes(MIB)
752 ),
753 "below the one-thread working floor the pack is shed even with trivial churn"
754 );
755 assert!(
756 ingest_admits_churn_for(8, None, WorkingSetBytes(u64::MAX), ChurnBytes(u64::MAX)),
757 "an unmeasurable host proceeds optimistically on both gates"
758 );
759 }
760
761 #[test]
762 fn connectivity_externalizes_only_when_the_in_ram_map_would_crowd_the_host() {
763 assert!(
764 connectivity_fits(
765 ConnectivityObjects::new(1_000_000),
766 Some(AvailableBytes::new(4 * GIB))
767 ),
768 "a small closure fits with headroom to spare"
769 );
770 assert!(
771 !connectivity_fits(
772 ConnectivityObjects::new(7_700_000),
773 Some(AvailableBytes::new(512 * MIB))
774 ),
775 "nixpkgs cannot hold its connectivity map on a 512MB box"
776 );
777 assert!(
778 connectivity_fits(ConnectivityObjects::new(u64::MAX), None),
779 "an unmeasurable host stays on the fast in-ram path"
780 );
781 }
782
783 #[test]
784 fn an_unmeasurable_host_stays_on_the_fast_default() {
785 assert_eq!(decay_for_headroom(None, None).ms(), DECAY_HEALTHY_MS);
786 assert_eq!(
787 decay_for_headroom(Some(AvailableBytes::new(GIB)), None).ms(),
788 DECAY_HEALTHY_MS,
789 "no budget means no pressure signal, so don't throttle"
790 );
791 }
792
793 #[test]
794 fn a_big_host_scales_up_to_the_ceiling_only() {
795 let budget = Some(MemoryBudget::new(256 * GIB));
796 assert_eq!(
797 clamp_to_budget(
798 budget,
799 OBJECT_CACHE_CEILING,
800 OBJECT_CACHE_PERCENT,
801 OBJECT_CACHE_FLOOR
802 ),
803 OBJECT_CACHE_CEILING
804 );
805 }
806
807 #[test]
808 fn decay_hysteresis_absorbs_small_wobble_but_honors_the_pressure_floor() {
809 let healthy = DecayMs(DECAY_HEALTHY_MS);
810 assert!(
811 !decay_warrants_apply(healthy, healthy),
812 "an unchanged target never rewrites the arenas"
813 );
814 assert!(
815 !decay_warrants_apply(DecayMs(5_000), DecayMs(5_200)),
816 "a sub-band change is ignored so a percent of headroom wobble doesn't churn"
817 );
818 assert!(
819 decay_warrants_apply(DecayMs(5_000), DecayMs(7_000)),
820 "a change past the hysteresis band is applied"
821 );
822 assert!(
823 decay_warrants_apply(DecayMs(200), DecayMs(DECAY_PRESSURE_MS)),
824 "a move to the pressure floor is always honored so RSS reclaim is never delayed"
825 );
826 }
827}