perf(xslt): cache xsl:number level single/multiple sibling ranks (#310)
siblingPosition ranked a numbered node among its parent's matching children by scanning the child list up to the node, counting matches. Run for every node of a wide list, numbering was O(n^2): the default xsl:number over 8000 list items took ~3.5s. The count test is the same for every candidate of one xsl:number call, so cache per parent and count pattern the one-based rank of each matching child, built once. Each node's rank is then an O(1) lookup. The transformer holds the cache across the whole transform; the count test itself is already O(1) through the match cache, so building a parent's ranks is linear in its children. The numbers are identical (a node's rank among matching siblings is 1 + the matching siblings before it). The 8000-item numbering drops ~3.5s to ~0.04s (~90x), quadratic to linear. The full suite, the W3C XSLT and Apache Xalan corpora, and the WASM build stay green; XSLTNumberSingleTests pins single, partial-match, and multiple-level.
This commit is contained in:
parent
971a7e9f56
commit
8f992a8897
@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- `xsl:number` levels `single` and `multiple` are no longer quadratic when numbering a wide list. Each numbered node was ranked among its siblings by rescanning the parent's child list, so numbering n siblings was O(n^2). The rank now comes from a per-parent, per-count-pattern cache built once (using the transformer's match cache, so a count test is O(1)), making the numbering linear. The numbers are unchanged. Numbering 8000 list items with the default `xsl:number` drops from about 3.5 s to about 0.04 s (~90x). Guarded by `XSLTNumberSingleTests`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `xsl:number level="any"` is no longer cubic over a wide fan-out. Numbering a node walks the nodes that precede it in document order, and that walk found each preceding sibling by rescanning the parent's whole child list, so visiting k preceding siblings cost O(k^2), and numbering n nodes was O(n^3). It now finds the context's index once and walks the children before it directly, so the walk is linear in the number of preceding nodes and the numbering is quadratic (inherent to counting preceding nodes per numbered node). The numbers are unchanged. Numbering 1000 items drops from about 4.7 s to about 0.7 s (~6x), and the scaling improves from cubic to quadratic. Guarded by `XSLTNumberAnyTests`.
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -244,7 +244,13 @@ extension PureXML.XSLT.Transformer {
|
||||
}
|
||||
return XSLTNumbering.format([Int(number.rounded())], format, grouping)
|
||||
}
|
||||
let numbers = XSLTNumbering.numbers(of: context.node, level: spec.level, count: spec.count, from: spec.from) { node, pattern in
|
||||
let numbers = XSLTNumbering.numbers(
|
||||
of: context.node,
|
||||
level: spec.level,
|
||||
count: spec.count,
|
||||
from: spec.from,
|
||||
siblingCache: numberingCache,
|
||||
) { node, pattern in
|
||||
matches(node, pattern)
|
||||
}
|
||||
return XSLTNumbering.format(numbers, format, grouping)
|
||||
|
||||
@ -12,6 +12,7 @@ enum XSLTNumbering {
|
||||
level: String,
|
||||
count: String?,
|
||||
from: String?,
|
||||
siblingCache: SiblingPositionCache? = nil,
|
||||
matches: (Tree, String) -> Bool,
|
||||
) -> [Int] {
|
||||
func matchesCount(_ candidate: Tree) -> Bool {
|
||||
@ -21,13 +22,21 @@ enum XSLTNumbering {
|
||||
func matchesFrom(_ candidate: Tree) -> Bool {
|
||||
from.map { matches(candidate, $0) } ?? false
|
||||
}
|
||||
// The count test is the same for every candidate of this call, so the
|
||||
// sibling-position cache can key by it. An explicit count is identified
|
||||
// by its pattern; the default count by the node's kind and name (a NUL
|
||||
// prefix keeps it out of the pattern-string space).
|
||||
let patternKey = count ?? "\u{0}\(node.kind):\(node.name?.description ?? "")"
|
||||
func position(of candidate: Tree) -> Int {
|
||||
siblingPosition(of: candidate, matching: matchesCount, cache: siblingCache, patternKey: patternKey)
|
||||
}
|
||||
switch level {
|
||||
case "multiple":
|
||||
var counts: [Int] = []
|
||||
var current: Tree? = node
|
||||
while let candidate = current, !matchesFrom(candidate) {
|
||||
if matchesCount(candidate) {
|
||||
counts.append(siblingPosition(of: candidate, matching: matchesCount))
|
||||
counts.append(position(of: candidate))
|
||||
}
|
||||
current = candidate.parent
|
||||
}
|
||||
@ -46,7 +55,7 @@ enum XSLTNumbering {
|
||||
// The count node may be the from node itself when it matches both
|
||||
// patterns (7.7), so test count before stopping at the boundary.
|
||||
if matchesCount(candidate) {
|
||||
return [siblingPosition(of: candidate, matching: matchesCount)]
|
||||
return [position(of: candidate)]
|
||||
}
|
||||
if matchesFrom(candidate) { break }
|
||||
current = candidate.parent
|
||||
@ -55,10 +64,21 @@ enum XSLTNumbering {
|
||||
}
|
||||
}
|
||||
|
||||
/// 1 + the count of preceding siblings the count pattern matches.
|
||||
private static func siblingPosition(of node: Tree, matching matchesCount: (Tree) -> Bool) -> Int {
|
||||
/// 1 + the count of preceding siblings the count pattern matches, i.e. the
|
||||
/// node's one-based rank among the parent's matching children. With a cache,
|
||||
/// each parent's matching children are ranked once and reused, so numbering
|
||||
/// a wide list is linear rather than quadratic.
|
||||
private static func siblingPosition(
|
||||
of node: Tree,
|
||||
matching matchesCount: (Tree) -> Bool,
|
||||
cache: SiblingPositionCache?,
|
||||
patternKey: String,
|
||||
) -> Int {
|
||||
guard let parent = node.parent else { return 1 }
|
||||
if let cache {
|
||||
return cache.rank(of: node, in: parent, patternKey: patternKey, matching: matchesCount)
|
||||
}
|
||||
var position = 1
|
||||
guard let parent = node.parent else { return position }
|
||||
for sibling in parent.children {
|
||||
if sibling === node { break }
|
||||
if matchesCount(sibling) { position += 1 }
|
||||
@ -66,6 +86,29 @@ enum XSLTNumbering {
|
||||
return position
|
||||
}
|
||||
|
||||
/// Caches, per parent and count pattern, the one-based rank of each matching
|
||||
/// child among the parent's matching children, so `xsl:number` level single
|
||||
/// and multiple do not rescan the child list for every numbered node.
|
||||
final class SiblingPositionCache {
|
||||
/// parent -> count pattern -> child -> one-based rank among matches.
|
||||
private var ranks: [ObjectIdentifier: [String: [ObjectIdentifier: Int]]] = [:]
|
||||
|
||||
func rank(of node: Tree, in parent: Tree, patternKey: String, matching matchesCount: (Tree) -> Bool) -> Int {
|
||||
let parentIdentity = ObjectIdentifier(parent)
|
||||
if let table = ranks[parentIdentity]?[patternKey] {
|
||||
return table[ObjectIdentifier(node)] ?? 1
|
||||
}
|
||||
var built: [ObjectIdentifier: Int] = [:]
|
||||
var rank = 0
|
||||
for child in parent.children where matchesCount(child) {
|
||||
rank += 1
|
||||
built[ObjectIdentifier(child)] = rank
|
||||
}
|
||||
ranks[parentIdentity, default: [:]][patternKey] = built
|
||||
return built[ObjectIdentifier(node)] ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Visits `node` and everything before it in document order (ancestors
|
||||
/// included), nearest first; the visitor returns false to stop.
|
||||
private static func walkPreceding(_ node: Tree, _ visit: (Tree) -> Bool) {
|
||||
|
||||
@ -18,6 +18,7 @@ extension PureXML.XSLT {
|
||||
let termination = Termination()
|
||||
private let matchCache = MatchCache()
|
||||
private let documentCache = PureXML.XSLT.DocumentCache()
|
||||
let numberingCache = XSLTNumbering.SiblingPositionCache()
|
||||
|
||||
/// The `xsl:message terminate="yes"` text, if one fired during `run()`.
|
||||
var terminationMessage: String? {
|
||||
@ -103,7 +104,6 @@ extension PureXML.XSLT {
|
||||
}
|
||||
|
||||
func matches(_ node: PureXML.Model.TreeNode, _ pattern: String, _ namespaces: [String: String] = [:]) -> Bool {
|
||||
// Patterns evaluate over the node's own document (so document()-loaded nodes match too).
|
||||
let documentRoot = Self.documentRoot(of: node)
|
||||
return matchCache.nodes(matching: pattern, over: documentRoot, functions: patternFunctions(), namespaces: namespaces).contains(ObjectIdentifier(node))
|
||||
}
|
||||
|
||||
49
Tests/XSLTNumberSingleTests.swift
Normal file
49
Tests/XSLTNumberSingleTests.swift
Normal file
@ -0,0 +1,49 @@
|
||||
import Testing
|
||||
@testable import PureXML
|
||||
|
||||
/// `xsl:number` level single and multiple rank a node among its parent's
|
||||
/// matching children. That rank now comes from a per-parent-and-pattern cache
|
||||
/// (built once) instead of rescanning the child list per numbered node. These
|
||||
/// pin that the numbers are unchanged: the position among matching siblings for
|
||||
/// the default and an explicit count, a partial-match count, and the
|
||||
/// multiple-level path that numbers each matching ancestor.
|
||||
@Suite("xsl:number level single and multiple")
|
||||
struct XSLTNumberSingleTests {
|
||||
private func transform(_ source: String, template: String) throws -> String {
|
||||
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>\(template)</out></xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
return try PureXML.XSLT.transform(stylesheet: style, source: source)
|
||||
}
|
||||
|
||||
@Test("default and explicit count rank a node among its same-name siblings")
|
||||
func test_single() throws {
|
||||
let source = "<r><item/><item/><item/></r>"
|
||||
let template = #"<xsl:for-each select="//item"><n><xsl:number/></n></xsl:for-each>"#
|
||||
#expect(try transform(source, template: template).hasSuffix("<n>1</n><n>2</n><n>3</n></out>"))
|
||||
let explicit = #"<xsl:for-each select="//item"><n><xsl:number count="item"/></n></xsl:for-each>"#
|
||||
#expect(try transform(source, template: explicit).hasSuffix("<n>1</n><n>2</n><n>3</n></out>"))
|
||||
}
|
||||
|
||||
@Test("a partial-match count numbers only the matching siblings")
|
||||
func test_partialMatch() throws {
|
||||
// The <other/> elements are interleaved but not counted, so the items
|
||||
// keep ranks 1, 2, 3 among the item siblings.
|
||||
let source = "<r><other/><item/><other/><item/><item/><other/></r>"
|
||||
let template = #"<xsl:for-each select="//item"><n><xsl:number count="item"/></n></xsl:for-each>"#
|
||||
#expect(try transform(source, template: template).hasSuffix("<n>1</n><n>2</n><n>3</n></out>"))
|
||||
}
|
||||
|
||||
@Test("multiple level numbers each matching ancestor")
|
||||
func test_multiple() throws {
|
||||
let source = "<doc><sec><sec/><sec/></sec><sec/></doc>"
|
||||
// The innermost <sec> elements: the two inside the first sec are 1.1 and
|
||||
// 1.2; the last top-level sec is 2.
|
||||
let template = #"<xsl:for-each select="//sec[not(sec)]"><n><xsl:number level="multiple" count="sec" format="1.1"/></n></xsl:for-each>"#
|
||||
let out = try transform(source, template: template)
|
||||
#expect(out.hasSuffix("<n>1.1</n><n>1.2</n><n>2</n></out>"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user