Repository navigation
CSS :nth-child() and friends are O(n²) on sibling-list width #84
Description
Description of the bug
:nth-child() and its siblings are O(n²) on the width of the sibling list. Each candidate node
recomputes its own position by walking previousSibling to the start of the list, so a selector
tested against n siblings performs n²/2 pointer hops.
On a table of 4000 rows, tr:nth-child(2n) takes 75× longer than tr, and the gap grows with
every row added.
Measurements
<table> containing n × <tr><td>x</td></tr>, PHP 8.3.16, main @ e23fbb0:
| rows | tr |
tr:nth-child(2n) |
ratio |
|---|---|---|---|
| 500 | 0.8 ms | 12.2 ms | 15× |
| 1000 | 1.6 ms | 32.6 ms | 20× |
| 2000 | 3.0 ms | 120.4 ms | 40× |
| 4000 | 6.4 ms | 476.8 ms | 75× |
tr is linear; :nth-child quadruples for each doubling of the row count. At 20 000 rows the same
selector takes ~12 seconds.
Which selectors are affected
Only the four :nth-* families. Measured at 2000 rows:
| selector | time |
|---|---|
tr |
5.6 ms |
tr:first-child |
3.9 ms |
tr:last-child |
3.6 ms |
tr:only-child |
3.5 ms |
tr:empty |
3.9 ms |
tr:nth-child(2n) |
122.2 ms |
tr:nth-last-child(2n) |
122.4 ms |
tr:nth-of-type(2n) |
169.8 ms |
tr:nth-last-of-type(2n) |
171.4 ms |
:first-child, :last-child, :only-child and :empty take a different path and are fine.
Root cause
src/CSS/DOMTraverser/PseudoClass.php:377 — nodePositionFromStart() walks the whole preceding
sibling chain for one node:
protected function nodePositionFromStart($node, $byType = false): int
{
$i = 1;
$tag = $node->tagName;
while (isset($node->previousSibling)) {
$node = $node->previousSibling;
if ($node->nodeType === XML_ELEMENT_NODE && (! $byType || $node->tagName === $tag)) {
++$i;
}
}
return $i;
}nodePositionFromEnd() (:399) is the mirror image. isNthChild() (:438) calls one of them per
node, so the cost for a full sibling list is 1 + 2 + … + n.
The :nth-of-type variants are slower still because $byType adds a tagName comparison at every
step of every walk.
Secondary: isNthChild() re-parses the an+b expression on every node —
Util::parseAnB($value) at :440 — for a value that is fixed for the whole query.
Suggested fix
Index the parent's child list once and reuse it, rather than re-deriving each node's position.
The same technique is already used elsewhere in the codebase for document-order sorting
(Util::siblingOffset()), where a SplObjectStorage memo scoped to a single call replaced exactly
this quadratic walk and made a 4000-wide sort go from 253 ms to 33 ms.
Sketch:
- On the first position request for a given parent, walk
childNodesonce and record each element
child's 1-based index (and its index among same-tag siblings, for$byType). - Memoize in an
SplObjectStoragescoped to the query, not stored on the handler — this library
mutates DOMs constantly (append(),remove(),wrap()), so a cache that outlives a single
traversal would go stale. nodePositionFromEnd()is thencount - index + 1off the same table, with no second walk.- Hoist
Util::parseAnB()out of the per-node path.
That makes the whole sibling list cost one pass instead of one pass per node.
Notes
- Not a regression —
mainand every currently open branch behave identically here. - Fix jQuery positional pseudo-classes to index the matched set #70 converts the jQuery positional pseudo-classes (
:eq,:first,:lt,:gt,:odd,
:even) into set-level filters and deliberately leaves the CSS structural ones alone, so it
neither causes nor fixes this. - Correctness is not in question; this is purely about cost.
Repro
<?php
require 'vendor/autoload.php';
foreach ([500, 1000, 2000, 4000] as $n) {
$xml = '<?xml version="1.0"?><table>' . str_repeat('<tr><td>x</td></tr>', $n) . '</table>';
$t = microtime(true); qp($xml, 'tr:nth-child(2n)')->count(); $nth = (microtime(true) - $t) * 1000;
$t = microtime(true); qp($xml, 'tr')->count(); $plain = (microtime(true) - $t) * 1000;
printf("rows %5d | tr %7.1f ms | tr:nth-child(2n) %9.1f ms | %3.0fx\n", $n, $plain, $nth, $nth / $plain);
}QueryPath version
main @ e23fbb0
PHP Version and environment
PHP 8.3.16 (cli), macOS. Not version-specific.
Activity