DSA DSA In Depth
0%
case eytzingercache-localitybinary-search

Production Case Study: Cache-Friendly Eytzinger Binary Search Layout vs Standard Binary Search

7 min read Module 2.3: Binary Search on Monotonic Spaces & Divide-and-Conquer Updated Oct 23, 2026
📄 Cheat Sheet
Fast-Forwarding Binary Search: Cache-Friendly Eytzinger Layout (Algorithmica / Morin)
CppCon Fast-Forwarding Binary Search: Cache-Friendly Eytzinger Layout (Algorithmica / Morin) Watch on YouTube ↗

Explains why std::lower_bound is bottlenecked by cache misses and associative cache-set aliasing on power-of-2 arrays, and how Eytzinger layout with software prefetching achieves a 3x-4x speedup.

0:00 / --:--

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):

  1. 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.
  2. 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).
  3. 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]$.
Fractional Cascading O(log N + k) Bridges & Eytzinger Cache-Line Search
O(log N + k) / Branchless BFS Layout

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.

Step 1 / 7
Custom Input:
Timeline

Invariant & State

Fractional Cascading Invariant: Promoting every 2nd element of M_{i+1} into M_i ensures at most 1 unpromoted element lies between consecutive bridges, so each lower level requires at most 1 comparison!
📊 Live Magnitude & Pointer Stage — Fractional Cascading O(log N + k) Bridges & Eytzinger Cache-Line Search Active Pivot Match/Sorted
* = Pro L₁ Aug… [0] O(log N Binary… [1] O(1) Br L₂: [1… [2]
[0]L₁ Augmented: [10, *18, 25, *42, 60]* = Promoted from L₂
[1]Binary Search L₁(x=22) ⇒ 25 (bridge→idx 2)O(log N) once at L₁
[2]L₂: [12, 18, 30, 42] (Check idx 1..2 in O(1)!)O(1) Bridge Hop!
Naive k Catalogs
O(k log N)
Fractional Cascading
O(log N + k)
Space Overhead
∑ N_i · (1 + 1/2 + 1/4 + ...) ≤ 2N
[Step 1] Binary searched L₁ once for x=22, then followed O(1) bridge pointer into L₂ and checked 1 neighbor.
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!)
Why Eytzinger Unlocks 4-Levels-Ahead Software Prefetching

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!

Eytzinger (BFS) Layout: Sorted [0..14] vs. 1-Indexed Level-Order B[1..15] B[1] = 7 B[2] = 3 B[3] = 11 B[4] = 1 B[5] = 5 B[6] = 9 B[7] = 13 Single 64-Byte Cache Line (16 × uint32_t): Holds B[0..15] (Top 4 Tree Levels Entirely in L1!) idx: [0:-] [1:7] [2:3] [3:11] [4:1] [5:5] [6:9] [7:13] [8:0] [9:2] [10:4] [11:6] [12:8] [13:10] [14:12] [15:14]
flowchart LR A["Start at Root k = 1"] --> B["Prefetch 4 Levels Ahead: __builtin_prefetch(&B[16 * k])"] B --> C["Branchless Child Step: k = 2 * k + (B[k] < x) Repeat while k <= N"] C --> D["Decode Answer Index: uint32_t shift = __builtin_ffs(~k) ans_node = k >> shift"]
Hardware Reality: Decoding the Final Eytzinger Node with __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);
    }
};
When NOT to Use the Eytzinger Layout

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.

Check yourself Question 1 of 4
Concept Check Indexing Rules of the 1-Indexed Eytzinger (BFS) Array Layout recall

A static sorted set of N keys is rearranged into a 1-indexed Eytzinger array B[1..N] (Breadth-First Search level-order of the implicit binary search tree).

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

What hardware cache mechanism causes `std::lower_bound` to suffer severe slowdowns specifically when N is a power of two?

Key Takeaways

Standard sorted binary search wastes 60 of every 64 bytes in a cache line during the first log_2(N) - 4 probes and suffers associative cache-set aliasing when N is a power of 2.
The Eytzinger layout stores the implicit binary search tree in 1-indexed BFS order (root at 1, children at 2k and 2k+1), packing the top 4 levels into a single 64-byte cache line.
Because all 16 great-great-grandchildren of node k lie contiguously at indices [16k .. 16k+15], __builtin_prefetch(&tree_[16*k]) prefetches the exact cache line needed 4 steps later.
After the branchless loop while (k <= N) k = 2*k + (tree_[k] < x), the lower_bound node is decoded in O(1) via k >> (std::countr_one(k) + 1).
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue