Optimize parser by chaining nodes as siblings instead of arrays (up to 14% faster)#269
Merged
Merged
Conversation
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).
Contributor
|
| 📦 Package | 📋 Versions |
|---|---|
| tinybench | 2 versions
|
| @babel/helper-validator-identifier | 2 versions
|
| js-tokens | 2 versions
|
| @babel/parser | 2 versions
|
| @babel/types | 2 versions
|
| @babel/helper-string-parser | 2 versions
|
| @emnapi/core | 3 versions
|
| @emnapi/wasi-threads | 2 versions
|
| @emnapi/runtime | 3 versions
|
| @rolldown/binding-wasm32-wasi | 2 versions
|
| obug | 2 versions
|
| picomatch | 2 versions
|
| postcss-value-parser | 2 versions
|
| glob-parent | 2 versions
|
| yaml | 2 versions
|
| is-arrayish | 2 versions
|
| get-tsconfig | 2 versions
|
| semver | 2 versions
|
| @oxc-project/types | 2 versions
|
| rolldown | 2 versions
|
| @rolldown/binding-android-arm64 | 2 versions
|
| @rolldown/binding-darwin-arm64 | 2 versions
|
| @rolldown/binding-darwin-x64 | 2 versions
|
| @rolldown/binding-freebsd-x64 | 2 versions
|
| @rolldown/binding-linux-arm-gnueabihf | 2 versions
|
| @rolldown/binding-linux-arm64-gnu | 2 versions
|
| @rolldown/binding-linux-arm64-musl | 2 versions
|
| @rolldown/binding-linux-ppc64-gnu | 2 versions
|
| @rolldown/binding-linux-s390x-gnu | 2 versions
|
| @rolldown/binding-linux-x64-gnu | 2 versions
|
| @rolldown/binding-linux-x64-musl | 2 versions
|
| @rolldown/binding-openharmony-arm64 | 2 versions
|
| @rolldown/binding-win32-arm64-msvc | 2 versions
|
| @rolldown/binding-win32-x64-msvc | 2 versions
|
| tinyexec | 2 versions
|
💡 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 |
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).
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
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 bynodes.push(node)with a pattern that tracksfirst_node/last_node(orfirst_child/last_child), linking them viaarena.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-childappend_children(x, [y])sites replaced withset_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 inparse_selector_listwhose 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 ofnumber[]+append_children, with the selector/block and prelude/block pairs linked directly viaset_first_child()/set_next_sibling().parse-declaration.ts: Single child nodes (the declaration's VALUE/RAW node) useset_first_child()instead ofappend_children(decl, [node]).Internal-only return type changes: Private helpers like
parse_feature_value()now returnnumber(first node index,0= empty) instead ofnumber[]. No public API signatures changed —AtRulePreludeParser.parse_prelude()and the exportedparse_atrule_prelude()still returnnumber[]/AnyNode[]as before.Implementation Details
arena.get_last_sibling()is used to find the insertion point.0instead of empty arrays, simplifying null checks.Benchmarks
Measured with
pnpm run benchmark(tinybench,--expose-gc), comparing this branch againstmain(commitc428a0b):Parse throughput (ops/sec, higher is better)
Parse+Walk memory (lower is better)
No behavioral change — all 1365 existing tests pass unmodified,
tsc --noEmitand lint are clean.https://claude.ai/code/session_01Dr6qmDsm5A5SyccNES4ixs