Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Planned
- CompositeSearcher for concatenated char classes (#72)
- Look-around assertions
- ARM NEON SIMD support (waiting for Go 1.26 native SIMD)
- UTF-8 Automata optimization
- SIMD prefilter for CompositeSequenceDFA (#83)

---

## [0.10.6] - 2026-01-14

### Added
- **CompositeSequenceDFA: 5x speedup for overlapping char class patterns** (#83)
- Patterns like `\w+[0-9]+`, `[a-zA-Z0-9]+[0-9]+` where char classes overlap
- Uses NFA subset construction for correct overlap handling
- Byte class reduction: 256 bytes → 3-8 equivalence classes
- First-part skip optimization: O(1) check if position can start match
- Loop unrolling: 4 bytes per iteration (Rust-inspired)
- Performance: 300 MB/s (vs 56 MB/s CompositeSearcher, vs 12 MB/s stdlib)

- **FindAllIndexCompact API: zero per-match allocations**
- `FindAllIndexCompact(b []byte, n int, results [][2]int) [][2]int`
- Returns `[][2]int` instead of `[][]int` (single allocation vs N allocations)
- Supports buffer reuse for repeated searches
- 60974 → 9 allocations for 1MB input with many matches

### Performance
- CompositeSequenceDFA: **25x faster than stdlib** for overlapping patterns
- FindAllIndexCompact: **99.98% allocation reduction** for FindAllIndex

---

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Cross-language benchmarks on 6MB input ([source](https://github.com/kolkov/regex
- Inner literals (`.*error.*`, `.*@example\.com`) — bidirectional DFA (900x+)
- Multi-pattern (`foo|bar|baz|...`) — Slim Teddy (≤32), Fat Teddy (33-64), or Aho-Corasick (>64)
- Anchored alternations (`^(\d+|UUID|hex32)`) — O(1) branch dispatch (5-20x)
- Concatenated char classes (`[a-zA-Z]+[0-9]+`) — sequential lookup tables (5x)
- Concatenated char classes (`[a-zA-Z]+[0-9]+`) — DFA with byte classes (25x)

## Features

Expand All @@ -90,7 +90,7 @@ coregex automatically selects the optimal engine:
| ReverseInner | `.*keyword.*` | 100-900x |
| ReverseSuffix | `.*\.txt` | 100-1100x |
| BranchDispatch | `^(\d+\|UUID\|hex32)` | 5-20x |
| CompositeSearcher | `[a-zA-Z]+[0-9]+` | 5x |
| CompositeSequenceDFA | `[a-zA-Z]+[0-9]+` | 25x |
| LazyDFA | IP, complex patterns | 10-150x |
| AhoCorasick | `a\|b\|c\|...\|z` (>64 patterns) | 75-113x |
| CharClassSearcher | `[\w]+`, `\d+` | 4-25x |
Expand Down
29 changes: 25 additions & 4 deletions meta/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@ type Engine struct {
dfa *lazy.DFA
pikevm *nfa.PikeVM
boundedBacktracker *nfa.BoundedBacktracker
charClassSearcher *nfa.CharClassSearcher // Specialized searcher for char_class+ patterns
compositeSearcher *nfa.CompositeSearcher // For concatenated char classes like [a-zA-Z]+[0-9]+
branchDispatcher *nfa.BranchDispatcher // O(1) branch dispatch for anchored alternations
anchoredFirstBytes *nfa.FirstByteSet // O(1) first-byte rejection for anchored patterns
charClassSearcher *nfa.CharClassSearcher // Specialized searcher for char_class+ patterns
compositeSearcher *nfa.CompositeSearcher // For concatenated char classes like [a-zA-Z]+[0-9]+
compositeSequenceDFA *nfa.CompositeSequenceDFA // DFA for composite patterns (faster than backtracking)
branchDispatcher *nfa.BranchDispatcher // O(1) branch dispatch for anchored alternations
anchoredFirstBytes *nfa.FirstByteSet // O(1) first-byte rejection for anchored patterns
reverseSearcher *ReverseAnchoredSearcher
reverseSuffixSearcher *ReverseSuffixSearcher
reverseSuffixSetSearcher *ReverseSuffixSetSearcher
Expand Down Expand Up @@ -346,6 +347,7 @@ type charClassSearcherResult struct {
boundedBT *nfa.BoundedBacktracker
charClassSrch *nfa.CharClassSearcher
compositeSrch *nfa.CompositeSearcher
compositeSeqDFA *nfa.CompositeSequenceDFA // DFA (faster than backtracking)
branchDispatcher *nfa.BranchDispatcher
finalStrategy Strategy
}
Expand Down Expand Up @@ -385,6 +387,9 @@ func buildCharClassSearchers(
// Fallback to BoundedBacktracker if extraction fails
result.finalStrategy = UseBoundedBacktracker
result.boundedBT = nfa.NewBoundedBacktracker(nfaEngine)
} else {
// Try to build faster DFA (uses subset construction for overlapping patterns)
result.compositeSeqDFA = nfa.NewCompositeSequenceDFA(re)
}
}

Expand Down Expand Up @@ -526,6 +531,7 @@ func CompileRegexp(re *syntax.Regexp, config Config) (*Engine, error) {
boundedBacktracker: charClassResult.boundedBT,
charClassSearcher: charClassResult.charClassSrch,
compositeSearcher: charClassResult.compositeSrch,
compositeSequenceDFA: charClassResult.compositeSeqDFA,
branchDispatcher: charClassResult.branchDispatcher,
anchoredFirstBytes: anchoredFirstBytes,
reverseSearcher: engines.reverseSearcher,
Expand Down Expand Up @@ -1548,6 +1554,11 @@ func (e *Engine) findCompositeSearcherAt(haystack []byte, at int) *Match {

// isMatchCompositeSearcher checks for match using CompositeSearcher.
func (e *Engine) isMatchCompositeSearcher(haystack []byte) bool {
// Prefer DFA over backtracking
if e.compositeSequenceDFA != nil {
atomic.AddUint64(&e.stats.DFASearches, 1)
return e.compositeSequenceDFA.IsMatch(haystack)
}
if e.compositeSearcher == nil {
return e.isMatchNFA(haystack)
}
Expand All @@ -1557,6 +1568,11 @@ func (e *Engine) isMatchCompositeSearcher(haystack []byte) bool {

// findIndicesCompositeSearcher searches using CompositeSearcher - zero alloc.
func (e *Engine) findIndicesCompositeSearcher(haystack []byte) (int, int, bool) {
// Prefer DFA over backtracking (2-4x faster for overlapping patterns)
if e.compositeSequenceDFA != nil {
atomic.AddUint64(&e.stats.DFASearches, 1)
return e.compositeSequenceDFA.Search(haystack)
}
if e.compositeSearcher == nil {
return e.findIndicesNFA(haystack)
}
Expand All @@ -1566,6 +1582,11 @@ func (e *Engine) findIndicesCompositeSearcher(haystack []byte) (int, int, bool)

// findIndicesCompositeSearcherAt searches using CompositeSearcher at position - zero alloc.
func (e *Engine) findIndicesCompositeSearcherAt(haystack []byte, at int) (int, int, bool) {
// Prefer DFA over backtracking (2-4x faster for overlapping patterns)
if e.compositeSequenceDFA != nil {
atomic.AddUint64(&e.stats.DFASearches, 1)
return e.compositeSequenceDFA.SearchAt(haystack, at)
}
if e.compositeSearcher == nil {
return e.findIndicesNFAAt(haystack, at)
}
Expand Down
17 changes: 13 additions & 4 deletions nfa/composite.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,15 @@ import (
// - \d+\s+\w+ → digits, whitespace, word chars
// - [a-z]+[A-Z]+ → lowercase then uppercase
//
// Thread safety: NOT thread-safe. For concurrent usage, each goroutine needs its own instance.
//
// Reference: https://github.com/coregx/coregex/issues/72
type CompositeSearcher struct {
parts []*charClassPart

// matchLengths is pre-allocated scratch space for backtracking.
// Reused across calls to avoid per-match allocations.
matchLengths []int
}

// charClassPart represents one segment of a composite pattern.
Expand All @@ -47,7 +53,8 @@ func NewCompositeSearcher(re *syntax.Regexp) *CompositeSearcher {
}

return &CompositeSearcher{
parts: parts,
parts: parts,
matchLengths: make([]int, len(parts)), // Pre-allocate to avoid per-match allocation
}
}

Expand Down Expand Up @@ -198,9 +205,11 @@ func (c *CompositeSearcher) SearchAt(haystack []byte, at int) (int, int, bool) {
// consumes all 6 characters. Backtracking gives back digits until
// [0-9]+ can match its minimum (1 character).
func (c *CompositeSearcher) matchAt(haystack []byte, pos int) (int, bool) {
// matchLengths[i] = how many chars part i consumed
matchLengths := make([]int, len(c.parts))
return c.matchAtWithBacktrack(haystack, pos, 0, matchLengths)
// Reset pre-allocated matchLengths (faster than allocating new slice)
for i := range c.matchLengths {
c.matchLengths[i] = 0
}
return c.matchAtWithBacktrack(haystack, pos, 0, c.matchLengths)
}

// matchAtWithBacktrack recursively matches parts with backtracking support.
Expand Down
Loading