Skip to content

perf(codegen): Use char codes for simple classes - #660

Merged
hildjj merged 4 commits into
peggyjs:mainfrom
scttcper:scttcper/charcode-classes
Oct 13, 2026
Merged

hildjj merged 4 commits into
peggyjs:mainfrom
scttcper:scttcper/charcode-classes

Conversation

@scttcper

@scttcper scttcper commented May 14, 2026 •

Copy link
Copy Markdown
Contributor

This optimizes generated parser code for simple character classes.

A lot of grammars have hot loops that look like this:

identifier = [a-zA-Z_$] [a-zA-Z0-9_$]*
number = [0-9]+ ("." [0-9]+)?
whitespace = [ \t\n\r]*

Peggy currently emits a regexp test for each character consumed by those classes. This PR emits direct charCodeAt comparisons when the class is simple enough to do that exactly: no i flag, no unicode mode, only single-code-unit ranges/characters, and at most 32 parts. Classes that take the new path also skip emitting their now-unused peg$rN regexp.

Anything more complicated still uses the existing regexp path. The 32 part cap is there because huge classes, like the unicode category classes in examples/javascript.pegjs, were 2-3x slower as one long chain of comparisons than as a regexp. The crossover was somewhere between 32 and 64 ranges.

As one real-world benchmark, I used Sentry’s search grammar, which is the grammar we use for parsing search bar queries: https://github.com/getsentry/sentry/blob/master/static/app/components/searchSyntax/grammar.pegjs. That grammar has a lot of key/value filters, so longer searches spend plenty of time in these character-class loops.

Input Before After Speedup
200 short search strings, cycling through common filter/query shapes 2.9267 ms 2.3065 ms 21.2%
one 2.7 KB query with 120 mixed free-text, filter, and grouped terms 0.7829 ms 0.5717 ms 27.0%
two copies of the long query joined with AND, about 5.4 KB 1.6433 ms 1.2222 ms 25.6%
the long query plus a parenthesized copy joined with OR, about 5.4 KB 1.6259 ms 1.2319 ms 24.2%

Before is current main, Node 26.11.1, median of 3 runs where each run is the median of 500 parses.

hyperfine agrees end to end, node startup included: 400 rounds of the short strings went from 1.306 s to 1.047 s (-19.8%), and 300 rounds of the long queries went from 1.309 s to 1.046 s (-20.1%).

The generated parser for that grammar is about 3% bigger (123 KB → 127 KB). For the example grammars it's +0.4% (javascript) to +3% (css).

@scttcper
scttcper marked this pull request as ready for review May 14, 2026 15:08
@scttcper

Copy link
Copy Markdown
Contributor Author

let me know if you need the benchmark or something else

@hildjj

hildjj commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Please rebase, add yourself to AUTHORS, and add to the CHANGELOG.

@hildjj

hildjj commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Also, this could use a few more tests, please. Include more complicated combinations that both should and should not use the new path.

scttcper and others added 4 commits October 12, 2026 18:42
Simple character classes were emitted as regexp tests for every accepted character. Hot parser loops like keys and identifiers pay for that over and over.

Emit direct charCodeAt comparisons for simple non-unicode, case-sensitive classes and reuse a temp char code inside each check. Keeps the regexp path for the complex cases.

Co-Authored-By: Codex GPT-5 <noreply@openai.com>
Adds codegen tests for classes that should use charCodeAt (mixed ranges,
inverted, escaped `]\^-`, control chars, surrogate ranges) and ones that
should stay on RegExp (`i`, `u`, `\p{}`, astral). Plus behavior tests
for the same shapes, EOF on inverted classes, and a grammar mixing both
paths.

Also adds the changelog entry, adds me to AUTHORS, and regenerates the
cli fixtures for the new `peg$charCode` declaration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Big classes like the unicode categories in the javascript example were
turning into one giant chain of comparisons, 2-3x slower than the
regexp on chars that miss. Classes with more than 32 parts now stay on
the regexp. The crossover was somewhere between 32 and 64 ranges.

Classes that use charCodeAt no longer emit their unused `peg$rN`
constant either. Generated size for the javascript example goes from
+10% to +0.4% vs main, xml from +20% to +2.5%.

Also fixes two type errors on class escapes and drops the empty class
fallback, empty classes never match so they never reach codegen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reran the sentry search grammar benchmark against current main, it's
21-27% faster now instead of 14-19%

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@scttcper
scttcper force-pushed the scttcper/charcode-classes branch from 89e497b to df5eeed Compare October 13, 2026 01:58
@scttcper

Copy link
Copy Markdown
Contributor Author

Rebased, added myself to AUTHORS, and added a CHANGELOG entry.

Added tests for classes that should take the new path (mixed ranges, inverted, escaped ]\^-, control chars, surrogate ranges) and ones that should stay on the regexp (i, u, \p{}, astral), plus behavior tests for the same shapes.

Also, big classes like the unicode categories in the javascript example were 2-3x slower as a comparison chain, so classes over 32 parts now stay on the regexp. Unused peg$rN regexps are no longer emitted either. Updated the description with fresh numbers against current main.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.49%. Comparing base (05c55c0) to head (df5eeed).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #660   +/-   ##
=======================================
  Coverage   99.49%   99.49%           
=======================================
  Files          34       34           
  Lines        4314     4314           
=======================================
  Hits         4292     4292           
  Misses         22       22           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@hildjj

hildjj commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

I'm going to finish looking at this in the morning. I had a quick idea though: is there some number of codepoints at which throwing them into a Set would be a win?

@hildjj

hildjj commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

LGTM. Thanks!

@hildjj
hildjj merged commit ff034bf into peggyjs:main Oct 13, 2026
10 checks passed
@hildjj

hildjj commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

Released in v5.1.1

@scttcper
scttcper deleted the scttcper/charcode-classes branch October 20, 2026 20:51
MarcelBolten added a commit to MarcelBolten/phpeggy that referenced this pull request Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants