Skip to content

Remember inherited lookups on the way up - #263

Merged
FlorianRappl merged 3 commits into
AngleSharp:develfrom
michaelthyregod:feature/inherit-lookup-cache
Nov 2, 2026
Merged

FlorianRappl merged 3 commits into
AngleSharp:develfrom
michaelthyregod:feature/inherit-lookup-cache

Conversation

@michaelthyregod

Copy link
Copy Markdown
Contributor

Types of Changes

Prerequisites

Please make sure you can check the following two boxes:

  • I have read the CONTRIBUTING document
  • My code follows the code style of this project

Contribution Type

What types of changes does your code introduce? Put an x in all the boxes that apply:

  • Bug fix (non-breaking change which fixes an issue, please reference the issue id)
  • New feature (non-breaking change which adds functionality, make sure to open an associated issue first)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • My change requires a change to the documentation
  • I have updated the documentation accordingly
  • I have added tests to cover my changes
  • All new and existing tests passed

Description

The cache part of #262; rebuilding GetInnerText on the render tree follows as a separate PR.

A computed style looks inherited declarations up through the snapshots of its parents. A property that no ancestor declares, e.g. visibility, was looked up through every ancestor for every element, so IsVisible on the render tree and GetInnerText grew quadratically with the depth.

The change

  • Snapshot remembers lookups: the nearest declaration found for a name (or that there is none) is kept on the snapshots passed on the way up. Snapshots never change, so each one is walked once per name. The entries are a small array per snapshot, copied on write and published with Interlocked.CompareExchange, so concurrent readers of the same computed style stay safe without a lock.
  • Explicitly inherited declarations (prop: inherit on every level) keep the existing walk; it is rare and needs every level.
  • No public API changes; only the private Snapshot class in CssStyleDeclaration.cs.
  • Tests in InheritanceChainTests: 300-level chains built with the DOM API, read deepest first and then again, for color inherited from two different ancestors and for visibility: hidden from one. A cache that leaks an answer past the declaring ancestor fails both.
  • Benchmark DeepTreeBenchmarks: GetInnerText, and IsVisible over a render tree built once, at 100 and 500 levels (within what the recursive walks survive on a 1 MB stack).

Benchmarks

Paired builds of the same benchmark code against devel (d0c4329), alternating which goes first each round. BenchmarkDotNet 0.15.8, net10.0, AngleSharp 1.9.0 for both, pinned to the performance cores of an Intel Core Ultra 7 165H. Median of 6 rounds:

Case devel this PR Change Allocated
DeepTreeBenchmarks.IsVisible, 100 levels 29.0 μs 9.9 μs −65.9% same
DeepTreeBenchmarks.IsVisible, 500 levels 566.9 μs 49.3 μs −91.3% same
DeepTreeBenchmarks.InnerText, 100 levels 291.7 μs 253.5 μs −13.1% 464 → 481 KB
DeepTreeBenchmarks.InnerText, 500 levels 2,125 μs 1,313 μs −38.2% 2,179 → 2,265 KB
InnerTextBenchmarks.GetInnerText_RealWorld_Length 73.7 ms 74.1 ms +0.5% 40.6 → 40.9 MB
InnerTextBenchmarks.GetInnerText_SelectorHeavy_Length 596.7 ms 597.8 ms +0.2% 321.1 → 318.9 MB
CssCascadeBenchmarks.RenderTree 5,241 μs 5,105 μs −2.6% same
CssCascadeBenchmarks.ComputedStyle 3,954 μs 3,983 μs +0.7% same

The deep-tree gains hold in every round (e.g. −90.9% to −91.5% for IsVisible at 500). The real documents stay within the noise: their rounds change sign, and one round was +15% for both GetInnerText cases at once, a disturbance of that round. GetInnerText computes the styles again on every call, so the remembered lookups cost about 4% more allocation on the deep trees and under 1% on the real ones.
🤖 Generated with Claude Code

A computed style looks inherited declarations up through the snapshots of
its parents. A property that no ancestor declares, e.g. visibility, was
looked up through every ancestor for every element, so GetInnerText and
IsVisible on the render tree grew quadratically with the depth (AngleSharp#262).

- Keep the nearest declaration found for a name, or that there is none,
  on the snapshots passed; they never change, so each one is walked once
  per name. The entries are copied on write and published atomically
- Leave the lookup of explicitly inherited declarations as it was
- Add tests for deep chains, read deepest first and twice
- Add DeepTreeBenchmarks for GetInnerText and IsVisible at 100 and 500
  levels

@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.

Good, but not good enough as the two most important pieces did not really move:

CssCascadeBenchmarks.RenderTree 5,241 μs 5,105 μs −2.6% same
CssCascadeBenchmarks.ComputedStyle 3,954 μs 3,983 μs +0.7% same

Can we use the PR to improve those, too?

The cascade allocated a new list for the matching rules of every element
and grew the explicit style one declaration at a time; on a real document
this growth and the garbage it leaves dominate the render tree.

- Reuse the list of matching rules from element to element; it is taken
  and given back atomically, so a concurrent cascade gets its own
- Size the explicit style for the declarations of the matching rules
@michaelthyregod

Copy link
Copy Markdown
Contributor Author

Profiling the two benchmarks on this branch (sampled stacks, net10.0):

  • RenderTree: the time went into allocation and the GC: a new list of matching rules per element (~15%) and the explicit style growing one declaration at a time (~8% each for its list and index). The new commit reuses the list and presizes the style. Paired builds against the previous commit of this PR (d4b5687), median of 6 rounds, pinned to the performance cores:

    Case before after Change Allocated
    CssCascadeBenchmarks.RenderTree 5,122 μs 4,981 μs −2.8% (faster in every round) 7,358 → 6,606 KB
    CssCascadeBenchmarks.ComputedStyle 4,085 μs 4,034 μs −1.3% (noise) 6,780 → 6,651 KB
    DeepTreeBenchmarks.InnerText, 500 levels 1,355 μs 1,301 μs −4.0% 2,265 → 2,124 KB

    So 10% less garbage per render, but only a few percent faster.

  • ComputedStyle: about two thirds of each call goes to building a new StyleCollection. GetStyleCollection runs on every GetComputedStyle call, which collects the sheets (CollectRules, 42%) and indexes the rules (StyleRuleIndex, 22%) again. The matching and computing are the small part.

Reusing the collection across calls would remove most of that, but it would have to be dropped when a sheet is added, removed or disabled, a rule changes, or a media condition stops matching (e.g. the device is resized). I see two ways:

  1. Keep it per document and device, and compare versions: a counter on the rule lists and declarations that every change bumps, checked against the document's current sheets on each call. It is exact, but touches the CSSOM classes.
  2. Leave it to the caller: GetStyleCollection is public, so callers that compute many styles can already build it once and call ComputeDeclarations. Only the docs or a sample would change.

Would you like one of these, here or in a separate PR? Or do you have an existing mechanism in mind for noticing style changes?

@FlorianRappl

Copy link
Copy Markdown
Contributor

With the reduced GC I think this makes sense for this round. Let's keep it like that!

@FlorianRappl FlorianRappl added this to the v1.3.2 milestone Nov 2, 2026
@FlorianRappl
FlorianRappl merged commit 34008c5 into AngleSharp:devel Nov 2, 2026
5 checks passed
@michaelthyregod
michaelthyregod deleted the feature/inherit-lookup-cache branch November 2, 2026 21:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants