PureXML/Sources/XPath/SiblingIndexCache.swift
Mihaela Mihaljevic d86e4effbe
fix(xpath): cache the attribute index for document-order keys (#305)
An attribute node's document-order key bands by the attribute's index among its
owner's attributes, found with owner.attributes.firstIndex { $0.name == name }.
That linear scan ran for every attribute node, so sorting an element's K
attributes into document order was O(K^2): an 8000-attribute element took ~0.18s
and grew quadratically.

Cache a per-owner attribute-name-to-index map in SiblingIndexCache, built once
on the second lookup (a single lookup still scans), exactly as the sibling-index
cache already does for child positions. The index is the same first-occurrence
position; only its cost changes.

The 8000-attribute sort drops to ~0.01s (~16x) and is now linear. The full
suite, the W3C XPath and Apache Xalan corpora, the C14N suite (which orders
attributes byte-exactly), and the WASM build stay green; AttributeDocumentOrderTests
pins declaration order through the cached sort.
2026-06-25 22:01:13 +02:00

60 lines
3.1 KiB
Swift

extension PureXML.XPath {
/// A per-operation cache of each node's index among its parent's
/// children. Document-order keys need the index at every ancestor level;
/// without a cache each lookup scans the sibling list, so sorting a
/// node-set drawn from one flat fan-out is quadratic (the 42 MB
/// benchmark corpus spent 27 seconds there). Each distinct parent is
/// enumerated exactly once.
final class SiblingIndexCache {
private var indexes: [ObjectIdentifier: [ObjectIdentifier: Int]] = [:]
private var scannedOnce: Set<ObjectIdentifier> = []
private var attributeIndexes: [ObjectIdentifier: [PureXML.Model.QualifiedName: Int]] = [:]
private var attributeScannedOnce: Set<ObjectIdentifier> = []
/// The index of an attribute, by name, among its owner's attributes, for
/// the attribute band of a document-order key. As with ``index(of:in:)``,
/// the per-owner table is built on the second lookup (a single lookup
/// scans), so sorting many attribute nodes from one element is linear
/// rather than quadratic in the attribute count.
func attributeIndex(of name: PureXML.Model.QualifiedName, in owner: PureXML.Model.TreeNode) -> Int {
let ownerIdentity = ObjectIdentifier(owner)
if let table = attributeIndexes[ownerIdentity] {
return table[name] ?? 0
}
guard attributeScannedOnce.contains(ownerIdentity) else {
attributeScannedOnce.insert(ownerIdentity)
return owner.attributes.firstIndex { $0.name == name } ?? 0
}
var table: [PureXML.Model.QualifiedName: Int] = [:]
table.reserveCapacity(owner.attributes.count)
for (index, attribute) in owner.attributes.enumerated() where table[attribute.name] == nil {
table[attribute.name] = index
}
attributeIndexes[ownerIdentity] = table
return table[name] ?? 0
}
func index(of child: PureXML.Model.TreeNode, in parent: PureXML.Model.TreeNode) -> Int {
let parentIdentity = ObjectIdentifier(parent)
if let table = indexes[parentIdentity] {
return table[ObjectIdentifier(child)] ?? 0
}
// The table only pays for itself from the second lookup under
// the same parent: a single lookup (one key over a wide parent,
// the per-predicate case that once built a million-entry table
// per evaluation) scans linearly instead.
guard scannedOnce.contains(parentIdentity) else {
scannedOnce.insert(parentIdentity)
return parent.children.firstIndex { $0 === child } ?? 0
}
var table: [ObjectIdentifier: Int] = [:]
table.reserveCapacity(parent.children.count)
for (index, node) in parent.children.enumerated() {
table[ObjectIdentifier(node)] = index
}
indexes[parentIdentity] = table
return table[ObjectIdentifier(child)] ?? 0
}
}
}