diff --git a/CHANGELOG.md b/CHANGELOG.md index 71666d2..be9fcd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Extracting the string-value of a node-set no longer scans the node's siblings, which made an XSLT transform (or any repeated `string(@x)` / `value-of`) over a wide flat fan-out quadratic. `firstInDocumentOrder()` computed a document-order key even for a zero- or one-node set, and that key walks the node up to the root taking its index among each parent's children, a linear scan of a wide parent. For a single node the order is irrelevant (the node is trivially first), so it now returns directly without computing the key. A transform that reads a couple of attributes and child values per element over a 20k-element document drops from about 2.3 s to about 0.65 s (~3.5x); the produced output is unchanged (the Apache Xalan gold-output corpus stays byte-identical). Guarded by `FirstInDocumentOrderTests`. + ## [0.4.2] - 2026-06-25 ### Changed diff --git a/Sources/XPath/XPathNode.swift b/Sources/XPath/XPathNode.swift index 3b86fe0..714b499 100644 --- a/Sources/XPath/XPathNode.swift +++ b/Sources/XPath/XPathNode.swift @@ -174,8 +174,14 @@ extension Sequence { /// The first node in document order, computing each node's order key once. func firstInDocumentOrder() -> PureXML.XPath.Node? { + let nodes = Array(self) + // Zero or one node needs no ordering: the single node is trivially first. + // This is the common case for string-value extraction (`string(@x)`, + // `value-of`), where computing a document-order key would needlessly scan + // the node's siblings, turning a flat fan-out quadratic. + guard nodes.count > 1 else { return nodes.first } let cache = PureXML.XPath.SiblingIndexCache() - return map { (node: $0, key: $0.documentOrder(cache: cache)) } + return nodes.map { (node: $0, key: $0.documentOrder(cache: cache)) } .min { PureXML.XPath.Node.ordered($0.key, before: $1.key) }? .node } diff --git a/Tests/FirstInDocumentOrderTests.swift b/Tests/FirstInDocumentOrderTests.swift new file mode 100644 index 0000000..4098ff7 --- /dev/null +++ b/Tests/FirstInDocumentOrderTests.swift @@ -0,0 +1,52 @@ +import Testing +@testable import PureXML + +/// `firstInDocumentOrder()` returns the document-order minimum of a node-set. A +/// zero- or one-node set needs no ordering at all, so it skips the order-key +/// computation, which would otherwise scan the node's siblings: that scan over a +/// wide fan-out is what made single-node string extraction (`string(@x)`, +/// `value-of`) quadratic in an XSLT transform. These pin the trivial fast path +/// and that the multi-node path still picks the true document-order first. +@Suite("firstInDocumentOrder single-node fast path") +struct FirstInDocumentOrderTests { + private func elements(_ xml: String) throws -> [PureXML.Model.TreeNode] { + let root = try PureXML.parseTree(xml) + guard let top = root.children.first(where: { $0.kind == .element }) else { return [] } + return top.children.filter { $0.kind == .element } + } + + @Test("an empty set has no first node") + func test_empty() { + let nodes: [PureXML.XPath.Node] = [] + #expect(nodes.firstInDocumentOrder() == nil) + } + + @Test("a single-node set returns that node without ordering") + func test_single() throws { + let kids = try elements("") + #expect([PureXML.XPath.Node.tree(kids[0])].firstInDocumentOrder() == .tree(kids[0])) + } + + @Test("string-value picks the document-order-first of a multi-node set") + func test_multiDocumentFirst() throws { + // A union deliberately formed out of document order; `string()` takes the + // value of the document-first node, which `firstInDocumentOrder` selects, + // so this exercises the multi-node path past the single-node fast path. + let tree = try PureXML.parseTree("firstsecondthird") + let value = try PureXML.XPath.Query("string(//c | //a | //b)").value(at: tree).string + #expect(value == "first") + } + + @Test("the string-value of a single-node path over a wide tree is correct") + func test_wideStringValue() throws { + // A wide flat fan-out is exactly where the old per-node sibling scan went + // quadratic; the value must be correct (and now linear to obtain). + var items = "" + for index in 0 ..< 500 { + items += "" + } + let tree = try PureXML.parseTree("\(items)") + let value = try PureXML.XPath.Query("string(/catalog/item[300]/@id)").value(at: tree).string + #expect(value == "i299") + } +} diff --git a/docs/benchmarks.md b/docs/benchmarks.md index a41d472..00f1cbb 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -104,3 +104,16 @@ patched. before/after recorded here. - The corpus generator changes only with a note here, since ratios are only comparable over identical corpora. + +## XSLT transform (quadratic fix, not in the parse/serialize/xpath series) + +A representative transform (select `item[@kind='even']` over the 20k-item +corpus, emit two attributes and two child values per item) was quadratic: +`firstInDocumentOrder()`, used to take the string-value of a single-node set +(`string(@id)`, `value-of select="name"`), computed a document-order key for +the one node, and that key scans the node's siblings, a linear pass over the +wide parent. Tens of thousands of single-node extractions each rescanned the +20k-wide parent. Returning the lone node directly (its order is irrelevant) +makes it linear: ~2.3 s -> ~0.65 s (~3.5x), output byte-identical (Xalan +gold-output verified). The fix is the same `count <= 1` early-out that +`sortedByDocumentOrder()` already had.