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:
parent
9032d61950
commit
4552d24067
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
56
Tests/XSLTKeyIdOrderTests.swift
Normal file
56
Tests/XSLTKeyIdOrderTests.swift
Normal 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>"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user