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:
parent
720263ee58
commit
68f6680a66
@ -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.
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user