|
| 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