[
https://issues.apache.org/jira/browse/GROOVY-12398?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18114452#comment-18114452
]
ASF GitHub Bot commented on GROOVY-12398:
-----------------------------------------
daniellansun commented on PR #2921:
URL: https://github.com/apache/groovy/pull/2921#issuecomment-5639709184
# Parser performance verification: GROOVY-12398
**Question.** Does commit `4c89f6774d5cbb431940b0ca9d1581a9434b0d1e` parse a
fixed Groovy corpus faster than commit
`4aa7b94b83bd29814b1449cde1b239bdbe8c0ff0`, when both use the same parse-only
path that production uses (SLL, then LL on failure)?
**Answer.** Yes. On this host, with a frozen corpus of 245 files (570 533
characters), the target parser used **about 35% less wall time** than the
baseline (pooled mean **1.53×**). Every post-warmup sample of the target was
faster than every post-warmup sample of the baseline. Parse outcomes (SLL
success, LL fallback, the one expected failure) are identical.
This is a **parse-only** measurement. It does not claim the same factor for
end-to-end `groovyc`.
---
## 1. What was compared
| | Baseline | Target |
|---|---|---|
| Commit | `4aa7b94b83bd29814b1449cde1b239bdbe8c0ff0` |
`4c89f6774d5cbb431940b0ca9d1581a9434b0d1e` |
| Subject | `prepare for version 7 (cont'd)` | `GROOVY-12398: classify
supplementary-plane identifiers by code point` |
| Raw jar SHA-256 |
`83130ad2d6ce26e1fee7eccca7a9f387151142defb923d3af1087f324cb7f8c7` |
`17946a113b5123939a6ecff762a3ffa62535bbb8d0444e39a928acf7a736cbe8` |
The range contains two commits:
1. `cfcc6d50fc` — lexer/parser grammar and predicate work (the performance
change).
2. `4c89f6774d` — review follow-up (code-point capitalization, invalid-octal
caret, tests). That follow-up is not expected to move the hot path; the
measurement is of the **full** GROOVY-12398 series against the parent of the
first commit.
Each commit was built in its own Git worktree (`./gradlew :jar`). The jars
were checked before timing: baseline still contains `invalidDigitCount` and
`java.util.regex.Pattern`; target contains `lastConsumedCodePoint` and
`isIdentifierAssign`.
---
## 2. Method
### Corpus
`src/main/groovy` + `src/test-resources/core`, `*.groovy` only, walked in
sorted path order.
The tree was **frozen from the baseline commit** and reused for every run,
so a change in source text cannot be mistaken for a parser change. The same two
directories at the target commit hash to the same SHA-256 (39 + 206 files).
Input is therefore identical in both trees.
| Root | Files | UTF-16/UTF-8 chars | Bytes |
|---|---:|---:|---:|
| `src/main/groovy` | 39 | 290 264 | 290 270 |
| `src/test-resources/core` | 206 | 280 269 | 280 277 |
| **Total** | **245** | **570 533** | **570 547** |
### Parse path
Matches `AstBuilder`:
1. `GroovyLangLexer` + `CommonTokenStream` + `GroovyLangParser`
2. `BailErrorStrategy`, no console listeners
3. `PredictionMode.SLL` first
4. On failure: `tokens.seek(0)`, `parser.reset()`, `PredictionMode.LL`
5. New lexer/parser per file; ATN/DFA shared through `ParserAtnManager`
(production-like cache warmup)
ANTLR runtime: `me.sunlan:antlr4-runtime:4.13.2.16` (the runtime Groovy
already uses).
### Timing protocol
- Independent JVM per invocation (`-Xms1g -Xmx1g -XX:+AlwaysPreTouch`).
- 5 untimed warmup passes (DFA fill + JIT), then **15 timed** full-corpus
passes.
- Wall time is `System.nanoTime()` around the whole corpus, not per file.
- Invocation order was interleaved **A B A B A B A** (baseline / target /
baseline / …) so a slow host cannot be blamed on “target ran first while the
machine was idle”.
- 4 baseline JVMs × 15 = **60** timed samples; 3 target JVMs × 15 = **45**
timed samples.
### Host
| | |
|---|---|
| Kernel | Linux 6.15.5 x86_64 (`hera`) |
| CPU | AMD EPYC 7763, 6 visible CPUs, 1 thread/core |
| Memory | 24 GiB |
| JVM | Corretto 25.0.4+7-LTS, mixed mode |
| Date | 2026-09-11 (UTC) |
This is a shared developer VM. Variance across baseline JVMs is reported
rather than hidden.
---
## 3. Correctness (same corpus, both parsers)
| Outcome | Baseline | Target |
|---|---:|---:|
| SLL success | 241 | 241 |
| LL fallback | 3 | 3 |
| Hard failure | 1 | 1 |
LL fallback (both parsers):
- `src/test-resources/core/Command_01.groovy`
- `src/test-resources/core/Command_05.groovy`
- `src/test-resources/core/Lambda_01x.groovy`
Hard failure (both parsers): `src/test-resources/core/Unicode_01.groovy` —
`GroovySyntaxError: Illegal escape character: '\u'`. That file is an
illegal-escape fixture, not a regression.
The speedup is **not** from fewer LL retries. The SLL/LL split is unchanged.
---
## 4. Results
Times are seconds to parse the whole corpus after warmup. Lower is better.
### Per JVM (15 timed rounds each)
| Run | Role | Min | Median | Mean | Stdev | Max |
|---|---|---:|---:|---:|---:|---:|
| A | baseline | 0.218082 | 0.233826 | 0.242895 | 0.022486 | 0.293010 |
| B | target | 0.148911 | 0.177118 | 0.177364 | 0.019880 | 0.211886 |
| A2 | baseline | 0.254599 | 0.295294 | 0.300075 | 0.036579 | 0.381228 |
| B2 | target | 0.147442 | 0.165518 | 0.166205 | 0.009369 | 0.185643 |
| A3 | baseline | 0.222311 | 0.236567 | 0.242742 | 0.018481 | 0.291574 |
| B3 | target | 0.147241 | 0.162760 | 0.167201 | 0.016246 | 0.204381 |
| A4 | baseline | 0.219386 | 0.260223 | 0.255703 | 0.027844 | 0.326735 |
A2 is a noisy baseline JVM (mean 0.300 s vs ~0.243 s for A and A3). It is
kept in the pool rather than discarded.
### Pooled post-warmup samples
| | n | Min | Median | Mean | Stdev | Max |
|---|---:|---:|---:|---:|---:|---:|
| Baseline | 60 | 0.218082 | 0.252503 | **0.260354** | 0.035573 | 0.381228 |
| Target | 45 | 0.147241 | 0.165395 | **0.170257** | 0.016237 | 0.211886 |
### Speedup
| Estimator | Baseline | Target | Factor | Time saved |
|---|---:|---:|---:|---:|
| Mean | 0.260354 s | 0.170257 s | **1.53×** | 34.6% |
| Median | 0.252503 s | 0.165395 s | **1.53×** | 34.5% |
| Best (min) | 0.218082 s | 0.147241 s | **1.48×** | 32.5% |
Throughput at the pooled means: **941 → 1 439 files/s**, **2.19e6 → 3.35e6
chars/s**.
### Separation
The slowest target round (0.211886 s) is still faster than the fastest
baseline round (0.218082 s). The two sample sets do not overlap.
Cohen’s *d* on the pooled samples is **3.1** (large). That is a description
of this data set, not a claim about every machine.
### Conservative cross-check
Against the **fastest** baseline JVM mean (A3, 0.242742 s) and the
**slowest** target JVM mean (B, 0.177364 s), the factor is still **1.37×**.
That is the least flattering pairing of JVM means in this run.
---
## 5. What this does and does not show
**Shown**
- On this host, the GROOVY-12398 parser is consistently faster on this
corpus than its parent, by roughly one and a half times in parse-only wall time.
- The improvement is in the SLL-successful majority (241/245 files), not in
a change of prediction mode mix.
- The corpus and the parse outcomes are the same, so the delta is in the
recognisers, not in “different files” or “more failures skipped”.
**Not shown**
- End-to-end compile time (semantic analysis, classgen, I/O). Parse is only
part of `groovyc`; a 1.5× parse-only gain will shrink to a smaller fraction of
a full compile, as already noted on the PR.
- Other hardware, JDK 17/21, or a Grails/Spock-sized corpus. Those would be
the right next measurement if the change is considered for 6.0.
- That every future Groovy file will see 1.5×. Command-heavy scripts that
already fall back to LL (three files here) are not the bulk of this corpus.
**Noise**
Baseline JVM A2 was ~24% slower than A/A3. The machine is not a quiet
benchmark box. Interleaving and pooling are how that is handled; dropping A2
would only make the target look better, so it was not dropped.
---
## 6. Conclusion
Relative to `4aa7b94b83`, commit `4c89f6774d` (GROOVY-12398 as it stands on
that branch) delivers a **real, repeatable parse-only speedup** of about
**1.5×** (≈35% less time) on `src/main/groovy` + `src/test-resources/core`.
Parse behaviour on that corpus is unchanged.
That is sufficient evidence for a **master** merge from a performance
standpoint. It is **not** by itself a case for slipping the same grammar/ATN
rewrite into a 6.0 RC without a further compile-level corpus check.
---
## Appendix: how to reproduce
```bash
# worktrees
git worktree add --detach /tmp/parse-verify/wt-baseline
4aa7b94b83bd29814b1449cde1b239bdbe8c0ff0
git worktree add --detach /tmp/parse-verify/wt-target
4c89f6774d5cbb431940b0ca9d1581a9434b0d1e
( cd /tmp/parse-verify/wt-baseline && ./gradlew :jar --offline -q )
( cd /tmp/parse-verify/wt-target && ./gradlew :jar --offline -q )
# freeze corpus from baseline
mkdir -p /tmp/parse-verify/corpus
cp -a /tmp/parse-verify/wt-baseline/src/main/groovy
/tmp/parse-verify/corpus/main-groovy
cp -a /tmp/parse-verify/wt-baseline/src/test-resources/core
/tmp/parse-verify/corpus/test-resources-core
ANTLR=$HOME/.gradle/caches/modules-2/files-2.1/me.sunlan/antlr4-runtime/4.13.2.16/9c7caa836c70ef09ee7b70f77c11b803f75e19c0/antlr4-runtime-4.13.2.16.jar
# ParseBench.java as used for this report (5 warmup, 15 rounds, SLL then LL)
java -Xms1g -Xmx1g -XX:+AlwaysPreTouch -Dbench.label=... \
-cp ".:<raw-jar>:$ANTLR" ParseBench \
/tmp/parse-verify/corpus/main-groovy \
/tmp/parse-verify/corpus/test-resources-core
```
> Improve lexer and parser performance
> ------------------------------------
>
> Key: GROOVY-12398
> URL: https://issues.apache.org/jira/browse/GROOVY-12398
> Project: Groovy
> Issue Type: Improvement
> Reporter: Daniel Sun
> Priority: Major
>
> The Groovy ANTLR4 grammar already uses left-recursive expressions, which is
> the main idea of the [optimized Java
> grammar|https://github.com/antlr/grammars-v4/tree/master/java/java]. Several
> remaining lexer and parser costs still match patterns that grammar was
> written to avoid.
> h3. Problem
> * Number literals are assembled from a chain of one-character fragments
> ({{Digit}}, {{Zero}}, {{NonZeroDigit}}, ...). The invalid-octal alternative
> also counts digits in a mid-rule action loop, which prevents DFA compilation
> of {{IntegerLiteral}}.
> * Every identifier, including ASCII names such as {{Foo}} and {{foo}},
> evaluates {{Character.isUpperCase}} to choose {{CapitalizedIdentifier}} vs
> {{Identifier}}.
> * GString and comment predicates ({{isFollowedByJavaLetterInGString}},
> {{isFollowedByWhiteSpaces}}) allocate a one-character {{String}} and run
> {{Pattern.matcher}} on each lookahead.
> * {{elementValues}} is ambiguous with assignment: {{@Foo(a = 1)}} can be
> named pairs or an assignment expression, so {{AdaptivePredict}} explores both
> (see [antlr/grammars-v4#215|https://github.com/antlr/grammars-v4/issues/215]).
> h3. Approach
> Keep token types and language semantics unchanged.
> * Flatten number and float fragments to character classes, as in the
> optimized Java lexer. Validate invalid octal once at the end of the
> alternative.
> * Split ASCII identifiers on a predicate-free DFA path: uppercase {{A-Z}}
> becomes {{CapitalizedIdentifier}}; {{a-z}}, dollar and underscore become
> {{Identifier}}. Unicode still uses predicates.
> * Replace the regex-based predicates with direct character tests.
> * Gate {{elementValue}} with {{!isIdentifierAssign(_input)}}, the same idea
> as Java's {{IsNotIdentifierAssign}}.
> h3. Result
> Local parse-only measurement (SLL then LL) of {{src/main/groovy}} +
> {{src/test-resources/core}} (245 files, approximately 570k characters):
> ||Run||Best||Mean||
> |before|0.231s|0.401s|
> |after|0.146s|0.169s|
> Best run is about 1.6x faster. Mean improves more because prediction is more
> stable after DFA warmup.
> Related: GROOVY-10454, GROOVY-9588
--
This message was sent by Atlassian Jira
(v8.20.10#820010)