Skip to content

Commit 343e776

Browse files
authored
test: cover alternation exclusion and zero-length matches for ONEPASS_NFA findMatch/findBoundsFrom (#96)
1 parent 8db8417 commit 343e776

7 files changed

Lines changed: 321 additions & 151 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# ONEPASS_NFA findMatch()/findBoundsFrom() O(n²) fix — implementation plan
2+
3+
**Branch/worktree:** `fix/onepass-findmatch-perf` at `.worktrees/onepass-findmatch-perf`
4+
5+
## Problem
6+
7+
`OnePassBytecodeGenerator.generateFindMatchFromMethod` (lines 1088-1145) and
8+
`generateFindBoundsFromMethod` locate the match start via `findFrom` (O(1) extra work — `findFrom`
9+
already delegates to the O(n) `matchFrom` single pass), then **discard the end position** and
10+
re-derive it by looping `matchEnd` from `matchStart` to `input.length()`, calling
11+
`matchesInRange(input, matchStart, matchEnd)` at every candidate length (each call allocates a
12+
substring and re-runs `matches()`). That's O(remaining input) work with allocation, on top of the
13+
O(1) end position `matchFrom` already computed and threw away. Confirmed via ad-hoc benchmark:
14+
`findMatch()` degrades to ~1.6-1.7x slower than JDK as haystack length grows (5k→20k chars),
15+
consistent with the redundant rescan.
16+
17+
Boolean `find()`/`findFrom()` do NOT have this problem — they were already fixed in `6841723`.
18+
19+
## Why the direct fix is safe here (and wouldn't be for other strategies)
20+
21+
`matchFrom` returns as soon as it reaches the *first* accept state reached while scanning forward
22+
(`OnePassBytecodeGenerator.java:918-926`), before checking whether a longer match is possible. For
23+
a strategy handling nullable/optional-tail patterns (e.g. `ab?`), that would return the wrong
24+
(shortest) end position for a greedy match.
25+
26+
**This does not apply to `ONEPASS_NFA`.** `PatternAnalyzer.isOnePassEligible()`
27+
(`PatternAnalyzer.java:90-124`) requires, for every NFA state: (1) no two character transitions
28+
have overlapping char sets, and (2) at most one epsilon transition. Quantifier branch points
29+
(`?`, `*`, `+`) always produce a state with two epsilon transitions (continue-loop vs. exit), so
30+
any pattern with a quantifier is disqualified from `ONEPASS_NFA` before this code path is ever
31+
reached — confirmed empirically: `(a)b?`, `([a-z]+)`, `(a+)`, `([a-z]*)x` all route to other
32+
strategies (`DFA_UNROLLED_WITH_GROUPS`, `SPECIALIZED_GREEDY_CHARCLASS`, `SPECIALIZED_CONCAT_GREEDY_GROUP`,
33+
`RECURSIVE_DESCENT`). Every existing `ONEPASS_NFA` test pattern (`([a-z])(bar)`, `(abc)`, `(a)$`,
34+
`^(a)`, `([a-z])$`) is a quantifier-free, fixed-length concatenation, which structurally has at
35+
most one possible accepting length per start position. So `matchFrom`'s returned position is
36+
*always* the unique correct end for any pattern that can actually reach this strategy — reusing it
37+
directly is correct, not just an optimization with a correctness trade-off.
38+
39+
## Changes
40+
41+
**File:** `reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/codegen/OnePassBytecodeGenerator.java`
42+
43+
1. `generateFindMatchFromMethod` (lines 1088-1145): replace the `matchEnd` trial loop with a
44+
direct call to `matchFrom(input, matchStart)` to get the end position in one O(match-length)
45+
pass, then call `matchInRange(input, matchStart, matchEnd)` for the `MatchResult` exactly as
46+
today (unchanged — this call already only fires once, at the resolved end, no allocation
47+
change there). Drop the now-unused `matchesInRange` trial loop entirely from this method.
48+
2. `generateFindBoundsFromMethod` (lines 1288+, same O(n²) shape per its own doc comment): same
49+
fix — call `matchFrom` for the end position directly instead of trial-looping.
50+
3. No changes to `matchFrom`, `findFrom`, `matches()`, or any other generated method — boolean
51+
`find()` perf (already fixed, 3.5x JDK) is untouched.
52+
4. No `StructuralHash`/`DFAState`/`PatternInfo` field changes — this only changes generated method
53+
bodies, not compiled pattern metadata, so no structural-hash update needed.
54+
55+
## Tests
56+
57+
**New file:** `reggie-runtime/src/test/java/com/datadoghq/reggie/runtime/OnePassFindMatchPerfCorrectnessTest.java`
58+
59+
- Confirm routing to `ONEPASS_NFA` for the patterns under test via
60+
`StrategyCorrectnessMetaTest.routeOf(...)`.
61+
- Behavioral parity vs JDK for `findMatch()`/`findMatchFrom()`/`findBoundsFrom()` on:
62+
- `([a-z])(bar)` (existing meta-test representative)
63+
- `(abc)` at various positions within a longer haystack (start, middle, end, no match)
64+
- `(a)$`, `^(a)`, `([a-z])$`, `^([a-z])` (anchor cases, mirrors `OnePassNfaAnchorFindTest`)
65+
- A long-haystack case (≥10k chars) with the match near the end, to directly exercise the
66+
perf-motivated code path and catch any off-by-one in the new direct-end-position logic.
67+
- Existing `OnePassNfaAnchorFindTest` and `StrategyCorrectnessMetaTest` must continue to pass
68+
unmodified — they already cover `findMatch()` for `ONEPASS_NFA` and will catch a correctness
69+
regression from this change.
70+
71+
## Verification steps
72+
73+
1. `./gradlew :reggie-codegen:build :reggie-runtime:build` — compiles.
74+
2. `./gradlew :reggie-runtime:test --tests "OnePassFindMatchPerfCorrectnessTest"` — new tests pass.
75+
3. `./gradlew :reggie-runtime:test --tests "OnePassNfaAnchorFindTest" --tests "StrategyCorrectnessMetaTest" --tests "PikeVMRoutingTest"` — no regression.
76+
4. `./gradlew :reggie-integration-tests:test --tests "*AlgorithmicFuzzTest.divergenceGate_enforcedViaProperty" -Dreggie.fuzz.enforce=true -Dreggie.fuzz.maxFindings=28` — fuzz gate still passes at budget 28 (no new divergences).
77+
5. `./gradlew :reggie-benchmark:jmh -Pjmh.args="StrategyBenchmark.*Onepass.* -f 2 -wi 5 -i 5 -bm avgt -tu ns"` — confirm `reggieOnepassFind` unaffected (still ~3.5x JDK) and spot-check `findMatch` improvement if a benchmark method exists for it (add one if not, scoped to this task only if needed for evidence — do not expand into a general benchmark refactor).
78+
6. `./gradlew spotlessApply` before commit.
79+
7. Update `doc/agents-fallback-and-limitations.md`'s `ONEPASS_NFA` known-gap entry: either remove
80+
it (if fixed) or narrow it further based on actual measured results.
81+
82+
## Out of scope
83+
84+
- No changes to other strategies' find/match generators.
85+
- No new JMH benchmark infrastructure beyond what's needed to verify this specific fix.
86+
- No attempt to relax `isOnePassEligible()` to admit quantified patterns — that's a separate,
87+
much larger feature (would require a real greedy-longest single-pass algorithm, not this fix).

‎doc/agents-fallback-and-limitations.md‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,9 @@ with an `UnsupportedOperationException`. Use `Reggie.compile()` instead for thos
159159
The following are **performance-only** issues — not correctness issues. Observed in the JMH
160160
benchmark suite and deferred as follow-up work:
161161

162-
- **`ONEPASS_NFA` `findMatch()`/`findBoundsFrom()` regression**: boolean `find()` on `ONEPASS_NFA`
163-
patterns is native and fast (~3.5x JDK, fixed in `6841723`). The group-capturing find path
164-
(`findMatch()`, `findBoundsFrom()`, generated by `OnePassBytecodeGenerator.generateFindMatchFromMethod`,
165-
lines 1088-1224) still re-scans candidate match lengths via `matchesInRange` after `findFrom`
166-
locates the match start, instead of reusing the end offset `matchFrom` already computed —
167-
measured ~1.6-1.7x slower than JDK on long haystacks and growing with input length.
162+
- **`ONEPASS_NFA` `findMatch()`/`findBoundsFrom()`**: fixed — both now reuse `matchFrom`'s
163+
single-pass end position directly instead of re-scanning every candidate length via
164+
`matchesInRange` (was effectively O(n²) with per-length substring allocation). Boolean `find()`
165+
was already O(n) as of `6841723`.
168166
- **`SPECIALIZED_MULTI_GROUP_GREEDY` and `SPECIALIZED_BOUNDED_QUANTIFIERS`**: ~2–2.5x over JDK, the
169167
weakest gains among generated strategies. Profiling may reveal further optimization opportunities.

‎reggie-benchmark/src/main/java/com/datadoghq/reggie/benchmark/StrategyBenchmark.java‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package com.datadoghq.reggie.benchmark;
1717

18+
import com.datadoghq.reggie.runtime.MatchResult;
1819
import com.datadoghq.reggie.runtime.ReggieMatcher;
1920
import com.datadoghq.reggie.runtime.RuntimeCompiler;
2021
import java.util.concurrent.TimeUnit;
@@ -88,6 +89,25 @@ public class StrategyBenchmark {
8889
private ReggieMatcher reggieOnepass;
8990
private Pattern jdkOnepass;
9091

92+
// ── ONEPASS_NFA findMatch/findBoundsFrom (long haystack) ────────────────────
93+
// Exercises the group-capturing find path fixed in this branch: matchFrom's end position is
94+
// now reused directly instead of being re-derived via an O(remaining input) matchesInRange
95+
// trial loop. Match sits near the end of a long haystack so any leftover rescan dominates.
96+
private static final String ONEPASS_LONG_PATTERN = "(abc)";
97+
private static final String ONEPASS_LONG_INPUT;
98+
99+
static {
100+
StringBuilder sb = new StringBuilder();
101+
for (int i = 0; i < 20_000; i++) {
102+
sb.append('x');
103+
}
104+
sb.append("abc");
105+
ONEPASS_LONG_INPUT = sb.toString();
106+
}
107+
108+
private ReggieMatcher reggieOnepassLong;
109+
private Pattern jdkOnepassLong;
110+
91111
@Setup(Level.Trial)
92112
public void setup() {
93113
reggieFixedRep = RuntimeCompiler.compile(FIXED_REP_PATTERN);
@@ -101,6 +121,9 @@ public void setup() {
101121

102122
reggieOnepass = RuntimeCompiler.compile(ONEPASS_PATTERN);
103123
jdkOnepass = Pattern.compile(ONEPASS_PATTERN);
124+
125+
reggieOnepassLong = RuntimeCompiler.compile(ONEPASS_LONG_PATTERN);
126+
jdkOnepassLong = Pattern.compile(ONEPASS_LONG_PATTERN);
104127
}
105128

106129
// ── FIXED_REPETITION_BACKREF benchmarks ─────────────────────────────────────
@@ -230,4 +253,24 @@ public boolean reggieOnepassFind() {
230253
public boolean jdkOnepassFind() {
231254
return jdkOnepass.matcher(ONEPASS_FIND_INPUT).find();
232255
}
256+
257+
// ── ONEPASS_NFA findMatch/findBoundsFrom benchmarks (long haystack) ─────────
258+
259+
@Benchmark
260+
public MatchResult reggieOnepassFindMatchLong() {
261+
return reggieOnepassLong.findMatch(ONEPASS_LONG_INPUT);
262+
}
263+
264+
@Benchmark
265+
public java.util.regex.MatchResult jdkOnepassFindMatchLong() {
266+
java.util.regex.Matcher m = jdkOnepassLong.matcher(ONEPASS_LONG_INPUT);
267+
m.find();
268+
return m.toMatchResult();
269+
}
270+
271+
@Benchmark
272+
public boolean reggieOnepassFindBoundsFromLong() {
273+
int[] bounds = new int[2];
274+
return reggieOnepassLong.findBoundsFrom(ONEPASS_LONG_INPUT, 0, bounds);
275+
}
233276
}

0 commit comments

Comments
 (0)