From 0405850d6ba3767a2f8dbcd99560679a4342451d Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Thu, 25 Jun 2026 20:56:14 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 2 +- Sources/XPath/AxisNavigation.swift | 21 ++++++++++++++------- Sources/XPath/EvaluatorSteps.swift | 17 ++++++++++++----- Tests/FollowingPrecedingAxisTests.swift | 10 ++++++++++ 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f8749..450311e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/XPath/AxisNavigation.swift b/Sources/XPath/AxisNavigation.swift index 23dfa08..4271101 100644 --- a/Sources/XPath/AxisNavigation.swift +++ b/Sources/XPath/AxisNavigation.swift @@ -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[.. [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[.. (the one holding a3), + // not a2/b/b1/b11/c; preceding::a from c selects only the first . + #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