perf(parsing): remove redundant work in attribute-value and namespace scanning

Two profile-guided passes (#139), found by sample-profiling a 42MB generated
corpus after re-measuring the parse/libxml2 ratio fresh:

scanAttributeValue's closing-quote check re-derived an already-known answer
through Reader.consume's general String-iteration path, which allocates a
fresh String(quote) and iterates it via Character machinery twice, on every
single attribute value in the document. The content loop's own break
condition already guarantees the buffered peek is either the quote or end of
input, so checking it directly and advancing once is the same behavior for
less work.

NamespaceContext.enterElement, called once per element: checkNameShape's
name.localName.contains(":") resolved to Sequence.contains(_:), walking
extended grapheme clusters to find one ASCII byte; utf8.contains(0x3A) finds
the same colon via the UTF-8 byte view. Separately, attributes.map{
resolveAttribute } allocated a fresh backing array on every element even
though resolveAttribute returns its argument unchanged whenever the attribute
has no prefix or the xmlns prefix (the common case); checking first with no
allocation and reusing the input array when nothing would change gives the
identical result without it.

Both are same-observable-behavior changes, verified against the full local
suite (1852 tests) and the full W3C XSTS conformance suite (14383 groups),
both unchanged. Measured parse ~6.59x -> ~6.00x versus libxml2 at the
20000-item/4.2MB corpus, best of 5; see docs/benchmarks.md passes 25-26.
This commit is contained in:
Mihaela Mihaljevic 2026-07-12 11:40:33 +02:00
parent 433ccfaec5
commit 7ffd1dd545
4 changed files with 57 additions and 3 deletions

View File

@ -50,6 +50,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- 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
numbers: `scanAttributeValue`'s closing-quote check now compares the
already-buffered peek directly instead of re-deriving the same answer
through `Reader.consume`'s general `String`-iteration path (which allocated
a fresh `String(quote)` per attribute value in the document), and
`NamespaceContext.enterElement` scans a name's UTF-8 bytes instead of its
Characters to find an illegal embedded colon, and skips its per-element
attribute-array allocation entirely when no attribute actually needs
namespace resolution (the common all-unprefixed-attributes case). Both are
same-observable-behavior changes (verified with the full local suite, 1852
tests, and the full W3C XSTS suite, 14383 groups, both unchanged) that
measured parse ~6.59x -> ~6.00x versus libxml2 at the 20000-item/4.2MB
corpus, best of 5; see `docs/benchmarks.md` for the full before/after and
the reasoning behind each change.
- Update Codeberg links after the NodetteHQ transfer, including README badges,
support and roadmap issue URLs, and the XSLT `system-property('xsl:vendor-url')`
result.

View File

@ -88,9 +88,19 @@ extension PureXML.Parsing.EventReader {
raw.append(character.isXMLWhitespace ? " " : character)
reader.advance()
}
guard reader.consume(String(quote)) else {
// 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()
// No '&' means no references to decode: the normalized run is the value.
guard sawAmpersand else { return raw }
return try decodeReferences(raw, at: mark)

View File

@ -51,7 +51,21 @@ extension PureXML.Parsing {
undo.append(changes)
let resolvedName = try resolveElement(name, at: mark)
let resolvedAttributes = try attributes.map { try resolveAttribute($0, at: mark) }
// `resolveAttribute` returns its argument unchanged whenever the
// attribute has no prefix or the `xmlns` prefix (the common case:
// most attributes in most documents are unprefixed), so `.map`
// over an all-unprefixed attribute list allocates a fresh backing
// array that is element-wise identical to `attributes`, on every
// single element in the document. Checking first, with no
// allocation, and reusing `attributes` directly when nothing would
// change is the same result for far less work in that common case.
let needsAttributeResolution = attributes.contains { attribute in
let prefix = attribute.name.prefix
return prefix != nil && prefix != "xmlns"
}
let resolvedAttributes = try needsAttributeResolution
? attributes.map { try resolveAttribute($0, at: mark) }
: attributes
try checkExpandedNames(resolvedAttributes, at: mark)
return (resolvedName, resolvedAttributes)
}
@ -103,7 +117,16 @@ extension PureXML.Parsing {
/// `:foo`, `a:b:c`, `xmlns:`) in its local name, where the colon
/// betrays it.
private func checkNameShape(_ name: PureXML.Model.QualifiedName, at mark: Mark) throws {
guard name.localName.contains(":") else { return }
// `":"` as a `Character` literal here resolves to `Sequence.contains(_:)`,
// which walks the string's extended grapheme clusters one at a time
// to compare each against `:`; called once per element and once per
// attribute name in the whole document, that grapheme decoding cost
// is pure overhead for finding one ASCII byte. `utf8.contains(_:)`
// walks the UTF-8 view's `UInt8` elements directly: 0x3A (`:`) is
// never a lead or continuation byte of a multi-byte UTF-8 sequence,
// so a byte-level scan finds every literal colon exactly the
// grapheme scan would, without decoding any of them.
guard name.localName.utf8.contains(0x3A) else { return }
throw ParseError.namespaceConstraint(reason: "'\(name.description)' is not a legal qualified name", mark)
}

View File

@ -76,6 +76,10 @@ 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 |
| 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) |
| 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 |
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.
## Profile findings (first sample, parse path)