Performance
Parséman is fast by default — the macro build beats hand-tuned generators on the benchmarks — but grammar authoring still has one dominant lever. This page covers the technique that matters most, plus how to measure.
What "the compiler" means on this page
Except where a claim names a different path, "the compiler" here means the JS-codegen lowering — the flat generated JavaScript that compile() and the macro build produce, and the only compiled form Parséman ships. Timings on this page were measured against that lowering and the interpreter.
The one rule: fewer combinator boundaries
The single biggest grammar-level perf lever is the number of combinator boundaries on the hot path. Every sequence / regex / oneOrMore step is a function call plus a result-object allocation plus — in a node() rule — a leaf push. Fewer, fatter combinators beat many thin ones.
Collapse opaque shapes into one regex
Measured on a repeated 3-shape group (name1 1px #111 …, ~29 KB), parsing the same content three ways:
| Approach | Interpreted | Compiled |
|---|---|---|
oneOrMore(sequence(ident, sp, num, sp, hex, sp)) | 0.289 ms | 0.167 ms |
same, with inline regex(…) instead of shared refs | 0.281 ms | 0.183 ms |
one regex(/…ident…num…hex…/) per group | 0.055 ms | 0.042 ms |
Two takeaways:
For a bare terminal, shared combinator ref vs. inline
regex(…)literal makes no difference to parse speed. Both produce the identical runtime structure (oneregexcombinator either way), and the JS-codegen lowering — whatcompile()and the macro build emit today — inlines aregextest at every use site whether or not the combinator object is shared. Factor out shared terminals for readability; on the compiled path it costs no parse time.This is a statement about terminals, not about sharing in general. Codegen does hoist a multiply-referenced subtree into a named function once it is bigger than a small threshold (
HOIST_MIN_SUBTREEinsrc/compiler/codegen.ts), so for larger shapes a sharedconstand a copy-pasted literal do not emit the same code. Sharing also affects emitted size independently of parse speed — see macro code size.Collapsing a fixed multi-token shape into a single
regexis 4–5× faster in both the interpreter and compiled output, because it erases the per-step call + allocation overhead.compile()is a real but smaller win (~1.7×) and stacks with collapsing.
When to collapse
Only where the CST treats the group as opaque text — a dimension \d+px, a hex color, an nth expression, a simple ident-run. A single regex yields one leaf, not structured sub-nodes.
If the shape is easier to write as combinators but should still be one source token, wrap it in token(). token() clears internal trivia, returns the matched source text, and contributes one CST leaf inside node(). The compiler can collapse safe nullable terminal runs inside it (many, optional, sepBy over literals/regexes) to one regex. That is an optimization opportunity, not a promise that retrofitting token() onto an already tuned grammar will make it faster — benchmark the actual grammar.
When not to collapse
Keep the parts as separate combinators wherever the builder needs them as distinct CST children:
- for named values/spans consumed by a builder (
field(name, parser)), - for trivia recovered between the parts,
- for distinct typed nodes.
Correctness first; collapse only the genuinely opaque runs.
Not to be confused with node unwrap or CST wrapper collapse
This is a performance technique — folding an opaque source token into one matcher. It is separate from node(…, { unwrap: true }), which changes AST/value shape, from node(..., { collapse: true }), which changes one grammar wrapper's CST-like shape, and from cstBuildHost({ collapse }), which changes public CST shape. See CST / AST nodes.
compile() stacks on top
Collapsing reduces the number of combinators; compile() (or the macro build) makes each remaining combinator cheaper by emitting flat JS. The two compound — a collapsed grammar compiled is the fastest configuration. Use the macro build for production so you pay the compile cost once, at build time.
Shared broad openers: prefer dispatch
Keep using choice for literal alternatives and branches with disjoint first sets. The compiler already turns those into cheap first-char dispatch, longest-literal checks, greedy classification, or shared-prefix code where that is the better shape.
When several branches first recognize the same broad token and only then differ by that token's value, a plain choice can be correct but still do repeated opener checks. CSS at-rules are the easy example: exact arms for @media, @supports, @property, plus a generic @anything; fallback all begin with @.
Use dispatch for that shape: parse the at-keyword once, route exact names with when(...), and keep the generic continuation in otherwise(...). The grammar says what is happening and the compiled parser avoids rechecking the shared opener for late/generic arms.
This is also the scannerless story in miniature. Parséman does not need a separate lexer to freeze every token kind before the grammar sees it, and it still keeps token-style routing where it matters: parse the meaningful shared prefix once, then choose the continuation by the returned value or the next structural marker. CSS function values, SCSS/Jess @supports/@media overlaps with interpolation and dialect-specific routes, and same-opener node arms such as identifier-or-function values all fit this shape. A sibling choice(...) may be correct, but dispatch(...) expresses the route the language actually takes and lets the selected branch own the routed value with routed().
pnpm bench:dispatch keeps small proof fixtures for this recommendation. It includes the same-opener at-rule case, broad identifier/function node arms where both the function arm and keyword arm begin by parsing an identifier, and a matches(...) route. These are intentionally not literal-vs-literal comparisons, because that is a good choice(...) case.
The benchmark prints current medians for each workload. Expect the broad-opener advantage to shrink as nearly every item takes the same specialized route; the main win is avoiding repeated broad opener parsing and fallback backtracking. Keep choice(...) for literal or first-set-disjoint arms, closed sets with no generic broad fallback, and cases where the first arm dominates and the rejected tails are cheap.
matches(...) dispatch arms are included as generated-code coverage for matcher routing. They are tracked, not treated as a speed gate: the regex predicate itself is real work, so small wins can fall inside run-to-run noise.
That is a directional benchmark artifact, not a hard release gate: absolute timings move with the machine, and the normal suite only asserts that the two grammars are equivalent and exercise the intended diagnostic paths. Opt into the timing check with:
PARSEMAN_PERF=1 pnpm vitest run --config vitest.perf.config.ts test/perf/dispatch-vs-choice.test.tsMeasuring
pnpm bench # parser-to-parser comparison
pnpm bench:parseman # Parseman interpreted vs compiled regression report
pnpm bench:svg # chart-only benchmarks + regenerate assets/bench-*.svg
pnpm bench:baseline # refresh the regression baseline + append a history snapshot
pnpm bench:release-compare-svg # regenerate committed 0.26/0.27/0.28 release evidence SVGs
pnpm bench:compile-grammars # regenerate the precompiled Peggy/Nearley/Jison parsers
pnpm bench:dispatch # dispatch vs equivalent shared-opener choice A/B
pnpm perf:guard # fast pre-commit CSS speed regression check
node --import tsx bench/compose-dispatch.ts # composed-grammar first-char dispatch A/BSee Benchmarks → Refreshing the charts for when to use bench:svg vs the full bench suite.
Composed grammars
The cross-parser charts measure single grammars compiled whole. A grammar built by compose([...]) gets first-char dispatch across artifacts too (see macro mode) — bench/compose-dispatch.ts isolates that: a CSS-value-shaped composed grammar whose value is a choice over many cross-rule ref arms. With fuse-time dispatch the compiled parser skips arms whose first char can't match instead of trying each per token. Check it out across a change to A/B it — the win scales with arm count and how many choice rules a grammar has (a real stylesheet grammar, with a 15-arm value rule plus many selector choices, sees appreciably more than one 6-arm choice in isolation).
The benchmark reports each grammar's median µs/op interpreted and compiled, with a delta against the committed baseline — so a regression shows up immediately. See Benchmarks for the full parser comparison charts (JSON, CSV, GraphQL).
Library-level ideas
The lever above is what grammar authors control. Below the grammar, the compiler also lowers many regex(…) terminals into charCodeAt scan loops — see Under the hood: regex lowering for what gets lowered, into what, and how it's kept correct and fast.
Node capture is arity-driven: a direct AST build that doesn't declare children, rawChildren, triviaLog, or state pays nothing to collect them; an injected ctx.build host keeps the complete CST contract. This is often a large slice of parse time on value-dense grammars. See Capture follows your build's arity.
For the full catalog of library-level codegen and macro optimizations (choice fast-paths, trivia loop specialization, transform/build inlining, and more), see notes/PERF_IDEAS.md in the repo.
