Skip to content

Commit 2c06e89

Browse files
Add design sketch for moving token/terminal matching into C++
Based on profiling showing ~40% of fresh-text parse time spent in C-to-Python matching callbacks. Key findings grounding the design: terminal variants already reduce to a 39-bit space (VariantHandler vbits/fbits), token meanings map into the same space via get_fbits(), and 4,893 of 6,012 grammar terminals are literals whose matching is pure interned-string identity plus bit tests. Proposes a TerminalSpec table and per-token MeaningRec arrays with a T_PYTHON escape hatch, GreynirCorrect-compatible gating, and a phased rollout with a parity harness. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fc77679 commit 2c06e89

1 file changed

Lines changed: 187 additions & 0 deletions

File tree

doc/cpp-matching-design.md

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# Design sketch: moving token/terminal matching into the C++ core
2+
3+
Status: DRAFT for discussion — 2026-07-13
4+
5+
## 1. Motivation and measured cost
6+
7+
Profiling (CPython 3.13, warm grammar, fresh matching cache) shows that for
8+
typical 10-25 token sentences, parse time divides roughly as:
9+
10+
| Component | Share |
11+
|------------------------------------------------------------|-------|
12+
| C++ Earley-Scott core (`earleyParse` self-time) | ~42% |
13+
| Python matching callbacks (`matching_func``BIN_Token.matches`) | ~40% |
14+
| Reducer (`reducer.py`) | ~9% |
15+
| Forest conversion (`Node.from_c_node`) | ~4.5% |
16+
| Tokenization and misc | ~4% |
17+
18+
The callback share is first-encounter cost: the per-token match cache
19+
(`alloc_func`/`matching_cache`) already eliminates repeat queries, giving a
20+
2.6x speedup on cache-warm text. The target of this design is the fresh-text
21+
path: eliminate most C→Python callbacks entirely, by making the C++ core
22+
able to answer the common matching queries itself.
23+
24+
A secondary motivation: C→Python CFFI *callbacks* are a structural weak
25+
spot on PyPy (measured ~50% slower than CPython 3.13 on typical text).
26+
Removing the callback from the hot path benefits both interpreters.
27+
28+
## 2. Why this is feasible: the machinery is already half-built
29+
30+
Three observations from `binparser.py`:
31+
32+
1. **The terminal side is already bit-encoded.** `VariantHandler` maps each
33+
terminal's variants to `_vbits`/`_fbits` (39 distinct `VBIT_*` bits — fits
34+
in a `uint64_t` with headroom), and the workhorse predicates
35+
(`fbits_match`, `fbits_match_mask`) are pure bitmask tests.
36+
37+
2. **The token-meaning side maps to the same bit space.** `get_fbits()`
38+
converts a BÍN `beyging` inflection string into the same fbits, already
39+
cached per distinct `beyging` string.
40+
41+
3. **81% of all terminals are literals.** Of the 6,012 terminals in
42+
`Greynir.grammar`, 4,893 are literal terminals (`'lemma:cat'_variants` or
43+
`"form:cat"`), whose match semantics are: interned-string identity on the
44+
lemma or word form, an optional category filter, fbits, and (for
45+
single-quoted verb literals) an MM exclusion. All of this reduces to
46+
integer compares. The remaining ~1,100 category terminals are dominated
47+
by `lo` (378) and `so` (326); most non-verb category matchers
48+
(`matcher_default`, `abfn`, `pfn`, and the simple paths of `no`/`lo`)
49+
are already just fbits tests plus small special cases.
50+
51+
The genuinely complex logic is concentrated in a few places: verb argument
52+
frames and subject cases (`verb_matches`, driven by Verbs.conf), adjective
53+
subject cases (`matcher_lo` with `_ADJ_ARGUMENTS`), prepositions
54+
(`matcher_fs`), person/street/proper-name matching, ending-constraint
55+
variants (`_x.../_z...`), and the unknown-word fallbacks. These stay in
56+
Python behind an escape hatch — indefinitely, if we like.
57+
58+
## 3. Design
59+
60+
### 3.1 Terminal spec table (built in Python, passed to C++ once)
61+
62+
Python already parses terminal names into structured form (`VariantHandler`),
63+
so the classification is done at grammar-load time in Python and handed to
64+
the C++ parser as a flat array — **no change to the binary grammar file**.
65+
66+
```c
67+
enum TerminalKind : uint8_t {
68+
T_PYTHON = 0, // escape hatch: call matching_func as today
69+
T_LIT_FORM, // "form" - token text identity (case-folded id)
70+
T_LIT_LEMMA, // 'lemma:cat'_variants - lemma id + cat + fbits
71+
T_LIT_LEMMA_MM, // as above, verb lemma with the MM exclusion rule
72+
T_CAT, // single category + fbits/mask test
73+
T_CAT_NOUN, // category in {kk, kvk, hk} + noun special cases
74+
};
75+
76+
struct TerminalSpec {
77+
uint8_t nKind;
78+
uint8_t nCatId; // small enum of ordfl values; CAT_NONE if unused
79+
uint16_t nFlags; // TF_HAS_GR, TF_ABBREV, TF_NO_INFO_OK, ...
80+
uint32_t nLitId; // interned literal id (lemma or form), or 0
81+
uint64_t nFbits; // required feature bits
82+
uint64_t nFbitMask; // comparison mask (e.g. cases-only for abfn)
83+
};
84+
```
85+
86+
A new entry point `setTerminalSpecs(Parser*, const TerminalSpec*, UINT n)`
87+
(or an extra argument to `newParser`) installs the table. Any terminal whose
88+
semantics we have not (yet) encoded is simply `T_PYTHON`.
89+
90+
### 3.2 Token meaning records (built lazily in Python, once per token key)
91+
92+
For each distinct token (keyed exactly like today's `matching_cache`),
93+
Python builds a compact meaning array once:
94+
95+
```c
96+
struct MeaningRec {
97+
uint8_t nCatId; // ordfl as enum
98+
uint8_t nFlags; // MF_NO_BEYGING ('-'), MF_NAME_FL (fl in nafn/ætt), ...
99+
uint32_t nLemmaId; // interned id, 0 if lemma not in grammar lexicon
100+
uint32_t nFormId; // interned id of the (case-folded) word form
101+
uint64_t nFbits; // get_fbits(m.beyging)
102+
};
103+
```
104+
105+
Interning: the id space is defined by the grammar's literal lexicon (the
106+
~4,900 distinct lemma/form strings appearing in literal terminals, interned
107+
at grammar load). A meaning whose lemma/form is not in that lexicon gets
108+
id 0 and can never match a literal terminal — one dict lookup per meaning
109+
at encoding time, integer compares forever after.
110+
111+
Delivery to C++ mirrors the existing cache handshake: a new callback
112+
113+
```c
114+
typedef const BYTE* (*MeaningsFunc)(UINT nHandle, UINT nToken, UINT* pnCount);
115+
```
116+
117+
which Python answers from a per-token-key cache (like `alloc_cache` today).
118+
Non-WORD tokens (numbers, dates, persons, entities, punctuation...) return
119+
a sentinel marking the token Python-only in this phase.
120+
121+
### 3.3 Matching in `Column::matches()`
122+
123+
```
124+
if terminal spec is T_PYTHON, or token is Python-only:
125+
fall back to m_pMatchingFunc(...) // exactly today's behavior
126+
else:
127+
for each MeaningRec of the token:
128+
switch on spec.nKind: integer/bit compares only
129+
cache the result byte as today
130+
```
131+
132+
The per-column byte cache and the cross-sentence buffer cache are unchanged;
133+
warm-path behavior is identical. The only change is who computes a cache
134+
miss for simple terminals.
135+
136+
### 3.4 What stays in Python (Phase 1)
137+
138+
- All `so_*` category terminals (verb frames, Verbs.conf subjects/arguments)
139+
- `lo` terminals with subject-case variants (`_sþf`/`_sþgf`/`_sef`)
140+
- Ending-constraint variants (`_x...`, `_z...`)
141+
- `fs`, `person`, `gata`, `sérnafn`, `eo`, `stt` and other special matchers
142+
- All unknown-word tokens (no BÍN meanings) and all non-WORD token kinds
143+
144+
**Compatibility requirement**: `verb_subject_matches` and
145+
`verb_is_strictly_impersonal` are overridden by GreynirCorrect
146+
(`reynir_correct.errfinder`), and any subclass may override matching
147+
behavior. The fast path must therefore be gated: a class-level flag on
148+
`BIN_Parser` (e.g. `_ALLOW_CPP_MATCHING`), turned off automatically when a
149+
subclass overrides any matcher hook. Derived packages then keep bit-exact
150+
behavior with zero changes, at today's speed.
151+
152+
### 3.5 Parity harness and rollout
153+
154+
- **Phase 0 — instrumentation**: count callback volume per matcher function
155+
on a realistic corpus, to rank phases by actual query volume (not
156+
terminal count).
157+
- **Phase 1 — literal terminals** (81% of the terminal set; the profile's
158+
808k calls to `BIN_LiteralTerminal.matches` are pure string compares
159+
crossing the boundary today).
160+
- **Phase 2 — fbits-only category terminals** (`matcher_default`, `abfn`,
161+
`pfn`, simple `no`/`lo`/`töl` paths).
162+
- **Phase 3 (optional) — verb frames**: encode per-verb argument/subject
163+
sets (Verbs.conf) as id-keyed bitsets in C++. Largest complexity;
164+
do only if Phase 0 data shows verbs dominate remaining callbacks.
165+
Note that `verb_matches` is already `lru_cache`d in Python, which
166+
absorbs some of the repeat cost.
167+
- **Parity mode**: a debug flag under which C++ computes its answer AND
168+
calls the Python matcher, asserting equality on every query; run the full
169+
test suite and a large corpus in this mode before each phase ships.
170+
Divergence in any query → the terminal is demoted to `T_PYTHON`.
171+
172+
### 3.6 Expected win
173+
174+
Fresh-text typical sentences: most of the ~40% callback share disappears
175+
(bounded by Phase coverage of query volume — measure in Phase 0); estimated
176+
overall parse speedup of 25-35% on novel text, larger on PyPy. Cache-warm
177+
and very-long-sentence workloads: little change (already cache/C++-bound).
178+
179+
## 4. Alternatives considered and rejected
180+
181+
- **Eager full-row precomputation in Python** (fill all 6K terminal bytes
182+
per token up front): does strictly more work than lazy queries; most of
183+
a row is never queried.
184+
- **Bitset vectorization in Python (numpy)**: adds a dependency and still
185+
pays per-query Python call overhead; the boundary is the problem.
186+
- **Moving the reducer to C++**: only ~9% of typical-sentence time; poor
187+
effort/reward compared to the matching boundary.

0 commit comments

Comments
 (0)