Skip to content

perf: DFA backend with SIMD prefilter (v0.2.1) - #2

Merged
kolkov merged 10 commits into
mainfrom
perf/dfa-prefilter
Mar 18, 2026
Merged

kolkov merged 10 commits into
mainfrom
perf/dfa-prefilter

Conversation

@kolkov

@kolkov kolkov commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the NFA search backend with a fully compiled DFA + SIMD-accelerated prefilter, delivering 11-25x throughput improvement across all search methods.

Key changes

  • DFA backend with flat []uint32 transition table and premultiplied state IDs. All failure transitions pre-computed at build time. Search = one table lookup per byte: trans[sid + class]
  • Match flag in high bit of each transition entry. Non-match bytes (common case) need zero masking — the raw value IS the clean state ID
  • SIMD prefilter via bytes.IndexByte (SSE2/AVX2 on amd64). Scans for pattern start bytes before and during DFA traversal. Skips entire non-matching regions in bulk
  • Skip-ahead on start state — when DFA returns to start state, re-engages prefilter to jump to next potential match position (same strategy as BurntSushi's Rust implementation)
  • Inline DFA loop in FindAll — eliminates per-match heap allocations (14 → 4 allocs)

Benchmarks (Intel i7-1255U, 64KB haystack, 4-7 patterns)

Method v0.1.0 v0.2.1 Improvement
Find 300 MB/s 4.0 GB/s 13x
IsMatch (no match) 260 MB/s 6.5 GB/s 25x
IsMatch (match@32KB) 545 MB/s 7.4 GB/s 14x
FindAll (77B, 10 matches) 40 MB/s, 14 alloc 125 MB/s, 4 alloc 3x

Breaking changes

None. Public API unchanged. NFA is retained as intermediate build step.

Test plan

  • All 27 unit tests pass
  • 2 fuzz test seeds pass
  • go vet clean
  • golangci-lint run — 0 issues
  • Benchmarks verified (no regressions)

kolkov added 10 commits March 18, 2026 12:51
…ate IDs

- Single flat []uint32 transition table with pre-computed failure transitions
- Premultiplied state IDs: lookup = trans[sid + byteClass] (one add, one load)
- Match flag in high bit of transitions for inline match detection
- Dual tables: transFlagged (Find) and trans (IsMatch) for optimal hot loops
- Match bitmap for compact match state tracking

Benchmark improvements vs NFA baseline:
- Find:             ~300 -> ~590 MB/s (+97%)
- IsMatch NoMatch:  ~260 -> ~380 MB/s (+46%)
- IsMatch WithMatch:~545 -> ~690 MB/s (+27%)
Removed duplicate trans/transFlagged tables. Single trans table with
match flag in high bit serves both IsMatch and Find paths.
Memory usage reduced by ~50% with no performance regression.
Added prefilter using bytes.IndexByte (SIMD-optimized on amd64) that
checks if any pattern start byte exists in the haystack before running
the full DFA. If none of the start bytes are found, no pattern can
possibly match, so we return false immediately.

This dramatically accelerates the common no-match case where the haystack
contains none of the pattern characters.

Technique: collect all distinct first bytes of patterns at build time.
At search time, scan haystack for each start byte using bytes.IndexByte
(processes 16-32 bytes per cycle via SSE/AVX). If no start byte found,
skip the full O(n) DFA traversal entirely.

Benchmark results (64KB haystack, 4 patterns):
  IsMatch NoMatch:    260 MB/s -> 5,800 MB/s  (22x faster)
  IsMatch Mixed:      260 MB/s -> 5,600 MB/s  (21x faster)
  IsMatch WithMatch:  545 MB/s ->   750 MB/s  (+38%)
Extended the SIMD-accelerated start byte prefilter to the Find method.
Before running the full DFA traversal, check if any pattern start byte
exists in haystack[start:] using bytes.IndexByte. If none found, return
nil immediately without scanning.

Benchmark improvements vs original NFA baseline:
  Find:              300 MB/s ->   500 MB/s (+67%)
  IsMatch NoMatch:   260 MB/s -> 6,500 MB/s (25x faster)
  IsMatch WithMatch: 545 MB/s ->   780 MB/s (+43%)
  FindAll:            40 MB/s ->    68 MB/s (+70%)
Cleaned up dead code after DFA migration:
- Removed NFA nextState, isMatch, getMatches, stateCount methods
  (NFA is now only used as intermediate representation for DFA build)
- Removed matchBitmap from DFA (match flag in transition table made it redundant)
- Consolidated to single transition table (transFlagged removed earlier)

No functional or performance changes - pure cleanup.
Inspired by BurntSushi's Rust aho-corasick, integrated the prefilter
directly into the search loop instead of just checking before the loop.

Technique: when the DFA returns to start state during search (meaning no
pattern prefix is currently being tracked), use bytes.IndexByte (SIMD) to
skip ahead to the next position where a pattern start byte occurs. This
avoids processing long runs of non-pattern bytes one at a time.

For Find: jump to first start byte before entering the DFA loop.
For IsMatch: re-engage prefilter whenever automaton returns to start state.

A 128-byte minimum threshold prevents prefilter overhead on short haystacks
where the DFA alone is faster (FindAll benchmark uses 77-byte haystack).

Benchmark results vs NFA baseline:
  Find:              300 MB/s -> 3,400 MB/s  (11x faster)
  IsMatch NoMatch:   260 MB/s -> 5,700 MB/s  (22x faster)
  IsMatch WithMatch: 545 MB/s -> 6,800 MB/s  (12x faster)
  FindAll:            40 MB/s ->    35 MB/s   (slight regression on 77B input)
Moved sid&matchMask from every-byte to match-only path in Find, FindAt,
and FindAllOverlapping. Key insight: when raw has no match flag (the
common case for non-match bytes), raw IS the clean state ID — storing it
directly saves one AND instruction per byte in the hot loop.

Before (every byte):
  raw = trans[sid&matchMask + class]  // AND on every byte
  sid = raw

After (common path, no match):
  raw = trans[sid + class]            // no masking needed
  sid = raw                           // raw is already clean

Match path (rare):
  sid = raw & matchMask               // mask only when match found

Final benchmark results vs NFA baseline (all improvements confirmed):
  Find:              300 MB/s -> 3,400 MB/s  (11x)
  IsMatch NoMatch:   260 MB/s -> 6,200 MB/s  (24x)
  IsMatch WithMatch: 545 MB/s -> 6,800 MB/s  (12x)
  FindAll:            40 MB/s ->    70 MB/s   (+75%)
Replaced FindAll's Find-per-match approach with a single inline DFA loop.
Previously each match required a heap-allocated *Match from Find(), then
copied into the result slice. Now matches are constructed as stack values
directly in the append, avoiding heap escape entirely.

Before: 14 allocs/op (10 *Match heap allocs + 4 slice grows)
After:   4 allocs/op (4 slice grows only)

Benchmark (77B haystack, 8 patterns, ~10 matches):
  FindAll:  40 MB/s, 14 allocs -> 100 MB/s, 4 allocs  (2.5x, -71% allocs)
- CHANGELOG.md: added v0.2.1 section with DFA backend, SIMD prefilter,
  skip-ahead optimization, inline FindAll, and benchmark results
- README.md: updated performance numbers (up to 7 GB/s), added
  architecture section, described DFA and prefilter techniques
- ahocorasick.go: bumped version to 0.2.1
- Remove stale nolint directives (gosec linter not triggered on these lines)
- Fix unnecessary uint32 conversion in matchOverflow lookup
- Export MemoryUsage method (was unused as private)
- Add nolint for legitimate G115 integer conversion in NFA builder
@kolkov
kolkov merged commit bee291c into main Mar 18, 2026
7 checks passed
@kolkov
kolkov deleted the perf/dfa-prefilter branch March 18, 2026 11:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant