blob: a0ff21b2a7b3b630cee3f91a5ed99462b16ec0b4 [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.
*/
use crate::configs::system::SystemConfig;
use bytes::BytesMut;
use crossbeam::queue::ArrayQueue;
use human_repr::HumanCount;
use once_cell::sync::OnceCell;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use tracing::{info, trace, warn};
/// Global memory pool instance. Use `memory_pool()` to access it.
pub static MEMORY_POOL: OnceCell<MemoryPool> = OnceCell::new();
/// Total number of distinct bucket sizes.
const NUM_BUCKETS: usize = 32;
/// Array of bucket sizes in ascending order. Each entry is a distinct buffer size (in bytes).
const BUCKET_SIZES: [usize; NUM_BUCKETS] = [
256,
512,
1024,
2 * 1024,
4 * 1024,
8 * 1024,
16 * 1024,
32 * 1024,
64 * 1024,
128 * 1024,
256 * 1024,
512 * 1024,
768 * 1024,
1024 * 1024,
1536 * 1024,
2 * 1024 * 1024, // Above 2MiB everything should be rounded up to the next power of 2 to take advantage of hugepages
4 * 1024 * 1024, // (environment variables MIMALLOC_ALLOW_LARGE_OS_PAGES=1 and MIMALLOC_LARGE_OS_PAGES=1).
6 * 1024 * 1024,
8 * 1024 * 1024,
10 * 1024 * 1024,
12 * 1024 * 1024,
16 * 1024 * 1024,
24 * 1024 * 1024,
32 * 1024 * 1024,
48 * 1024 * 1024,
64 * 1024 * 1024,
96 * 1024 * 1024,
128 * 1024 * 1024,
192 * 1024 * 1024,
256 * 1024 * 1024,
384 * 1024 * 1024,
512 * 1024 * 1024,
];
/// Retrieve the global `MemoryPool` instance. Panics if not yet initialized.
pub fn memory_pool() -> &'static MemoryPool {
MEMORY_POOL
.get()
.expect("Memory pool not initialized - MemoryPool::init_pool should be called first")
}
/// A memory pool that maintains fixed-size buckets for reusing `BytesMut` buffers.
///
/// Each bucket corresponds to a particular size in `BUCKET_SIZES`. The pool tracks:
/// - Buffers currently in use (`in_use`)
/// - Buffers allocated historically (`allocations`)
/// - Buffers returned to the pool (`returned`)
/// - External allocations/deallocations (buffers allocated outside the pool limit)
/// - Resizes and dropped returns
#[derive(Clone)]
pub struct MemoryPool {
/// Whether the pool is enabled.
pub is_enabled: bool,
/// Configured maximum bytes for which the pool is responsible.
pub memory_limit: usize,
/// Configured maximum number of buffers in each bucket.
pub bucket_capacity: usize,
/// Array of queues for reusable buffers. Each queue can store up to `bucket_capacity` buffers.
/// The length of each queue (`buckets[i].len()`) is how many **free** buffers are currently available.
/// Free doesn't mean the buffer is allocated, it just means it's not in use.
buckets: [Arc<ArrayQueue<BytesMut>>; NUM_BUCKETS],
/// Number of buffers **in use** for each bucket size (grow/shrink as they are acquired/released).
in_use: [Arc<AtomicUsize>; NUM_BUCKETS],
/// Total number of buffers **ever allocated** in each bucket. Monotonically increasing.
allocations: [Arc<AtomicUsize>; NUM_BUCKETS],
/// Total number of buffers **ever returned** to each bucket. Monotonically increasing.
returned: [Arc<AtomicUsize>; NUM_BUCKETS],
/// Count of buffers allocated outside the pool limit (e.g., if usage exceeds `memory_limit` or no matching bucket).
external_allocations: Arc<AtomicUsize>,
/// Count of buffers deallocated outside the pool (e.g., returning a buffer that doesn't match any bucket).
external_deallocations: Arc<AtomicUsize>,
/// Number of resize events detected (the buffer capacity changed between acquire and release).
resize_events: Arc<AtomicUsize>,
/// Number of returns that couldn't be stored because the relevant bucket queue was at capacity.
dropped_returns: Arc<AtomicUsize>,
/// Flag set when the pool usage first exceeds `memory_limit`. Used to log a single warning.
capacity_warning: Arc<AtomicBool>,
}
impl MemoryPool {
/// Create a new memory pool. Usually called from `init_pool` below.
pub fn new(is_enabled: bool, memory_limit: usize, bucket_capacity: usize) -> Self {
let buckets = [0; NUM_BUCKETS].map(|_| Arc::new(ArrayQueue::new(bucket_capacity)));
if is_enabled {
info!(
"Initializing MemoryPool with {NUM_BUCKETS} buckets, each will have capacity: {bucket_capacity}."
);
} else {
info!("MemoryPool is disabled.");
}
Self {
is_enabled,
memory_limit,
bucket_capacity,
buckets,
in_use: [0; NUM_BUCKETS].map(|_| Arc::new(AtomicUsize::new(0))),
allocations: [0; NUM_BUCKETS].map(|_| Arc::new(AtomicUsize::new(0))),
returned: [0; NUM_BUCKETS].map(|_| Arc::new(AtomicUsize::new(0))),
external_allocations: Arc::new(AtomicUsize::new(0)),
external_deallocations: Arc::new(AtomicUsize::new(0)),
resize_events: Arc::new(AtomicUsize::new(0)),
dropped_returns: Arc::new(AtomicUsize::new(0)),
capacity_warning: Arc::new(AtomicBool::new(false)),
}
}
/// Initialize the global pool from the given config.
pub fn init_pool(config: Arc<SystemConfig>) {
let is_enabled = config.memory_pool.enabled;
let memory_limit = config.memory_pool.size.as_bytes_usize();
let bucket_capacity = config.memory_pool.bucket_capacity as usize;
let pool = MemoryPool::new(is_enabled, memory_limit, bucket_capacity);
if MEMORY_POOL.set(pool).is_err() {
warn!("Memory pool already initialized.");
// This shouldn't ever happen in production code, only in tests
// if someone forgets to add #[serial] tag to tests that have different
// memory pool limits (different instances are created within same executable).
if memory_pool().memory_limit != memory_limit {
panic!("Previously initialized memory pool has a different limit.");
}
}
}
/// Acquire a `BytesMut` buffer with at least `capacity` bytes.
///
/// - If a bucket can fit `capacity`, try to pop from its free buffer queue; otherwise create a new buffer.
/// - If `memory_limit` would be exceeded, allocate outside the pool.
pub fn acquire_buffer(&self, capacity: usize) -> BytesMut {
if !self.is_enabled {
return BytesMut::with_capacity(capacity);
}
let current = self.pool_current_size();
match self.best_fit(capacity) {
Some(idx) => {
if let Some(mut buf) = self.buckets[idx].pop() {
buf.clear();
self.inc_bucket_in_use(idx);
trace!("Reused buffer, capacity: {}", BUCKET_SIZES[idx]);
return buf;
}
let new_size = BUCKET_SIZES[idx];
if current + new_size > self.memory_limit {
self.set_capacity_warning(true);
trace!(
"Pool is at capacity. Allocating outside the pool: requested {} B, current usage {} B, limit {} B",
new_size, current, self.memory_limit
);
self.inc_external_allocations();
return BytesMut::with_capacity(new_size);
}
self.inc_bucket_alloc(idx);
self.inc_bucket_in_use(idx);
BytesMut::with_capacity(new_size)
}
None => {
if current + capacity > self.memory_limit {
trace!(
"Pool is at capacity. Allocating outside the pool: requested {} B, current usage {} B, limit {} B",
capacity, current, self.memory_limit
);
self.inc_external_allocations();
return BytesMut::with_capacity(capacity);
}
self.inc_external_allocations();
BytesMut::with_capacity(capacity)
}
}
}
/// Return a `BytesMut` buffer previously acquired from the pool.
///
/// - If `current_capacity` differs from `original_capacity`, increments `resize_events`.
/// - If a matching bucket exists, place it back in that bucket's queue (if space is available).
/// - Otherwise, treat it as an external deallocation.
pub fn release_buffer(&self, buffer: BytesMut, original_capacity: usize) {
if !self.is_enabled {
return;
}
let current_capacity = buffer.capacity();
if current_capacity != original_capacity {
self.inc_resize_events();
trace!(
"Buffer capacity {} != original {} when returning",
current_capacity,
original_capacity
);
}
if let Some(orig_idx) = self.best_fit(original_capacity) {
self.dec_bucket_in_use(orig_idx);
}
match self.best_fit(current_capacity) {
Some(idx) => {
self.inc_bucket_return(idx);
if self.buckets[idx].push(buffer).is_err() {
self.inc_dropped_returns();
trace!(
"Pool full for size: {} B, dropping buffer",
BUCKET_SIZES[idx]
);
self.set_capacity_warning(true);
}
}
None => {
self.inc_external_deallocations();
trace!("Returned outside-of-pool buffer, capacity: {current_capacity}, dropping");
}
}
}
/// Write a log message summarizing current usage, allocations, etc.
/// Only logs if the pool is enabled and `pool_current_size()` is non-zero.
/// Also logs a warning if the pool usage has exceeded `memory_limit`.
pub fn log_stats(&self) {
if !self.is_enabled || self.pool_current_size() == 0 {
return;
}
let bucket_stats = (0..NUM_BUCKETS)
.filter_map(|i| {
let current_el = self.bucket_current_elements(i);
let allocated_el = self.bucket_allocated_elements(i);
if current_el > 0 || allocated_el > 0 {
Some(format!(
"{label}:[{current_el}/{current_size}/{allocated_el}/{allocated_size}/{returns}]",
label = size_str(BUCKET_SIZES[i]),
current_el = current_el.human_count_bare(),
current_size = size_str(self.bucket_current_size(i)),
allocated_el = allocated_el.human_count_bare(),
allocated_size = size_str(self.bucket_allocated_size(i)),
returns = self.bucket_returns(i).human_count_bare(),
))
} else {
None
}
})
.collect::<Vec<String>>()
.join(", ");
info!(
"Pool Buckets: {bucket_stats} (BucketLabel:[InUseCount/InUseSize/AllocCount/AllocSize/Returns])"
);
info!(
"Pool Summary: Curr:{current}/Alloc:{allocated}/Util:{util:.1}%/Limit:{limit}/ExtAlloc:{ext_alloc}/ExtDealloc:{ext_dealloc}/DropRet:{drop_ret}/Resizes:{resize_events}/BucketCap:{cap}",
current = size_str(self.pool_current_size()),
allocated = size_str(self.pool_allocated_size()),
util = self.pool_utilization(),
limit = size_str(self.pool_maximum_size()),
ext_alloc = self.external_allocations(),
ext_dealloc = self.external_deallocations(),
drop_ret = self.dropped_returns(),
resize_events = self.resize_events(),
cap = self.bucket_capacity,
);
if self.should_print_warning() {
warn!("Memory pool usage exceeded limit! Consider adjusting memory_pool.size.");
self.set_capacity_warning(false);
}
}
#[inline]
pub fn best_fit(&self, capacity: usize) -> Option<usize> {
match BUCKET_SIZES.binary_search(&capacity) {
Ok(idx) => Some(idx),
Err(idx) => {
if idx < NUM_BUCKETS {
Some(idx)
} else {
None
}
}
}
}
/// Returns the configured maximum size of the pool, usually from config, in bytes.
fn pool_maximum_size(&self) -> usize {
self.memory_limit
}
/// Sums the sizes (in bytes) of all buffers currently in use across the pool.
fn pool_current_size(&self) -> usize {
(0..NUM_BUCKETS)
.filter(|&i| self.bucket_current_elements(i) > 0)
.map(|i| self.bucket_current_size(i))
.sum()
}
/// Sums the sizes (in bytes) of all buffers ever allocated across the pool (historical).
fn pool_allocated_size(&self) -> usize {
let mut size = 0;
for i in 0..NUM_BUCKETS {
size += self.bucket_allocated_size(i);
}
size
}
/// Returns pool utilization percentage.
fn pool_utilization(&self) -> f64 {
(self.pool_current_size() as f64 / self.pool_maximum_size() as f64) * 100.0
}
fn bucket_current_elements(&self, idx: usize) -> usize {
self.in_use[idx].load(Ordering::Acquire)
}
fn bucket_current_size(&self, idx: usize) -> usize {
self.bucket_current_elements(idx) * BUCKET_SIZES[idx]
}
fn bucket_allocated_elements(&self, idx: usize) -> usize {
self.allocations[idx].load(Ordering::Acquire)
}
fn bucket_allocated_size(&self, idx: usize) -> usize {
self.bucket_allocated_elements(idx) * BUCKET_SIZES[idx]
}
fn bucket_returns(&self, idx: usize) -> usize {
self.returned[idx].load(Ordering::Acquire)
}
fn resize_events(&self) -> usize {
self.resize_events.load(Ordering::Acquire)
}
fn dropped_returns(&self) -> usize {
self.dropped_returns.load(Ordering::Acquire)
}
fn external_allocations(&self) -> usize {
self.external_allocations.load(Ordering::Acquire)
}
fn external_deallocations(&self) -> usize {
self.external_deallocations.load(Ordering::Acquire)
}
pub(super) fn inc_resize_events(&self) {
self.resize_events.fetch_add(1, Ordering::Release);
}
pub(super) fn inc_dropped_returns(&self) {
self.dropped_returns.fetch_add(1, Ordering::Release);
}
pub(super) fn inc_external_allocations(&self) {
self.external_allocations.fetch_add(1, Ordering::Release);
}
pub(super) fn inc_external_deallocations(&self) {
self.external_deallocations.fetch_add(1, Ordering::Release);
}
pub(super) fn inc_bucket_alloc(&self, idx: usize) {
self.allocations[idx].fetch_add(1, Ordering::Release);
}
pub(super) fn inc_bucket_return(&self, idx: usize) {
self.returned[idx].fetch_add(1, Ordering::Release);
}
pub(super) fn inc_bucket_in_use(&self, idx: usize) {
self.in_use[idx].fetch_add(1, Ordering::Release);
}
pub(super) fn dec_bucket_in_use(&self, idx: usize) {
self.in_use[idx].fetch_sub(1, Ordering::Release);
}
fn should_print_warning(&self) -> bool {
self.capacity_warning.load(Ordering::Acquire)
}
fn set_capacity_warning(&self, value: bool) {
self.capacity_warning.store(value, Ordering::Release);
}
}
/// Return a buffer to the pool by calling `release_buffer` with the original capacity.
/// This extension trait makes it easy to do `some_bytes.return_to_pool(orig_cap)`.
pub trait BytesMutExt {
fn return_to_pool(self, original_capacity: usize);
}
impl BytesMutExt for BytesMut {
fn return_to_pool(self, original_capacity: usize) {
memory_pool().release_buffer(self, original_capacity);
}
}
/// Convert a size in bytes to a string like “8KiB” or “2MiB”.
fn size_str(size: usize) -> String {
if size >= 1024 * 1024 {
format!("{}MiB", size / (1024 * 1024))
} else if size >= 1024 {
format!("{}KiB", size / 1024)
} else {
format!("{}B", size)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{configs::system::MemoryPoolConfig, streaming::utils::PooledBuffer};
use iggy::prelude::IggyByteSize;
use serial_test::serial;
use std::{str::FromStr, sync::Once};
static TEST_INIT: Once = Once::new();
fn initialize_pool_for_tests() {
TEST_INIT.call_once(|| {
let config = Arc::new(SystemConfig {
memory_pool: MemoryPoolConfig {
enabled: true,
size: IggyByteSize::from_str("4GiB").unwrap(),
bucket_capacity: 8192,
},
..SystemConfig::default()
});
MemoryPool::init_pool(config);
});
}
#[test]
#[serial(memory_pool)]
fn test_pooled_buffer_resize_tracking() {
initialize_pool_for_tests();
let pool = memory_pool();
let initial_resize_events = pool.resize_events();
let small_bucket_idx = 2;
let small_bucket_size = BUCKET_SIZES[small_bucket_idx];
let mut buffer = PooledBuffer::with_capacity(small_bucket_size);
assert_eq!(
buffer.capacity(),
small_bucket_size,
"Initial capacity should match requested size"
);
let original_in_use_before = pool.bucket_current_elements(small_bucket_idx);
// Force a resize with data significantly larger than current bucket
// Use 256KiB data to ensure it goes to a much larger bucket
let large_bucket_idx = 6;
let large_data_size = BUCKET_SIZES[large_bucket_idx] - 1024;
buffer.put_slice(&vec![0u8; large_data_size]);
assert!(
buffer.capacity() >= large_data_size,
"Buffer should resize to fit data"
);
assert_eq!(
pool.resize_events(),
initial_resize_events + 1,
"Resize event should be recorded"
);
assert_eq!(
pool.bucket_current_elements(small_bucket_idx),
original_in_use_before - 1,
"Original bucket in-use count should decrease by 1"
);
let new_bucket_idx = pool.best_fit(buffer.capacity()).unwrap();
assert!(
new_bucket_idx > small_bucket_idx,
"Buffer should move to a larger bucket"
);
let new_bucket_in_use = pool.bucket_current_elements(new_bucket_idx);
assert!(
new_bucket_in_use > 0,
"New bucket should have buffers in use"
);
drop(buffer);
assert_eq!(
pool.bucket_current_elements(small_bucket_idx),
original_in_use_before - 1,
"Small bucket count should remain decreased after drop"
);
assert_eq!(
pool.bucket_current_elements(new_bucket_idx),
new_bucket_in_use - 1,
"New bucket in-use count should decrease after drop"
);
}
#[test]
#[serial(memory_pool)]
fn test_multiple_resize_operations() {
initialize_pool_for_tests();
let pool = memory_pool();
let initial_resize_events = pool.resize_events();
let mut buffer = PooledBuffer::with_capacity(BUCKET_SIZES[1]); // 8KiB
let original_bucket_idx = pool.best_fit(buffer.capacity()).unwrap();
let original_bucket_in_use = pool.bucket_current_elements(original_bucket_idx);
let first_resize_size = BUCKET_SIZES[4]; // 64KiB
buffer.put_slice(&vec![0u8; first_resize_size]);
assert!(
buffer.capacity() >= first_resize_size,
"Buffer should increase capacity after first resize"
);
assert_eq!(
pool.resize_events(),
initial_resize_events + 1,
"One resize event should be recorded"
);
let mid_bucket_idx = pool.best_fit(buffer.capacity()).unwrap();
assert!(
mid_bucket_idx > original_bucket_idx,
"Buffer should move to a larger bucket after first resize"
);
let mid_bucket_in_use = pool.bucket_current_elements(mid_bucket_idx);
assert_eq!(
pool.bucket_current_elements(original_bucket_idx),
original_bucket_in_use - 1,
"Original bucket count should decrease after first resize"
);
let second_resize_size = BUCKET_SIZES[9]; // 1MiB
buffer.put_slice(&vec![0u8; second_resize_size]);
assert!(
buffer.capacity() >= second_resize_size,
"Buffer should increase capacity after second resize"
);
assert_eq!(
pool.resize_events(),
initial_resize_events + 2,
"Two resize events should be recorded"
);
let final_bucket_idx = pool.best_fit(buffer.capacity()).unwrap();
assert!(
final_bucket_idx > mid_bucket_idx,
"Buffer should move to an even larger bucket after second resize"
);
let final_bucket_in_use = pool.bucket_current_elements(final_bucket_idx);
assert_eq!(
pool.bucket_current_elements(mid_bucket_idx),
mid_bucket_in_use - 1,
"Mid bucket count should decrease after second resize"
);
drop(buffer);
assert_eq!(
pool.bucket_current_elements(original_bucket_idx),
original_bucket_in_use - 1,
"Original bucket should remain decreased"
);
assert_eq!(
pool.bucket_current_elements(mid_bucket_idx),
mid_bucket_in_use - 1,
"Mid bucket should remain decreased"
);
assert_eq!(
pool.bucket_current_elements(final_bucket_idx),
final_bucket_in_use - 1,
"Final bucket count should decrease after drop"
);
}
#[test]
#[serial(memory_pool)]
fn test_different_resize_methods() {
initialize_pool_for_tests();
let pool = memory_pool();
// Test put_slice
{
let initial_events = pool.resize_events();
let mut buffer = PooledBuffer::with_capacity(4 * 1024);
let orig_bucket_idx = pool.best_fit(buffer.capacity()).unwrap();
let orig_in_use = pool.bucket_current_elements(orig_bucket_idx);
buffer.put_slice(&vec![0u8; 64 * 1024]);
assert_eq!(
pool.resize_events(),
initial_events + 1,
"put_slice should trigger resize event"
);
assert_eq!(
pool.bucket_current_elements(orig_bucket_idx),
orig_in_use - 1,
"put_slice should update bucket accounting"
);
}
// Test put_bytes
{
let initial_events = pool.resize_events();
let mut buffer = PooledBuffer::with_capacity(4 * 1024);
let orig_bucket_idx = pool.best_fit(buffer.capacity()).unwrap();
let orig_in_use = pool.bucket_current_elements(orig_bucket_idx);
buffer.put_bytes(0, 64 * 1024); // 64KiB of zeros
assert_eq!(
pool.resize_events(),
initial_events + 1,
"put_bytes should trigger resize event"
);
assert_eq!(
pool.bucket_current_elements(orig_bucket_idx),
orig_in_use - 1,
"put_bytes should update bucket accounting"
);
}
// Test extend_from_slice
{
let initial_events = pool.resize_events();
let mut buffer = PooledBuffer::with_capacity(4 * 1024);
let orig_bucket_idx = pool.best_fit(buffer.capacity()).unwrap();
let orig_in_use = pool.bucket_current_elements(orig_bucket_idx);
buffer.extend_from_slice(&vec![0u8; 64 * 1024]);
assert_eq!(
pool.resize_events(),
initial_events + 1,
"extend_from_slice should trigger resize event"
);
assert_eq!(
pool.bucket_current_elements(orig_bucket_idx),
orig_in_use - 1,
"extend_from_slice should update bucket accounting"
);
}
// Test reserve
{
let initial_events = pool.resize_events();
let mut buffer = PooledBuffer::with_capacity(4 * 1024);
let orig_bucket_idx = pool.best_fit(buffer.capacity()).unwrap();
let orig_in_use = pool.bucket_current_elements(orig_bucket_idx);
buffer.reserve(64 * 1024);
if buffer.capacity() > 4 * 1024 {
assert_eq!(
pool.resize_events(),
initial_events + 1,
"reserve should trigger resize event when capacity changes"
);
assert_eq!(
pool.bucket_current_elements(orig_bucket_idx),
orig_in_use - 1,
"reserve should update bucket accounting when capacity changes"
);
}
}
}
}