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)