From 15b2222b4acd1e4f4bd22fab0064bc6c8470f80c Mon Sep 17 00:00:00 2001 From: Tobias Koppers Date: Fri, 17 Apr 2026 11:40:10 +0000 Subject: [PATCH 1/2] Report OS memory pressure in TurboMalloc and trace samples Adds `TurboMalloc::memory_pressure()` returning a normalized `Option` in `0..=100` backed by `/proc/pressure/memory` (Linux), the `kern.memorystatus_level` sysctl (macOS) and `GlobalMemoryStatusEx` (Windows). Unsupported targets return `None`. The value is attached to `TraceRow::MemorySample` and propagated through the trace-server store, so that span queries return a parallel `memory_pressure_samples` vector alongside `memory_samples`, enabling future task-eviction heuristics based on real OS pressure. Co-Authored-By: Claude --- Cargo.lock | 2 + .../crates/turbo-tasks-malloc/Cargo.toml | 6 + .../crates/turbo-tasks-malloc/src/lib.rs | 34 +++++ .../turbo-tasks-malloc/src/memory_pressure.rs | 139 ++++++++++++++++++ .../src/reader/turbopack.rs | 8 +- .../turbopack-trace-server/src/server.rs | 5 + .../turbopack-trace-server/src/store.rs | 56 +++++-- .../turbopack-trace-utils/src/raw_trace.rs | 7 +- .../turbopack-trace-utils/src/tracing.rs | 4 + 9 files changed, 245 insertions(+), 16 deletions(-) create mode 100644 turbopack/crates/turbo-tasks-malloc/src/memory_pressure.rs diff --git a/Cargo.lock b/Cargo.lock index fc3871017304..226d1ccc11e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10133,8 +10133,10 @@ dependencies = [ name = "turbo-tasks-malloc" version = "0.1.0" dependencies = [ + "libc", "libmimalloc-sys", "mimalloc", + "windows-sys 0.60.2", ] [[package]] diff --git a/turbopack/crates/turbo-tasks-malloc/Cargo.toml b/turbopack/crates/turbo-tasks-malloc/Cargo.toml index 4a5a971554d6..ab01665e0aaf 100644 --- a/turbopack/crates/turbo-tasks-malloc/Cargo.toml +++ b/turbopack/crates/turbo-tasks-malloc/Cargo.toml @@ -29,6 +29,12 @@ mimalloc = { version = "0.1.48", features = [ "local_dynamic_tls", ], optional = true } +[target.'cfg(target_os = "macos")'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.60", features = ["Win32_System_SystemInformation"] } + [features] custom_allocator = ["dep:mimalloc", "dep:libmimalloc-sys"] default = ["custom_allocator"] diff --git a/turbopack/crates/turbo-tasks-malloc/src/lib.rs b/turbopack/crates/turbo-tasks-malloc/src/lib.rs index edf251ab604d..71ae780d9c3c 100644 --- a/turbopack/crates/turbo-tasks-malloc/src/lib.rs +++ b/turbopack/crates/turbo-tasks-malloc/src/lib.rs @@ -1,4 +1,5 @@ mod counter; +mod memory_pressure; use std::{ alloc::{GlobalAlloc, Layout}, @@ -107,6 +108,22 @@ impl TurboMalloc { pub fn reset_allocation_counters(start: AllocationCounters) { self::counter::reset_allocation_counters(start); } + + /// Returns a memory pressure value in the range `0..=100`, or `None` when + /// the current platform does not expose a memory pressure signal or a + /// query for it failed. + /// + /// `0` means no memory pressure, `100` means maximum pressure. + /// + /// - On Linux this is derived from `/proc/pressure/memory` (the `some` `avg10` stall + /// percentage). + /// - On macOS this is derived from the `kern.memorystatus_level` sysctl (`100 - + /// free_memory_percentage`). + /// - On Windows this is `MEMORYSTATUSEX::dwMemoryLoad` (percentage of physical memory in use). + /// - On other platforms this returns `None`. + pub fn memory_pressure() -> Option { + memory_pressure::memory_pressure() + } } /// Get the allocator for this platform that we should wrap with TurboMalloc. @@ -164,3 +181,20 @@ unsafe impl GlobalAlloc for TurboMalloc { ret } } + +#[cfg(test)] +mod tests { + use super::TurboMalloc; + + #[test] + fn memory_pressure_is_in_range() { + // On supported platforms the value must be within 0..=100. On + // unsupported platforms we expect `None` and have nothing to assert. + if let Some(value) = TurboMalloc::memory_pressure() { + assert!( + value <= 100, + "memory_pressure() returned {value}, expected a value in 0..=100" + ); + } + } +} diff --git a/turbopack/crates/turbo-tasks-malloc/src/memory_pressure.rs b/turbopack/crates/turbo-tasks-malloc/src/memory_pressure.rs new file mode 100644 index 000000000000..ac9111cdfcaf --- /dev/null +++ b/turbopack/crates/turbo-tasks-malloc/src/memory_pressure.rs @@ -0,0 +1,139 @@ +//! Per-OS memory pressure detection. +//! +//! All implementations return a value in the range `0..=100`, where `0` means +//! no memory pressure and `100` means maximum memory pressure. Platforms that +//! do not expose a memory pressure signal (or for which the query fails) +//! return `None`. + +/// See [`super::TurboMalloc::memory_pressure`]. +pub fn memory_pressure() -> Option { + platform::memory_pressure() +} + +fn clamp_percent(value: f64) -> u8 { + if !value.is_finite() { + return 0; + } + value.round().clamp(0.0, 100.0) as u8 +} + +#[cfg(all(target_os = "linux", not(target_family = "wasm")))] +mod platform { + use super::clamp_percent; + + /// Reads the `some avg10=` field from `/proc/pressure/memory`. + /// Returns `None` if the file cannot be read or parsed (for example on + /// kernels older than 4.20 or in containers without access to PSI). + pub fn memory_pressure() -> Option { + let content = std::fs::read_to_string("/proc/pressure/memory").ok()?; + parse_psi(&content) + } + + fn parse_psi(content: &str) -> Option { + // Expected format: + // some avg10=0.00 avg60=0.00 avg300=0.00 total=... + // full avg10=0.00 avg60=0.00 avg300=0.00 total=... + for line in content.lines() { + let rest = line.strip_prefix("some ")?; + for field in rest.split_ascii_whitespace() { + if let Some(val) = field.strip_prefix("avg10=") { + let parsed: f64 = val.parse().ok()?; + return Some(clamp_percent(parsed)); + } + } + } + None + } + + #[cfg(test)] + mod tests { + use super::parse_psi; + + #[test] + fn parses_typical_psi_content() { + let content = "some avg10=12.34 avg60=5.67 avg300=1.00 total=123456\nfull avg10=0.00 \ + avg60=0.00 avg300=0.00 total=0\n"; + assert_eq!(parse_psi(content), Some(12)); + } + + #[test] + fn returns_none_on_malformed_content() { + assert_eq!(parse_psi(""), None); + assert_eq!(parse_psi("garbage"), None); + } + + #[test] + fn clamps_to_100() { + let content = "some avg10=150.00 avg60=0.00 avg300=0.00 total=0\n"; + assert_eq!(parse_psi(content), Some(100)); + } + } +} + +#[cfg(target_os = "macos")] +mod platform { + use std::{ffi::c_void, mem::size_of}; + + use super::clamp_percent; + + /// Reads the `kern.memorystatus_level` sysctl, which exposes the percentage + /// of free memory available (0..=100). The returned memory pressure is + /// `100 - free_percentage`. + pub fn memory_pressure() -> Option { + // `kern.memorystatus_level` returns an `int` (percentage of free + // memory, 0..=100). + let mut level: libc::c_int = 0; + let mut size: libc::size_t = size_of::() as libc::size_t; + let name = c"kern.memorystatus_level"; + + // Safety: `sysctlbyname` writes up to `size` bytes into `&mut level`; + // the buffer is large enough for a `c_int`. We pass a valid, + // NUL-terminated C string as the first argument. + let ret = unsafe { + libc::sysctlbyname( + name.as_ptr(), + &mut level as *mut libc::c_int as *mut c_void, + &mut size, + std::ptr::null_mut(), + 0, + ) + }; + + if ret != 0 || size != size_of::() as libc::size_t { + return None; + } + + let pressure = 100.0 - f64::from(level); + Some(clamp_percent(pressure)) + } +} + +#[cfg(windows)] +mod platform { + use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX}; + + /// Reads `MEMORYSTATUSEX::dwMemoryLoad`, which is the approximate + /// percentage of physical memory in use (0..=100). + pub fn memory_pressure() -> Option { + let mut status: MEMORYSTATUSEX = unsafe { std::mem::zeroed() }; + status.dwLength = std::mem::size_of::() as u32; + // Safety: `status` is a properly sized and initialized MEMORYSTATUSEX. + let ok = unsafe { GlobalMemoryStatusEx(&mut status) }; + if ok == 0 { + return None; + } + let load = status.dwMemoryLoad; + Some(load.min(100) as u8) + } +} + +#[cfg(not(any( + all(target_os = "linux", not(target_family = "wasm")), + target_os = "macos", + windows, +)))] +mod platform { + pub fn memory_pressure() -> Option { + None + } +} diff --git a/turbopack/crates/turbopack-trace-server/src/reader/turbopack.rs b/turbopack/crates/turbopack-trace-server/src/reader/turbopack.rs index 105671d7ec0d..8b8e0d7f4748 100644 --- a/turbopack/crates/turbopack-trace-server/src/reader/turbopack.rs +++ b/turbopack/crates/turbopack-trace-server/src/reader/turbopack.rs @@ -290,9 +290,13 @@ impl TurbopackFormat { } } } - TraceRow::MemorySample { ts, memory } => { + TraceRow::MemorySample { + ts, + memory, + memory_pressure, + } => { let ts = Timestamp::from_micros(ts); - store.add_memory_sample(ts, memory); + store.add_memory_sample(ts, memory, memory_pressure); } TraceRow::AllocationCounters { ts: _, diff --git a/turbopack/crates/turbopack-trace-server/src/server.rs b/turbopack/crates/turbopack-trace-server/src/server.rs index 0d2dfc34d1cf..2b11274beeae 100644 --- a/turbopack/crates/turbopack-trace-server/src/server.rs +++ b/turbopack/crates/turbopack-trace-server/src/server.rs @@ -44,6 +44,7 @@ pub enum ServerToClientMessage { args: Vec<(String, String)>, path: Vec, memory_samples: Vec, + memory_pressure_samples: Vec, }, } @@ -294,6 +295,8 @@ fn handle_connection( path.reverse(); let memory_samples = store.memory_samples_for_range(span.start(), span.end()); + let memory_pressure_samples = store + .memory_pressure_samples_for_range(span.start(), span.end()); ServerToClientMessage::QueryResult { id, is_graph, @@ -308,6 +311,7 @@ fn handle_connection( args, path, memory_samples, + memory_pressure_samples, } } else { ServerToClientMessage::QueryResult { @@ -324,6 +328,7 @@ fn handle_connection( args: Vec::new(), path: Vec::new(), memory_samples: Vec::new(), + memory_pressure_samples: Vec::new(), } } }; diff --git a/turbopack/crates/turbopack-trace-server/src/store.rs b/turbopack/crates/turbopack-trace-server/src/store.rs index 1f9465523ac2..3ae4b7a0f37c 100644 --- a/turbopack/crates/turbopack-trace-server/src/store.rs +++ b/turbopack/crates/turbopack-trace-server/src/store.rs @@ -23,9 +23,11 @@ pub type SpanId = NonZeroUsize; /// at the cut-off depth (Flattening). const CUT_OFF_DEPTH: u32 = 80; -/// A single memory usage sample: (timestamp, memory_bytes). -/// Sorted by timestamp. -type MemorySample = (Timestamp, u64); +/// A single memory usage sample: (timestamp, memory_bytes, memory_pressure). +/// Sorted by timestamp. `memory_pressure` is an OS-reported pressure value in +/// the range `0..=100`; `0` is used when the reporter platform did not expose +/// a pressure signal. +type MemorySample = (Timestamp, u64, u8); /// Maximum number of memory samples returned in a query result. const MAX_MEMORY_SAMPLES: usize = 200; @@ -345,11 +347,11 @@ impl Store { span.self_deallocation_count += count; } - pub fn add_memory_sample(&mut self, ts: Timestamp, memory: u64) { + pub fn add_memory_sample(&mut self, ts: Timestamp, memory: u64, memory_pressure: u8) { // Samples arrive nearly sorted (roughly chronological from the trace // writer), so an insertion-sort step is efficient: push to the end // then swap backward until the timestamp ordering is restored. - self.memory_samples.push((ts, memory)); + self.memory_samples.push((ts, memory, memory_pressure)); let mut i = self.memory_samples.len() - 1; while i > 0 && self.memory_samples[i - 1].0 > ts { self.memory_samples.swap(i, i - 1); @@ -361,29 +363,57 @@ impl Store { /// `[start, end]`. When more samples exist, groups of N consecutive /// samples are merged by taking the maximum memory value in each group. pub fn memory_samples_for_range(&self, start: Timestamp, end: Timestamp) -> Vec { - // Binary search for the first sample >= start - let lo = self.memory_samples.partition_point(|(ts, _)| *ts < start); - // Binary search for the first sample > end - let hi = self.memory_samples.partition_point(|(ts, _)| *ts <= end); - - let slice = &self.memory_samples[lo..hi]; + let slice = self.memory_samples_slice(start, end); let count = slice.len(); if count == 0 { return Vec::new(); } if count <= MAX_MEMORY_SAMPLES { - return slice.iter().map(|(_, mem)| *mem).collect(); + return slice.iter().map(|(_, mem, _)| *mem).collect(); } // Merge groups of N samples, taking the max memory in each group. let n = count.div_ceil(MAX_MEMORY_SAMPLES); slice .chunks(n) - .map(|chunk| chunk.iter().map(|(_, mem)| *mem).max().unwrap()) + .map(|chunk| chunk.iter().map(|(_, mem, _)| *mem).max().unwrap()) .collect() } + /// Returns up to `MAX_MEMORY_SAMPLES` memory pressure values in the range + /// `[start, end]`. The returned slice has the same length and group + /// boundaries as [`Self::memory_samples_for_range`] so that the two + /// results can be rendered in parallel. Each group is downsampled by + /// taking the maximum pressure value. + pub fn memory_pressure_samples_for_range(&self, start: Timestamp, end: Timestamp) -> Vec { + let slice = self.memory_samples_slice(start, end); + let count = slice.len(); + if count == 0 { + return Vec::new(); + } + + if count <= MAX_MEMORY_SAMPLES { + return slice.iter().map(|(_, _, p)| *p).collect(); + } + + let n = count.div_ceil(MAX_MEMORY_SAMPLES); + slice + .chunks(n) + .map(|chunk| chunk.iter().map(|(_, _, p)| *p).max().unwrap()) + .collect() + } + + fn memory_samples_slice(&self, start: Timestamp, end: Timestamp) -> &[MemorySample] { + // Binary search for the first sample >= start + let lo = self + .memory_samples + .partition_point(|(ts, _, _)| *ts < start); + // Binary search for the first sample > end + let hi = self.memory_samples.partition_point(|(ts, _, _)| *ts <= end); + &self.memory_samples[lo..hi] + } + pub fn complete_span(&mut self, span_index: SpanIndex) { let span = &mut self.spans[span_index.get()]; span.is_complete = true; diff --git a/turbopack/crates/turbopack-trace-utils/src/raw_trace.rs b/turbopack/crates/turbopack-trace-utils/src/raw_trace.rs index a0c724573c40..7b0f5aee4a1d 100644 --- a/turbopack/crates/turbopack-trace-utils/src/raw_trace.rs +++ b/turbopack/crates/turbopack-trace-utils/src/raw_trace.rs @@ -102,7 +102,12 @@ impl LookupSpan<'a>> RawTraceLayer { // We won the race — write the sample THREAD_LOCAL_LAST_MEMORY_SAMPLE.with(|tl| tl.set(ts)); let memory = TurboMalloc::memory_usage() as u64; - self.write(TraceRow::MemorySample { ts, memory }); + let memory_pressure = TurboMalloc::memory_pressure().unwrap_or(0); + self.write(TraceRow::MemorySample { + ts, + memory, + memory_pressure, + }); } Err(actual) => { // Lost the race; update thread-local with the winner's timestamp diff --git a/turbopack/crates/turbopack-trace-utils/src/tracing.rs b/turbopack/crates/turbopack-trace-utils/src/tracing.rs index 175b85db0a5d..f082d9ae41b1 100644 --- a/turbopack/crates/turbopack-trace-utils/src/tracing.rs +++ b/turbopack/crates/turbopack-trace-utils/src/tracing.rs @@ -106,6 +106,10 @@ pub enum TraceRow<'a> { ts: u64, /// Memory usage in bytes (from TurboMalloc::memory_usage()) memory: u64, + /// OS memory pressure in `0..=100` (from + /// `TurboMalloc::memory_pressure()`). `0` is used when the current + /// platform does not report a pressure value. + memory_pressure: u8, }, } From 24d0c6a8423ec1d247155d3ac20f64d77f95debe Mon Sep 17 00:00:00 2001 From: Tobias Koppers Date: Fri, 17 Apr 2026 11:45:55 +0000 Subject: [PATCH 2/2] TurboMalloc: fall back to /proc/meminfo when PSI is unavailable PSI (`/proc/pressure/memory`) is not available on all Linux kernels (older than 4.20, built without `CONFIG_PSI`, or in restricted containers). Falling back to `(MemTotal - MemAvailable) / MemTotal` from `/proc/meminfo` ensures `TurboMalloc::memory_pressure()` returns a sane value on any standard Linux system, matching the semantics of Windows' `dwMemoryLoad`. The unit test is tightened to assert `Some(_)` on every supported platform (Linux, macOS, Windows). Co-Authored-By: Claude --- .../crates/turbo-tasks-malloc/src/lib.rs | 35 ++++++--- .../turbo-tasks-malloc/src/memory_pressure.rs | 73 ++++++++++++++++--- 2 files changed, 90 insertions(+), 18 deletions(-) diff --git a/turbopack/crates/turbo-tasks-malloc/src/lib.rs b/turbopack/crates/turbo-tasks-malloc/src/lib.rs index 71ae780d9c3c..5e252abf74f0 100644 --- a/turbopack/crates/turbo-tasks-malloc/src/lib.rs +++ b/turbopack/crates/turbo-tasks-malloc/src/lib.rs @@ -116,7 +116,8 @@ impl TurboMalloc { /// `0` means no memory pressure, `100` means maximum pressure. /// /// - On Linux this is derived from `/proc/pressure/memory` (the `some` `avg10` stall - /// percentage). + /// percentage), falling back to `(MemTotal - MemAvailable) / MemTotal` from `/proc/meminfo` + /// when PSI is not available (older kernels, no `CONFIG_PSI`, or containers without access). /// - On macOS this is derived from the `kern.memorystatus_level` sysctl (`100 - /// free_memory_percentage`). /// - On Windows this is `MEMORYSTATUSEX::dwMemoryLoad` (percentage of physical memory in use). @@ -188,13 +189,29 @@ mod tests { #[test] fn memory_pressure_is_in_range() { - // On supported platforms the value must be within 0..=100. On - // unsupported platforms we expect `None` and have nothing to assert. - if let Some(value) = TurboMalloc::memory_pressure() { - assert!( - value <= 100, - "memory_pressure() returned {value}, expected a value in 0..=100" - ); - } + let value = TurboMalloc::memory_pressure(); + + // On all supported platforms the value must be reported. + #[cfg(any( + all(target_os = "linux", not(target_family = "wasm")), + target_os = "macos", + windows, + ))] + let value = value.expect("memory_pressure() should return Some on this platform"); + + // On unsupported platforms we expect None and have nothing further to assert. + #[cfg(not(any( + all(target_os = "linux", not(target_family = "wasm")), + target_os = "macos", + windows, + )))] + let Some(value) = value else { + return; + }; + + assert!( + value <= 100, + "memory_pressure() returned {value}, expected a value in 0..=100" + ); } } diff --git a/turbopack/crates/turbo-tasks-malloc/src/memory_pressure.rs b/turbopack/crates/turbo-tasks-malloc/src/memory_pressure.rs index ac9111cdfcaf..157e24575432 100644 --- a/turbopack/crates/turbo-tasks-malloc/src/memory_pressure.rs +++ b/turbopack/crates/turbo-tasks-malloc/src/memory_pressure.rs @@ -21,12 +21,19 @@ fn clamp_percent(value: f64) -> u8 { mod platform { use super::clamp_percent; - /// Reads the `some avg10=` field from `/proc/pressure/memory`. - /// Returns `None` if the file cannot be read or parsed (for example on - /// kernels older than 4.20 or in containers without access to PSI). + /// Reads memory pressure on Linux. Prefers `/proc/pressure/memory` (the + /// `some avg10` stall percentage) when the kernel exposes PSI, and falls + /// back to `MemAvailable`/`MemTotal` from `/proc/meminfo` when it does + /// not (older kernels, containers without PSI access, or kernels built + /// without `CONFIG_PSI`). pub fn memory_pressure() -> Option { - let content = std::fs::read_to_string("/proc/pressure/memory").ok()?; - parse_psi(&content) + if let Ok(content) = std::fs::read_to_string("/proc/pressure/memory") + && let Some(value) = parse_psi(&content) + { + return Some(value); + } + let content = std::fs::read_to_string("/proc/meminfo").ok()?; + parse_meminfo(&content) } fn parse_psi(content: &str) -> Option { @@ -34,7 +41,9 @@ mod platform { // some avg10=0.00 avg60=0.00 avg300=0.00 total=... // full avg10=0.00 avg60=0.00 avg300=0.00 total=... for line in content.lines() { - let rest = line.strip_prefix("some ")?; + let Some(rest) = line.strip_prefix("some ") else { + continue; + }; for field in rest.split_ascii_whitespace() { if let Some(val) = field.strip_prefix("avg10=") { let parsed: f64 = val.parse().ok()?; @@ -45,9 +54,41 @@ mod platform { None } + fn parse_meminfo(content: &str) -> Option { + // Returns `(MemTotal - MemAvailable) / MemTotal * 100`, i.e. the + // percentage of physical memory currently unavailable — analogous to + // Windows' `dwMemoryLoad`. + let mut total: Option = None; + let mut available: Option = None; + for line in content.lines() { + if let Some(rest) = line.strip_prefix("MemTotal:") { + total = parse_kb(rest); + } else if let Some(rest) = line.strip_prefix("MemAvailable:") { + available = parse_kb(rest); + } + if total.is_some() && available.is_some() { + break; + } + } + let total = total?; + let available = available?; + if total == 0 { + return None; + } + let used = total.saturating_sub(available); + let pct = (used as f64) * 100.0 / (total as f64); + Some(clamp_percent(pct)) + } + + fn parse_kb(rest: &str) -> Option { + // Expected format: " 12345 kB" + let mut iter = rest.split_ascii_whitespace(); + iter.next()?.parse().ok() + } + #[cfg(test)] mod tests { - use super::parse_psi; + use super::{parse_meminfo, parse_psi}; #[test] fn parses_typical_psi_content() { @@ -57,16 +98,30 @@ mod platform { } #[test] - fn returns_none_on_malformed_content() { + fn returns_none_on_malformed_psi() { assert_eq!(parse_psi(""), None); assert_eq!(parse_psi("garbage"), None); } #[test] - fn clamps_to_100() { + fn clamps_psi_to_100() { let content = "some avg10=150.00 avg60=0.00 avg300=0.00 total=0\n"; assert_eq!(parse_psi(content), Some(100)); } + + #[test] + fn parses_meminfo() { + let content = + "MemTotal: 1000 kB\nMemFree: 500 kB\nMemAvailable: 750 kB\n"; + // used = 250, 25% + assert_eq!(parse_meminfo(content), Some(25)); + } + + #[test] + fn returns_none_on_missing_meminfo_fields() { + assert_eq!(parse_meminfo("MemTotal: 1000 kB\n"), None); + assert_eq!(parse_meminfo(""), None); + } } }