Quant developer HFT course · Module 5: The CPU
The cache hierarchy
The question
A lookup table used on every message is 4 MB. Someone proposes shrinking it to 256 KB by storing smaller entries. Why might that make the hot path much faster even though the code does the same work?
The levels
A core reads data from a hierarchy of memories, each larger and slower than the one above:
- L1 data cache: per core, tens of KB, a few cycles. Separate L1 instruction cache of similar size.
- L2: per core on current server parts, around a megabyte or two, low tens of cycles.
- L3 (last-level cache): shared by the cores on a die or socket, tens of MB, a few tens of cycles, more on large chips where it is split into slices across a mesh.
- Main memory: on the order of 80 to 120 nanoseconds for a load that misses everything, which is several hundred cycles at server clock speeds. On a two-socket machine, memory attached to the other socket is slower again.
Treat these numbers as orders of magnitude. They change with each processor generation, and an interviewer will respect "roughly" followed by "I'd measure it on the target machine" more than false precision. The ratios are what matter: each step down costs several times more.
Working set
The question is about the working set: the data the hot path touches repeatedly. If it fits in L2, almost every access is a hit after warm-up. At 4 MB, the table does not fit in L2 and competes with everything else for L3; many lookups miss to L3, and some to memory, especially after other code has run and evicted parts of it. At 256 KB it fits in L2, and most lookups become an order of magnitude cheaper. The arithmetic per lookup is unchanged; the memory behind it is not.
This is the most important performance property of a data structure in a trading system: does what the hot path touches stay in cache between events? That is why the earlier modules kept saying "contiguous", "compact" and "hot fields together".
How caches are organised
Caches hold whole 64-byte lines. Each cache is set-associative: the address picks a set, and the line can go in any of a small number of ways within that set, typically 8 to 16. When a set is full, a line is evicted, usually the least recently used one.
Two consequences:
- Conflict misses. Addresses that are a large power of two apart map to the same set. Walking a column of a matrix whose rows are 4 KB apart, or many arrays allocated at 4 KB-aligned addresses and accessed together, can thrash one set while the rest of the cache sits empty. Padding the stride by a line fixes it.
- Writes allocate. Writing to a line that is not in the cache normally fetches it first (a read for ownership), so writing a large buffer costs the reads as well. The prefetching lesson later in this module covers non-temporal stores that avoid this.
Whether L3 includes the contents of L1 and L2 (inclusive) or not (non-inclusive or exclusive) varies between designs and matters for some cross-core effects, but rarely for the code you write.
The instruction side
Code is data too. Hot code must fit in the L1 instruction cache and the decoded-micro-op cache for the core to run it at full speed. Large templates, aggressive inlining and rarely taken error paths mixed into hot functions all enlarge the instruction footprint. Marking error handlers cold, keeping them out of line, and using profile-guided optimisation to lay out hot code together are the instruction-side equivalents of packing data.
Measuring it yourself
A pointer chase through a randomly permuted array of a chosen size measures load latency at each level: below 32 KB you see L1 latency, then a step at each cache size, then memory. It is a good experiment to have done, because it turns the table above into numbers for your own machine, and it is the kind of thing worth mentioning when asked how you know.
// next[i] holds the index of the following element in a random cycle through the array
std::size_t i = 0;
auto t0 = now();
for (std::size_t k = 0; k < steps; ++k) i = next[i]; // each load depends on the last
auto ns_per_load = (now() - t0) / steps;Socket and NUMA
On multi-socket servers, each socket has its own memory. A thread reading memory attached to the other socket pays an extra trip across the interconnect, and cache lines shared between sockets are expensive to keep coherent. Latency-sensitive processes are pinned to one socket, and their memory is allocated there (with numactl or by first touching it from a thread on that socket, since Linux places pages on the node that first writes them).
Check yourself
Question 1 of 3
A hot path reads a 4 MB table randomly. Why can shrinking it to 256 KB speed it up without changing the code's logic?
The working set decides where the data lives. Fitting in L2 changes the cost of each lookup by roughly an order of magnitude.
Question 2 of 3
What can cause conflict misses even though the cache is mostly empty?
Associativity limits how many lines one set holds. Power-of-two strides concentrate addresses in a few sets.
Question 3 of 3
On a two-socket server, where should a latency-critical thread's memory be allocated?
Remote memory costs an extra interconnect hop on every miss. Pin the thread and allocate locally; Linux places a page on the node of the thread that first touches it.
In the interview
Say your own answer out loud first, then open the one we would give.
Describe the cache hierarchy.
Per-core L1 data and instruction caches of tens of KB at a few cycles, a per-core L2 of around a megabyte at low tens of cycles, a shared L3 of tens of megabytes at a few tens of cycles, then main memory at roughly a hundred nanoseconds, more if it belongs to another socket. Lines are 64 bytes and caches are set-associative. I would quote the exact figures only after measuring the target machine.
Why does working-set size matter so much?
Because it decides which level of the hierarchy serves most accesses. A hot path whose data fits in L1 or L2 runs from fast caches; one whose data spills to L3 or memory pays many times more per access for the same instructions. Compact layouts, smaller types and fewer pointers are how you shrink it.
How would you measure memory latency on a machine?
A dependent pointer chase through a randomly permuted array, timing the average step while varying the array size. The time per step steps up at each cache size, which gives the latency of each level on that hardware.