This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-resource / src / mem.rs
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}