Why std::lower_bound Is Slow on Modern Hardware
Given a static sorted table of $N = 2^{20}$ ($1{,}048{,}576$) 32-bit integers ($4\text{ MB}$), how fast is std::lower_bound? Textbook complexity says $20$ comparisons. Hardware reality says ~16 sequential DRAM/L3 memory stalls for three architectural reasons analyzed by Paul-Virak Khuong and Pat Morin (2017):
- Zero Spatial Locality in the Top $\log_2 N - 4$ Levels: A $64$-byte CPU cache line holds sixteen 32-bit integers. Standard sorted order packs adjacent ranks ($A[i], A[i+1], \dots, A[i+15]$) into a cache line. That spatial locality is completely useless until the search interval $[lo, hi)$ shrinks to width $\le 16$ in the final $4$ iterations! The first $16$ probes each fetch a 64-byte line only to read a single 4-byte integer and discard the remaining 60 bytes.
- Data-Dependent Memory Addresses Block Hardware Prefetching: The CPU cannot know whether the next probe is $mid - step$ or $mid + step$ until the load of $A[mid]$ completes, preventing memory-level parallelism (MLP).
- Power-of-Two Cache-Set Aliasing: When $N = 2^{20}$, the top probes occur at byte offsets $N/2, N/4, 3N/4, N/8, \dots$, which are all large multiples of $2^{12} = 4096$ bytes. Because CPU L1/L2 caches index their cache sets using lower address bits (bits $6\dots 11$), all top binary-search probes map to the exact same cache set, evicting each other even when the working set is tiny!
The Eytzinger (BFS / 1-Indexed Heap) Array Layout
Named after 16th-century genealogist Michaël Eytzinger (and identical to the 1-indexed array layout of a binary heap), the Eytzinger layout stores the implicit binary search tree in Breadth-First Search (BFS) level order starting at index $k = 1$:
- The root (overall median) is at $B[1]$.
- For any node at index $k$, its left child (quartile $< B[k]$) is at $B[2k]$ and its right child (quartile $ge B[k]$) is at $B[2k + 1]$.
Compare Chazelle-Guibas Fractional Cascading (propagating every 2nd element from L_{i+1} into L_i to replace k binary searches with O(1) bridge pointers) and Eytzinger 1-indexed BFS layout.
Invariant & State
Search Layout ($N = 2^{20}$ uint32_t) |
Comparisons | Cache Lines Touched | Prefetch / MLP Capability |
|---|---|---|---|
Sorted Array + Branched std::lower_bound |
$20$ | $~17$ lines (+ set aliasing) | None (1 load in flight at a time) |
Sorted Array + Branchless cmov |
$20$ | $~17$ lines (+ set aliasing) | Eliminates branch mispredicts only |
| Eytzinger Layout + Branchless + 4-Level Prefetch | $20$ | $5$ cache lines ($4$ levels/line!) | 4 concurrent cache lines in flight ($3.5\times$ faster!) |
In the Eytzinger array $B[1..N]$, what are the 16 great-great-grandchildren of node $k$ (4 levels down the search tree)? Their indices are the contiguous range:
$$\left[16k, \; 16k + 15\right]$$Because sixteen 32-bit integers equal exactly $64$ bytes (one single cache line!), if we align $B$ to a 64-byte boundary, all 16 possible targets 4 steps in the future reside in the exact same cache line starting at &B[16 * k]! Thus, at step $k$, we can unconditionally issue __builtin_prefetch(&B[16 * k]) without knowing which way the next 4 branches will go, keeping 4 independent DRAM loads pipelined in flight simultaneously!
__builtin_ctz(~k)During the branchless search loop while (k <= N) k = 2 * k + (B[k] < x);, every time B[k] < x is false (meaning $B[k] \ge x$, so $B[k]$ is a valid lower_bound candidate!), we take the left branch ($2k$), appending a $0$-bit to the binary representation of $k$. Every time B[k] < x is true, we take the right branch ($2k + 1$), appending a $1$-bit to $k$. Therefore, the last ancestor where we branched left ($B[\text{ancestor}] \ge x$) is obtained simply by stripping all trailing $1$-bits from $k$ and shifting right by one more bit: k >> (__builtin_ctz(~k) + 1)! One hardware tzcnt instruction recovers the exact lower_bound node without maintaining a separate best variable inside the loop.
Production C++20 Implementation: 64-Byte Aligned Eytzinger Table with Prefetching
#include <bit>
#include <concepts>
#include <cstddef>
#include <cstdint>
#include <span>
#include <vector>
template <std::totally_ordered T>
class EytzingerTable {
public:
explicit EytzingerTable(std::span<const T> sorted_input)
: n_(sorted_input.size()), tree_(n_ + 1) {
std::size_t src_idx = 0;
build_inorder(1, sorted_input, src_idx);
}
// Returns the Eytzinger index k in [1..n_] of the first element >= x,
// or 0 if all elements are < x.
std::size_t lower_bound_eytzinger_index(const T& x) const noexcept {
std::size_t k = 1;
constexpr std::size_t kPrefetchMultiplier = 16; // 4 levels ahead = 16 elements (64B for uint32_t)
while (k <= n_) {
if (k * kPrefetchMultiplier <= n_) {
__builtin_prefetch(&tree_[k * kPrefetchMultiplier], 0, 1);
}
// Branchless step: left child (2k) if tree_[k] >= x, right child (2k+1) if tree_[k] < x
k = (k << 1) + static_cast<std::size_t>(tree_[k] < x);
}
// Strip trailing 1-bits (right branches) + the 0-bit of the last left branch
const int trailing_ones = std::countr_one(k);
return k >> (trailing_ones + 1);
}
const T& node_value(std::size_t k) const noexcept { return tree_[k]; }
private:
std::size_t n_;
std::vector<T> tree_; // 1-indexed: tree_[1 .. n_]
// Populates tree_ in BFS order via an in-order traversal of the implicit binary tree
void build_inorder(std::size_t k, std::span<const T> sorted_in, std::size_t& src_idx) noexcept {
if (k > n_) return;
build_inorder(k << 1, sorted_in, src_idx);
tree_[k] = sorted_in[src_idx++];
build_inorder((k << 1) | 1, sorted_in, src_idx);
}
};
The Eytzinger layout sacrifices in-order sequential iteration and $O(1)$ rank arithmetic (computing how many elements lie between two pointers takes bit manipulation instead of pointer subtraction). Do not use Eytzinger if your workload frequently iterates over sorted ranges $[A, B]$; for range-scan-heavy workloads, use a B-Tree (or $B^+$-Tree with 16-key SIMD nodes) which preserves leaf-level sequential order while still packing 4 binary levels per cache line.