Skip to content

Commit d36bed9

Browse files
Prune longest-match alternatives by their deep FIRST set (#8) (#34)
The three longest-match dispatch loops (parseNonRec, parseLeftRec atoms, parsePratt nuds) decided whether to try an alternative from its SHALLOW first token (firstTokenOf): a leading rule ref defeated it (→ always tried), so a rule-ref-led alt was speculatively parsed and usually failed at every position — ~57% of all alternative attempts on the TS corpus. Resolve each alternative's FIRST set through the transitive firstSets (already computed, previously used only for rule-ref guards) and skip the alt when the lookahead is provably outside it. Sound by construction: exprFirst is a complete over-approximation (never omits a startable token), and a nullable alt is always tried — its only extra match is the empty one, which never wins the longest-match comparison. Mirrored in both createParser (gen-parser.ts) and emitParser (emit-parser.ts). The per-alt FIRST descriptor arrays are precomputed (interpreter) / hoisted to deduped module-level consts (emitter) so the guard costs nothing per call — which also removes the same per-call array allocation from the existing rule-ref firstGuard, and shrinks the emitted TS parser 328KB → 314KB. Parser-layer ~9% faster (up to ~15% on expression-dense files). Zero behaviour change: byte-identical CST + accept/reject across the full 18,805-file corpus (createParser ≡ emitParser), interpreter CST identical to HEAD, run-conformance 5386/5659 unchanged, 26/26 gates pass.
1 parent 5c0845f commit d36bed9

2 files changed

Lines changed: 97 additions & 33 deletions

File tree

src/emit-parser.ts

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,6 @@ function analyze(grammar: CstGrammar) {
178178
contMeta.set(ruleName, continuations.map(c => mixfixOf(c, ruleName)));
179179
}
180180

181-
// Per-alt first-token (non-recursive + left-rec atoms + pratt nuds use these).
182-
const altFirst = new Map<RuleExpr, FirstTok>();
183-
for (const rule of grammar.rules) {
184-
const alts = rule.body.type === 'alt' ? rule.body.items : [rule.body];
185-
for (const alt of alts) altFirst.set(alt, firstTokenOf(alt));
186-
}
187-
188181
// Nullability.
189182
const nullableRules = new Set<string>();
190183
function exprNullable(e: RuleExpr): boolean {
@@ -257,6 +250,19 @@ function analyze(grammar: CstGrammar) {
257250
}
258251
}
259252

253+
// Deep per-alternative FIRST set + nullability for the longest-match dispatch — the
254+
// emitted mirror of gen-parser.ts's altMightStart. An alternative whose FIRST element
255+
// is a rule ref (`Decl …`, `Expr …`) is pruned when the lookahead can't begin that
256+
// rule (resolved through the transitive firstSets), not only when it begins with a
257+
// known literal/token. Sound: exprFirst over-approximates (never omits a startable
258+
// token) and a nullable alt is always tried (its empty match never wins longest-match).
259+
const altDeepFirst = new Map<RuleExpr, Set<string> | null>();
260+
const altNullable = new Map<RuleExpr, boolean>();
261+
for (const rule of grammar.rules) {
262+
const alts = rule.body.type === 'alt' ? rule.body.items : [rule.body];
263+
for (const alt of alts) { altDeepFirst.set(alt, exprFirst(alt)); altNullable.set(alt, exprNullable(alt)); }
264+
}
265+
260266
// ── Lever 1: integer token kinds ──
261267
// Replace the per-call string dispatch in keyMatchesTok / canStartFT /
262268
// ruleMightStart / matchLiteral / matchToken with integer compares. Two int fields
@@ -350,7 +356,7 @@ function analyze(grammar: CstGrammar) {
350356
return {
351357
grammar, tokenNames, opTable, prefixOps, noUnaryLhsOps, postfixOpValues,
352358
prattRules, leftRecSet, ruleByName, prattClassified, leftRecClassified,
353-
maxBp, templateTokenName, templateTokenNames, firstTokenOf, altFirst,
359+
maxBp, templateTokenName, templateTokenNames, firstTokenOf, altDeepFirst, altNullable,
354360
ledMeta, contMeta, nullableRules, firstSets, symtab,
355361
};
356362
}
@@ -382,6 +388,12 @@ class Emitter {
382388
// loops (quantifier/sep) never clash with an enclosing construct's loop.
383389
private helpers = new Map<string, string>(); // structural key → fn name
384390
private helperDefs: string[] = [];
391+
// Deduped FIRST-set descriptor arrays hoisted to module-level consts (keyed by the
392+
// baked literal text). The longest-match alt guards and the rule-ref guards sit on the
393+
// hottest dispatch paths; referencing a shared frozen array avoids allocating a fresh
394+
// [{…},…] on every call. Spliced at the same `//${HELPERS}` sentinel (top-level, above
395+
// the rule fns); only read at runtime inside those fns, so const TDZ is a non-issue.
396+
private descConsts = new Map<string, string>(); // baked array literal → const name
385397
private a: ReturnType<typeof analyze>;
386398
constructor(a: ReturnType<typeof analyze>) { this.a = a; }
387399
private id() { return `_t${this.tmp++}`; }
@@ -573,12 +585,35 @@ class Emitter {
573585
const a = this.a;
574586
if (a.nullableRules.has(name)) return '';
575587
const fs = a.firstSets.get(name);
576-
if (!fs) return '';
577-
// ruleMightStart: true iff some key in fs matches peek(); guard = NOT that.
578-
// Pre-classify each FIRST key into an int descriptor (same split keyMatchesTok
579-
// does) and bake the descriptor array; the runtime uses integer compares.
580-
const descs = [...fs].map(k => this.keyDescLiteral(k));
581-
return `!ruleMightStartDescs([${descs.join(', ')}], peek())`;
588+
if (!fs || fs.size === 0) return '';
589+
// ruleMightStart: true iff some key in fs matches peek(); guard = NOT that. Each FIRST
590+
// key is pre-classified into an int descriptor (same split keyMatchesTok does); the
591+
// hoisted shared array means the runtime does integer compares with no allocation.
592+
return `!ruleMightStartDescs(${this.descArrayConst(fs)}, peek())`;
593+
}
594+
595+
// Deep per-alternative dispatch condition (mirrors gen-parser.ts altMightStart): the
596+
// POSITIVE "this alt might start at startTok" test for the longest-match loops. `true`
597+
// when the alt is nullable or its FIRST set is unknown/empty (always tried — an empty
598+
// match never wins longest-match); else a membership test over the alt's transitive
599+
// FIRST set, baked as a hoisted int-descriptor array (same encoding firstGuard uses).
600+
altGuard(alt: RuleExpr): string {
601+
const a = this.a;
602+
if (a.altNullable.get(alt)) return 'true';
603+
const fs = a.altDeepFirst.get(alt);
604+
if (!fs || fs.size === 0) return 'true';
605+
return `ruleMightStartDescs(${this.descArrayConst(fs)}, startTok)`;
606+
}
607+
608+
// Register (deduped) a FIRST-set's baked int-descriptor array as a module-level const
609+
// and return its NAME. Keys are sorted so two FIRST sets with the same members share
610+
// one const regardless of traversal order (dedup is best-effort; the boolean membership
611+
// result `ruleMightStartDescs` computes is order-independent either way).
612+
descArrayConst(fs: Set<string>): string {
613+
const lit = `[${[...fs].sort().map(k => this.keyDescLiteral(k)).join(', ')}]`;
614+
let nm = this.descConsts.get(lit);
615+
if (!nm) { nm = `_fs${this.descConsts.size}`; this.descConsts.set(lit, nm); this.helperDefs.push(`const ${nm} = ${lit};`); }
616+
return nm;
582617
}
583618

584619
// ── Lever 1 emit helpers ──
@@ -896,9 +931,8 @@ function emitNonRecRule(e: Emitter, a: ReturnType<typeof analyze>, rule: RuleDec
896931
e.emit(` let bestNode = null; let bestPos = saved;`);
897932
e.emit(` const startTok = tokens[saved] ?? null;`);
898933
alts.forEach((alt, i) => {
899-
const ft = a.altFirst.get(alt) ?? null;
900934
e.emit(` // alt ${i}`);
901-
e.emit(` if (canStartFT(${e.firstTokDescLiteral(ft)}, startTok)) {`);
935+
e.emit(` if (${e.altGuard(alt)}) {`);
902936
e.emit(` pos = saved;`);
903937
e.emit(` const children = arm_${sanitize(rule.name)}_${i}();`);
904938
e.emit(` if (children !== null && pos > bestPos) {`);
@@ -931,8 +965,7 @@ function emitLeftRecRule(e: Emitter, a: ReturnType<typeof analyze>, rule: RuleDe
931965
e.emit(` let node = null; let bestAtomPos = saved;`);
932966
e.emit(` const startTok = tokens[saved] ?? null;`);
933967
atoms.forEach((atom, i) => {
934-
const ft = a.altFirst.get(atom) ?? null;
935-
e.emit(` if (canStartFT(${e.firstTokDescLiteral(ft)}, startTok)) {`);
968+
e.emit(` if (${e.altGuard(atom)}) {`);
936969
e.emit(` pos = saved;`);
937970
e.emit(` const children = atom_${sanitize(rule.name)}_${i}();`);
938971
e.emit(` if (children !== null && pos > bestAtomPos) {`);
@@ -987,9 +1020,8 @@ function emitPrattRule(e: Emitter, a: ReturnType<typeof analyze>, rule: RuleDecl
9871020
// NUD loop.
9881021
nuds.forEach((nud, i) => {
9891022
const items = nud.type === 'seq' ? nud.items : [nud];
990-
const ft = a.altFirst.get(nud) ?? null;
9911023
e.emit(` // nud ${i}`);
992-
e.emit(` if (canStartFT(${e.firstTokDescLiteral(ft)}, startTok)) {`);
1024+
e.emit(` if (${e.altGuard(nud)}) {`);
9931025
e.emit(` pos = saved;`);
9941026
if (items[0]?.type === 'prefix') {
9951027
// prefix $ pattern: identical to parsePratt's prefix branch.

src/gen-parser.ts

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -337,11 +337,12 @@ export function createParser(grammar: CstGrammar) {
337337
const templateTokenNames = new Set<string>(grammar.tokens.filter(t => t.template).map(t => t.name));
338338

339339
// ── First-token dispatch ──
340-
// For each alternative, the token it MUST begin with, if that is statically
341-
// knowable (a leading literal or token ref). When known, the alternative can
342-
// be skipped outright if the current token can't be its first token — so the
343-
// alternative loops branch on the lookahead instead of trying every arm.
344-
// `null` = not statically knowable (rule ref / prefix / optional first) → always try.
340+
// The single token an expression MUST begin with, if statically knowable (a leading
341+
// literal or token ref); `null` = not knowable (rule ref / prefix / optional first) →
342+
// always try. The alternative loops now use the deeper `altMightStart` (which resolves
343+
// a leading rule ref through `firstSets`); `firstTokenOf` remains the dispatch for the
344+
// Pratt LED continuations (`ledFirst`), whose connectors are operator literals for
345+
// which the single-token form is already exact.
345346
type FirstTok = { lit: string } | { tok: string } | null;
346347
function firstTokenOf(alt: RuleExpr): FirstTok {
347348
const items = alt.type === 'seq' ? alt.items : [alt];
@@ -351,11 +352,6 @@ export function createParser(grammar: CstGrammar) {
351352
if (first.type === 'ref' && tokenNames.has(first.name)) return { tok: first.name };
352353
return null;
353354
}
354-
const firstOf = new Map<RuleExpr, FirstTok>();
355-
for (const rule of grammar.rules) {
356-
const alts = rule.body.type === 'alt' ? rule.body.items : [rule.body];
357-
for (const alt of alts) firstOf.set(alt, firstTokenOf(alt));
358-
}
359355
// Does a FIRST-set key (a token name, or a literal keyword/punctuation) match a token?
360356
function keyMatchesTok(key: string, tok: Token): boolean {
361357
if (tokenNames.has(key)) {
@@ -520,6 +516,42 @@ export function createParser(grammar: CstGrammar) {
520516
return false;
521517
}
522518

519+
// ── Deep per-alternative dispatch ──
520+
// The shallow `firstTokenOf` above only prunes an alternative when its FIRST element
521+
// is a literal or a *token* ref; a leading *rule* ref (e.g. an alt `Decl …` / `Expr …`)
522+
// defeats it (→ null → always tried). But the longest-match loops try EVERY alt that
523+
// isn't pruned, so a rule-ref-led alt is speculatively parsed (and usually fails) at
524+
// every position — measured at ~57% of all alternative attempts on real TS. The full
525+
// transitive `firstSets` already knows what each rule can begin with, so resolve the
526+
// alt's FIRST set through it and prune the alt when the lookahead is provably outside.
527+
//
528+
// Sound by construction: `exprFirst` is a sound OVER-approximation (it never omits a
529+
// token a non-empty match could begin with), so an alt pruned here genuinely cannot
530+
// match non-empty at this token. A NULLABLE alt is always tried — its only extra match
531+
// is the empty one, and an empty match never wins the longest-match comparison
532+
// (`pos === saved`, never `> bestPos`), so behaviour is identical with or without it.
533+
// Strictly dominates `canStart(firstOf…)`: whenever the shallow check pruned, the deep
534+
// FIRST set (whose leading member is that same literal/token) prunes too.
535+
// Precompute each alt's dispatch keys ONCE: `null` = always try (nullable / unknowable
536+
// / empty FIRST — collapsed so the hot path is a single branch), else the flat array of
537+
// FIRST-set keys to test the lookahead against. Doing the nullability + FIRST resolution
538+
// here keeps `altMightStart` to a map lookup + a bounded scan — no per-call tree walk.
539+
const altDispatch = new Map<RuleExpr, string[] | null>();
540+
for (const rule of grammar.rules) {
541+
const alts = rule.body.type === 'alt' ? rule.body.items : [rule.body];
542+
for (const alt of alts) {
543+
const fs = exprNullable(alt) ? null : exprFirst(alt);
544+
altDispatch.set(alt, fs && fs.size > 0 ? [...fs] : null);
545+
}
546+
}
547+
function altMightStart(alt: RuleExpr, tok: Token | null): boolean {
548+
if (!tok) return true;
549+
const keys = altDispatch.get(alt);
550+
if (!keys) return true; // always try (nullable / top / empty)
551+
for (let i = 0; i < keys.length; i++) if (keyMatchesTok(keys[i], tok)) return true;
552+
return false;
553+
}
554+
523555
// ── Parser core ──
524556

525557
const profCounts = new Map<string, number>();
@@ -703,7 +735,7 @@ export function createParser(grammar: CstGrammar) {
703735
const startTok = tokens[saved] ?? null;
704736

705737
for (const alt of alts) {
706-
if (!canStart(firstOf.get(alt), startTok)) continue;
738+
if (!altMightStart(alt, startTok)) continue;
707739
pos = saved;
708740
// The markup container arm (HTML element with children) is matched by a
709741
// dedicated path that honours optional end tags — content stops at a trigger
@@ -735,7 +767,7 @@ export function createParser(grammar: CstGrammar) {
735767
let bestAtomPos = saved;
736768
const startTok = tokens[saved] ?? null;
737769
for (const atom of atoms) {
738-
if (!canStart(firstOf.get(atom), startTok)) continue;
770+
if (!altMightStart(atom, startTok)) continue;
739771
pos = saved;
740772
const children = matchExpr(atom);
741773
if (children !== null && pos > bestAtomPos) {
@@ -791,7 +823,7 @@ export function createParser(grammar: CstGrammar) {
791823
let bestNudPos = saved;
792824
const startTok = tokens[saved] ?? null;
793825
for (const nud of nuds) {
794-
if (!canStart(firstOf.get(nud), startTok)) continue;
826+
if (!altMightStart(nud, startTok)) continue;
795827
pos = saved;
796828
const items = nud.type === 'seq' ? nud.items : [nud];
797829

0 commit comments

Comments
 (0)