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 1: Reading hot code

Allocation on the hot path

Lesson · 10 min

The question

This handler builds an order for every quote it acts on. There is no new anywhere in it. Does it allocate?

Allocation you did not write

Most allocations on a hot path are not written as new. They come from library types doing their job:

void on_signal(const Quote& q, Gateway& gw) {
    std::string tag = "MOM-" + std::to_string(q.seq);    // may allocate, twice
    std::vector<Leg> legs;                               // empty: no allocation yet
    legs.push_back(Leg{q.instrument, q.price, 10});      // allocates
    auto ord = std::make_shared<Order>(q, std::move(legs), std::move(tag));  // allocates
    gw.send(ord);
}

Four or five allocations per signal, and none of them visible as new. std::to_string and the + build strings; whether those allocate depends on the length and on the library's small string buffer (Module 3). The first push_back gives the vector its first heap block. make_shared allocates the order and its reference count together. If Order holds its own containers, they allocate too.

Things that commonly allocate behind your back: std::string beyond its small buffer, any container growing, std::function holding a large callable, std::shared_ptr creation, exceptions, std::any, iostreams and most formatting, and node-based containers on every insert.

Why it hurts

An allocation is not always slow. A modern allocator serves most requests from a per-thread cache in a few tens of nanoseconds. The trouble is the rest:

emptyemptynew / make_sharedyour codethread cachefast pathcentral listsmay take a lockmmap / brksystem call
Where an allocation can end up. Most calls stop at the first box. The ones that do not are rare, and they are the slow requests at the far end of your latency distribution.

When the thread cache for that size is empty, the allocator goes to shared structures, which may mean a lock and contention with other threads. When those are empty too, it asks the kernel for more memory with a system call, and the new pages fault in when first touched (Module 6). These cases are rare, which is exactly why they are dangerous: they do not move the average, they create the occasional request that takes a hundred times longer than its neighbours. Trading systems are judged on those requests.

Freeing has costs too. Memory freed on a different thread from the one that allocated it can take a slower path, and some allocators return memory to the kernel in bursts.

There is also a quieter cost. Each allocation puts an object somewhere new, so data that the hot path reads together ends up scattered, which brings back the pointer chasing of the previous lesson.

Keeping allocation out of the loop

The standard techniques, roughly in the order you would mention them:

  1. Reserve up front. reserve on vectors and hash maps at start-up, sized for the worst day you expect.
  2. Reuse instead of recreate. Keep a member vector and clear() it each event: clear keeps the capacity, so the next push_back does not allocate.
  3. Pass views, not owners. std::string_view and std::span let a function read data without copying it into a new owner (Module 2).
  4. Fixed-capacity types. A fixed-size char array for symbols and tags, a fixed-capacity vector for legs.
  5. Pools and arenas. Preallocate objects of one type and hand them out from a free list, or carve a large block sequentially and reset it per batch (Module 3).
class SignalHandler {
    std::vector<Leg> legs_;            // capacity reserved in the constructor, reused every call
    OrderPool& pool_;                  // preallocated orders
public:
    explicit SignalHandler(OrderPool& p) : pool_(p) { legs_.reserve(8); }
    void on_signal(const Quote& q, Gateway& gw) {
        legs_.clear();                                  // keeps capacity
        legs_.push_back(Leg{q.instrument, q.price, 10});
        Order* o = pool_.acquire();                     // no heap call
        o->fill(q, legs_, q.seq);                       // tag stored as an integer, formatted later
        gw.send(*o);
    }
};

The tag string has gone completely: the sequence number is stored as an integer and turned into text only when something needs to read it, off the hot path.

How you know

You find allocations by measuring, not reading. A heap profiler, a counting allocator in a test build, or a breakpoint on malloc after warm-up all work. A common pattern in trading code is a test that runs the hot path after warm-up with an allocator hook that fails the test on any allocation.

Check yourself

Question 1 of 3 · choose every correct option

Which of these lines can allocate on the heap? Choose all that apply.

Question 2 of 3

Why is an allocator a latency problem when its average call is fast?

Question 3 of 3

A handler fills a local std::vector with up to eight elements on every call. What is the cheapest change that removes the allocation?

In the interview

Say your own answer out loud first, then open the one we would give.

Why avoid heap allocation on the hot path?

Most allocations are quick, but some go to a slower path: refilling from shared lists, possibly under a lock, or asking the kernel for memory and then taking page faults. Those occasional calls are what produce latency spikes. Allocation also scatters objects in memory, which costs cache misses later.

How do you find hidden allocations?

I would not rely on reading the code. I would run the hot path after warm-up under a heap profiler, or with an allocator hook that counts or fails on any allocation, and treat any allocation there as a bug to explain.

What would you do instead of allocating per message?

Reserve capacity at start-up, reuse containers across messages by clearing them, pass views rather than copying into new strings, use fixed-capacity types where the size has a known bound, and take objects from a preallocated pool. Formatting and logging get deferred to another thread.