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.
| Operation | Typical 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
#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
// 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
// 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
// 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
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 arenaHuge pages
#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 > 0Kernel bypass networking
The Linux kernel adds ~1-5μs latency per packet. For sub-microsecond execution, use kernel bypass:
DPDK — Data Plane Development Kit
#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.
#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 = 0Latency measurement
#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
# 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 dataOrder 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).
#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.
#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:
#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
| Feature | Problem | Alternative |
|---|---|---|
std::mutex | ~30ns even uncontended | std::atomic, lock-free |
std::shared_ptr | atomic refcount on hot path | raw ptr + pool, std::unique_ptr |
std::function | heap alloc, virtual dispatch | templates + lambdas, function pointers |
new/delete in hot path | malloc lock, OS calls | arena/pool allocators |
| Virtual dispatch | branch misprediction, no inlining | CRTP, std::variant |
| Exceptions | ABI overhead even when not thrown (some platforms) | std::expected, error codes |