Skip to content

Use a simple DFA to parse operators.#28

Closed
JaredDavis22 wants to merge 2 commits into
scijava:mainfrom
JaredDavis22:alternativeoperatorparsing
Closed

Use a simple DFA to parse operators.#28
JaredDavis22 wants to merge 2 commits into
scijava:mainfrom
JaredDavis22:alternativeoperatorparsing

Conversation

@JaredDavis22

Copy link
Copy Markdown
Contributor

Stores a copy of the starting node in ParseOperation.

@ctrueden

ctrueden commented Jul 17, 2026

Copy link
Copy Markdown
Member

This adds 133 net lines of code, and IMHO makes the parseOperator method harder to understand. What are the benefits of this change?

@ctrueden

Copy link
Copy Markdown
Member

See #24 (comment) for related discussion.

@ctrueden
ctrueden force-pushed the alternativeoperatorparsing branch from cff73b0 to 09f01c5 Compare July 17, 2026 21:28
@ctrueden

Copy link
Copy Markdown
Member

FYI, I rebased this over the latest main branch. (No conflicts.)

@JaredDavis22

JaredDavis22 commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

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.

@JaredDavis22

Copy link
Copy Markdown
Contributor Author

The graph code and some slight reformatting is in https://github.com/JaredDavis22/parsingtonFork/tree/matcherUsingThreadLocalguess

@ctrueden
ctrueden force-pushed the alternativeoperatorparsing branch from 09f01c5 to f5be89e Compare July 19, 2026 18:11
@ctrueden

Copy link
Copy Markdown
Member

I pushed some JMH benchmarks to the main branch, rebased this PR over top, and ran the benchmarks. Here is the comparison:

benchmark main alternativeoperatorpar
EvaluationBenchmark.evaluate ARITH_INT/PARSE_AND_EVAL/TREE 4.899 us/op 3.309 ▼ -32.5%
EvaluationBenchmark.evaluate ARITH_INT/PARSE_AND_EVAL/STACK 4.644 us/op 3.274 ▼ -29.5%
EvaluationBenchmark.evaluate ARITH_INT/EVAL_ONLY/TREE 0.107 us/op 0.109 ~ +1.9%
EvaluationBenchmark.evaluate ARITH_INT/EVAL_ONLY/STACK 0.211 us/op 0.206 ~ -2.4%
EvaluationBenchmark.evaluate ARITH_DOUBLE/PARSE_AND_EVAL/TREE 4.258 us/op 3.031 ▼ -28.8%
EvaluationBenchmark.evaluate ARITH_DOUBLE/PARSE_AND_EVAL/STACK 4.102 us/op 3.040 ▼ -25.9%
EvaluationBenchmark.evaluate ARITH_DOUBLE/EVAL_ONLY/TREE 0.092 us/op 0.093 ~ +1.1%
EvaluationBenchmark.evaluate ARITH_DOUBLE/EVAL_ONLY/STACK 0.179 us/op 0.186 ~ +3.9%
EvaluationBenchmark.evaluate POWER/PARSE_AND_EVAL/TREE 1.987 us/op 1.508 ▼ -24.1%
EvaluationBenchmark.evaluate POWER/PARSE_AND_EVAL/STACK 2.035 us/op 1.412 ▼ -30.6%
EvaluationBenchmark.evaluate POWER/EVAL_ONLY/TREE 0.048 us/op 0.049 ~ +2.1%
EvaluationBenchmark.evaluate POWER/EVAL_ONLY/STACK 0.104 us/op 0.099 ▼ -4.8%
EvaluationBenchmark.evaluate VARIABLES/PARSE_AND_EVAL/TREE 5.342 us/op 1.758 ▼ -67.1%
EvaluationBenchmark.evaluate VARIABLES/PARSE_AND_EVAL/STACK 5.253 us/op 1.713 ▼ -67.4%
EvaluationBenchmark.evaluate VARIABLES/EVAL_ONLY/TREE 0.206 us/op 0.205 ~ -0.5%
EvaluationBenchmark.evaluate VARIABLES/EVAL_ONLY/STACK 0.341 us/op 0.350 ~ +2.6%
EvaluationBenchmark.evaluate BOOLEAN/PARSE_AND_EVAL/TREE 3.708 us/op 2.865 ▼ -22.7%
EvaluationBenchmark.evaluate BOOLEAN/PARSE_AND_EVAL/STACK 3.629 us/op 2.831 ▼ -22.0%
EvaluationBenchmark.evaluate BOOLEAN/EVAL_ONLY/TREE 0.065 us/op 0.065 ~ +0.0%
EvaluationBenchmark.evaluate BOOLEAN/EVAL_ONLY/STACK 0.221 us/op 0.193 ▼ -12.7%
EvaluationBenchmark.evaluate MIXED/PARSE_AND_EVAL/TREE 6.611 us/op 2.682 ▼ -59.4%
EvaluationBenchmark.evaluate MIXED/PARSE_AND_EVAL/STACK 6.567 us/op 2.522 ▼ -61.6%
EvaluationBenchmark.evaluate MIXED/EVAL_ONLY/TREE 0.125 us/op 0.147 ▲ +17.6%
EvaluationBenchmark.evaluate MIXED/EVAL_ONLY/STACK 0.265 us/op 0.316 ▲ +19.2%
LiteralsBenchmark.parseSingleLiteral INT 297.027 ns/op 304.721 ~ +2.6%
LiteralsBenchmark.parseSingleLiteral DOUBLE 341.368 ns/op 337.501 ~ -1.1%
LiteralsBenchmark.parseSingleLiteral SCIENTIFIC 367.391 ns/op 368.822 ~ +0.4%
LiteralsBenchmark.parseSingleLiteral LONG 374.838 ns/op 373.311 ~ -0.4%
LiteralsBenchmark.parseSingleLiteral FLOAT 367.845 ns/op 372.257 ~ +1.2%
LiteralsBenchmark.parseSingleLiteral HEX 190.694 ns/op 193.504 ~ +1.5%
LiteralsBenchmark.parseSingleLiteral BINARY 223.511 ns/op 219.819 ~ -1.7%
LiteralsBenchmark.parseSingleLiteral BIG_INTEGER 3270.566 ns/op 3362.487 ~ +2.8%
LiteralsBenchmark.parseSingleLiteral STRING 39.812 ns/op 40.805 ~ +2.5%
LiteralsBenchmark.parseSingleLiteral BOOLEAN 2.720 ns/op 2.784 ~ +2.4%
ParsingBenchmark.parsePostfix CONSTANT 0.328 us/op 0.319 ~ -2.7%
ParsingBenchmark.parsePostfix VARIABLE 0.384 us/op 0.122 ▼ -68.2%
ParsingBenchmark.parsePostfix ARITHMETIC 5.376 us/op 3.638 ▼ -32.3%
ParsingBenchmark.parsePostfix QUADRATIC 9.105 us/op 3.591 ▼ -60.6%
ParsingBenchmark.parsePostfix NESTED_GROUPS 5.248 us/op 2.070 ▼ -60.6%
ParsingBenchmark.parsePostfix FUNCTIONS 5.356 us/op 2.783 ▼ -48.0%
ParsingBenchmark.parsePostfix LONG_SUM 38.568 us/op 9.889 ▼ -74.4%
ParsingBenchmark.parsePostfix OPERATORS_COMMON 10.097 us/op 4.649 ▼ -54.0%
ParsingBenchmark.parsePostfix OPERATORS_RARE 5.266 us/op 1.606 ▼ -69.5%
ParsingBenchmark.parsePostfix INT_LIST 4.881 us/op 4.445 ▼ -8.9%
ParsingBenchmark.parsePostfix DOUBLE_LIST 5.169 us/op 4.810 ▼ -6.9%
ParsingBenchmark.parsePostfix MIXED_LIST 8.580 us/op 7.757 ▼ -9.6%
ParsingBenchmark.parseTree CONSTANT 0.346 us/op 0.339 ~ -2.0%
ParsingBenchmark.parseTree VARIABLE 0.398 us/op 0.125 ▼ -68.6%
ParsingBenchmark.parseTree ARITHMETIC 5.446 us/op 3.742 ▼ -31.3%
ParsingBenchmark.parseTree QUADRATIC 9.186 us/op 3.783 ▼ -58.8%
ParsingBenchmark.parseTree NESTED_GROUPS 5.039 us/op 2.067 ▼ -59.0%
ParsingBenchmark.parseTree FUNCTIONS 5.466 us/op 3.127 ▼ -42.8%
ParsingBenchmark.parseTree LONG_SUM 38.864 us/op 11.478 ▼ -70.5%
ParsingBenchmark.parseTree OPERATORS_COMMON 10.252 us/op 4.896 ▼ -52.2%
ParsingBenchmark.parseTree OPERATORS_RARE 5.195 us/op 1.698 ▼ -67.3%
ParsingBenchmark.parseTree INT_LIST 4.937 us/op 4.425 ▼ -10.4%
ParsingBenchmark.parseTree DOUBLE_LIST 5.375 us/op 4.817 ▼ -10.4%
ParsingBenchmark.parseTree MIXED_LIST 8.547 us/op 8.103 ~ -5.2%

So, like you said, good improvements overall. I'll try to scrutinize this PR more carefully to figure out how best to proceed.

@JaredDavis22

JaredDavis22 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

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.

EvaluationBenchmark.evaluate MIXED/EVAL_ONLY/TREE | 0.125 us/op | 0.147 ▲ +17.6%
EvaluationBenchmark.evaluate MIXED/EVAL_ONLY/STACK | 0.265 us/op | 0.316 ▲ +19.2%

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.

JaredDavis22@90e16fe

@ctrueden

Copy link
Copy Markdown
Member

I pushed a new first-char-operator-parsing branch that attains better speedups than this DFA/trie approach, by simply bucketing operators by first character (a03428a). It also includes a new unit test (6eca317) that passes on main but fails with this PR, because the DFA logic changes the "invalid character at" being reported. @JaredDavis22 Could you give it a try with your real-world use case and report back how the performance is for you?

@JaredDavis22

Copy link
Copy Markdown
Contributor Author

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.
b) use a List vs parsing operator list for the groups.
c) DFA changes (emitDotGraph, private...)
d) reuse Matchers (Literals is no longer static)
e) minor code cleanup (concat in StringBuilder.append)(simplify returns) autoboxing...

Please take a look at these when you refactor the DFA code. Thanks.

@ctrueden

ctrueden commented Jul 20, 2026

Copy link
Copy Markdown
Member

@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.

@JaredDavis22

Copy link
Copy Markdown
Contributor Author

I pushed a new first-char-operator-parsing branch that attains better speedups than this DFA/trie approach, by simply bucketing operators by first character

The DFA should be faster since it doesn't just optimize on the first char. I wonder if the binary search used by next list is slower than a hashmap? I will look at it Monday.

First chars + > < have some depth to the tree which is not optimized by a first letter approach.

image

@ctrueden

Copy link
Copy Markdown
Member

The DFA should be faster since it doesn't just optimize on the first char.

Here's an excerpt of a chat I had with Claude about it earlier tonight:

Chat transcript

You said we "don't need a trie" -- which for the set of standard operators, I fully agree, a trie is overkill. But what about custom operators? A bunch of custom operators with common multi-character prefixes? For example, the Kotlin language has shl and shr, which share a common sh prefix. If we had, say, sh1 through sh9999 operators (stupid, I know, but hey, Parsington allows whatever custom operator sets people want), that would be 9999 operators on the s list, so our first-character heuristic would no longer save us much. Whereas presumably a trie would do more heavily lifting in such a circumstance? I'm not looking to change anything else in the code yet -- just asking your thoughts on whether a trie (or prefix set more general than lead character) could ever be worth using here?

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 sh1sh9999 the s bucket degrades to ~10k startsWith calls per s-initial token, each failing at index 2 — roughly 30k char comparisons where a trie walks ~6 nodes. That's a real, not theoretical, blowup.

But the constant factors are lopsided enough to matter. A bucket scan iteration is a field load plus String.startsWith, which for a 1-3 char token is a length check and a couple of inlined compares in a tight, well-predicted loop — call it single-digit nanoseconds. A trie step is a hash lookup or binary search per character, with pointer chasing and likely cache misses — maybe 10-30ns each, times token length. My rough estimate puts the crossover somewhere around 20-50 candidates in a single bucket. Below that, the flat scan should win outright; the trie is a pessimization.

So the question is where real operator sets land. Kotlin's word operators (shl, shr, ushr, and, or, xor, inv) give bucket sizes of 1-2. A rich language embedding with 200 operators spread over ~20 distinct first characters averages 10 per bucket — still comfortably on the flat-scan side. The pathological case needs hundreds of operators sharing a leading character, which I don't think anyone has actually built. Worth noting too: even in your 9999-operator scenario, first-char bucketing is still a strict improvement over main's unconditional full-list scan — just a much less dramatic one.

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: Map<String, List<Operator>> keyed on the token, probed once per distinct token length, longest first. The standard set has lengths {1,2,3,4,10}, so that's 5 probes regardless of whether there are 50 operators or 50,000. The catch on Java 8 is that each probe needs a substring allocation (Java 9+ or a custom CharSequence-range hash avoids it) — which is likely why it'd lose to the flat scan today, but it degrades far more gracefully.

The extension point already exists. parseOperator() is protected, and ExpressionParser takes a parseOperationFactory. Someone with a genuinely pathological operator set can subclass and drop in a trie without Parsington carrying that complexity for everyone. That seems like the right default: keep the common path simple and fast, and let the exotic case pay for itself.

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 lastHit.

Happy to actually measure the crossover — synthesize an sh1shN operator set, sweep N, and race bucket vs. trie — if you want data rather than my estimate. That'd settle whether 20-50 is the right ballpark.


Benchmark results are in. The simpler approach is also the faster one:

summary (vs main; geomean)
  alternativeoperatorparsing (DFA):  -30.6%
  firstchar (bucket):                -40.1%

Head-to-head on the parse-heavy benchmarks:

benchmark main DFA first-char
parsePostfix LONG_SUM 38.57 µs 9.89 (-74%) 4.59 (-88%)
parsePostfix OPERATORS_RARE 5.27 1.61 (-70%) 0.65 (-88%)
parsePostfix VARIABLE 0.384 0.122 (-68%) 0.040 (-90%)
parseTree QUADRATIC 9.19 3.78 (-59%) 2.90 (-69%)
evaluate VARIABLES/PARSE_AND_EVAL/STACK 5.25 1.71 (-67%) 0.90 (-83%)
parsePostfix ARITHMETIC 5.38 3.64 (-32%) 3.62 (-33%)

They tie on operator-dense expressions (ARITHMETIC, ARITH_DOUBLE, BOOLEAN) and diverge sharply on everything with variables, groups, or long token streams. Two reasons, I think: the bucket version also fixes parseGroupTerminator(), which the DFA branch leaves at O(N) — that's the second full scan every variable token pays — and it allocates nothing per lookup, where ParsingNode.getIndex() allocates a throwaway node per character.

One caveat, stated plainly: the EVAL_ONLY rows move in both branches (MIXED/EVAL_ONLY/TREE +41.6% here, +17.6% on the DFA branch). Neither change touches evaluation — those expressions are pre-parsed — so that's measurement artifact, probably JIT/code-layout, and it's a reminder that with Cnt=4 on sub-microsecond benchmarks the noise floor is wide. The parse-side deltas are far too large and too consistent to be explained that way, but I wouldn't defend any individual number to the last percent.

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 sh1sh9999 point — it just puts a measured floor under where the crossover has to be. If you want, the sweep I offered (synthesize sh1shN, vary N, race bucket vs. trie) would find the actual crossover rather than my 20-50 guess.

A length-bucketed hash gets the same operator-count independence with no new data structure: Map<String, List> keyed on the token, probed once per distinct token length, longest first.

Yeah for sure, that's what I meant by "(or prefix set more general than lead character)". In practice, such a prefix set/hash is (almost?) always better in implementation than an actual trie structure, I think?

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. instanceof at 10 chars is the outlier, and even that is one probe.

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. 😈

@JaredDavis22

Copy link
Copy Markdown
Contributor Author

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?

@ctrueden

Copy link
Copy Markdown
Member

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.

@JaredDavis22

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants