From 2eb37418486b739f2e94e27cfac228c48602e20f Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Thu, 25 Jun 2026 01:33:50 +0200 Subject: [PATCH] perf(xpath): skip node-set de-duplication where no duplicate can arise (#280) Each location step accumulated its context nodes' axis results into a Set to drop nodes reachable from more than one context, hashing and copying every node. The wrapped TreeNode is reference-counted, so each copy is ARC traffic, and the profile showed the Set insert, hashing, and Node copies dominating evaluation. De-duplication is needed only when two distinct context nodes can reach the same node. Skip it on a single context and on the axes whose results are disjoint per context (child, attribute, namespace, self): those produce the identical node sequence the Set would, since every insertion succeeds, with no hashing. Keep the Set on the axes that genuinely overlap (descendant, ancestor, parent, the siblings, following/preceding). Predicates stay applied per context and the path result still passes through the final order-and-unique pass, so results are identical. Measured xpath ~13x to ~10x slower than libxml2 on the 20k-item generated corpus via a same-state A/B, ~28% faster, same result. XPathNodeSetDedupTests pins that a node shared by many contexts through ancestor/parent/descendant is still counted once, the disjoint axes return every distinct result, and an overlapping union still de-duplicates. The full XPath suite and the Apache Xalan XSLT gold-output corpus stay green. --- CHANGELOG.md | 2 ++ Sources/XPath/Axis.swift | 14 +++++++++ Sources/XPath/EvaluatorSteps.swift | 35 +++++++++++++++++----- Tests/XPathNodeSetDedupTests.swift | 47 ++++++++++++++++++++++++++++++ docs/benchmarks.md | 1 + 5 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 Tests/XPathNodeSetDedupTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b28913..32862bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- XPath location-step evaluation skips cross-context node-set de-duplication when no duplicate can arise (measured `xpath` ~13x -> ~10x slower than libxml2 on the 20k-item generated corpus via a same-machine-state A/B, ~28% faster, identical results). A step accumulated each context node's axis results into a `Set` to drop nodes reached from more than one context, which hashed and copied every node (the wrapped `TreeNode` reference is reference-counted, so each copy is ARC traffic). De-duplication is needed only when two distinct context nodes can reach the same node, so it is now skipped on a single context and on the axes whose results are disjoint per context (`child`, `attribute`, `namespace`, `self`); those produce the identical node sequence the `Set` would (every insertion succeeds) without the hashing. The `Set` is kept on the axes that genuinely overlap (`descendant`, `ancestor`, `parent`, the siblings, `following`/`preceding`). Predicates are applied per context (unchanged) and the path result still passes through the final order-and-unique pass, so semantics are preserved. Guarded by `XPathNodeSetDedupTests`, which checks that a node shared by many contexts through `ancestor`/`parent`/`descendant` is still counted once, that the disjoint axes return every distinct result, and that an overlapping union still de-duplicates; the full XPath suite and the Apache Xalan XSLT gold-output corpus stay green. + - Serialization roughly halved (measured `serialize` ~5.5x -> ~2.7x slower than libxml2 on the 20k-item generated corpus, `bash scripts/benchmark.sh 20000 5`, with byte-identical output). The escaping functions (`text`, `attribute`, `comment`, `processingInstruction`) rebuilt every value character by character even when nothing needed escaping, which is the common case. They now scan the value's UTF-8 bytes for the escapable bytes (markup characters, the active attribute quote, the whitespace characters, a comment hyphen, a PI question mark, and any non-ASCII byte in ASCII-only mode) and return the value unchanged when none is present, falling back to the original character path only when something must be escaped. Guarded by `EscapingFastPathTests`, which pins both the unchanged fast path and the escaped slow path for each function, and verified against the Apache Xalan XSLT gold-output corpus (a serialize-heavy differential). No output changed. - Attribute-value parsing skips the reference-decode pass when the value contains no `&`. The byte-run scanner already visits every value byte, so it now notes any `&`; an ampersand-free value (the common case) returns its normalized run directly instead of calling the entity decoder, which re-scanned the whole value for `&` before doing nothing. Measured against a same-machine-state baseline, every run came in below every baseline run (~4% parse improvement on the 20k-item generated corpus). The bulk byte-run scanners (`contentRunBytes`, `attributeRunBytes`) moved to `ReaderByteRuns.swift` with their cursor mutation centralized through a single `advanceByteRun` method, keeping `Reader`'s position fields write-encapsulated while the file stays under the length cap. The byte and Character paths are held to identical decoded values by `ParserPathDifferentialTests` (extended with ampersand-free, entity, and character-reference attribute cases); no spec behavior changed. diff --git a/Sources/XPath/Axis.swift b/Sources/XPath/Axis.swift index 0a33264..d4ed5d2 100644 --- a/Sources/XPath/Axis.swift +++ b/Sources/XPath/Axis.swift @@ -27,6 +27,20 @@ extension PureXML.XPath { } } + /// Whether the axis yields disjoint node-sets from distinct context nodes, + /// so accumulating its results across a step's context nodes can never + /// produce a duplicate and needs no cross-context de-duplication. A child, + /// attribute, or namespace node belongs to exactly one element, and the + /// self axis maps each distinct context to itself; every other axis + /// (descendant, ancestor, the siblings, following/preceding, parent) can + /// reach one node from two different contexts. + var yieldsDisjointResults: Bool { + switch self { + case .child, .attribute, .namespace, .selfAxis: true + default: false + } + } + /// The principal node kind of the axis: attributes for the attribute axis, /// namespaces for the namespace axis, elements otherwise. Governs what a /// name test or `*` selects. diff --git a/Sources/XPath/EvaluatorSteps.swift b/Sources/XPath/EvaluatorSteps.swift index ecfcd28..314a437 100644 --- a/Sources/XPath/EvaluatorSteps.swift +++ b/Sources/XPath/EvaluatorSteps.swift @@ -15,21 +15,40 @@ extension PureXML.XPath.Evaluator { var current = start for step in steps { var result: [Node] = [] - var seen: Set = [] - for contextNode in current { - let matched = PureXML.XPath.AxisNavigation.nodes(on: step.axis, from: contextNode) - .filter { matches($0, step.test, on: step.axis, namespaces: context.namespaces) } - let filtered = try applyPredicates(step.predicates, to: matched, context) - for node in filtered where seen.insert(node).inserted { - result.append(node) + // Cross-context de-duplication is needed only when two distinct + // context nodes can reach the same node. With at most one context, or + // on an axis whose results are disjoint per context (child, attribute, + // namespace, self), no duplicate can arise, so accumulate directly: + // the `Set` path below produces the identical sequence (every insert + // succeeds) at the cost of hashing and copying every node. + if current.count <= 1 || step.axis.yieldsDisjointResults { + for contextNode in current { + try result.append(contentsOf: stepNodes(step, from: contextNode, context)) + try context.checkBudget(result.count) + } + } else { + var seen: Set = [] + for contextNode in current { + for node in try stepNodes(step, from: contextNode, context) where seen.insert(node).inserted { + result.append(node) + } + try context.checkBudget(result.count) } - try context.checkBudget(result.count) } current = result } return current } + /// The nodes a single `step` selects from one `contextNode`: the axis nodes + /// 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) } + return try applyPredicates(step.predicates, to: matched, context) + } + /// Filters nodes through each predicate in turn. A numeric predicate is a /// position test; any other is taken as a boolean. static func applyPredicates( diff --git a/Tests/XPathNodeSetDedupTests.swift b/Tests/XPathNodeSetDedupTests.swift new file mode 100644 index 0000000..8dbf34b --- /dev/null +++ b/Tests/XPathNodeSetDedupTests.swift @@ -0,0 +1,47 @@ +import Testing +@testable import PureXML + +/// `evaluateSteps` skips cross-context node-set de-duplication on axes whose +/// results are disjoint per context (child, attribute, namespace, self) or when +/// there is a single context, and keeps it on the axes that can reach one node +/// from two contexts (ancestor, descendant, parent, the siblings, +/// following/preceding). These tests pin both directions: a shared node reached +/// from many contexts must appear exactly once, and a disjoint axis must still +/// return every distinct result. +@Suite("XPath node-set de-duplication") +struct XPathNodeSetDedupTests { + /// Two subtrees, each holding a `c`, both under `root`; `a` holds two of them. + private func doc() throws -> PureXML.Model.TreeNode { + try PureXML.parseTree("") + } + + private func count(_ path: String) throws -> Int { + try Int(PureXML.XPath.Query("count(\(path))").value(at: doc()).number) + } + + @Test("ancestor axis de-duplicates a node shared by many contexts") + func test_ancestorDedup() throws { + // Three `c` nodes all have `root` as an ancestor; it must be counted once. + #expect(try count("//c/ancestor::root") == 1) + // Two `c` nodes under `a` share `a` as parent; `a` counted once. + #expect(try count("/root/a/c/parent::a") == 1) + // descendant from multiple contexts: every `c` reached once. + #expect(try count("/root/*/descendant-or-self::c") == 3) + } + + @Test("disjoint axes return every distinct result") + func test_disjointAxesComplete() throws { + // child from multiple contexts: all three `c` children. + #expect(try count("/root/*/child::c") == 3) + // self from the three distinct `c` contexts: three nodes. + #expect(try count("//c/self::c") == 3) + // The whole node test still works under //. + #expect(try count("//c") == 3) + } + + @Test("a union of overlapping paths still de-duplicates") + func test_unionDedup() throws { + // `//c` and `//a//c` overlap on the two `c` under `a`; union is 3, not 5. + #expect(try count("//c | //a//c") == 3) + } +} diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 67df05c..317f10f 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -60,6 +60,7 @@ offsets throughout; libxml2's xmlReadMemory takes an int byte count). | 6 | byte-level markup dispatch (the single byte after `<` selects end-tag/PI/declaration/start-tag, replacing up to five literal string comparisons per element with one byte peek) | 0.147 s (9.0x) | unchanged | unchanged | | 7 | `sawAmpersand` short-circuit for attribute values (the byte scanner notes any `&`; an ampersand-free value skips the reference-decode pass and its full re-scan); measured against same-state baseline, every run below every baseline run | 0.143 s (~4% vs baseline) | unchanged | unchanged | | 8 | serialize escaping fast path (text/attribute/comment/PI return the value unchanged when no character needs escaping, instead of rebuilding it character by character; byte-level scan for the escapable bytes) | unchanged | unchanged | 0.032 s (2.7x, from 5.5x) | +| 9 | skip cross-context node-set de-duplication on a single context or a disjoint axis (child/attribute/namespace/self); the `Set` hashing and copying was pure overhead where no duplicate can arise, same output (same-state A/B, ~28%) | unchanged | 0.097 s (10x, from 13x) | unchanged | ## Profile findings (first sample, parse path)