Speed up the tokenizer and eliminate double-tokenization at parse boundaries - #279
Merged
Merged
Conversation
Profiled parsing bootstrap.css/tailwind.css: next_token_fast (21.3%) and consume_ident_or_function (20.1%) dominate runtime. Token-frequency analysis on the same files showed identifiers are the single most common non-whitespace token (~15-21%), yet the dispatch chain checked them 11th - behind CDO/CDC (`<!--`/`-->`, which never occurred in either file - a legacy token from `<style><!--...--></style>` compatibility) and the at-keyword/hash checks, both individually rarer than identifiers. Reordered the chain to check identifiers right after the comment/whitespace fast paths, and moved CDO to the very end, right before the delimiter fallback. Consolidated the three separate `ch === CHAR_HYPHEN` checks (CDC, hyphen-led identifier, hyphen-led signed number) into one block sharing a single lookahead read, instead of three independent re-reads - while preserving the exact original precedence (CDC must still win for "-->", since a bare "--" is valid identifier-start for custom properties like `--custom-color`). Two other approaches were measured and rejected before landing on this: - Sticky-regex-based identifier scanning was 35-40% *slower* than the existing manual char-loop, even accounting for real identifiers averaging ~11-12 chars (longer than assumed) - V8's JIT-compiled loop with a lookup table already beats regex .exec() overhead here. - Pre-scanning the source once for "<!--"/"-->" and gating the CDO check behind a boolean flag measured slightly *slower* than the bare comparison - branch prediction already makes an always-false check near-free, so the extra flag read is pure overhead. Net effect verified via 6 repeated full end-to-end parse benchmarks (3 before, 3 after) on both files together: ~1.5-4% faster depending on file, with bootstrap.css seeing the larger gain (tailwind.css's much higher rate of backslash-escaped identifiers, from Tailwind's `\:` variant syntax, doesn't benefit from the identifier-check reorder since escaped idents take a separate path). Full test suite (1386 tests, including dedicated CDO/CDC and custom-property tokenizer tests) passes unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017XjfDK7or6VrnJ41vBhxrK
…izer Continuing the tokenizer tuning pass: measured whether reading/writing this.pos (and this._line/this._line_offset for loops that track newlines) on every character, versus a local variable synced back once at the end, makes a difference in V8. It does - roughly 5% faster in isolation for a full linear identifier-class scan, consistent between bootstrap.css and tailwind.css. Applied to every consume_* method that showed measurable self-time in a CPU profile of parsing both files: consume_ident_or_function (20.1% self-time), consume_number (4.7%), the inline whitespace-skip in next_token_fast, and consume_whitespace. Also did the same (mechanical, same-cost-to-verify) for consume_hash and consume_at_keyword despite their low frequency in real CSS, since the pattern is safe and free. consume_string, consume_unicode_range, and consume_hex_escape were deliberately left untouched: none showed measurable self-time in the profiler even before this change (well under 0.4% combined), and their escape-sequence/newline interactions are more tangled, so there's nothing to gain versus real risk to a hand-tuned hot path. For functions with no newline tracking in their hot path (consume_number, consume_at_keyword, consume_hash), the local variable is used throughout with a single sync-back before each return. For consume_ident_or_function, the common (non-escape) branch uses the local var; the rare backslash-escape branch syncs this.pos before using the existing this.pos/this.advance()-based logic (which already handles newline tracking correctly), then resyncs the local afterward. For the whitespace-skip loops, pos/_line/_line_offset are all hoisted together and synced back once after the loop. Verified via 6 repeated full end-to-end parse benchmarks (bootstrap.css + tailwind.css together) before and after: combined with the prior dispatch-reorder commit, total measured improvement is ~5.9% (versus ~1.5-4% from the reorder alone). Full test suite (1386 tests) passes unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017XjfDK7or6VrnJ41vBhxrK
Contributor
|
| 📦 Package | 📏 Base Size | 📏 Source Size | 📈 Size Change |
|---|---|---|---|
| @projectwallace/css-parser | 39 kB | 40.6 kB | +1.6 kB |
Member
Author
|
CI is green except Audit packages, same pre-existing issue as #278: this branch doesn't touch The size-increase comment above is expected — the dispatch consolidation and local-variable hoisting add a small amount of code in exchange for the ~6% measured speedup described in the PR body. Generated by Claude Code |
…aries
The parser previously did a coarse boundary scan by fully tokenizing
(next_token/next_token_fast) up to a selector's `{` or a declaration
value's terminator, only to hand that exact span to a dedicated
sub-parser (SelectorParser, ValueNodeParser) that re-tokenizes it from
scratch to build the real AST. The coarse pass only ever needed to know
*where* the construct ends, not the individual tokens in it.
Replaced both boundary scans with raw character-level scans that skip
ordinary content via a single lookup-table check per character (same
cost profile as consume_ident_or_function's inner loop), while still
routing through consume_string/_skip_comment for correctness on quotes
and comments so escapes, nesting, and on_comment callbacks stay
byte-for-byte identical to the old tokenized scan:
- Lexer.skip_to_unquoted(target) - used by parse_selector to find the
next unquoted '{'.
- Lexer.skip_to_declaration_stop(end) - used by parse_declaration_with_lexer
to find the next unquoted ';', '{', '}', '(', ')', or '!', with paren-depth
tracking left to the caller (needed for e.g. url(data:...;...)).
- _skip_comment() extracted from next_token_fast's inline comment handling
so both the tokenizer and the new raw scans share one implementation.
Getting this right required matching several non-obvious side effects of
the old token-based scan exactly (verified by diffing serialized ASTs
between old and new implementations across handwritten edge cases and
the full text of bootstrap/tailwind's regular and minified builds - byte
identical in both):
- The old "consume ':', skip whitespace" call also tokenized the value's
first token as a side effect; the new scan needs to see that character
itself (e.g. the '(' of a leading calc(...)), so whitespace is now
skipped without tokenizing.
- A paren-depth adjustment can land exactly on the scan's `end` boundary
without the loop's "ran out of input" branch ever running, so the loop
now relies solely on explicit break/return/continue rather than a loop
condition.
- "color:}" (empty value directly followed by the block's closing brace)
is a quirk of the old pre-tokenization: the brace itself became the
"first value token" before the main loop ever ran, extending the
declaration's recorded text by one character. Replicated explicitly for
that degenerate case.
- After '!' turns out not to be followed by an identifier, the
already-peeked token must be treated as ordinary content in place,
not consumed a second time - otherwise a trailing '}' right after
a bare '!' gets swallowed instead of ending the declaration.
Measured on repeated bootstrap.css + tailwind.css parses: ~7.9% faster
than the tokenizer-only optimizations in the prior two commits (the
selector-boundary change alone measured ~3.83%; the declaration-value
scan accounts for most of the remainder).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XjfDK7or6VrnJ41vBhxrK
The double-tokenization elimination work (previous commit) and the tokenizer dispatch/hoisting commits erred toward over-explaining in comments compared to the rest of the file. Cut them down while keeping the essential why, no behavior change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017XjfDK7or6VrnJ41vBhxrK
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three commits, all driven by CPU-profiling real parses of bootstrap.css and tailwind.css
rather than guessing.
Parse+Walk throughput (ops/sec):
Tokenizer tuning (commits 1-2)
<!--/-->, a token that occurred zero times in either file; it's legacy HTML-comment compatibility) and the at-keyword/hash checks, both individually rarer than identifiers. Moved the identifier check up front and CDO to dead last. Also consolidated three separatech === CHAR_HYPHENchecks (CDC, hyphen-led identifier, hyphen-led signed number) into one block sharing a single lookahead read, while preserving exact precedence (CDC must still win for-->, since bare--is valid identifier-start for custom properties).this.pos(andthis._line/this._line_offsetwhere relevant) to local variables inside hot loops, syncing back to the instance once at the end instead of touchingthison every character. Applied to everyconsume_*method that showed measurable self-time in the profiler:consume_ident_or_function(20.1% self-time),consume_number(4.7%), the inline whitespace-skip innext_token_fast, andconsume_whitespace; alsoconsume_hash/consume_at_keywordfor consistency despite their low frequency.consume_string,consume_unicode_range, andconsume_hex_escapewere deliberately left untouched - none showed measurable self-time in the profiler (well under 0.4% combined) and their escape/newline interactions are more tangled, so there's nothing to gain versus real risk to a hand-tuned hot path.Eliminate double-tokenization at selector/declaration-value boundaries (commit 3)
The parser did a coarse boundary scan by fully tokenizing up to a selector's
{or a declaration value's terminator, only to hand that exact span to a dedicated sub-parser (SelectorParser,ValueNodeParser) that re-tokenizes it from scratch to build the real AST. The coarse pass only ever needed to know where the construct ends, not the individual tokens in it.Replaced both boundary scans with raw character-level scans (
Lexer.skip_to_unquoted,Lexer.skip_to_declaration_stop) that skip ordinary content via a single lookup-table check per character, while still routing throughconsume_string/a shared_skip_commentfor correctness on quotes and comments, so escapes, nesting, andon_commentcallbacks stay byte-for-byte identical to the old tokenized scan.Getting this right required matching several non-obvious side effects of the old token-based scan exactly (verified by diffing serialized ASTs between old and new implementations across handwritten edge cases and the full text of bootstrap/tailwind's regular and minified builds - byte identical in both) - see the commit message for the specific quirks (a pre-tokenization side effect on
:consumption, a loop-exit edge case, and an empty-value-before-}quirk).Approaches measured and rejected along the way
Uint16Array<!--and gating the CDO check behind a boolean flagVerification
-->→ CDC, not identifier--+ delimiter>) and the custom-property tokenizer test (--custom-prop→ single IDENT token).tsc --noEmitandoxlint/oxfmtall clean.https://claude.ai/code/session_017XjfDK7or6VrnJ41vBhxrK
Generated by Claude Code