Skip to content

Optimize string comparisons to avoid substring allocations - #292

Merged
bartveneman merged 3 commits into
mainfrom
claude/kind-gates-s34sm6
Sep 19, 2026
Merged

bartveneman merged 3 commits into
mainfrom
claude/kind-gates-s34sm6

Conversation

@bartveneman

Copy link
Copy Markdown
Member

Summary

This PR optimizes string comparison operations throughout the codebase by introducing a new str_equals_range() function that performs case-insensitive equality checks directly on source ranges without allocating substrings first. This reduces memory pressure and improves performance in hot code paths.

Key Changes

  • New str_equals_range() function in string-utils.ts: Performs case-insensitive equality comparison between a source string range [start, end) and a literal string without allocating a substring. Uses bitwise OR to normalize ASCII uppercase to lowercase in a single operation.

  • Selector parser optimization: Refactored parse_pseudo_class_function() and is_nth_pseudo() to use offset-based comparisons instead of substring allocation. This avoids creating temporary strings for every parenthesized pseudo-class (:not(), :is(), :nth-child(), etc.).

  • Value node parser optimization:

    • Replaced is_whitespace_inline() implementation with a direct token type check (TOKEN_WHITESPACE) instead of scanning characters, reducing O(n) to O(1).
    • Updated function name checks to use str_equals_range() for if(), url(), and src() functions.
    • Added TOKEN_WHITESPACE import for the optimized whitespace check.
  • Dimension parsing refactor: Extracted dimension_number_end() helper function to find where a dimension's numeric part ends, allowing callers to slice only the part they need without allocating both value and unit substrings when only one is needed.

  • CSS node optimization: Updated value and unit getters to use dimension_number_end() for more efficient parsing without unnecessary allocations.

  • Test coverage: Added tests for comment handling between value tokens and function arguments to ensure whitespace/comment skipping works correctly.

Implementation Details

  • The str_equals_range() function uses a length check first for early exit, then compares characters with ch |= 32 to normalize ASCII uppercase (A-Z: 65-90) to lowercase (a-z: 97-122) without branching.
  • All literal strings passed to str_equals_range() must be lowercase by contract.
  • The refactoring maintains identical semantics while reducing allocations in frequently-called code paths.

https://claude.ai/code/session_01FXs4NU4Mm1TjRXsmUw9ufe

Profiled with --prof on Tailwind's dist CSS (3.5MB, ~560k nodes) to find
real hot spots rather than guessing:

- ValueNodeParser.is_whitespace_inline() scanned every character of a
  token to check if it was all-whitespace. The tokenizer already emits
  whitespace runs as a single TOKEN_WHITESPACE token (comments never end
  up inside one - verified the tokenizer splits a whitespace run around
  a comment into two separate TOKEN_WHITESPACE tokens), so a direct
  token_type check is behaviorally identical and O(1) instead of O(n).
  This runs on every token during value/function/media-feature parsing.

- DIMENSION nodes' .value and .unit getters each called parse_dimension(),
  which allocates an object plus two substrings, to get half of what they
  needed. Split out dimension_number_end() (the split-point scan, no
  allocation) so each getter slices only its own part.

- parse_function_node() (every var()/calc()/rgb()/url()/... in a value)
  and parse-selector.ts's parenthesized-pseudo-class handling (:not(),
  :is(), :nth-child(), ...) each allocated a substring just to compare
  it against a handful of literal function/pseudo names. Added
  str_equals_range() for allocation-free offset-based comparison and
  used it in both places.

Added a regression test for comments between value tokens and function
arguments, the scenario the whitespace-check change depends on.

All 1428 existing + new tests pass; no API changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ Package Size Increase

📦 Package 📏 Base Size 📏 Source Size 📈 Size Change
@projectwallace/css-parser 45.5 kB 45.9 kB +468 B

@pkg-pr-new

pkg-pr-new Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@projectwallace/css-parser@9999804

commit: 9999804

claude and others added 2 commits September 18, 2026 18:40
is_whitespace_inline() was reduced to a single field comparison last
commit, so the wrapping method call was pure overhead on a path that
runs for every token during value parsing. Inlined all 9 call sites as
direct token_type === TOKEN_WHITESPACE checks, reusing the local
token_type/tt/t variable already captured in scope where one exists
instead of re-reading the field through this.lexer.

Also fixes a real TS narrowing conflict this surfaced: two spots read
this.lexer.token_type directly right after assigning it to a local (t)
and checking that local, which left TS unable to prove the raw field
read could still be TOKEN_WHITESPACE. Using the local everywhere sidesteps
it and matches the surrounding code's existing style.

All 1428 tests pass; no behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@bartveneman

bartveneman commented Sep 19, 2026 •

Copy link
Copy Markdown
Member Author

after:

── Table 1: Wallace CSS Parser ──────────────────────────────────────────

┌─────────┬──────────────┬───────────┬────────────────────┬─────────────────┬────────────────┬──────────────────────┐
│ (index) │ File         │ Size      │ Tokenize (ops/sec) │ Parse (ops/sec) │ Walk (ops/sec) │ Parse+Walk (ops/sec) │
├─────────┼──────────────┼───────────┼────────────────────┼─────────────────┼────────────────┼──────────────────────┤
│ 0       │ 'Nerdy.dev'  │ '111 KB'  │ '655'              │ '531'           │ '2604'         │ '444'                │
│ 1       │ 'CSS Tricks' │ '1308 KB' │ '71'               │ '47'            │ '292'          │ '40'                 │
│ 2       │ 'Bootstrap'  │ '274 KB'  │ '308'              │ '274'           │ '1390'         │ '231'                │
│ 3       │ 'Tailwind'   │ '3557 KB' │ '24'               │ '21'            │ '98'           │ '17'                 │
└─────────┴──────────────┴───────────┴────────────────────┴─────────────────┴────────────────┴──────────────────────┘

── Table 2: Parse+Walk Memory – Wallace ─────────────────────────────────

┌─────────┬──────────────┬───────────┬───────────┐
│ (index) │ File         │ Size      │ Wallace   │
├─────────┼──────────────┼───────────┼───────────┤
│ 0       │ 'Nerdy.dev'  │ '111 KB'  │ '4.1 MB'  │
│ 1       │ 'CSS Tricks' │ '1308 KB' │ '24.3 MB' │
│ 2       │ 'Bootstrap'  │ '274 KB'  │ '8.3 MB'  │
│ 3       │ 'Tailwind'   │ '3557 KB' │ '51.3 MB' │
└─────────┴──────────────┴───────────┴───────────┘

@bartveneman

Copy link
Copy Markdown
Member Author

before:

┌─────────┬──────────────┬───────────┬────────────────────┬─────────────────┬────────────────┬──────────────────────┐
│ (index) │ File         │ Size      │ Tokenize (ops/sec) │ Parse (ops/sec) │ Walk (ops/sec) │ Parse+Walk (ops/sec) │
├─────────┼──────────────┼───────────┼────────────────────┼─────────────────┼────────────────┼──────────────────────┤
│ 0       │ 'Nerdy.dev'  │ '111 KB'  │ '652'              │ '518'           │ '2630'         │ '434'                │
│ 1       │ 'CSS Tricks' │ '1308 KB' │ '71'               │ '46'            │ '295'          │ '39'                 │
│ 2       │ 'Bootstrap'  │ '274 KB'  │ '305'              │ '264'           │ '1407'         │ '226'                │
│ 3       │ 'Tailwind'   │ '3557 KB' │ '24'               │ '21'            │ '99'           │ '17'                 │
└─────────┴──────────────┴───────────┴────────────────────┴─────────────────┴────────────────┴──────────────────────┘

── Table 2: Parse+Walk Memory – Wallace ─────────────────────────────────

┌─────────┬──────────────┬───────────┬───────────┐
│ (index) │ File         │ Size      │ Wallace   │
├─────────┼──────────────┼───────────┼───────────┤
│ 0       │ 'Nerdy.dev'  │ '111 KB'  │ '4.1 MB'  │
│ 1       │ 'CSS Tricks' │ '1308 KB' │ '24.6 MB' │
│ 2       │ 'Bootstrap'  │ '274 KB'  │ '8.4 MB'  │
│ 3       │ 'Tailwind'   │ '3557 KB' │ '52.2 MB' │
└─────────┴──────────────┴───────────┴───────────┘

Copy link
Copy Markdown
Member Author

Delta on your before/after numbers:

File Tokenize Parse Parse+Walk Memory
Nerdy.dev +0.5% +2.5% +2.3% ~same
CSS Tricks ~same +2.2% +2.6% -1.2%
Bootstrap +1.0% +3.8% +2.2% -1.2%
Tailwind ~same ~same ~same -1.7%

Parse-only gains land in the 2-4% range on the three smaller/mid files, roughly flat on Tailwind (its size dwarfs the per-token savings from this PR, and single-run noise on a 3.5MB parse is bigger than the effect). Walk-only shows a consistent ~1% dip across all four files, but walk() isn't touched by this PR at all, so that's single-run measurement noise, not a regression — worth a grain of salt either way since these are one-shot runs rather than medians over several rounds.

Matches what I estimated from CPU-time profiling before opening this PR (~3-12% on parse-heavy real-world CSS, closer to flat on Tailwind).


Generated by Claude Code

@bartveneman
bartveneman merged commit fd051a4 into main Sep 19, 2026
16 checks passed
@bartveneman
bartveneman deleted the claude/kind-gates-s34sm6 branch September 19, 2026 19:37
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