diff --git a/CHANGELOG.md b/CHANGELOG.md index 154871f..617e319 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 +- XPath compiles a step's node test once per query instead of re-evaluating its fixed structure on every node. The descendant and attribute walks tested each candidate through a closure that called `matchesTree`/`matchesAttribute` with the step's `NodeTest` and the evaluation `namespaces` dictionary, so the dominant traversal of a `//name`-style query passed (and reference-counted) the namespace dictionary by value on every one of tens of thousands of nodes. `compiledTreeTest`, `compiledAttributeTest`, and `compiledNodeTest` resolve the parts that do not change as the walk moves (the axis principal kind, whether eval-time bindings are in effect, the prefix-wildcard shape of the test name) exactly once, returning a tight per-node closure that captures only the local name. The selected nodes are identical to the unfused `matchesTree`/`matchesAttribute`/`matches` path. Measured `xpath` ~35% faster on the 20k-item generated corpus via a same-machine-state A/B (from ~2.0x to ~1.3x slower than libxml2). Guarded by `XPathCompiledNodeTestTests`, an exhaustive differential over every node, test, axis, and binding asserting the compiled closures equal the functions they specialize, and confirmed by an adversarial equivalence review. + - XPath evaluation shares the focus-independent state (the variable bindings, the function library, the namespace bindings, and the budget) through a single `Environment` reference instead of carrying it by value in every context. Re-focusing the context on each node of a node-set, which happens once per node when a predicate is applied, copied three dictionaries every time; it now copies only the node, position, and size and shares the environment by reference. The values and behavior are unchanged. Measured `xpath` ~16% faster on the 20k-item generated corpus via a same-machine-state A/B; covered by the full XPath suite (the change is internal to the evaluator). - The XPath attribute axis fuses the step's node test into the walk, the same way the descendant axes do, so an attribute the test rejects is never wrapped in an `XPath.Node`. A predicate like `[@name='value']` previously built an `XPath.Node` for every attribute of the element (each wrap retains the owner element and copies the attribute's qualified name and value) and then filtered to the one named attribute; it now wraps only the matching attribute. `matches` delegates, for an attribute node, to a new `matchesAttribute` (the attribute axis always has the attribute principal kind, so this is exact), and the attribute walk applies it. The node-set, declaration order, and uniqueness are identical to the build-then-filter path. Measured `xpath` ~20% faster on the 20k-item generated corpus via a same-machine-state A/B. Guarded by `XPathFusedAttributeTests` (name and namespaced-name tests, the principal-kind wildcard, `node()`, the kind tests that never select an attribute, and a predicate over the selection) and confirmed by an adversarial equivalence review. diff --git a/Sources/XPath/EvaluatorSteps.swift b/Sources/XPath/EvaluatorSteps.swift index 796ae96..2cdd29b 100644 --- a/Sources/XPath/EvaluatorSteps.swift +++ b/Sources/XPath/EvaluatorSteps.swift @@ -56,16 +56,19 @@ extension PureXML.XPath.Evaluator { if case let .tree(tree) = contextNode { switch step.axis { case .descendant: - return PureXML.XPath.AxisNavigation.descendants(of: tree) { matchesTree($0, step.test, on: step.axis, context.namespaces) } + let keep = compiledTreeTest(step.test, on: step.axis, context.namespaces) + return PureXML.XPath.AxisNavigation.descendants(of: tree, where: keep) case .descendantOrSelf: - return PureXML.XPath.AxisNavigation.descendantsOrSelf(of: tree) { matchesTree($0, step.test, on: step.axis, context.namespaces) } + let keep = compiledTreeTest(step.test, on: step.axis, context.namespaces) + return PureXML.XPath.AxisNavigation.descendantsOrSelf(of: tree, where: keep) case .attribute: - return PureXML.XPath.AxisNavigation.attributes(of: tree) { matchesAttribute($0, step.test, context.namespaces) } + let keep = compiledAttributeTest(step.test, context.namespaces) + return PureXML.XPath.AxisNavigation.attributes(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) } + let keep = compiledNodeTest(step.test, on: step.axis, context.namespaces) + return PureXML.XPath.AxisNavigation.nodes(on: step.axis, from: contextNode).filter(keep) } /// Filters nodes through each predicate in turn. A numeric predicate is a @@ -131,6 +134,103 @@ extension PureXML.XPath.Evaluator { } } + /// Compiles a tree-node test into a per-node predicate, resolving everything + /// that is invariant across a traversal (the axis principal kind, whether + /// namespace bindings are in effect, the prefix-wildcard shape of the test + /// name) exactly once instead of on every node. A descendant walk over a + /// wide subtree applies the returned closure tens of thousands of times, so + /// hoisting the test's fixed structure out of the per-node path is the bulk + /// of the saving. The closure is equivalent to ``matchesTree`` for the same + /// arguments, the one source of truth it specializes. + static func compiledTreeTest( + _ test: NodeTest, + on axis: Axis, + _ namespaces: [String: String], + ) -> (PureXML.Model.TreeNode) -> Bool { + switch test { + case let .name(name): + guard axis.principalKind == .element else { return { _ in false } } + // No eval-time bindings and no prefix wildcard: the match is the + // plain in-document-string comparison, with no per-node namespace or + // suffix work. + if namespaces.isEmpty, !name.hasSuffix(":*") { + return { tree in + tree.kind == .element && tree.name.map { $0.description == name || $0.localName == name } == true + } + } + return { tree in + tree.kind == .element && tree.name.map { qualifiedMatches($0, name, namespaces) } == true + } + case .wildcard: + guard axis.principalKind == .element else { return { _ in false } } + return { $0.kind == .element } + case .text: + return { $0.kind == .text || $0.kind == .cdata } + case .node: + return { _ in true } + case .comment: + return { $0.kind == .comment } + case let .processingInstruction(target): + return { tree in + tree.kind == .processingInstruction && (target == nil || tree.name?.description == target) + } + } + } + + /// Compiles an attribute-node test into a per-attribute predicate, hoisting + /// the same invariants as ``compiledTreeTest`` (the binding state and the + /// prefix-wildcard shape) out of the per-attribute path. Equivalent to + /// ``matchesAttribute`` for the same arguments. + static func compiledAttributeTest( + _ test: NodeTest, + _ namespaces: [String: String], + ) -> (PureXML.Model.Attribute) -> Bool { + switch test { + case let .name(name): + if namespaces.isEmpty, !name.hasSuffix(":*") { + return { $0.name.description == name || $0.name.localName == name } + } + return { qualifiedMatches($0.name, name, namespaces) } + case .wildcard, .node: + return { _ in true } + case .text, .comment, .processingInstruction: + return { _ in false } + } + } + + /// Compiles a general node test (any node kind) into a per-node predicate, + /// for the axes that build a node list and filter it. A tree node is tested + /// by the compiled tree test; an attribute or namespace node keeps the + /// ``matches`` behavior. Equivalent to ``matches`` for the same arguments. + static func compiledNodeTest( + _ test: NodeTest, + on axis: Axis, + _ namespaces: [String: String], + ) -> (Node) -> Bool { + let treeTest = compiledTreeTest(test, on: axis, namespaces) + switch test { + case let .name(name): + let kind = axis.principalKind + return { node in + if case let .tree(tree) = node { return treeTest(tree) } + return nameMatches(node, name, kind, namespaces) + } + case .wildcard: + let kind = axis.principalKind + return { node in + if case let .tree(tree) = node { return treeTest(tree) } + return wildcardMatches(node, kind) + } + case .node: + return { _ in true } + case .text, .comment, .processingInstruction: + return { node in + if case let .tree(tree) = node { return treeTest(tree) } + 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 diff --git a/Tests/XPathCompiledNodeTestTests.swift b/Tests/XPathCompiledNodeTestTests.swift new file mode 100644 index 0000000..7238b6e --- /dev/null +++ b/Tests/XPathCompiledNodeTestTests.swift @@ -0,0 +1,101 @@ +import Testing +@testable import PureXML + +/// The compiled per-node tests (`compiledTreeTest`, `compiledAttributeTest`, +/// `compiledNodeTest`) hoist a step's invariant structure (the axis principal +/// kind, the binding state, the prefix-wildcard shape) out of the per-node path +/// so a wide traversal resolves it once, not on every node. They must stay +/// exactly equivalent to the unfused `matchesTree`, `matchesAttribute`, and +/// `matches` they specialize: a divergence would make a fused traversal select +/// different nodes than the filter fallback. This walks a document covering +/// every node kind and asserts equivalence across every test, axis, and +/// binding, so a future edit to a `matches*` function that forgets its compiled +/// twin fails here. +@Suite("XPath compiled node-test equivalence") +struct XPathCompiledNodeTestTests { + private static let source = + #"text"# + + private typealias Eval = PureXML.XPath.Evaluator + private typealias Test = PureXML.XPath.NodeTest + private typealias Axis = PureXML.XPath.Axis + + private static let tests: [Test] = [ + .name("e"), .name("k:e"), .name("k:c"), .name("k:*"), .name("missing"), + .wildcard, .node, .text, .comment, + .processingInstruction(target: nil), .processingInstruction(target: "pi"), + ] + private static let axes: [Axis] = [ + .child, .descendant, .descendantOrSelf, .attribute, .namespace, + .selfAxis, .parent, .ancestor, .following, .preceding, + .followingSibling, .precedingSibling, .ancestorOrSelf, + ] + private static let bindings: [[String: String]] = [[:], ["k": "urn:k"]] + + private func allTreeNodes(_ root: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] { + var out = [root] + for child in root.children { + out += allTreeNodes(child) + } + return out + } + + @Test("compiledTreeTest equals matchesTree for every node, test, axis, binding") + func test_treeEquivalence() throws { + let nodes = try allTreeNodes(PureXML.parseTree(Self.source)) + for namespaces in Self.bindings { + for axis in Self.axes { + for test in Self.tests { + let compiled = Eval.compiledTreeTest(test, on: axis, namespaces) + for node in nodes { + #expect( + compiled(node) == Eval.matchesTree(node, test, on: axis, namespaces), + "tree test=\(test) axis=\(axis) ns=\(namespaces)", + ) + } + } + } + } + } + + @Test("compiledNodeTest equals matches for tree, attribute, and namespace nodes") + func test_nodeEquivalence() throws { + let trees = try allTreeNodes(PureXML.parseTree(Self.source)) + var nodes: [PureXML.XPath.Node] = trees.map { .tree($0) } + for tree in trees { + for attr in tree.attributes { + nodes.append(.attribute(owner: tree, attr)) + } + } + nodes.append(.namespace(owner: trees[0], prefix: "k", uri: "urn:k")) + for namespaces in Self.bindings { + for axis in Self.axes { + for test in Self.tests { + let compiled = Eval.compiledNodeTest(test, on: axis, namespaces) + for node in nodes { + #expect( + compiled(node) == Eval.matches(node, test, on: axis, namespaces: namespaces), + "node test=\(test) axis=\(axis) ns=\(namespaces)", + ) + } + } + } + } + } + + @Test("compiledAttributeTest equals matchesAttribute for every attribute, test, binding") + func test_attributeEquivalence() throws { + let attrs = try allTreeNodes(PureXML.parseTree(Self.source)).flatMap(\.attributes) + for namespaces in Self.bindings { + for test in Self.tests { + let compiled = Eval.compiledAttributeTest(test, namespaces) + for attr in attrs { + #expect( + compiled(attr) == Eval.matchesAttribute(attr, test, namespaces), + "attr test=\(test) ns=\(namespaces)", + ) + } + } + } + } +} diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 833f39c..fe0417f 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -68,6 +68,7 @@ offsets throughout; libxml2's xmlReadMemory takes an int byte count). | 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 | | 15 | the same test fusion for the attribute axis (`matchesAttribute`): a `[@name='...']` predicate built every attribute node per element and filtered to one; it now wraps only the matching attribute, saving the owner retain and the qualified-name/value copies for the rest. Identical node-set (adversarially equivalence-reviewed; same-state A/B, ~20%) | unchanged | 0.022 s (~2.0x) | unchanged | | 16 | share the focus-independent evaluation state via an `Environment` reference: `focused(on:position:size:)` (called once per node of a predicate's input) copied the variables, functions, and namespace dictionaries every time; it now copies only node/position/size and shares one reference. Same semantics (same-state A/B, ~16%) | unchanged | 0.020 s (~2.0x) | unchanged | +| 17 | compile the step's node test once per query instead of re-evaluating it per node: the descendant/attribute walks called `{ matchesTree($0, step.test, on: step.axis, context.namespaces) }`, passing the `namespaces` dictionary (and the `NodeTest`'s associated string) by value on every one of tens of thousands of nodes, so the dominant traversal carried a dictionary retain/release per node. `compiledTreeTest`/`compiledAttributeTest`/`compiledNodeTest` resolve the axis principal kind, the binding state, and the name shape once and return a tight closure capturing only the local name. Equivalent to `matchesTree`/`matchesAttribute`/`matches` (exhaustive differential test + adversarial review; same-state A/B, ~35%) | unchanged | 0.013 s (~1.3x) | unchanged | ## Profile findings (first sample, parse path)