Skip to content

Speed up cascade resolution by ~4.5x - #220

Merged
FlorianRappl merged 1 commit into
AngleSharp:develfrom
lahma:perf/cascade-and-parse
Jul 31, 2026
Merged

Speed up cascade resolution by ~4.5x#220
FlorianRappl merged 1 commit into
AngleSharp:develfrom
lahma:perf/cascade-and-parse

Conversation

@lahma

@lahma lahma commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What

CPU-profiled the library's most common workloads (stylesheet parsing, getComputedStyle, render tree construction, serialization) with ultra ETW sampling at 8190 Hz, then fixed what the profile pointed at. The cascade path turned out to be doing a large amount of repeated work per element.

Findings and fixes

StyleCollection re-walked the DOM on every enumeration. It held a lazy sheet sequence (defaultSheets.Concat(document.GetStyleSheets().OfType<ICssStyleSheet>())), and the collection is enumerated once per element and once per ancestor during cascade resolution. StyleExtensions.GetStyleSheets alone was 48% of the profile. The sequence is now walked once and the flattened matching rules cached for the lifetime of the collection — which spans a single cascade or render pass.

CssStyleRule.TryMatch sorted its selector list on every match attempt. _selectorList.OrderByDescending(m => m.Specificity) ran per element per rule — 29% of TryMatch's own subtree and most of its allocation traffic. The list only changes when the selector is assigned, so it is sorted there. OrderByDescending is stable, so equal-specificity ordering is unchanged.

SortBySpecificity paid 16.6% of the profile in array covariance checks. SelectMany(...).OrderBy(...) over Tuple<ICssStyleRule, Priority> meant LINQ's internal ToArray under shared generics hit CastHelpers.StelemRef / StelemRef_Helper for every store. Replaced with a List<RuleMatch> of structs and an index tie-break that reproduces OrderBy's stability exactly.

TryMatch re-resolved DocumentElement per rule because scope was always passed as null and fell back internally. Now resolved once per element.

Parsing: TryCreateShorthand allocated a filtered List plus LINQ closures per declaration; replaced with a direct scan. The serialized-set filter only depends on the name being looked up, so this is equivalent.

Measurements

BenchmarkDotNet, MediumRun, idle machine, baseline = devel checked out in a worktree running identical benchmark source.

Benchmark Baseline This PR Change
ComputedStyle 23,169 µs ±0.5% 5,100 µs ±0.3% 4.5x faster
RenderTree 29,828 µs ±1.0% 7,288 µs ±1.0% 4.1x faster
ComputedStyle allocated 25.19 MB 1.41 MB 18x less
RenderTree allocated 56.25 MB 5.25 MB 11x less
ParseInlineDeclarations 838 µs 785 µs 6% faster

Stylesheet parsing (CssParserBenchmarks, eight real-world sheets): throughput unchanged — deltas are mixed in sign and all inside the error bars, so no speedup is claimed there. Allocations, which are deterministic, drop consistently:

Sheet Baseline This PR
cdnjs.cloudflare 1616.6 KB 1379.8 KB (−14.6%)
florian-rappl 2580.4 KB 2268.8 KB (−12.1%)
z-ecx.images-amazon 5724.0 KB 5076.9 KB (−11.3%)
static.licdn 1208.0 KB 1095.9 KB (−9.3%)
csszengarden 1395.5 KB 1283.2 KB (−8.0%)
maxcdn.bootstrapcdn 5341.1 KB 4977.7 KB (−6.8%)
style.aliunicorn 930.0 KB 897.8 KB (−3.5%)
s.yimg 1052.2 KB 1032.2 KB (−1.9%)

Notes

  • All 1950 tests pass.
  • Adds CssCascadeBenchmarks — the styling side had no benchmark coverage, so there was nothing to gate a change like this against.
  • Not addressed: GetComputedStyle still constructs a fresh style collection per call, leaving GetStyleSheets at ~25% of that path. Removing it requires caching a collection across calls with invalidation when stylesheets change, which is a design change with real correctness risk — better as its own issue.

🤖 Generated with Claude Code

Profiling getComputedStyle over a realistic document (ultra/ETW, 8190 Hz)
showed the cascade path dominated by work that was repeated per element:

- StyleCollection held a lazy sheet sequence, so every enumeration re-walked
  the whole DOM looking for style/link elements. The collection is enumerated
  once per element AND once per ancestor, making StyleExtensions.GetStyleSheets
  48% of the profile on its own. The sequence is now walked once and the
  flattened matching rules cached for the lifetime of the collection, which
  spans a single cascade or render pass.

- CssStyleRule.TryMatch sorted its selector list by descending specificity on
  every match attempt (29% of its own subtree). The list only changes when the
  selector is assigned, so it is sorted there instead. OrderByDescending is
  stable, so equal-specificity ordering is unchanged.

- SortBySpecificity built Tuple objects through SelectMany/OrderBy. Under
  shared generics LINQ's internal ToArray spent 16.6% of the whole profile in
  array covariance checks (CastHelpers.StelemRef). Replaced with a list of
  structs plus an index tie-break that reproduces OrderBy's stability exactly.

- TryMatch re-read DocumentElement per rule per element because scope was
  always passed as null; it is now resolved once per element.

Also drops a per-call filtered list and LINQ closures from TryCreateShorthand
on the parsing path.

Measured with BenchmarkDotNet (MediumRun, idle machine), baseline = devel:

  ComputedStyle   23,169 us -> 5,100 us   (4.5x)   25.19 MB -> 1.41 MB
  RenderTree      29,828 us -> 7,288 us   (4.1x)   56.25 MB -> 5.25 MB

Stylesheet parsing throughput is unchanged (all deltas within error bars);
its allocations drop 2-15% across the eight real-world sample sheets.

Adds CssCascadeBenchmarks to cover the styling side, which had no benchmark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@FlorianRappl FlorianRappl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@FlorianRappl
FlorianRappl merged commit 297976a into AngleSharp:devel Jul 31, 2026
5 checks passed
@lahma
lahma deleted the perf/cascade-and-parse branch July 31, 2026 12:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants