Skip to content

Speed up the tokenizer and eliminate double-tokenization at parse boundaries - #279

Merged
bartveneman merged 4 commits into
mainfrom
claude/code-review-refactoring-dw1sip
Jul 29, 2026
Merged

Speed up the tokenizer and eliminate double-tokenization at parse boundaries#279
bartveneman merged 4 commits into
mainfrom
claude/code-review-refactoring-dw1sip

Conversation

@bartveneman

@bartveneman bartveneman commented Jul 29, 2026

Copy link
Copy Markdown
Member

Summary

Three commits, all driven by CPU-profiling real parses of bootstrap.css and tailwind.css
rather than guessing.

Parse+Walk throughput (ops/sec):

File Before After Change
Large 16058 16565 +3.2%
Bootstrap 210 225 +7.1%
Tailwind 16 17 +6.3%

Tokenizer tuning (commits 1-2)

  • Reorder the dispatch chain by measured token frequency. Identifiers are the single most common non-whitespace token (~15-21% of all tokens across both files), but the chain checked them 11th - behind CDO/CDC (<!--/-->, 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 separate ch === CHAR_HYPHEN checks (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).
  • Hoist this.pos (and this._line/this._line_offset where relevant) to local variables inside hot loops, syncing back to the instance once at the end instead of touching this on every character. Applied to every consume_* 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 in next_token_fast, and consume_whitespace; also consume_hash/consume_at_keyword for consistency despite their low frequency.

consume_string, consume_unicode_range, and consume_hex_escape were 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 through consume_string/a shared _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.

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

Idea Result
Sticky-regex identifier scanning 35-40% slower than the manual char-loop, even accounting for real identifiers averaging ~11-12 chars
Pre-converting the source to a Uint16Array No net win - the conversion pass costs as much as the savings from several scans
Pre-scanning for <!-- and gating the CDO check behind a boolean flag Slightly slower - branch prediction already makes an always-false check nearly free

Verification

  • Full test suite (1386 tests) passes unchanged, including the dedicated CDO/CDC precedence test (--> → CDC, not identifier -- + delimiter >) and the custom-property tokenizer test (--custom-prop → single IDENT token).
  • tsc --noEmit and oxlint/oxfmt all clean.
  • Commit 3 additionally verified via full-file AST comparison (every node's type/start/length/is_important) across bootstrap.css, bootstrap.min.css, tailwind.css, and tailwind.min.css - byte-identical output between old and new implementations.
  • Measured via repeated full end-to-end parse benchmarks (bootstrap.css + tailwind.css together): commits 1-2 measured ~5.9% faster combined; commit 3 measured ~7.9% faster than commits 1-2's baseline (the selector-boundary change alone was ~3.83% of that; the declaration-value scan accounts for most of the remainder).

https://claude.ai/code/session_017XjfDK7or6VrnJ41vBhxrK


Generated by Claude Code

claude added 2 commits July 29, 2026 09:39
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
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

⚠️ Package Size Increase

📦 Package 📏 Base Size 📏 Source Size 📈 Size Change
@projectwallace/css-parser 39 kB 40.6 kB +1.6 kB

Copy link
Copy Markdown
Member Author

CI is green except Audit packages, same pre-existing issue as #278: this branch doesn't touch package.json/pnpm-lock.yaml, and the two high findings are the transitive brace-expansion DoS advisory via tailwindcss > purgecss > glob > minimatch, already tracked separately in #258 (still open). Not fixing it here to keep this PR scoped to the tokenizer changes.

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
@bartveneman bartveneman changed the title Speed up the tokenizer: reorder dispatch by frequency, hoist hot-loop locals Speed up the tokenizer and eliminate double-tokenization at parse boundaries Jul 29, 2026
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
@bartveneman
bartveneman merged commit 2692cf2 into main Jul 29, 2026
7 of 8 checks passed
@bartveneman
bartveneman deleted the claude/code-review-refactoring-dw1sip branch July 29, 2026 18:44
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.

2 participants