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:
parent
4bd547d4b1
commit
5ad3cf10fc
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
73
Tests/XPathSingleStepOrderTests.swift
Normal file
73
Tests/XPathSingleStepOrderTests.swift
Normal 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"])
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user