Review: Static Multi Pool Allocator

Hello, I just finished my fixed size multi pool allocator. Now I need code review :>

From the previous review, I got this following :

  • small alignment can't be used to alloc bigger alignment
  • the size must be power of the alignment size

I created new project to address that with the following solution :

  • there are multiple pool
  • each pool can be configured to have different size and alignment
  • the pool with size and alignment bigger than the data's size and alignment is chosen

I already checked it with Miri and fixed the reported errors, is there still something wrong that I don't know?

#![feature(allocator_api)]

pub struct Block {
    next: Option<std::ptr::NonNull<Block>>
}

pub struct StaticMultiPoolAlloc<const N: usize> {
    start: [std::ptr::NonNull<u8>; N],
    head: [std::cell::UnsafeCell<Option<std::ptr::NonNull<Block>>>; N],
    block_size: [usize; N],
    aligns: [usize; N],
    layout: [std::alloc::Layout; N]
}

fn align_up(size: usize, align: usize) -> usize {
    (size + align - 1) & !(align - 1)
}

impl<const N: usize> StaticMultiPoolAlloc<N> {
    pub fn new(num_blocks: [usize; N], block_size: [usize; N], align_multiply: [usize; N]) -> Self {
        _ = align_multiply.map(|val| {
            if val == 0 {
                panic!("Align multiply must be > 0");
            }
        });
        let block_align = std::mem::align_of::<Block>();
        let aligns: [usize; N] = align_multiply.map(|val| val * block_align);
        
        let base_block_size = std::mem::size_of::<Block>();
        let block_size: [usize; N] = std::array::from_fn(|i| {
            let size = block_size[i].max(base_block_size);
            align_up(size, block_align) 
        });
        
        let layout: [std::alloc::Layout; N] = std::array::from_fn(|i| std::alloc::Layout::from_size_align(
            num_blocks[i] * block_size[i],
            aligns[i]
        ).expect("Error in creating layout in fn new -> PoolAllocator"));
        
        // SAFETY: the layout is valid, because if not it will trigger panic
        
        let starts: [std::ptr::NonNull<u8>; N] = std::array::from_fn(|i| {
        
        let start = unsafe { std::alloc::alloc(layout[i]) };
        let start_ptr = std::ptr::NonNull::new(start).expect("Error OOM in fn new -> PoolAllocator");
        
        unsafe {
            for j in 0..num_blocks[i] {
                let current_ptr = start.add(j * block_size[i]).cast::<Block>();
                let next_ptr = if j < num_blocks[i] - 1 {
                    Some(std::ptr::NonNull::new_unchecked(start.add((j + 1) * block_size[i]).cast::<Block>()))
                } else {
                    None
                };
                
                (*current_ptr).next = next_ptr;
            }
        }
        
        start_ptr
        
        });
        
        let head: [std::cell::UnsafeCell<Option<std::ptr::NonNull<Block>>>; N] = std::array::from_fn(|i| {
            std::cell::UnsafeCell::new(Some(std::ptr::NonNull::new(starts[i].as_ptr().cast::<Block>()).unwrap()))
        });
        
        Self {
            start: starts,
            head,
            block_size,
            aligns,
            layout,
        }
        
    }
}

impl<const N: usize> Drop for StaticMultiPoolAlloc<N> {
    fn drop(&mut self) {
        // SAFETY: deallocate using the pointer. The layout is saved, so it is the correct layout
        for (i, val) in self.start.iter().enumerate() {
            unsafe {
                std::alloc::dealloc(val.as_ptr(), self.layout[i]);
            }
        }
    }
}

unsafe impl<const N: usize> std::alloc::Allocator for StaticMultiPoolAlloc<N> {
    #[inline(always)]
    fn allocate(&self, layout: std::alloc::Layout) -> Result<std::ptr::NonNull<[u8]>, std::alloc::AllocError> {
        let size = layout.size();
        let align = layout.align();
        //let base_align = std::mem::align_of::<Block>();
        
        let index = self.aligns.iter().enumerate().position(|(i, &val)| {
            size <= self.block_size[i] && align <= val
        });

        let i = index.ok_or(std::alloc::AllocError)?;
        
        let head_ptr = unsafe { &mut *self.head[i].get() };
        if let Some(block) = *head_ptr {
            let taken_block = block;
            unsafe { *head_ptr = block.as_ref().next };
            let ptr = taken_block.cast::<u8>();
            return Ok(std::ptr::NonNull::slice_from_raw_parts(
                ptr, self.block_size[i]
            ));
        }
        
        Err(std::alloc::AllocError)
    }
    
    #[inline(always)]
    unsafe fn deallocate(&self, ptr: std::ptr::NonNull<u8>, layout: std::alloc::Layout) {
        let new_block = ptr.cast::<Block>();
        let align = layout.align();
        
        let index = self.aligns.iter().enumerate().position(|(_, &val)| {
            align <= val
        });

        let i = index.ok_or(std::alloc::AllocError).unwrap();
        
        // dereference raw pointer is unsafe
        let head_ptr = unsafe { &mut *self.head[i].get() };
        unsafe { (*new_block.as_ptr()).next = *head_ptr };
        
        *head_ptr = Some(new_block);
    }
}

#[derive(Debug)]
#[repr(align(64))]
struct Tes(i32);

fn main() {
    /*
        [pool 1, num block: 4, each block: 80 byte, total byte: 80*4, align: 8*1]
        [pool 2, num block: 2, each block: 1024 byte, total byte: 1024*2, align: 8*8]
        total prealloc in entire pool: (80*4) + (1024*2)
        fixed size, panic if no enough size available
        single threaded usage, each thread has their own pool so there is no locking, maximizing cpu cache hit
    */
    let pool: StaticMultiPoolAlloc<2> = StaticMultiPoolAlloc::new([4, 2], [80, 1024], [1, 8]);
    let mut vec = Vec::with_capacity_in(2, &pool);
    vec.push(Tes(10));
    println!("{:?}", vec[0].0);
}

Typically, safety comments follow pattern:

  1. they are written before a call to unsafe function or operation,
  2. and they explain how each of the preconditions of that function was satisfied. (Those preconditions should be listed in the function's documentation, which is a reason to read their docs.)

How not to do:

    let s = "world";

    // slicing a string by byte index is unsafe
    let subslice = unsafe { s.get_unchecked(1..3) };
    println!("s[1..3]: {subslice}");

How to write such comments, instead:

    let s = "world";

    // SAFETY:
    // 1. 1 <= 3, so starting index does not exceed ending index
    // 2. 3 <= s.len(), so the slice is inbounds of original string
    // 3. s is ASCII-only, so all positions are UTF-8 sequence boundaries
    let subslice = unsafe { s.get_unchecked(1..3) };
    println!("s[1..3]: {subslice}");

(This code should be simplified to safe subset of Rust though, for better compiler checks. LLVM will optimize the checks when it can.)

Thanks for the tips. I'll add more comments as I come across things I understand. Sometimes, the unsafe block is just to dereference a non null pointer, I don't know how to explain it. For the allocator thing, the documentation on it is hard to follow for beginner like me :<. So I would really appreciate technical review to see if anything is incorrect

Sorry if my English isn't good, I'm not native English speaker :<

You're welcome:

fn main() {
    const TRIGGER_64: usize = 0x0200_0000_0000_0000;
    
    let pool = StaticMultiPoolAlloc::new([128], [TRIGGER_64 + 1], [1]);
    
    #[repr(C)]
    struct BigTest {
        _pad1: std::mem::MaybeUninit<[u8; TRIGGER_64]>,
        value: [u8; 1],
    }
    
    let mut b: Box<std::mem::MaybeUninit<BigTest>,
                   &StaticMultiPoolAlloc<1>> = Box::new_uninit_in(&pool);
    if let Some(tail) = b.as_bytes_mut().last_mut() { tail.write(0x33); }
    std::hint::black_box(b);;
}

Run it in release mode and observe segmentation violation. L36's multiplication num_blocks[i] * block_size[i] overflows.

I prefer to follow approach "unsafe code is considered likely unsound until satisfaction of all invariants has been proven" over "unsafe code is correct until a bug is found", because of examples like this.

Thank you for the report. I’ve learned that this happens because the type is usize, and the result of the multiplication exceeds the maximum value a usize can hold. In debug builds, this triggers a panic that allows us to catch errors during the development phase via unit tests. In release builds, it performs a wrap around (afaik, this wrap around behavior is also present in C, C++, and Zig to enable optimizations), so it’s the best of both worlds, catching bugs in debug builds while maintaining high performance in release builds. Because of this, the allocation ends up using an invalid result, which triggers a segmentation violation

The std method requires a usize :

pub const fn from_size_align(
    size: usize,
    align: usize,
) -> Result<Layout, LayoutError>

Given this, I have no choice but to either return an error by changing the return type to Result or stop the program with a panic. For now, I've implemented a panic!, I'm writing the returning an error right now. This will allow the caller to decide whether to panic or retry, for instance, if the size value is dynamic, a retry could be handled. I have updated the multiplication to the following :

let layout: [std::alloc::Layout; N] = std::array::from_fn(|i| std::alloc::Layout::from_size_align(
    num_blocks[i].checked_mul(block_size[i])
        .expect("Integer overflow, memory size is too big in fn new -> StaticMultiPoolAllocator"),
    aligns[i]
).expect("Error in creating layout in fn new -> StaticMultiPoolAllocator"));

This approach will now trigger a panic with a custom error message in both debug and release builds to also handle size that comes from dynamic value

Are there any further issues I should be aware of?

I have finished converting all recoverable panics (in some case) to return errors

#![feature(allocator_api)]
#![feature(maybe_uninit_as_bytes)]
#![feature(array_try_from_fn)]

#[derive(Debug)]
pub enum Error {
    InvalidAlignment(&'static str),
    IntegerOverflow(&'static str),
    LayoutError(std::alloc::LayoutError),
    OutOfMemory
}

pub struct Block {
    next: Option<std::ptr::NonNull<Block>>
}

pub struct StaticMultiPoolAlloc<const N: usize> {
    start: [std::ptr::NonNull<u8>; N],
    head: [std::cell::UnsafeCell<Option<std::ptr::NonNull<Block>>>; N],
    block_size: [usize; N],
    aligns: [usize; N],
    layout: [std::alloc::Layout; N]
}

fn align_up(size: usize, align: usize) -> usize {
    (size + align - 1) & !(align - 1)
}

impl<const N: usize> StaticMultiPoolAlloc<N> {
    pub fn new(num_blocks: [usize; N], block_size: [usize; N], align_multiply: [usize; N]) -> Result<Self, Error> {
        if align_multiply.iter().any(|&val| val == 0) {
            return Err(Error::InvalidAlignment("Align multiply must be > 0"));
        }
        let block_align = std::mem::align_of::<Block>();
        let aligns: [usize; N] = align_multiply.map(|val| val * block_align);
        
        let base_block_size = std::mem::size_of::<Block>();
        let block_size: [usize; N] = std::array::from_fn(|i| {
            let size = block_size[i].max(base_block_size);
            align_up(size, block_align) 
        });
        
        let layout: [std::alloc::Layout; N] = std::array::try_from_fn(|i| std::alloc::Layout::from_size_align(
            num_blocks[i]
                .checked_mul(block_size[i])
                .ok_or(Error::IntegerOverflow("Integer overflow, each num block * block size must fit within usize, because it will make the layout incorrect due to wrap around"))?,
            aligns[i]
        ).map_err(|err| {
            Error::LayoutError(err)
        }))?;
        
        // SAFETY: the layout is valid, because if not it will trigger panic
        let starts: [std::ptr::NonNull<u8>; N] = std::array::try_from_fn(|i| {
        
        let start = unsafe { std::alloc::alloc(layout[i]) };
        let start_ptr = std::ptr::NonNull::new(start).ok_or(Error::OutOfMemory)?;
        
        unsafe {
            for j in 0..num_blocks[i] {
                let current_ptr = start.add(j * block_size[i]).cast::<Block>();
                let next_ptr = if j < num_blocks[i] - 1 {
                    Some(std::ptr::NonNull::new_unchecked(start.add((j + 1) * block_size[i]).cast::<Block>()))
                } else {
                    None
                };
                
                (*current_ptr).next = next_ptr;
            }
        }
        
        Ok(start_ptr)
        
        })?;
        
        let head: [std::cell::UnsafeCell<Option<std::ptr::NonNull<Block>>>; N] = std::array::from_fn(|i| {
            std::cell::UnsafeCell::new(Some(std::ptr::NonNull::new(starts[i].as_ptr().cast::<Block>()).unwrap()))
        });
        
        Ok(Self {
            start: starts,
            head,
            block_size,
            aligns,
            layout,
        })
        
    }
}

impl<const N: usize> Drop for StaticMultiPoolAlloc<N> {
    fn drop(&mut self) {
        // SAFETY: deallocate using the pointer. The layout is saved, so it is the correct layout
        for (i, val) in self.start.iter().enumerate() {
            unsafe {
                std::alloc::dealloc(val.as_ptr(), self.layout[i]);
            }
        }
    }
}

unsafe impl<const N: usize> std::alloc::Allocator for StaticMultiPoolAlloc<N> {
    #[inline(always)]
    fn allocate(&self, layout: std::alloc::Layout) -> Result<std::ptr::NonNull<[u8]>, std::alloc::AllocError> {
        let size = layout.size();
        let align = layout.align();
        //let base_align = std::mem::align_of::<Block>();
        
        let index = self.aligns.iter().enumerate().position(|(i, &val)| {
            size <= self.block_size[i] && align <= val
        });

        let i = index.ok_or(std::alloc::AllocError)?;
        
        let head_ptr = unsafe { &mut *self.head[i].get() };
        if let Some(block) = *head_ptr {
            let taken_block = block;
            unsafe { *head_ptr = block.as_ref().next };
            let ptr = taken_block.cast::<u8>();
            return Ok(std::ptr::NonNull::slice_from_raw_parts(
                ptr, self.block_size[i]
            ));
        }
        
        Err(std::alloc::AllocError)
    }
    
    #[inline(always)]
    unsafe fn deallocate(&self, ptr: std::ptr::NonNull<u8>, layout: std::alloc::Layout) {
        let new_block = ptr.cast::<Block>();
        let align = layout.align();
        
        let index = self.aligns.iter().enumerate().position(|(_, &val)| {
            align <= val
        });

        let i = index.ok_or(std::alloc::AllocError).unwrap();
        
        // dereference raw pointer is unsafe
        let head_ptr = unsafe { &mut *self.head[i].get() };
        unsafe { (*new_block.as_ptr()).next = *head_ptr };
        
        *head_ptr = Some(new_block);
    }
}

fn main() {
    const TRIGGER_64: usize = 0x0200_0000_0000_0000;
    
    let pool = StaticMultiPoolAlloc::new([128], [TRIGGER_64 + 1], [1]).unwrap();
    
    #[repr(C)]
    struct BigTest {
        _pad1: std::mem::MaybeUninit<[u8; TRIGGER_64]>,
        value: [u8; 1],
    }
    
    let mut b: Box<std::mem::MaybeUninit<BigTest>,
                   &StaticMultiPoolAlloc<1>> = Box::new_uninit_in(&pool);
    if let Some(tail) = b.as_bytes_mut().last_mut() { tail.write(0x33); }
    std::hint::black_box(b);
}