Skip to content
C++
Domain Track
Difficulty 5/5

C++ in HFT & Low-Latency Finance

C++ for HFT: lock-free data structures, order book implementation, market data feed parsing, cache optimization, kernel bypass networking, custom allocators, NUMA awareness, and nanosecond profiling.

The latency constraint

HFT systems operate on nanosecond to microsecond budgets. A price update arrives on a NIC, traverses the kernel, gets processed, and triggers an order — all within 1-10 microseconds. At this scale, every CPU instruction, every cache miss, and every system call matters.

OperationTypical latency
L1 cache hit~4 cycles / ~1ns
L2 cache hit~12 cycles / ~4ns
L3 cache hit~40 cycles / ~12ns
DRAM access~200 cycles / ~60ns
System call~1000+ cycles / ~300ns
Network round-trip (co-lo)~100-500ns
Network round-trip (internet)~1ms+

Lock-free data structures

Mutexes are off the table. Even uncontended std::mutex::lock() takes ~30ns on x86. Lock-free structures use std::atomic to coordinate threads without OS involvement.

Single-producer single-consumer ring buffer

cpp
#include <atomic>
#include <array>

template<typename T, std::size_t Capacity>
class SPSCQueue {
    static_assert((Capacity & (Capacity - 1)) == 0, "Capacity must be power of 2");

    alignas(64) std::atomic<std::size_t> head_{0};  // writer
    alignas(64) std::atomic<std::size_t> tail_{0};  // reader
    std::array<T, Capacity> data_;

public:
    bool push(const T& item) noexcept {
        std::size_t h = head_.load(std::memory_order_relaxed);
        std::size_t next = (h + 1) & (Capacity - 1);
        if (next == tail_.load(std::memory_order_acquire))
            return false;  // full
        data_[h] = item;
        head_.store(next, std::memory_order_release);
        return true;
    }

    bool pop(T& item) noexcept {
        std::size_t t = tail_.load(std::memory_order_relaxed);
        if (t == head_.load(std::memory_order_acquire))
            return false;  // empty
        item = data_[t];
        tail_.store((t + 1) & (Capacity - 1), std::memory_order_release);
        return true;
    }
};

The alignas(64) separates the head and tail onto different cache lines — prevents false sharing where the reader and writer invalidate each other's cache lines on every operation.

Why false sharing kills performance

cpp
// BAD — head_ and tail_ share a cache line
struct BadQueue {
    std::atomic<int> head_{0};  // bytes 0-3
    std::atomic<int> tail_{0};  // bytes 4-7 — same 64-byte cache line!
};
// Every push (writes head_) invalidates the cache line the reader holds for tail_
// Every pop (writes tail_) invalidates the cache line the writer holds for head_
// Result: ~500ns per operation instead of ~20ns

// GOOD — each on its own cache line
struct GoodQueue {
    alignas(64) std::atomic<int> head_{0};
    alignas(64) std::atomic<int> tail_{0};
};

CPU cache optimization

Hot/cold data splitting

cpp
// BAD — rarely-used data pollutes cache
struct Order {
    // Hot — accessed on every tick
    int64_t order_id;
    double price;
    int32_t quantity;
    int32_t side;  // 24 bytes

    // Cold — accessed only on fills/cancels
    char symbol[32];
    char account[16];
    char notes[128];
    // 176 bytes of rarely-used data shares cache line with hot data
};

// GOOD — separate hot and cold data
struct OrderHot {
    int64_t order_id;
    double  price;
    int32_t quantity;
    int32_t side;
};  // 24 bytes — fits in a cache line with 3 other orders

struct OrderCold {
    char symbol[32];
    char account[16];
    char notes[128];
};

// Hot data in tight array, cold data indexed separately
OrderHot hot_orders[65536];
OrderCold cold_orders[65536];

Prefetching

cpp
// Manual prefetch — bring data into cache before you need it
void process_orders(OrderHot* orders, int count) {
    constexpr int PREFETCH_DISTANCE = 8;

    for (int i = 0; i < count; ++i) {
        if (i + PREFETCH_DISTANCE < count)
            __builtin_prefetch(&orders[i + PREFETCH_DISTANCE], 0, 1);

        process(orders[i]);
    }
}

Custom allocators

malloc / new are off the table in hot paths — they acquire locks, may call the OS, and have non-deterministic latency.

Arena allocator

cpp
class Arena {
    alignas(64) std::byte buf_[64 * 1024];  // 64KB on the stack
    std::size_t offset_{0};

public:
    void* alloc(std::size_t size, std::size_t align = 8) noexcept {
        std::size_t padding = (-offset_) & (align - 1);
        std::size_t pos = offset_ + padding;
        if (pos + size > sizeof(buf_)) return nullptr;
        offset_ = pos + size;
        return buf_ + pos;
    }

    void reset() noexcept { offset_ = 0; }
};

// Per-message scratch space — no heap, O(1) alloc, O(1) free
thread_local Arena message_arena;

void on_market_data(const FeedMessage& msg) {
    message_arena.reset();
    auto* parsed = new(message_arena.alloc(sizeof(ParsedMsg))) ParsedMsg{};
    // ... process ...
}  // no deallocation needed — reset wipes entire arena

Huge pages

cpp
#include <sys/mman.h>

// Allocate 2MB huge page — reduces TLB misses for large hot arrays
void* alloc_huge(std::size_t size) {
    void* p = mmap(nullptr, size,
        PROT_READ | PROT_WRITE,
        MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB,
        -1, 0);
    return (p == MAP_FAILED) ? nullptr : p;
}

// For /proc/sys/vm/nr_hugepages > 0

Kernel bypass networking

The Linux kernel adds ~1-5μs latency per packet. For sub-microsecond execution, use kernel bypass:

DPDK — Data Plane Development Kit

cpp
#include <rte_eal.h>
#include <rte_ethdev.h>
#include <rte_mbuf.h>

// Polling loop — no interrupts, no kernel, ~100ns per packet
void rx_loop(uint16_t port_id, uint16_t queue_id) {
    rte_mbuf* pkts[32];

    while (running) {
        uint16_t n = rte_eth_rx_burst(port_id, queue_id, pkts, 32);
        for (uint16_t i = 0; i < n; ++i) {
            process_packet(pkts[i]);
            rte_pktmbuf_free(pkts[i]);
        }
        // No sleep — busy poll: wastes a core, minimizes latency
    }
}

DPDK bypasses the kernel entirely. One CPU core is dedicated to polling the NIC. Latency: ~200-500ns vs 1-5μs through the kernel.

solarflare / OpenOnload

User-space TCP stack that intercepts POSIX socket calls. Requires supported NICs (Solarflare/Xilinx). Drop-in replacement — no code changes needed.


NUMA awareness

Modern servers have multiple CPU sockets. Memory accesses across NUMA nodes take 2-3x longer.

cpp
#include <numa.h>

// Allocate on the node local to the CPU running this thread
int node = numa_node_of_cpu(sched_getcpu());
void* local_mem = numa_alloc_onnode(size, node);

// Pin thread to a specific CPU core
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(core_id, &cpuset);
pthread_setaffinity_np(pthread_self(), sizeof(cpuset), &cpuset);

// Disable NUMA balancing for latency-critical processes
// /proc/sys/kernel/numa_balancing = 0

Latency measurement

cpp
#include <x86intrin.h>

// RDTSC — read time-stamp counter (cycles)
uint64_t rdtsc() {
    unsigned lo, hi;
    __asm__ volatile("rdtsc" : "=a"(lo), "=d"(hi));
    return ((uint64_t)hi << 32) | lo;
}

// Measure a function in cycles
uint64_t t0 = rdtsc();
process_order(order);
uint64_t t1 = rdtsc();
uint64_t cycles = t1 - t0;

// Convert to nanoseconds: cycles / GHz
// 1000 cycles / 3GHz = ~333ns

// RDTSCP — serializing variant (prevents out-of-order execution)
uint64_t rdtscp() {
    unsigned lo, hi, aux;
    __asm__ volatile("rdtscp" : "=a"(lo), "=d"(hi), "=c"(aux));
    return ((uint64_t)hi << 32) | lo;
}

Compiler flags for latency

bash
# Maximum optimization
-O3 -march=native              # use all available CPU features (SSE4, AVX2, ...)

# Avoid non-determinism
-fno-math-errno                # faster math, fewer state writes
-fno-trapping-math             # assume no floating-point exceptions

# Link-time optimization — cross-TU inlining
-flto=thin                     # parallel LTO

# PGO — profile-guided optimization
g++ -O2 -fprofile-generate ... # first: instrument
./myapp < production_trace     # run with real data
g++ -O3 -fprofile-use ...      # recompile with profile data

Order book implementation

The limit order book is the central data structure in trading. It maps price levels to queues of resting orders. The key constraint: add, cancel, and best_bid/ask must all be O(1) or O(log n).

cpp
#include <map>
#include <list>
#include <cstdint>
#include <limits>

struct Order {
    uint64_t id;
    int32_t  qty;
};

// Price-level map — bids descending, asks ascending
// std::map<int32_t, std::list<Order>>:
//   insert: O(log N price levels)
//   cancel: O(1) with stored iterator
//   best bid/ask: begin()/rbegin() — O(1)

class OrderBook {
    using Level = std::list<Order>;
    std::map<int32_t, Level, std::greater<int32_t>> bids_;  // descending
    std::map<int32_t, Level>                         asks_;  // ascending

public:
    void add_bid(int32_t price, Order o) {
        bids_[price].push_back(o);   // price level created if absent
    }

    void add_ask(int32_t price, Order o) {
        asks_[price].push_back(o);
    }

    void cancel_bid(int32_t price, std::list<Order>::iterator it) {
        auto& level = bids_[price];
        level.erase(it);             // O(1) given iterator
        if (level.empty()) bids_.erase(price);
    }

    int32_t best_bid() const {
        return bids_.empty() ? 0 : bids_.begin()->first;
    }

    int32_t best_ask() const {
        return asks_.empty() ? std::numeric_limits<int32_t>::max()
                             : asks_.begin()->first;
    }

    int32_t spread() const { return best_ask() - best_bid(); }
};

In production, std::map is often replaced with a flat price ladder — a fixed-size array indexed by price ticks — giving O(1) access at the cost of a fixed price range. For deeply liquid instruments (e.g. S&P futures) the ladder stays warm in L1 cache.


Market data feed parsing (zero-copy)

Exchange feeds (NASDAQ ITCH, CME MDP3, OPRA) send binary UDP datagrams. The parser must extract fields with no heap allocation and no branching on the critical path.

cpp
#include <cstdint>
#include <cstring>

// NASDAQ ITCH 5.0 — Add Order message (type 'A'), 36 bytes
#pragma pack(push, 1)
struct ITCHAddOrder {
    char     msg_type;        // 'A'
    uint16_t stock_locate;
    uint16_t tracking_number;
    uint8_t  timestamp[6];   // 48-bit nanoseconds since midnight
    uint64_t order_ref;
    char     buy_sell;        // 'B' or 'S'
    uint32_t shares;
    char     stock[8];
    uint32_t price;           // price * 10000 (fixed point)
};
#pragma pack(pop)

// Zero-copy parse — no allocation, no memcpy of fields
void handle_packet(const uint8_t* buf, std::size_t len) {
    if (len < 1) return;

    switch (buf[0]) {
    case 'A': {
        if (len < sizeof(ITCHAddOrder)) return;
        const auto* msg = reinterpret_cast<const ITCHAddOrder*>(buf);
        // Network byte order → host byte order
        uint32_t price  = __builtin_bswap32(msg->price);
        uint32_t shares = __builtin_bswap32(msg->shares);
        double   price_d = price / 10000.0;

        // Update book — no allocation on this path
        if (msg->buy_sell == 'B')
            book.add_bid(price, {__builtin_bswap64(msg->order_ref), (int32_t)shares});
        else
            book.add_ask(price, {__builtin_bswap64(msg->order_ref), (int32_t)shares});
        break;
    }
    // ... other message types
    }
}

Key rules: use #pragma pack / __attribute__((packed)) to match the wire format; reinterpret_cast directly onto the buffer (UB-safe via std::start_lifetime_as in C++23); avoid std::string construction.


Spinlock for ultra-short critical sections

std::mutex has ~30ns overhead even uncontended. For critical sections measured in nanoseconds, a spinlock using std::atomic_flag avoids kernel involvement entirely:

cpp
#include <atomic>

class Spinlock {
    std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
public:
    void lock() noexcept {
        while (flag_.test_and_set(std::memory_order_acquire))
            __builtin_ia32_pause();  // PAUSE instruction — reduces power, improves pipeline
    }
    void unlock() noexcept {
        flag_.clear(std::memory_order_release);
    }
};

// RAII guard
struct SpinGuard {
    Spinlock& sl;
    SpinGuard(Spinlock& s) : sl(s) { sl.lock(); }
    ~SpinGuard() { sl.unlock(); }
};

Spinlock order_lock;

void update_order(uint64_t id, int32_t new_qty) {
    SpinGuard g(order_lock);
    // < 5ns critical section here — spinlock pays off
}

When to use: only when the critical section is provably shorter than the OS context-switch cost (~1μs). Never hold a spinlock across I/O or any call that might block. Prefer lock-free structures (SPSC queue) for throughput-critical paths.


C++ features to avoid in hot paths

FeatureProblemAlternative
std::mutex~30ns even uncontendedstd::atomic, lock-free
std::shared_ptratomic refcount on hot pathraw ptr + pool, std::unique_ptr
std::functionheap alloc, virtual dispatchtemplates + lambdas, function pointers
new/delete in hot pathmalloc lock, OS callsarena/pool allocators
Virtual dispatchbranch misprediction, no inliningCRTP, std::variant
ExceptionsABI overhead even when not thrown (some platforms)std::expected, error codes