DSA DSA In Depth
0%
case ripgrepsimdstring-search

Production Case Study: Why ripgrep Is So Fast (Aho-Corasick, Boyer-Moore & SIMD Teddy)

7 min read Module 4.1: Tries, Exact String Matching & Multi-Pattern Automata Updated Oct 23, 2026
📄 Cheat Sheet
Advanced Data Structures: Aho-Corasick Multi-Pattern Automaton
Niema Moshiri Advanced Data Structures: Aho-Corasick Multi-Pattern Automaton Watch on YouTube ↗

Examines how Andrew Gallant's ripgrep extracts literal prefixes from ASTs and dispatches them to AVX2 packed-pair and Teddy shuffle masks before running the regex automaton.

0:00 / --:--

1. Architecture of a High-Throughput Search Pipeline

A production code/log search tool (ripgrep, GNU grep, Intel Hyperscan) is structured as a multi-stage funnel designed to keep the inner loop purely vectorized and branch-free:

  1. AST Literal Extraction: Given a regex $R$, the compiler analyzes the syntax tree to extract a small set of required prefix or inner literals $L(R)$. For example, \b(?:foo|bar)_baz\d+ yields the exact prefix set $\{\texttt{"foo\_baz"}, \texttt{"bar\_baz"}\}$ (or their common suffix $\texttt{"\_baz"}$).
  2. Prefilter Selection:
    • $|L| = 1$ (Single Literal): Dispatches to Packed-Pair SIMD (_mm256_cmpeq_epi8 on the two rarest bytes in the literal, inspired by Horspool's guard byte) with a Two-Way linear-time verifier.
    • $2 \le |L| \le 64$ (Small Multi-Pattern): Dispatches to SIMD Teddy, which uses the SSSE3/AVX2 in-register parallel table lookup instruction pshufb / vpshufb (_mm256_shuffle_epi8) on the low and high 4-bit nibbles of each text byte to test up to 8 pattern buckets across 32 byte positions simultaneously.
    • $|L| > 64$ (Large Multi-Pattern): Dispatches to an Aho-Corasick automaton (using a contiguous precomputed byte-transition table near the root and compact transitions deeper in the trie).
  3. Line-Boundary Expansion & Regex Verification: Only when the SIMD prefilter flags a match at byte offset $k$ does ripgrep scan backward/forward for the enclosing '\n' bytes and run the full lazy-DFA / Thompson NFA on that single line.

2. Inside SIMD Teddy: Nibble Bitmasks via vpshufb

How can a CPU test whether 32 text bytes match any of 8 arbitrary characters in just a few instructions? An AVX2 _mm256_cmpeq_epi8 only compares against one broadcast byte. Teddy exploits the fact that _mm256_shuffle_epi8(table, index) treats each half of a 256-bit register as a 16-entry lookup table indexed by the lower 4 bits ($0..15$) of each byte in index!

  • Each byte $b \in [0, 255]$ is split into its low nibble $b_{\text{lo}} = b \mathbin{\&} \text{0x0F} \in [0, 15]$ and high nibble $b_{\text{hi}} = (b \gg 4) \mathbin{\&} \text{0x0F} \in [0, 15]$.
  • We group our patterns into 8 buckets $0..7$ (one bit per bucket in a uint8 mask). For pattern prefix byte $p$ in bucket $k$, we set bit $k$ in table_lo[p & 0x0F] and bit $k$ in table_hi[p >> 4].
  • At runtime, for 32 input bytes $T[i..i+31]$, two parallel shuffles compute res = _mm256_and_si256(shuffle(table_lo, T_lo), shuffle(table_hi, T_hi)). Byte lane $j$ of res has bit $k$ set if and only if $T[i+j]$ matches both the low nibble and high nibble of a pattern in bucket $k$! Doing this across the first 2 or 3 bytes of each pattern (with shifted alignment alignr) eliminates almost all false positives before leaving SIMD registers.
Prefilter Engine Sweet Spot ($K$ Patterns) Throughput (AVX2 Core) Worst-Case Guarantee
Packed-Pair SIMD + Two-Way $K = 1$ $15\text{–}25\text{ GB/s}$ $O(N + M)$ strict linear
SIMD Teddy (1–3 byte fingerprint) $2 \le K \le 64$ $8\text{–}16\text{ GB/s}$ $O(N / 32)$ scan + bucket verify
Aho-Corasick Contiguous DFA $K > 64$ (up to $10^5$) $0.8\text{–}1.5\text{ GB/s}$ $O(N + Z)$ strict linear
Prefilter Zero-False-Negative Soundness Invariant

A literal prefilter $F$ for regex $R$ must satisfy a one-sided soundness contract: every text substring matching $R$ MUST contain at least one occurrence of some literal $w \in L(R)$ (zero false negatives). False positives (where a literal $w \in L(R)$ appears on a line that does not match the full regex $R$) are permissible for correctness and affect only performance; the full DFA/NFA verifier filters them out.

Why Boyer-Moore Loop Skips Lose to SIMD on Short Patterns

Wait—doesn't Boyer-Moore skip $M$ bytes at a time? On a 6-character pattern ($M = 6$), scalar Boyer-Moore skips ~5 bytes per iteration, but each iteration executes a data-dependent memory load and branch (s += shift[T[s + M - 1]]), limiting throughput to ~1 iteration per 2–3 clock cycles (~2 bytes/cycle). AVX2 Packed-Pair or Teddy inspects 32 bytes every 1–2 cycles (~16–32 bytes/cycle) with predictable linear hardware prefetching and zero data-dependent branches until a match occurs. Scalar Boyer-Moore only beats SIMD when $M$ is very large ($M > 64$) and the rare guard bytes occur frequently.

3. Visual Trace: SIMD Teddy Nibble-Mask Lookup (vpshufb)

32 Text Bytes T e.g. 'E' = 0x45 Lo Nibble (T & 0x0F) 0x05 -> vpshufb(LUT_lo) Hi Nibble (T >> 4) 0x04 -> vpshufb(LUT_hi) vpand (Lo & Hi) 32x 8-bit Bucket Masks pos0 & pos1 vpmovmskb Verify Buckets
flowchart LR A["Regex AST: (ERR|WARN|FATAL)_SOCKET_[0-9]{4}"] --> B["Literal Extractor: 3 Prefixes"] B --> C{"Count & Length Heuristic"} C -- "K = 1" --> D["Packed-Pair SIMD"] C -- "2 <= K <= 64" --> E["SIMD Teddy (vpshufb)"] C -- "K > 64" --> F["Aho-Corasick DFA"] D --> G{"Candidate Byte Offset Hit?"} E --> G F --> G G -- "99.99% No" --> H["Advance 32B Vector Window"] G -- "Rare Yes" --> I["Find Line Bounds & Run Lazy DFA Verifier"]
Rabin-Karp Polynomial Rolling Hash Ring & Boyer-Moore-Horspool / Teddy SIMD Skip
O(1) Rolling Hash · O(N/M) Horspool Skip

Trace Rabin-Karp O(1) modular window slides H_{i+1} = ((H_i − T[i]·B^{m−1})·B + T[i+m]) mod P, Boyer-Moore-Horspool right-to-left bad-character skips, and ripgrep Teddy 16-byte SIMD nybble-mask filtering.

Step 1 / 10
Custom Input:
Timeline

Invariant & State

Rolling Hash Recurrence: H₀ = (3·10² + 1·10¹ + 4) mod 97 = 23 (Target H("159") = 159 mod 97 = 62)
🌳 Live 2D Hierarchical Tree Topology — Rabin-Karp Polynomial Rolling Hash Ring & Boyer-Moore-Horspool / Teddy SIMD Skip Visited/Active Pivot/Root Resolved
evict ne 3 [0] 1 [1] H=314 4 [2] 1 [3] 5 [4] 9 [5] 2 [6]
[0]3evict next
[1]1
[2]4H=314
[3]1
[4]5
[5]9
[6]2
Target Pattern P
"159" (H(P) = 62)
Active Window
T[0..2] = "314"
Window Hash H_i
314 mod 97 = 23
Update Cost
O(1) Modular Ops
[Step 1] Initial window T[0..2] = "314": polynomial hash H₀ = 314 mod 97 = 23 != H("159") = 62.

4. Reference Implementation: Teddy Nibble-Mask Matcher

#include <array>
#include <cstdint>
#include <cstring>
#include <string_view>
#include <vector>

// Portable 2-byte Teddy Nibble Fingerprint Engine (models vpshufb bucket filtering)
class Teddy2BytePrefilter {
public:
    // Up to 8 patterns (1 bit per pattern bucket in uint8_t mask)
    explicit Teddy2BytePrefilter(const std::vector<std::string_view>& patterns)
        : patterns_(patterns) {
        lut_lo_[0].fill(0); lut_hi_[0].fill(0);
        lut_lo_[1].fill(0); lut_hi_[1].fill(0);
        for (size_t b = 0; b < patterns.size() && b < 8; ++b) {
            uint8_t bit = static_cast<uint8_t>(1u << b);
            for (size_t pos = 0; pos < 2; ++pos) {
                uint8_t ch = static_cast<uint8_t>(patterns[b][pos]);
                lut_lo_[pos][ch & 0x0F] |= bit;
                lut_hi_[pos][ch >> 4]   |= bit;
            }
        }
    }

    // Scans text and returns (offset, pattern_id) matches
    std::vector<std::pair<size_t, size_t>> find_all(std::string_view text) const {
        std::vector<std::pair<size_t, size_t>> hits;
        if (text.size() < 2) return hits;

        uint8_t prev_m0 = 0;
        for (size_t i = 0; i < text.size(); ++i) {
            uint8_t b = static_cast<uint8_t>(text[i]);
            // Parallel nibble table lookup (in AVX2: 2x _mm256_shuffle_epi8 + _mm256_and_si256)
            uint8_t m0 = lut_lo_[0][b & 0x0F] & lut_hi_[0][b >> 4];
            uint8_t m1 = lut_lo_[1][b & 0x0F] & lut_hi_[1][b >> 4];

            // Bucket bit k is set in cand iff text[i-1] matched pos 0 AND text[i] matched pos 1
            uint8_t cand = prev_m0 & m1;
            prev_m0 = m0;

            while (cand != 0) {
                int bucket = __builtin_ctz(cand);
                cand &= static_cast<uint8_t>(cand - 1);
                size_t start = i - 1;
                std::string_view pat = patterns_[bucket];
                if (start + pat.size() <= text.size() &&
                    std::memcmp(text.data() + start, pat.data(), pat.size()) == 0) {
                    hits.emplace_back(start, static_cast<size_t>(bucket));
                }
            }
        }
        return hits;
    }

private:
    std::vector<std::string_view> patterns_;
    std::array<std::array<uint8_t, 16>, 2> lut_lo_{};
    std::array<std::array<uint8_t, 16>, 2> lut_hi_{};
};
When Literal Prefilters Backfire: Common Prefixes & High Hit Rates

Suppose a user searches for "e[0-9]{6}" in English prose. The literal extractor extracts the single-byte prefix "e", which occurs roughly every 8 bytes in English text! Entering and exiting the SIMD prefilter only to fail inside the DFA verifier every 8 bytes is slower than running the DFA directly due to function-call and state-reload overhead. Production engines like ripgrep and regex track the empirical false-positive rate of the prefilter at runtime (or score literals by byte rarity at compile time) and dynamically disable the prefilter if it triggers too frequently.

Check yourself Question 1 of 4
Concept Check Role of Literal Extraction in `ripgrep` recall

When searching a codebase for a regular expression like `"fn\s+(?:parse|eval)_[a-z0-9_]+"`, `ripgrep` does not run its regex state machine over every byte of the input files.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

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

Why can enabling a SIMD prefilter on a high-frequency literal make overall search SLOWER than running a pure DFA directly, and how do production engines mitigate this?

Key Takeaways

ripgrep achieves multi-gigabyte-per-second search speeds by extracting required literal prefixes/substrings from the regex AST and scanning with SIMD prefilters before invoking the regex automaton.
SIMD Teddy splits each text byte into low and high 4-bit nibbles and uses vpshufb (_mm256_shuffle_epi8) as a 16-entry parallel lookup table to test 8 pattern buckets across 32 bytes per cycle.
Prefilters must guarantee zero false negatives and monitor false-positive rates so common literals (like 'e' or ' ') do not thrash between the SIMD scanner and the regex verifier.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue