perf(xpath): fuse the node test into the following/preceding walk (#303)
After #302 cached the document node list, the following and preceding axes still materialized the entire following/preceding span (Array(slice), one Node copy with a slow side-table retain per node) before the step's node test filtered it. For a rare-target query that copy is almost entirely waste. Fuse the node test into the walk: apply the test to the cached document-order slice directly, so a node the test rejects is never copied out of the shared list, only the matches are materialized. The selected nodes and their order are unchanged (the test is the same compiled node test the general axis path used). The 200-context following:: query over a 20k-element document drops from ~9s (post-#302) to ~6s, ~2.6x below the original ~16s. The full suite, the W3C XPath/XSLT and Apache Xalan corpora, and the WASM build stay green; FollowingPrecedingAxisTests gains a fused-name-test case.
This commit is contained in:
parent
ad289b0df1
commit
0405850d6b
@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- The `following` and `preceding` axes no longer rebuild the whole document node list and linearly search it on every context node, which made them quadratic over many context nodes. They now share a per-evaluation cache of each document's ordered node list and a node-to-index map (the document does not change during evaluation), so the list is built once and a node's position is an O(1) lookup; `following` additionally skips the context's contiguous subtree by index arithmetic rather than filtering every descendant out. The selected nodes and their order are unchanged. A 200-context `following::` query over a 20k-element document drops from about 16 s to about 9 s; the residual cost is the size of the produced node-set itself. Guarded by `FollowingPrecedingAxisTests`.
|
||||
- The `following` and `preceding` axes no longer rebuild the whole document node list and linearly search it on every context node, which made them quadratic over many context nodes. They now share a per-evaluation cache of each document's ordered node list and a node-to-index map (the document does not change during evaluation), so the list is built once and a node's position is an O(1) lookup; `following` additionally skips the context's contiguous subtree by index arithmetic rather than filtering every descendant out. The step's node test is fused into the walk, so a node the test rejects is never copied out of the shared list (only the matches are materialized). The selected nodes and their order are unchanged. A 200-context `following::` query over a 20k-element document drops from about 16 s to about 6 s. Guarded by `FollowingPrecedingAxisTests`.
|
||||
|
||||
## [0.4.3] - 2026-06-25
|
||||
|
||||
|
||||
@ -29,8 +29,8 @@ extension PureXML.XPath {
|
||||
switch axis {
|
||||
case .followingSibling: followingSiblings(of: context)
|
||||
case .precedingSibling: precedingSiblings(of: context)
|
||||
case .following: following(of: context, cache: cache)
|
||||
case .preceding: preceding(of: context, cache: cache)
|
||||
case .following: following(of: context, cache: cache, where: { _ in true })
|
||||
case .preceding: preceding(of: context, cache: cache, where: { _ in true })
|
||||
case .attribute: attributes(of: context)
|
||||
case .namespace: namespaces(of: context)
|
||||
default: []
|
||||
@ -119,7 +119,11 @@ extension PureXML.XPath {
|
||||
return parent.children[..<index].reversed().map(Node.tree)
|
||||
}
|
||||
|
||||
private static func following(of node: Node, cache: DocumentNavigationCache?) -> [Node] {
|
||||
/// The following axis, fusing the step's node test into the walk: `keep`
|
||||
/// is applied to the document-order slice directly, so a node the test
|
||||
/// rejects is never copied out of the shared node list (only matches are
|
||||
/// materialized). The unfused caller passes a test that keeps everything.
|
||||
static func following(of node: Node, cache: DocumentNavigationCache?, where keep: (Node) -> Bool) -> [Node] {
|
||||
guard let root = rootTree(of: node) else { return [] }
|
||||
let document = orderedDocument(rootedAt: root, cache: cache)
|
||||
if let index = document.index[node] {
|
||||
@ -128,16 +132,19 @@ extension PureXML.XPath {
|
||||
// subtree. Skip it by index rather than scanning the whole tail
|
||||
// and filtering each descendant out (which made this quadratic).
|
||||
let start = index + 1 + descendantCount(of: node)
|
||||
return start < document.nodes.count ? Array(document.nodes[start...]) : []
|
||||
return start < document.nodes.count ? document.nodes[start...].filter(keep) : []
|
||||
}
|
||||
// An attribute or namespace start: document order places it after
|
||||
// its owner and before the owner's children, and it has no
|
||||
// descendants, so everything after the owner follows.
|
||||
guard let owner = node.parent, let index = document.index[owner] else { return [] }
|
||||
return Array(document.nodes[(index + 1)...])
|
||||
return document.nodes[(index + 1)...].filter(keep)
|
||||
}
|
||||
|
||||
private static func preceding(of node: Node, cache: DocumentNavigationCache?) -> [Node] {
|
||||
/// The preceding axis, fusing the step's node test into the walk: the
|
||||
/// span before the context is filtered by `keep` (and the ancestors,
|
||||
/// which the axis excludes) in one pass, so only matches are materialized.
|
||||
static func preceding(of node: Node, cache: DocumentNavigationCache?, where keep: (Node) -> Bool) -> [Node] {
|
||||
guard let root = rootTree(of: node) else { return [] }
|
||||
let document = orderedDocument(rootedAt: root, cache: cache)
|
||||
let anchorIndex = document.index[node] ?? node.parent.flatMap { document.index[$0] }
|
||||
@ -146,7 +153,7 @@ extension PureXML.XPath {
|
||||
// over the preceding span is fine; the win here is the cached node
|
||||
// list and the O(1) anchor lookup instead of a rebuild and scan.
|
||||
let excluded = Set(ancestors(of: node))
|
||||
return document.nodes[..<index].filter { !excluded.contains($0) }.reversed()
|
||||
return document.nodes[..<index].filter { !excluded.contains($0) && keep($0) }.reversed()
|
||||
}
|
||||
|
||||
/// The number of tree-node descendants of `node`, matching what the
|
||||
|
||||
@ -68,11 +68,18 @@ extension PureXML.XPath.Evaluator {
|
||||
}
|
||||
}
|
||||
let keep = compiledNodeTest(step.test, on: step.axis, context.namespaces)
|
||||
return PureXML.XPath.AxisNavigation.nodes(
|
||||
on: step.axis,
|
||||
from: contextNode,
|
||||
cache: context.environment.documentNavigation,
|
||||
).filter(keep)
|
||||
let cache = context.environment.documentNavigation
|
||||
// The following and preceding axes fuse the node test into the walk so a
|
||||
// rejected node is never copied out of the cached document node list; the
|
||||
// other axes build their list and filter it.
|
||||
switch step.axis {
|
||||
case .following:
|
||||
return PureXML.XPath.AxisNavigation.following(of: contextNode, cache: cache, where: keep)
|
||||
case .preceding:
|
||||
return PureXML.XPath.AxisNavigation.preceding(of: contextNode, cache: cache, where: keep)
|
||||
default:
|
||||
return PureXML.XPath.AxisNavigation.nodes(on: step.axis, from: contextNode, cache: cache).filter(keep)
|
||||
}
|
||||
}
|
||||
|
||||
/// Filters nodes through each predicate in turn. A numeric predicate is a
|
||||
|
||||
@ -42,6 +42,16 @@ struct FollowingPrecedingAxisTests {
|
||||
#expect(try names("//b11/preceding::*", Self.doc) == ["a", "a1", "a2"])
|
||||
}
|
||||
|
||||
@Test("a name test fused into the walk selects only matching nodes")
|
||||
func test_fusedNodeTest() throws {
|
||||
// following::a from a1 selects only the later <a> (the one holding a3),
|
||||
// not a2/b/b1/b11/c; preceding::a from c selects only the first <a>.
|
||||
#expect(try names("//a1/following::a", Self.doc) == ["a"])
|
||||
#expect(try names("//c/preceding::a", Self.doc) == ["a"])
|
||||
#expect(try names("//a1/following::b11", Self.doc) == ["b11"])
|
||||
#expect(try names("//a3/preceding::a1", Self.doc) == ["a1"])
|
||||
}
|
||||
|
||||
@Test("the axes give the same result from many context nodes (shared cache)")
|
||||
func test_manyContexts() throws {
|
||||
// Exercises the shared per-query cache across context nodes. The path is a
|
||||
|
||||
Loading…
Reference in New Issue
Block a user