fix(xslt): make key(), id(), and EXSLT set functions linear over wide fan-outs (#300)

key(), id(), and EXSLT set:leading/set:trailing sorted their result with
sorted(by: Node.precedes). precedes recomputes both nodes' document-order keys
on every comparison, and each key walks the node to the root taking its index
among each parent's children, a linear scan of a wide parent. Over a wide flat
fan-out the sort was therefore quadratic. key() also deduplicated its matches
with matched.contains(where: ===), a linear membership scan, O(m^2) in the
match count. The EXSLT relative() used the cache-less documentOrder, rescanning
the parent per node.

Sort through sortedByDocumentOrder() (each key computed once behind a shared
sibling-index cache), deduplicate key()'s matches through a Set, and give
relative() a shared cache. The selected nodes, their document order, and the
deduplication are unchanged.

A key()-driven transform over a 20k-element document drops from ~1.15s to
~0.43s (~2.7x). The full suite, the Apache Xalan XSLT gold-output corpus, the
W3C XPath/XSLT/EXSLT tests, and the WASM build stay green; XSLTKeyIdOrderTests
pins key() dedup-and-order and id() document order.
This commit is contained in:
Mihaela Mihaljevic 2026-06-25 18:45:08 +02:00 committed by GitHub
parent 9032d61950
commit 4552d24067
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 70 additions and 8 deletions

View File

@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- `key()`, `id()`, and the EXSLT `set:leading`/`set:trailing` functions no longer rescan a node's siblings per comparison, which made them quadratic over a wide flat fan-out. They sorted their result with `sorted(by: Node.precedes)`, which recomputes both nodes' document-order keys on every comparison (each key a linear scan of a wide parent), and `key()` additionally deduplicated its matches with a linear membership scan. They now sort through `sortedByDocumentOrder()` (each key computed once, behind a shared sibling-index cache) and `key()` deduplicates through a set. The selected nodes, their document order, and the deduplication are unchanged. A `key()`-driven transform over a 20k-element document drops from about 1.15 s to about 0.43 s (~2.7x); the Apache Xalan gold-output corpus stays byte-identical. Guarded by `XSLTKeyIdOrderTests`.
- Extracting the string-value of a node-set no longer scans the node's siblings, which made an XSLT transform (or any repeated `string(@x)` / `value-of`) over a wide flat fan-out quadratic. `firstInDocumentOrder()` computed a document-order key even for a zero- or one-node set, and that key walks the node up to the root taking its index among each parent's children, a linear scan of a wide parent. For a single node the order is irrelevant (the node is trivially first), so it now returns directly without computing the key. A transform that reads a couple of attributes and child values per element over a 20k-element document drops from about 2.3 s to about 0.65 s (~3.5x); the produced output is unchanged (the Apache Xalan gold-output corpus stays byte-identical). Guarded by `FirstInDocumentOrderTests`.
## [0.4.2] - 2026-06-25

View File

@ -114,11 +114,14 @@ extension PureXML.XPath {
guard let pivot = (arguments.count > 1 ? arguments[1].nodes ?? [] : []).firstInDocumentOrder() else {
return []
}
// Decorate once: each node's order key is computed a single time and
// reused for both the pivot comparison and the final sort.
let pivotKey = pivot.documentOrder
// Decorate once through a shared sibling-index cache: each node's
// order key is computed a single time, and the per-parent index is
// built once rather than rescanned for every node (which made this
// quadratic over a wide fan-out).
let cache = PureXML.XPath.SiblingIndexCache()
let pivotKey = pivot.documentOrder(cache: cache)
return first
.map { (node: $0, key: $0.documentOrder) }
.map { (node: $0, key: $0.documentOrder(cache: cache)) }
.filter { leading ? Node.ordered($0.key, before: pivotKey) : Node.ordered(pivotKey, before: $0.key) }
.sorted { Node.ordered($0.key, before: $1.key) }
.map(\.node)

View File

@ -221,7 +221,7 @@ extension PureXML.XSLT {
// without a second argument, against the reference node's own
// base (node-set) or the stylesheet (string).
let secondBase: String? = arguments.count > 1
? (arguments[1].nodes?.min(by: PureXML.XPath.Node.precedes)).map { baseURI(of: $0, documents) }
? arguments[1].nodes?.firstInDocumentOrder().map { baseURI(of: $0, documents) }
: nil
func load(_ reference: String, _ base: String) -> [PureXML.XPath.Node] {
documentReference(resolveDocumentReference(reference, against: base), loader, documents, selfDocument)

View File

@ -102,7 +102,7 @@ extension PureXML.XSLT.Library {
}
let matched = index.filter { tokens.contains($0.key) }.values
.map { PureXML.XPath.Node.tree($0) }
.sorted(by: PureXML.XPath.Node.precedes)
.sortedByDocumentOrder()
return .nodeSet(matched)
}
@ -150,11 +150,12 @@ extension PureXML.XSLT.Library {
}
let index = keys(documentRoot)
var matched: [PureXML.Model.TreeNode] = []
var seen: Set<ObjectIdentifier> = []
for value in values {
for node in index[name]?[value] ?? [] where !matched.contains(where: { $0 === node }) {
for node in index[name]?[value] ?? [] where seen.insert(ObjectIdentifier(node)).inserted {
matched.append(node)
}
}
return .nodeSet(matched.map { PureXML.XPath.Node.tree($0) }.sorted(by: PureXML.XPath.Node.precedes))
return .nodeSet(matched.map { PureXML.XPath.Node.tree($0) }.sortedByDocumentOrder())
}
}

View File

@ -0,0 +1,56 @@
import Testing
@testable import PureXML
/// `key()`, `id()`, and the EXSLT `set:leading`/`set:trailing` functions return
/// their nodes in document order. The order is now computed through the cached
/// `sortedByDocumentOrder`/`documentOrder(cache:)` path (not `sorted(by:precedes)`
/// or the cache-less key, which rescanned a wide parent per comparison), and
/// `key()` deduplicates through a set, not a linear membership scan. These pin
/// that the results are unchanged: the right nodes, in document order, once each.
@Suite("XSLT key/id/set document order and dedup")
struct XSLTKeyIdOrderTests {
@Test("key() returns its matches in document order, deduplicated")
func test_keyDedupAndOrder() throws {
// Several values map to overlapping nodes; the node-set second argument
// unions them and each matching node must appear once, in document order.
let source = """
<data>
<r k="a">1</r><r k="b">2</r><r k="a">3</r><r k="c">4</r><r k="b">5</r>
<want>a</want><want>b</want><want>a</want>
</data>
"""
let style = """
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="xml" indent="no"/>
<xsl:key name="byK" match="r" use="@k"/>
<xsl:template match="/">
<out><xsl:for-each select="key('byK', /data/want)"><xsl:value-of select="."/>,</xsl:for-each></out>
</xsl:template>
</xsl:stylesheet>
"""
let out = try PureXML.XSLT.transform(stylesheet: style, source: source)
// k='a' -> nodes 1,3; k='b' -> nodes 2,5. Union, deduplicated, in
// document order: 1,2,3,5.
#expect(out.hasSuffix("<out>1,2,3,5,</out>"))
}
@Test("id() with several tokens returns the elements in document order")
func test_idOrder() throws {
let source = """
<!DOCTYPE doc [<!ATTLIST item ref ID #IMPLIED>]>
<doc><item ref="c">C</item><item ref="a">A</item><item ref="b">B</item></doc>
"""
let style = """
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="xml" indent="no"/>
<xsl:template match="/">
<out><xsl:for-each select="id('b a c')"><xsl:value-of select="."/></xsl:for-each></out>
</xsl:template>
</xsl:stylesheet>
"""
let out = try PureXML.XSLT.transform(stylesheet: style, source: source)
// id() returns the referenced elements in document order regardless of the
// token order: C, A, B as they appear.
#expect(out.hasSuffix("<out>CAB</out>"))
}
}