Skip to content

Cpp: make DFAState edge lookups lock-free to speed up lexing#4953

Open
analog-cbarber wants to merge 1 commit into
antlr:devfrom
zuzukin:cpp-lockfree-dfa-edges
Open

Cpp: make DFAState edge lookups lock-free to speed up lexing#4953
analog-cbarber wants to merge 1 commit into
antlr:devfrom
zuzukin:cpp-lockfree-dfa-edges

Conversation

@analog-cbarber

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

Copy link
Copy Markdown

What

Replaces DFAState::edges — a FlatHashMap<size_t, DFAState*> guarded by the
ATN-wide _edgeMutex — with a lazily-allocated array of
std::atomic<DFAState*>, giving a lock-free getEdge on the per-character hot
path. Writers still take the existing mutex; only the read path changes.

Why

The lexer consults the DFA edge table once per input character, so the hottest
loop in the C++ runtime pays a shared-lock acquire plus a hash-map probe per
codepoint. The Java reference runtime has neither cost: DFAState.edges there
is a plain array indexed by symbol, read without locking. This change brings
the C++ runtime to parity with the Java design.

Edges are indexed by t + 1 (as in the Java runtime) so EOF (t == -1) maps
to slot 0; the table is sized maxTokenType + 2.

Performance

Measured on a 4.7 MB JSON parse (Apple M5 Max, best of 7): ~12% off the
single-thread wall time (551 ms → 486 ms). JSON is lexer-heavy, which flatters
this change; parser-heavy grammars see less.

Safety / verification

  • Memory-safety: an earlier revision of this patch dropped the t + 1 offset
    and produced an out-of-bounds write when a parser decision cached an edge on
    EOF lookahead. That is fixed in this submission and the scenario is covered:
    the patch has been running under AddressSanitizer in CI (single-token /
    empty / truncated / malformed inputs across two grammars) in a downstream
    project, clean.
  • Semantics: atomic release-store on write, acquire-load on read; the map →
    array change does not alter which edges are cached.
  • The full downstream test suite (event-stream comparisons against
    pure-Python-runtime oracles) runs against this patch continuously.

Context

This patch (with #4954) is shipped today in antlrope, a
C++-accelerated Python runtime that drives
ParserInterpreter/LexerInterpreter from serialized ATNs. antlrope
currently vendors a patched copy of this runtime; once this change is merged
and released, it can drop the patch and vendor the unmodified upstream runtime
again. Benchmarks and methodology:
https://zuzukin.github.io/antlrope/concepts/ (the "vendored runtime and its
patches" section) and https://github.com/zuzukin/srdl-bench.


Authorship note: this patch was developed with AI assistance (Claude Opus
4.7, reflected in the commit trailer); I reviewed the design and code, and the
verification above (ASan CI, downstream test suite, benchmarks) is what I'd
stake it on.

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>
@analog-cbarber
analog-cbarber force-pushed the cpp-lockfree-dfa-edges branch from 9066854 to baa5db5 Compare July 18, 2026 16:56
analog-cbarber added a commit to zuzukin/antlrope that referenced this pull request Jul 18, 2026
The lock-free DFA-edge patch indexes edges by t + 1 (EOF in slot 0), but
DFASerializer::getEdgeLabel still printed the raw slot index, shifting
every DFA-dump edge label by one token type. Print getDisplayName(i - 1)
as the Java serializer does. Caught by the upstream runtime testsuite on
antlr/antlr4#4953/#4954; diagnostic output only — parse behavior and the
event stream are unaffected. Updates the pinned fork SHAs (amended:
baa5db5ba, 96ea7bcc5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.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