From 7a5761b64d67388f7e37d9193860ac1519dec397 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Thu, 25 Jun 2026 12:23:53 +0200 Subject: [PATCH] perf(parse): split the name prefix from a scanned colon offset (#292) QualifiedName(_:) ran firstIndex(of: ":") over the whole name, grapheme by grapheme, on every element and attribute. Most names have no prefix, so the common case scanned the entire name to find no colon at all. The byte scanner already visits every byte of an all-ASCII name, so have takeASCIIName record the offset of the first colon (one comparison per byte), and split at that known offset in QualifiedName(ascii:colonOffset:). The unprefixed name (no colon) is then built with no scan; a prefixed name splits at the recorded offset. The names are identical to the firstIndex path for every colon placement: none, leading (:local), trailing (prefix:), middle, doubled (::), and multiple (first colon wins). The hint is confined to the all-ASCII fast path; non-ASCII names and every other construction site keep QualifiedName(_:). Measured parse ~12% faster on the 20k-item generated corpus via a same-state A/B (8.5x to ~7.7x slower than libxml2). Verified by QualifiedNameColonHintTests (colon-placement differential), the W3C XML conformance corpora, the Xalan XSLT gold-output corpus, the full suite, the WASM build, and an adversarial equivalence review. --- CHANGELOG.md | 2 + Sources/Model/QualifiedName.swift | 25 ++++++++++++ Sources/Parsing/EventReader.swift | 2 +- Sources/Parsing/Reader.swift | 15 ++++++-- Tests/QualifiedNameColonHintTests.swift | 51 +++++++++++++++++++++++++ docs/benchmarks.md | 1 + 6 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 Tests/QualifiedNameColonHintTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 257861b..3e2f0e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- 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. ## [0.4.1] - 2026-06-25 diff --git a/Sources/Model/QualifiedName.swift b/Sources/Model/QualifiedName.swift index bd8b57d..c7fde30 100644 --- a/Sources/Model/QualifiedName.swift +++ b/Sources/Model/QualifiedName.swift @@ -45,6 +45,31 @@ public extension PureXML.Model { } } + /// Builds a name whose first colon is already known by byte offset (the + /// byte scanner found it while reading the name), so the prefix split + /// needs no second scan. `colonOffset` is nil for the common unprefixed + /// name. The split follows ``init(_:)`` exactly: a colon at the start or + /// end (`:local`, `prefix:`) does not split. The name is all-ASCII, so a + /// byte offset is a character offset. + init(ascii raw: String, colonOffset: Int?) { + namespaceURI = nil + guard let colonOffset else { + prefix = nil + localName = raw + return + } + let colon = raw.index(raw.startIndex, offsetBy: colonOffset) + let head = String(raw[raw.startIndex ..< colon]) + let tail = String(raw[raw.index(after: colon)...]) + if head.isEmpty || tail.isEmpty { + prefix = nil + localName = raw + } else { + prefix = head + localName = tail + } + } + /// The full `prefix:local` rendering used in serialized output. public var description: String { guard let prefix else { return localName } diff --git a/Sources/Parsing/EventReader.swift b/Sources/Parsing/EventReader.swift index 564b088..fa4fe6b 100644 --- a/Sources/Parsing/EventReader.swift +++ b/Sources/Parsing/EventReader.swift @@ -289,7 +289,7 @@ public extension PureXML.Parsing { // no per-character buffering. The Character loop below handles a // non-ASCII name, the non-fast-path, and the too-long error. if let fast = reader.takeASCIIName(maxLength: limits.maxNameLength) { - return PureXML.Model.QualifiedName(fast) + return PureXML.Model.QualifiedName(ascii: fast.name, colonOffset: fast.colon) } guard let first = reader.peek(), first.isXMLNameStart else { throw ParseError.expectedName(reader.mark) diff --git a/Sources/Parsing/Reader.swift b/Sources/Parsing/Reader.swift index 0c99eef..a6f3212 100644 --- a/Sources/Parsing/Reader.swift +++ b/Sources/Parsing/Reader.swift @@ -222,10 +222,18 @@ extension PureXML.Parsing { /// exceeds `maxLength`, so the caller falls back to the Character scanner /// (which handles the full Unicode name grammar and the length error). /// A name has no newlines, so position tracking is plain arithmetic. - mutating func takeASCIIName(maxLength: Int) -> String? { + /// Scans an all-ASCII name from the byte storage, also reporting the + /// offset of the name's first colon (or nil when it has none). The + /// scanner already visits every byte, so noting the colon costs one + /// compare per byte and lets ``Model/QualifiedName`` split the prefix + /// without a second pass over the name (the common unprefixed name then + /// splits without scanning at all). + mutating func takeASCIIName(maxLength: Int) -> (name: String, colon: Int?)? { guard buffer.isEmpty, pendingRaw == nil, let storage, byteIndex < storage.count else { return nil } let pointer = storage.pointer - guard PureXML.Parsing.XMLCharacter.isASCIINameStart(pointer[byteIndex]) else { return nil } + let startByte = pointer[byteIndex] + guard PureXML.Parsing.XMLCharacter.isASCIINameStart(startByte) else { return nil } + var colon: Int? = startByte == 0x3A ? 0 : nil var index = byteIndex + 1 let limit = byteIndex + maxLength while index < storage.count { @@ -233,6 +241,7 @@ extension PureXML.Parsing { if byte >= 0x80 { return nil } if !PureXML.Parsing.XMLCharacter.isASCIINameChar(byte) { break } if index >= limit { return nil } + if colon == nil, byte == 0x3A { colon = index - byteIndex } index += 1 } let length = index - byteIndex @@ -240,7 +249,7 @@ extension PureXML.Parsing { byteIndex = index offset += length column += length - return run + return (run, colon) } /// If the upcoming characters equal `literal`, consume them and return true. diff --git a/Tests/QualifiedNameColonHintTests.swift b/Tests/QualifiedNameColonHintTests.swift new file mode 100644 index 0000000..ea64200 --- /dev/null +++ b/Tests/QualifiedNameColonHintTests.swift @@ -0,0 +1,51 @@ +import Testing +@testable import PureXML + +/// `QualifiedName(ascii:colonOffset:)` splits a scanned ASCII name using the +/// first-colon offset the byte scanner already found, instead of re-scanning +/// the name with `firstIndex(of:)`. It must produce exactly what +/// `QualifiedName(_:)` produces for the same name. This pins that equivalence +/// across every colon placement: none, leading, trailing, middle, doubled, and +/// a bare colon, so the hint path can never disagree with the scanning path. +@Suite("QualifiedName colon-hint equivalence") +struct QualifiedNameColonHintTests { + private static let names = [ + "item", "name", "price", "id", "kind", "currency", + "m:rank", "xml:space", "a:b", "x:y:z", + ":local", "prefix:", "::", ":", "a:", ":b", + "_underscore", "with-dash", "with.dot", "n123", + ] + + /// The offset of the first ':' (a byte offset, which for an ASCII name is a + /// character offset), or nil. This mirrors what `takeASCIIName` records. + private func firstColonOffset(_ name: String) -> Int? { + name.firstIndex(of: ":").map { name.distance(from: name.startIndex, to: $0) } + } + + @Test("the hint init equals the scanning init for every colon placement") + func test_equivalence() { + for name in Self.names { + let scanned = PureXML.Model.QualifiedName(name) + let hinted = PureXML.Model.QualifiedName(ascii: name, colonOffset: firstColonOffset(name)) + #expect(hinted.prefix == scanned.prefix, "prefix mismatch for \(name)") + #expect(hinted.localName == scanned.localName, "localName mismatch for \(name)") + #expect(hinted == scanned, "qualified name mismatch for \(name)") + } + } + + @Test("prefixed and unprefixed ASCII names round-trip through the parser") + func test_throughParser() throws { + let node = try PureXML.parse(#""#) + guard case let .document(children) = node, let root = children.compactMap(\.element).first, + case let .element(child)? = root.children.first + else { + Issue.record("no child element") + return + } + #expect(child.name.prefix == "m") + #expect(child.name.localName == "child") + let attributeNames = child.attributes.map(\.name) + #expect(attributeNames.contains { $0.prefix == nil && $0.localName == "plain" }) + #expect(attributeNames.contains { $0.prefix == "m" && $0.localName == "flag" }) + } +} diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 6af35a8..79bb130 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -70,6 +70,7 @@ offsets throughout; libxml2's xmlReadMemory takes an int byte count). | 16 | share the focus-independent evaluation state via an `Environment` reference: `focused(on:position:size:)` (called once per node of a predicate's input) copied the variables, functions, and namespace dictionaries every time; it now copies only node/position/size and shares one reference. Same semantics (same-state A/B, ~16%) | unchanged | 0.020 s (~2.0x) | unchanged | | 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 | ## Profile findings (first sample, parse path)