Skip to content

Commit ce12f4e

Browse files
perf(css): recycle nested block contents during the walk (#21628)
* perf(css): stream and recycle a block's children The walk already streamed top-level nodes, but everything inside one block was held until that whole subtree finished, so a stylesheet wrapped in a single `@media` kept its entire AST. Enter a rule as soon as its prelude is sealed at `{`, walk and recycle each child as it completes, and exit at `}` where the rule's end is final — peak storage becomes the open path. Off by default (`streamBlocks`) and walk-only. A streamed rule's children reach the visitors instead of its body, so `A.declarations` / `A.childRules` read empty for it; `CssParser` reads both and cannot opt in until those sites accumulate during the walk instead. Peak column storage over 20k rules: `@media`-wrapped 8.05 MiB -> 0, the same for `@supports` and nested at-rules, CSS nesting 4.39 MiB -> 0. Shapes with nothing to reclaim pay for the bookkeeping (flat +9%, many small blocks +17%) — the lazy activation threshold that fixes it is not implemented yet. Differential against the default walk over 20 shapes: no ordering or count differences, and the whole delta is the provisional `end` a rule reports at `enter` before its block closes. * perf(css): carry the streamed block frame in locals, not stacks `_streamConsumeBlock` recurses through the block consumer, so its own locals already are the stack — the four parallel arrays it pushed and popped per rule were eight array operations, with their bounds and capacity checks, on every rule. It cost 5.2% self time in a CPU profile of a flat stylesheet, where streaming reclaims nothing and the work is pure overhead. Save the frame into locals across the recursive call instead. Interleaved A/B, min of 35 samples per side, 20k rules: flat +9.3% -> +1.8%, many small blocks +17.2% -> +10.6%, one big `@media` -23.9% -> -2.7%, `@supports` +3.0% -> -1.7%, nested at-rules -5.2% -> -3.3%, CSS nesting +0.8% -> -0.6%. Memory is unchanged: every shape that held a subtree still recycles to zero. The remaining cost on small blocks is the streaming machinery running where there is nothing to reclaim, which the lazy activation threshold addresses. * perf(css): stream a nested block only once it is worth recycling The streamed walk ran on every block, so a small one paid the machinery for nothing: +9.3% on a flat stylesheet, +17.2% on many small `@media`. Enter a rule and start recycling only once its block passes `_STREAM_MIN_NODES`. `_nodeCount` restarts at each top-level node, so `_nodeCount - mark` is exactly how far the current block has grown; under the threshold the sink collects like the non-streaming path and the rule is handed back materialized for the ordinary walk, keeping `A.declarations` / `A.childRules` readable on it. Activation has to enter every still-buffering ancestor first, outermost in, or a nested block enters before its parent — `@media{@supports{...}}` fired the inner rule's `enter` first. Ancestors are unreachable from locals, so the per-block frame moved into depth-indexed tables: nesting is shallow and strictly LIFO, so an indexed store avoids the bounds and capacity checks that push/pop cost on every rule. Memory is now released on every shape that used to hold a subtree: one big `@media`, `@supports`, nested at-rules and CSS nesting all go from 8.05 / 4.39 MiB to 0. CPU is +1.8% to +7.7% — the sink still costs an indirect call per child before activation, which the next commit removes by checking the threshold inside the block consumer instead. * perf(css): make a streamed block cost nothing until it is one A block wrote its frame and pushed every child through the sink from the moment it opened, so a stylesheet with no big block paid for machinery it never used. Keep the block's state in `consumeABlocksContentsInto`'s locals and write the frame only where an ancestor could still be reached for -- just before a child rule, and on the block's own growth past the threshold. A block of nothing but declarations, which is every leaf rule, now touches no frame at all, and the growth check is one compare in the collect loop rather than a call and a type test per child. Two fixes fall out of entering a rule before its block is consumed: - Sibling indices were wrong on every streamed block: the parent's counter was bumped when a child block opened and then reused as the walk counter, so the first child of a large `@media` reported index 456. Each list now counts separately, as `_walkRule` does, and the index a child carries is the one the collected walk gives it. - `A.declarations` / `A.childRules` returned `null` on a streamed rule -- indistinguishable from an at-rule with no block, which is what `CssParser` reads to decide. The body slot is claimed, empty, before `enter` fires; claiming it up front also lets the slots be handed back per child, which is the last thing a streamed block still grew. Threshold raised to 16384 nodes: a block that crosses it just before its own `}` activates for almost nothing back. Measured against main, 20k rules per shape, each arm in its own process, median of 13: @media -9.6%, @supports -8.0%, @media>@supports -11.4%, CSS nesting -7.5%, flat +0.2%, many small @media +1.0%. Retained heap while the block is open, forced GC: 31.2 MiB of node columns and 3.9 MiB of JS heap down to 0 and 0.16 MiB. The default path is unchanged. * perf(css): print a streamed block in pieces instead of assembling it `streamBlocks` was walk-only, because the printer builds a parent's text out of its children's -- exactly what a streamed body has already released. So printing held the whole subtree: 60 MiB live while a 20k-rule `@media` was open, for 3.2 MiB of output. A streamed rule now prints as it goes: its `prelude{` is held back when the block opens, each finished child is emitted straight after it, and the `}` closes it. Held rather than emitted, because an opener that turns out to have nothing inside it still has to be droppable at its `}` -- a whole stack of them, since the block that empties may be several levels down. Two transforms genuinely need a whole block at once, and neither is given up: - A longhand family only merges in a block with no child rule, so a block of declarations alone is the one that declines to stream. - Only the last of a set of identical declarations can be read. There is no looking ahead for the later one, so the earlier one is taken back out of the output instead: `PrintContext` keeps the output as the pieces it was emitted in, and a piece can be retracted after later ones are out. Positions follow from that -- a piece's place in the output is no longer known when it is emitted, so mappings anchor to a piece and are resolved against the pieces once, when a map is asked for. Printing without one now never scans the text for newlines at all. Output is byte for byte the collected printer's, source map included, over 23 shapes covering empty blocks, duplicate declarations across a child rule, kept comments, raw hacks, custom properties and `@keyframes`. Minifying 20k rules, each arm in its own process, median of 11: `@media` -36.8%, `@media>@supports` -38.1%, CSS nesting -36.9%, flat +0.4%, many small `@media` +2.0%. Retained heap while the block is open: 60.4 -> 7.7 MiB on `@media`, 16.3 -> 3.8 MiB on nesting, against 3.2 and 0.7 MiB of output. The collected path is unchanged, on both CSS and HTML. * perf(css): keep the output flat as it is built, and cut pieces only where needed Two regressions on stylesheets that never stream, both from how the output is now held. Every emit became a piece of its own, so a flat stylesheet -- the common one -- built an array of 20k strings where it used to append to one. Output accumulates into a plain string again, and a piece is cut off only for text that may still be taken back, which is the block's own declarations and nothing else. Mappings carry an offset within their piece, so an anchor can sit mid-string. The other was subtler and was costing 3.8 MiB. A printer returns its text as a rope of its children's pieces, and the old `_emit` happened to flatten each one while scanning it for newlines -- a scan this branch had removed as useless, since positions are now worked out only if a map is asked for. Without it, ropes append onto ropes and every fragment of every node stays reachable until the output is finally flattened: a second copy of the whole output. Flattening each piece as it lands fixes the memory and costs 40% CPU, an allocation per piece. So the tail is cut off and forced flat once per 64 KiB instead, which the result would have copied anyway. Measured against main, each arm in its own process, arms alternated: minify, retained heap while the block is open, 20k rules @media 60.4 -> 3.4 MiB flat 5.9 -> 5.0 MiB nesting 16.3 -> 1.2 MiB small @media 3.9 -> 3.9 MiB minify CPU, 10k rules, median of 9 @media -35.3% @supports -31.9% nesting -29.2% flat +2.2% small @media +3.3% (both inside the +/-5% this box resolves) Output and source map stay byte for byte main's, streamed or not. * perf(css): stream every walk, rather than on request `streamBlocks` was an opt-in because streaming used to be walk-only, and a caller that printed had to decline it. Printing streams too now, so there is nothing left for the option to trade off: a block under the threshold is still collected and walked in one batch exactly as it always was, and one past it streams. So the walk always streams and the option is gone. It stays a flag internally, because the standalone `parseA*` entry points hand back a materialized tree and must not stream; `_setupParse` leaves it off for them and only `grammar` turns it on. What a streamed rule reports about its own block is now the walk's ordinary behaviour rather than something a caller asked for, so `A.declarations` and `A.childRules` say so where they are defined: they read as an empty block on a streamed rule, and `null` still means no block at all. The tests can no longer print a stylesheet both ways to compare, so they assert the output itself, over fixtures already in minimal form. Every case pins whether it is exercising the streamed or the collected path, so none of them can quietly stop testing streaming if the threshold moves. * perf(css): stop rescanning a piece for newlines once per mapping Generated positions are worked out when a map is asked for rather than as the output is built, by walking the output alongside the mappings. But the walk asked `indexOf` for the next newline once per mapping, and `indexOf` has no end bound: with no newline ahead it scans to the end of the piece and returns -1, every time. Minified output has no newlines at all, so every one of a piece's mappings scanned the whole piece -- quadratic in the mappings a piece holds, which is one per top-level rule. Carry the next newline's offset across mappings instead, and look for another only after one is passed. A piece with no newlines is scanned once. Minifying 10k rules, each arm in its own process, median of 9: flat +2.7% -> -2.9%, many small `@media` +3.6% -> -5.8%. * ci(bun): put the worker idle-memory limit above what the suite uses `runtimes (bun)` segfaults at a different wild address each run, on this branch and on main alike. Reproduced locally with Bun 1.3.14, the CI patches and the CI command, and it is the idle-memory restart: with the limit below what the suite uses, workers restart constantly and Bun crashes tearing one down. limit vs the suite's heaps result 16MB below segfault, spawned(61) terminated(57) 64MB below segfault, spawned(20) terminated(16) 512MB above 103 suites, 28993 tests, green 2048MB above 103 suites, 28993 tests, green The last green CI run had no limit at all and finished all 159 suites with worker heaps reaching 1337 MB. Restoring the limit set it to the 512 MB the other runtimes use — under half of that — so it fires on workers doing ordinary work, and the run dies about a third of the way in. Raise it to 2048 MB: above what the suite needs, still a bound on a worker that runs away. Node and Deno keep 512 MB, where a spurious restart only costs time. Ending a worker rather than terminating it, as the patch already does, buys Bun a restart it would not otherwise survive — a small run took five and passed — but not a run full of them. Both teardown paths crash in bulk, so the limit is the lever, and the patch is left as it is. * fix(css): print a streamed rule's prelude with its parent bound A prelude can be read in terms of what encloses it: `from` is the `0%` a keyframe selector means, but only inside `@keyframes`. `_streamOpen` bound the node and left the parent as whatever the last walked node had, because `_streamEnterRule` binds it only when the rule has an `enter` visitor to run — which printing on its own does not. A `@keyframes` selector whose own block crossed the threshold therefore printed as `from` where the collected printer prints `0%`, and the two disagreed on the same input. Also stop cutting an empty piece off the output. Every retractable piece cuts the accumulated tail off in front of it, so a run of declarations in a streamed block cut an empty one for each after the first: 3000 consecutive declarations built 6000 pieces where 3001 carry the whole output. Both found by CodeRabbit on the pull request, and both now covered: the keyframe case, and the fallback past the depth the frame table holds, which nothing reached before. * refactor(css): make the streamed block's output bound its own The `;` a `}` makes redundant is dropped by walking the output back from the end, bounded by where the block started. That bound was taken while the block's opener was still held back, so the piece it names can hold output from before the block — the walk stops short of it today only because the opener's piece ends in `{` and is never taken back, which is an invariant nothing states. Take the bound after cutting the tail, so it names the block's own first piece and the walk cannot reach past it either way. The bound now covers a case it did not: a run of retractable pieces already cuts after each, so a mark taken between them was mid-tail. Raised by CodeRabbit as a correctness risk. It is not reachable today — reverting either the cut or the bound entirely leaves every test passing — so this is the invariant made explicit rather than a fix. The new case pins what is observable: a declaration before a streamed block keeps its `;` when the block drops its own.
1 parent 6412396 commit ce12f4e

6 files changed

Lines changed: 1330 additions & 94 deletions

File tree

‎.github/workflows/test.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,9 @@ jobs:
339339
# jest-worker patch also has a Bun worker end itself rather than be
340340
# terminated from the parent, which segfaults Bun on an idle-memory restart
341341
# — and exit once it has, since ending only drops its message listener and
342-
# a worker still holding a handle would never be replaced.
342+
# a worker still holding a handle would never be replaced. That buys Bun a
343+
# restart it would not otherwise survive, but not a run full of them, so the
344+
# limit has to sit above what the suite actually uses (see test:base:bun).
343345
- if: matrix.runtime == 'bun'
344346
run: |
345347
git apply test/patches/jest-worker+30.4.1.patch

0 commit comments

Comments
 (0)