Skip to content

Reject candidates that cannot match before walking their ancestors - #189

Open
jdalton wants to merge 1 commit into
dperini:masterfrom
jdalton:perf/ancestor-filter
Open

Reject candidates that cannot match before walking their ancestors#189
jdalton wants to merge 1 commit into
dperini:masterfrom
jdalton:perf/ancestor-filter

Conversation

@jdalton

@jdalton jdalton commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

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.

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
jdalton force-pushed the perf/ancestor-filter branch from 5898fc3 to 4401165 Compare September 4, 2026 18:00
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.

1 participant