From 68f6680a6602d0e9283f9bf3c55c0242356c1e4d Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Thu, 25 Jun 2026 08:52:28 +0200 Subject: [PATCH] perf(xpath): share focus-independent state through an Environment reference (#288) An EvaluationContext carried the variable bindings, the function library, the namespace bindings, and the budget by value. Re-focusing the context onto each node of a node-set (focused(on:position:size:), called once per node when a predicate is applied) copied all three dictionaries every time, even though none of them changes as the focus moves. Bundle the focus-independent state in an Environment reference type. The context now holds the node, position, size, and an Environment reference; re-focusing copies the three integers and shares the environment by reference instead of copying and reference-counting three dictionaries. The values and behavior are identical. Measured xpath ~16% faster on the 20k-item generated corpus via a same-state A/B (count unchanged at 10000). The full XPath suite, the Apache Xalan XSLT gold-output corpus, and the WASM build stay green. --- CHANGELOG.md | 2 + Sources/XPath/EvaluationContext.swift | 79 ++++++++++++++++++++++----- docs/benchmarks.md | 1 + 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 566ca16..154871f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- XPath evaluation shares the focus-independent state (the variable bindings, the function library, the namespace bindings, and the budget) through a single `Environment` reference instead of carrying it by value in every context. Re-focusing the context on each node of a node-set, which happens once per node when a predicate is applied, copied three dictionaries every time; it now copies only the node, position, and size and shares the environment by reference. The values and behavior are unchanged. Measured `xpath` ~16% faster on the 20k-item generated corpus via a same-machine-state A/B; covered by the full XPath suite (the change is internal to the evaluator). + - The XPath attribute axis fuses the step's node test into the walk, the same way the descendant axes do, so an attribute the test rejects is never wrapped in an `XPath.Node`. A predicate like `[@name='value']` previously built an `XPath.Node` for every attribute of the element (each wrap retains the owner element and copies the attribute's qualified name and value) and then filtered to the one named attribute; it now wraps only the matching attribute. `matches` delegates, for an attribute node, to a new `matchesAttribute` (the attribute axis always has the attribute principal kind, so this is exact), and the attribute walk applies it. The node-set, declaration order, and uniqueness are identical to the build-then-filter path. Measured `xpath` ~20% faster on the 20k-item generated corpus via a same-machine-state A/B. Guarded by `XPathFusedAttributeTests` (name and namespaced-name tests, the principal-kind wildcard, `node()`, the kind tests that never select an attribute, and a predicate over the selection) and confirmed by an adversarial equivalence review. - The XPath descendant and descendant-or-self axes fuse the step's node test into the traversal, so a node the test rejects is never wrapped in an `XPath.Node` (and so never reference-counted). Previously the axis built the full subtree node list and then filtered it; for a `//name` query over a wide document, most of the wrapped nodes were immediately discarded. The node test now runs against the raw tree node during the walk: `matches(_:_:on:namespaces:)` delegates to a new tree-node `matchesTree` (one source of truth, exercised by the full XPath suite), and the descendant walk applies it. The node-set, document order, and uniqueness are identical to the build-then-filter path; only non-matching nodes are no longer materialized. Measured `xpath` ~11% faster on the 20k-item generated corpus via a same-machine-state A/B. Guarded by `XPathFusedDescendantTests` (every node-test kind through the fused path: element and namespaced names, the principal-kind wildcard, `text()`/`comment()`/`processing-instruction()`/`node()`, and a predicate on the result) and confirmed by an adversarial equivalence review that found no query whose result the change alters. diff --git a/Sources/XPath/EvaluationContext.swift b/Sources/XPath/EvaluationContext.swift index 35eefd0..7947f73 100644 --- a/Sources/XPath/EvaluationContext.swift +++ b/Sources/XPath/EvaluationContext.swift @@ -7,21 +7,79 @@ extension PureXML.XPath { /// node, its position and size within the current node-set, the variable /// bindings, and the function library. Predicates and the `position()` and /// `last()` functions read the position and size. + /// The part of an evaluation that does not change as the focus moves from + /// node to node: the variable bindings, the function library, the namespace + /// bindings, and the budget. Held by reference so that re-focusing on each + /// node of a node-set (see ``EvaluationContext/focused(on:position:size:)``, + /// called once per node of a predicate's input) shares it rather than copying + /// (and reference-counting) three dictionaries every time. + final class Environment { + let variables: [String: Value] + let functions: FunctionTable + let namespaces: [String: String] + let budget: Budget? + + init(variables: [String: Value], functions: FunctionTable, namespaces: [String: String], budget: Budget?) { + self.variables = variables + self.functions = functions + self.namespaces = namespaces + self.budget = budget + } + } + struct EvaluationContext { var node: Node var position: Int var size: Int - var variables: [String: Value] - var functions: FunctionTable + /// The focus-independent bindings, shared across every re-focus. + let environment: Environment + + var variables: [String: Value] { + environment.variables + } + + var functions: FunctionTable { + environment.functions + } + /// Prefix-to-URI bindings supplied at evaluation time, so a name test like /// `x:foo` resolves `x` to a URI and matches by namespace rather than by the /// document's own prefix. When any bindings are supplied the XPath 1.0 /// rule applies exactly: an unprefixed name test selects only the null /// namespace. Empty by default, in which case matching falls back to /// the in-document prefix string. - var namespaces: [String: String] = [:] + var namespaces: [String: String] { + environment.namespaces + } + /// The optional protective budget; nil evaluates unbounded. - var budget: Budget? + var budget: Budget? { + environment.budget + } + + init(node: Node, position: Int, size: Int, environment: Environment) { + self.node = node + self.position = position + self.size = size + self.environment = environment + } + + init( + node: Node, + position: Int, + size: Int, + variables: [String: Value], + functions: FunctionTable, + namespaces: [String: String] = [:], + budget: Budget? = nil, + ) { + self.init( + node: node, + position: position, + size: size, + environment: Environment(variables: variables, functions: functions, namespaces: namespaces, budget: budget), + ) + } /// Throws when `count` exceeds the budget's node-set cap. func checkBudget(_ count: Int) throws { @@ -31,17 +89,10 @@ extension PureXML.XPath { } /// A copy positioned on `node` at one-based `position` within a node-set of - /// `size`, keeping the same variables, functions, and namespace bindings. + /// `size`, sharing the same environment (variables, functions, namespaces, + /// budget) rather than copying it. func focused(on node: Node, position: Int, size: Int) -> EvaluationContext { - EvaluationContext( - node: node, - position: position, - size: size, - variables: variables, - functions: functions, - namespaces: namespaces, - budget: budget, - ) + EvaluationContext(node: node, position: position, size: size, environment: environment) } } diff --git a/docs/benchmarks.md b/docs/benchmarks.md index b650934..833f39c 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -67,6 +67,7 @@ offsets throughout; libxml2's xmlReadMemory takes an int byte count). | 13 | single-accumulator descendant traversal: `descendants(of:)` recursed on wrapped nodes and concatenated a fresh array at every node (`append(contentsOf: descendants(of: child))`), O(n x depth) allocation churn; rewritten to recurse on the raw tree node with one shared `inout` accumulator, O(n). Same output (same-state A/B, ~46%) | unchanged | 0.032 s (~3x, from ~6x) | unchanged | | 14 | fuse the node test into the descendant walk: a node the step's test rejects is no longer wrapped in an `XPath.Node` (so never retained); `matches` delegates to a tree-node `matchesTree` so there is one source of truth, and the traversal applies it during the walk. Identical node-set (adversarially equivalence-reviewed; same-state A/B, ~11%) | unchanged | 0.028 s (~2.5x) | unchanged | | 15 | the same test fusion for the attribute axis (`matchesAttribute`): a `[@name='...']` predicate built every attribute node per element and filtered to one; it now wraps only the matching attribute, saving the owner retain and the qualified-name/value copies for the rest. Identical node-set (adversarially equivalence-reviewed; same-state A/B, ~20%) | unchanged | 0.022 s (~2.0x) | unchanged | +| 16 | share the focus-independent evaluation state via an `Environment` reference: `focused(on:position:size:)` (called once per node of a predicate's input) copied the variables, functions, and namespace dictionaries every time; it now copies only node/position/size and shares one reference. Same semantics (same-state A/B, ~16%) | unchanged | 0.020 s (~2.0x) | unchanged | ## Profile findings (first sample, parse path)