The post continues the series about how the coherent-rpmalloc generic C++ memory allocator works. If you haven’t read part 1 and part 2, please do before continuing, as they are an integral part in understanding the implementations described below.
The algorithms in part 2 are relatively simple, but have some tricky moments. I’m going to form their descriptions as Q&A.
Q: How does a simple pointer in void rpfree(void* pointer) know which span it belongs to, so that the algorithm can get to the meta-data needed to handle the de-allocation?
A: This is one of the keys to the great performance of the allocator and a very clever thing that Mattias Jansson, the original rpmalloc creator, did. In his implementation every span has to be 64KB aligned. This means that from every pointer allocated by the library, we can get the span it belongs to by just masking a bunch of bits. This is extremely fast. Some allocators achieve this by putting meta-data in front of every allocation, but that increases significantly the memory overhead. The only down-side is that it requires the embedder to always pass to the library 64KB-aligned memory. Rpmalloc can get away with this, because it directly uses the OS to get memory, VirtualAlloc, already returns 64KB-aligned addresses, while on POSIX systems it’s also relatively easy to map memory that way.
This is very inconvenient for us as we would put a heavy burden to games that use Hummingbird, so we had to get rid of the requirement. To put in perspective, if we request from the client 64KB, with a 64KB alignment, at worst the embedding developer will have to get 128KB just to fulfill the alignment requirement – a huge waste.
Q: How does coherent-rpmalloc remove the need for 64KB-aligned memory, while keeping the performance?
A: Basically the problem boils down to:
- From an allocated pointer (a block), how do we get the address of the span that allocated it, so that we can reach the meta-data and de-allocate it?
At first I tried different techniques to remove the requirement. The first one was looking at other implementations:
- Some allocators put meta-data in front of every block. This would waste a lot of memory for small allocations and pollute the CPU cache with unnecessary data when used.
- tcmalloc, which was rpmalloc’s inspiration, uses a radix-tree to associate each allocation with a pointer to its’ owning structure. However for 64-bit pointers that radix-tree can potentially grow up to ~4MB. Hummingbird as a whole seldom uses more than 10MB, so wasting 4MB just on allocator bookkeeping was out of the question.
- I rolled my own sorted skip-list that associates each allocation to the span it did it. A skip-list can be made lock-free, but my tests even on a single thread showed a significant slow-down compared to bit-masking. While the search has very good big-O complexity, the performance was poor, because very often involved CPU cache misses. While it was possible to squeeze my skip-list in some contiguous memory piece to improve cache coherency, I ultimately decided that the increase in complexity was too large and the results not guaranteed.
In the end I decided that keeping the span-alignment trick is the best thing, but I had to amortize the risk of wasted memory.
What if instead of requesting from the user spans, like rpmalloc does, we request an array of spans? This is why segments got introduced.
Our spans are 8KB and need 8KB alignment for the bit-masking to work.
- If we request individual spans, like rpmalloc does, then 8KB with 8KB alignment would at worst need a 16KB “real” allocation to fulfill the alignement – a 50% waste
- If we request 32 spans from the user, that is 256KB with an 8KB alignment, then the possible waste is only ~3.03%. Much better.
This is how segments were born. We only request and return to the user whole segments of 256KB.
This has multiple good upsides:
- Drastically reduces the risk of memory waste
- Reduces the communication between the library and the embedder – if their allocator is slow, it’ll have almost no effect over Hummingbird.
- Slightly improves cache locality
And some downsides:
- Segments have to be managed and be performant
- There is a risk of memory waste in degenerate cases. As we only return whole segments to the user, we need to have a segment completely free before we return it, even a single allocation can keep it alive. In practice our workload does not hit such cases, but is something to keep in mind.
Q: How are span caches & segments handled?
A: rpmalloc has multiple levels of span caches. The per-thread ones are pretty trivial because they are touched only by a single thread that owns them, so no synchronization is needed to add/remove spans from the local cache.
The global span cache is protected by a spin lock allowing adding/removing spans from multiple threads.
Segments are always shared across multiple threads. All segments are put in a linked list and each segment has 32 spans inside. The algorithm to “get” a fresh span is relatively simple:
- Walk the list of segments and look for a free span:
- Each segment has an “unsigned int”, which is used as a bit-mask and indicates, which “slot” is free of the 32 spans in the segment. Updating it in a lock-free way is very easy, just do a CAS operation on the word by trying to flip the bit we want to mark as used or free.
- If there are no free spans in any segment
- Allocate a new segment by requesting memory from the user
- Adding/removing segments from the linked list is done through a readers-writer lock
As segments are iterated much more often from multiple threads than they are added/removed, it is really important to have very good read performance on the list. I implemented it with a readers-writer lock.
A single “unsigned int” is used as the lock primitive – one bit is reserved for the “write” lock, while all the rest are used as counter that holds the number of readers currently holding the lock.
- When a reader wants to enter the locked section (iterate the linked list), it tries to CAS the unsigned lock to [0 | READERS + 1], where readers is the lock value with the write bit masked.
- Releasing the read lock will just try to CAS to [READERS – 1]
- The writer will try to CAS the lock to “1”, which means the lock is taken for writing. The operation requires a reference value of “0”, which means that nobody is reading or writing the linked-list.
- Releasing the write lock just involves setting the lock to “0”
Q: How do you support threads whose lifetime can’t control, when there is thread-local data involved?
A: The original rpmalloc has two functions “rpmalloc_thread_initialize” and “rpmalloc_thread_finalize” that need to be called on each thread before using the allocator and after you’re done with it. They set and later clear the thread-local heap object.
Unfortunately for our middleware software this solution was no good. Some game engines – for instance Unreal Engine will move their workload across multiple new threads. The prime example is their rendering thread, which actually changes from time to time without notifying plugins. If we were to use the default rpmalloc way – we’d have the risk of “leaking” heaps. If UE4 destroys a thread where we have initialized a heap and we are not notified, that heap will leak.
Coherent-rpmalloc solves this by having a pool of heaps that are re-assigned to threads working with the library dynamically. All entry points in the Hummingbird library are already marked within our code. When the user calls any method, if the thread-local data is not set for that thread, then a heap is assigned to it. When code execution leaves the library, that heap is again marked as reusable. We have a fixed amount of threads that can run simultaneously, so there is no risk of leaks. Each thread holds a preferred heap, which is used so that it gets all the time the same heap when it enters the library. Only when a new thread comes into play, will it eventually take a heap of another thread. The old thread has probably been destroyed outside the knowledge of the library.
Q: Why did you move the library to C++?
A: The original rpmalloc uses some inline assembly and platform-specific macros for thread-local data and atomic operations. I really wanted to move this to standard C/C++, because I also needed to support additional platforms like game consoles. It would’ve been great to move to C11, but it’s not supported on all compilers we target, so I went for C++11. All atomic operations use standard primitives, while thread-local storage uses pthread on platforms that have no thread_local support.
This pretty much sums-up the most interesting parts of the allocator we crafted. If you have additional questions or suggestions, please don’t hesitate to comment.