Skip to content

Commit 9066854

Browse files
Cpp: make DFAState edge lookups lock-free to speed up lexing
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. 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>
1 parent 7d57703 commit 9066854

6 files changed

Lines changed: 107 additions & 33 deletions

File tree

runtime/Cpp/runtime/src/atn/LexerATNSimulator.cpp

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -181,19 +181,18 @@ size_t LexerATNSimulator::execATN(CharStream *input, dfa::DFAState *ds0) {
181181
}
182182

183183
dfa::DFAState *LexerATNSimulator::getExistingTargetState(dfa::DFAState *s, size_t t) {
184-
dfa::DFAState* retval = nullptr;
185-
SharedLock<SharedMutex> edgeLock(atn._edgeMutex);
186-
if (t <= MAX_DFA_EDGE) {
187-
auto iterator = s->edges.find(t - MIN_DFA_EDGE);
184+
if (t > MAX_DFA_EDGE) {
185+
return nullptr;
186+
}
187+
// Lock-free: the edge table is published with release in addDFAEdge and read
188+
// here with acquire (see DFAState::getEdge). A benign miss (null) just causes
189+
// the target to be recomputed, mirroring the Java runtime.
190+
dfa::DFAState *retval = s->getEdge(t - MIN_DFA_EDGE);
188191
#if LEXER_DEBUG_ATN == 1
189-
if (iterator != s->edges.end()) {
190-
std::cout << std::string("reuse state ") << s->stateNumber << std::string(" edge to ") << iterator->second->stateNumber << std::endl;
191-
}
192-
#endif
193-
194-
if (iterator != s->edges.end())
195-
retval = iterator->second;
192+
if (retval != nullptr) {
193+
std::cout << std::string("reuse state ") << s->stateNumber << std::string(" edge to ") << retval->stateNumber << std::endl;
196194
}
195+
#endif
197196
return retval;
198197
}
199198

@@ -529,7 +528,7 @@ void LexerATNSimulator::addDFAEdge(dfa::DFAState *p, size_t t, dfa::DFAState *q)
529528
}
530529

531530
UniqueLock<SharedMutex> edgeLock(atn._edgeMutex);
532-
p->edges[t - MIN_DFA_EDGE] = q; // connect
531+
p->setEdge(t - MIN_DFA_EDGE, MAX_DFA_EDGE - MIN_DFA_EDGE + 1, q); // connect
533532
}
534533

535534
dfa::DFAState *LexerATNSimulator::addDFAState(ATNConfigSet *configs) {

runtime/Cpp/runtime/src/atn/ParserATNSimulator.cpp

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -292,11 +292,10 @@ size_t ParserATNSimulator::execATN(dfa::DFA &dfa, dfa::DFAState *s0, TokenStream
292292
}
293293

294294
dfa::DFAState *ParserATNSimulator::getExistingTargetState(dfa::DFAState *previousD, size_t t) {
295-
dfa::DFAState* retval;
296-
SharedLock<SharedMutex> edgeLock(atn._edgeMutex);
297-
auto iterator = previousD->edges.find(t);
298-
retval = (iterator == previousD->edges.end()) ? nullptr : iterator->second;
299-
return retval;
295+
// Lock-free acquire read (see DFAState::getEdge). Edges are indexed by t + 1 so
296+
// EOF (t == SIZE_MAX) maps to slot 0, matching the generated parser; a miss
297+
// yields nullptr and the target is recomputed.
298+
return previousD->getEdge(t + 1);
300299
}
301300

302301
dfa::DFAState *ParserATNSimulator::computeTargetState(dfa::DFA &dfa, dfa::DFAState *previousD, size_t t) {
@@ -1297,13 +1296,15 @@ dfa::DFAState *ParserATNSimulator::addDFAEdge(dfa::DFA &dfa, dfa::DFAState *from
12971296
UniqueLock<SharedMutex> stateLock(atn._stateMutex);
12981297
to = addDFAState(dfa, to); // used existing if possible not incoming
12991298
}
1300-
if (from == nullptr || t > (int)atn.maxTokenType) {
1299+
if (from == nullptr || t < -1 || t > (int)atn.maxTokenType) {
13011300
return to;
13021301
}
13031302

13041303
{
13051304
UniqueLock<SharedMutex> edgeLock(atn._edgeMutex);
1306-
from->edges[t] = to; // connect
1305+
// Edges are indexed by t + 1 so EOF (t == -1) lands in slot 0; the table
1306+
// therefore needs maxTokenType + 2 slots.
1307+
from->setEdge(t + 1, atn.maxTokenType + 2, to); // connect
13071308
}
13081309

13091310
#if DFA_DEBUG == 1

runtime/Cpp/runtime/src/dfa/DFA.cpp

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,14 @@ bool DFA::isPrecedenceDfa() const {
6969
DFAState* DFA::getPrecedenceStartState(int precedence) const {
7070
assert(_precedenceDfa); // Only precedence DFAs may contain a precedence start state.
7171

72-
auto iterator = s0->edges.find(precedence);
73-
if (iterator == s0->edges.end())
72+
if (precedence < 0) {
7473
return nullptr;
74+
}
7575

76-
return iterator->second;
76+
// Read under ATN::_edgeMutex (held by the caller); the precedence start-state
77+
// table is the only edge table that grows, and it is never read via the
78+
// lock-free getEdge path used by the lexer/parser simulators.
79+
return s0->getEdge(static_cast<size_t>(precedence));
7780
}
7881

7982
void DFA::setPrecedenceStartState(int precedence, DFAState *startState) {
@@ -85,7 +88,7 @@ void DFA::setPrecedenceStartState(int precedence, DFAState *startState) {
8588
return;
8689
}
8790

88-
s0->edges[precedence] = startState;
91+
s0->setEdge(static_cast<size_t>(precedence), static_cast<size_t>(precedence) + 1, startState);
8992
}
9093

9194
std::vector<DFAState *> DFA::getStates() const {

runtime/Cpp/runtime/src/dfa/DFASerializer.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ std::string DFASerializer::toString() const {
2525
std::stringstream ss;
2626
std::vector<DFAState *> states = _dfa->getStates();
2727
for (auto *s : states) {
28-
for (size_t i = 0; i < s->edges.size(); i++) {
29-
DFAState *t = s->edges[i];
28+
for (size_t i = 0; i < s->edgeCount(); i++) {
29+
DFAState *t = s->getEdge(i);
3030
if (t != nullptr && t->stateNumber != INT32_MAX) {
3131
ss << getStateString(s);
3232
std::string label = getEdgeLabel(i);

runtime/Cpp/runtime/src/dfa/DFAState.cpp

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
* can be found in the LICENSE.txt file in the project root.
44
*/
55

6+
#include <algorithm>
7+
#include <cstddef>
68
#include <memory>
79
#include <sstream>
810
#include <string>
9-
#include <cstddef>
11+
1012
#include "atn/ATNConfigSet.h"
1113
#include "atn/SemanticContext.h"
1214
#include "atn/ATNConfig.h"
@@ -17,6 +19,39 @@
1719
using namespace antlr4::dfa;
1820
using namespace antlr4::atn;
1921

22+
DFAState::~DFAState() {
23+
delete[] _edges.load(std::memory_order_relaxed);
24+
}
25+
26+
void DFAState::setEdge(size_t index, size_t minSize, DFAState *target) {
27+
std::atomic<DFAState *> *edges = _edges.load(std::memory_order_relaxed);
28+
size_t count = _edgeCount.load(std::memory_order_relaxed);
29+
30+
if (edges == nullptr || index >= count) {
31+
size_t newCount = std::max(minSize, index + 1);
32+
auto *newEdges = new std::atomic<DFAState *>[newCount];
33+
for (size_t i = 0; i < count; ++i) {
34+
newEdges[i].store(edges[i].load(std::memory_order_relaxed), std::memory_order_relaxed);
35+
}
36+
for (size_t i = count; i < newCount; ++i) {
37+
newEdges[i].store(nullptr, std::memory_order_relaxed);
38+
}
39+
// Publish the (fully initialized) table: a lock-free getEdge() that
40+
// observes this pointer with acquire is guaranteed to see the slot
41+
// contents and the updated count.
42+
_edgeCount.store(newCount, std::memory_order_release);
43+
_edges.store(newEdges, std::memory_order_release);
44+
// Only the precedence start state ever reaches this with a non-null
45+
// `edges`, and it is read exclusively under ATN::_edgeMutex (never via the
46+
// lock-free getEdge path), so freeing the old table here cannot race a
47+
// concurrent reader.
48+
delete[] edges;
49+
edges = newEdges;
50+
}
51+
52+
edges[index].store(target, std::memory_order_release);
53+
}
54+
2055
std::string DFAState::PredPrediction::toString() const {
2156
return std::string("(") + pred->toString() + ", " + std::to_string(alt) + ")";
2257
}

runtime/Cpp/runtime/src/dfa/DFAState.h

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
#include <cstddef>
1313
#include "antlr4-common.h"
1414

15+
#include <atomic>
16+
1517
#include "atn/ATNConfigSet.h"
16-
#include "FlatHashMap.h"
1718

1819
namespace antlr4 {
1920
namespace dfa {
@@ -65,12 +66,6 @@ namespace dfa {
6566

6667
std::unique_ptr<atn::ATNConfigSet> configs;
6768

68-
/// {@code edges[symbol]} points to target of symbol. Shift up by 1 so (-1)
69-
/// <seealso cref="Token#EOF"/> maps to {@code edges[0]}.
70-
// ml: this is a sparse list, so we use a map instead of a vector.
71-
// Watch out: we no longer have the -1 offset, as it isn't needed anymore.
72-
FlatHashMap<size_t, DFAState*> edges;
73-
7469
/// if accept state, what ttype do we match or alt do we predict?
7570
/// This is set to <seealso cref="ATN#INVALID_ALT_NUMBER"/> when <seealso cref="#predicates"/>{@code !=null} or
7671
/// <seealso cref="#requiresFullContext"/>.
@@ -112,6 +107,41 @@ namespace dfa {
112107

113108
explicit DFAState(std::unique_ptr<atn::ATNConfigSet> configs) : configs(std::move(configs)) {}
114109

110+
DFAState(const DFAState&) = delete;
111+
DFAState& operator=(const DFAState&) = delete;
112+
113+
~DFAState();
114+
115+
/// Outgoing DFA edges, mirroring Java's {@code DFAState.edges} array:
116+
/// {@code edges[symbol]} points to the target state for that symbol. The
117+
/// table is allocated lazily at a fixed size and its slots are published
118+
/// with release/read with acquire, so getEdge() needs no lock. Allocation,
119+
/// growth and stores (setEdge) must be serialized by the caller through
120+
/// ATN::_edgeMutex.
121+
122+
/// Lock-free read of the edge for the given (already offset) index, or
123+
/// nullptr if there is no such edge yet. Safe to call without any lock.
124+
DFAState *getEdge(size_t index) const noexcept {
125+
std::atomic<DFAState *> *edges = _edges.load(std::memory_order_acquire);
126+
if (edges == nullptr || index >= _edgeCount.load(std::memory_order_acquire)) {
127+
return nullptr;
128+
}
129+
return edges[index].load(std::memory_order_acquire);
130+
}
131+
132+
/// Number of edge slots currently allocated (for serialization/iteration).
133+
size_t edgeCount() const noexcept { return _edgeCount.load(std::memory_order_acquire); }
134+
135+
/// Store an edge. `minSize` is the natural full size of the table for this
136+
/// DFA kind (the lexer char range, or maxTokenType+2 for the parser, whose
137+
/// edges are indexed by t+1 so EOF lands in slot 0); the table is
138+
/// grown to at least `index + 1` if needed. The caller MUST hold the
139+
/// ATN::_edgeMutex write lock. Lexer/parser tables are allocated once at
140+
/// `minSize` and never reallocated, so concurrent lock-free getEdge() calls
141+
/// never observe a moved table; only the precedence start state grows, and
142+
/// it is read exclusively under the lock.
143+
void setEdge(size_t index, size_t minSize, DFAState *target);
144+
115145
/// <summary>
116146
/// Get the set of all alts mentioned by all ATN configurations in this
117147
/// DFA state.
@@ -134,6 +164,12 @@ namespace dfa {
134164
bool equals(const DFAState &other) const;
135165

136166
std::string toString() const;
167+
168+
private:
169+
// Array of `_edgeCount` atomic edge slots, or nullptr before the first
170+
// edge is added. Owned by this state and freed in the destructor.
171+
std::atomic<std::atomic<DFAState *> *> _edges{nullptr};
172+
std::atomic<size_t> _edgeCount{0};
137173
};
138174

139175
inline bool operator==(const DFAState &lhs, const DFAState &rhs) {

0 commit comments

Comments
 (0)