Skip to content

Commit 4f5f8c6

Browse files
Add O(1) lid_of keyword early-exit prefilter across portable targets.
Ident-shaped texts miss a length×first-byte bit table derived from keyword literals before any string match; rust splits kw/punct match tables so non-keyword Idents skip punct arms too. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c002273 commit 4f5f8c6

4 files changed

Lines changed: 130 additions & 17 deletions

File tree

src/emit-portable.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,60 @@ export function lidOf(plan: LexIdPlan, text: string): number {
676676
return i >= 0 ? i : 0;
677677
}
678678

679+
/**
680+
* O(1) early-exit prefilter for runtime `lid_of` on Ident-shaped texts.
681+
*
682+
* - `maxByteLen` spans ALL literals (so oversize still ⇒ lid 0, puncts included).
683+
* - `firstByLenBits` is filled only from `isKeywordLiteral` texts (Ident / `@kw` shapes).
684+
* Runtime applies the bit probe only when the first byte is Ident-start or `@`; punct first
685+
* bytes skip the probe and go straight to the slow path — no tax on the punct hot path.
686+
* Conservatively correct: every keyword-shaped literal hits; non-keywords may fall through.
687+
*
688+
* Layout: `(maxByteLen + 1)` rows × 32 bytes (256 bits).
689+
*/
690+
export type LidPrefilterPlan = {
691+
maxByteLen: number;
692+
/** Flat bit table, length `(maxByteLen + 1) * 32` — keyword-shaped (len × first) only. */
693+
firstByLenBits: Uint8Array;
694+
};
695+
696+
/** True if first byte is Ident-start (`A-Za-z_$`) or `@` (decorator-keyword shape). */
697+
export function lidPrefilterAppliesToFirst(b0: number): boolean {
698+
return (b0 >= 65 && b0 <= 90) || (b0 >= 97 && b0 <= 122) || b0 === 95 || b0 === 36 || b0 === 64;
699+
}
700+
701+
/** Build length×first-byte prefilter: maxLen from all lids; bits from keyword-shaped lids only. */
702+
export function buildLidPrefilter(plan: LexIdPlan): LidPrefilterPlan {
703+
let maxByteLen = 0;
704+
const kw: { len: number; first: number }[] = [];
705+
for (let i = 1; i < plan.lids.length; i++) {
706+
const text = plan.lids[i]!;
707+
const buf = Buffer.from(text, 'utf8');
708+
if (buf.length === 0) continue;
709+
maxByteLen = Math.max(maxByteLen, buf.length);
710+
if (isKeywordLiteral(text)) kw.push({ len: buf.length, first: buf[0]! });
711+
}
712+
const firstByLenBits = new Uint8Array((maxByteLen + 1) * 32);
713+
for (const { len, first } of kw) {
714+
firstByLenBits[len * 32 + (first >> 3)] |= 1 << (first & 7);
715+
}
716+
return { maxByteLen, firstByLenBits };
717+
}
718+
719+
/**
720+
* Probe the prefilter (mirrors emitted runtime).
721+
* Non-Ident-shaped texts are never rejected here (punct path).
722+
* True ⇒ may be a literal (must consult slow path); false ⇒ definite lid 0 for Ident-shaped.
723+
*/
724+
export function lidPrefilterMayHit(pf: LidPrefilterPlan, text: string): boolean {
725+
const buf = Buffer.from(text, 'utf8');
726+
const n = buf.length;
727+
if (n === 0 || n > pf.maxByteLen) return false;
728+
const b0 = buf[0]!;
729+
if (!lidPrefilterAppliesToFirst(b0)) return true; // punct-shaped — defer to slow path
730+
return (pf.firstByLenBits[n * 32 + (b0 >> 3)]! & (1 << (b0 & 7))) !== 0;
731+
}
732+
679733
/** Resolve kid for a known kind; 0 if not in the plan. */
680734
export function kidOf(plan: LexIdPlan, kind: string): number {
681735
const i = plan.kids.indexOf(kind);

src/target-go.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
// slices to saved lengths; the slices keep their capacity across parses (reset to len 0), so a
1111
// warmed parser allocates ~nothing per parse.
1212
import { type ParserIR, type RdRule, type PrattRule, type Step, type Bracket, type CharRange, type LexTok, type TplCfg, type NewlineCfg, type FirstSig, type LexFirstBytes, type LexIdPlan, type ArenaIdPlan } from './emit-portable.ts';
13-
import { portableIR, buildLexDispatchPlan, lexTokFirstBytes, punctFirstBytes, buildLexIdPlan, buildArenaIdPlan, lidOf, kidOf, lidFlagTable, kidFlagTable, ttIdOf, ruleIdOf, TT_SKIP_PUNCT, rangesHaveNonAscii, isFirstGuardable, groupByPreserveOrder } from './emit-portable.ts';
13+
import { portableIR, buildLexDispatchPlan, lexTokFirstBytes, punctFirstBytes, buildLexIdPlan, buildLidPrefilter, buildArenaIdPlan, lidOf, kidOf, lidFlagTable, kidFlagTable, ttIdOf, ruleIdOf, TT_SKIP_PUNCT, rangesHaveNonAscii, isFirstGuardable, groupByPreserveOrder } from './emit-portable.ts';
1414
import type { Target } from './emit.ts';
1515
import type { TokenPattern, CstGrammar } from './types.ts';
1616

@@ -44,17 +44,31 @@ const isGuardable = (f: FirstSig, nAlts?: number): f is NonNullable<FirstSig> =>
4444
function renderIdTablesGo(ids: LexIdPlan): string {
4545
const kidsLit = ids.kids.map(J).join(', ');
4646
const lidsLit = ids.lids.map(J).join(', ');
47+
const pf = buildLidPrefilter(ids);
48+
const bitsLit = [...pf.firstByLenBits].join(', ');
4749
return `var KIND_STR = []string{${kidsLit}}
4850
var _lids = []string{${lidsLit}}
4951
var _kidMap = map[string]uint16{}
5052
var _lidMap = map[string]uint16{}
53+
const _lidMaxLen = ${pf.maxByteLen}
54+
var _lidFirstBits = [...]byte{${bitsLit}}
5155
5256
func init() {
5357
\tfor i, k := range KIND_STR { _kidMap[k] = uint16(i) }
5458
\tfor i, t := range _lids { _lidMap[t] = uint16(i) }
5559
}
5660
func kidOf(kind string) uint16 { if v, ok := _kidMap[kind]; ok { return v }; return 0 }
57-
func lidOf(text string) uint16 { if v, ok := _lidMap[text]; ok { return v }; return 0 }
61+
func lidOf(text string) uint16 {
62+
\tn := len(text)
63+
\tif n == 0 || n > _lidMaxLen { return 0 }
64+
\tb0 := text[0]
65+
\t// Ident/@-keyword shape: O(1) length×first-byte miss ⇒ lid 0. Punct first bytes skip the probe.
66+
\tif (b0 >= 'A' && b0 <= 'Z') || (b0 >= 'a' && b0 <= 'z') || b0 == '_' || b0 == '$' || b0 == '@' {
67+
\t\tif _lidFirstBits[n*32+(int(b0)>>3)]&(1<<(b0&7)) == 0 { return 0 }
68+
\t}
69+
\tif v, ok := _lidMap[text]; ok { return v }
70+
\treturn 0
71+
}
5872
func tokKind(t *Tok) string { return KIND_STR[t.Kid] }
5973
func tokText(src string, t *Tok) string { return src[t.Off:t.End] }
6074
func mkTok(off, end int, nl bool, kid, lid uint16) Tok { return Tok{Off: uint32(off), End: uint32(end), Kid: kid, Lid: lid, Nl: nl} }

src/target-rust.ts

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
// combinators take non-capturing `fn(&mut Parser) -> bool` pointers (the kids vec is now on
1313
// the Parser as `scratch`, so the second param the old owned-tree version threaded is gone).
1414
import { type ParserIR, type RdRule, type PrattRule, type Step, type Bracket, type CharRange, type LexTok, type TplCfg, type NewlineCfg, type FirstSig, type LexFirstBytes, type LexIdPlan, type ArenaIdPlan } from './emit-portable.ts';
15-
import { portableIR, buildLexDispatchPlan, lexTokFirstBytes, punctFirstBytes, buildLexIdPlan, buildArenaIdPlan, lidOf, kidOf, lidFlagTable, kidFlagTable, ttIdOf, ruleIdOf, TT_SKIP_PUNCT, rangesHaveNonAscii, isFirstGuardable, groupByPreserveOrder } from './emit-portable.ts';
15+
import { portableIR, buildLexDispatchPlan, lexTokFirstBytes, punctFirstBytes, buildLexIdPlan, buildLidPrefilter, buildArenaIdPlan, lidOf, kidOf, lidFlagTable, kidFlagTable, ttIdOf, ruleIdOf, TT_SKIP_PUNCT, rangesHaveNonAscii, isFirstGuardable, groupByPreserveOrder } from './emit-portable.ts';
16+
import { isKeywordLiteral } from './grammar-utils.ts';
1617
import type { Target } from './emit.ts';
1718
import type { TokenPattern, CstGrammar } from './types.ts';
1819

@@ -48,28 +49,59 @@ function renderIdTablesRust(ids: LexIdPlan): string {
4849
const kidsLit = ids.kids.map(J).join(', ');
4950
const lidsLit = ids.lids.map(J).join(', ');
5051
const kidArms = ids.kids.map((k, i) => `${J(k)} => ${i}`).join(', ');
51-
// Group lids[1..] by UTF-8 byte length for a two-level match (len → text). Rust `str::len` is bytes.
52-
const byLen = new Map<number, { text: string; id: number }[]>();
52+
// Group lids[1..] by UTF-8 byte length; split keyword-shaped vs punct for separate match tables.
53+
const byLenKw = new Map<number, { text: string; id: number }[]>();
54+
const byLenPu = new Map<number, { text: string; id: number }[]>();
5355
for (let i = 1; i < ids.lids.length; i++) {
54-
const text = ids.lids[i];
56+
const text = ids.lids[i]!;
5557
const blen = Buffer.byteLength(text);
56-
const arr = byLen.get(blen) ?? [];
57-
arr.push({ text, id: i });
58-
byLen.set(blen, arr);
58+
const ent = { text, id: i };
59+
const map = isKeywordLiteral(text) ? byLenKw : byLenPu;
60+
const arr = map.get(blen) ?? [];
61+
arr.push(ent);
62+
map.set(blen, arr);
5963
}
60-
const lenArms = [...byLen.entries()].sort((a, b) => a[0] - b[0]).map(([len, ents]) => {
61-
const arms = ents.map((e) => `${J(e.text)} => ${e.id}`).join(', ');
62-
return ` ${len} => match text { ${arms}, _ => 0 },`;
63-
}).join('\n');
64+
const lenArmsOf = (byLen: Map<number, { text: string; id: number }[]>) =>
65+
[...byLen.entries()].sort((a, b) => a[0] - b[0]).map(([len, ents]) => {
66+
const arms = ents.map((e) => `${J(e.text)} => ${e.id}`).join(', ');
67+
return ` ${len} => match text { ${arms}, _ => 0 },`;
68+
}).join('\n');
69+
const kwArms = lenArmsOf(byLenKw);
70+
const puArms = lenArmsOf(byLenPu);
71+
const pf = buildLidPrefilter(ids);
72+
const bitsLit = [...pf.firstByLenBits].join(', ');
6473
return `const KIND_STR: &[&str] = &[${kidsLit}];
6574
const _LIDS: &[&str] = &[${lidsLit}];
75+
const _LID_MAX_LEN: usize = ${pf.maxByteLen};
76+
const _LID_FIRST_BITS: &[u8] = &[${bitsLit}];
6677
#[inline(always)] fn tok_kind(t: &Tok) -> &'static str { KIND_STR[t.kid as usize] }
6778
#[inline(always)] fn tok_text<'a>(src: &'a str, t: &Tok) -> &'a str { &src[t.off as usize..t.end as usize] }
6879
#[inline(always)] fn mk_tok(off: usize, end: usize, nl: bool, kid: u16, lid: u16) -> Tok { Tok { off: off as u32, end: end as u32, kid, lid, nl } }
6980
fn kid_of(kind: &str) -> u16 { match kind { ${kidArms}, _ => 0 } }
81+
/// Ident/@-keyword: O(1) length×first-byte prefilter, then keyword-only match (no punct arms).
82+
#[inline(always)]
7083
fn lid_of(text: &str) -> u16 {
71-
match text.len() {
72-
${lenArms || ' // no non-empty lids'}
84+
let n = text.len();
85+
if n == 0 || n > _LID_MAX_LEN { return 0; }
86+
let b0 = text.as_bytes()[0];
87+
if matches!(b0, b'A'..=b'Z' | b'a'..=b'z' | b'_' | b'$' | b'@') {
88+
let b0u = b0 as usize;
89+
if (_LID_FIRST_BITS[n * 32 + (b0u >> 3)] & (1u8 << (b0u & 7))) == 0 { return 0; }
90+
return lid_of_kw(text, n);
91+
}
92+
lid_of_punct(text, n)
93+
}
94+
#[inline(never)]
95+
fn lid_of_kw(text: &str, n: usize) -> u16 {
96+
match n {
97+
${kwArms || ' // no keyword lids'}
98+
_ => 0,
99+
}
100+
}
101+
#[inline(never)]
102+
fn lid_of_punct(text: &str, n: usize) -> u16 {
103+
match n {
104+
${puArms || ' // no punct lids'}
73105
_ => 0,
74106
}
75107
}

src/target-ts.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// is checked byte-for-byte against the interpreter (createParser), so a divergence in the
66
// portable logic surfaces here before Go/Rust are compiled.
77
import { type ParserIR, type RdRule, type PrattRule, type Step, type Bracket, type CharRange, type LexTok, type TplCfg, type NewlineCfg, type FirstSig, type LexFirstBytes, type LexIdPlan } from './emit-portable.ts';
8-
import { portableIR, buildLexDispatchPlan, lexTokFirstBytes, punctFirstBytes, buildLexIdPlan, lidOf, kidOf, lidFlagTable, kidFlagTable, rangesHaveNonAscii, isFirstGuardable, groupByPreserveOrder } from './emit-portable.ts';
8+
import { portableIR, buildLexDispatchPlan, lexTokFirstBytes, punctFirstBytes, buildLexIdPlan, buildLidPrefilter, lidOf, kidOf, lidFlagTable, kidFlagTable, rangesHaveNonAscii, isFirstGuardable, groupByPreserveOrder } from './emit-portable.ts';
99
import type { Target } from './emit.ts';
1010
import type { CstGrammar } from './types.ts';
1111

@@ -38,12 +38,25 @@ const isGuardable = (f: FirstSig, nAlts?: number): f is NonNullable<FirstSig> =>
3838

3939
/** Emit kid/lid lookup tables into generated lexer source. */
4040
function renderIdTablesTS(ids: LexIdPlan): string {
41+
const pf = buildLidPrefilter(ids);
42+
const bitsLit = [...pf.firstByLenBits].join(', ');
4143
return `const KIND_STR: string[] = ${J(ids.kids)};
4244
const _LIDS: string[] = ${J(ids.lids)};
4345
const _KID_MAP = new Map<string, number>(KIND_STR.map((k, i) => [k, i]));
4446
const _LID_MAP = new Map<string, number>(_LIDS.map((t, i) => [t, i]));
47+
const _LID_MAX_LEN = ${pf.maxByteLen};
48+
const _LID_FIRST_BITS = new Uint8Array([${bitsLit}]);
4549
function kid_of(kind: string): number { return _KID_MAP.get(kind) ?? 0; }
46-
function lid_of(text: string): number { return _LID_MAP.get(text) ?? 0; }
50+
function lid_of(text: string): number {
51+
const n = text.length;
52+
if (n === 0 || n > _LID_MAX_LEN) return 0;
53+
const b0 = text.charCodeAt(0);
54+
// Ident/@-keyword shape: O(1) length×first-byte miss ⇒ lid 0. Punct first bytes skip the probe.
55+
if ((b0 >= 65 && b0 <= 90) || (b0 >= 97 && b0 <= 122) || b0 === 95 || b0 === 36 || b0 === 64) {
56+
if ((_LID_FIRST_BITS[n * 32 + (b0 >> 3)]! & (1 << (b0 & 7))) === 0) return 0;
57+
}
58+
return _LID_MAP.get(text) ?? 0;
59+
}
4760
function tok_kind(t: Tok): string { return KIND_STR[t.kid]!; }
4861
function tok_text(src: string, t: Tok): string { return src.slice(t.off, t.end); }
4962
function mk_tok(off: number, end: number, nl: boolean, kid: number, lid: number): Tok { return { off, end, nl, kid, lid }; }

0 commit comments

Comments
 (0)