Add allMatches to Traverse function to improve performance - #511
jtmaxwell3 wants to merge 19 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #511 +/- ##
==========================================
+ Coverage 74.09% 74.15% +0.05%
==========================================
Files 456 456
Lines 38107 38307 +200
Branches 5221 5268 +47
==========================================
+ Hits 28237 28406 +169
- Misses 8712 8740 +28
- Partials 1158 1161 +3 ☔ 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>
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