Use a simple DFA to parse operators.#28
Conversation
|
This adds 133 net lines of code, and IMHO makes the |
|
See #24 (comment) for related discussion. |
cff73b0 to
09f01c5
Compare
|
FYI, I rebased this over the latest main branch. (No conflicts.) |
|
The majority of my operators are * = + and -. The existing code, which uses a loop through a list, has these near the end of a 50+ element list. So, every potential operator has to go through 40+ dead ends (even operators which are not implemented) before a hit is found. The dfa structure finds an operator very quickly and also rejects non operators very quickly. It seems it is worth the code complexity for the better performance. This link should show the dfa visually from a dot file. |
|
The graph code and some slight reformatting is in https://github.com/JaredDavis22/parsingtonFork/tree/matcherUsingThreadLocalguess |
Store a copy of the starting node in ParseOperation.
09f01c5 to
f5be89e
Compare
|
I pushed some JMH benchmarks to the main branch, rebased this PR over top, and ran the benchmarks. Here is the comparison:
So, like you said, good improvements overall. I'll try to scrutinize this PR more carefully to figure out how best to proceed. |
|
Thanks! The benchmark looks great. I see 2 benchmarks that increased > 5%. Seems strange to see an increase. Is the DFA build time in those paths? Is that a common usage? Would a lazy init of the DFA solve it? I didn't dig yet. Today I went a little too far in https://github.com/JaredDavis22/parsingtonFork/tree/matcherUsingThreadLocalguess and removed most/all of the unimplemented operations/test/etc. I made some changes to the DFA code in matcherUsingThreadLocalguess. I assume you are going to adjust the code anyway so I have not been keeping the DFA only branch current. Let me know what would help. Also tried a regular expression ignore case compile flag. Before I tested it I did a search of google "knowledge" which stated it is usually slower. Reverted that change w/o a test. Want to include this change as well? It creates a group subset of operators. |
|
I pushed a new |
|
I rebased my recent changes against origin/main. Results are in branch performancevsmain. https://github.com/JaredDavis22/parsingtonFork/tree/performancevsmain a) removed unimplemented operators. They are in git history. They were just runtime overhead. Please take a look at these when you refactor the DFA code. Thanks. |
|
@JaredDavis22 It seems our efforts collided ⏳ 😆 Let's lock in the DFA vs. prefix-hash performance improvement first, then we can start going through your other changes, (a) through (e) one at a time. |
Here's an excerpt of a chat I had with Claude about it earlier tonight: Chat transcript
You're right about the asymptotics. First-char bucketing is O(bucket size) per token; a trie is O(match depth), independent of operator count. With But the constant factors are lopsided enough to matter. A bucket scan iteration is a field load plus So the question is where real operator sets land. Kotlin's word operators ( If it ever did become real, a trie isn't the only escape. A length-bucketed hash gets the same operator-count independence with no new data structure: The extension point already exists. One caveat if a trie ever does get built. It has to preserve the fallback-to-shorter-token behavior when the longest match fails the prefix/infix context check — that's exactly what the current branch drops, and it's harder to get right with deeper prefixes, not easier. You'd need to retain the candidate list at every accepting node along the path and walk back down on failure, not just keep the deepest Happy to actually measure the crossover — synthesize an Benchmark results are in. The simpler approach is also the faster one: Head-to-head on the parse-heavy benchmarks:
They tie on operator-dense expressions ( One caveat, stated plainly: the This also settles the trie question from your last message empirically. The constant-factor argument I sketched holds up: on the standard 50-operator set the trie's better asymptotics lose to a flat scan over a 1-8 element bucket, by about 10 points of geomean. Which doesn't contradict your
I agree for this shape of problem. The hash approach wins on cache behavior — one contiguous probe instead of a pointer chase per character — and there's no per-node object graph to build or traverse. The case where a trie still earns its keep is incremental matching against a stream you can't index into, or a set with very long shared prefixes where you want to bail at the first divergent character rather than hashing the full token length. Neither applies here: expressions are in-memory strings, and operator symbols are short. At least in broad strokes, I think Claude is correct here: in my experience, trie data structures tend to end up having more overhead than hashmaps and don't work as well in practice for a lot of real-world use cases. But if you want to keep playing with it to prove us wrong, go for it. 😈 |
|
I'll give it a shot. I had optimizing the group in the list as item B. It is spread out over some commits. If the benchmark compares DFA vs bucket + group that is not an equal comparison. How does DFA + group vs bucket + group compare? |
I don't have the bandwidth to dig on this right now, but hopefully you have all the tools you need now to do it? Let me know if you run into any blockers. |
|
Hi, I could not beat the first char map approach. I would have bet a large amount of $ that the dfa would be faster. Glad you found a faster implementation. |

Stores a copy of the starting node in ParseOperation.