Blog series
- Part 1: Introduction & Allocators
- Part 2: Unq, an allocator-aware Box
- Part 3: Containers
- Part 4: Generating Vulkan bindings
Welcome to Handmade Rust, a series (hopefully) where I will be developing a Vulkan rendering engine in Rust the Handmade way. By this, I mean using no external libraries, not even the Rust standard library, only the core lib. I am doing this mainly for my own enjoyment but also because I want to get better at writing Rust, and sometimes the best way to really understand something is to just do it yourself. The project will be available on GitHub at stevenlr/HandmadeRust.
The first step will be to build a foundation library for memory allocation, containers, and other utilities that are not provided by the core lib.
The Allocator
trait
Let’s begin with memory allocation. We’ll need to somehow be able to acquire memory. Usually this is done using some kind of global allocator (think malloc
, the new
keyword, etc.). However for performance reasons, it is really important to be able to control how memory is allocated, this justifies writing specialized allocators for different tasks. For instance you can have a global allocator which can be slow but general purpose, or a temporary allocator for when you need small allocations very quickly, or a composable allocator that uses different underlying allocators depending on the requested size. This is quite different from how most languages choose to do dynamic memory allocation, where nobody cares when and how the memory is allocated.
First of all we’ll need a Layout
that defines the size and alignment of the memory we want to allocate. We’ll also define the Allocator
trait, which is going to be underwhelmingly unsurprising.
pub struct Layout
{
pub size: usize,
pub align: usize,
}
impl Layout
{
pub fn new(size: usize) -> Self
{
Self
{
size,
align: 4,
}
}
pub fn from_type<T>() -> Self
{
Self
{
size: size_of::<T>(),
align: align_of::<T>(),
}
}
}
pub trait Allocator
{
unsafe fn alloc(&mut self, layout: Layout) -> Option<*mut c_void>;
unsafe fn dealloc(&mut self, ptr: *mut c_void);
}
Implementing an allocator
Usually a container that is allocation-aware will be defined like this:
struct Container<A: Allocator>
{
alloc: A,
// ...
}
Now an issue arises from the fact that the alloc
and dealloc
methods from the Allocator
trait use mutable references to self
: in Rust, there can either be one mutable reference to an object or many immutable references. In other words you have to choose between mutability and aliasing. In the current configuration, this means that we have to create one allocator per container, which kind of defeats the purpose of having them is the first place.
The solution here is to implement the Allocator
trait for an immutable reference and then use interior mutabilty when necessary. Then the A
type parameter from our previous type can be &MyAllocator
and problem solved!
impl Allocator for &MyAllocator
{
// ...
}
fn foo()
{
let my_allocator = MyAllocator::new();
let container1 = Container::new(&my_allocator);
let container2 = Container::new(&my_allocator);
}
You can see the example allocator I implemented using Windows’ HeapAlloc
and HeapFree
there: fnd::alloc::Win32HeapAllocator.
Aligned memory
It is often desirable to request aligned memory. For this, we can provide two convenience methods in the Allocator
trait.
impl Layout
{
// ...
pub fn align_up(&self, i: usize) -> usize
{
let p = i + self.align - 1;
return p - (p % self.align);
}
}
pub trait Allocator
{
// ...
unsafe fn alloc_aligned(&mut self, layout: Layout) -> Option<*mut c_void>
{
let actual_size = layout.size + layout.align - 1 + size_of::<usize>();
let ptr = match self.alloc(Layout::new(actual_size))
{
Some(p) => p as usize,
None => return None,
};
let aligned_ptr = layout.align_up(ptr + size_of::<usize>());
let actual_ptr_ptr = aligned_ptr - size_of::<usize>();
write_unaligned(actual_ptr_ptr as *mut usize, ptr);
Some(aligned_ptr as *mut c_void)
}
unsafe fn dealloc_aligned(&mut self, ptr: *mut c_void)
{
let aligned_ptr = ptr as usize;
let actual_ptr_ptr = aligned_ptr - size_of::<usize>();
let actual_ptr = read_unaligned(actual_ptr_ptr as *const usize);
self.dealloc(actual_ptr as *mut c_void);
}
The way this works is by allocating excess memory: We know that by allocating an excess of align - 1
bytes, we’ll be able to properly align the returned pointer, and still have enough room to fit the size that was requested.
However when deallocating, we have to remember what was the actual pointer that was returned by the allocation in order to deallocate it. To do this, we also allocate an excess of 8 bytes (the size of usize
in a 64 bits environment) so we can fit the pointer to the original memory right before the returned aligned pointer.
In the above (wonderful) drawing, the user requests a memory space of size size
. We allocate size + 8 + align - 1
bytes (the entire black rectangle). Its address is ptr
. We offset it by 8 and align this up to align
, which gives us aligned ptr
(red). This is the pointer we return to the user. Finally we offset this aligned pointer by -8 bytes and write ptr
there.
To deallocate we simply offset the pointer by -8 bytes which gives us the address of ptr
. We read that and this gives us the pointer we have to deallocate.
Wrap up
That’s it for today. If you have any question or remark about this post, you can tweet at me at @steven_lr. Next time we’ll work on a Box
replacement using our Allocator
trait.