Cpp: move DFA write locks from the ATN onto the DFA - #4954
Open
analog-cbarber wants to merge 3 commits into
Open
Cpp: move DFA write locks from the ATN onto the DFA#4954analog-cbarber wants to merge 3 commits into
analog-cbarber wants to merge 3 commits into
Conversation
analog-cbarber
force-pushed
the
cpp-per-dfa-locks
branch
from
July 18, 2026 16:56
c64c65b to
96ea7bc
Compare
The TypeScript runtime tests have failed in CI since about April: every
test dies at the Execute stage with
TypeError: Cannot read properties of undefined (reading 'fileExists')
at readConfig (.../ts-node/dist/configuration.js:91:33)
before any ANTLR code runs. The harness installs its toolchain unpinned
(npm install -g typescript ts-node ...), and npm's typescript@latest is
now the TypeScript 7 rewrite, which no longer exposes the ts.sys internal
API that ts-node (10.9.x, dormant since 2022) wires into. The same tree
was green in February under TypeScript 5.x; only the unpinned toolchain
moved.
Replace ts-node with tsx, which executes TypeScript via esbuild and does
not depend on the TypeScript compiler API, so it cannot be broken by
TypeScript's release cadence. The global typescript install is dropped
too: it existed only as ts-node's peer (the JavaScript runtime's webpack
build installs its own pinned typescript locally). The ts-node section of
the helper tsconfig goes away, and TsNodeRunner is renamed TsxRunner to
match what it now runs.
tsx transpiles without type-checking, as ts-node --transpile-only would;
these execution tests compare runtime output, so no coverage is lost.
Verified by reproducing the harness setup (ESM package.json + helper
tsconfig + a test file importing antlr4): ts-node under typescript 7.0.2
fails with exactly the CI error; tsx runs the same file successfully.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Barber <analog.cbarber@gmail.com>
Author
The C++ runtime stored each DFA state's outgoing edges in a
FlatHashMap<size_t, DFAState*> and guarded every access with a single
shared ATN-wide mutex (ATN::_edgeMutex). Because the lexer consults the
edge table once per input character, the hottest loop in the runtime paid
a shared-lock acquire plus a hash-map probe on every codepoint. The Java
reference runtime has neither cost: DFAState.edges is a plain array indexed
by symbol, and reads are lock-free (a benign miss simply recomputes the
edge, which is idempotent), while writes are serialized per state.
This change brings the C++ representation in line with Java:
- DFAState::edges becomes a lazily allocated, fixed-size array of
std::atomic<DFAState*>. getEdge() reads a slot with acquire ordering
and needs no lock; setEdge() publishes slots with release ordering and
is still serialized by the caller via ATN::_edgeMutex.
- Lexer and parser getExistingTargetState() now read edges lock-free,
removing the per-symbol shared-lock acquire from the hot path.
- The lexer and parser edge tables are allocated once at their natural
full size (the char range and maxTokenType+1 respectively) and never
reallocated, so a concurrent lock-free reader can never observe a moved
table. The only table that grows is the precedence DFA's start-state
table, which is read exclusively under ATN::_edgeMutex and never via
the lock-free path, so resizing it cannot race a reader.
- DFASerializer::getEdgeLabel shifts its index back by one when printing
edge labels (edges are indexed by t + 1 so EOF occupies slot 0),
matching the Java serializer; slot 0 renders as "EOF".
The concurrency contract is therefore identical to Java's: lock-free reads
tolerating a benign recompute-on-miss, with writes serialized. There is no
public API change.
Measured on an 84 MiB JSON input (Apple M5 Max, single-threaded interpreted
parse), the lexer stage goes from 26.7 to 47.9 MiB/s (~1.8x) and total
native parse from 15.0 to 20.9 MiB/s (~1.4x); a 54 MiB netlist input shows
1.7x / 1.3x. Output is byte-for-byte identical to the previous runtime
across all test inputs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Christopher Barber <analog.cbarber@gmail.com>
DFA construction (addDFAState's state-set dedup and DFAState::setEdge) was serialized by ATN::_stateMutex / ATN::_edgeMutex. But each ParserInterpreter / LexerInterpreter owns its own decisionToDFA, so concurrent interpreters over a shared (read-only) ATN serialized on a single per-ATN lock for DFA writes they do not share — concurrency became a net loss (measured ~0.6x of serial on 4 threads). Move those two write locks onto the DFA itself (dfa::DFA::stateMutex/edgeMutex, heap-allocated so DFA stays movable) and lock the owning DFA in the simulators. ATN::_mutex (lazy nextTokens cache) is unchanged; edge reads stay lock-free. Independent DFAs now use independent locks (concurrent interpreters scale ~2.1x on 4 threads); a DFA shared across threads (generated recognizers' static decisionToDFA) still serializes its own writers, now at per-decision rather than per-ATN granularity. Same locking discipline, finer scope. Verified with a ThreadSanitizer build of the runtime + an 8-thread concurrent parse harness over a shared ATN: no data races. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Christopher Barber <analog.cbarber@gmail.com>
analog-cbarber
force-pushed
the
cpp-per-dfa-locks
branch
from
August 23, 2026 13:58
96ea7bc to
910af6e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Moves the DFA state/edge write locks off the ATN
(
ATN::_stateMutex/ATN::_edgeMutex, removed) and onto the DFA itself(
dfa::DFA::stateMutex()/edgeMutex(), heap-allocated soDFAstaysmovable). The lexer/parser simulators lock the owning DFA instead of the ATN.
ATN::_mutex(the lazynextTokenscache) is unchanged.Why
Each
ParserInterpreter/LexerInterpreterowns its owndecisionToDFA,but the write locks lived on the shared ATN — so concurrent interpreters
over one (read-only) ATN serialized on a single lock for DFA writes they do
not even share. Measured effect: parsing a shared spec across 4 threads ran at
~0.6× of serial — concurrency was a net loss.
With per-DFA locks, independent DFAs use independent locks: the same 4-thread
workload scales to ~2.1× (≈4× better wall-clock than before the patch).
A DFA genuinely shared across threads (a generated recognizer's static
decisionToDFA) still serializes its own writers — same locking discipline,per-decision rather than per-ATN granularity.
Safety / verification
parse harness over a shared ATN reports no data races.
Context
Shipped today (with #4953) in
antlrope; this is what makes its
shared-spec parallel parsing scale. Measurements and methodology:
https://zuzukin.github.io/antlrope/concepts/ ("How much do the patches
contribute?").
Authorship note: this patch was developed with AI assistance (Claude Opus
4.8, reflected in the commit trailer); I reviewed the design and code, and the
verification above (TSan harness, ASan CI, benchmarks) is what I'd stake it on.