perf(parsing): byte-path quote handling, lazy expanded-name check, byte-scan entity guard

Third profile-guided pass (#139, docs/benchmarks.md pass 27), targeting
scanContentMarkup(), which inlines the start-tag/attribute/namespace scanners
and carries ~46% of parse time.

scanAttributeValue's opening and closing quote checks ran on the Character
path (peek + advance, buffering the quote as a Character only to compare and
discard it) on every attribute value in the document; both now check the raw
byte first, reaching the byte fast path with no Character buffering, with the
Character path kept as the off-fast-path fallback for streaming sources. The
quote bytes are ASCII and non-CR/LF, so the raw byte and the normalized
stream agree, the same reasoning peekByte's other callers already rely on.

NamespaceContext.checkExpandedNames allocated a Set's native storage and
built a concatenated URI+local key string per element even when at most one
attribute carries a namespace URI, where no duplicate is possible (the
default namespace never applies to attributes, and xmlns declarations carry
no URI). Counting URI-bearing attributes first, with no allocation, and
returning early below two skips all of it on the overwhelming majority of
elements; for xml:* attributes specifically the key exceeded the small-string
limit, so this also removes a real per-element heap allocation.

EntityDecoder.decode/decodeLenient guarded with raw.contains("&"), a
grapheme-by-grapheme walk to find one ASCII byte, once per ampersand-bearing
text node or attribute value; raw.utf8.contains(0x26) finds the same byte
without decoding graphemes, the same shape as checkNameShape's colon check.

The quote-handling extraction into two helpers also resolves the cyclomatic
complexity the inline version tripped. Six new regression tests pin the
quote edge cases (empty value, single-quoted, a quote kind nested inside the
other, unterminated at end of input) plus byte/Character-path agreement for
each in the parser path differential.

Verified: full local suite (1858 tests) and full W3C XSTS suite (14383
groups) pass unchanged. Parse ~6.00x -> ~4.75x versus libxml2 at the
20000-item corpus, best of 5.
This commit is contained in:
Mihaela Mihaljevic 2026-07-12 12:27:00 +02:00
parent 7ffd1dd545
commit fe0400c3df
7 changed files with 150 additions and 22 deletions

View File

@ -50,6 +50,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- A third parse-path optimization pass (#139, `docs/benchmarks.md` pass 27),
found by sample-profiling `scanContentMarkup()` in isolation after passes
25-26 (it dominates parse time, ~46%, since it inlines
`scanStartTag`/`scanAttributes`/`scanAttributeValue`/`NamespaceContext.enterElement`):
`scanAttributeValue`'s opening and closing quote checks now use the byte
fast path (`peekByte()` + `reader.consume(_:)`/a byte peek before the
Character peek) instead of the Character path, for every attribute value in
the document; `NamespaceContext.checkExpandedNames` skips its `Set<String>`
allocation and key-string building entirely when fewer than two attributes
carry a namespace URI (the check can only ever throw on a collision between
two such attributes, so 0 or 1 can never trigger it, and this is the
overwhelming majority of elements); and `EntityDecoder`'s `raw.contains("&")`
guard is now a UTF-8 byte scan instead of a `Character`-based one, the same
shape as `NamespaceContext.checkNameShape`'s colon check in pass 26. All
three are same-observable-behavior changes, verified against the full local
suite (1858 tests, 6 new covering the quote-handling edge cases directly:
empty value, single-quoted, a quote-kind nested inside the other, and
unterminated-at-end-of-input, plus byte/Character-path agreement for each)
and the full W3C XSTS suite (14383 groups), both unchanged. Measured parse
~6.00x -> ~4.5-4.75x versus libxml2 at the 20000-item/4.2MB corpus, best of
5; see `docs/benchmarks.md` for the full before/after and reasoning.
- Two parse-path optimization passes (#139, `docs/benchmarks.md` passes 25-26),
found by sample-profiling a 42MB generated corpus after re-measuring the
parse/libxml2 ratio fresh rather than trusting the doc's last-recorded

View File

@ -18,7 +18,14 @@ extension PureXML.Parsing {
budget: inout Int,
at mark: Mark,
) throws -> String {
guard raw.contains("&") else { return raw }
// A `Character` literal here resolves to `Sequence.contains(_:)`,
// walking extended grapheme clusters to find one ASCII byte; called
// once per ampersand-bearing text node or attribute value in the
// whole document. `utf8.contains(0x26)` finds the same `&` via the
// UTF-8 byte view, since 0x26 is never a lead or continuation byte
// of a multi-byte sequence (matches the reasoning already applied
// to `NamespaceContext.checkNameShape`'s colon check).
guard raw.utf8.contains(0x26) else { return raw }
var expander = EntityExpander(entities: entities, mark: mark, budget: budget)
try expander.expand(raw, visiting: [], counts: false)
budget = expander.budget
@ -37,7 +44,9 @@ extension PureXML.Parsing {
at mark: Mark,
undeclared: inout [String],
) throws -> String {
guard raw.contains("&") else { return raw }
// See `decode(_:entities:budget:at:)`'s matching guard: byte-level
// scan, not a grapheme walk, for the same reason.
guard raw.utf8.contains(0x26) else { return raw }
var expander = EntityExpander(entities: entities, mark: mark, budget: budget, undeclared: [])
try expander.expand(raw, visiting: [], counts: false)
budget = expander.budget

View File

@ -51,11 +51,7 @@ extension PureXML.Parsing.EventReader {
mutating func scanAttributeValue() throws -> String {
let mark = reader.mark
guard let quote = reader.peek(), quote == "\"" || quote == "'" else {
throw PureXML.Parsing.ParseError.unquotedAttributeValue(reader.mark)
}
reader.advance()
let quoteByte: UInt8 = quote == "\"" ? 0x22 : 0x27
let (quoteByte, quoteCharacter) = try consumeAttributeValueOpenQuote()
var raw = ""
var length = 0
// Whether any '&' was seen: without one the value has no references, so
@ -72,7 +68,14 @@ extension PureXML.Parsing.EventReader {
raw += run
continue
}
guard let character = reader.peek(), character != quote else { break }
// Byte-level check before the Character peek: whenever
// `attributeRunBytes` stopped because the next byte is the closing
// quote (its only non-empty-input stopping reason besides '<', CR,
// or a control/non-ASCII byte the Character path must inspect),
// this avoids buffering that quote as a Character only to compare
// and discard it a moment later.
if reader.peekByte() == quoteByte { break }
guard let character = reader.peek(), character != quoteCharacter else { break }
length += 1
try checkContent(length, mark)
if character == "<" {
@ -88,21 +91,51 @@ extension PureXML.Parsing.EventReader {
raw.append(character.isXMLWhitespace ? " " : character)
reader.advance()
}
// The loop above only exits (breaks) when `reader.peek()` is nil (end of
// input) or equals `quote`: there is no third case, so the closing quote
// is already known and buffered from that last peek. `reader.consume(String(quote))`
// would re-derive the same answer through `matches`/`consume`'s general
// path (which allocates a fresh `String(quote)` and iterates it twice,
// once to compare and once to count how many times to advance), on
// every single attribute value in the document; checking the
// already-buffered peek directly and advancing once is the same
// observable behavior for less work.
guard reader.peek() == quote else {
throw PureXML.Parsing.ParseError.unexpectedEndOfInput(reader.mark)
}
reader.advance()
// The loop above only exits (breaks) when the next position is nil (end
// of input) or the closing quote: there is no third case.
try consumeAttributeValueCloseQuote(byte: quoteByte, character: quoteCharacter)
// No '&' means no references to decode: the normalized run is the value.
guard sawAmpersand else { return raw }
return try decodeReferences(raw, at: mark)
}
/// Detects and consumes the attribute value's opening quote, returning
/// both its byte and `Character` forms: `quoteByte` drives
/// `attributeRunBytes`'s bulk scan and the closing-quote byte check;
/// `quoteCharacter` is the fallback for wherever the byte fast path does
/// not apply (a streaming source with no owned byte storage, or a
/// lookahead buffer some earlier scan already populated). Byte fast path:
/// `peekByte()` only returns non-nil when the lookahead buffer is empty,
/// so `reader.consume(_:)` below reaches its own byte fast path
/// (`matchesBytes`) directly, with no Character buffering at all.
private mutating func consumeAttributeValueOpenQuote() throws -> (byte: UInt8, character: Character) {
if let byte = reader.peekByte(), byte == 0x22 || byte == 0x27 {
reader.consume(byte == 0x22 ? "\"" : "'")
return (byte, byte == 0x22 ? "\"" : "'")
}
guard let quote = reader.peek(), quote == "\"" || quote == "'" else {
throw PureXML.Parsing.ParseError.unquotedAttributeValue(reader.mark)
}
reader.advance()
return (quote == "\"" ? 0x22 : 0x27, quote)
}
/// Consumes the attribute value's closing quote. Called only once the
/// value-scanning loop has already confirmed (via a peek that consumed
/// nothing) the next position is either the quote or end of input, so
/// whichever check fired that break already buffered nothing new;
/// re-checking the byte first, then falling back to the already-buffered
/// Character peek, reaches the same verdict `reader.consume(String(quote))`
/// would, without that function's fresh `String` allocation and double
/// `Character` iteration on every single attribute value in the document.
private mutating func consumeAttributeValueCloseQuote(byte quoteByte: UInt8, character quoteCharacter: Character) throws {
if reader.peekByte() == quoteByte {
reader.advance()
return
}
guard reader.peek() == quoteCharacter else {
throw PureXML.Parsing.ParseError.unexpectedEndOfInput(reader.mark)
}
reader.advance()
}
}

View File

@ -161,6 +161,22 @@ extension PureXML.Parsing {
/// Attributes must be distinct by expanded name: two prefixes bound to
/// the same URI cannot carry the same local name on one element.
private func checkExpandedNames(_ attributes: [PureXML.Model.Attribute], at mark: Mark) throws {
// The only observable effect below is throwing `duplicateAttribute`,
// which needs two attributes with an equal (URI, localName) pair; an
// unprefixed attribute never carries a URI (the default namespace
// does not apply to attributes) and neither does an `xmlns`/
// `xmlns:*` declaration, so with fewer than two URI-bearing
// attributes no collision is possible. Counting first, with no
// allocation, skips the `Set<String>` storage allocation and the
// per-attribute key string (which, for the built-in `xml`
// namespace URI, is long enough to need its own heap allocation)
// on every element that has at most one namespaced attribute, the
// overwhelming majority in most real documents.
var namespacedCount = 0
for attribute in attributes where attribute.name.namespaceURI != nil {
namespacedCount += 1
}
guard namespacedCount > 1 else { return }
var seen: Set<String> = []
for attribute in attributes where attribute.name.namespaceURI != nil {
let key = (attribute.name.namespaceURI ?? "") + "\u{0}" + attribute.name.localName

View File

@ -99,6 +99,46 @@ struct ByteFastPathTests {
}
}
@Test("An empty attribute value parses on the byte fast path")
func test_emptyAttributeValue() throws {
// attributeRunBytes returns nil immediately (empty run), so the
// closing-quote check must fire on the very first byte peek.
let node = try PureXML.parse("<a v=\"\"/>")
#expect(node.firstElement?.attributes == [.init("v", "")])
}
@Test("A single-quoted attribute value uses the byte fast path")
func test_singleQuotedAttributeValue() throws {
let node = try PureXML.parse("<a v='x'/>")
#expect(node.firstElement?.attributes == [.init("v", "x")])
}
@Test("A double quote is legal content inside a single-quoted value")
func test_doubleQuoteInsideSingleQuotedValue() throws {
let node = try PureXML.parse("<a v='it\"s'/>")
#expect(node.firstElement?.attributes == [.init("v", "it\"s")])
}
@Test("A single quote is legal content inside a double-quoted value")
func test_singleQuoteInsideDoubleQuotedValue() throws {
let node = try PureXML.parse("<a v=\"it's\"/>")
#expect(node.firstElement?.attributes == [.init("v", "it's")])
}
@Test("An attribute value unterminated at end of input is rejected")
func test_unterminatedAttributeValueAtEndOfInput() {
#expect(throws: PureXML.Parsing.ParseError.self) {
try PureXML.parse("<a v=\"x")
}
}
@Test("An attribute value that ends exactly at end of input with no closing quote is rejected")
func test_attributeValueMissingClosingQuoteAtVeryEnd() {
#expect(throws: PureXML.Parsing.ParseError.self) {
try PureXML.parse("<a v=\"")
}
}
@Test("A leading greater-than in text content is preserved")
func test_textLeadingGreaterThan() throws {
let node = try PureXML.parse("<p>a > b</p>")

View File

@ -70,6 +70,13 @@ struct ParserPathDifferentialTests {
expectAgreement("char-ref LF survives", "<a v=\"x&#10;y\"/>")
expectAgreement("literal tab plus ref tab", "<a v=\"a\tb&#9;c\"/>")
expectAgreement("multi-line attr then attr", "<a b=\"\nval\nval2\" c=\"y\"/>")
expectAgreement("empty value", "<a v=\"\"/>")
expectAgreement("single-quoted value", "<a v='x'/>")
expectAgreement("double quote inside single-quoted value", "<a v='it\"s'/>")
expectAgreement("single quote inside double-quoted value", "<a v=\"it's\"/>")
expectAgreement("empty then non-empty value", "<a v=\"\" w=\"x\"/>")
expectAgreement("unterminated value at end of input", "<a v=\"x")
expectAgreement("value with only the opening quote then end", "<a v=\"")
}
@Test("Bracket and CDATA-close boundaries agree on both paths")

View File

@ -78,8 +78,9 @@ offsets throughout; libxml2's xmlReadMemory takes an int byte count).
| 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) |
| 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 |
| 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 |
Passes 25-26 were measured in a fresh session on different hardware from the original baseline and passes 1-24 (this doc does not know what machine recorded which historical entry), so the absolute seconds are not directly comparable across the gap; the ratio column (parse ~6.59x before pass 25, ~6.30x after pass 25, ~6.00x after pass 26, all at the 20000-item/4.2MB corpus, best of 5) is the honest same-session A/B this log's discipline requires. Reconciling the whole table against one pinned machine is open work, not assumed.
Passes 25-27 were measured in a fresh session on different hardware from the original baseline and passes 1-24 (this doc does not know what machine recorded which historical entry), so the absolute seconds are not directly comparable across the gap; the ratio column (parse ~6.59x before pass 25, ~6.30x after pass 25, ~6.00x after pass 26, ~4.5-4.75x after pass 27, all at the 20000-item/4.2MB corpus, best of 5) is the honest same-session A/B this log's discipline requires. Reconciling the whole table against one pinned machine is open work, not assumed.
## Profile findings (first sample, parse path)