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.
This commit is contained in:
Mihaela Mihaljevic 2026-06-25 08:52:28 +02:00 committed by GitHub
parent 720263ee58
commit 68f6680a66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 68 additions and 14 deletions

View File

@ -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.

View File

@ -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)
}
}

View File

@ -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)