Skip to content
Work Practice Products Coding interviews Machine learning coding Quant developer HFT course Blog Method Why me About Enquire Book a call

Quant developer HFT course · Module 3: Containers and memory

Allocators, pmr and object pools

Lesson · 11 min

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.

  1. Step 1 / 4
    free headfreeslot 0freeslot 1freeslot 2freeslot 3freeslot 4freeslot 5

    Start-up: six slots, all free, linked through their own storage. The head points at slot 0.

  2. Step 2 / 4
    free headorder Aslot 0order Bslot 1freeslot 2freeslot 3freeslot 4freeslot 5

    acquire twice: each call pops the head. Orders A and B take slots 0 and 1; no call to the heap.

  3. Step 3 / 4
    free headfreeslot 0order Bslot 1freeslot 2freeslot 3freeslot 4freeslot 5

    release(A): slot 0 is pushed back on the front of the free list. Constant time again.

  4. Step 4 / 4
    free headorder Cslot 0order Bslot 1freeslot 2freeslot 3freeslot 4freeslot 5

    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?

Question 2 of 3

When is a monotonic (arena) resource a good fit?

Question 3 of 3

Why pass std::pmr::null_memory_resource() as the upstream of a monotonic buffer?

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.