I'm trying to implement a free list allocator using Red Black Tree for optimize O(LogN) best fit search.
My strategy is that when a block is allocated, it's allocated with a Header where
struct Header {
std::size_t m_Size;
};
thus sizeof(Header) == sizeof(std::size_t)
This is done, so when deallocating I could know how much bytes were allocated to give it back as a free node.
Now there's a problem with this solution, because now I have to align the Header itself + the allocated block to the requested alignment, so padding in between the Header and the allocated block start, and padding between the end of the allocated block and a start of a new Header (so next block Header will be already aligned) are needed.
So to illustrate the problem better here's a Red Black Tree with nodes indicating free block sizes minus sizeof(Header)

Now let's assume a user allocate a block of size 16 with alignment of 16:
allocate(16, 16);
Now best fit will yield us node 17.
But we can't count on it, let's assume node 17 is at address 0x8 and we're on x32 so sizeof(Header) = 4.
Header's address will be from 0x8-0xC, now we would need to add a padding so our block will be aligned to 16 as requested, this padding is equal to 4 bytes so our allocated block will start at 0x10 which is aligned to 16. Now no padding is needed at the end of the block since 0x10 + 16d will be aligned to next block Header.
The padding between the end of the allocated block to the start of the new block is easy to calculate before-hand like so:
std::size_t headerPadding = size % sizeof(Header) != 0 ? sizeof(Header) - size % sizeof(Header) : 0;
So it's not dependent on the address of the free node.
But the padding between the end of the Header and the start of the allocated block IS dependent on the address of the free node like I've demonstrated.
And for our example the total needed size in the case of this specific node will be 4 (padding between Header and allocated block) + 16 (allocated block size) + 0 (padding needed for next free block header alignment) = 20.
Obviously node 17 doesn't match.
Now my strategy to fix this is as follows:
- Find best fit
- See if best fit matches the size requirements as described
- If yes we're done
- If not get its successor and check if it matches the size requirements as described
- If yes we're done
- If not start over from successor parent until we reach a node that satisfy size requirements or we reach the original best fit again
Here's a code describing the process:
void FreeTreeAllocator::Find(const std::size_t size, const std::size_t alignment, std::size_t& sizePadding, std::size_t& headerPadding, RBTree::Node*& curr)
{
headerPadding = size % sizeof(Header) != 0 ? sizeof(Header) - size % sizeof(Header) : 0;
RBTree::Node* best = m_Tree.SearchBest(m_Tree.m_Root, size + headerPadding);
RBTree::Node* origin = best;
std::vector<std::size_t> visited;
while (visited[visited.size() - 1] != (std::size_t)origin && !IsNodeBigEnough(size, alignment, sizePadding, headerPadding, best))
{
RBTree::Node* successor = m_Tree.Successor(best);
if (IsNodeBigEnough(size, alignment, sizePadding, headerPadding, successor))
{
best = successor;
break;
}
else
{
std::vector<std::size_t>::iterator it;
do {
best = successor->m_Parent;
it = std::find(visited.begin(), visited.end(), (std::size_t)best);
} while (it != visited.end());
}
visited.push_back((std::size_t)best);
}
}
bool FreeTreeAllocator::IsNodeBigEnough(const std::size_t size, const std::size_t alignment, std::size_t& sizePadding, std::size_t& headerPadding, RBTree::Node* curr)
{
if (curr == m_Tree.m_Nil)
return false;
void* currentAddress = reinterpret_cast<char*>(curr) + sizeof(Header);
std::size_t space = curr->m_Value;
std::align(alignment, size, currentAddress, space);
sizePadding = reinterpret_cast<char*>(currentAddress) - reinterpret_cast<char*>(curr) - sizeof(Header);
return sizePadding + size + headerPadding <= curr->m_Value;
}
Now for the given allocation request:
allocate(16, 16);
and the given example tree from the picture the following the algorithm described, the search path will be:
17 -> 21 -> 22 -> 23 -> 25 -> 27
At worst case this is O(LogN + M) where M is the size of the right sub-tree of the original best fit node.
Now one way this could be solved if I make sizeof(Header) = sizeof(std::max_align_t), this way the padding between the Header and the start of the allocated block will always be 0, so we won't need this padding anymore because every request will be aligned without this padding, so we could really just do:
void FreeTreeAllocator::Find(const std::size_t size, std::size_t& headerPadding, RBTree::Node*& curr)
{
headerPadding = size % sizeof(Header) != 0 ? sizeof(Header) - size % sizeof(Header) : 0;
RBTree::Node* best = m_Tree.SearchBest(m_Tree.m_Root, size + headerPadding);
return best;
But that will waste alot of memory compared to my proposed idea where I settle on O(LogN + M) best fit search.
Now why do I ask it?
Because I see the use of Red Black Tree as optimization for free list allocator to reduce best fit search to O(LogN) while I can't seem to make it really O(LogN), the flaw with my design I guess is that there need to be a Header for book-keeping on how much bytes to give back to a free block when deallocating, and I don't see a way to do it without this, if I was able to not have a Header at all, or make it so I don't have issues with it's alignment to find padding specific for a node in the list (by making sizeof(Header) = sizeof(std::max_align_t), or even sizeof(Header) = 1) then this could be solved with simple O(LogN) search.
I'm looking for ideas on how to solve this issue, how other implementations are doing this in O(LogN) while keeping inner fragmentation as low as possible?
UPDATE:
I ended up having the node addresses aligned to alignof(std::max_align_t) - sizeof(Header) so that makes Header always aligned no matter if you're on x32/x64 (remember that Header is composed of sizeof(std::size_t)), and no matter if alignof(std::max_align_t) is 8 or 16.
That makes the allocated payload to start at address that is aligned to alignof(std::max_align_t) just like malloc, so no matter what is allocated it will always be aligned to the max alignment and no padding is required between Header and payload.
The only padding required is after payload to match the next address that is aligned to alignof(std::max_align_t) - sizeof(Header) + any padding required to make it so the allocated block is at least sizeof(RBTree::Node) bytes big (including sizeof(Header) inside the equation) so when deallocation we could safely store an RBTree::Node without overriding other data.
Without the padding between Header and the payload, and with the padding needed to align next block to alignof(std::max_align_t) - sizeof(Header) we can easily use the default RBTree::Search of O(LogN) since the padding can be calculated beforehand depending on the size of the block and removing from the equation the start address of a particular node.
The only remaining problem I have with optimizing this free list allocator to O(LogN) is the deallocation part, more precisely the coalescence part.
What I can't solve now is how to do O(1) coalescence. I rearranged the RBTree::Node struct such as that m_Parent is first and so its LSB is always set 1 (for every function relying on m_Parent I have a getter function for fixing it) and then I can check if next block of current deallocated block (we can get to next block with size from Header) if the first sizeof(std::size_t) bytes & 0x1 is true and if so it's a free node, if not it's a busy block with Header (since Header's m_Size LSB will always be 0 because we add padding for alignment with std::max_align_t).
The remaining problem is how to get to the previous memory block and know if it's free or busy, that I cannot figure yet and would love to hear suggestions.