diff --git a/CHANGELOG.md b/CHANGELOG.md index 4718faf..8fc00cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` + 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 diff --git a/Sources/Parsing/EntityDecoder.swift b/Sources/Parsing/EntityDecoder.swift index 2b7cbbe..b8b5638 100644 --- a/Sources/Parsing/EntityDecoder.swift +++ b/Sources/Parsing/EntityDecoder.swift @@ -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 diff --git a/Sources/Parsing/EventReaderAttributes.swift b/Sources/Parsing/EventReaderAttributes.swift index a254ac0..ef1e4dc 100644 --- a/Sources/Parsing/EventReaderAttributes.swift +++ b/Sources/Parsing/EventReaderAttributes.swift @@ -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() + } } diff --git a/Sources/Parsing/NamespaceContext.swift b/Sources/Parsing/NamespaceContext.swift index 288ba33..b8f46c0 100644 --- a/Sources/Parsing/NamespaceContext.swift +++ b/Sources/Parsing/NamespaceContext.swift @@ -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` 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 = [] for attribute in attributes where attribute.name.namespaceURI != nil { let key = (attribute.name.namespaceURI ?? "") + "\u{0}" + attribute.name.localName diff --git a/Tests/ByteFastPathTests.swift b/Tests/ByteFastPathTests.swift index ee94882..cf09d77 100644 --- a/Tests/ByteFastPathTests.swift +++ b/Tests/ByteFastPathTests.swift @@ -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("") + #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("") + #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("") + #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("") + #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 > b

") diff --git a/Tests/ParserPathDifferentialTests.swift b/Tests/ParserPathDifferentialTests.swift index 7efc062..4e7ea22 100644 --- a/Tests/ParserPathDifferentialTests.swift +++ b/Tests/ParserPathDifferentialTests.swift @@ -70,6 +70,13 @@ struct ParserPathDifferentialTests { expectAgreement("char-ref LF survives", "
") expectAgreement("literal tab plus ref tab", "") expectAgreement("multi-line attr then attr", "") + expectAgreement("empty value", "") + expectAgreement("single-quoted value", "") + expectAgreement("double quote inside single-quoted value", "") + expectAgreement("single quote inside double-quoted value", "") + expectAgreement("empty then non-empty value", "") + expectAgreement("unterminated value at end of input", "` (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`'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)