Skip to content

Commit afd9d8d

Browse files
authored
perf: WrapLineAnchor for (?m)^ patterns — O(1) line-start check instead of NFA (#148)
WrapIncomplete forced NFA verification for every prefilter candidate on (?m)^ multiline patterns, causing 7x regression on ARM64 (LogParser 2s->14s). WrapLineAnchor checks line-start in O(1) (pos==0 || haystack[pos-1]==newline), keeps IsComplete()=true so Teddy returns matches directly without NFA. LangArena methods: 755ms -> <1ms. multiline_php on macOS ARM64: 63ms -> 1.55ms (41x faster). Stdlib compat: 38/38 PASS (was 34/38 with 4 skipped).
1 parent acda90a commit afd9d8d

3 files changed

Lines changed: 99 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- ARM NEON SIMD support (Go 1.26 `simd/archsimd` intrinsics — [#120](https://github.com/coregx/coregex/issues/120))
1313
- SIMD prefilter for CompositeSequenceDFA (#83)
1414

15+
## [0.12.16] - 2026-03-21
16+
17+
### Performance
18+
- **`WrapLineAnchor` for `(?m)^` patterns**`WrapIncomplete` forced NFA
19+
verification for every prefilter candidate on multiline line-anchor patterns,
20+
causing 7x regression on ARM64 (LogParser 2s → 14s, reported by @kostya).
21+
`WrapLineAnchor` checks line-start position in O(1) (`pos==0 || haystack[pos-1]=='\n'`),
22+
keeping `IsComplete()=true` so Teddy returns matches directly without NFA.
23+
LangArena `methods`: 755ms → **<1ms**. `multiline_php` on macOS ARM64:
24+
63ms → **1.55ms** (41x faster).
25+
26+
### Fixed
27+
- **Stdlib compatibility**`WrapLineAnchor` also fixes 4 previously skipped
28+
stdlib compat test patterns (`http_methods`, `la_methods`, `la_suspicious`,
29+
`dot_star`). Test now **38/38 PASS, 0 SKIP**.
30+
1531
## [0.12.15] - 2026-03-21
1632

1733
### Performance

meta/compile.go

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -637,17 +637,50 @@ func adjustForAnchors(pf prefilter.Prefilter, strategy Strategy, re *syntax.Rege
637637
if !hasAnchorAssertions(re) {
638638
return pf, strategy
639639
}
640-
// Mark prefilter incomplete — engine must verify anchor constraints
640+
641+
hasMultilineAnchor := hasMultilineLineAnchor(re)
642+
641643
if pf != nil && pf.IsComplete() {
642-
pf = prefilter.WrapIncomplete(pf)
644+
if hasMultilineAnchor && !hasNonLineAnchors(re) {
645+
// (?m)^ with complete literals and NO other anchors (\b, $):
646+
// Use line-anchor wrapper — O(1) line-start check per candidate.
647+
// This keeps IsComplete()=true so Teddy can return matches directly
648+
// without expensive NFA verification.
649+
pf = prefilter.WrapLineAnchor(pf)
650+
} else {
651+
// Other anchors (\b, $) or mixed anchors:
652+
// Mark incomplete — engine must verify with NFA/DFA.
653+
pf = prefilter.WrapIncomplete(pf)
654+
}
643655
}
656+
644657
// DFA can't verify (?m)^ multiline line anchors — use NFA
645-
if strategy == UseDFA && hasMultilineLineAnchor(re) {
658+
if strategy == UseDFA && hasMultilineAnchor {
646659
strategy = UseNFA
647660
}
648661
return pf, strategy
649662
}
650663

664+
// hasNonLineAnchors checks if the pattern has anchors other than (?m)^ line start.
665+
// Returns true for \b, $, \A, \z, or non-multiline ^.
666+
func hasNonLineAnchors(re *syntax.Regexp) bool {
667+
if re == nil {
668+
return false
669+
}
670+
switch re.Op {
671+
case syntax.OpBeginLine:
672+
return false // (?m)^ is fine
673+
case syntax.OpEndLine, syntax.OpEndText, syntax.OpBeginText, syntax.OpWordBoundary, syntax.OpNoWordBoundary:
674+
return true
675+
}
676+
for _, sub := range re.Sub {
677+
if hasNonLineAnchors(sub) {
678+
return true
679+
}
680+
}
681+
return false
682+
}
683+
651684
// buildSearchStateConfig extracts all DFA references needed for per-search caches.
652685
// Strategy-specific DFAs come from reverse searchers (which have their own DFAs).
653686
func buildSearchStateConfig(nfaEngine *nfa.NFA, numCaptures int, engines strategyEngines, strategy Strategy) searchStateConfig {

prefilter/wrap.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,50 @@ func (w *incompleteWrapper) HeapBytes() int {
3333
func (w *incompleteWrapper) IsFast() bool {
3434
return w.inner.IsFast()
3535
}
36+
37+
// lineAnchorWrapper wraps a prefilter and adds (?m)^ line-start verification.
38+
// Instead of marking complete=false (which forces expensive NFA verification),
39+
// this wrapper checks that each candidate is at line start (pos==0 || haystack[pos-1]=='\n').
40+
// This gives O(1) verification per candidate vs O(n*states) NFA.
41+
type lineAnchorWrapper struct {
42+
inner Prefilter
43+
}
44+
45+
// WrapLineAnchor wraps a complete prefilter to add (?m)^ line-start checking.
46+
// The wrapper's Find skips candidates not at line boundaries, and IsComplete
47+
// returns true (no NFA verification needed — line check is sufficient).
48+
func WrapLineAnchor(pf Prefilter) Prefilter {
49+
return &lineAnchorWrapper{inner: pf}
50+
}
51+
52+
func (w *lineAnchorWrapper) Find(haystack []byte, start int) int {
53+
pos := start
54+
for {
55+
candidate := w.inner.Find(haystack, pos)
56+
if candidate == -1 {
57+
return -1
58+
}
59+
// Verify (?m)^ — candidate must be at start of line
60+
if candidate == 0 || haystack[candidate-1] == '\n' {
61+
return candidate
62+
}
63+
// Not at line start — skip to next candidate
64+
pos = candidate + 1
65+
}
66+
}
67+
68+
func (w *lineAnchorWrapper) IsComplete() bool {
69+
return w.inner.IsComplete()
70+
}
71+
72+
func (w *lineAnchorWrapper) LiteralLen() int {
73+
return w.inner.LiteralLen()
74+
}
75+
76+
func (w *lineAnchorWrapper) HeapBytes() int {
77+
return w.inner.HeapBytes()
78+
}
79+
80+
func (w *lineAnchorWrapper) IsFast() bool {
81+
return w.inner.IsFast()
82+
}

0 commit comments

Comments
 (0)