DSA DSA In Depth
0%
case timsortpdqsortproduction-internals

Production Case Study: Python/V8 TimSort (Galloping & Run Invariants) vs Rust pdqsort

7 min read Module 2.2: Sorting Mechanics, Hybrid Sorts & Linear-Time Order Statistics Updated Oct 23, 2026
📄 Cheat Sheet
TimSort: Adaptive Natural Run Merging, Minrun & Galloping Mode
Gaurav Sen TimSort: Adaptive Natural Run Merging, Minrun & Galloping Mode Watch on YouTube ↗

Explains how production standard-library sorts exploit natural sorted runs, branchless partitioning, and cache-friendly merge buffers to outperform textbook sorts by 3-10x on real-world data.

0:00 / --:--

Part I: TimSort (Tim Peters, 2002) — Adaptive Stable Merging

Designed by Tim Peters in 2002 for CPython (and subsequently adopted by Java Arrays.sort(Object[]), Android, and V8's Array.prototype.sort), TimSort is a natural adaptive Merge Sort built around four hardware-tuned mechanisms:

  1. Natural Run Detection & Reverse-Run Inversion: A linear scan identifies the longest prefix starting at index $i$ that is either non-decreasing ($A[0] \le A[1] \le \dots$) or strictly decreasing ($A[0] > A[1] > \dots$). Why strictly decreasing? Reversing a non-strictly decreasing run ($A[0] \ge A[1]$) in place would flip equal keys and violate stability!
  2. Minimum Run Length (minrun $\in [32, 64]$): If a natural run is shorter than minrun, TimSort extends it to length minrun using Binary Insertion Sort. Furthermore, minrun is chosen so that $N / \text{minrun}$ is a power of $2$ (or slightly less than a power of $2$), ensuring the final top-level merges are perfectly balanced!
  3. The Run Stack Balance Invariants: Active runs are pushed onto a stack. To ensure merges happen between runs of comparable size while bounding stack depth by $O(\log N)$, TimSort maintains two invariants on the top three run lengths $X, Y, Z$ (where $Z$ is on top of the stack): $$X > Y + Z \quad \text{and} \quad Y > Z$$ Notice this is the exact recurrence of the Fibonacci numbers! If either invariant is violated, $Y$ is merged with $\min(X, Z)$.
  4. Galloping Mode (MIN_GALLOP = 7): During a two-finger merge of runs $A$ and $B$, if one run wins $7$ times in a row, TimSort switches from $1$-by-$1$ linear comparison to Galloping (Exponential + Binary Search): it probes offsets $1, 3, 7, 15, 2^k - 1$ to find the entire contiguous chunk of $A$ smaller than $B[j]$ in $O(\log m)$ comparisons and copies the whole chunk via a single vectorized memmove!
The 2015 TimSort Stack-Overflow Bug (De Gouw et al. & Powersort Replacement)

For 13 years, CPython and Java allocated a fixed 85-slot run stack assuming the condition X > Y + Z && Y > Z forced run lengths on the stack to grow at least as fast as Fibonacci numbers. In 2015, Stijn de Gouw et al. used formal verification (KeY) to prove that merging $Y$ with $X$ can break the invariant for the element below $X$ ($W \le X_{\text{new}} + Z$), allowing an adversarial input to overflow the fixed run stack! Modern implementations either check 4 stack elements ($W, X, Y, Z$) or replace the stack heuristic entirely with Munro & Wild's Powersort (2018) (now standard in CPython 3.11+ and PyPy), which assigns each boundary a binary tree node power in $O(1)$ bit operations (__builtin_clz(mid_A ^ mid_B)) and guarantees near-optimal merge entropy with a trivially provable $\lceil \log_2 N \rceil + 1$ stack bound.

Part II: Pattern-Defeating Quicksort (pdqsort, Orson Peters 2021)

When stability is not required (sorting primitive slices in Rust's slice::sort_unstable or Go 1.19+'s slices.Sort), allocating $O(N)$ merge space is undesirable. Orson Peters's pdqsort upgrades Musser's Introsort into an adaptive, branchless $O(1)$-auxiliary-space engine:

  • Adaptive $O(N)$ Fast Paths on Pre-Sorted / Equal Data: When a partition moves zero elements (meaning no keys crossed the pivot), the subarray might already be sorted! pdqsort runs a bounded speculative Insertion Sort (partial_insertion_sort allowing up to 8 misplaced elements)—finishing in $O(N)$ time on sorted and nearly-sorted arrays.
  • 3-Way Left-Partitioning on Duplicates: If the chosen pivot equals the predecessor element from the parent partition ($v == A[lo - 1]$), all keys strictly less than $v$ were already partitioned to the left. A single PartitionEqual pass packs all keys equal to $v$ and skips them!
  • Branchless BlockQuicksort + Pattern Busting: Uses 64-element offset blocks to avoid branch mispredictions when types have branchless comparators, and deterministically shuffles endpoint elements when a highly unbalanced split ($< N/8$) occurs before falling back to Heapsort after $\log_2 N$ bad splits.
TimSort Natural Run Stack (A > B+C), Galloping & pdqsort BlockQuicksort
O(N) Adaptive · O(N log N) Worst

Explore TimSort natural run detection, Fibonacci-bounded run stack invariants (|Z| > |Y|+|X| ∧ |Y| > |X|), exponential galloping merge, and Rust pdqsort branchless offsets.

Step 1 / 7
Custom Input:
Timeline

Invariant & State

TimSort Stack Invariant: For top 3 runs Z, Y, X: (1) |Z| > |Y| + |X| and (2) |Y| > |X|. This forces run lengths to grow at least as fast as Fibonacci numbers (stack height ≤ 85 for 2⁶⁴).
🔗 Live Pointer & State Transition Graph — TimSort Natural Run Stack (A > B+C), Galloping & pdqsort BlockQuicksort Active Pointer Jump/Transition Matched/Optimal
|Z|=64 Run Z… [0] |Y|=32 Run Y… [1] |Y| ≤ | Run X… [2] Fibonac Merge… [3]
[0]Run Z: len=64|Z|=64
[1]Run Y: len=32|Y|=32
[2]Run X: len=40 (Violates |Y| > |X|!)|Y| ≤ |X| Violation!
[3]Merge(Y, Z) or Merge(Y, X) ⇒ Balanced!Fibonacci Stack Bound
MinRun Length
32..64 (chosen so N/minrun ≈ 2^k)
Gallop Threshold
MIN_GALLOP = 7 consecutive wins
Adaptive Best Case
O(N) comparisons
[Step 1] Detected |Y|=32 ≤ |X|=40 violation on run stack; merged Y with smaller neighbor to restore Fibonacci invariant.
Property TimSort / Powersort (Python, V8, Java) pdqsort (Rust sort_unstable, Go slices.Sort) Classic Introsort (C++ std::sort)
Worst-Case Time $\Theta(N \log N)$ $\Theta(N \log N)$ (Heapsort fallback) $\Theta(N \log N)$ (Heapsort fallback)
Sorted / Reverse / Equal Time $\Theta(N)$ ($N-1$ comparisons!) $\Theta(N)$ (speculative insertion + eq partition) $\Theta(N \log N)$
$k$ Distinct Keys Time $O(N \log N)$ (or $O(N \log k)$ via galloping) $\Theta(N \log k)$ (entropy-optimal) $\Theta(N \log N)$
Auxiliary Memory Up to $N/2$ pointers ($O(N)$ worst, $O(1)$ sorted) $O(\log N)$ stack frames ($O(1)$ heap) $O(\log N)$ stack frames
Stable? Yes (Mandatory for object/UI sorts) No (Unstable primitive speed) No
TimSort Galloping Mode: Triggered When One Run Wins MIN_GALLOP = 7 Consecutive Comparisons Run A: [10, 11, 12, 13, 14, 15, 16, 17, ..., 48, 85, 90] Run B: [50, 52, 55, ...] +1 (11) +3 (13) +7 (17) +15 (25) +31 (41) +63 (85 > 50) Binary Search in [offset+31 .. offset+63] Result: Locates all 39 elements < 50 in just 12 comparisons, then copies the entire block via a single SIMD memmove!
flowchart TD A["pdqsort Partition Step"] --> B{"Was Pivot Equal to Left Neighbor A[lo-1]?"} B -- "Yes" --> C["partition_equal(lo, hi): Pack all == pivot & advance lo in O(N)"] B -- "No" --> D["Branchless Block Partition Track: Was already partitioned (0 swaps)?"] D --> E{"0 Swaps & Balanced Split?"} E -- "Yes" --> F["partial_insertion_sort (≤ 8 moves): Finishes in O(N) on sorted/nearly-sorted!"] E -- "No" --> G{"Bad Split (< N/8)?"} G -- "Yes" --> H["Decrement bad_allowed; Shuffle Endpoints If bad_allowed == 0 → Heapsort O(N log N)"] G -- "No" --> I["Recurse on Left, Loop on Right"]
Hardware Reality: Why TimSort Only Allocates min(|A|, |B|) Temp Buffer Space

When merging an adjacent run $A$ of length $10{,}000$ with a run $B$ of length $400$, copying both $A$ and $B$ into a temporary buffer would waste memory bandwidth. TimSort copies only the smaller run into temporary storage: if $|A| \le |B|$, it copies $A$ to tmp and merges left-to-right into $A$'s vacated space; if $|B| < |A|$, it copies $B$ to tmp and merges right-to-left starting from the end of $B$! Combined with galloping boundary trimming (trimming elements of $A$ already smaller than $B[0]$ and elements of $B$ already larger than $A[\text{last}]$ before copying anything), TimSort often allocates only a few kilobytes of L1-resident scratch space.

Production C++20 Implementation: Galloping Search (gallop_left / gallop_right) & Speculative Insertion

#include <algorithm>
#include <concepts>
#include <cstddef>
#include <utility>

// TimSort's gallop_right: Exponential + Binary search for the first index k in a[0..n-1]
// where key < a[k] (so all elements in a[0..k-1] are <= key, preserving stability).
template <std::totally_ordered T>
std::size_t gallop_right(const T& key, const T* a, std::size_t n, std::size_t hint = 0) noexcept {
    std::size_t last_ofs = 0;
    std::size_t ofs = 1;

    if (key < a[hint]) {
        // Gallop left from hint
        const std::size_t max_ofs = hint + 1;
        while (ofs < max_ofs && key < a[hint - ofs]) {
            last_ofs = ofs;
            ofs = (ofs << 1) + 1;
        }
        if (ofs > max_ofs) ofs = max_ofs;
        std::size_t tmp = last_ofs;
        last_ofs = hint - ofs;
        ofs = hint - tmp;
    } else {
        // Gallop right from hint: offsets 1, 3, 7, 15, ...
        const std::size_t max_ofs = n - hint;
        while (ofs < max_ofs && !(key < a[hint + ofs])) {
            last_ofs = ofs;
            ofs = (ofs << 1) + 1;
        }
        if (ofs > max_ofs) ofs = max_ofs;
        last_ofs += hint;
        ofs += hint;
    }

    // Standard half-open binary search inside the bracketed window (last_ofs, ofs]
    std::size_t lo = last_ofs;
    std::size_t hi = ofs;
    while (lo < hi) {
        const std::size_t mid = lo + ((hi - lo) >> 1);
        if (key < a[mid]) hi = mid;
        else              lo = mid + 1;
    }
    return lo;
}

// pdqsort's Speculative Partial Insertion Sort: sorts in O(N) if <= 8 elements are out of order,
// or aborts immediately in O(1) once 8 inversions are exceeded!
template <std::totally_ordered T>
bool partial_insertion_sort(T* first, T* last) noexcept {
    if (last - first <= 1) return true;
    constexpr int MAX_STEPS = 8;
    int steps = 0;

    for (T* cur = first + 1; cur != last; ++cur) {
        if (*cur < *(cur - 1)) {
            if (++steps > MAX_STEPS) return false; // Abort early: array is not nearly sorted
            T val = std::move(*cur);
            T* j = cur;
            do {
                *j = std::move(*(j - 1));
                --j;
            } while (j != first && val < *(j - 1));
            *j = std::move(val);
        }
    }
    return true;
}
When Galloping Hurts: Adaptive min_gallop Adjustment

On uniformly random data, galloping performs $\approx 2\log_2 k$ comparisons to advance just $k = 1$ or $2$ elements—more comparisons than a simple linear merge! To prevent galloping from slowing down random inputs, TimSort dynamically adjusts its threshold min_gallop: every time galloping succeeds in copying at least MIN_GALLOP = 7 elements, min_gallop is decremented by $1$ (making re-entry into galloping easier); every time galloping exits early because both runs failed to yield a block of $\ge 7$ elements, min_gallop is incremented by $1$, smoothly disabling galloping on noisy data.

Check yourself Question 1 of 4
Concept Check Why TimSort Only Reverses Strictly Decreasing Natural Runs recall

When TimSort scans for natural runs starting at $A[lo]$, it accepts non-decreasing runs ($A[i] \le A[i+1]$) or <em>strictly</em> decreasing runs ($A[i] > A[i+1]$), reversing the latter in-place.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

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

Why did this allow an attacker to overflow a fixed-size run stack in Java and Python, and what fourth-element check fixes it?

Key Takeaways

TimSort exploits real-world sorted structure by identifying natural ascending or strictly-descending runs, padding short runs to minrun ∈ [32, 64] via Binary Insertion Sort.
To preserve sorting stability, descending runs must be strictly decreasing (A[i] > A[i+1]) before being reversed in-place.
TimSort's stack invariant (X > Y + Z and Y > Z, upgraded to 4-element check or Powersort after the 2015 bug) guarantees balanced merges and O(log N) stack space, while Galloping mode accelerates skewed merges via exponential search + block memmove.
Rust's pdqsort combines branchless BlockQuicksort, speculative partial_insertion_sort (<= 8 moves), and equal-key left partitioning to achieve O(N) adaptive best-case and O(N log N) worst-case time in O(log N) space.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue