blob: 394bbe774d35f006dcd934f0e2ff6b13f8868ea5 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Process-wide accounting of the bytes currently handed out by the Rust global allocator.
//!
//! [`AccountingAllocator`] wraps the selected global allocator and maintains a single signed
//! process-wide byte balance, which [`current_balance`] exposes so it can be compared against the
//! memory pool's reservations in tracing output. This is observability only: it never rejects an
//! allocation, never panics, and never gates the memory pool.
//!
//! The balance counts `Layout` bytes, not resident pages: it excludes allocator fragmentation,
//! jemalloc's retained pages, `mmap`ed regions, and anything a C dependency allocates through libc
//! `malloc` rather than Rust's `GlobalAlloc`. See the [memory management contributor guide].
//!
//! [memory management contributor guide]:
//! https://datafusion.apache.org/comet/contributor-guide/memory_management.html
use std::alloc::{GlobalAlloc, Layout};
use std::cell::Cell;
use std::sync::atomic::{AtomicIsize, Ordering};
/// A thread flushes its accumulated delta into the shared balance once the magnitude reaches this.
/// Batching keeps the common path to a thread-local add-and-compare, so only about one atomic
/// read-modify-write per 64 KiB of churn touches the shared cacheline.
const SETTLE_THRESHOLD: isize = 64 * 1024;
/// Outstanding bytes, process-wide. Signed because a thread can flush a negative delta before
/// another flushes the matching positive one.
static BALANCE: AtomicIsize = AtomicIsize::new(0);
thread_local! {
/// Set while this thread is inside [`track`], so an allocation made *by* `track` settles
/// directly instead of recursing. The only such allocation today is the one some platforms
/// make when registering `LOCAL_DRIFT`'s destructor on first touch.
///
/// Const-initialized and destructor-free, so reading it never allocates and never fails,
/// which is what makes it safe to consult before touching `LOCAL_DRIFT`.
static IN_TRACK: Cell<bool> = const { Cell::new(false) };
/// This thread's un-flushed delta.
static LOCAL_DRIFT: ThreadDrift = const { ThreadDrift(Cell::new(0)) };
}
/// Owns a thread's un-flushed delta and settles the remainder when the thread exits. Without the
/// destructor, up to [`SETTLE_THRESHOLD`] bytes of accounting would be discarded every time a
/// thread died, and tokio's blocking pool churns threads on its idle timeout.
struct ThreadDrift(Cell<isize>);
impl Drop for ThreadDrift {
fn drop(&mut self) {
let drift = self.0.replace(0);
if drift != 0 {
BALANCE.fetch_add(drift, Ordering::Relaxed);
}
}
}
/// Bytes currently handed out by the Rust global allocator, process-wide.
///
/// Returns 0 when the [`AccountingAllocator`] is not installed. Never reported negative: the
/// balance can dip below zero transiently while per-thread deltas settle out of order.
///
/// The value is approximate. Each live thread holds up to [`SETTLE_THRESHOLD`] bytes of
/// un-flushed delta in either direction, so the reported balance can lag the true one by up to
/// that amount times the number of live threads.
pub fn current_balance() -> usize {
BALANCE.load(Ordering::Relaxed).max(0) as usize
}
/// Adds `delta` to `local_drift`, flushing into the shared balance once the magnitude reaches
/// [`SETTLE_THRESHOLD`].
fn settle(local_drift: &Cell<isize>, delta: isize) {
let drift = local_drift.get().wrapping_add(delta);
if drift.unsigned_abs() >= SETTLE_THRESHOLD as usize {
local_drift.set(0);
BALANCE.fetch_add(drift, Ordering::Relaxed);
} else {
local_drift.set(drift);
}
}
/// Records a signed byte delta against the process balance.
#[inline]
fn track(delta: isize) {
if delta == 0 {
return;
}
// A re-entrant call is one made by `track` itself; the outer frame owns the flag and will
// clear it, so this frame must only settle and return.
if IN_TRACK.with(|in_track| in_track.replace(true)) {
BALANCE.fetch_add(delta, Ordering::Relaxed);
return;
}
// `try_with` rather than `with`: during thread teardown `LOCAL_DRIFT`'s destructor has already
// run, and any allocation after that point must not panic inside the allocator.
if LOCAL_DRIFT
.try_with(|thread_drift| settle(&thread_drift.0, delta))
.is_err()
{
BALANCE.fetch_add(delta, Ordering::Relaxed);
}
IN_TRACK.with(|in_track| in_track.set(false));
}
/// Wraps a global allocator, accounting the `Layout` bytes it hands out.
///
/// Adapted from the `AccountingAllocator` in
/// [apache/datafusion#22626](https://github.com/apache/datafusion/pull/22626), which lives in
/// DataFusion's test-only `sqllogictest` crate and so cannot be depended on directly.
pub struct AccountingAllocator<A: GlobalAlloc> {
inner: A,
}
impl<A: GlobalAlloc> AccountingAllocator<A> {
pub const fn new(inner: A) -> Self {
Self { inner }
}
}
// SAFETY: every method delegates to `inner`, which upholds the `GlobalAlloc` contract. The
// accounting is pure bookkeeping over an `AtomicIsize` and thread-local `Cell`s: it does not
// inspect, retain, or alter any pointer, and it cannot unwind.
unsafe impl<A: GlobalAlloc> GlobalAlloc for AccountingAllocator<A> {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let ptr = self.inner.alloc(layout);
if !ptr.is_null() {
track(layout.size() as isize);
}
ptr
}
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
let ptr = self.inner.alloc_zeroed(layout);
if !ptr.is_null() {
track(layout.size() as isize);
}
ptr
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
// Settle before delegating. A free cannot fail, so there is nothing to wait for, and the
// inner free can be slow: jemalloc returns oversize blocks to the OS eagerly, and unmapping
// a few hundred megabytes takes milliseconds. Accounting afterwards would keep the block on
// the balance for that whole window, after the allocator's own statistics had already
// dropped it.
track(-(layout.size() as isize));
self.inner.dealloc(ptr, layout);
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
let new_ptr = self.inner.realloc(ptr, layout, new_size);
if !new_ptr.is_null() {
// Accounting after the fact is only safe because this allocator cannot fail the
// allocation or unwind. A variant that enforced a limit would have to decide *before*
// delegating: `realloc` may free or move the old block, and a caller that never
// received the new pointer would free the stale one while unwinding.
//
// A single allocation cannot exceed `isize::MAX` on any real platform, so neither cast
// wraps.
track(new_size as isize - layout.size() as isize);
}
new_ptr
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::alloc::System;
use std::sync::atomic::AtomicUsize;
use std::sync::{Mutex, MutexGuard};
/// `BALANCE` is process-wide and the crate's tests run in parallel, so a test that reads it
/// sees every other test's allocations. The tests that move it by tens of megabytes take this
/// lock so they cannot land inside each other's windows; the rest of the crate is kept out by
/// making each window microseconds wide and each expected move far larger than anything else
/// allocates in that time.
static SERIAL: Mutex<()> = Mutex::new(());
fn serial() -> MutexGuard<'static, ()> {
SERIAL
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
const MIB: usize = 1024 * 1024;
/// Slack allowed between an observed balance and the expected one, to absorb whatever the
/// rest of the crate allocates during a test's window. It is half the smallest move any test
/// below expects, so a wrongly ordered or wrongly sized update still lands outside it.
const MARGIN: usize = 16 * MIB;
fn about(actual: usize, expected: usize) -> bool {
actual.abs_diff(expected) <= MARGIN
}
/// An inner allocator that records the reported balance at the moment each inner call is
/// made, which pins down whether the wrapper accounts before or after delegating.
struct Recording {
balance_at_dealloc: AtomicUsize,
balance_at_realloc: AtomicUsize,
}
impl Recording {
fn new() -> Self {
Self {
balance_at_dealloc: AtomicUsize::new(usize::MAX),
balance_at_realloc: AtomicUsize::new(usize::MAX),
}
}
}
unsafe impl GlobalAlloc for Recording {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
System.alloc(layout)
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
self.balance_at_dealloc
.store(current_balance(), Ordering::Relaxed);
System.dealloc(ptr, layout)
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
self.balance_at_realloc
.store(current_balance(), Ordering::Relaxed);
System.realloc(ptr, layout, new_size)
}
}
#[test]
fn settle_flushes_only_at_the_threshold() {
for (delta, residue) in [
(1024, 1024),
(-1024, -1024),
(SETTLE_THRESHOLD - 1, SETTLE_THRESHOLD - 1),
(SETTLE_THRESHOLD, 0),
(-SETTLE_THRESHOLD, 0),
] {
let drift = Cell::new(0);
settle(&drift, delta);
// A flush resets the drift to zero, so the residue alone says whether the shared
// balance was touched. Reading `BALANCE` here would race with every other test.
assert_eq!(drift.get(), residue, "delta {delta}");
}
}
/// A real allocation must move the reported balance. This is the one test that checks the
/// wrapper is actually installed as the global allocator for the current feature set. The
/// block is zeroed and never touched, so it costs address space rather than resident memory.
#[test]
#[cfg(feature = "alloc-accounting")]
fn a_real_allocation_raises_the_balance() {
use std::hint::black_box;
const SIZE: usize = 256 * MIB;
let _guard = serial();
let before = current_balance();
// `black_box` keeps the allocation observable so it cannot be elided.
let held: Vec<u8> = black_box(vec![0u8; SIZE]);
let during = current_balance();
black_box(&held);
assert!(
during >= before + SIZE / 2,
"a {SIZE} byte allocation should raise the balance (before={before}, during={during}); \
is the accounting wrapper installed for this feature set?"
);
drop(held);
}
/// The balance must drop before the inner allocator is asked to free the block, because
/// jemalloc drops its own count at the start of a large free and then spends milliseconds
/// unmapping the pages; see the comment on `dealloc`.
#[test]
fn dealloc_settles_before_delegating() {
// Well above the settle threshold, so both the allocation and the free flush immediately.
const SIZE: usize = 64 * MIB;
let _guard = serial();
let allocator = AccountingAllocator::new(Recording::new());
let layout = Layout::from_size_align(SIZE, 8).unwrap();
// SAFETY: the layout is valid and non-zero, and the block is freed below through the same
// allocator that produced it.
let ptr = unsafe { allocator.alloc(layout) };
assert!(!ptr.is_null());
let after_alloc = current_balance();
unsafe { allocator.dealloc(ptr, layout) };
let seen = allocator.inner.balance_at_dealloc.load(Ordering::Relaxed);
assert!(
about(seen + SIZE, after_alloc),
"inner dealloc saw balance {seen}, expected about {} (balance after alloc was \
{after_alloc})",
after_alloc.saturating_sub(SIZE)
);
}
/// `realloc` moves the balance by the size difference, not by the new size, and does so after
/// delegating: the inner allocator must see the balance still carrying the old size.
#[test]
fn realloc_accounts_the_size_difference_after_delegating() {
const OLD: usize = 64 * MIB;
const GROWN: usize = 96 * MIB;
const SHRUNK: usize = 32 * MIB;
let _guard = serial();
let allocator = AccountingAllocator::new(Recording::new());
let layout = Layout::from_size_align(OLD, 8).unwrap();
// SAFETY: each layout matches the block's current size, and the block is freed at the end
// through the same allocator that produced it.
let ptr = unsafe { allocator.alloc(layout) };
assert!(!ptr.is_null());
let before_grow = current_balance();
let ptr = unsafe { allocator.realloc(ptr, layout, GROWN) };
assert!(!ptr.is_null());
let after_grow = current_balance();
let seen = allocator.inner.balance_at_realloc.load(Ordering::Relaxed);
assert!(
about(seen, before_grow),
"inner realloc saw balance {seen}, expected about {before_grow}: the wrapper must \
account after delegating"
);
assert!(
about(after_grow, before_grow + (GROWN - OLD)),
"growing {OLD} -> {GROWN} moved the balance {before_grow} -> {after_grow}, expected \
about +{}",
GROWN - OLD
);
let layout = Layout::from_size_align(GROWN, 8).unwrap();
let ptr = unsafe { allocator.realloc(ptr, layout, SHRUNK) };
assert!(!ptr.is_null());
let after_shrink = current_balance();
assert!(
about(after_shrink + (GROWN - SHRUNK), after_grow),
"shrinking {GROWN} -> {SHRUNK} moved the balance {after_grow} -> {after_shrink}, \
expected about -{}",
GROWN - SHRUNK
);
unsafe { allocator.dealloc(ptr, Layout::from_size_align(SHRUNK, 8).unwrap()) };
}
/// Threads must settle their remaining drift on exit.
///
/// The worker writes a drift straight into its `LOCAL_DRIFT` cell and exits, so the only path
/// by which that value can reach the shared balance is `ThreadDrift::drop`. That holds only
/// while the wrapper is not installed: with it, thread teardown's own allocations call `track`
/// and flush the oversized drift before the destructor runs, and the test would pass without
/// one. So the test is confined to the default build, which is the one CI runs. The injected
/// amount is far larger than any real allocation, and is taken back out afterwards.
#[test]
#[cfg(not(feature = "alloc-accounting"))]
fn thread_exit_settles_remaining_drift() {
use std::thread;
const INJECTED: isize = 1 << 40;
let _guard = serial();
let before = BALANCE.load(Ordering::Relaxed);
thread::spawn(|| {
LOCAL_DRIFT.with(|drift| drift.0.set(drift.0.get() + INJECTED));
})
.join()
.unwrap();
let moved = BALANCE.load(Ordering::Relaxed) - before;
BALANCE.fetch_sub(INJECTED, Ordering::Relaxed);
assert!(
moved >= INJECTED / 2,
"drift from an exited thread never reached the shared balance: \
balance moved {moved} bytes, expected at least {}",
INJECTED / 2
);
}
}