perf(xpath): order results by a per-root index, linear not quadratic in depth (#347)

Sorting a node-set into document order keyed each node by its root path, an
array as long as the node's depth, computed with a per-node ancestor walk.
Ordering the descendants of a deeply-nested document was therefore
O(n log n x depth): `//a` over an 8000-deep document took ~19s through the
reference query path.

The key is now a single O(1) pre-order index from a per-root numbering,
built once per document and shared through the evaluation's navigation
cache, with a band ordering an element before its namespace nodes, its
attribute nodes, and its children. A node-set spanning several documents
(an XSLT document() result) is grouped by root, ranked by first appearance,
so only a foreign node walks its ancestors. `appendSubtree` (the numbering
walk) is now iterative, so it no longer recurses on depth either.

Document order is unchanged: 1786 tests pass, including the XPath, XSLT,
XPointer, and Schematron conformance corpora. `Query.nodes` over a
50000-deep document now orders in a fraction of a second.
This commit is contained in:
Mihaela Mihaljevic 2026-06-26 18:22:20 +02:00 committed by GitHub
parent 3405c9709d
commit 3e4f596b7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 166 additions and 29 deletions

View File

@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Sorting an XPath result into document order is now linear in the document, not quadratic in its depth. Each node's order key was its root path, an array as long as the node's depth, so ordering the descendants of a deeply-nested document was O(n log n x depth). The key is now a single O(1) index from a per-root pre-order numbering (built once per document and shared through the evaluation cache), with a band ordering an element before its namespace and attribute nodes and its children. A node-set spanning several documents is grouped by root. This linearizes the reference query path (`Query.nodes`, which XSLT and Schematron drive) on deep documents; the underlying subtree walk (`appendSubtree`) is now iterative too, so it no longer recurses. Document order is unchanged.
- Parsing is now linear in nesting depth, not quadratic. Resolving each open element's name and attributes looked up namespace bindings by walking a scope stack as deep as the document, so a deeply-nested document was O(depth^2): a 40000-deep document took tens of seconds. The parser now maintains the current in-scope bindings incrementally (a single map plus a per-element undo list), so a lookup is O(1) and parsing is O(n). The same document parses in a fraction of a second, with no change to resolved namespaces. (#345)
- The immutable value `Model.Node` tree type is now depth-safe. An `Element` previously stored its children inline (the enum was `indirect`), so a `Node`'s recursive layout made building, comparing, hashing, and releasing a deeply-nested tree recurse one native frame per level and overflow the stack (on a constrained stack such as a Swift Task, as shallow as a few thousand deep). `Element` now holds its children behind a copy-on-write reference (`ElementStorage`): the `Node` enum is no longer `indirect`, so it is a fixed-size value, and `==`, `hashValue`, and release all walk the tree iteratively through explicit work stacks. Release flattens only storage it solely owns, so a copy-on-write sibling keeps its subtree. Value semantics are unchanged (copying a node still copies its children, lazily) and `Element`/`Node` remain `Equatable`, `Hashable`, and `Sendable`. A 50000-deep tree now builds, compares, hashes, and releases in a fraction of a second with no crash. (Completes the value-`Node` half of #341.)

View File

@ -203,7 +203,7 @@ extension PureXML.XPath {
/// The document's nodes in document order plus a node-to-index map, built
/// once per document root and reused across the following and preceding
/// evaluations of one query.
private static func orderedDocument(
static func orderedDocument(
rootedAt root: PureXML.Model.TreeNode,
cache: DocumentNavigationCache?,
) -> (nodes: [Node], index: [Node: Int]) {
@ -289,13 +289,17 @@ extension PureXML.XPath {
}
private static func appendSubtree(_ tree: PureXML.Model.TreeNode, into result: inout [Node]) {
result.append(.tree(tree))
for child in tree.children {
appendSubtree(child, into: &result)
// Iterative pre-order walk: a deeply-nested document would otherwise
// overflow the stack here. Children are pushed reversed so they pop in
// document order.
var stack: [PureXML.Model.TreeNode] = [tree]
while let node = stack.popLast() {
result.append(.tree(node))
stack.append(contentsOf: node.children.reversed())
}
}
private static func rootTree(of node: Node) -> PureXML.Model.TreeNode? {
static func rootTree(of node: Node) -> PureXML.Model.TreeNode? {
let start: PureXML.Model.TreeNode? = switch node {
case let .tree(tree): tree
case let .attribute(owner, _), let .namespace(owner, _, _): owner

View File

@ -0,0 +1,97 @@
extension PureXML.XPath.AxisNavigation {
/// A document-order sort key. Comparing two nodes is O(1): the document each
/// belongs to (ranked by first appearance), the pre-order index of the node's
/// owning tree node, a band placing an element before its namespace nodes
/// before its attribute nodes before its children, and a tie-breaking
/// sub-index. This replaces the former per-node root path, an array as long as
/// the node's depth, which made ordering a deep document quadratic.
private struct OrderKey: Comparable {
var rootRank = 0
var index = 0
var band = 0
var sub = 0
static func < (lhs: OrderKey, rhs: OrderKey) -> Bool {
if lhs.rootRank != rhs.rootRank { return lhs.rootRank < rhs.rootRank }
if lhs.index != rhs.index { return lhs.index < rhs.index }
if lhs.band != rhs.band { return lhs.band < rhs.band }
return lhs.sub < rhs.sub
}
}
/// Sorts `nodes` into XPath document order using a per-root pre-order index
/// (built once and cached) rather than a per-node root path, so the sort is
/// O(n log n + document) instead of O(n log n x depth).
///
/// A node-set spanning several documents (an XSLT `document()` result) is
/// grouped by root, ranked by first appearance; only a node outside the first
/// node's document pays an O(depth) root walk, so the common single-document
/// case never walks a node's ancestors.
static func sortByDocumentOrder(_ nodes: [PureXML.XPath.Node], cache: PureXML.XPath.DocumentNavigationCache?) -> [PureXML.XPath.Node] {
guard nodes.count > 1, let primaryRoot = rootTree(of: nodes[0]) else { return nodes }
return decorate(nodes, primaryRoot: primaryRoot, cache: cache)
.sorted { $0.key < $1.key }
.map(\.node)
}
/// The single node first in document order, by the same keying as
/// ``sortByDocumentOrder(_:cache:)`` but in O(n) rather than a full sort.
static func firstByDocumentOrder(_ nodes: [PureXML.XPath.Node], cache: PureXML.XPath.DocumentNavigationCache?) -> PureXML.XPath.Node? {
guard nodes.count > 1, let primaryRoot = rootTree(of: nodes[0]) else { return nodes.first }
return decorate(nodes, primaryRoot: primaryRoot, cache: cache)
.min { $0.key < $1.key }?
.node
}
/// Pairs each node with its document-order key. The primary document's index
/// is built once; a foreign node falls back to its own document's index,
/// ranked after the primary by first appearance.
private static func decorate(
_ nodes: [PureXML.XPath.Node],
primaryRoot: PureXML.Model.TreeNode,
cache: PureXML.XPath.DocumentNavigationCache?,
) -> [(node: PureXML.XPath.Node, key: OrderKey)] {
let primaryID = ObjectIdentifier(primaryRoot)
var indexByRoot: [ObjectIdentifier: [PureXML.XPath.Node: Int]] = [primaryID: orderedDocument(rootedAt: primaryRoot, cache: cache).index]
var rootRank: [ObjectIdentifier: Int] = [primaryID: 0]
return nodes.map { node in
if var key = compactKey(node, in: indexByRoot[primaryID] ?? [:]) {
key.rootRank = 0
return (node, key)
}
guard let root = rootTree(of: node) else { return (node, OrderKey(rootRank: .max)) }
let rootID = ObjectIdentifier(root)
let rank: Int
if let existing = rootRank[rootID] {
rank = existing
} else {
rank = rootRank.count
rootRank[rootID] = rank
indexByRoot[rootID] = orderedDocument(rootedAt: root, cache: cache).index
}
var key = compactKey(node, in: indexByRoot[rootID] ?? [:]) ?? OrderKey()
key.rootRank = rank
return (node, key)
}
}
/// `node`'s order key within `index` (a pre-order numbering of one document's
/// tree nodes), or nil when `node` belongs to a different document. The band
/// orders an element before its namespace nodes before its attribute nodes
/// before its children (whose own index is greater), mirroring the bands of
/// the former path-key form.
private static func compactKey(_ node: PureXML.XPath.Node, in index: [PureXML.XPath.Node: Int]) -> OrderKey? {
switch node {
case .tree:
guard let position = index[node] else { return nil }
return OrderKey(index: position, band: 0)
case let .attribute(owner, attribute):
guard let position = index[.tree(owner)] else { return nil }
let attributeIndex = owner.attributes.firstIndex { $0.name == attribute.name } ?? 0
return OrderKey(index: position, band: 2, sub: attributeIndex)
case let .namespace(owner, prefix, _):
guard let position = index[.tree(owner)] else { return nil }
return OrderKey(index: position, band: 1, sub: prefix.hashValue)
}
}
}

View File

@ -158,31 +158,19 @@ public extension PureXML.XPath {
}
extension Sequence<PureXML.XPath.Node> {
/// The nodes sorted into document order, computing each node's order key once
/// (decorate-sort-undecorate) rather than recomputing the root path on every
/// comparison, which turns an O(n log n x depth) sort into O(n log n).
func sortedByDocumentOrder() -> [PureXML.XPath.Node] {
// Zero- and one-node sets are already sorted: the common predicate
// case must not touch order keys at all.
let nodes = Array(self)
if nodes.count <= 1 { return nodes }
let cache = PureXML.XPath.SiblingIndexCache()
return nodes.map { (node: $0, key: $0.documentOrder(cache: cache)) }
.sorted { PureXML.XPath.Node.ordered($0.key, before: $1.key) }
.map(\.node)
/// The nodes sorted into XPath document order. Keys come from a per-root
/// pre-order index (see ``PureXML/XPath/AxisNavigation/sortByDocumentOrder(_:cache:)``),
/// so the sort is linear in the document rather than O(n log n x depth): a
/// per-node root path made this quadratic on a deeply-nested document.
/// Passing the evaluation's shared `cache` builds each document's index once
/// and reuses it across every sort in the query.
func sortedByDocumentOrder(cache: PureXML.XPath.DocumentNavigationCache? = nil) -> [PureXML.XPath.Node] {
PureXML.XPath.AxisNavigation.sortByDocumentOrder(Array(self), cache: cache)
}
/// The first node in document order, computing each node's order key once.
func firstInDocumentOrder() -> PureXML.XPath.Node? {
let nodes = Array(self)
// Zero or one node needs no ordering: the single node is trivially first.
// This is the common case for string-value extraction (`string(@x)`,
// `value-of`), where computing a document-order key would needlessly scan
// the node's siblings, turning a flat fan-out quadratic.
guard nodes.count > 1 else { return nodes.first }
let cache = PureXML.XPath.SiblingIndexCache()
return nodes.map { (node: $0, key: $0.documentOrder(cache: cache)) }
.min { PureXML.XPath.Node.ordered($0.key, before: $1.key) }?
.node
/// The first node in document order, by the same keying as
/// ``sortedByDocumentOrder(cache:)`` but without a full sort.
func firstInDocumentOrder(cache: PureXML.XPath.DocumentNavigationCache? = nil) -> PureXML.XPath.Node? {
PureXML.XPath.AxisNavigation.firstByDocumentOrder(Array(self), cache: cache)
}
}

View File

@ -0,0 +1,46 @@
import Testing
@testable import PureXML
/// XPath result ordering must not be quadratic in document depth. Sorting a
/// node-set into document order keyed each node by its root path, an array as
/// long as the node's depth, so ordering the descendants of a deeply-nested
/// document was O(n log n x depth). The order key is now a single O(1) index
/// from a per-root pre-order numbering, so the sort is linear in the document.
/// The time limit turns a regression back to the path-key form into a failure.
@Suite("XPath document-order scale")
struct XPathDocumentOrderScaleTests {
/// A document holding a chain of `depth` nested elements with a text leaf, as
/// a tree node. The document wrapper makes the top element a child, so `//a`
/// selects all `depth` of them.
private func deepTree(_ depth: Int) -> PureXML.Model.TreeNode {
var node: PureXML.Model.Node = .text("x")
for _ in 0 ..< depth {
node = .element(.init("a", children: [node]))
}
return PureXML.Model.TreeNode(.document([node]))
}
#if os(WASI)
@Test("ordering the descendants of a deep document is linear")
#else
@Test("ordering the descendants of a deep document is linear", .timeLimit(.minutes(1)))
#endif
func test_deepDescendantOrderingIsLinear() throws {
let depth = 50000
let tree = deepTree(depth)
let matches = try PureXML.XPath.Query("//a").nodes(over: tree)
#expect(matches.count == depth)
}
@Test("document order is preserved for mixed element, attribute, and child results")
func test_documentOrderUnchanged() throws {
// `//node()|//@*` over a small tree exercises the element/attribute bands
// of the order key; the result must be in document order.
let xml = "<r><a x=\"1\" y=\"2\"><b/></a><c/></r>"
let strings = try PureXML.XPath.Query("//*").strings(over: PureXML.parse(xml))
// Document order of the elements: r, a, b, c (by their start tags).
let names = try PureXML.XPath.Query("//*").elements(over: PureXML.parse(xml)).map(\.name.localName)
#expect(names == ["r", "a", "b", "c"])
#expect(strings.count == 4)
}
}