Quant developer HFT course · Module 3: Containers and memory
Hash maps: buckets, load factor and rehashing
The question
Most order lookups take a few hundred nanoseconds, but a few take tens of microseconds. The lookup is an unordered_map from order id to order. What might cause the slow ones?
How std::unordered_map is laid out
The standard's interface practically requires separate chaining. There is an array of buckets; each bucket points to a chain of nodes; each node holds one key, one value and a link to the next node, and is allocated individually.
A lookup computes hash(key) % bucket_count (or a cheaper equivalent), reads the bucket, and walks its chain comparing keys. With a good hash and a reasonable load, chains are short and a lookup is: compute the hash, one load for the bucket, one or two loads for nodes. Each of those loads can be a cache miss because the nodes are scattered.
Load factor and rehash
The load factor is size() / bucket_count(). The default maximum is 1.0. When an insert would exceed it, the map rehashes: it allocates a larger bucket array, typically about twice as many buckets, and relinks every node into its new bucket.
That rehash is the answer to the question's first suspect. It walks every element, so it costs time proportional to the size of the map, and it happens inside an ordinary insert. With hundreds of thousands of orders, the insert that triggers it takes far longer than the rest. If inserts and lookups share a thread, lookups queue behind it.
The fix is the same as for vectors: reserve(n) sizes the bucket array for n elements up front, so no insert below n triggers a rehash. max_load_factor can be lowered to trade memory for shorter chains.
Hash quality
The second suspect is the hash. libstdc++'s std::hash for integers is the identity: the hash of 4711 is 4711. With a bucket count that is a prime number, as libstdc++ uses, that is usually fine. But keys with structure can still collide badly. Exchange order ids often encode a session, a date or a counter in fixed bit fields, and if the varying bits are the ones the bucket index ignores, many keys share a bucket and chains get long.
Long chains are a slow path that appears only for some keys, which matches tens of microseconds for a few lookups. The cure is a hash that mixes all the bits, for example a multiply-and-shift or a library's integer mixer, and checking the distribution with bucket_size on a real day's ids.
There is a security version of the same problem: if an attacker can choose keys, they can pick ones that collide. That is less of a concern for exchange-assigned ids than for anything parsed from outside input.
Other sources of slow lookups
- Cold nodes. A node not touched for a while has been evicted from the cache, so its lookup pays a full memory access, or two if the bucket is cold as well.
- Allocation on insert. Each insert allocates a node, with all the tail-latency risks of Module 1.
- Erase and reinsert churn scatters nodes further across the heap over the day.
Open addressing
Open-addressing tables store entries directly in one array. A key hashes to a slot; if that slot holds a different key, the table probes nearby slots in a fixed sequence until it finds the key or an empty slot.
Modern designs such as Abseil's Swiss tables also keep a small array of control bytes with a few bits of each key's hash, and use SIMD to compare sixteen of them at once, so most lookups decide with one or two cache-line reads. There is no per-element allocation; erasing leaves a marker (a tombstone) or shifts entries back.
The trade-offs: elements move when the table grows, so pointers into it are not stable; performance degrades sharply as the table fills, so the maximum load is kept below one; and deletion is more complicated than unlinking a node.
For the slow order lookups: reserve for the day's peak, use a mixing hash, and if that is not enough, move to an open-addressing table keyed by order id that stores handles into the order pool.
Check yourself
Question 1 of 3
What is the load factor of an unordered_map with 1,500 elements and 1,024 buckets?
Load factor is elements divided by buckets. With the default maximum of 1.0, this map would already have rehashed; it can only exist if max_load_factor was raised.
Question 2 of 3
Why can an occasional insert into an unordered_map take far longer than the rest?
Rehashing is proportional to the map's size and happens inside the insert that crosses the load factor. reserve moves that work to start-up.
Question 3 of 3
Order ids are 64-bit numbers whose low 20 bits are always zero. The table uses a power-of-two bucket count and takes the low bits of an identity hash. What happens?
With identity hashing and a power-of-two mask, the bucket index is the low bits of the key, which are all zero. A hash that mixes high bits into low bits fixes it.
In the interview
Say your own answer out loud first, then open the one we would give.
How does std::unordered_map work internally?
Separate chaining: an array of buckets, each pointing to a linked chain of individually allocated nodes. A lookup hashes the key to a bucket and walks the chain. When the load factor exceeds the maximum, by default 1.0, it rehashes into a larger bucket array.
Why might hash map lookups have a long tail?
Rehashes inside inserts, long chains from a hash that does not mix the key's bits, cache misses on cold buckets and nodes, and allocation on insert. Reserving capacity, using a good mixing hash, and switching to an open-addressing table address those in turn.
What is open addressing and what does it trade?
Entries live directly in one array; collisions probe nearby slots. It avoids per-node allocation and pointer chasing, so lookups touch fewer cache lines. In exchange, elements move on growth so references are unstable, the load must stay well below full, and deletion needs tombstones or backward shifting.