diff --git a/CHANGELOG.md b/CHANGELOG.md index 34a02a1..4718faf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Sources/Parsing/EventReaderAttributes.swift b/Sources/Parsing/EventReaderAttributes.swift index f86297d..a254ac0 100644 --- a/Sources/Parsing/EventReaderAttributes.swift +++ b/Sources/Parsing/EventReaderAttributes.swift @@ -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) diff --git a/Sources/Parsing/NamespaceContext.swift b/Sources/Parsing/NamespaceContext.swift index 4c9212b..288ba33 100644 --- a/Sources/Parsing/NamespaceContext.swift +++ b/Sources/Parsing/NamespaceContext.swift @@ -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) } diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 986b339..d4a3883 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -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)