perf: DFA backend with SIMD prefilter (v0.2.1) - #2
Merged
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
[]uint32transition table and premultiplied state IDs. All failure transitions pre-computed at build time. Search = one table lookup per byte:trans[sid + class]bytes.IndexByte(SSE2/AVX2 on amd64). Scans for pattern start bytes before and during DFA traversal. Skips entire non-matching regions in bulkBenchmarks (Intel i7-1255U, 64KB haystack, 4-7 patterns)
FindIsMatch(no match)IsMatch(match@32KB)FindAll(77B, 10 matches)Breaking changes
None. Public API unchanged. NFA is retained as intermediate build step.
Test plan
go vetcleangolangci-lint run— 0 issues