Speed up cascade resolution by ~4.5x - #220
Merged
Merged
Conversation
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>
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.
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
StyleCollectionre-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.GetStyleSheetsalone 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.TryMatchsorted its selector list on every match attempt._selectorList.OrderByDescending(m => m.Specificity)ran per element per rule — 29% ofTryMatch's own subtree and most of its allocation traffic. The list only changes when the selector is assigned, so it is sorted there.OrderByDescendingis stable, so equal-specificity ordering is unchanged.SortBySpecificitypaid 16.6% of the profile in array covariance checks.SelectMany(...).OrderBy(...)overTuple<ICssStyleRule, Priority>meant LINQ's internalToArrayunder shared generics hitCastHelpers.StelemRef/StelemRef_Helperfor every store. Replaced with aList<RuleMatch>of structs and an index tie-break that reproducesOrderBy's stability exactly.TryMatchre-resolvedDocumentElementper rule because scope was always passed asnulland fell back internally. Now resolved once per element.Parsing:
TryCreateShorthandallocated a filteredListplus 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 =
develchecked out in a worktree running identical benchmark source.ComputedStyleRenderTreeComputedStyleallocatedRenderTreeallocatedParseInlineDeclarationsStylesheet 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:Notes
CssCascadeBenchmarks— the styling side had no benchmark coverage, so there was nothing to gate a change like this against.GetComputedStylestill constructs a fresh style collection per call, leavingGetStyleSheetsat ~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