From 1468b133f37bd9623f6b3a8f4c8d67274e9e73c2 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Fri, 31 Jul 2026 13:10:32 +0300 Subject: [PATCH] Speed up cascade resolution by ~4.5x 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) --- .../Dom/Internal/CssStyleDeclaration.cs | 38 ++++- .../Dom/Internal/Rules/CssStyleRule.cs | 13 +- .../Dom/Internal/StyleCollection.cs | 34 ++-- .../Extensions/StyleCollectionExtensions.cs | 83 +++++++--- .../CssCascadeBenchmarks.cs | 148 ++++++++++++++++++ 5 files changed, 283 insertions(+), 33 deletions(-) create mode 100644 src/AngleSharp.Performance.Css/CssCascadeBenchmarks.cs diff --git a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs index c4adf162..b483b2d3 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs @@ -126,7 +126,6 @@ private ICssProperty TryCreateShorthand(String shorthandName, IEnumerable 0) { - var longhands = Declarations.Where(m => !serialized.Contains(m.Name)).ToList(); var values = new ICssValue[requiredProperties.Length]; var important = 0; var count = 0; @@ -135,9 +134,9 @@ private ICssProperty TryCreateShorthand(String shorthandName, IEnumerable 0 ? TryCreateShorthand(name, serialized, usedProperties, force) : - longhands.Where(m => m.Name == name).FirstOrDefault(); + FindUnserializedLonghand(name, serialized); if (property?.Value is not null) { @@ -340,6 +339,39 @@ internal void UpdateDeclarations(IEnumerable decls) => private ICssProperty GetPropertyShorthand(String name) => TryCreateShorthand(name, Enumerable.Empty(), new List(), true); + /// + /// Gets the first declaration with the given name, unless that name was + /// already serialized. Equivalent to filtering all declarations by the + /// serialized set first, since only declarations with exactly this name + /// can ever be returned. + /// + private ICssProperty FindUnserializedLonghand(String name, IEnumerable serialized) + { + if (serialized is ICollection collection) + { + if (collection.Count > 0 && collection.Contains(name)) + { + return null; + } + } + else if (serialized.Contains(name)) + { + return null; + } + + for (var i = 0; i < _declarations.Count; i++) + { + var declaration = _declarations[i]; + + if (declaration.Name == name) + { + return declaration; + } + } + + return null; + } + private ICssProperty CreateProperty(String propertyName) { var newProperty = _context.CreateProperty(propertyName); diff --git a/src/AngleSharp.Css/Dom/Internal/Rules/CssStyleRule.cs b/src/AngleSharp.Css/Dom/Internal/Rules/CssStyleRule.cs index ab6798cd..09d5d83d 100644 --- a/src/AngleSharp.Css/Dom/Internal/Rules/CssStyleRule.cs +++ b/src/AngleSharp.Css/Dom/Internal/Rules/CssStyleRule.cs @@ -21,7 +21,7 @@ sealed class CssStyleRule : CssRule, ICssStyleRule, ISelectorVisitor private readonly CssStyleDeclaration _style; private readonly CssRuleList _rules; private ISelector _selector; - private IEnumerable _selectorList; + private ISelector[] _selectorList; private Boolean _nested; #endregion @@ -120,8 +120,13 @@ public Boolean TryMatch(IElement element, IElement? scope, out Priority specific if (_selectorList is not null) { - foreach (var selector in _selectorList.OrderByDescending(m => m.Specificity)) + // Already ordered by descending specificity when the selector was + // assigned - sorting here would repeat the work for every single + // element the rule is matched against. + for (var i = 0; i < _selectorList.Length; i++) { + var selector = _selectorList[i]; + if (selector.Match(element, scope)) { specificity += selector.Specificity; @@ -186,7 +191,9 @@ void ISelectorVisitor.PseudoElement(string name) void ISelectorVisitor.List(IEnumerable selectors) { - _selectorList = selectors; + // OrderByDescending is stable, so selectors of equal specificity keep + // their declared order - same as when this ran per match attempt. + _selectorList = selectors.OrderByDescending(m => m.Specificity).ToArray(); } void ISelectorVisitor.Combinator(IEnumerable selectors, IEnumerable symbols) diff --git a/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs b/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs index cfaa1a18..194a4d50 100644 --- a/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs +++ b/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs @@ -3,6 +3,7 @@ namespace AngleSharp.Css.Dom using AngleSharp.Dom; using System.Collections; using System.Collections.Generic; + using System.Linq; sealed class StyleCollection : IStyleCollection { @@ -10,6 +11,7 @@ sealed class StyleCollection : IStyleCollection private readonly IEnumerable _sheets; private readonly IRenderDevice _device; + private List? _rules; #endregion @@ -31,25 +33,39 @@ public StyleCollection(IEnumerable sheets, IRenderDevice device) #region Methods - public IEnumerator GetEnumerator() + public IEnumerator GetEnumerator() => GetRules().GetEnumerator(); + + /// + /// Gets the flattened list of style rules that apply to the current + /// device. The supplied sheet sequence is usually a lazy query over the + /// document, so it is walked exactly once and the result is reused for + /// the lifetime of the collection - which spans a single cascade or + /// render pass. Without this the whole DOM would be re-walked for every + /// element, and for every one of its ancestors. + /// + internal List GetRules() => _rules ??= CollectRules(); + + #endregion + + #region Helpers + + private List CollectRules() { + var rules = new List(); + foreach (var sheet in _sheets) { if (!sheet.IsDisabled && sheet.Media.Validate(_device)) { - var rules = sheet.Rules.GetMatchingStyles(_device); - - foreach (var rule in rules) + foreach (var rule in sheet.Rules.GetMatchingStyles(_device)) { - yield return rule; + rules.Add(rule); } } } - } - - #endregion - #region Helpers + return rules; + } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); diff --git a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs index ced250ae..de94d76f 100644 --- a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs +++ b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs @@ -113,11 +113,11 @@ public static ICssStyleDeclaration ComputeExplicitStyle(this IStyleCollection st { var ctx = element.Owner?.Context ?? throw new InvalidOperationException("The element must be associated with a browsing context."); var computedStyle = new CssStyleDeclaration(ctx); - var rules = styles.SortBySpecificity(element); + var matches = styles.SortBySpecificity(element); - foreach (var rule in rules) + for (var i = 0; i < matches.Count; i++) { - var inlineStyle = rule.Style; + var inlineStyle = matches[i].Rule.Style; computedStyle.SetDeclarations(inlineStyle); } @@ -158,33 +158,80 @@ internal static ICssStyleDeclaration ComputeDeclarationsWithParent(this IStyleCo #region Helpers - private static IEnumerable SortBySpecificity(this IEnumerable rules, IElement element) + private static List SortBySpecificity(this IStyleCollection styles, IElement element) { - IEnumerable> MapPriority(ICssStyleRule rule) + // Resolving the scope once is equivalent to letting every TryMatch call + // fall back to it, but avoids re-reading DocumentElement per rule. + var scope = element.Owner?.DocumentElement; + var matches = new List(); + + if (styles is StyleCollection collection) { - if (rule.TryMatch(element, null, out var specificity)) + var rules = collection.GetRules(); + + for (var i = 0; i < rules.Count; i++) { - yield return Tuple.Create(rule, specificity); + MapPriority(rules[i], element, scope, matches); } + } + else + { + foreach (var rule in styles) + { + MapPriority(rule, element, scope, matches); + } + } + + // OrderBy is a stable sort; the index tie-break reproduces that for + // rules that share the same specificity. + matches.Sort(RuleMatchComparer.Instance); + return matches; + } + + private static void MapPriority(ICssStyleRule rule, IElement element, IElement? scope, List matches) + { + if (rule.TryMatch(element, scope, out var specificity)) + { + matches.Add(new RuleMatch(rule, specificity, matches.Count)); + } - foreach (var subRule in rule.Rules) + var subRules = rule.Rules; + + for (var i = 0; i < subRules.Length; i++) + { + if (subRules[i] is ICssStyleRule style) { - if (subRule is ICssStyleRule style) - { - foreach (var item in MapPriority(style)) - { - yield return item; - } - } + MapPriority(style, element, scope, matches); } } + } + + private readonly struct RuleMatch + { + public RuleMatch(ICssStyleRule rule, Priority priority, Int32 index) + { + Rule = rule; + Priority = priority; + Index = index; + } - return rules.SelectMany(MapPriority).OrderBy(GetPriority).Select(GetRule); + public ICssStyleRule Rule { get; } + + public Priority Priority { get; } + + public Int32 Index { get; } } - private static Priority GetPriority(Tuple item) => item.Item2; + private sealed class RuleMatchComparer : IComparer + { + public static readonly RuleMatchComparer Instance = new RuleMatchComparer(); - private static ICssStyleRule GetRule(Tuple item) => item.Item1; + public Int32 Compare(RuleMatch x, RuleMatch y) + { + var result = Comparer.Default.Compare(x.Priority, y.Priority); + return result != 0 ? result : x.Index.CompareTo(y.Index); + } + } #endregion } diff --git a/src/AngleSharp.Performance.Css/CssCascadeBenchmarks.cs b/src/AngleSharp.Performance.Css/CssCascadeBenchmarks.cs new file mode 100644 index 00000000..06965c22 --- /dev/null +++ b/src/AngleSharp.Performance.Css/CssCascadeBenchmarks.cs @@ -0,0 +1,148 @@ +namespace AngleSharp.Performance.Css +{ + using AngleSharp; + using AngleSharp.Css.Dom; + using AngleSharp.Css.Parser; + using AngleSharp.Dom; + using BenchmarkDotNet.Attributes; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + + /// + /// Covers the styling side of the library: cascade resolution via + /// getComputedStyle, full render tree construction and the parsing of + /// inline style attributes. + /// + [MemoryDiagnoser] + public class CssCascadeBenchmarks + { + private static readonly CssParser DeclarationParser = new CssParser(); + + private IDocument _document = null!; + private IWindow _window = null!; + private IElement[] _elements = null!; + private String[] _inlineStyles = null!; + + [GlobalSetup] + public void Setup() + { + var config = Configuration.Default.WithCss(); + var context = BrowsingContext.New(config); + _document = context.OpenAsync(req => req.Content(BuildDocument())).GetAwaiter().GetResult(); + _window = _document.DefaultView!; + + // A representative sample instead of every element - keeps a single + // benchmark iteration in a sane range while still walking the cascade + // for elements at different depths. + _elements = _document.QuerySelectorAll("#root *").Where((_, i) => i % 12 == 0).ToArray(); + _inlineStyles = BuildInlineStyles(); + } + + [Benchmark] + public Int32 ComputedStyle() + { + var total = 0; + + foreach (var element in _elements) + { + total += _window.GetComputedStyle(element).Length; + } + + return total; + } + + [Benchmark] + public Object RenderTree() + { + return _window.Render(); + } + + [Benchmark] + public Int32 ParseInlineDeclarations() + { + var total = 0; + + foreach (var style in _inlineStyles) + { + total += DeclarationParser.ParseDeclaration(style)?.Length ?? 0; + } + + return total; + } + + private static String[] BuildInlineStyles() + { + var templates = new[] + { + "color:#333;background:#fff", + "display:none", + "margin:0;padding:0", + "width:100%;height:auto", + "font-family:Arial,Helvetica,sans-serif;font-size:12px", + "border:1px solid #ccc;border-radius:4px", + "position:absolute;top:0;left:0;z-index:10", + "background-image:url(https://example.com/i.png);background-repeat:no-repeat", + "text-align:center;line-height:1.5", + "float:left;clear:both;overflow:hidden", + "background:linear-gradient(to right,#fff 0%,#000 100%)", + "transform:translate(10px,20px) rotate(45deg)", + "box-shadow:0 1px 2px rgba(0,0,0,.2)", + "flex:1 1 auto;align-items:center;justify-content:space-between", + "padding:10px 15px 10px 15px;margin:0 auto", + "visibility:hidden;opacity:0.5", + "color:rgb(51,51,51);background-color:rgba(255,255,255,0.9)", + "font:bold 14px/1.2 'Segoe UI',sans-serif", + "grid-template-columns:repeat(3,1fr);gap:10px", + "transition:all .3s ease-in-out", + }; + + var list = new List(); + + for (var i = 0; i < 10; i++) + { + list.AddRange(templates); + } + + return list.ToArray(); + } + + private static String BuildDocument() + { + var sb = new StringBuilder(); + sb.Append("
"); + + for (var s = 0; s < 25; s++) + { + sb.Append("
"); + + for (var c = 0; c < 12; c++) + { + sb.Append("
"); + sb.Append("

Text ").Append(s).Append('-').Append(c).Append("

"); + sb.Append("link"); + sb.Append("
"); + } + + sb.Append("
"); + } + + sb.Append("
"); + return sb.ToString(); + } + } +}