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.
This commit is contained in:
parent
cf8246a1cb
commit
52eee325bd
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
59
Tests/XPathFusedDescendantTests.swift
Normal file
59
Tests/XPathFusedDescendantTests.swift
Normal file
@ -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 =
|
||||
#"<r xmlns:k="urn:k"><a id="1">text-a<k:b id="2"/><!--c--><?pi go?></a><a id="3"><d><k:b id="4"/></d></a></r>"#
|
||||
|
||||
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) // <!--c-->
|
||||
#expect(try count("//processing-instruction()") == 1) // <?pi go?>
|
||||
#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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user