perf(xpath): share value-Node projections across a result set (#349)
Mapping an ordered node-set to selections rebuilt each result as an independent value Node through TreeNode.node, projecting that node's whole subtree. For a result set whose nodes nest within one another (`//a` over a deep chain, where every `a` contains all deeper ones) this materialized the sum of their subtrees: quadratic. `//a` over an 8000-deep chain took ~19s. The projection now threads a memo keyed by tree-node identity across one result set. A tree node is projected once; a result that nests a deeper one reuses its already-built value Node by reference, which the copy-on-write Element storage (from the value-Node depth-safety work) makes a shared, not copied, subtree. The set is built in one pass over the distinct nodes. The same query now returns in ~0.04s and scales linearly. The non-memoized `node` projection (serialization, value comparison) is unchanged. Results are identical: 1787 tests pass, including the XPath and XSLT value corpora. Closes #348.
This commit is contained in:
parent
3e4f596b7c
commit
b672c49a84
@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- The value-returning XPath result API (`PureXML.xpath`, `XPath.Query.evaluate`/`elements`/`strings`) is now linear for results that nest within one another, not quadratic. Each result was rebuilt as an independent value `Node` subtree, so selecting a deep chain of nested elements materialized the sum of their subtrees. The projection now shares a per-result-set memo keyed by tree-node identity, so a result reuses the already-built value `Node` of a deeper one by reference (copy-on-write); each tree node is projected once. `//a` over a 8000-deep chain drops from about 19 s to about 0.04 s, and the result set is unchanged. (#348)
|
||||
|
||||
- Sorting an XPath result into document order is now linear in the document, not quadratic in its depth. Each node's order key was its root path, an array as long as the node's depth, so ordering the descendants of a deeply-nested document was O(n log n x depth). The key is now a single O(1) index from a per-root pre-order numbering (built once per document and shared through the evaluation cache), with a band ordering an element before its namespace and attribute nodes and its children. A node-set spanning several documents is grouped by root. This linearizes the reference query path (`Query.nodes`, which XSLT and Schematron drive) on deep documents; the underlying subtree walk (`appendSubtree`) is now iterative too, so it no longer recurses. Document order is unchanged.
|
||||
|
||||
- Parsing is now linear in nesting depth, not quadratic. Resolving each open element's name and attributes looked up namespace bindings by walking a scope stack as deep as the document, so a deeply-nested document was O(depth^2): a 40000-deep document took tens of seconds. The parser now maintains the current in-scope bindings incrementally (a single map plus a per-element undo list), so a lookup is O(1) and parsing is O(n). The same document parses in a fraction of a second, with no change to resolved namespaces. (#345)
|
||||
|
||||
@ -182,6 +182,85 @@ public extension PureXML.Model.TreeNode {
|
||||
parent.built.append(.document(frame.built))
|
||||
}
|
||||
}
|
||||
|
||||
/// Like ``node`` but reuses subtrees already projected during this pass through
|
||||
/// `memo` (keyed by tree-node identity). Projecting several result nodes that
|
||||
/// nest within one another then shares structure (copy-on-write) instead of
|
||||
/// rebuilding each subtree, so a result set of nested nodes is linear rather
|
||||
/// than quadratic. The projection is identical to ``node``.
|
||||
func node(memo: inout [ObjectIdentifier: PureXML.Model.Node]) -> PureXML.Model.Node {
|
||||
if let cached = memo[ObjectIdentifier(self)] { return cached }
|
||||
let result: PureXML.Model.Node = switch kind {
|
||||
case .document:
|
||||
.document(Self.projectedChildren(of: self, memo: &memo))
|
||||
case .element:
|
||||
.element(PureXML.Model.Element(
|
||||
name: name ?? PureXML.Model.QualifiedName(""),
|
||||
attributes: attributes,
|
||||
children: Self.projectedChildren(of: self, memo: &memo),
|
||||
))
|
||||
case .text: .text(value)
|
||||
case .entityReference: .text(stringValue)
|
||||
case .cdata: .cdata(value)
|
||||
case .comment: .comment(value)
|
||||
case .processingInstruction: .processingInstruction(target: name?.description ?? "", data: value)
|
||||
case .doctype, .namespace: .text("")
|
||||
}
|
||||
memo[ObjectIdentifier(self)] = result
|
||||
return result
|
||||
}
|
||||
|
||||
/// ``projectedChildren(of:)`` that caches each element/document subtree in
|
||||
/// `memo` and reuses an already-projected child instead of walking it again.
|
||||
private static func projectedChildren(of root: PureXML.Model.TreeNode, memo: inout [ObjectIdentifier: PureXML.Model.Node]) -> [PureXML.Model.Node] {
|
||||
let rootFrame = ProjectFrame(root, splice: false)
|
||||
var stack: [ProjectFrame] = [rootFrame]
|
||||
while let frame = stack.last {
|
||||
guard frame.next < frame.tree.children.count else {
|
||||
stack.removeLast()
|
||||
guard let parent = stack.last else { break }
|
||||
finishProjection(frame, into: parent, memo: &memo)
|
||||
continue
|
||||
}
|
||||
let child = frame.tree.children[frame.next]
|
||||
frame.next += 1
|
||||
switch child.kind {
|
||||
case .doctype, .namespace:
|
||||
continue
|
||||
case .entityReference:
|
||||
stack.append(ProjectFrame(child, splice: true))
|
||||
case .element, .document:
|
||||
if let cached = memo[ObjectIdentifier(child)] {
|
||||
frame.built.append(cached)
|
||||
} else {
|
||||
stack.append(ProjectFrame(child, splice: false))
|
||||
}
|
||||
default:
|
||||
frame.built.append(projectLeaf(child))
|
||||
}
|
||||
}
|
||||
return rootFrame.built
|
||||
}
|
||||
|
||||
/// ``finishProjection(_:into:)`` that also records an element or document
|
||||
/// frame's projected node in `memo` for reuse.
|
||||
private static func finishProjection(_ frame: ProjectFrame, into parent: ProjectFrame, memo: inout [ObjectIdentifier: PureXML.Model.Node]) {
|
||||
if frame.splice {
|
||||
parent.built.append(contentsOf: frame.built)
|
||||
return
|
||||
}
|
||||
let node: PureXML.Model.Node = if case .element = frame.tree.kind {
|
||||
.element(PureXML.Model.Element(
|
||||
name: frame.tree.name ?? PureXML.Model.QualifiedName(""),
|
||||
attributes: frame.tree.attributes,
|
||||
children: frame.built,
|
||||
))
|
||||
} else {
|
||||
.document(frame.built)
|
||||
}
|
||||
memo[ObjectIdentifier(frame.tree)] = node
|
||||
parent.built.append(node)
|
||||
}
|
||||
}
|
||||
|
||||
/// A branch node being converted from a value ``Node`` to a ``TreeNode``: the
|
||||
|
||||
@ -17,7 +17,7 @@ extension PureXML.XPath {
|
||||
guard let value = try? eval(expression, context), case let .nodeSet(nodes) = value else {
|
||||
return []
|
||||
}
|
||||
return orderUnique(nodes).map(selection)
|
||||
return selections(orderUnique(nodes))
|
||||
}
|
||||
|
||||
/// Evaluates an expression to a typed value with optional variable bindings.
|
||||
@ -103,7 +103,7 @@ extension PureXML.XPath {
|
||||
guard let value = try? eval(expression, context), case let .nodeSet(nodes) = value else {
|
||||
return []
|
||||
}
|
||||
return orderUnique(nodes).map(selection)
|
||||
return selections(orderUnique(nodes))
|
||||
}
|
||||
|
||||
private static func rootContext(
|
||||
@ -221,10 +221,24 @@ extension PureXML.XPath {
|
||||
return current
|
||||
}
|
||||
|
||||
private static func selection(_ node: Node) -> Selection {
|
||||
/// Projects an ordered node-set to selections, sharing a projection memo
|
||||
/// so that result nodes nesting within one another reuse each other's
|
||||
/// already-built value `Node` subtrees rather than each rebuilding the
|
||||
/// whole subtree (which is quadratic for nested results).
|
||||
static func selections(_ nodes: [Node]) -> [Selection] {
|
||||
var memo: [ObjectIdentifier: PureXML.Model.Node] = [:]
|
||||
var result: [Selection] = []
|
||||
result.reserveCapacity(nodes.count)
|
||||
for node in nodes {
|
||||
result.append(selection(node, memo: &memo))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func selection(_ node: Node, memo: inout [ObjectIdentifier: PureXML.Model.Node]) -> Selection {
|
||||
switch node {
|
||||
case let .tree(tree):
|
||||
return .node(tree.node)
|
||||
return .node(tree.node(memo: &memo))
|
||||
case let .attribute(_, attribute):
|
||||
return .attribute(attribute)
|
||||
case let .namespace(_, prefix, uri):
|
||||
|
||||
@ -43,4 +43,38 @@ struct XPathDocumentOrderScaleTests {
|
||||
#expect(names == ["r", "a", "b", "c"])
|
||||
#expect(strings.count == 4)
|
||||
}
|
||||
|
||||
/// A value `Node` nesting `depth` `a` elements inside one another, as a document.
|
||||
private func deepValueDocument(_ depth: Int) -> PureXML.Model.Node {
|
||||
var node: PureXML.Model.Node = .text("leaf")
|
||||
for _ in 0 ..< depth {
|
||||
node = .element(.init("a", children: [node]))
|
||||
}
|
||||
return .document([node])
|
||||
}
|
||||
|
||||
#if os(WASI)
|
||||
@Test("the value-Node result API is linear for nested results")
|
||||
#else
|
||||
@Test("the value-Node result API is linear for nested results", .timeLimit(.minutes(1)))
|
||||
#endif
|
||||
func test_nestedValueResultsAreLinear() throws {
|
||||
// `//a` over a deep chain selects every `a`, each containing all deeper
|
||||
// ones. Without projection sharing, materializing them as value `Node`s is
|
||||
// the sum of their subtree sizes, which is quadratic (#348).
|
||||
let depth = 30000
|
||||
let document = deepValueDocument(depth)
|
||||
let results = try PureXML.XPath.Query("//a").evaluate(over: document)
|
||||
#expect(results.count == depth)
|
||||
// The outermost result still carries the full nesting, proving the shared
|
||||
// projection did not truncate any subtree: descending the first child from
|
||||
// the outermost `a` reaches all `depth` `a` elements.
|
||||
var element = results.first?.element
|
||||
var reachable = 0
|
||||
while let current = element {
|
||||
reachable += 1
|
||||
element = current.children.first?.element
|
||||
}
|
||||
#expect(reachable == depth)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user