Repository navigation
Add allMatches to Traverse function to improve performance - #511
jtmaxwell3 wants to merge 21 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #511 +/- ##
==========================================
+ Coverage 74.35% 74.41% +0.05%
==========================================
Files 456 456
Lines 38283 38492 +209
Branches 5245 5293 +48
==========================================
+ Hits 28467 28643 +176
- Misses 8666 8695 +29
- Partials 1150 1154 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
The optimization is real and worth having, but the The mechanism you found reproduces, and is worse than you reported. It isn't the state count: The problem. A differential fuzz — 20,000 random pattern/input pairs, fixed seed, identical generator compiled against both
1. Variable bindings (108 cases). 2. Capture registers (4 cases), on the deterministic path. All four are variable-free, verified to compile with So "deterministic method only" would not be a sufficient guard; group-bearing patterns diverge there too.
A narrowing that keeps the win. I prototyped and tested one: // DeterministicFsaTraversalMethod
bool dedupeByState = !allMatches && Fst.GroupNames.Count() <= 1;with Where this could go further. The rewrite-rule environment matchers look like the best target. One caveat on the measurements. I could not reproduce the 83s → 3s end to end. On the Aweti grammar I have ( Happy to open a PR with the narrowed gate, the differential fuzz harness, and a regression test for the lost-match case if that is useful. Separately, the fuzz also found 19 cases where 🤖 Analysis performed with Claude Code |
PR #511 proposes skipping traversal instances whose (State, AnnotationIndex) was already pushed. The motivating pathology is real and reproduces on a 2-state fsa: Advance forks per Optional annotation, so instances are exponential in optional count and independent of the state count. The key is too coarse. A 20,000-case differential fuzz finds 112 cases where Match() changes, 108 from ignored variable bindings and 4 from shortened ranges on the deterministic path, with AllMatches().First() identical in all 20,000 as the control. A narrowed gate -- deterministic method, no capture groups -- measures 0 divergences with the full bound preserved. Two censuses close the extensions: environment matchers are 0.20%/1.18% of traversal instances on Amharic/Mbugwe, and the analysis rules' apparent 93.95% collapse is 99.7% alternate captures, because AnalysisAffixProcessRule and AnalysisCompoundingRule set AllSubmatches and enumerate morph boundaries on purpose. That makes a fourth row for the apparent-vs-sound table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Rebuilt on the stack you named — master + All five of our grammars are partial, so #491's prune is inert by default on every one of them.
That is presumably not true of whatever you measured on, and it would explain why the stack did nothing for us. Forcing the guard on does change the behaviour, just not enough. With Measurements, all on Aweti source-list index 182,
None produced a single parse, so there is nothing to compare for either speed or parity, and I can't yet tell you whether the narrowed gate would cost you your win. Three things would let me reproduce it rather than keep guessing:
Worth noting for its own sake: when I instrumented the word on plain master, 🤖 Analysis performed with Claude Code |
…516) Two hand-built cases distilled from a 20,000-case differential fuzz (TraversalDedupDifferentialFuzzTests) comparing this branch against master. Both fail here and pass on master: - NondeterministicTraversal_DedupOnVariableBindingLosesMatch: an anchored high=$v0+ match disappears entirely because two instances reach the same (State, AnnotationIndex) with different VariableBindings, and the surviving one can never complete. - DeterministicTraversal_DedupOnRegistersShortensMatch: an alternation match is shortened because two lineages converge on the same (State, AnnotationIndex) with different open-group registers, and the surviving lineage completes earlier than the correct one. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
PR #511 proposes skipping traversal instances whose (State, AnnotationIndex) was already pushed. The motivating pathology is real and reproduces on a 2-state fsa: Advance forks per Optional annotation, so instances are exponential in optional count and independent of the state count. The key is too coarse. A 20,000-case differential fuzz finds 112 cases where Match() changes, 108 from ignored variable bindings and 4 from shortened ranges on the deterministic path, with AllMatches().First() identical in all 20,000 as the control. A narrowed gate -- deterministic method, no capture groups -- measures 0 divergences with the full bound preserved. Two censuses close the extensions: environment matchers are 0.20%/1.18% of traversal instances on Amharic/Mbugwe, and the analysis rules' apparent 93.95% collapse is 99.7% alternate captures, because AnalysisAffixProcessRule and AnalysisCompoundingRule set AllSubmatches and enumerate morph boundaries on purpose. That makes a fourth row for the apparent-vs-sound table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| instances.Sort(InstanceCompare); | ||
| TInst first = instances.First(); | ||
| instances.Clear(); | ||
| instances.Add(first); |
There was a problem hiding this comment.
Minor: F1. A valid match can be lost when Acceptable reads the registers. ExpandInstances keeps one instance per lattice node, so the registers of every other path are gone before the final arc runs CheckAccepting/Acceptable. SynthesisRewriteRuleSpec.CheckTarget reads match.Range and AnalysisCompoundingSubruleRuleSpec reads captures: if the kept path is rejected, there is no result, even when a dropped path would have passed. Master tries every path. Unverified: from reading the code, not run.
| return x.State.Equals(y.State) && x.AnnotationIndex.Equals(y.AnnotationIndex); | ||
| return x.State.Equals(y.State) | ||
| && x.AnnotationIndex.Equals(y.AnnotationIndex) | ||
| && x.VariableBindings.Equals(y.VariableBindings); |
There was a problem hiding this comment.
Minor: F2. The dedup never fires for patterns with variables. VariableBindings does not override Equals/GetHashCode, and each key holds a fresh Clone(), so this compares references and never matches. Every alpha-variable rule pays for the lattice, the extra copies, and a second CheckAccepting/Acceptable pass, and gets no speedup. NondeterministicTraversal_DedupOnVariableBindingLosesMatch passes for this reason. Unverified: from reading the code, not run.
| if (IsDeterministic) | ||
| { | ||
| compare = x.IsLazy ? -compare : compare; | ||
| compare = (x.IsLazy || y.IsLazy) ? -compare : compare; |
There was a problem hiding this comment.
Minor: F3. Match() results change for determinized patterns, and the description does not say so. When either result is lazy the shorter one now sorts first, and this applies on the AllMatches path too. DeterministicTraversal_DedupOnRegistersShortensMatch asserts [0,1), where the fuzz comment above reports master returning [0,4). The comparator is also still intransitive: with a lazy a of length 2, b of length 3 and c of length 1, a<b, b<c and c<a. Is this change intended? Unverified: not run.
| { | ||
| if (!allMatches) | ||
| { | ||
| if (curResults.Count > resultCount) |
There was a problem hiding this comment.
FYI: F4. The final arc is recorded once per yielded instance, not once. resultCount is read before the Advance loop and never updated, so when 2+ annotations start at the next offset, the same (origInst, arc) is added again. ExtractResults then produces duplicate FstResults and repeats the Acceptable calls. The same happens at NondeterministicFsaTraversalMethod.cs:132. Updating resultCount after recording fixes both.
| private int InstanceCompare(TInst x, TInst y) | ||
| { | ||
| int compare = 0; | ||
| if (x.Priorities != null) |
There was a problem hiding this comment.
FYI: F5. In a DFA, which path wins at a merge is not well defined. Priorities is null there, so InstanceCompare always returns 0, and the surviving captures depend on incoming-arc order and how Sort handles ties. Master falls back to the Order tiebreak instead. This can change GroupCaptures for determinized patterns with groups. Unverified.
| } | ||
| foreach (LatticeArc latticeArc in incoming) | ||
| { | ||
| foreach (TInst source in ExpandArcInstances(latticeArc, lattice, allMatches)) |
There was a problem hiding this comment.
FYI: F6. Stack depth during extraction grows with match length. ExpandInstances and ExpandArcInstances recurse once per lattice edge, where master used an explicit stack. A long enough input to the public Matcher could throw StackOverflowException, which cannot be caught. Unverified; HermitCrab words are short.
|
|
||
| namespace SIL.Machine.Matching; | ||
|
|
||
| // Minimal, hand-built reproductions of two cases found by a differential fuzz |
There was a problem hiding this comment.
FYI: F7. The comments here break the code-comments standard that --agent-strict enforces. This header is provenance and points to TraversalDedupDifferentialFuzzTests, which is not in the tree. Line 36 still says the key is (State, AnnotationIndex) alone. The second test's name says the dedup "shortens" the match, yet the test asserts that shorter match as correct.
| ); | ||
| } | ||
| } | ||
| if (!allMatches && instances.Count > 1) |
There was a problem hiding this comment.
FYI: F8. The allMatches parameter is dead here. ExtractResults, ExpandArcInstances and ExpandInstances run only on the !allMatches path, so the parameter is always false and this check always passes. Keeping one element does not need a full sort: a single MinBy scan, as Fst.cs already uses, does the same.
API and compatibility: Findings: F1 new, unverified; F2 new, unverified; F3 new, unverified; F4 new; F5 new, unverified; F6 new, unverified; F7 new; F8 new. Reviewed at 039a97d |
I discovered when parsing wemulujaʼjawype in the Aweti project that a single call to Traverse in DeterministicFsaTraversalMethod could take 4 seconds. It was processing more than 200,000 traversals, even though the input only had 47 characters and the fsa only had 4 states. This was because the DeterministicFsaTraversalInstance data structure included the registers, which encoded the match to that point. Processing 200,000 traversals was especially annoying since the caller only wanted one match.
The registers only record the match, they don't filter the traversal in any way. So it should be possible to traverse in two passes, where the first pass finds traversals that are acceptable without recording the registers and the second pass extracts the registers for each of the successful traversals. But it turns out that the code path that was causing the performance issue only takes the first match. So I optimized for the case when the caller only wanted one match. This can be done in one pass.
To fix the performance issue, I added an allMatches parameter to Traverse, and changed the code to ignore traversals that arrived at a previously processed <State, AnnotationIndex> tuple. Since the only difference between the new traversal and the previously processed traversal is in the registers, we can just ignore the new traversal. This means that in the worst case it only takes O(N * |States|) to find a traversal. I tried returning immediately when I found the first match, but that produces the shortest match and many of the MatcherTests failed. Letting it run to the end finds matches of all lengths. Otherwise, the choice of match doesn't seem to matter.
With this change in place, parsing wemulujaʼjawype went from 83 seconds to 3 seconds and parsing 185 Aweti words went from 4 minutes to 1 minute.
This change is