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:
parent
380160cc2b
commit
d359208ff6
31
CHANGELOG.md
31
CHANGELOG.md
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
120
Tests/CommentAndPIEscapingDifferentialTests.swift
Normal file
120
Tests/CommentAndPIEscapingDifferentialTests.swift
Normal 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)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user