perf(xpath): skip the document-order sort for an already-ordered single step (#283)

A path that is a single forward-axis step from one context node returns its
nodes already in document order with no duplicates, so the document-order
sort is redundant. AxisNavigation produces the structural forward axes
(child, descendant, descendant-or-self, self, following, following-sibling)
in document order, deriving sibling order from the same children array the
order key compares, so the two can never disagree. This is the common shape
after //x compiles and fuses to a single descendant::x step.

The attribute axis (whose order interleaves with namespace declarations), the
namespace axis (produced in dictionary order), and every reverse axis
(nearest-first) are excluded and still sorted. Measured xpath ~7% faster on
the 20k-item generated corpus via a same-state A/B, identical results.
XPathSingleStepOrderTests pins document order for each included axis, that the
reverse axes still normalize, and that multi-step paths still sort. An
adversarial order-correctness review found no query whose order or uniqueness
the change breaks.
This commit is contained in:
Mihaela Mihaljevic 2026-06-25 05:04:00 +02:00 committed by GitHub
parent 4bd547d4b1
commit 5ad3cf10fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 104 additions and 6 deletions

View File

@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- A single forward-axis location step from one context node no longer sorts its result into document order, because it is already in document order with no duplicates. `AxisNavigation` produces the structural forward axes (`child`, `descendant`, `descendant-or-self`, `self`, `following`, `following-sibling`) in document order, deriving sibling order from the same `children` array the order key compares, so a one-step path over any of them needs neither the document-order sort nor de-duplication. (This is the common shape after `//x` compiles and fuses to a single `descendant::x` step.) The attribute axis (whose order interleaves with namespace declarations) and the namespace axis (produced in dictionary/hash order), plus every reverse axis (nearest-first), are excluded and still sorted. Measured `xpath` ~7% faster on the 20k-item generated corpus via a same-machine-state A/B, identical results. Guarded by `XPathSingleStepOrderTests` (document order for each included axis, reverse axes still normalized, multi-step paths still sorted) and confirmed by an adversarial order-correctness review that found no query whose order or uniqueness the change breaks.
- A path expression's result is no longer re-de-duplicated before sorting. `evaluateSteps` already returns a duplicate-free node-set (each step de-duplicates across its context nodes, or uses an axis whose per-context results cannot overlap), so the `Set`-based de-duplication that ran before the document-order sort copied every node for nothing; each copy retains the wrapped tree node, whose `weak parent` reference forces a side table and the slow reference-counting path. Path and trailing-path evaluation now sort the steps' result directly; unions and filter primaries (which genuinely can carry duplicates) keep the de-duplicating pass. Measured `xpath` ~9% faster on the 20k-item generated corpus via a same-machine-state A/B, identical results. Covered by the existing node-set de-duplication and union tests (a path still yields each node once).
- XPath compiles `//` (the abbreviation for `descendant-or-self::node()/child::X`) to a single `descendant::X` step when the child step's predicates are non-positional, avoiding the materialization of every node in the subtree as an intermediate context (measured `xpath` ~10x -> ~7x slower than libxml2 on the 20k-item generated corpus via a same-machine-state A/B, identical results; ~54% faster than before this cycle's two XPath changes combined). The two node-sets are equal, so the fusion is transparent, EXCEPT for a positional predicate: `//x[1]` is the first `x` child of EACH parent, not the first `x` in document order, which `descendant::x[1]` would wrongly select. The rewrite is therefore applied only when every predicate is provably non-positional: its value is a boolean, node-set, or string (never a number, which XPath reads as `position() = value`) and it nowhere references `position()` or `last()`. Anything not provably both is left unfused, so a result can never change. Guarded by `XPathDescendantFusionTests` (positional forms keep their per-parent meaning, non-positional forms select across the subtree, composition with trailing and nested steps) and confirmed by an adversarial differential review (fused versus a spec-literal unfused reference) that found no query whose result the fusion changes.

View File

@ -27,6 +27,22 @@ extension PureXML.XPath {
}
}
/// Whether the axis, taken from a SINGLE context node, yields its nodes
/// already in document order with no duplicates, so a one-step path over
/// it needs neither the de-duplication nor the document-order sort. True
/// for the structural forward axes, which `AxisNavigation` produces in
/// document order (child by child-array order, descendant by pre-order,
/// the following families by document position). The attribute axis is
/// excluded because its order interleaves with namespace declarations,
/// and the namespace axis because its nodes are produced in dictionary
/// (hash) order; the sort normalizes both, so they are left to it.
var preservesDocumentOrderFromSingleContext: Bool {
switch self {
case .child, .descendant, .descendantOrSelf, .selfAxis, .followingSibling, .following: true
default: false
}
}
/// Whether the axis yields disjoint node-sets from distinct context nodes,
/// so accumulating its results across a step's context nodes can never
/// produce a duplicate and needs no cross-context de-duplication. A child,

View File

@ -165,12 +165,18 @@ extension PureXML.XPath {
private static func evalPath(absolute: Bool, steps: [Step], _ context: EvaluationContext) throws -> Value {
let start: Node = absolute ? root(of: context.node) : context.node
// `evaluateSteps` already yields a duplicate-free node-set (each step
// either de-duplicates across contexts or uses an axis that cannot
// produce a duplicate), so only the document-order sort remains; the
// `Set` de-duplication in `orderUnique` would copy every node for
// nothing.
return try .nodeSet(evaluateSteps(steps, from: [start], context).sortedByDocumentOrder())
let nodes = try evaluateSteps(steps, from: [start], context)
// A single forward-axis step from the one start node yields nodes
// already in document order with no duplicates (the axis produces
// document order and cannot repeat a node from one context, and the
// node test and predicates only filter), so the sort is redundant.
// Otherwise sort: `evaluateSteps` is already duplicate-free (each step
// de-duplicates or uses a per-context-disjoint axis), so only the
// document-order sort is needed, not the `Set` de-duplication.
if steps.count == 1, steps[0].axis.preservesDocumentOrderFromSingleContext {
return .nodeSet(nodes)
}
return .nodeSet(nodes.sortedByDocumentOrder())
}
private static func evalFilter(

View File

@ -0,0 +1,73 @@
import Testing
@testable import PureXML
/// A single forward-axis step from one context node is returned without the
/// document-order sort, on the proof that `AxisNavigation` already produces the
/// structural forward axes in document order. These tests pin that the order is
/// in fact document order for each such axis (so the skipped sort would not have
/// changed it), and that the axes left to the sort (attribute, namespace) and
/// the reverse axes still order correctly.
@Suite("XPath single-step path order")
struct XPathSingleStepOrderTests {
/// Document order of the `n` elements is 1..6; b and c are nested so a naive
/// per-parent accumulation would differ from document order.
private static let source = """
<r xmlns:k="urn:k">
<n id="1"/>
<a><n id="2"/><b><n id="3"/></b><n id="4"/></a>
<n id="5"/>
<a><n id="6"/></a>
</r>
"""
private func ids(_ path: String) throws -> [String] {
try PureXML.XPath.Query(path).evaluate(over: PureXML.parse(Self.source)).compactMap {
if case let .node(node) = $0 { return node.element?.attributes.first { $0.name.localName == "id" }?.value }
return nil
}
}
private func names(_ path: String) throws -> [String] {
try PureXML.XPath.Query(path).evaluate(over: PureXML.parse(Self.source)).compactMap {
if case let .node(node) = $0 { return node.element?.name.localName }
return nil
}
}
@Test("descendant axis returns in document order without the sort")
func test_descendantOrder() throws {
#expect(try ids("//n") == ["1", "2", "3", "4", "5", "6"])
#expect(try ids("/r/descendant::n") == ["1", "2", "3", "4", "5", "6"])
}
@Test("child and descendant-or-self single steps are in document order")
func test_childAndSelfOrder() throws {
// The n children of r are n1 and n5 (the a elements have no id).
#expect(try ids("/r/child::n") == ["1", "5"])
// /r/a selects BOTH a elements; descendant-or-self::*[@id] under them is
// n2, n3, n4 (first a) and n6 (second a), sorted to document order.
#expect(try ids("/r/a/descendant-or-self::*[@id]") == ["2", "3", "4", "6"])
}
@Test("following and following-sibling single steps are in document order")
func test_followingOrder() throws {
// following:: from n2 reaches n3, n4, n5, n6 (not n2's ancestors/self).
#expect(try ids("//n[@id='2']/following::n") == ["3", "4", "5", "6"])
// following-sibling:: of n2 within its parent a: b(no id) then n4.
#expect(try ids("//n[@id='2']/following-sibling::n") == ["4"])
}
@Test("reverse axes still order nearest-first via the sort path")
func test_reverseAxisOrder() throws {
// Ancestors of n3 are b, a, r; the node-set normalizes to document order.
#expect(try names("//n[@id='3']/ancestor::*") == ["r", "a", "b"])
// preceding:: of n5 excludes ancestors; n1 and the first a's n2/n3/n4.
#expect(try ids("//n[@id='5']/preceding::n") == ["1", "2", "3", "4"])
}
@Test("a multi-step path is still sorted to document order")
func test_multiStepStillSorted() throws {
// //a/n gathers n children of every a; document order across both a's.
#expect(try ids("//a/n") == ["2", "4", "6"])
}
}

View File

@ -63,6 +63,7 @@ offsets throughout; libxml2's xmlReadMemory takes an int byte count).
| 9 | skip cross-context node-set de-duplication on a single context or a disjoint axis (child/attribute/namespace/self); the `Set<Node>` hashing and copying was pure overhead where no duplicate can arise, same output (same-state A/B, ~28%) | unchanged | 0.097 s (10x, from 13x) | unchanged |
| 10 | `//` descendant fusion (`descendant-or-self::node()/child::X[P]` compiled to `descendant::X[P]` when `P` is non-positional), avoiding the whole-subtree intermediate context; guarded so positional predicates keep their per-parent meaning, adversarially reviewed for semantic transparency | unchanged | 0.063 s (~7x, from 10x) | unchanged |
| 11 | drop the redundant `Set` de-duplication when sorting a path result: `evaluateSteps` already yields a duplicate-free node-set, so only the document-order sort is needed (each skipped insert was a copy of a node wrapping a side-table-refcounted tree node); same-state A/B, ~9% | unchanged | 0.058 s (~6x, from ~7x) | unchanged |
| 12 | skip the document-order sort for a single forward-axis step from one context: `AxisNavigation` produces child/descendant/descendant-or-self/self/following/following-sibling already in document order with no duplicates, so the sort is redundant (adversarially reviewed for order correctness; same-state A/B, ~7%) | unchanged | 0.055 s (~6x) | unchanged |
## Profile findings (first sample, parse path)