Parse Benchmark
August 3, 2026 ยท View on GitHub
This benchmark compares parse throughput for generated ANTLR parsers and tree-sitter parsers on Kotlin, C#, and Java fixtures, with an explicit Trino SQL fixture set for SQL-dialect Rust vs Go checks.
The harness is intentionally a standalone script instead of cargo bench.
cargo bench is useful for in-process Rust-only measurements, but this check
has to generate ANTLR parsers, build a Go binary, run Python parsers, and load
tree-sitter language libraries. Keeping that orchestration outside Cargo makes
the same command usable locally and in CI.
Setup
Use the same ANTLR jar and grammars-v4 checkout described in AGENTS.md.
The repository-local directory is ignored and survives operating-system
temporary-directory cleanup; cargo clean removes it. The benchmark defaults
to:
ANTLR4_JAR=target/antlr-cleanroom/tools/antlr-4.13.2-complete.jarGRAMMARS_V4=target/antlr-cleanroom/grammars-v4
The sparse checkout must include C#, the modern Java grammar, and Trino SQL in addition to Kotlin:
git -C target/antlr-cleanroom/grammars-v4 sparse-checkout set kotlin/kotlin csharp/v7 java/java sql/trino
Install the Python dependencies in the interpreter you will use to run the benchmark:
python3 -m pip install -r tools/parse-bench/requirements.txt
Run
Quick local smoke:
python3 tools/parse-bench/run.py --quick
SQL-only Rust vs Go smoke (with AST parity gate):
python3 tools/parse-bench/run.py \
--languages trino \
--runtimes rust-antlr,go-antlr \
--ast-check \
--quick
Longer local run with reports:
python3 tools/parse-bench/run.py \
--iters 20 \
--warmups 3 \
--rust-generated-only \
--json target/parse-bench/results.json \
--markdown target/parse-bench/results.md
Generator experiments use the same harness through repeatable passthrough arguments. Use separate work directories so generated sources and binaries do not overwrite the baseline:
python3 tools/parse-bench/run.py \
--languages kotlin,trino \
--runtimes rust-antlr \
--rust-generator-arg=--fixed-lookahead \
--rust-generator-arg=3 \
--work-dir target/parse-bench-fixed
The script regenerates parsers into target/parse-bench, builds:
- a Rust runner generated directly from the grammar source with this runtime,
- a Python ANTLR runner using
antlr4-python3-runtime, - a Go ANTLR runner using
github.com/antlr4-go/antlr/v4, - a tree-sitter runner using
tree-sitter-language-pack.
Rust generation keeps the pinned JavaParser.g4 unchanged, including its
JavaParserBase option and both semantic predicates. The generated benchmark
parser uses the source compiler's historical assume-true policy for those
target-language helpers, matching downstream generation while retaining the
predicate metadata that controls runtime routing; since #209 those
untranslated coordinates lower as generatable hook-backed templates, so the
predicate-bearing rules run generated bodies rather than the ATN interpreter.
Python and Go still use the equivalent portable rewrite because that
grammars-v4 revision does not provide JavaParserBase implementations for
those targets. The issue-174-return-expression.java fixture guards the
resulting Java method-body performance path. JSON rows include a
benchmark-variant tag, so the comparator skips only method changes such as
this legacy-to-predicate transition (or the #209 interpreter-to-generated
routing change) and resumes Java regression checks once both reports use the
same variant.
Rust generation also keeps the pinned C# grammar unchanged. Its semantic
helper names are described by patterns/csharp.toml, while the benchmark-owned
Rust support module implements interpolation and the CSharpLexerBase
preprocessor state. Generic codegen exposes typed lexer lifecycle hooks,
structural channel/mode constants, and reusable parser-predicate lowerings; it
does not contain C# grammar or rule names.
The output table reports min and avg parse time per fixture and a relative
ratio against rust-antlr for the same fixture.
Use --rust-generated-only for Adaptive LL delivery evidence so the Rust
generator fails if any parser rule lacks a generated body and the Rust runner
fails if a generated parser path falls back to the interpreter.
Rust vs Go AST parity
Pass --ast-check with --phase parse and both rust-antlr and go-antlr
selected. Before timing, the harness dumps each fixture's parse tree from both
runners (same Rule/Term/Err format as the Kotlin parity dumper) and
requires:
- byte-identical dumps, and
- no error nodes on either side (Rust also rejects a non-zero syntax-error count).
Dumps land under <work-dir>/ast-dumps/<language>/.
Kotlin, C#, Java, and Trino fixtures pass this gate. The former C# divergence
was caused by the missing CSharpLexerBase.NextToken preprocessing behavior:
inactive #if sections were still lexed as ordinary C# by the Rust runner.
The Rust support module now evaluates directives and consumes inactive sections
before normal lexing resumes.
python3 tools/parse-bench/run.py \
--languages kotlin,csharp,java,trino \
--runtimes rust-antlr,go-antlr \
--ast-check \
--quick
Lex-only measurements
Use --phase lex to time generated Rust lexing and token buffering without
constructing a parser:
python3 tools/parse-bench/run.py \
--phase lex \
--languages kotlin,csharp,java,trino \
--runtimes rust-antlr \
--iters 20 \
--warmups 3
The source-derived fixtures cover ordinary Kotlin, C#, Java, and Trino input. Two lex-only Kotlin fixtures add concentrated ASCII coverage for long identifiers, strings, comments, whitespace, and punctuation, plus mixed-script coverage for the Unicode fallback. Two lex-only Java fixtures isolate long ASCII range classes for identifiers, numbers, and whitespace.
Use a detached checkout for same-machine baseline comparisons, and select the compiler-level configurations explicitly:
python3 tools/parse-bench/run.py \
--phase lex \
--runtimes rust-antlr \
--runtime-root /tmp/antlr-runtime-main \
--rust-native \
--rust-thin-lto
--rust-native adds -C target-cpu=native. --rust-thin-lto writes
lto = "thin" and codegen-units = 1 in the generated benchmark workspace,
where Cargo profile settings control the final application and its
dependencies.
For profile-guided optimization, first build an instrumented runner and train it on the selected fixtures:
PROFILE_DIR="$(mktemp -d /tmp/antlr-parse-pgo.XXXXXX)"
PROFILE_DATA=/tmp/antlr-parse-pgo.profdata
python3 tools/parse-bench/run.py \
--languages kotlin,csharp,java,trino \
--runtimes rust-antlr \
--rust-generated-only \
--rust-pgo-generate "$PROFILE_DIR" \
--work-dir /tmp/antlr-parse-pgo-train
rustup component add llvm-tools-preview
SYSROOT="$(rustc --print sysroot)"
HOST="$(rustc -vV | sed -n 's/^host: //p')"
"$SYSROOT/lib/rustlib/$HOST/bin/llvm-profdata" merge \
-o "$PROFILE_DATA" "$PROFILE_DIR"
Then rebuild and measure with the merged profile:
python3 tools/parse-bench/run.py \
--languages kotlin,csharp,java,trino \
--runtimes rust-antlr \
--rust-generated-only \
--rust-pgo-use "$PROFILE_DATA" \
--work-dir /tmp/antlr-parse-pgo-use
The profile-generation timings are instrumented training data, not comparison
results. When testing PGO together with --rust-native or --rust-thin-lto,
pass the same options to both commands. Use separate work directories for the
ordinary, instrumented, and profile-use runners.
Prediction Memory Counters
Set ANTLR_PERF_DUMP=1 to build the Rust runner with performance counters.
Parse runs print prediction and context-store measurements; lex-only runs print
lexer direct-ASCII, generic-character, scalar-replay, and bulk-commit counts:
ANTLR_PERF_DUMP=1 python3 tools/parse-bench/run.py \
--languages csharp \
--runtimes rust-antlr \
--iters 10 \
--warmups 2 \
--rust-generated-only
The dump includes canonical context counts, pooled and retained bytes, arena and workspace capacities, merge-cache activity, and outer-context cache hits/misses. It also reports learned parser-DFA warm hits/misses, ATN fallbacks, state interning activity, dense/sparse row counts, edge-density histograms, and hot/cold retained bytes. Parser token-set counters report inline, dense, and interval selection; packed bitset bytes; membership hits/misses; and interval probes eliminated or retained. Store statistics are collected in a separate untimed parse after the benchmark loop, so walking the stores does not affect reported timings.
PR Watchdog
For CI, run the benchmark on the base checkout and the PR checkout on the same runner, then compare JSON reports:
python3 tools/parse-bench/compare.py \
--baseline base-parse-bench.json \
--current head-parse-bench.json \
--max-regression 1.15
By default the comparator checks rust-antlr only. Repeat --runtime to add
other runtimes.
Fixtures
Fixture metadata lives in fixtures/manifest.json. The fixture files are
source-referenced benchmark inputs that point at independent upstream parser
stress patterns:
- Kotlin: JetBrains Kotlin, kotlinx.coroutines, Ktor.
- C#: dotnet/wpf, Mono.
- Java: Mojang DataFixerUpper, Bazel, Google Closure Compiler, Trino.
- Trino SQL: Trino benchmark TPC-DS/TPC-H queries with benchmark placeholders normalized to identifiers, including a curated TPC-DS grammar-stress suite selected by CTE/window/UNION/EXISTS/CASE/grouping feature density.