Reject candidates that cannot match before walking their ancestors - #189
Open
jdalton wants to merge 1 commit into
Open
Reject candidates that cannot match before walking their ancestors#189jdalton wants to merge 1 commit into
jdalton wants to merge 1 commit into
Conversation
A candidate can only match 'div ul li a' if a div, a ul and a li all appear somewhere above it, and that is far cheaper to answer than the match itself. On the benchmark fixture it is also nearly decisive: of 2370 anchors, 10 survive the tag test and 10 match. 'dl dd a' rejects 96%, 'ul li a span' rejects all of them. The tags above an element are summarized as bits in one integer. An element's summary is its parent's summary plus the parent's own bit, so a chain is walked once rather than once per candidate, and consecutive candidates — which arrive in document order and usually share a parent — answer from a single-entry memo without touching the Map. Bits collide, which only costs a candidate that would have been rejected; the filter never decides a match, it only skips work. The required tags are collected as the selector compiles: a compound's tag is promoted to a requirement when a descendant or child combinator puts it above the candidate. A sibling combinator does not promote, and does not disqualify either, since siblings share a parent and an ancestor of a sibling above that parent is still an ancestor. The bits come from the same string the generated comparison uses, so the filter cannot reject anything the full test would have accepted. div ul li a 2.66ms -> 1.08ms 2.46x dl dd a 1.68ms -> 1.07ms 1.57x div p a 1.91ms -> 1.17ms 1.63x ul li a span 1.28ms -> 382us 3.35x ul li a 1.57ms -> 1.20ms 1.31x Two gates keep it from costing anything where it cannot pay. It is emitted only for a selection, since matching one element has no candidates to reject; and only when the selector walks ancestors at all, because a chain of child combinators takes one step per combinator whatever the depth — measured 1.47x slower on 'div.example > p > a' before that gate went in, and unchanged after. The summaries are dropped with the call that built them, next to where the nth caches are reset: they key on elements, so holding them longer would keep a removed subtree alive, and an element that moved in between would carry a summary describing where it used to be. References: - Spec: https://drafts.csswg.org/selectors-4/#descendant-combinators — what a descendant combinator requires of the ancestors - Chromium: https://github.com/chromium/chromium/blob/155.0.8041.1/third_party/blink/renderer/core/css/selector_filter.h — the same idea in Blink: a bitset summary that only ever skips work
jdalton
force-pushed
the
perf/ancestor-filter
branch
from
September 4, 2026 18:00
5898fc3 to
4401165
Compare
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.
A candidate can only match 'div ul li a' if a div, a ul and a li all appear above it, which is far cheaper to answer than the match: of 2370 anchors on the benchmark fixture, 10 survive the test and 10 match. Tags above an element are summarized as bits in one integer, and an element's summary is its parent's plus the parent's own bit, so a chain is walked once rather than once per candidate.
Detail, and how it was checked
A candidate can only match 'div ul li a' if a div, a ul and a li all appear somewhere above it, and that is far cheaper to answer than the match itself. On the benchmark fixture it is also nearly decisive: of 2370 anchors, 10 survive the tag test and 10 match. 'dl dd a' rejects 96%, 'ul li a span' rejects all of them.
The tags above an element are summarized as bits in one integer. An element's summary is its parent's summary plus the parent's own bit, so a chain is walked once rather than once per candidate, and consecutive candidates — which arrive in document order and usually share a parent — answer from a single-entry memo without touching the Map. Bits collide, which only costs a candidate that would have been rejected; the filter never decides a match, it only skips work.
The required tags are collected as the selector compiles: a compound's tag is promoted to a requirement when a descendant or child combinator puts it above the candidate. A sibling combinator does not promote, and does not disqualify either, since siblings share a parent and an ancestor of a sibling above that parent is still an ancestor. The bits come from the same string the generated comparison uses, so the filter cannot reject anything the full test would have accepted.
div ul li a 2.66ms -> 1.08ms 2.46x dl dd a 1.68ms -> 1.07ms 1.57x div p a 1.91ms -> 1.17ms 1.63x ul li a span 1.28ms -> 382us 3.35x ul li a 1.57ms -> 1.20ms 1.31x
Two gates keep it from costing anything where it cannot pay. It is emitted only for a selection, since matching one element has no candidates to reject; and only when the selector walks ancestors at all, because a chain of child combinators takes one step per combinator whatever the depth — measured 1.47x slower on 'div.example > p > a' before that gate went in, and unchanged after.
The summaries are dropped with the call that built them, next to where the nth caches are reset: they key on elements, so holding them longer would keep a removed subtree alive, and an element that moved in between would carry a summary describing where it used to be.
Extracted from #167 as a standalone change: one file, applies to master on its own, and checked against the benchmark fixture to confirm every selector still agrees with the native engine.
References: the spec, the browser source, and what each part was reasoned from
This patch applies to master on its own. The sixteen in this series were checked by cherry-picking them onto master one after another, in this order and in reverse, and all sixteen land without a conflict.