perf(parse): adopt prepared children when materializing the tree (#293)
Building the mutable TreeNode tree from a parsed value Node ran append for every child. append -> insert detaches the child from any previous parent, rejects a self/ancestor cycle, and inserts into a children array that grows element by element. The children the conversion passes come from element.children.map and are freshly built and parentless, so all three guards are no-ops and the incremental growth rebuilds an array that was already prepared. TreeNode.init(adopting:) adopts the prepared array directly and wires each child's parent. The tree is identical: the same children in the same order with the same parent links. The adopting initializer is used only by the value-Node conversion; every public factory keeps the detaching, cycle-checking append path for externally supplied children that may already live in a tree. Measured parse ~12% faster on the 20k-item generated corpus via a same-state A/B (7.7x to ~6.5x slower than libxml2). Verified by TreeNodeAdoptingInitTests (parent-wiring and post-adoption mutability), the W3C XML conformance corpora, the Xalan XSLT gold-output corpus, the full suite, the WASM build, and an adversarial equivalence review.
This commit is contained in:
parent
7a5761b64d
commit
cc2e1aaf7c
@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Materializing the mutable `TreeNode` tree from a parsed value `Node` adopts each node's prepared children directly instead of appending them one at a time. Building a node ran `append` for every child, which detaches the child from any previous parent, rejects a cycle, and grows the children array element by element. The children produced by the value-`Node` conversion are freshly built and parentless, so those guards are always no-ops and the repeated growth is wasted; `TreeNode.init(adopting:)` adopts the already-built array and only wires each child's parent. The resulting tree is identical (same children in the same order, the same parent links). The adopting initializer is used only by the conversion; every public factory keeps the detaching, cycle-checking `append` path for externally supplied children. Measured `parse` ~12% faster on the 20k-item generated corpus via a same-machine-state A/B. Verified by a parent-wiring differential (`TreeNodeAdoptingInitTests`), the W3C XML conformance corpora, and an adversarial equivalence review.
|
||||
|
||||
- Element and attribute names split their prefix without a second pass over the name. The byte scanner (`takeASCIIName`) already visits every byte of an all-ASCII name, so it now also records the offset of the name's first colon at the cost of one comparison per byte. `QualifiedName(ascii:colonOffset:)` then splits at the known offset, and the common unprefixed name (no colon) is built with no scan at all, where `QualifiedName(_:)` previously ran `firstIndex(of:)` grapheme by grapheme over the whole name on every element and attribute. The resulting names are identical for every colon placement (none, leading, trailing, middle, doubled). Measured `parse` ~12% faster on the 20k-item generated corpus via a same-machine-state A/B. Verified by a colon-placement differential (`QualifiedNameColonHintTests`), the W3C XML conformance corpora, and an adversarial equivalence review.
|
||||
|
||||
- The entity decoder copies the literal text between references in bulk instead of one character at a time. `EntityExpander.expand` rebuilt every reference-bearing run grapheme by grapheme (`result.append(character)` with a per-character amplification-budget charge), so a text node holding a single `&` was reconstructed character by character; it now scans to the next `&` or `<` and appends the run in one operation, charging the budget by the run length. The decoded text, the CDATA-verbatim handling, and the billion-laughs budget enforcement are unchanged (the same overrun throws at the same input). The element-content reference-findings pass, which scanned each text run for `&` and rendered the enclosing element's qualified name, now returns immediately when the document has no DTD content models, which is the common case. Measured `parse` ~5% faster on the 20k-item generated corpus (whose text uses `&`) via a same-machine-state A/B. Verified by the W3C XML conformance corpora, a six-case decode differential (`EntityDecodeBulkTests`: references in runs, decimal and hex character references, declared-entity replacement with budget overrun, CDATA in replacement text, an unterminated reference), and an adversarial equivalence review.
|
||||
|
||||
@ -86,6 +86,31 @@ public extension PureXML.Model {
|
||||
}
|
||||
}
|
||||
|
||||
/// Adopts an array of freshly built, parentless children directly,
|
||||
/// wiring each child's parent without the detach, cycle check, and
|
||||
/// one-at-a-time array growth that ``append(_:)`` needs for children
|
||||
/// that may already live in a tree. The caller guarantees every child
|
||||
/// is newly constructed and unshared (as the value-`Node` conversion
|
||||
/// does), so those guards would all be no-ops; skipping them, and
|
||||
/// adopting the prepared array rather than rebuilding it element by
|
||||
/// element, is the bulk of the cost of materializing a parsed tree.
|
||||
init(
|
||||
adopting kind: PureXML.Model.TreeNodeKind,
|
||||
name: PureXML.Model.QualifiedName? = nil,
|
||||
attributes: [PureXML.Model.Attribute] = [],
|
||||
value: String = "",
|
||||
children: [TreeNode],
|
||||
) {
|
||||
self.kind = kind
|
||||
self.name = name
|
||||
self.attributes = attributes
|
||||
self.value = value
|
||||
self.children = children
|
||||
for child in children {
|
||||
child.parent = self
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Factories
|
||||
|
||||
/// Creates a document node holding the given top-level children.
|
||||
|
||||
@ -3,10 +3,10 @@ public extension PureXML.Model.TreeNode {
|
||||
convenience init(_ node: PureXML.Model.Node) {
|
||||
switch node {
|
||||
case let .document(children):
|
||||
self.init(kind: .document, children: children.map(PureXML.Model.TreeNode.init))
|
||||
self.init(adopting: .document, children: children.map(PureXML.Model.TreeNode.init))
|
||||
case let .element(element):
|
||||
self.init(
|
||||
kind: .element,
|
||||
adopting: .element,
|
||||
name: element.name,
|
||||
attributes: element.attributes,
|
||||
children: element.children.map(PureXML.Model.TreeNode.init),
|
||||
|
||||
70
Tests/TreeNodeAdoptingInitTests.swift
Normal file
70
Tests/TreeNodeAdoptingInitTests.swift
Normal file
@ -0,0 +1,70 @@
|
||||
import Testing
|
||||
@testable import PureXML
|
||||
|
||||
/// `parseTree` materializes the mutable tree through `TreeNode.init(adopting:)`,
|
||||
/// which adopts a prepared array of freshly built children and wires their
|
||||
/// parents directly instead of appending them one at a time. These pin that the
|
||||
/// adopted tree is indistinguishable from one built through `append`: every
|
||||
/// child's `parent` points back to its container, sibling order is preserved,
|
||||
/// and the tree stays correctly mutable afterwards (so the bypassed `append`
|
||||
/// bookkeeping left nothing inconsistent).
|
||||
@Suite("TreeNode adopting-init equivalence")
|
||||
struct TreeNodeAdoptingInitTests {
|
||||
private static let source = #"""
|
||||
<catalog xmlns:m="urn:m">
|
||||
<item id="i0" m:rank="0"><name>first</name><price>1</price></item>
|
||||
<item id="i1"><name>second</name><note>a & b</note></item>
|
||||
</catalog>
|
||||
"""#
|
||||
|
||||
private func assertParentsConsistent(_ node: PureXML.Model.TreeNode) {
|
||||
for child in node.children {
|
||||
#expect(child.parent === node)
|
||||
assertParentsConsistent(child)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("every child's parent points back to its container")
|
||||
func test_parentWiring() throws {
|
||||
let tree = try PureXML.parseTree(Self.source)
|
||||
#expect(tree.parent == nil)
|
||||
assertParentsConsistent(tree)
|
||||
}
|
||||
|
||||
@Test("sibling order is preserved through adoption")
|
||||
func test_childOrder() throws {
|
||||
let tree = try PureXML.parseTree(Self.source)
|
||||
guard let catalog = tree.children.first(where: { $0.kind == .element }) else {
|
||||
Issue.record("no catalog")
|
||||
return
|
||||
}
|
||||
let items = catalog.children.filter { $0.kind == .element }
|
||||
#expect(items.count == 2)
|
||||
#expect(items[0].attributes.first { $0.name.localName == "id" }?.value == "i0")
|
||||
#expect(items[1].attributes.first { $0.name.localName == "id" }?.value == "i1")
|
||||
let firstItemChildren = items[0].children.filter { $0.kind == .element }.map { $0.name?.localName }
|
||||
#expect(firstItemChildren == ["name", "price"])
|
||||
}
|
||||
|
||||
@Test("an adopted tree is still correctly mutable")
|
||||
func test_stillMutable() throws {
|
||||
let tree = try PureXML.parseTree(Self.source)
|
||||
guard let catalog = tree.children.first(where: { $0.kind == .element }),
|
||||
let firstItem = catalog.children.first(where: { $0.kind == .element })
|
||||
else {
|
||||
Issue.record("no item")
|
||||
return
|
||||
}
|
||||
let originalCount = catalog.children.count
|
||||
let added = PureXML.Model.TreeNode.element(PureXML.Model.QualifiedName("added"))
|
||||
catalog.append(added)
|
||||
#expect(added.parent === catalog)
|
||||
#expect(catalog.children.count == originalCount + 1)
|
||||
// Re-parenting a node already in the adopted tree detaches it cleanly.
|
||||
let secondItem = PureXML.Model.TreeNode.element(PureXML.Model.QualifiedName("wrapper"))
|
||||
catalog.append(secondItem)
|
||||
secondItem.append(firstItem)
|
||||
#expect(firstItem.parent === secondItem)
|
||||
#expect(!catalog.children.contains { $0 === firstItem })
|
||||
}
|
||||
}
|
||||
@ -71,6 +71,7 @@ offsets throughout; libxml2's xmlReadMemory takes an int byte count).
|
||||
| 17 | compile the step's node test once per query instead of re-evaluating it per node: the descendant/attribute walks called `{ matchesTree($0, step.test, on: step.axis, context.namespaces) }`, passing the `namespaces` dictionary (and the `NodeTest`'s associated string) by value on every one of tens of thousands of nodes, so the dominant traversal carried a dictionary retain/release per node. `compiledTreeTest`/`compiledAttributeTest`/`compiledNodeTest` resolve the axis principal kind, the binding state, and the name shape once and return a tight closure capturing only the local name. Equivalent to `matchesTree`/`matchesAttribute`/`matches` (exhaustive differential test + adversarial review; same-state A/B, ~35%) | unchanged | 0.013 s (~1.3x) | unchanged |
|
||||
| 18 | bulk-copy literal runs in the entity decoder: `EntityExpander.expand` rebuilt every reference-bearing text run one character at a time (`result.append(character)` with a per-character budget charge), so a text node holding a single `&` was copied grapheme by grapheme; it now scans to the next `&`/`<` and appends the literal run in one go (charging the budget by the run length). Also skips the element-content reference-findings pass (an `&` scan plus a qualified-name render per text node) when there is no DTD. Identical decoded text (W3C XML conformance corpora + 6-case differential + adversarial review; same-state A/B, ~5%) | 0.138 s (~8.5x) | unchanged | unchanged |
|
||||
| 19 | carry the name's first-colon offset out of the byte scanner so `QualifiedName` splits the prefix without a second pass: `takeASCIIName` already visits every name byte, so it records the first `:` (one compare per byte); `QualifiedName(ascii:colonOffset:)` then splits at the known offset, and the common unprefixed name (no colon) takes no scan at all instead of a grapheme-by-grapheme `firstIndex(of:)` over the whole name. Identical names (colon-placement differential + W3C conformance + adversarial review; same-state A/B, ~12%) | 0.124 s (~7.7x) | unchanged | unchanged |
|
||||
| 20 | adopt prepared children when materializing the tree instead of appending them one at a time: building the mutable `TreeNode` tree from the parsed value `Node` ran `append` per child, which detaches the child from any old parent, checks for a cycle, and grows the children array element by element; the converted children are freshly built and parentless, so `TreeNode.init(adopting:)` takes the prepared array directly and only wires each child's parent. Identical tree (parent-wiring differential + W3C conformance + adversarial review; same-state A/B, ~12%) | 0.106 s (~6.5x) | unchanged | unchanged |
|
||||
|
||||
## Profile findings (first sample, parse path)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user