Quant developer HFT course · Module 3: Containers and memory
Allocators, pmr and object pools
The question
You have removed the obvious allocations from the hot path, but you still need to create orders and short-lived message objects at a high rate. How do you allocate them without calling malloc?
Three tools
Low-latency code uses three patterns to get memory without a general-purpose allocator on the hot path. Each is built on the same move: take one large block at start-up, then hand out pieces of it with an operation that is a few instructions.
An object pool holds a fixed number of objects of one type. Free slots form a linked list threaded through their own storage, so acquiring an object pops the head and releasing pushes it back.
An object pool handing out and taking back slots.
-
Step 1 / 4
Start-up: six slots, all free, linked through their own storage. The head points at slot 0.
-
Step 2 / 4
acquire twice: each call pops the head. Orders A and B take slots 0 and 1; no call to the heap.
-
Step 3 / 4
release(A): slot 0 is pushed back on the front of the free list. Constant time again.
-
Step 4 / 4
acquire: the head is slot 0, so order C reuses it while it is still warm in cache.
template <class T, std::size_t N>
class Pool {
union Slot { Slot* next; alignas(T) std::byte obj[sizeof(T)]; };
std::array<Slot, N> slots_;
Slot* free_ = nullptr;
public:
Pool() { for (auto& s : slots_) { s.next = free_; free_ = &s; } }
template <class... A> T* acquire(A&&... a) {
if (!free_) return nullptr; // full: the caller decides what to do
Slot* s = free_; free_ = s->next;
return ::new (s->obj) T(std::forward<A>(a)...);
}
void release(T* p) {
p->~T();
Slot* s = reinterpret_cast<Slot*>(p); s->next = free_; free_ = s;
}
};
Acquire and release are constant time and never touch the heap. All objects sit in one array, so they share cache lines and pages that stay warm. The cost is a fixed capacity, chosen up front, and a policy for running out: reject the order, fall back to the heap and raise an alert, or size it so it cannot happen.
An arena (also called a bump or monotonic allocator) hands out memory by advancing a pointer through a block, and frees everything at once by resetting the pointer. It suits objects that all die together: everything allocated while handling one message, or one batch.
class Arena {
std::byte* base_; std::size_t cap_, used_ = 0;
public:
void* alloc(std::size_t n, std::size_t align) {
std::size_t p = (used_ + align - 1) & ~(align - 1);
if (p + n > cap_) return nullptr;
used_ = p + n;
return base_ + p;
}
void reset() { used_ = 0; } // frees everything; destructors are your job
};
A size-class allocator keeps a free list per size range, like a general allocator but without locks and owned by one thread. Use it when object types vary but stay on one thread.
std::pmr
Standard containers take an allocator as a template parameter, which historically meant a different container type for every allocator. C++17's polymorphic memory resources fix that: std::pmr::vector<T> is a std::vector whose allocator calls a std::pmr::memory_resource through a virtual function, chosen at run time.
std::array<std::byte, 64 * 1024> buf;
std::pmr::monotonic_buffer_resource arena(buf.data(), buf.size(),
std::pmr::null_memory_resource()); // never touches the heap
std::pmr::vector<Fill> fills(&arena);
std::pmr::string note("partial fill", &arena);
The library provides monotonic_buffer_resource (an arena), unsynchronized_pool_resource (size-class pools for one thread), synchronized_pool_resource (the same with locking), and null_memory_resource as an upstream that throws instead of falling back to the heap, so an overflow fails loudly in testing.
The virtual call per allocation is a small price; the benefit is that existing code using standard containers can be moved onto an arena by changing the resource, not the types.
What you give up
- Fixed capacity, for pools and arenas, which turns running out into a design decision.
- Per-object freeing with an arena: nothing is reclaimed until reset, so a long-lived object allocated from a per-message arena dangles after the reset.
- Thread confinement. These allocators are fast because one thread owns them. An object released on a different thread from the one that acquired it needs a handoff, usually by sending it back through a queue.
- Destructors. An arena reset frees memory without running destructors. That is fine for trivially destructible types and a leak for anything that owns other memory.
Check yourself
Question 1 of 3
What makes a free-list pool's acquire constant time?
The free list costs no extra memory and no search: the head is the next free slot.
Question 2 of 3
When is a monotonic (arena) resource a good fit?
An arena only frees everything at once. It is ideal for batch lifetimes and wrong for objects with individual lifetimes.
Question 3 of 3
Why pass std::pmr::null_memory_resource() as the upstream of a monotonic buffer?
By default a monotonic resource asks its upstream, the global heap, for more memory. A null upstream turns that into an error you can catch in testing.
In the interview
Say your own answer out loud first, then open the one we would give.
How do you avoid malloc on the hot path while still creating objects?
Preallocate at start-up and hand out pieces cheaply: an object pool with a free list for fixed-type objects, an arena that is reset per message or batch for short-lived ones, or per-thread size-class pools. std::pmr lets standard containers use those resources without changing their types.
What is std::pmr?
Polymorphic memory resources from C++17: allocators that delegate to a memory_resource object through a virtual call, so containers with different allocation strategies share one type. The standard ones include a monotonic buffer and single-threaded and synchronised pools.
What are the risks of custom allocators?
Fixed capacity that must be planned for, objects outliving an arena reset, frees on the wrong thread, and destructors not running on reset. I would add capacity alarms and run the tests with sanitizers and with the null upstream so overflows fail loudly.