Skip to content

Optimize parser by chaining nodes as siblings instead of arrays (up to 14% faster)#269

Merged
bartveneman merged 3 commits into
mainfrom
claude/css-parser-fast-paths-6qwf79
Jul 19, 2026
Merged

Optimize parser by chaining nodes as siblings instead of arrays (up to 14% faster)#269
bartveneman merged 3 commits into
mainfrom
claude/css-parser-fast-paths-6qwf79

Conversation

@bartveneman

@bartveneman bartveneman commented Jul 16, 2026

Copy link
Copy Markdown
Member

Summary

This PR refactors the CSS parser to eliminate intermediate array allocations by chaining parsed nodes as siblings directly in the arena. This optimization targets the common case where most CSS constructs (media queries, container queries, selector lists, values, declaration blocks, etc.) contain a single element, avoiding unnecessary array allocations and push operations.

Key Changes

  • Sibling chaining pattern: Replaced let nodes: number[] = [] followed by nodes.push(node) with a pattern that tracks first_node/last_node (or first_child/last_child), linking them via arena.set_next_sibling(). This avoids allocating arrays regardless of how many items end up in the chain — not just the single-element case.

  • Arena helper method: Added get_last_sibling(node_index) to walk a sibling chain and find its tail, enabling efficient appending to chains built incrementally (used when a chain being appended may itself already be a multi-node chain, e.g. a compound selector's parts).

  • Parser updates across multiple files:

    • parse-atrule-prelude.ts: Media queries, container queries, feature values, and range features now chain components as siblings; single-child append_children(x, [y]) sites replaced with set_first_child.
    • parse-value.ts: Value tokens and function/parenthesis arguments chain as siblings instead of arrays — this is the hottest path in the parser, since it runs once per declaration.
    • parse-selector.ts: Selector lists, complex selectors, and compound selector parts chain as siblings. Also removed a dead sibling-chain scan in parse_selector_list whose result was never used.
    • parse.ts: Top-level stylesheet rules, style-rule block children, and at-rule block children are now built as chains incrementally instead of number[] + append_children, with the selector/block and prelude/block pairs linked directly via set_first_child()/set_next_sibling().
    • parse-declaration.ts: Single child nodes (the declaration's VALUE/RAW node) use set_first_child() instead of append_children(decl, [node]).
  • Internal-only return type changes: Private helpers like parse_feature_value() now return number (first node index, 0 = empty) instead of number[]. No public API signatures changed — AtRulePreludeParser.parse_prelude() and the exported parse_atrule_prelude() still return number[]/AnyNode[] as before.

Implementation Details

  • The optimization maintains the same AST structure — nodes are still chained as siblings, just without the intermediate array wrapper.
  • When appending a chain to an existing chain, arena.get_last_sibling() is used to find the insertion point.
  • Empty cases are represented by 0 instead of empty arrays, simplifying null checks.
  • Comments document the rationale for each optimization, noting that single-element cases are the common path.

Benchmarks

Measured with pnpm run benchmark (tinybench, --expose-gc), comparing this branch against main (commit c428a0b):

Parse throughput (ops/sec, higher is better)

File Size Before After Change
Large 3 KB 8510 9588 +12.7%
Bootstrap 274 KB 108 117 +8.3%
Tailwind 3557 KB 7 8 +14.3%

Parse+Walk memory (lower is better)

File Size Before After Change
Large 3 KB 0.2 MB 0.1 MB -50%
Bootstrap 274 KB 12.6 MB 7.9 MB -37.3%
Tailwind 3557 KB 48.7 MB 40.3 MB -17.3%

No behavioral change — all 1365 existing tests pass unmodified, tsc --noEmit and lint are clean.

https://claude.ai/code/session_01Dr6qmDsm5A5SyccNES4ixs

Declaration values, function/parenthesis arguments, selector lists,
and at-rule prelude components are built by pushing into a `number[]`
and linking it via `append_children`, even though the common CSS shape
is a single value/selector/component. Build sibling chains
incrementally instead (tracking first/last node index) so the
single-item case allocates nothing, and replace single-child
`append_children(parent, [child])` call sites with the existing
`set_first_child`. Adds `arena.get_last_sibling` to let parsers find
the tail of an already-linked chain without an array.

Also drops a dead sibling-chain scan in parse_selector_list whose
result was never used.

On the Bootstrap benchmark this improves parse throughput ~4% and
cuts parse+walk memory ~30% (12.6MB -> 8.8MB), with no behavior
change (1365 tests still pass).
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

⚠️ Duplicate Dependencies (found: 35, threshold: 1)

📦 Package 📋 Versions
tinybench
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • vitest@4.1.9
        • tinybench@2.9.0

  • root@
    • tinybench@6.0.2

@babel/helper-validator-identifier
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • magicast@0.5.2
        • @babel/parser@7.29.3
          • @babel/types@7.29.0
            • @babel/helper-validator-identifier@7.28.5

  • root@
    • tsdown@0.22.3
      • ...
        • @babel/parser@8.0.0
          • @babel/types@8.0.0
            • @babel/helper-validator-identifier@8.0.2

js-tokens
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ast-v8-to-istanbul@1.0.0
        • js-tokens@10.0.0

  • root@
    • tailwindcss@2.2.19
      • cosmiconfig@7.1.0
        • parse-json@5.2.0
          • @babel/code-frame@7.27.1
            • js-tokens@4.0.0

@babel/parser
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • magicast@0.5.2
        • @babel/parser@7.29.3

  • root@
    • tsdown@0.22.3
      • rolldown-plugin-dts@0.26.0
        • @babel/generator@8.0.0
          • @babel/parser@8.0.0

@babel/types
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • magicast@0.5.2
        • @babel/parser@7.29.3
          • @babel/types@7.29.0

  • root@
    • tsdown@0.22.3
      • rolldown-plugin-dts@0.26.0
        • @babel/generator@8.0.0
          • @babel/parser@8.0.0
            • @babel/types@8.0.0

@babel/helper-string-parser
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • magicast@0.5.2
        • @babel/parser@7.29.3
          • @babel/types@7.29.0
            • @babel/helper-string-parser@7.27.1

  • root@
    • tsdown@0.22.3
      • ...
        • @babel/parser@8.0.0
          • @babel/types@8.0.0
            • @babel/helper-string-parser@8.0.0

@emnapi/core
3 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • rolldown@1.0.3
          • @rolldown/binding-wasm32-wasi@1.0.3
            • @emnapi/core@1.10.0

  • root@
    • knip@6.18.0
      • oxc-resolver@11.21.3
        • @oxc-resolver/binding-wasm32-wasi@11.21.3
          • @emnapi/core@1.11.0

  • root@
    • knip@6.18.0
      • oxc-parser@0.137.0
        • @oxc-parser/binding-wasm32-wasi@0.137.0
          • @emnapi/core@1.11.1

@emnapi/wasi-threads
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • @rolldown/binding-wasm32-wasi@1.0.3
          • @emnapi/core@1.10.0
            • @emnapi/wasi-threads@1.2.1

  • root@
    • knip@6.18.0
      • oxc-parser@0.137.0
        • @oxc-parser/binding-wasm32-wasi@0.137.0
          • @emnapi/core@1.11.1
            • @emnapi/wasi-threads@1.2.2

@emnapi/runtime
3 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • rolldown@1.0.3
          • @rolldown/binding-wasm32-wasi@1.0.3
            • @emnapi/runtime@1.10.0

  • root@
    • knip@6.18.0
      • oxc-resolver@11.21.3
        • @oxc-resolver/binding-wasm32-wasi@11.21.3
          • @emnapi/runtime@1.11.0

  • root@
    • knip@6.18.0
      • oxc-parser@0.137.0
        • @oxc-parser/binding-wasm32-wasi@0.137.0
          • @emnapi/runtime@1.11.1

@rolldown/binding-wasm32-wasi
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • vite@8.0.16
          • rolldown@1.0.3
            • @rolldown/binding-wasm32-wasi@1.0.3

  • root@
    • tsdown@0.22.3
      • rolldown@1.1.2
        • @rolldown/binding-wasm32-wasi@1.1.2

obug
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • obug@2.1.1

  • root@
    • tsdown@0.22.3
      • obug@2.1.3

picomatch
2 versions
  • root@
    • tailwindcss@2.2.19
      • chokidar@3.6.0
        • anymatch@3.1.3
          • picomatch@2.3.2

  • root@
    • @vitest/coverage-v8@4.1.9
      • vitest@4.1.9
        • @vitest/mocker@4.1.9
          • vite@8.0.16
            • picomatch@4.0.4

postcss-value-parser
2 versions
  • root@
    • tailwindcss@2.2.19
      • reduce-css-calc@2.1.8
        • postcss-value-parser@3.3.1

  • root@
    • tailwindcss@2.2.19
      • autoprefixer@10.4.23
        • postcss-value-parser@4.2.0

glob-parent
2 versions
  • root@
    • tailwindcss@2.2.19
      • chokidar@3.6.0
        • glob-parent@5.1.2

  • root@
    • tailwindcss@2.2.19
      • glob-parent@6.0.2

yaml
2 versions
  • root@
    • tailwindcss@2.2.19
      • cosmiconfig@7.1.0
        • yaml@1.10.3

  • root@
    • @vitest/coverage-v8@4.1.9
      • vitest@4.1.9
        • @vitest/mocker@4.1.9
          • vite@8.0.16
            • yaml@2.9.0

is-arrayish
2 versions
  • root@
    • tailwindcss@2.2.19
      • cosmiconfig@7.1.0
        • parse-json@5.2.0
          • error-ex@1.3.4
            • is-arrayish@0.2.1

  • root@
    • tailwindcss@2.2.19
      • color@4.2.3
        • color-string@1.9.1
          • simple-swizzle@0.2.4
            • is-arrayish@0.3.4

get-tsconfig
2 versions
  • root@
    • knip@6.18.0
      • get-tsconfig@4.14.0

  • root@
    • tsdown@0.22.3
      • rolldown-plugin-dts@0.26.0
        • get-tsconfig@5.0.0-beta.5

semver
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • istanbul-lib-report@3.0.1
        • make-dir@4.0.0
          • semver@7.8.0

  • root@
    • tsdown@0.22.3
      • semver@7.8.5

@oxc-project/types
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • vite@8.0.16
          • rolldown@1.0.3
            • @oxc-project/types@0.133.0

  • root@
    • knip@6.18.0
      • oxc-parser@0.137.0
        • @oxc-project/types@0.137.0

rolldown
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • vitest@4.1.9
        • @vitest/mocker@4.1.9
          • vite@8.0.16
            • rolldown@1.0.3

  • root@
    • tsdown@0.22.3
      • rolldown@1.1.2

@rolldown/binding-android-arm64
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • vite@8.0.16
          • rolldown@1.0.3
            • @rolldown/binding-android-arm64@1.0.3

  • root@
    • tsdown@0.22.3
      • rolldown@1.1.2
        • @rolldown/binding-android-arm64@1.1.2

@rolldown/binding-darwin-arm64
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • vite@8.0.16
          • rolldown@1.0.3
            • @rolldown/binding-darwin-arm64@1.0.3

  • root@
    • tsdown@0.22.3
      • rolldown@1.1.2
        • @rolldown/binding-darwin-arm64@1.1.2

@rolldown/binding-darwin-x64
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • vite@8.0.16
          • rolldown@1.0.3
            • @rolldown/binding-darwin-x64@1.0.3

  • root@
    • tsdown@0.22.3
      • rolldown@1.1.2
        • @rolldown/binding-darwin-x64@1.1.2

@rolldown/binding-freebsd-x64
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • vite@8.0.16
          • rolldown@1.0.3
            • @rolldown/binding-freebsd-x64@1.0.3

  • root@
    • tsdown@0.22.3
      • rolldown@1.1.2
        • @rolldown/binding-freebsd-x64@1.1.2

@rolldown/binding-linux-arm-gnueabihf
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • vite@8.0.16
          • rolldown@1.0.3
            • @rolldown/binding-linux-arm-gnueabihf@1.0.3

  • root@
    • tsdown@0.22.3
      • rolldown@1.1.2
        • @rolldown/binding-linux-arm-gnueabihf@1.1.2

@rolldown/binding-linux-arm64-gnu
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • vite@8.0.16
          • rolldown@1.0.3
            • @rolldown/binding-linux-arm64-gnu@1.0.3

  • root@
    • tsdown@0.22.3
      • rolldown@1.1.2
        • @rolldown/binding-linux-arm64-gnu@1.1.2

@rolldown/binding-linux-arm64-musl
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • vite@8.0.16
          • rolldown@1.0.3
            • @rolldown/binding-linux-arm64-musl@1.0.3

  • root@
    • tsdown@0.22.3
      • rolldown@1.1.2
        • @rolldown/binding-linux-arm64-musl@1.1.2

@rolldown/binding-linux-ppc64-gnu
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • vite@8.0.16
          • rolldown@1.0.3
            • @rolldown/binding-linux-ppc64-gnu@1.0.3

  • root@
    • tsdown@0.22.3
      • rolldown@1.1.2
        • @rolldown/binding-linux-ppc64-gnu@1.1.2

@rolldown/binding-linux-s390x-gnu
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • vite@8.0.16
          • rolldown@1.0.3
            • @rolldown/binding-linux-s390x-gnu@1.0.3

  • root@
    • tsdown@0.22.3
      • rolldown@1.1.2
        • @rolldown/binding-linux-s390x-gnu@1.1.2

@rolldown/binding-linux-x64-gnu
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • vite@8.0.16
          • rolldown@1.0.3
            • @rolldown/binding-linux-x64-gnu@1.0.3

  • root@
    • tsdown@0.22.3
      • rolldown@1.1.2
        • @rolldown/binding-linux-x64-gnu@1.1.2

@rolldown/binding-linux-x64-musl
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • vite@8.0.16
          • rolldown@1.0.3
            • @rolldown/binding-linux-x64-musl@1.0.3

  • root@
    • tsdown@0.22.3
      • rolldown@1.1.2
        • @rolldown/binding-linux-x64-musl@1.1.2

@rolldown/binding-openharmony-arm64
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • vite@8.0.16
          • rolldown@1.0.3
            • @rolldown/binding-openharmony-arm64@1.0.3

  • root@
    • tsdown@0.22.3
      • rolldown@1.1.2
        • @rolldown/binding-openharmony-arm64@1.1.2

@rolldown/binding-win32-arm64-msvc
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • vite@8.0.16
          • rolldown@1.0.3
            • @rolldown/binding-win32-arm64-msvc@1.0.3

  • root@
    • tsdown@0.22.3
      • rolldown@1.1.2
        • @rolldown/binding-win32-arm64-msvc@1.1.2

@rolldown/binding-win32-x64-msvc
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • ...
        • vite@8.0.16
          • rolldown@1.0.3
            • @rolldown/binding-win32-x64-msvc@1.0.3

  • root@
    • tsdown@0.22.3
      • rolldown@1.1.2
        • @rolldown/binding-win32-x64-msvc@1.1.2

tinyexec
2 versions
  • root@
    • @vitest/coverage-v8@4.1.9
      • vitest@4.1.9
        • tinyexec@1.1.2

  • root@
    • tsdown@0.22.3
      • tinyexec@1.2.4

💡 To find out what depends on a specific package, run: pnpm -r why example-package

⚠️ Package Size Increase

📦 Package 📏 Base Size 📏 Source Size 📈 Size Change
@projectwallace/css-parser 41.1 kB 41.3 kB +161 B

claude and others added 2 commits July 16, 2026 18:08
Extend the incremental sibling-chain technique to the Parser class's
own loops: top-level stylesheet rules, style-rule block children, and
at-rule block children were each collected into a number[] before
being linked via append_children, even though the arena already
supports O(1) incremental linking via set_next_sibling. Build the
chain as we go instead, tracking first/last node index, so no array
is ever allocated for these loops regardless of how many
rules/declarations a block contains.

These are internal to the Parser class (no public API change).
@bartveneman bartveneman changed the title Optimize parser by chaining nodes as siblings instead of arrays Optimize parser by chaining nodes as siblings instead of arrays (up to 14% faster) Jul 19, 2026
@bartveneman
bartveneman merged commit d2f0731 into main Jul 19, 2026
8 checks passed
@bartveneman
bartveneman deleted the claude/css-parser-fast-paths-6qwf79 branch July 19, 2026 10:07
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