vercel/next.js · #97761

turbo-tasks-malloc: report memory from mimalloc

lukesandberg · merged Sep 1, 20264 files · 313 + / 117
turbopack/crates/turbo-tasks-backend/src/backend/eviction.rs5 + / 2
@@ -129,8 +129,11 @@ impl EvictionControl {         evict     } -    /// Call after completing an eviction cycle. Seeds the memory floor with the-    /// post-eviction usage; later cycles lower it further as memory settles.+    /// Call after completing an eviction cycle and after mimalloc is cleaned up+    /// with [`TurboMalloc::collect`], since the freed memory is only reflected+    /// in [`TurboMalloc::memory_usage`] once it has been. Seeds the memory floor+    /// with the post-eviction usage; later cycles lower it further as memory+    /// settles.     pub(crate) fn record_eviction(&mut self) {         self.memory_floor = Some(TurboMalloc::memory_usage());     }
turbopack/crates/turbo-tasks-backend/src/backend/mod.rs6 + / 2
@@ -3028,8 +3028,6 @@ impl TurboTasksBackend {                                     // memory so racing with execution is as likely to save time as                                     // cost it.                                     self.storage.evict_after_snapshot(background_span.id());-                                    // Sample the post-eviction floor as the new baseline.-                                    eviction_control.record_eviction();                                     true                                 } else {                                     false@@ -3082,6 +3080,12 @@ impl TurboTasksBackend {                                 {                                     TurboMalloc::collect(true);                                 }++                                // Sample the new baseline after the collect above, which is what+                                // makes the evicted memory show up in `memory_usage`.+                                if ran_eviction {+                                    eviction_control.record_eviction();+                                }                             }                         }                     }
turbopack/crates/turbo-tasks-malloc/src/counter.rs225 + / 106
@@ -1,60 +1,140 @@-use std::{-    cell::UnsafeCell,-    ptr::NonNull,-    sync::atomic::{AtomicUsize, Ordering},-};+//! Allocation accounting.+//!+//! Every build tracks per-thread allocation totals, which the tracing layer reads through+//! [`allocation_counters`] to attribute allocations to spans.+//!+//! Builds without the `custom_allocator` feature additionally maintain a process-wide counter of+//! live bytes, which backs [`crate::TurboMalloc::memory_usage`]. With mimalloc that figure comes+//! from the allocator instead, so [`global`] is not compiled in: the atomic would otherwise be+//! contended by every thread on every allocation, and the thread-local buffering that makes it+//! affordable is inlined into every allocation site in the binary. +use std::{cell::UnsafeCell, ptr::NonNull};++#[cfg(not(all(feature = "custom_allocator", not(target_family = "wasm"))))]+pub use self::global::get; use crate::AllocationCounters; -/// Tracks the current total amount of memory allocated through all the [ThreadLocalCounter]-/// instances.  This is an overestimate as individual threads 'preallocate' a [TARGET_BUFFER] bytes-/// to reduce the number of global synchronizations.  This means at any given time this might-/// overcount by up to [MAX_BUFFER] bytes for each thread.-static ALLOCATED: AtomicUsize = AtomicUsize::new(0);-const KB: usize = 1024;-/// When global counter is updates we will keep a thread-local buffer of this-/// size.-const TARGET_BUFFER: usize = 100 * KB;-/// When the thread-local buffer would exceed this size, we will update the-/// global counter.-const MAX_BUFFER: usize = 200 * KB;+/// The process-wide live-bytes counter, and the buffering that keeps updating it affordable.+///+/// Only compiled without the `custom_allocator` feature; see the module docs. Each thread holds+/// its buffer in its own [`ThreadLocalCounter`] and passes it in, so the counter's state lives in+/// exactly one place.+#[cfg(not(all(feature = "custom_allocator", not(target_family = "wasm"))))]+mod global {+    use std::sync::atomic::{AtomicUsize, Ordering};++    /// Tracks the current total amount of memory allocated through all the+    /// [`super::ThreadLocalCounter`] instances.  This is an overestimate as individual threads+    /// 'preallocate' a [TARGET_BUFFER] bytes to reduce the number of global synchronizations.+    /// This means at any given time this might overcount by up to [MAX_BUFFER] bytes for each+    /// thread.+    static ALLOCATED: AtomicUsize = AtomicUsize::new(0);+    const KB: usize = 1024;+    /// When global counter is updates we will keep a thread-local buffer of this+    /// size.+    pub const TARGET_BUFFER: usize = 100 * KB;+    /// When the thread-local buffer would exceed this size, we will update the+    /// global counter.+    pub const MAX_BUFFER: usize = 200 * KB;++    /// Live bytes (allocations minus deallocations) across all threads.+    pub fn get() -> usize {+        ALLOCATED.load(Ordering::Relaxed)+    }++    /// Takes `size` from the global counter, refilling `buffer` while it is there.+    ///+    /// Kept out of the allocator's inlined hot path: the buffer means this runs about once per+    /// [`TARGET_BUFFER`] bytes rather than once per allocation.+    #[inline(never)]+    pub fn refill(buffer: &mut usize, size: usize) {+        debug_assert!(*buffer < size);+        let offset = size - *buffer + TARGET_BUFFER;+        *buffer = TARGET_BUFFER;+        ALLOCATED.fetch_add(offset, Ordering::Relaxed);+    }++    /// Returns everything buffered above [`TARGET_BUFFER`] to the global counter.+    #[inline(never)]+    pub fn flush_excess(buffer: &mut usize) {+        debug_assert!(*buffer > MAX_BUFFER);+        let offset = *buffer - TARGET_BUFFER;+        *buffer = TARGET_BUFFER;+        ALLOCATED.fetch_sub(offset, Ordering::Relaxed);+    }++    /// Returns everything buffered, for a thread that is going away.+    pub fn flush_all(buffer: &mut usize) {+        if *buffer > 0 {+            ALLOCATED.fetch_sub(*buffer, Ordering::Relaxed);+            *buffer = 0;+        }+    }++    impl super::ThreadLocalCounter {+        /// Charges `size` against this thread's buffer, refilling it from the global counter when+        /// it runs dry. Does nothing with `custom_allocator`, where there is no global+        /// counter.+        #[inline(always)]+        pub(super) fn buffered_add(&mut self, size: usize) {+            if self.buffer >= size {+                self.buffer -= size;+            } else {+                refill(&mut self.buffer, size);+            }+        }++        /// Returns `size` to this thread's buffer, flushing the excess to the global counter once+        /// the buffer grows past [`global::MAX_BUFFER`]. Does nothing with+        /// `custom_allocator`.+        #[inline(always)]+        pub(super) fn buffered_remove(&mut self, size: usize) {+            self.buffer += size;+            if self.buffer > MAX_BUFFER {+                flush_excess(&mut self.buffer);+            }+        }+    }+} +/// Per-thread allocation and deallocation totals. #[derive(Default)] struct ThreadLocalCounter {     /// Thread-local buffer of allocated bytes that have been added to the     /// global counter desprite not being allocated yet. It is unsigned so that     /// means the global counter is always equal or greater than the real     /// value.+    #[cfg(not(all(feature = "custom_allocator", not(target_family = "wasm"))))]     buffer: usize,     allocation_counters: AllocationCounters, }  impl ThreadLocalCounter {     const fn new() -> Self {         Self {+            #[cfg(not(all(feature = "custom_allocator", not(target_family = "wasm"))))]             buffer: 0,             allocation_counters: AllocationCounters::new(),         }     }+     #[inline(always)]     fn add(&mut self, size: usize) {         self.allocation_counters.allocations += size;         self.allocation_counters.allocation_count += 1;-        if self.buffer >= size {-            self.buffer -= size;-        } else {-            add_slow(self, size);-        }++        #[cfg(not(all(feature = "custom_allocator", not(target_family = "wasm"))))]+        self.buffered_add(size);     }      #[inline(always)]     fn remove(&mut self, size: usize) {         self.allocation_counters.deallocations += size;         self.allocation_counters.deallocation_count += 1;-        self.buffer += size;-        if self.buffer > MAX_BUFFER {-            remove_slow(self);-        }++        #[cfg(not(all(feature = "custom_allocator", not(target_family = "wasm"))))]+        self.buffered_remove(size);     }      #[inline(always)]@@ -63,62 +143,28 @@ impl ThreadLocalCounter {         self.allocation_counters.deallocation_count += 1;         self.allocation_counters.allocations += new_size;         self.allocation_counters.allocation_count += 1;-        match old_size.cmp(&new_size) {-            std::cmp::Ordering::Equal => {}-            std::cmp::Ordering::Less => {-                let size = new_size - old_size;-                if self.buffer >= size {-                    self.buffer -= size;-                } else {-                    add_slow(self, size);-                }-            }-            std::cmp::Ordering::Greater => {-                let size = old_size - new_size;-                self.buffer += size;-                if self.buffer > MAX_BUFFER {-                    remove_slow(self);-                }++        #[cfg(not(all(feature = "custom_allocator", not(target_family = "wasm"))))]+        {+            match old_size.cmp(&new_size) {+                std::cmp::Ordering::Equal => {}+                std::cmp::Ordering::Less => self.buffered_add(new_size - old_size),+                std::cmp::Ordering::Greater => self.buffered_remove(old_size - new_size),             }         }     }      fn unload(&mut self) {-        if self.buffer > 0 {-            ALLOCATED.fetch_sub(self.buffer, Ordering::Relaxed);-            self.buffer = 0;-        }+        #[cfg(not(all(feature = "custom_allocator", not(target_family = "wasm"))))]+        global::flush_all(&mut self.buffer);         self.allocation_counters = AllocationCounters::default();     } } -// Keep the uncommon atomic updates out of the allocator's inlined hot path.-#[cold]-#[inline(never)]-fn add_slow(local: &mut ThreadLocalCounter, size: usize) {-    debug_assert!(local.buffer < size);-    let offset = size - local.buffer + TARGET_BUFFER;-    local.buffer = TARGET_BUFFER;-    ALLOCATED.fetch_add(offset, Ordering::Relaxed);-}--#[cold]-#[inline(never)]-fn remove_slow(local: &mut ThreadLocalCounter) {-    debug_assert!(local.buffer > MAX_BUFFER);-    let offset = local.buffer - TARGET_BUFFER;-    local.buffer = TARGET_BUFFER;-    ALLOCATED.fetch_sub(offset, Ordering::Relaxed);-}- thread_local! {   static LOCAL_COUNTER: UnsafeCell<ThreadLocalCounter> = const {UnsafeCell::new(ThreadLocalCounter::new())}; } -pub fn get() -> usize {-    ALLOCATED.load(Ordering::Relaxed)-}- pub fn allocation_counters() -> AllocationCounters {     with_local_counter(|local| local.allocation_counters.clone()) }@@ -155,8 +201,8 @@ pub fn update(old_size: usize, new_size: usize) {     with_local_counter(|local| local.update(old_size, new_size)); } -/// Flushes the thread-local buffer to the global counter. This should be called-/// e. g. when a thread is stopped or goes to sleep for a long time.+/// Clears this thread's counters. Called when a thread stops, so a recycled thread does not+/// inherit the previous occupant's totals. pub fn flush() {     with_local_counter(|local| local.unload()); }@@ -166,42 +212,115 @@ mod tests {     use super::*;      #[test]-    fn counting() {-        let mut expected = get();-        add(100);-        // Initial change should fill up the buffer-        expected += TARGET_BUFFER + 100;-        assert_eq!(get(), expected);+    fn counts_allocations_and_deallocations() {+        let start = allocation_counters();+         add(100);-        // Further changes should use the buffer-        assert_eq!(get(), expected);-        add(MAX_BUFFER);-        // Large changes should require more buffer space-        expected += 100 + MAX_BUFFER;-        assert_eq!(get(), expected);+        add(250);         remove(100);-        // Small changes should use the buffer-        // buffer size is now TARGET_BUFFER + 100-        assert_eq!(get(), expected);-        remove(MAX_BUFFER);-        // The buffer should not grow over MAX_BUFFER-        // buffer size would be TARGET_BUFFER + 100 + MAX_BUFFER-        // but it will be reduce to TARGET_BUFFER-        // this means the global counter should reduce by 100 + MAX_BUFFER-        expected -= MAX_BUFFER + 100;-        assert_eq!(get(), expected);--        update(100, 200);-        // Small reallocations should use the buffer.-        assert_eq!(get(), expected);-        update(0, MAX_BUFFER);-        // Growing beyond the buffer should require more buffer space. The prior small growth-        // consumed another 100 bytes from the buffer.-        expected += MAX_BUFFER + 100;-        assert_eq!(get(), expected);-        update(MAX_BUFFER + 1, 0);-        // Shrinking beyond MAX_BUFFER should flush the excess.-        expected -= MAX_BUFFER + 1;-        assert_eq!(get(), expected);++        let after = allocation_counters();+        assert_eq!(after.allocations - start.allocations, 350);+        assert_eq!(after.allocation_count - start.allocation_count, 2);+        assert_eq!(after.deallocations - start.deallocations, 100);+        assert_eq!(after.deallocation_count - start.deallocation_count, 1);+    }++    #[test]+    fn update_counts_both_sides() {+        let start = allocation_counters();++        update(40, 100);++        let after = allocation_counters();+        assert_eq!(after.allocations - start.allocations, 100);+        assert_eq!(after.allocation_count - start.allocation_count, 1);+        assert_eq!(after.deallocations - start.deallocations, 40);+        assert_eq!(after.deallocation_count - start.deallocation_count, 1);+    }++    /// `reset_allocation_counters` restores a previously captured value, which is how the tracing+    /// layer excludes its own writes from a span's totals.+    #[test]+    fn reset_restores_a_captured_value() {+        let start = allocation_counters();+        add(4096);+        assert!(allocation_counters().allocations > start.allocations);++        reset_allocation_counters(start.clone());+        assert_eq!(allocation_counters().allocations, start.allocations);+        assert_eq!(+            allocation_counters().allocation_count,+            start.allocation_count+        );+    }++    /// `flush` is called when a thread stops so a thread reusing the slot starts clean.+    #[test]+    fn flush_clears_this_threads_counters() {+        std::thread::spawn(|| {+            add(1234);+            assert!(allocation_counters().allocations >= 1234);+            flush();+            let cleared = allocation_counters();+            assert_eq!(cleared.allocations, 0);+            assert_eq!(cleared.allocation_count, 0);+            assert_eq!(cleared.deallocations, 0);+            assert_eq!(cleared.deallocation_count, 0);+        })+        .join()+        .unwrap();+    }++    /// The buffered global counter only exists without the `custom_allocator` feature.+    ///+    /// Asserts the buffering arithmetic on a [`ThreadLocalCounter`] directly: how much a thread+    /// keeps buffered, and therefore when it has to touch the global. The global itself is not+    /// read — it is process-wide, and this binary installs [`crate::TurboMalloc`] as its global+    /// allocator, so every other thread moves it concurrently. `buffer` is thread-local and+    /// exact.+    #[cfg(not(all(feature = "custom_allocator", not(target_family = "wasm"))))]+    #[test]+    fn counting() {+        use super::global::{MAX_BUFFER, TARGET_BUFFER};++        let mut local = ThreadLocalCounter::new();++        // A fresh counter has nothing buffered, so the first allocation has to reach the global,+        // taking a full TARGET_BUFFER while it is there.+        local.add(100);+        assert_eq!(local.buffer, TARGET_BUFFER);++        // Further small allocations come straight out of the buffer.+        local.add(100);+        assert_eq!(local.buffer, TARGET_BUFFER - 100);++        // An allocation larger than the buffer refills it from the global.+        local.add(MAX_BUFFER);+        assert_eq!(local.buffer, TARGET_BUFFER);++        // Frees go back into the buffer while it stays under MAX_BUFFER.+        local.remove(100);+        assert_eq!(local.buffer, TARGET_BUFFER + 100);++        // Past MAX_BUFFER the excess is flushed back to the global, down to TARGET_BUFFER.+        local.remove(MAX_BUFFER);+        assert_eq!(local.buffer, TARGET_BUFFER);++        // A reallocation that grows by less than the buffer is served locally.+        local.update(100, 200);+        assert_eq!(local.buffer, TARGET_BUFFER - 100);++        // One that grows beyond it refills.+        local.update(0, MAX_BUFFER);+        assert_eq!(local.buffer, TARGET_BUFFER);++        // One that shrinks beyond MAX_BUFFER flushes the excess.+        local.update(MAX_BUFFER + 1, 0);+        assert_eq!(local.buffer, TARGET_BUFFER);++        // Unloading returns whatever is still buffered.+        local.unload();+        assert_eq!(local.buffer, 0);     } }
turbopack/crates/turbo-tasks-malloc/src/lib.rs77 + / 7
@@ -7,7 +7,7 @@ use std::{     ops::{Add, AddAssign}, }; -use self::counter::{add, flush, get, remove, update};+use self::counter::{add, flush, remove, update};  #[derive(Default, Clone, Debug)] pub struct AllocationInfo {@@ -85,16 +85,52 @@ impl AllocationCounters { pub struct TurboMalloc;  impl TurboMalloc {-    /// Returns the current amount of live memory (bytes allocated minus freed)-    /// tracked across all threads.+    /// Returns the bytes the allocator currently has committed from the OS.     ///-    /// For efficiency reasons every thread only synchronizes with this counter after ~100K bytes of-    /// allocations or deallocations.  So this could be off by as much as 100K*number of thread in-    /// either direction.+    /// This is the allocator's own accounting, not a per-OS query, so it means the same thing on+    /// every platform. It counts what mimalloc has taken from the OS, which includes allocator+    /// overhead and fragmentation, and excludes anything mimalloc did not hand out — the binary,+    /// mmap'd files, and any memory allocated by the embedding process. It is a measure of what+    /// this allocator holds, not of the process's total footprint.+    ///+    /// It does not track frees in lock step. mimalloc reuses and purges pages on its own+    /// schedule, so the figure lags a burst of frees, and memory abandoned by threads that have+    /// since exited is only reclaimed by a forcing [`Self::collect`].+    ///+    /// Without the `custom_allocator` feature this is a process-wide counter of live bytes+    /// (allocations minus deallocations), maintained by [`self::counter`]. That figure is+    /// approximate: threads buffer their updates, so it can be off by up to a fixed amount per+    /// thread in either direction.     pub fn memory_usage() -> usize {-        get()+        #[cfg(all(feature = "custom_allocator", not(target_family = "wasm")))]+        {+            // `current_commit` is a relaxed atomic load, but `mi_process_info` also calls+            // `_mi_prim_process_info`, which is a `getrusage` (plus a `task_info` on macOS). All+            // eight out-params are optional, so ask only for the one we use.+            let mut current_commit = 0usize;+            // Safety: every out-param is either null or a valid `usize` we own.+            unsafe {+                libmimalloc_sys::mi_process_info(+                    std::ptr::null_mut(),+                    std::ptr::null_mut(),+                    std::ptr::null_mut(),+                    std::ptr::null_mut(),+                    std::ptr::null_mut(),+                    &mut current_commit,+                    std::ptr::null_mut(),+                    std::ptr::null_mut(),+                );+            }+            current_commit+        }+        #[cfg(not(all(feature = "custom_allocator", not(target_family = "wasm"))))]+        {+            self::counter::get()+        }     } +    /// Clears the calling thread's allocation counters. Call this when a thread is about to stop,+    /// so a thread that reuses its slot does not inherit the previous totals.     pub fn thread_stop() {         flush();     }@@ -205,6 +241,40 @@ unsafe impl GlobalAlloc for TurboMalloc { mod tests {     use super::TurboMalloc; +    // `memory_usage` reports what *this* allocator has committed, so the test binary has to+    // actually route its allocations through it. Without this the `vec!` below goes to the+    // system allocator and mimalloc's counter never moves.+    #[global_allocator]+    static ALLOC: TurboMalloc = TurboMalloc;++    /// Also guards against the counter silently becoming unavailable. mimalloc's `committed`+    /// stat is maintained even at `MI_STAT 0` (which is what a release build compiles, since+    /// `build.rs` sets `MI_DEBUG=0`) because the `mi_os_stat_*` macros are not gated on+    /// `MI_STAT` — an internal detail rather than a documented guarantee, so a+    /// `libmimalloc-sys` bump could zero it out. If that happens, this fails.+    #[test]+    fn memory_usage_is_reported_and_tracks_a_large_allocation() {+        let before = TurboMalloc::memory_usage();+        assert!(before > 0, "a running process has live memory");++        // Large enough to dwarf whatever else the test process does concurrently, and written to+        // so the pages are actually committed.+        const SIZE: usize = 256 * 1024 * 1024;+        let mut buffer = vec![0u8; SIZE];+        for chunk in buffer.chunks_mut(4096) {+            chunk[0] = 1;+        }+        std::hint::black_box(&buffer);++        let after = TurboMalloc::memory_usage();+        assert!(+            after >= before + SIZE / 2,+            "expected a rise of at least {} bytes, got {before} -> {after}",+            SIZE / 2+        );+        drop(buffer);+    }+     #[test]     fn memory_pressure_is_in_range() {         let value = TurboMalloc::memory_pressure();