Skip to content

Cpp: move DFA write locks from the ATN onto the DFA - #4954

Open
analog-cbarber wants to merge 3 commits into
antlr:devfrom
zuzukin:cpp-per-dfa-locks
Open

Cpp: move DFA write locks from the ATN onto the DFA#4954
analog-cbarber wants to merge 3 commits into
antlr:devfrom
zuzukin:cpp-per-dfa-locks

Conversation

@analog-cbarber

@analog-cbarber analog-cbarber commented Jul 18, 2026

Copy link
Copy Markdown

Note: stacked on #4953 (and, for green CI, on #4963 — the test-harness fix for the repo-wide typescript failures, see #4962). This change is the single Cpp: move DFA write locks commit; the diff collapses to it as the others merge.

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 so DFA stays
movable). The lexer/parser simulators lock the owning DFA instead of the ATN.
ATN::_mutex (the lazy nextTokens cache) is unchanged.

Why

Each ParserInterpreter / LexerInterpreter owns its own decisionToDFA,
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

  • ThreadSanitizer: a TSan build of the runtime with an 8-thread concurrent
    parse harness over a shared ATN reports no data races.
  • AddressSanitizer runs continuously in a downstream CI against this patch.
  • Read paths are unchanged by this commit (edge reads are lock-free via Cpp: make DFAState edge lookups lock-free to speed up lexing #4953).

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.

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>
@analog-cbarber

Copy link
Copy Markdown
Author

Note on the failing typescript checks: pre-existing CI breakage diagnosed in #4962, fix proposed in #4963 — unrelated to this C++-only change. The cpp jobs are green on all three platforms.

analog-cbarber and others added 2 commits August 23, 2026 09:58
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>
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.

1 participant