From 8f992a8897b66f0044383780ed14cea11d52b06a Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Fri, 26 Jun 2026 01:13:07 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 4 +++ Sources/XSLT/XSLTElements.swift | 8 ++++- Sources/XSLT/XSLTNumbering.swift | 53 +++++++++++++++++++++++++++--- Sources/XSLT/XSLTTransformer.swift | 2 +- Tests/XSLTNumberSingleTests.swift | 49 +++++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 Tests/XSLTNumberSingleTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index fc68b5a..82f0c67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/XSLT/XSLTElements.swift b/Sources/XSLT/XSLTElements.swift index 76370a8..b79abbf 100644 --- a/Sources/XSLT/XSLTElements.swift +++ b/Sources/XSLT/XSLTElements.swift @@ -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) diff --git a/Sources/XSLT/XSLTNumbering.swift b/Sources/XSLT/XSLTNumbering.swift index e2c4b7d..e822e0f 100644 --- a/Sources/XSLT/XSLTNumbering.swift +++ b/Sources/XSLT/XSLTNumbering.swift @@ -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) { diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift index 4916961..eb4600f 100644 --- a/Sources/XSLT/XSLTTransformer.swift +++ b/Sources/XSLT/XSLTTransformer.swift @@ -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)) } diff --git a/Tests/XSLTNumberSingleTests.swift b/Tests/XSLTNumberSingleTests.swift new file mode 100644 index 0000000..10885eb --- /dev/null +++ b/Tests/XSLTNumberSingleTests.swift @@ -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 = """ + + + \(template) + + """ + 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 = "" + let template = #""# + #expect(try transform(source, template: template).hasSuffix("123")) + let explicit = #""# + #expect(try transform(source, template: explicit).hasSuffix("123")) + } + + @Test("a partial-match count numbers only the matching siblings") + func test_partialMatch() throws { + // The elements are interleaved but not counted, so the items + // keep ranks 1, 2, 3 among the item siblings. + let source = "" + let template = #""# + #expect(try transform(source, template: template).hasSuffix("123")) + } + + @Test("multiple level numbers each matching ancestor") + func test_multiple() throws { + let source = "" + // The innermost elements: the two inside the first sec are 1.1 and + // 1.2; the last top-level sec is 2. + let template = #""# + let out = try transform(source, template: template) + #expect(out.hasSuffix("1.11.22")) + } +}