Memory 0.6: Composition and Joint Allocators
If you’ve been a long reader of my blog, you might remember my memory library. I haven’t forgotten about it, even though the 0.5 release was in February! After three patches and a long pause in development to focus on standardese, I’ve finally finished the 0.6 release. It mainly provides two major features: composition and joint allocators.
foonathan/memory is a library providing various memory allocators and adapter classes.
Those allocators use a new RawAllocator
concept that is simpler than STL’s Allocator
and allows better control over the allocation aspect.
Adapters and traits ensure compatibility with the existing model,
allowing usage in STL or other containers.
Composition
Andrei’s talk made the idea of compositioning allocators quite popular. He proposed a library where you have many allocator “building blocks” and you can plug them together to make powerful combinations.
Thanks to my BlockAllocator
concept - check out the 0.5 release post or my Meeting C++ talk for infos about it, you can already combine some allocators.
For example, you can use my virtual_block_allocator
to create a memory_stack
that is virtual memory aware.
But this is not the kind of composition he described.
In his library he could, for example, wrote a fallback_allocator
.
It is an adapter that takes two allocators.
It first tries the first one and if that fails,
it uses the second allocator.
But if the allocation of a RawAllocator
fails,
it must not return nullptr
.
So checking whether it failed will boil down to catching the exception it throws instead.
This is slow (and only works when the library is compiled with exception support), but there is an even bigger problem:
The deallocation.
It must know from which allocator the memory came and deallocate it there.
This is not supported for the current RawAllocator
,
because it can’t be supported for all allocators:
For new_allocator
- a wrapper over ::operator new
,
how can it detect whether the memory was allocated by it in the deallocation?
Instead, I’ve added a new concept, a composable RawAllocator
.
This is a RawAllocator
that also provides try_allocate_node/array
and try_deallocate_node/array
functions.
The try allocation functions return nullptr
on failure,
instead of throwing an exception/aborting/…
The try deallocate function checks whether the memory came from the allocation,
and only deallocates it if it did.
It returns true
if it could deallocate,
false
otherwise.
All allocators that can be composable are now composable.
This allows implementing the fallback_operator
:
void* fallback_allocator::allocate_node(std::size_t size, std::size_t alignment)
{
// first try default
auto ptr = get_default_allocator()
.try_allocate_node(size, alignment);
if (!ptr)
// default was not successful
// this is not composable, so guaranteed to be succesful
ptr = get_fallback_allocator()
.allocate_node(size, alignment);
return ptr;
}
void fallback_allocator::deallocate_node(void* ptr,
std::size_t size, std::size_t alignment) noexcept
{
// first try default
auto res = get_default_allocator()
.try_deallocate_node(ptr,
size, alignment);
if (!res)
// could not be allocated by default
get_fallback_allocator()
.deallocate_node(ptr, size, alignment);
}
The actual implementation uses the new
composable_allocator_traits
instead of directly calling the member functions.
In addition to fallback_allocator
, I’ve also implemented segregator
.
segregator
does not require a composable allocator, onlyfallback_allocator
does that.
That is an allocator adapter taking one or more Segregatable
s and a RawAllocator
.
A Segregatable
is a simple class that owns an allocator and can decide for each allocation whether this allocator should be used.
The most basic Segregatable
is the threshold_segregatable
.
It handles allocation up to a given max size.
The segregator
now ask each Segregatable
in turn if it wants that allocation.
It uses the first one that does.
If no Segregatable
wants it, it uses the RawAllocator
for the allocation:
auto seg = memory::make_segregator(memory::threshold(16u, std::move(small_alloc)),
memory::threshold(128u, std::move(medium_alloc)),
std::move(big_alloc));
seg.allocate_node(8, 4); // uses small_alloc
seg.allocate_node(32, 8); // uses medium alloc
seg.allocate_node(4_KiB, 8); // uses big_alloc
threshold()
is a simple function that creates athreshold_segregatable
.
I’ve also added the null_allocator
: The allocator that allocates nothing,
where every call results in an exception.
It is useful for segregator
: Pass it as final RawAllocator
to ensure that at least some Segregatable
handles it.
Joint memory allocations
I’ve also added facilities for joint memory allocations inspired by this great post. Consider the following type:
struct my_type
{
std::string str;
std::vector<int> vec;
my_type(const char* name)
: str(name), vec({1, 2, 3, 4, 5})
{}
};
Now consider what happens when you dynamically allocate it:
The constructor of std::string
and std::vector
will (“might” for you pedantic people) also allocate dynamic memory.
Even if you use an allocator for the dynamic allocation,
it still does two more!
This is where joint allocations become useful. The idea is that you allocate a bigger memory block than needed for the object itself, and use the additional memory - the “joint memory” - for the dynamic allocation of the members.
With the facilities I’ve implemented in memory, this is very easy:
struct my_type : memory::joint_type<my_type>
{
memory::string<memory::joint_allocator> str;
memory::joint_array<int> vec;
my_type(memory::joint tag, const char* name)
: memory::joint_type<my_type>(tag),
str(name, *this),
vec({1, 2, 3, 4, 5}, *this)
{}
};
We have to change my_type
for it though.
The first thing to do is to inherit from memory::joint_type
.
This base will insert two pointers for managing the joint memory.
Then each member with dynamic allocations must use the joint_allocator
in order to use the joint memory.
joint_allocator
is a RawAllocator
that will use the joint memory of a given object for dynamic memory allocation.
In this case we use it with std::string
.
memory::string<RawAllocator>
is just a template alias forstd::basic_string<char, std::char_traits<char>, memory::std_allocator<char, RawAllocator>>
. It is not a new string type or anything like that.
Because the memory::joint_allocator
has a bit of overhead - an additional pointer to be precise,
there is also memory::joint_array<T>
.
This is a dynamic fixed size array, i.e. a std::vector<T>
that cannot grow.
It is designed to use joint memory and has no overhead.
All constructors for the joint type must also take an object of memory::joint
as first parameter.
This object has two jobs:
First, it can only be created by friend
s,
so it prohibits accidental creation of joint types without joint memory.
Second, it contains meta data about the joint memory and needs to be passed to the joint_type
.
Because of the custom allocators,
we have to pass an allocator to the objects.
This is simple *this
, the object with the joint memory.
It is very important that you always pass
*this
as allocator, not some other object.
In order to create a joint type we use the allocate_joint
function:
auto ptr = memory::allocate_joint<my_type>
(memory::default_allocator{},
memory::joint_size(…),
"joint!");
std::cout << ptr->str << '\n';
for (auto& el : *ptr)
std::cout << el << ' ';
std::cout << '\n';
The function takes the allocator used for the - single! - allocation, the size of the joint memory and additional arguments passed to the types constructor.
The size has the type memory::joint_size
which is explicitly convertible from a std::size_t
.
The only downside with joint memory is the manual calculation of the size beforehand.
When doing so, one also has to keep alignment buffers in mind.
If the size isn’t enough, it will throw an exception.
The return type of allocate_joint
is memory::joint_ptr<T, RawAllocator>
.
It behaves similar to std::unique_ptr<T>
, but owns the entire joint memory block and will deallocate it when it goes out of scope.
For more information, check out the example.
About Allocator propagation
In my first real blog post I’ve talked about how the STL Allocator
model has these propagate_on_XXX
typedefs.
These control whether the allocator will be copy/move assigned/swapped when the container is copy/move assigned/swapped.
The select_on_container_copy_construction()
member function controls what happens on container copy construction,
move construction cannot be customized.
In that post I said that the defaults of no propagation are bad, as they can lead to performance pessimization, undefined and un-intuitive behavior. I proposed that you should always change the defaults so container assignment will also assign the allocator.
After the blog post I got an e-mail from Alisdair Meredith who designed that part of the allocator model. He explained the reasons behind the choices, mainly because of containers where the allocator is shared with the members. I wrote more about it in this blog post. I wasn’t quite convinced why this was necessary, but didn’t run into the situation myself, so I did not comment it further.
But with the joint allocations, I did run into the situation. Consider what happens when we have two joint objects and assign them:
auto a = memory::allocate_joint<my_type>(…);
auto b = memory::allocate_joint<my_type>(…);
*a = *b;
This will assign all members, so also the str
container.
str
uses a joint_allocator
inside the std_allocator
adapter that allows using RawAllocator
s in STL containers.
The default propagation choice inside the std_allocator
is always propagate containers,
which was the guideline I made in the original post.
So the assignment operator of the container will assign the allocator from a->str
to the allocator used by b->str
.
The str
object from a
will use the allocator using joint memory from b
!
b
might not have enough memory to start with,
but imagine b
getting destroyed before a
.
This will also destroy b
s memory, so a
now uses destroyed memory.
This is bad, so propagation isn’t the right choice here. We don’t want that the allocator gets assigned when the container gets assigned - similar for swap. As swapping two containers with unequal allocators is undefined behavior, this forbids swaps between containers of different joint memory, only swapping between members of a joint object is allowed.
The same issue exists with copy construction.
If we write the copy constructor of my_type
like so:
my_type(memory::joint tag, const joint_type& other)
: memory::joint_type<my_type>(tag),
str(other.str),
vec(other.vec)
{}
Remember, each constructor must take
memory::joint
as first parameter.
str
will copy the allocator from other.str
,
so it will use the joint memory from other
instead of *this
.
You have to use the copy constructor version that takes an allocator:
str(other.str, *this) // copy construct str using *this as allocator
Luckily, copy construction calls select_on_container_copy_construction()
,
so by putting a static_assert()
inside there we can stop this code from compiling.
Sadly, there is no select_on_container_move_construction()
,
so you have to watch out there.
memory::joint_array
does not provide the regular copy/move constructors, so is safe to use.
In order to control the propagation behavior by the std_allocator
,
I’ve put the default behavior into the propagation_traits
.
They can be specialized for own RawAllocator
s and control the propagation behavior of std_allocator
.
Minor features
In addition to those two major features, I’ve implemented a couple of smaller ones.
Block size literals
If you’re using any arena allocator (like memory::memory_pool
, memory::memory_stack
,…),
you often create them like so:
memory::memory_pool<> pool(16, 4096);
The 4096
is the initial size of the arena, so 4KiB.
For convenience, I’ve added user defined literals for those,
so now you can write:
using namespace memory::literals;
memory::memory_pool<> pool(16, 4_KiB);
The header memory_arena.hpp
now provides user defined literals for KiB, MiB and GiB going multiple of 1024
and KB, MB and GB going multiple of 1000
.
They simple return a std::size_t
.
temporary_allocator
improvements
The temporary_allocator
is a facility for temporary allocations.
It uses a global, thread-local stack to allow fast allocations.
In this update the stack became public as temporary_stack
and the creation can now be controlled.
The macro FOONATHAN_MEMORY_TEMPORARY_STACK_MODE
can be set two 0
, 1
or 2
.
0
means that there will not be any stack created automatically,
you have to crate a temporary_stack
object yourself in a top-level function and pass that down.
With 1
there is one per-thread stack available by calling get_temporary_stack()
,
but it will not be destroyed automatically.
For that you have to use the temporary_stack_initializer
class,
create on object in a top-level function,
the destructor will destroy the stack.
And with 2
the stack will be destroyed automagically, but with a slight runtime overhead.
You can still use temporary_stack_initializer
though,
but it is not required anymore.
Stack allocator additions
I’ve added memory_stack_raii_unwind
which does exactly what you think it does,
as well as iteration_allocator
.
iteration_allocator
is designed if you do many allocations in a loop,
where each allocation must live for N
iterations and can then be destroyed.
This is a generalization of the double-frame allocator.
It consists of N
memory stacks internally and switches between them on each iteration.
If it cycles back to a stack, it will clear it and free all it’s memory:
// creates it with 2 stacks,
// each one using 2KiB memory
memory::iteration_allocator<2> alloc(4_KiB);
while (…)
{
auto mem = alloc.allocate(…);
// mem now lives for two iterations
…
// switch stacks
alloc.next_iteration();
}
Conclusion
This update also comes with OS X support and many bugfixes.
The documentation is currently still using Doxygen, but as standardese, is almost at a point where I can use it, I will soon transfer it over and also improve the documentation.
In the meantime, you can also check out the slides for my Meeting C++ talk about it and try the library.
The next update will probably tackle per-thread allocators and will most likely be the last 0.x
version.
As always: I appreciate any feedback, feature requests, etc., so feel free to contact me!
This blog post was written for my old blog design and ported over. If there are any issues, please let me know.