fix(xpath): skip the document-order key for single-node string extraction (#299)
firstInDocumentOrder() computed a document-order key even for a zero- or one-node set. The key walks a node up to the root, taking its index among each parent's children, which is a linear scan of a wide parent. string(@x) and value-of take the string-value of a (usually single) node-set through this path, so an XSLT transform over a wide flat fan-out rescanned the wide parent once per extraction, turning the transform quadratic. For a set of zero or one node the document order is irrelevant: the node is trivially first. Return it directly without computing the key, the same count <= 1 early-out sortedByDocumentOrder() already has. A transform reading a couple of attributes and child values per element over a 20k-element document drops from ~2.3s to ~0.65s (~3.5x). The output is unchanged: the full suite, the Apache Xalan XSLT gold-output corpus, the W3C XPath/XSLT/EXSLT tests, and the WASM build stay green, and FirstInDocumentOrderTests pins the single-node fast path and the multi-node document-first selection.
This commit is contained in:
parent
1ae71c325d
commit
9032d61950
@ -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
|
||||
|
||||
@ -174,8 +174,14 @@ extension Sequence<PureXML.XPath.Node> {
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
52
Tests/FirstInDocumentOrderTests.swift
Normal file
52
Tests/FirstInDocumentOrderTests.swift
Normal file
@ -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("<r><a/></r>")
|
||||
#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("<r><a>first</a><b>second</b><c>third</c></r>")
|
||||
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 += "<item id=\"i\(index)\"/>"
|
||||
}
|
||||
let tree = try PureXML.parseTree("<catalog>\(items)</catalog>")
|
||||
let value = try PureXML.XPath.Query("string(/catalog/item[300]/@id)").value(at: tree).string
|
||||
#expect(value == "i299")
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user