perf(serialize): precompute the attribute quote, bulk-copy comment/PI escaping

Two profile-guided serialize passes (#139, docs/benchmarks.md passes 29-30).
The attribute writer now precomputes the quote character as a String once
per element instead of interpolating a Character into every attribute,
avoiding the grapheme-breadcrumb machinery that repeats twice per attribute
for a value that never changes within an element. Escaping.comment and
processingInstruction's escaping fallback (taken whenever a comment has a
hyphen or PI data has a '?', a case pass 8 could skip past but not speed up)
is rewritten in the same byte-scan bulk-copy shape as passes 23-24, so a
value with one rare trigger no longer pays a full character-by-character
rebuild.

Both are same-observable-behavior changes. The quote change is byte-
identical by construction. The comment/PI rewrite is verified against a new
differential suite that reimplements the original character-loop as an
independent oracle and checks adjacency/trailing/combining-mark cases plus
two 20000-case seeded fuzzes, since a byte match inside a combining-mark
cluster is not, on its own, the bare trigger character. Full local suite
(1925 tests) unchanged.

Measured with interleaved same-state A/B: pass 29 ~1.5% at a 100k-item
corpus; pass 30 ~2x on a dedicated comment/PI micro-benchmark (the standard
corpus has no comment/PI content, so real documents with copyright banners
or hyphenated comment prose see this, not the headline ratio). A same-
session multi-append restructuring attempt measured a clear ~6% regression
and was reverted; a suspected hasInlineContent double-scan was profiled at
~0.2% of samples and not worth a pass. Both outcomes are recorded in
docs/benchmarks.md rather than dropped.
This commit is contained in:
Mihaela Mihaljevic 2026-07-15 04:05:29 +02:00
parent 380160cc2b
commit d359208ff6
5 changed files with 270 additions and 6 deletions

View File

@ -50,6 +50,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Two serialize-path optimization passes (#139, `docs/benchmarks.md` passes
29-30), found by sample-profiling the serialize loop: the attribute writer
precomputes the quote character as a `String` once per element instead of
interpolating a `Character` into every attribute (a `Character`
interpolation goes through the string's grapheme-breadcrumb machinery, work
repeated twice per attribute for a value that never changes within an
element), and `Escaping.comment`/`processingInstruction`'s escaping
fallback (taken whenever a comment has a hyphen or PI data has a `?`, a
case pass 8 could only skip past, not speed up) is rewritten in the same
byte-scan bulk-copy shape as passes 23-24, so a comment or PI value with
one rare trigger no longer pays a full character-by-character rebuild. Both
are same-observable-behavior changes: the quote change is byte-identical by
construction (a `Character` and its single-scalar `String` render
identically); the comment/PI rewrite is verified against a new differential
suite (`Tests/CommentAndPIEscapingDifferentialTests.swift`) that
reimplements the original character-loop as an independent oracle and
checks hand-picked adjacency/trailing/combining-mark cases plus two
20000-case seeded fuzzes, since a byte match inside a combining-mark
cluster is not, on its own, the bare trigger character. Full local suite
(1925 tests) unchanged. Measured with interleaved same-state A/B: pass 29
~1.5% at a 100k-item corpus (clean, two pairs); pass 30 ~2x on a dedicated
comment/PI micro-benchmark (no comment/PI content exists in the standard
corpus, so it does not move the headline ratio there, but real-world
documents with copyright banners or hyphenated prose in comments exercise
it directly). A same-session multi-append restructuring attempt (splitting
a single interpolated `output +=` into several direct appends) measured a
clear ~6% regression and was reverted rather than landed; a suspected
`hasInlineContent` double-scan of an element's children was profiled and
found to contribute ~0.2% of samples, not worth a pass. Both outcomes are
recorded in `docs/benchmarks.md` rather than silently dropped.
- A fourth parse-path optimization pass (#139, `docs/benchmarks.md` pass 28): - A fourth parse-path optimization pass (#139, `docs/benchmarks.md` pass 28):
`scanAttributes`' duplicate-name check reuses one stored scratch `scanAttributes`' duplicate-name check reuses one stored scratch
`Set<String>` (cleared with `removeAll(keepingCapacity: true)` per element) `Set<String>` (cleared with `removeAll(keepingCapacity: true)` per element)

View File

@ -168,15 +168,30 @@ extension PureXML.Emitting {
} }
var result = "" var result = ""
var previousHyphen = false var previousHyphen = false
for character in value { var runStart = value.startIndex
// Find each candidate hyphen byte, copy the verbatim run before it in
// one append, and resolve it to its full grapheme (a byte match inside
// a combining-mark cluster is not a bare "-" and is copied like any
// other character); only a genuine "-" can extend or start a run.
while let marker = value.utf8[runStart...].firstIndex(where: { $0 == 0x2D }) {
if runStart < marker {
result += value[runStart ..< marker]
previousHyphen = false
}
let character = value[marker]
if character == "-", previousHyphen { if character == "-", previousHyphen {
result.append(" ") result += " "
} }
result.append(character) result.append(character)
previousHyphen = character == "-" previousHyphen = character == "-"
runStart = value.index(after: marker)
}
if runStart < value.endIndex {
result += value[runStart...]
previousHyphen = false
} }
if previousHyphen { if previousHyphen {
result.append(" ") result += " "
} }
return result return result
} }
@ -192,12 +207,25 @@ extension PureXML.Emitting {
} }
var result = "" var result = ""
var previousQuestion = false var previousQuestion = false
for character in data { var runStart = data.startIndex
// Candidates are '?' (may start the offending pair) and '>' (may close
// it); every other character, including one that merely shares a byte
// with a combining-mark cluster, is bulk-copied and resets the flag.
while let marker = data.utf8[runStart...].firstIndex(where: { $0 == 0x3F || $0 == 0x3E }) {
if runStart < marker {
result += data[runStart ..< marker]
previousQuestion = false
}
let character = data[marker]
if character == ">", previousQuestion { if character == ">", previousQuestion {
result.append(" ") result += " "
} }
result.append(character) result.append(character)
previousQuestion = character == "?" previousQuestion = character == "?"
runStart = data.index(after: marker)
}
if runStart < data.endIndex {
result += data[runStart...]
} }
return result return result
} }

View File

@ -103,9 +103,10 @@ public extension PureXML.Emitting {
} }
output += "<" + element.name.description output += "<" + element.name.description
let quote = options.attributeQuote.character let quote = options.attributeQuote.character
let quoteString = String(quote)
for attribute in element.attributes { for attribute in element.attributes {
let value = Escaping.attribute(attribute.value, quote: quote, asciiOnly: options.asciiOnly) let value = Escaping.attribute(attribute.value, quote: quote, asciiOnly: options.asciiOnly)
output += " \(attribute.name.description)=\(quote)\(value)\(quote)" output += " \(attribute.name.description)=\(quoteString)\(value)\(quoteString)"
} }
if element.children.isEmpty, options.selfCloseEmptyElements { if element.children.isEmpty, options.selfCloseEmptyElements {

View File

@ -0,0 +1,120 @@
import Testing
@testable import PureXML
/// Reference oracle for `Escaping.comment`/`processingInstruction`: the original
/// character-by-character algorithm the byte-scanning bulk-copy rewrite must
/// match exactly, including when a hyphen/`?`/`>` combines with a following
/// combining mark into a single extended grapheme cluster (a byte match that is
/// not, on its own, that bare character).
private enum ReferenceEscaping {
static func comment(_ value: String) -> String {
var result = ""
var previousHyphen = false
for character in value {
if character == "-", previousHyphen {
result.append(" ")
}
result.append(character)
previousHyphen = character == "-"
}
if previousHyphen {
result.append(" ")
}
return result
}
static func processingInstruction(_ data: String) -> String {
var result = ""
var previousQuestion = false
for character in data {
if character == ">", previousQuestion {
result.append(" ")
}
result.append(character)
previousQuestion = character == "?"
}
return result
}
}
private struct DifferentialRNG: RandomNumberGenerator {
private var state: UInt64
init(seed: UInt64) {
state = seed == 0 ? 0x9E37_79B9_7F4A_7C15 : seed
}
mutating func next() -> UInt64 {
state ^= state >> 12
state ^= state << 25
state ^= state >> 27
return state &* 0x2545_F491_4F6C_DD1D
}
}
@Suite("Comment/PI escaping: bulk-copy rewrite matches the character-by-character oracle")
struct CommentAndPIEscapingDifferentialTests {
private typealias Escaping = PureXML.Emitting.Escaping
private static let combiningMarks: [Character] = ["\u{0301}", "\u{0332}", "\u{20D0}"]
@Test("comment: hand-picked adjacency and combining-mark cases")
func test_comment_handPicked() {
let cases = [
"", "-", "--", "---", "----",
"a-b", "a--b", "a---b", "a----b",
"-a", "--a", "a-", "a--",
"trailing-", "trailing--", "trailing---",
"-\u{0301}", "-\u{0301}-", "a-\u{0301}-b", "--\u{0301}-",
"hyphen-\u{0332}ated words are fine-ish- probably",
]
for value in cases {
#expect(
Escaping.comment(value) == ReferenceEscaping.comment(value),
"mismatch for \(value.debugDescription)",
)
}
}
@Test("processing instruction: hand-picked adjacency and combining-mark cases")
func test_pi_handPicked() {
let cases = [
"", "?", ">", "?>", "??>", "?>>", "??>>", "?a>", "a?>b",
"?\u{0301}>", "?\u{0301}>b", "a?\u{0301}>", "?>?>?>",
"?a?b>c", "a>b?c",
]
for value in cases {
#expect(
Escaping.processingInstruction(value) == ReferenceEscaping.processingInstruction(value),
"mismatch for \(value.debugDescription)",
)
}
}
@Test("comment: seeded fuzz across hyphen/combining-mark alphabets")
func test_comment_fuzz() {
var rng = DifferentialRNG(seed: 12345)
let alphabet: [Character] = ["a", "b", "-"] + Self.combiningMarks
for _ in 0 ..< 20000 {
let length = Int.random(in: 0 ..< 12, using: &rng)
let value = String((0 ..< length).map { _ in alphabet[Int.random(in: 0 ..< alphabet.count, using: &rng)] })
#expect(
Escaping.comment(value) == ReferenceEscaping.comment(value),
"mismatch for \(value.debugDescription)",
)
}
}
@Test("processing instruction: seeded fuzz across ?/>/combining-mark alphabets")
func test_pi_fuzz() {
var rng = DifferentialRNG(seed: 67890)
let alphabet: [Character] = ["a", "b", "?", ">"] + Self.combiningMarks
for _ in 0 ..< 20000 {
let length = Int.random(in: 0 ..< 12, using: &rng)
let value = String((0 ..< length).map { _ in alphabet[Int.random(in: 0 ..< alphabet.count, using: &rng)] })
#expect(
Escaping.processingInstruction(value) == ReferenceEscaping.processingInstruction(value),
"mismatch for \(value.debugDescription)",
)
}
}
}

View File

@ -76,6 +76,8 @@ offsets throughout; libxml2's xmlReadMemory takes an int byte count).
| 22 | build the mutable `TreeNode` tree directly from the event stream instead of building the value `Node` tree and converting it: `parseTree` ran `TreeNode(parse(...))`, allocating two full trees (the value tree, then a converted reference tree). `Parser.buildTree` is the direct counterpart to `build` (same event loop, stack discipline, and well-formedness guards) that accumulates `TreeNode` children per frame and adopts them when an element closes, so the tree is allocated once. Identical tree (structural differential against the convert path + the whole suite and conformance corpora, which all run through `parseTree`, + adversarial review; same-state A/B, ~8%) | 0.090 s (~5.5x) | unchanged | unchanged | | 22 | build the mutable `TreeNode` tree directly from the event stream instead of building the value `Node` tree and converting it: `parseTree` ran `TreeNode(parse(...))`, allocating two full trees (the value tree, then a converted reference tree). `Parser.buildTree` is the direct counterpart to `build` (same event loop, stack discipline, and well-formedness guards) that accumulates `TreeNode` children per frame and adopts them when an element closes, so the tree is allocated once. Identical tree (structural differential against the convert path + the whole suite and conformance corpora, which all run through `parseTree`, + adversarial review; same-state A/B, ~8%) | 0.090 s (~5.5x) | unchanged | unchanged |
| 23 | bulk-copy the verbatim runs when escaping output: `Escaping.text`/`attribute` rebuilt a value that needed escaping character by character (`result += String(character)` per unescaped character), so re-escaping a text node that holds a single `&` rebuilt it grapheme by grapheme; they now copy each run between escapable characters in one append and only the markers are rewritten. The `*NeedsEscaping` fast path (no rebuild when nothing needs escaping) is unchanged. Byte-identical output (per-character-oracle differential with multibyte cases + Apache Xalan gold output + C14N suite + adversarial 400k fuzz; same-state A/B, ~7%) | unchanged | unchanged | 0.029 s (~2.8x) | | 23 | bulk-copy the verbatim runs when escaping output: `Escaping.text`/`attribute` rebuilt a value that needed escaping character by character (`result += String(character)` per unescaped character), so re-escaping a text node that holds a single `&` rebuilt it grapheme by grapheme; they now copy each run between escapable characters in one append and only the markers are rewritten. The `*NeedsEscaping` fast path (no rebuild when nothing needs escaping) is unchanged. Byte-identical output (per-character-oracle differential with multibyte cases + Apache Xalan gold output + C14N suite + adversarial 400k fuzz; same-state A/B, ~7%) | unchanged | unchanged | 0.029 s (~2.8x) |
| 24 | find the characters to escape by scanning bytes, not graphemes: the bulk-escape loop still iterated `Character`s and compared each to `&`/`<`/`>` (a grapheme compare per character); since every trigger is an ASCII marker, or in ASCII-only mode a non-ASCII lead byte, `value.utf8.firstIndex(where:)` locates the next one without decoding the run into graphemes (the marker byte lands on a grapheme boundary, so the string slice and subscript stay valid). Byte-identical output (the oracle differential extended with combining-mark, ZWJ, and flag clusters + the gold/C14N suites + adversarial grapheme-alignment review; same-state A/B, ~23%) | unchanged | unchanged | 0.022 s (~2.2x) | | 24 | find the characters to escape by scanning bytes, not graphemes: the bulk-escape loop still iterated `Character`s and compared each to `&`/`<`/`>` (a grapheme compare per character); since every trigger is an ASCII marker, or in ASCII-only mode a non-ASCII lead byte, `value.utf8.firstIndex(where:)` locates the next one without decoding the run into graphemes (the marker byte lands on a grapheme boundary, so the string slice and subscript stay valid). Byte-identical output (the oracle differential extended with combining-mark, ZWJ, and flag clusters + the gold/C14N suites + adversarial grapheme-alignment review; same-state A/B, ~23%) | unchanged | unchanged | 0.022 s (~2.2x) |
| 29 | precompute the attribute quote as a `String` once per element instead of interpolating the `Character` into every attribute's `"...\(quote)...\(quote)"`: a `Character` interpolation goes through `Character.write(to:)` and the string's grapheme-breadcrumb machinery, work repeated twice per attribute for a value that is the same one or two bytes every time in a given element. `quoteString` is built once outside the attribute loop and interpolated as a `String` (a plain code-unit append, no grapheme handling). Output is unchanged (a `Character` and its single-scalar `String` render identically); full local suite (1921 tests, including the gold/C14N/Xalan serializer suites) unchanged. Same-state interleaved A/B: clean at the 100k-item corpus (two pairs, patched 0.1696-0.1698 s vs baseline 0.1717-0.1728 s, both patched runs beating both baseline runs, ~1.5%); noisier but still favorable at the 20k corpus (4 pairs, one round overlapped). Trust the larger, cleaner corpus per this log's rule. | unchanged | unchanged | ~1.5% at 100k corpus |
| 30 | `Escaping.comment`/`processingInstruction`'s escaping fallback (taken whenever a comment has a hyphen or PI data has a `?`, which pass 8 could only skip past, not speed up): the fallback still rebuilt the whole value one `Character` at a time even though the space it must insert is rare (only at a `--`/`?>` collision), so a long comment with one hyphen anywhere still paid a full character-by-character copy. Rewritten in the same bulk-copy shape as passes 23/24: scan the byte view for the candidate trigger byte (`-` for comment; `?` or `>` for PI, since either can matter to the adjacency check), copy the verbatim run before it in one append, and resolve the actual decision through `Character` equality at that position exactly as the original loop did, so a hyphen/`?`/`>` that is not its own grapheme (a combining mark attached to it, changing the composed `Character`) is copied verbatim exactly as before, never misread from its lone UTF-8 byte. Verified by a dedicated differential suite (`Tests/CommentAndPIEscapingDifferentialTests.swift`) that reimplements the original character-loop as an independent oracle and checks equality over hand-picked adjacency/trailing/combining-mark cases plus two 20000-case seeded fuzzes (one per function); full local suite (1925 tests) passes. No comment/PI content exists in the standard benchmark corpus, so this was measured with a dedicated micro-benchmark (a document of 300000 alternating realistic comment/PI nodes - hyphenated prose, copyright lines, `?`-bearing PI data - driven through the public `PureXML.serialize` entry point, since `Escaping` is internal): same-state interleaved A/B, clean separation across 4 runs, old 0.451-0.455 s vs new 0.223-0.224 s, ~2x. | unchanged | unchanged | ~2x on comment/PI-bearing content (no effect on the standard corpus, which has none) |
| 25 | `scanAttributeValue`'s closing-quote check: after the content loop peeks the next character to decide whether it is the closing quote (breaking either on a match or on end of input, the only two cases), `reader.consume(String(quote))` re-derived the same answer through `matches`/`consume`'s general path, which allocates a fresh `String(quote)` and iterates it via `Sequence`/`Character` machinery twice (once to compare, once to count how many times to `advance()`), on every single attribute value in the document. Checking the already-buffered peek directly (`reader.peek() == quote`) and calling `advance()` once is the same observable behavior (same throw on end-of-input, same consumption on a match) for far less work. Verified: full local suite (1852 tests) and the full W3C XSTS suite (14383 groups, ratchet unchanged) pass unchanged. | 0.129 s (fresh session baseline; see note below) | unchanged | unchanged | | 25 | `scanAttributeValue`'s closing-quote check: after the content loop peeks the next character to decide whether it is the closing quote (breaking either on a match or on end of input, the only two cases), `reader.consume(String(quote))` re-derived the same answer through `matches`/`consume`'s general path, which allocates a fresh `String(quote)` and iterates it via `Sequence`/`Character` machinery twice (once to compare, once to count how many times to `advance()`), on every single attribute value in the document. Checking the already-buffered peek directly (`reader.peek() == quote`) and calling `advance()` once is the same observable behavior (same throw on end-of-input, same consumption on a match) for far less work. Verified: full local suite (1852 tests) and the full W3C XSTS suite (14383 groups, ratchet unchanged) pass unchanged. | 0.129 s (fresh session baseline; see note below) | unchanged | unchanged |
| 26 | `NamespaceContext.enterElement`, called once per element: (a) `checkNameShape`'s `name.localName.contains(":")` used a `Character` literal, resolving to `Sequence.contains(_:)`'s grapheme-by-grapheme walk to find one ASCII byte; `localName.utf8.contains(0x3A)` finds the same colon via the UTF-8 byte view, since 0x3A is never a lead or continuation byte of a multi-byte sequence. (b) `attributes.map { try resolveAttribute($0, at: mark) }` allocated a fresh backing array on every element even though `resolveAttribute` returns its argument completely unchanged whenever the attribute has no prefix or the `xmlns` prefix (the common case: most attributes in most documents are unprefixed); checking `attributes.contains { prefix != nil && prefix != "xmlns" }` first (no allocation) and reusing `attributes` directly when nothing would change gives the identical `resolvedAttributes` value for the common case without the allocation. Verified: full local suite and full W3C XSTS suite pass unchanged. | 0.121 s (~6.00x from 0.129 s at pass 25's baseline) | unchanged | unchanged | | 26 | `NamespaceContext.enterElement`, called once per element: (a) `checkNameShape`'s `name.localName.contains(":")` used a `Character` literal, resolving to `Sequence.contains(_:)`'s grapheme-by-grapheme walk to find one ASCII byte; `localName.utf8.contains(0x3A)` finds the same colon via the UTF-8 byte view, since 0x3A is never a lead or continuation byte of a multi-byte sequence. (b) `attributes.map { try resolveAttribute($0, at: mark) }` allocated a fresh backing array on every element even though `resolveAttribute` returns its argument completely unchanged whenever the attribute has no prefix or the `xmlns` prefix (the common case: most attributes in most documents are unprefixed); checking `attributes.contains { prefix != nil && prefix != "xmlns" }` first (no allocation) and reusing `attributes` directly when nothing would change gives the identical `resolvedAttributes` value for the common case without the allocation. Verified: full local suite and full W3C XSTS suite pass unchanged. | 0.121 s (~6.00x from 0.129 s at pass 25's baseline) | unchanged | unchanged |
| 27 | Sample-profiling isolated `scanContentMarkup()` (the inlined dispatcher covering `scanStartTag`/`scanAttributes`/`scanAttributeValue`/`NamespaceContext.enterElement`) at ~46% of total parse time. Three further findings in the same shapes as passes 25/26: (a) `scanAttributeValue`'s opening quote check still ran on the Character path (`reader.peek()` + `reader.advance()`) even though the byte is known ASCII and non-CR/LF; `peekByte()` plus `reader.consume(_:)` reaches the byte fast path directly (`peekByte()` only returns non-nil when the lookahead buffer is already empty, so `consume`'s own `matchesBytes` engages with no Character buffering at all), extended to the closing-quote check too (a byte peek before the Character peek, avoiding buffering the quote as a Character only to compare and discard it). (b) `NamespaceContext.checkExpandedNames` allocated a `Set<String>`'s storage and built a concatenated key per element even when 0 or 1 attribute carries a namespace URI, the overwhelming majority of elements; the check can only ever throw when two URI-bearing attributes collide, so counting first (no allocation) and returning early below 2 skips the allocation entirely in that case (for the built-in `xml` namespace URI specifically, the key string is long enough to need its own heap allocation, so this also cuts a real per-`xml:*`-attribute allocation). (c) `EntityDecoder.decode`/`decodeLenient`'s `raw.contains("&")` guard was the same grapheme-walk-for-one-ASCII-byte shape pass 26(a) already fixed elsewhere; `raw.utf8.contains(0x26)` finds the same `&` via the UTF-8 byte view. Verified: full local suite (1858 tests, 6 new covering the quote-handling edge cases: empty value, single-quoted, a quote-kind nested inside the other, unterminated at end of input, and byte/Character-path agreement for all of them) and full W3C XSTS suite (14383 groups) pass unchanged. | 0.099 s (~4.75x from 0.121 s at pass 26's baseline) | unchanged | unchanged | | 27 | Sample-profiling isolated `scanContentMarkup()` (the inlined dispatcher covering `scanStartTag`/`scanAttributes`/`scanAttributeValue`/`NamespaceContext.enterElement`) at ~46% of total parse time. Three further findings in the same shapes as passes 25/26: (a) `scanAttributeValue`'s opening quote check still ran on the Character path (`reader.peek()` + `reader.advance()`) even though the byte is known ASCII and non-CR/LF; `peekByte()` plus `reader.consume(_:)` reaches the byte fast path directly (`peekByte()` only returns non-nil when the lookahead buffer is already empty, so `consume`'s own `matchesBytes` engages with no Character buffering at all), extended to the closing-quote check too (a byte peek before the Character peek, avoiding buffering the quote as a Character only to compare and discard it). (b) `NamespaceContext.checkExpandedNames` allocated a `Set<String>`'s storage and built a concatenated key per element even when 0 or 1 attribute carries a namespace URI, the overwhelming majority of elements; the check can only ever throw when two URI-bearing attributes collide, so counting first (no allocation) and returning early below 2 skips the allocation entirely in that case (for the built-in `xml` namespace URI specifically, the key string is long enough to need its own heap allocation, so this also cuts a real per-`xml:*`-attribute allocation). (c) `EntityDecoder.decode`/`decodeLenient`'s `raw.contains("&")` guard was the same grapheme-walk-for-one-ASCII-byte shape pass 26(a) already fixed elsewhere; `raw.utf8.contains(0x26)` finds the same `&` via the UTF-8 byte view. Verified: full local suite (1858 tests, 6 new covering the quote-handling edge cases: empty value, single-quoted, a quote-kind nested inside the other, unterminated at end of input, and byte/Character-path agreement for all of them) and full W3C XSTS suite (14383 groups) pass unchanged. | 0.099 s (~4.75x from 0.121 s at pass 26's baseline) | unchanged | unchanged |
@ -134,6 +136,88 @@ patched.
`Character` and dropping it costs about the same as `consume`'s `Character` and dropping it costs about the same as `consume`'s
literal-matching path. Sample attribution at this granularity overstates literal-matching path. Sample attribution at this granularity overstates
single-character round trips; trust only the interleaved A/B. single-character round trips; trust only the interleaved A/B.
- **Dead end, do not redo: name interning (libxml2's `xmlDict`), at ANY name
length.** A bounded byte-span-to-`String` intern cache in `takeASCIIName`
(256-slot open addressing on `ByteStorage`, FNV over the span, correctness-
and XSTS-verified) was measured in two rounds. Round one, on the standard
short-name corpus, was neutral. That first result looked explainable by
Swift's small-string optimization alone (**strings up to 15 UTF-8 bytes are
stored inline, so building a short ASCII name allocates nothing**), which
would have left interning a live option for documents whose names exceed
15 bytes and therefore heap-allocate per occurrence. Round two tested
exactly that boundary with two corpora, one all names <=15 bytes and one
all names >15 bytes (element and attribute names 20-26 bytes, few distinct,
each repeated 100k times), interning gated to fire only above the
small-string threshold. Result (two interleaved A/B pairs each, XSTS clean):
neutral-to-slightly-negative on short names, and **decisively NEGATIVE on
long names, ~4-5% slower, both pairs** — the opposite of the hypothesis.
The complete mechanism, which the boundary test forced out: a Swift
`String` is reference-counted, so interning does not merely avoid an
allocation, it trades that allocation for **ARC traffic on the shared
instance** — every cache probe copies a `String` out of a slot (a retain),
and a hit returns another retain. For the name sizes that occur in XML,
the atomic retain/release traffic to share one heap `String` costs more
than the small `malloc` + memcpy + UTF-8 validation it was meant to
replace. `xmlDict` pays off in C precisely because a C name is a raw
pointer with no refcount: sharing is free (pointer equality). Swift's
`String` has no free-to-share form, so the technique does not port at any
length. Consequence for future rounds: interning names, and more broadly
any candidate whose only saving is "materialize one shared refcounted
value instead of N cheap independent ones," is negative by construction in
Swift and not worth an A/B. The remaining real costs on this corpus sit in
reference-counted allocations that no sharing scheme can cheapen: `TreeNode`
instances, the weak-parent side tables (#8, decided/deferred), attribute
arrays, and the per-event enum machinery, plus the scanning-architecture
split (#142).
- **Dead end, do not redo: replacing a single interpolated `output +=
"...\(a)...\(b)..."` with several sequential `output += "..."; output +=
a; ...` appends, in the serializer's element/attribute/close/CDATA/
comment/PI writers.** Sample-profiling the serialize loop (100k-item
corpus, 20 s capture) showed `DefaultStringInterpolation.appendLiteral`
and `_SmallString.init(_:appending:)` as two of the heaviest leaf frames,
suggesting the interpolation's intermediate buffer was the cost and that
splitting it into direct appends into `output` would avoid it. Measured:
the opposite. Same-state interleaved A/B at the 20k corpus, multi-append
consistently slower than the unmodified interpolated baseline across 5
runs with no overlap (patched 0.0354-0.0359 s vs baseline 0.0335-0.0336
s, ~6% slower). The mechanism: `output +=` is not free per call regardless
of what is being appended, since each call re-checks
`isKnownUniquelyReferenced` and remaining native capacity before the
copy; one interpolated append per site pays that check once and copies
the fully-assembled piece in one `memmove`, while N sequential appends
pay the check N times for the same total bytes copied. The interpolation
machinery's own overhead (building the intermediate) was real but smaller
than the per-call check/capacity overhead it was traded for. Isolating the
variables mattered: the narrower fix in pass 29 (precompute the `Character`
quote as a `String` once, keep everything else as ONE interpolated append
per attribute) kept the single-append discipline and measured a small
real win, confirming the appends-per-site count, not the interpolation
machinery itself, was the sensitive variable. Consequence for future
rounds: when a site already does its work in one `output +=` call,
decomposing it into more calls is negative by construction here even if
it removes allocation; the fix that wins is reducing what goes *into*
the one call, not increasing the call count.
- **Checked, not indicted: `write(element:)`'s `hasInlineContent` check
(`element.children.contains { $0 is .text/.cdata }`).** A plausible-looking
suspect from code reading (it walks `element.children` a second time,
redundant with `push`'s walk right after it), but sample-profiling the
100k-item corpus put `childFormatting(of:parentFormatted:hasInlineContent:)`
(which consumes its result) at 14 of ~7800 top-of-stack samples (~0.2%);
`hasInlineContent`'s own closure does not even appear as a distinct frame,
meaning it was inlined into `write` and contributes a sliver of that
function's already-small 186 samples (~2.4%). On this corpus's shape the
check is early-exit-cheap almost everywhere (`<name>`/`<price>`/`<note>`
match their single text child immediately) and the one costly full scan
(the `<catalog>` root against its 100k `<item>` children, none of which
match) is paid exactly once, not per element. A same-state experiment that
hardcoded the result to `false` to try to isolate a ceiling was invalid as
a measurement: forcing it changes `childFormatting`'s decision for
`<name>`/`<price>`/`<note>` (they stop early-returning `false`) and adds
real indentation output those elements should not get, which is slower and
writes more bytes (24095401 vs 20995400) for reasons unrelated to the
check's own cost. No pass taken; not worth an A/B without a corpus that is
wide-fan-out at multiple nesting levels rather than just the root, which
this campaign's standard corpus is not.
## Rules ## Rules