WEEK 4 · 15-WEEK TRACK · SESSIONS 3 & 6 · 9-SESSION TRACK

Custom Allocators, Memory Pools & Runtime Polymorphism

Week 3 taught you to own a lifetime. This week you own the memory itself. malloc is a correct, thread-safe, general-purpose service and it is the single largest source of unbounded jitter in a tick-to-trade path — so you take it out and put a pointer swap in its place. Then you put polymorphic objects in those pools and price the one thing that makes them polymorphic: the virtual call.

Lab: make pool — four tests green, then METRIC|pool_ns_per_op HW4: include/pool.hpp, autograded on ns/alloc Project Phase 1: no new on the hot path
Part 1

1What is it? An allocator you sized before the session opened

A custom allocator is not a faster malloc. It is a different contract: you give up generality — arbitrary sizes, unbounded growth, thread safety you did not ask for — and in exchange you get a cost that is the same on every single call. In a system graded on p99.9, a predictable 4 ns beats an average of 22 ns every time.

What the general-purpose heap has to do

malloc serves every size, from every thread, forever. That forces four things into your call: a size-class lookup, a free-list walk that lengthens as the heap fragments, a lock (or a per-thread cache that can miss and then take one), and — when the arena is exhausted — a syscall (mmap/brk) that leaves user space entirely.

None of those four is a bug. Each is the correct implementation of a promise you never needed. The fast path is genuinely fast; it is the distribution that is unusable.

What the hot path needs instead

  • O(1), always. A pointer bump or a free-list pop. No search, no coalescing, no fit.
  • No locks. One pool per thread, so there is nothing to contend for.
  • No syscalls. All memory is reserved before the session opens.
  • No fragmentation. Every slot is the same size, so a freed slot always fits the next request.
  • Bounded. You size it for the worst tick, and you decide what happens past that.

That last one is the part students skip. A pool that silently falls back to new when it is full has all the complexity of a pool and none of the guarantee.

Interactive · the malloc latency distribution

20,000 simulated malloc calls, drawn on a logarithmic axis. Move the fragmentation slider and watch the mean barely move while p99.9 walks two decades to the right. The violet marker is the pool you build in lab — it does not have a tail to walk.

Mean
—
p50
—
p99
—
p99.9
—
Max
—
p99.9 / p50
—
samples mean p99 p99.9 pool (3.7 ns, measured)

Heap conditions

Where the tail comes from

Every row is a real code path inside a production allocator. You cannot tune them away — you can only stop calling them.

The habit. Nobody in this course is impressed by a mean. When you report an allocator, report p50, p99, p99.9 and max — and if p99.9/p50 is above about 3, you have a distribution problem, not a speed problem.
Part 2

2How does it work? A free-list threaded through the free slots

The fixed-size pool is the cheapest allocator that exists, and it is about twelve lines of real code. One slab, owned once. Every slot is the same size. A free slot has no live object in it, so you store the address of the next free slot inside the slot itself — an intrusive free-list that costs zero extra memory. Allocate is a pop; free is a push.

Live objects
0
Free slots
32
High-water mark
0
Ops per alloc
1
Fragmentation
0%
nullptr returns
0

Replay · live objects per tick

Pool log · newest first

Size the pool, then abuse it

// include/pool.hpp — HW4. Slot is at least sizeof(void*)
// because a FREE slot stores the next free slot in itself.
template <class T, std::size_t N>
class ObjectPool {
  union Slot { Slot* next; alignas(T) unsigned char buf[sizeof(T)]; };
  Slot  slots_[N];
  Slot* free_ = nullptr;
 public:
  ObjectPool() {                          // thread the list once
    for (std::size_t i = N; i-- > 0;) { slots_[i].next = free_; free_ = &slots_[i]; }
  }
  template <class... A> T* alloc(A&&... a) {
    if (!free_) return nullptr;           // exhausted: caller must cope
    Slot* s = free_; free_ = s->next;     // O(1) pop
    return new (s->buf) T{std::forward<A>(a)...};   // placement new
  }
  void release(T* p) {                    // O(1) push
    if (!p) return;
    p->~T();                              // YOU call the dtor. The pool never does.
    Slot* s = reinterpret_cast<Slot*>(p);
    s->next = free_; free_ = s;
  }
};
Say it out loud. Placement new to construct, explicit ~T() to destroy, pool.release to reclaim. The global allocator is never touched after construction.

Interactive · the arena, and what alignment costs you

A pool needs one fixed size. An arena (bump allocator) does not care about size at all — it keeps one offset, rounds it up to the requested alignment, hands it back, and advances. There is no per-object free; there is only reset(), which reclaims everything in one instruction. Perfect for per-tick scratch. Notice the padding wasted counter as you raise the alignment.

Bump one allocation

Offset
0
Used
0.0%
Padding wasted
0 B
Allocations
0
free() calls
0
class Arena {
  unsigned char* base_;
  std::size_t    cap_, off_ = 0;
 public:
  Arena(unsigned char* base, std::size_t cap) : base_(base), cap_(cap) {}
  void* alloc(std::size_t n, std::size_t align) {
    std::size_t p = (off_ + align - 1) & ~(align - 1);   // round up
    if (p + n > cap_) return nullptr;
    off_ = p + n;
    return base_ + p;                                    // one add: O(1)
  }
  void reset() { off_ = 0; }              // free EVERYTHING, O(1)
};

Interactive · four allocators, one hot path

The same on_book written four ways. Read the code, then read the contract underneath it — that table is what you are actually choosing between.


    

—

—

Per-thread pools, and the reason they exist

A pool with a mutex is a heap with extra steps. The whole point was to remove the lock, so give each thread its own pool — feed handler, strategy, order gateway — and let objects be produced and consumed on the same thread. When they genuinely must cross a thread boundary, hand the object across an SPSC queue and return the slot to the pool that owns it, on its own thread. Never free into a pool you do not own.

Then pad the pools apart. Two per-thread pool objects that land in the same 64-byte cache line will ping that line between cores on every allocation — false sharing that costs you tens of nanoseconds per op for no reason at all. alignas(64) on the pool member is the fix, and it is one word.

std::pmr: the standard already did this

C++17 factored the idea out. A std::pmr::memory_resource is an abstract source of bytes; a std::pmr::polymorphic_allocator lets one container type use any resource. So std::pmr::vector<Level> is one type whose memory can come from your stack buffer today and a pool tomorrow, with no template churn.

  • monotonic_buffer_resource — a bump allocator over a buffer you supply. release() reclaims all of it at once. This is your per-tick scratch.
  • unsynchronized_pool_resource — pooled fixed-size blocks, no locking. The "unsynchronized" is the feature.
  • synchronized_pool_resource — the same, with a mutex. Not for the hot path.
  • null_memory_resource — throws on allocation. Wire it as the upstream of your monotonic resource and you get a compile-your-mistake-into-a-crash guarantee that you never silently spilled to the heap.
Part 3

3Why it matters: cache lines, and knowing when not to pool

Owning the memory buys you a second thing beyond determinism: you get to decide where objects sit. A cache line is 64 bytes on every x86-64 core you will run on, and the hardware moves memory in exactly that unit. A struct that straddles a line boundary is not slightly slower — it costs an extra line fetch and a split-load penalty on every access, forever.

Interactive · aligned vs unaligned access

Place a Quote at a byte offset inside the slab and watch how many 64-byte lines the hardware has to touch. Then tick alignas(64) and watch the split loads go to zero. The right-hand column is the same struct, cache-line aligned, for comparison.

As placed
Lines touched
—
Split loads
—
ns / access
—
Same struct at offset 0 — alignas(64)
Lines touched
—
Split loads
0
ns / access
—

Place the object

Cost of misalignment
—
Per second, at this rate
—
struct alignas(64) Quote {            // one line, never straddles
  std::int64_t  bid_ticks;
  std::int64_t  ask_ticks;
  std::uint32_t bid_qty;
  std::uint32_t ask_qty;
  std::uint64_t seq;
};
static_assert(sizeof(Quote)  == 64, "Quote must be exactly one cache line");
static_assert(alignof(Quote) == 64, "Quote must be cache-line aligned");
Cost model, stated. 0.5 cycle of load-port throughput per aligned 8-byte load (two load ports), +3 cycles for each load that straddles a line boundary, and one L1 tag lookup per extra line touched. Measured penalties on recent x86 sit in that range; the shape is what matters, not the constant. Clock: 4.0 GHz.

Interactive · should you pool this?

The honest answer is often no. Describe the allocation and get a recommendation — plus the one line of code that implements it.

Describe the allocation

Recommendation
Fixed-size pool


      

The three real "do not pool" cases

  • It is not on the hot path. A pool you allocate from once at startup buys you nothing and costs a reviewer's afternoon.
  • The size is genuinely arbitrary. A fixed-size pool sized for the worst case wastes most of its slab; an arena or plain new off the hot path is honest.
  • You cannot bound the count. A pool must have an answer for exhaustion. If you have no defensible bound, you do not have a pool — you have a heap with a cliff.
Part 4

4Runtime polymorphism — what a virtual call costs

Pools give you objects of one type. The moment those objects sit behind a common interface — an Order, a Quoter, a Strategy — you are using runtime polymorphism: one call site, several bodies, chosen by a hidden pointer in the object. It is the right tool for an open set of types and a real cost for a closed one. This part builds the mechanism, prices it, and shows how a virtual object lives inside a pool.

Inheritance, and why you hold a Base&

Public inheritance is an is-a claim: a Passive can be used wherever a Quoter is expected. virtual makes the call late-bound — the object decides, not the static type of the reference. override is checked at compile time; final forbids further overriding and lets the compiler prove the target.

struct Quoter {
  virtual ~Quoter() = default;      // see the rule opposite
  virtual std::int64_t price(std::int64_t mid) const = 0;  // pure
  virtual void on_fill(std::uint32_t) {}  // hook
  std::uint32_t id = 0;
};
struct Passive final : Quoter {
  std::int64_t price(std::int64_t mid) const override { return mid - 1; }
};
struct Aggressive : Quoter {
  std::int64_t price(std::int64_t mid) const override { return mid + 1; }
  void on_fill(std::uint32_t q) override { filled += q; }
  std::uint32_t filled = 0;
};
std::int64_t quote(const Quoter& q, std::int64_t mid) {
  return q.price(mid);   // one call site, any Quoter
}
Pass a reference, never a copy. Quoter q = passive; would not even compile here (abstract), and with a concrete base it slices: only the Quoter part is copied and the call runs the base body. Hold Base& or Base*.

The virtual-destructor rule

Any class you delete or destroy through a base pointer needs a virtual destructor. Without one, only the base destructor runs: everything the derived part owned — a std::string buffer, a file descriptor, a lock — is skipped. That is undefined behaviour, and it usually looks like it works. Rule of thumb: a class with any virtual function gets a virtual destructor.

struct Bad  { ~Bad() {} virtual void f() {} };   // dtor NOT virtual
struct Sub : Bad { std::string tag = std::string(64, 'x'); };
Bad* p = new Sub;
delete p;          // UB: ~Bad only; Sub::tag never destroyed
                       // (clang warns: -Wdelete-non-abstract-non-virtual-dtor)

struct Good { virtual ~Good() = default; };   // the fix

An abstract interface is pure virtuals plus a virtual destructor and nothing else — no state, no helpers. The same rule applies when you never call delete: a pool that calls p->~Base() needs the destructor virtual too (third widget below).

Interactive · the vptr and the vtable

Every polymorphic object carries one hidden pointer, the vptr, to a per-class table of function addresses that lives in read-only memory. Pick a derived class and a method, then step through the call: Base* → object → vptr → vtable slot → function. Layout shown is the Itanium ABI (GCC and Clang on x86-64, 8-byte pointers); the two destructor slots are the complete-object and deleting variants.

sizeof with vptr
—
sizeof without
—
Vtable slot
—
Dependent loads
2
Devirtualizable
—

Choose the call

// p->price(mid) compiles to roughly:
mov  rax, [rdi]        ; 1. vptr out of the object
mov  rax, [rax + 16]   ; 2. slot [2] out of the vtable
call rax               ; 3. indirect call: target unknown

Interactive · what one virtual call costs on the hot path

The vtable loads are not the bill. The bill is the indirect branch when the predictor cannot learn the target, and the inlining you lose because the optimiser cannot see through the call. Numbers are the ones measured in class — one laptop, -O2, 10 M calls — and are order-of-magnitude only: direct/inlined ≈ 0.4 ns, virtual with one hot target ≈ 2.2 ns, virtual with four unpredictable targets ≈ 7 ns, and final with an exact type is back to direct. Measure yours.

Call site

ns / call
—
vs direct
—
Mispredict rate
—
Per tick
—
Budget used
—

Model: ns = 2.2 + mispredict × 6.4 for a virtual call, with mispredict = (1 − predictability) × (1 − 1/targets) — four fully random targets give the measured ≈ 7 ns. Direct is 0.4 ns. A fitted model, not a law of hardware.

The alternatives map

Dynamic dispatch answers one question: how do I call code I do not know yet? If you do know the type set at build time, there are cheaper answers. Open set = plugins and boundaries; closed set = the handful of message and strategy types you actually ship.

MechanismType setCost per callWhere it belongs
virtual functionopen2 loads + indirect branch, no inliningclient boundary, config, logging
templates / CRTPclosedzero — inlined (weeks 5–6)the on_book body, codec, book ops
std::variant + std::visitclosedtable jump, targets inlinedthe message union off the wire
function pointer / std::functionopenindirect + type erasure (maybe a heap allocation)a callback installed once at startup, never per tick
switch on an enumclosedone predictable compare + jumpthe parse boundary: decide the type once, then go static
using Msg = std::variant<Add, Cancel>;      // closed set, no vptr
struct Handler {
  long operator()(const Add& a)    const { return a.px; }
  long operator()(const Cancel& c) const { return (long)c.id; }
};
long h(const Msg& m) { return std::visit(Handler{}, m); }
The stance. Virtuals at the edges — configuration, setup, logging, the boundary a student bot plugs into. Not tick-to-trade. Also: dynamic_cast is a hierarchy walk priced like one (tens of nanoseconds), never a branch; if you reach for it in a hot loop, the type set was closed all along and a variant or a template was the right design. Prefer composition to inheritance, keep interfaces small, write override everywhere and final often.

Interactive · a polymorphic object living in a pool

Putting the two halves of the week together. The pool hands out raw bytes; placement new builds a Limit in them; you talk to it through an Order*; and reclaiming it means calling p->~Order() — which is only correct if that destructor is virtual. Step through the lifecycle, then flip the switch and watch what leaks.

Order::~Order() is
Pool slots free
4
String buffers live
0
Leaked (cumulative)
0 B

Lifecycle log · newest first

struct Order {
  virtual ~Order() = default;      // the switch above
  int id = 0;
};
struct Limit : Order {
  std::string tag = std::string(64, 'x');   // owns a heap buffer
  ~Limit() override { std::puts("~Limit"); }
};
struct Pool {
  alignas(std::max_align_t) unsigned char slab[sizeof(Limit)];
  Order* alloc()          { return new (slab) Limit{}; }  // placement new
  void   release(Order* p) { p->~Order(); }              // virtual: ~Limit, then ~Order
};
int main() { Pool pl; Order* o = pl.alloc(); pl.release(o); }   // prints ~Limit
Two rules, one habit. Construct with placement new, destroy with an explicit destructor call through a base that has a virtual destructor, then return the slot. Never delete a pool pointer. Better still: hand out a unique_ptr with a pool deleter so the pair cannot be forgotten. A polymorphic type also costs 8 bytes per object for the vptr — budget the pool slot for sizeof(Derived), the largest one.
Part 5

5Five ways this goes wrong

These are the five that cost marks on HW4, every time. Open them.

Falling back to new when the pool is empty

It looks like defensive programming and it is the opposite. You spent a week removing malloc from the hot path, and then you put it back — on exactly the path that runs when the market is busiest and the pool is fullest. The fallback fires precisely during the burst you built the pool for, so your p99.9 is worse than if you had never pooled at all, because now the slow path also carries a branch and a cold cache.

Worse, it hides the sizing bug. A pool that silently spills never tells you it was too small, so you ship a capacity you never validated.

Fix: alloc() returns nullptr, loudly, and the caller applies a policy you wrote down — drop the order and count it, reuse the oldest slot, or degrade to a cheaper action. Export the exhaustion counter as a metric and treat a non-zero value as a sizing defect, not a runtime event.

Forgetting p->~T() — or calling delete on a pool pointer

The pool hands you raw bytes; placement new makes them an object. Nothing in that chain runs a destructor for you. Skip p->~T() and the slot is still reused correctly — so your tests pass — while anything the object owned (a string's heap buffer, a file descriptor, a lock) leaks quietly for the rest of the session.

The inverse is a crash. delete p on a pointer into your slab tells the global allocator to free memory it never owned. That is undefined behaviour and it usually corrupts the heap somewhere else entirely, hours later, in a function that is innocent.

Fix: exactly one pattern — new (pool.alloc()) T{...} to construct, p->~T() then pool.release(p) to reclaim. Better: put that pair inside the pool's own release() (as above) or hand out a unique_ptr<T, PoolDeleter> so RAII does it and the pattern cannot be forgotten.

A slot narrower than a pointer, or ignoring alignof(T)

The intrusive free-list stores a void* inside a free slot. If your slot size is sizeof(T) and T is a 4-byte struct, writing the next pointer scribbles 4 bytes into the neighbouring slot. The starter's constructor is written slot_(obj_size < sizeof(void*) ? sizeof(void*) : obj_size) for exactly this reason.

Alignment is the same class of bug with a different symptom. ::operator new gives you memory aligned for max_align_t, but your slot stride is yours to get wrong: a stride of 40 bytes for a type that needs 16-byte alignment leaves every other slot misaligned. On x86 that is a silent slowdown; with SSE/AVX loads or on ARM it is a fault.

Fix: slot = max(sizeof(T), sizeof(void*)), then round the stride up to alignof(T). Assert it: static_assert(sizeof(Slot) % alignof(T) == 0), and declare the storage alignas(T). The union Slot idiom gets both right for free.

One pool, several threads

A free-list pop is read head; head = head->next — two operations that are not atomic together. Two threads allocating at once hand out the same slot twice, and you now have two live objects at one address. This does not crash; it produces one order with another order's price, which is the most expensive kind of bug in this course.

The reflex fix — wrap it in a std::mutex — reintroduces the unbounded wait you removed. The other reflex fix, a lock-free CAS free-list, is a real technique but it also reintroduces contention and the ABA problem, and you do not need it yet.

Fix: one pool per thread, thread_local or held by the thread's own object. Objects are allocated and released on the same thread; when they must cross, pass them over an SPSC queue and release the slot on the owning thread. Then alignas(64) the pools so two threads' free-list heads do not share a cache line.

Pooling the wrapper and not the allocation

ObjectPool<Message, 4096> where Message holds a std::string symbol and a std::vector<Level> levels pools the outer 48 bytes and nothing else. Every construction still calls malloc twice, inside the members you did not look at. Your benchmark shows a lovely 3.7 ns because it constructs empty objects; your bot shows no improvement at all.

The same trap in a different coat: std::pmr::vector<std::string>. The vector's buffer comes from your resource; every string in it still goes to the heap, because std::string is not pmr-aware. You want std::pmr::vector<std::pmr::string>, and the resource has to be propagated.

Fix: make hot-path structs flat and trivially destructible — fixed char sym[8], std::array<Level, N>, integer prices in ticks. Then verify rather than assume: run under a malloc counter (or an upstream null_memory_resource) and prove the hot path allocates zero times.

Part 6

6Check yourself

Four questions, instant feedback, nothing recorded.

Q1Your ObjectPool<Order,4096> runs dry in the middle of a tick. Which response preserves the latency guarantee you built it for?

Q2You construct with new (pool.alloc()) Order{...} and later call pool.release(p) — but your release forgets p->~Order(). Order holds a std::string. What happens?

Q3Each tick your parser needs a dozen temporaries of different sizes — a level vector, a scratch buffer, a couple of small structs — all dead by the end of the tick. What do you allocate them from?

Q4A pool hands out Order* pointing at a Limit that owns a std::string. Order's destructor is not virtual. release() calls p->~Order(). What runs?

Part 7

7Do the work

In the lab (~45–60 min)

  • make pool — the stub compiles and every test fails, because alloc() returns nullptr. That red output is the starting line.
  • Build it in four steps: one buffer owned in the constructor, the intrusive free-list threaded back to front, alloc() as a pop, free() as a push.
  • Green on pool_basic, pool_full, pool_reuse, pool_pattern, then read the METRIC|pool_ns_per_op line.
  • Sketch the per-tick arena in a scratch file and argue when it beats the free-list pool.
  • Polymorphism drill: write Quoter with a virtual destructor, time a virtual call against a direct one with one target and with four, and confirm final brings it back to direct.

HW4 & Project Phase 1

  • HW4: include/pool.hpp — O(1) alloc/free, correct slot size and alignment, placement new, explicit destruction. Autograded.
  • HW4: benchmark pool vs new/delete in a tight loop. Report p50, p99, p99.9 and max for both — not the mean — and argue why yours is stable.
  • Phase 1: every per-tick new in on_book comes from a pool or an arena; scratch on a monotonic buffer, release() at tick end.
  • Polymorphism: one pooled polymorphic type (placement new, virtual destructor, explicit ~Base()) — and a written note on why the tick-to-trade path itself stays non-virtual.
  • Prove it: re-run the latency replay and show p99.9 down against your Week-2 baseline.
Code
Get the code
Both tracks share one starter tree. make pool builds tests/pool_test.cpp against your include/pool.hpp and prints the metric; make test runs the whole grader.
Previous
← Week 3 · Memory Management & Smart Pointers
RAII told you who frees what. This week decided where it came from.
Next
Week 5 · Templates & Generic Programming →
You priced runtime polymorphism. Now get the same generality at compile time — one pool, written once, stamped out for every type at zero runtime cost.