Skip to content

CSS :nth-child() and friends are O(n²) on sibling-list width #84

Description

@jakejackson1

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:377nodePositionFromStart() 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 childNodes once and record each element
    child's 1-based index (and its index among same-tag siblings, for $byType).
  • Memoize in an SplObjectStorage scoped 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 then count - index + 1 off 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 — main and 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions