Repository navigation
Commit ce12f4e
authored
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
- lib
- css
- util
- test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
339 | 339 | | |
340 | 340 | | |
341 | 341 | | |
342 | | - | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
343 | 345 | | |
344 | 346 | | |
345 | 347 | | |
| |||
0 commit comments