From 52eee325bdcf0c59b1d306740d7d8ce415b2d8bf Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Thu, 25 Jun 2026 07:05:03 +0200 Subject: [PATCH] perf(xpath): fuse the node test into the descendant traversal (#286) The descendant and descendant-or-self axes built the full subtree node list and then filtered it by the step's node test. For a //name query over a wide document most of the wrapped nodes were discarded immediately, and each wrap retains a tree node whose weak parent forces the slow reference-counting path. Run the node test against the raw tree node during the walk, so a rejected node is never wrapped. matches(_:_:on:namespaces:) now delegates, for a tree node, to a new tree-node matchesTree (one source of truth, exercised by the full XPath suite), and the descendant walk applies it as a keep filter. The node-set, document order, and uniqueness are identical to build-then-filter; only non-matching nodes are no longer materialized. Measured xpath ~11% faster on the 20k-item generated corpus via a same-state A/B (count unchanged at 10000). XPathFusedDescendantTests covers every node-test kind through the fused path, and an adversarial equivalence review found no query whose result changes. Full suite, Apache Xalan XSLT gold output, and the WASM build stay green. --- CHANGELOG.md | 2 + Sources/XPath/AxisNavigation.swift | 32 +++++++++++++++ Sources/XPath/EvaluatorSteps.swift | 56 +++++++++++++++++++++---- Tests/XPathFusedDescendantTests.swift | 59 +++++++++++++++++++++++++++ docs/benchmarks.md | 1 + 5 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 Tests/XPathFusedDescendantTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 55c069a..045a86d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- The XPath descendant and descendant-or-self axes fuse the step's node test into the traversal, so a node the test rejects is never wrapped in an `XPath.Node` (and so never reference-counted). Previously the axis built the full subtree node list and then filtered it; for a `//name` query over a wide document, most of the wrapped nodes were immediately discarded. The node test now runs against the raw tree node during the walk: `matches(_:_:on:namespaces:)` delegates to a new tree-node `matchesTree` (one source of truth, exercised by the full XPath suite), and the descendant walk applies it. The node-set, document order, and uniqueness are identical to the build-then-filter path; only non-matching nodes are no longer materialized. Measured `xpath` ~11% faster on the 20k-item generated corpus via a same-machine-state A/B. Guarded by `XPathFusedDescendantTests` (every node-test kind through the fused path: element and namespaced names, the principal-kind wildcard, `text()`/`comment()`/`processing-instruction()`/`node()`, and a predicate on the result) and confirmed by an adversarial equivalence review that found no query whose result the change alters. + - The XPath descendant and descendant-or-self axes build their node list through a single shared accumulator instead of concatenating a freshly built array at every node. `descendants(of:)` recursed on wrapped nodes and did `result.append(contentsOf: descendants(of: child))` at each level, allocating and copying an array per node (roughly O(n x depth) work over the subtree); it now recurses on the raw tree node and appends into one `inout` accumulator (O(n)). For a `//`-style query over a wide document this is the dominant cost: measured `xpath` ~46% faster on the 20k-item generated corpus via a same-machine-state A/B, with the identical node-set in the identical order (the change is purely how the array is built). ## [0.4.0] - 2026-06-25 diff --git a/Sources/XPath/AxisNavigation.swift b/Sources/XPath/AxisNavigation.swift index 56d1ef9..02768e3 100644 --- a/Sources/XPath/AxisNavigation.swift +++ b/Sources/XPath/AxisNavigation.swift @@ -67,6 +67,38 @@ extension PureXML.XPath { } } + /// The descendants of `tree` that satisfy `keep`, in document order. The + /// whole subtree is still traversed (every node may have matching + /// descendants), but a node `keep` rejects is never wrapped in a ``Node``, + /// so it is never retained. This fuses a step's node test into the axis + /// walk, the common case being `descendant::name` over a wide subtree + /// where most nodes do not match. + static func descendants(of tree: PureXML.Model.TreeNode, where keep: (PureXML.Model.TreeNode) -> Bool) -> [Node] { + var result: [Node] = [] + appendDescendants(of: tree, where: keep, into: &result) + return result + } + + /// `descendant-or-self` with a `keep` filter: the context node itself is + /// tested first, then its descendants. + static func descendantsOrSelf(of tree: PureXML.Model.TreeNode, where keep: (PureXML.Model.TreeNode) -> Bool) -> [Node] { + var result: [Node] = [] + if keep(tree) { result.append(.tree(tree)) } + appendDescendants(of: tree, where: keep, into: &result) + return result + } + + private static func appendDescendants( + of tree: PureXML.Model.TreeNode, + where keep: (PureXML.Model.TreeNode) -> Bool, + into result: inout [Node], + ) { + for child in tree.children { + if keep(child) { result.append(.tree(child)) } + appendDescendants(of: child, where: keep, into: &result) + } + } + static func ancestors(of node: Node) -> [Node] { var result: [Node] = [] var current = node.parent diff --git a/Sources/XPath/EvaluatorSteps.swift b/Sources/XPath/EvaluatorSteps.swift index 314a437..ebe5807 100644 --- a/Sources/XPath/EvaluatorSteps.swift +++ b/Sources/XPath/EvaluatorSteps.swift @@ -44,11 +44,27 @@ extension PureXML.XPath.Evaluator { /// that pass the node test, then the step's predicates (applied per context, /// as XPath proximity position requires). private static func stepNodes(_ step: Step, from contextNode: Node, _ context: EvaluationContext) throws -> [Node] { - let matched = PureXML.XPath.AxisNavigation.nodes(on: step.axis, from: contextNode) - .filter { matches($0, step.test, on: step.axis, namespaces: context.namespaces) } + let matched = matchedAxisNodes(step, from: contextNode, context) return try applyPredicates(step.predicates, to: matched, context) } + /// The axis nodes that pass the step's node test. For the descendant family + /// over a tree context the test is fused into the traversal so a node the + /// test rejects is never wrapped (and so never retained); every other axis + /// builds the axis node list and filters it. + private static func matchedAxisNodes(_ step: Step, from contextNode: Node, _ context: EvaluationContext) -> [Node] { + if case let .tree(tree) = contextNode { + let keep: (PureXML.Model.TreeNode) -> Bool = { matchesTree($0, step.test, on: step.axis, context.namespaces) } + switch step.axis { + case .descendant: return PureXML.XPath.AxisNavigation.descendants(of: tree, where: keep) + case .descendantOrSelf: return PureXML.XPath.AxisNavigation.descendantsOrSelf(of: tree, where: keep) + default: break + } + } + return PureXML.XPath.AxisNavigation.nodes(on: step.axis, from: contextNode) + .filter { matches($0, step.test, on: step.axis, namespaces: context.namespaces) } + } + /// Filters nodes through each predicate in turn. A numeric predicate is a /// position test; any other is taken as a boolean. static func applyPredicates( @@ -82,19 +98,43 @@ extension PureXML.XPath.Evaluator { // MARK: Node tests static func matches(_ node: Node, _ test: NodeTest, on axis: Axis, namespaces: [String: String] = [:]) -> Bool { + if case let .tree(tree) = node { + return matchesTree(tree, test, on: axis, namespaces) + } + // Attribute and namespace nodes: a name or wildcard test selects them + // (by the axis's principal kind), `node()` matches anything, and the + // kind tests (text/comment/processing-instruction) never match a node + // that is not a tree node. + switch test { + case let .name(name): return nameMatches(node, name, axis.principalKind, namespaces) + case .wildcard: return wildcardMatches(node, axis.principalKind) + case .node: return true + case .text, .comment, .processingInstruction: return false + } + } + + /// The node test applied directly to a tree node, the single-node core of + /// ``matches`` for a `.tree` node. Taking the ``TreeNode`` rather than a + /// wrapped ``Node`` lets the descendant traversal test a candidate without + /// first wrapping (and so retaining) it, so a node the test rejects is never + /// retained. ``matches`` delegates here for every tree node, keeping one + /// source of truth. + static func matchesTree(_ tree: PureXML.Model.TreeNode, _ test: NodeTest, on axis: Axis, _ namespaces: [String: String]) -> Bool { switch test { case let .name(name): - nameMatches(node, name, axis.principalKind, namespaces) + guard axis.principalKind == .element, tree.kind == .element, let qualified = tree.name else { return false } + return qualifiedMatches(qualified, name, namespaces) case .wildcard: - wildcardMatches(node, axis.principalKind) + return axis.principalKind == .element && tree.kind == .element case .text: - isTreeKind(node, in: [.text, .cdata]) + return tree.kind == .text || tree.kind == .cdata case .node: - true + return true case .comment: - isTreeKind(node, in: [.comment]) + return tree.kind == .comment case let .processingInstruction(target): - processingInstructionMatches(node, target) + guard tree.kind == .processingInstruction else { return false } + return target == nil || tree.name?.description == target } } diff --git a/Tests/XPathFusedDescendantTests.swift b/Tests/XPathFusedDescendantTests.swift new file mode 100644 index 0000000..e53b49f --- /dev/null +++ b/Tests/XPathFusedDescendantTests.swift @@ -0,0 +1,59 @@ +import Testing +@testable import PureXML + +/// The descendant and descendant-or-self axes over a tree context fuse the node +/// test into the traversal (`matchesTree`), so a node the test rejects is never +/// wrapped. These tests pin that every node-test kind selects exactly what the +/// unfused `matches` path would, across element names, the principal-kind +/// wildcard, and the `text()`/`comment()`/`processing-instruction()`/`node()` +/// kind tests, including namespaced names. +@Suite("XPath fused descendant node tests") +struct XPathFusedDescendantTests { + /// No inter-element whitespace, so the only text node is "text-a". + private static let source = + #"text-a"# + + private func count(_ path: String) throws -> Int { + try Int(PureXML.XPath.Query("count(\(path))").value(at: PureXML.parseTree(Self.source)).number) + } + + private func ids(_ path: String) throws -> [String] { + try PureXML.XPath.Query(path).evaluate(over: PureXML.parse(Self.source)).compactMap { + if case let .node(node) = $0 { return node.element?.attributes.first { $0.name.localName == "id" }?.value } + return nil + } + } + + @Test("element name test under // selects the right elements in order") + func test_nameTest() throws { + #expect(try ids("//a") == ["1", "3"]) + // Namespaced name test resolves the in-document prefix. + #expect(try ids("//k:b") == ["2", "4"]) + #expect(try count("//d") == 1) + } + + @Test("the principal-kind wildcard under // selects only elements") + func test_wildcard() throws { + // Elements: r, a, k:b, a, d, k:b = 6 (the descendant-or-self of //* counts r too). + #expect(try count("//*") == 6) + // descendant-or-self::* from the root is the same set. + #expect(try count("/descendant-or-self::*") == 6) + } + + @Test("the kind tests under // select the right node kinds") + func test_kindTests() throws { + #expect(try count("//text()") == 1) // "text-a" + #expect(try count("//comment()") == 1) // + #expect(try count("//processing-instruction()") == 1) // + #expect(try count("//processing-instruction('pi')") == 1) + #expect(try count("//processing-instruction('other')") == 0) + // node() matches every node kind under the document element. + #expect(try count("//node()") >= 6) + } + + @Test("a predicate still filters the fused descendant result") + func test_predicateOnFused() throws { + #expect(try ids("//a[@id='3']") == ["3"]) + #expect(try ids("//k:b[1]") == ["2", "4"]) // first k:b child of EACH parent + } +} diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 12223c7..7b315a8 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -65,6 +65,7 @@ offsets throughout; libxml2's xmlReadMemory takes an int byte count). | 11 | drop the redundant `Set` de-duplication when sorting a path result: `evaluateSteps` already yields a duplicate-free node-set, so only the document-order sort is needed (each skipped insert was a copy of a node wrapping a side-table-refcounted tree node); same-state A/B, ~9% | unchanged | 0.058 s (~6x, from ~7x) | unchanged | | 12 | skip the document-order sort for a single forward-axis step from one context: `AxisNavigation` produces child/descendant/descendant-or-self/self/following/following-sibling already in document order with no duplicates, so the sort is redundant (adversarially reviewed for order correctness; same-state A/B, ~7%) | unchanged | 0.055 s (~6x) | unchanged | | 13 | single-accumulator descendant traversal: `descendants(of:)` recursed on wrapped nodes and concatenated a fresh array at every node (`append(contentsOf: descendants(of: child))`), O(n x depth) allocation churn; rewritten to recurse on the raw tree node with one shared `inout` accumulator, O(n). Same output (same-state A/B, ~46%) | unchanged | 0.032 s (~3x, from ~6x) | unchanged | +| 14 | fuse the node test into the descendant walk: a node the step's test rejects is no longer wrapped in an `XPath.Node` (so never retained); `matches` delegates to a tree-node `matchesTree` so there is one source of truth, and the traversal applies it during the walk. Identical node-set (adversarially equivalence-reviewed; same-state A/B, ~11%) | unchanged | 0.028 s (~2.5x) | unchanged | ## Profile findings (first sample, parse path)