From 72eaeeb439f2b78f6666298e2d97c3f10df0a057 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 10 Jun 2026 18:48:57 +0200 Subject: [PATCH] perf(parsing): byte-backed Reader and bulk content runs String input copies its UTF-8 once into owned storage (an unsafe pointer encapsulated in a class lifetime, never escaping the parser) and scalars decode straight off the pointer, eliminating the per-character closure chain; ASCII literals match and consume at the byte level; and plain-ASCII character data scans as a single byte run with one String built per run. Parse drops 32 percent cumulative (0.49 s to 0.34 s on the 4.2 MB corpus, libxml2 ratio 30x to 20x). The W3C suite caught one boundary (a run starting with '>' could swallow the ]]> check after the character path consumed ]]); the run scanner leaves a leading '>' to the character path, pinned as a unit regression. All conformance suites, Linux, and WASM verified. --- CHANGELOG.md | 1 + README.md | 2 +- Sources/Parsing/ByteStorage.swift | 26 +++++++ Sources/Parsing/EventReader.swift | 13 +++- Sources/Parsing/Reader.swift | 123 +++++++++++++++++++++++++++++- Tests/ParsingTests.swift | 14 ++++ docs/benchmarks.md | 2 + 7 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 Sources/Parsing/ByteStorage.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f0a20..d4400ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Byte-backed Reader, performance passes 2 and 3 (#139): String input now copies its UTF-8 once into owned storage (an unsafe pointer encapsulated in a class lifetime, never escaping the parser) and scalars decode straight off the pointer, eliminating the per-character closure chain; ASCII literals match and consume at the byte level; and plain-ASCII character data, the bulk of any document, scans as a single byte run with one String built per run. Cumulative parse time on the 4.2 MB benchmark corpus drops 32 percent (0.49 s to 0.34 s, libxml2 ratio 30x to 20x). The W3C conformance suite caught one boundary in the run scanner (a run starting with '>' could swallow the "]]>" check after the character path consumed "]]"; ibm14n01); the run scanner now leaves a leading '>' to the character path, with the case pinned as a unit regression. All suites (W3C xmlconf, RELAX NG, xalan), Linux, and WASM verified. - First measured performance pass (#139): Reader.matches() no longer materializes an Array of the literal per call (it runs on every scan decision and was the hottest comparison in the parse profile); parse time on the 4.2 MB benchmark corpus drops 16 percent (0.49 s to 0.41 s, libxml2 ratio 30x to 26x). The benchmark harness, baseline, profile findings, and the optimization log live in docs/benchmarks.md. ### Fixed diff --git a/README.md b/README.md index fe68ee5..3e16555 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ flowchart TB PureXML is a working, dependency-free XML library today: parse, emit, validate, query, and stream documents on macOS, Linux, Windows, and WASM. The test suite -currently runs **980 tests in 167 suites** (`swift test`). +currently runs **981 tests in 167 suites** (`swift test`). ### Shipped (libxml2-aligned surface) diff --git a/Sources/Parsing/ByteStorage.swift b/Sources/Parsing/ByteStorage.swift new file mode 100644 index 0000000..8ed8e18 --- /dev/null +++ b/Sources/Parsing/ByteStorage.swift @@ -0,0 +1,26 @@ +extension PureXML.Parsing { + /// Owned contiguous UTF-8 storage for the Reader's fast path: one copy + /// of the input, read through an unsafe cursor with no per-byte bounds + /// or retain traffic. The bytes come from a Swift String, so they are + /// valid UTF-8 by construction. The pointer never escapes the parser; + /// deallocation rides the class lifetime. + final class ByteStorage { + let pointer: UnsafeMutablePointer + let count: Int + + init(_ bytes: [UInt8]) { + count = bytes.count + pointer = UnsafeMutablePointer.allocate(capacity: max(count, 1)) + bytes.withUnsafeBufferPointer { buffer in + if let base = buffer.baseAddress { + pointer.initialize(from: base, count: count) + } + } + } + + deinit { + pointer.deinitialize(count: count) + pointer.deallocate() + } + } +} diff --git a/Sources/Parsing/EventReader.swift b/Sources/Parsing/EventReader.swift index 8e1bc6a..382e18c 100644 --- a/Sources/Parsing/EventReader.swift +++ b/Sources/Parsing/EventReader.swift @@ -207,7 +207,18 @@ public extension PureXML.Parsing { let mark = reader.mark var raw = "" var length = 0 - while let character = reader.peek(), character != "<" { + while true { + // Bulk byte runs first: plain ASCII content (the bulk of any + // document) skips the per-character machinery entirely; the + // run never spans '<', CR, ']', or invalid bytes, so the + // character path below keeps exact error marks. + if let run = reader.contentRunBytes() { + length += run.utf8.count + try checkContent(length, mark) + raw += run + continue + } + guard let character = reader.peek(), character != "<" else { break } length += 1 try checkContent(length, mark) if character == ">", raw.hasSuffix("]]") { diff --git a/Sources/Parsing/Reader.swift b/Sources/Parsing/Reader.swift index 49c885d..a4454a6 100644 --- a/Sources/Parsing/Reader.swift +++ b/Sources/Parsing/Reader.swift @@ -11,7 +11,7 @@ extension PureXML.Parsing { /// clusters reassemble naturally wherever scanned text is appended to a /// `String`. struct Reader { - private let pull: () -> Character? + private var pull: () -> Character? private var buffer: [Character] = [] private var exhausted = false private(set) var offset = 0 @@ -24,8 +24,15 @@ extension PureXML.Parsing { /// A raw character read past a carriage return that was not part of the /// line ending, held back to be returned next. private var pendingRaw: Character? + /// The contiguous fast path: when the input is a String, its UTF-8 + /// bytes are copied once into owned storage and scalars decode + /// straight off the pointer, bypassing the per-character closure + /// chain entirely. + private let storage: ByteStorage? + private var byteIndex = 0 init(pulling pull: @escaping () -> Character?) { + storage = nil // Split multi-scalar graphemes so the buffer is scalar-level. var queued: [Character] = [] self.pull = { @@ -41,9 +48,38 @@ extension PureXML.Parsing { } init(_ string: String) { - // Iterate scalars directly: each yielded Character is one scalar. - var iterator = string.unicodeScalars.makeIterator() - self.init(pulling: { iterator.next().map(Character.init) }) + // One O(n) copy into owned storage; every scalar after that + // decodes from the pointer. Each yielded Character is one + // scalar, the same contract as the streaming path. + storage = ByteStorage(Array(string.utf8)) + pull = { nil } + } + + /// Decodes the next scalar from the owned bytes. The input came from + /// a Swift String, so the UTF-8 is valid by construction; the length + /// guard is defensive only. + private mutating func nextFromBytes(_ storage: ByteStorage) -> Character? { + guard byteIndex < storage.count else { return nil } + let pointer = storage.pointer + let first = pointer[byteIndex] + if first < 0x80 { + byteIndex += 1 + return Character(Unicode.Scalar(first)) + } + let length = first >= 0xF0 ? 4 : (first >= 0xE0 ? 3 : 2) + guard byteIndex + length <= storage.count else { + byteIndex = storage.count + return nil + } + var value = UInt32(first & (0xFF >> UInt8(length + 1))) + for index in 1 ..< length { + value = (value << 6) | UInt32(pointer[byteIndex + index] & 0x3F) + } + byteIndex += length + guard let scalar = Unicode.Scalar(value) else { + return nil + } + return Character(scalar) } /// Ensures the lookahead buffer holds at least `count` characters (or the @@ -83,6 +119,9 @@ extension PureXML.Parsing { pendingRaw = nil return pending } + if let storage { + return nextFromBytes(storage) + } return pull() } @@ -112,6 +151,9 @@ extension PureXML.Parsing { /// True if the upcoming characters equal `literal`, without consuming. mutating func matches(_ literal: String) -> Bool { + if buffer.isEmpty, pendingRaw == nil, let storage { + if let byteResult = matchesBytes(literal, storage) { return byteResult } + } // No per-call Array materialization: literals are short constants // and this runs on every scan decision (the hottest comparison in // the parse profile). @@ -126,9 +168,33 @@ extension PureXML.Parsing { return true } + /// Byte-level compare for plain ASCII literals without CR or LF + /// (line-ending normalization cannot touch those, so raw bytes and + /// the normalized stream agree). Returns nil when the literal needs + /// the Character path. + private func matchesBytes(_ literal: String, _ storage: ByteStorage) -> Bool? { + var index = byteIndex + for byte in literal.utf8 { + guard byte < 0x80, byte != 0x0D, byte != 0x0A else { return nil } + guard index < storage.count, storage.pointer[index] == byte else { return false } + index += 1 + } + return true + } + /// If the upcoming characters equal `literal`, consume them and return true. @discardableResult mutating func consume(_ literal: String) -> Bool { + if buffer.isEmpty, pendingRaw == nil, let storage, let byteResult = matchesBytes(literal, storage) { + guard byteResult else { return false } + // ASCII literal without newlines: one byte per scalar, no + // line changes, so position tracking is plain arithmetic. + let count = literal.utf8.count + byteIndex += count + offset += count + column += count + return true + } guard matches(literal) else { return false } for _ in literal { advance() @@ -142,6 +208,55 @@ extension PureXML.Parsing { } } + /// Bulk-scans character data in byte mode: consumes and returns the + /// longest upcoming run of plain ASCII content bytes (no '<', no + /// carriage return, no "]]" pair, every byte a valid XML character), + /// or nil when the fast path does not apply. Anything subtle, an + /// entity boundary aside ('&' is plain content here), is left for + /// the character path so error marks stay exact. Returns nil rather + /// than an empty run so callers can alternate with the slow loop. + mutating func contentRunBytes() -> String? { + guard buffer.isEmpty, pendingRaw == nil, let storage else { return nil } + let pointer = storage.pointer + let count = storage.count + // A run never contains ']', so "]]>" can only straddle the + // boundary where the slow path consumed "]]" and the run would + // begin with '>': leave a leading '>' to the slow path and its + // cdataCloseInContent check (W3C ibm14n01). + if byteIndex < count, pointer[byteIndex] == 0x3E { return nil } + var index = byteIndex + var newlines = 0 + var lastLineStart = -1 + while index < count { + let byte = pointer[index] + if byte == 0x3C { break } // '<' + // Valid plain ASCII content only; CR, ']' and non-ASCII go + // to the character path. + guard byte < 0x80, byte != 0x0D, byte != 0x5D, + byte >= 0x20 || byte == 0x09 || byte == 0x0A + else { + if index == byteIndex { return nil } + break + } + if byte == 0x0A { + newlines += 1 + lastLineStart = index + } + index += 1 + } + guard index > byteIndex else { return nil } + let run = String(decoding: UnsafeBufferPointer(start: pointer + byteIndex, count: index - byteIndex), as: UTF8.self) + offset += index - byteIndex + if newlines > 0 { + line += newlines + column = index - lastLineStart + } else { + column += index - byteIndex + } + byteIndex = index + return run + } + /// Prepends `text` to the unread stream: used to splice a general /// entity's replacement text into content so it is reparsed as markup /// (4.4.2 Included). Position tracking keeps reporting the source diff --git a/Tests/ParsingTests.swift b/Tests/ParsingTests.swift index 99474c9..7bc04eb 100644 --- a/Tests/ParsingTests.swift +++ b/Tests/ParsingTests.swift @@ -158,6 +158,20 @@ struct ParsingTests { #expect(root?.children.first == .text("text")) #expect(root?.children.last == .element(.init("c"))) } + + @Test("A CDATA close in content is rejected when it straddles the bulk-scan boundary") + func test_cdataCloseAcrossScanBoundary() throws { + // The fast byte path never holds ']' bytes, so the "]]" lands in the + // character path and the following '>' must not begin a bulk run + // (W3C ibm14n01, caught by the conformance suite). + #expect(throws: PureXML.Parsing.ParseError.self) { + _ = try PureXML.parse("My name is Snow ]]> Man") + } + // A lone '>' after plain text stays legal content. + let document = try PureXML.parse("a > b") + let serialized = PureXML.serialize(document) + #expect(serialized.contains("a > b")) + } } private extension PureXML.Model.Node { diff --git a/docs/benchmarks.md b/docs/benchmarks.md index c4f5bd9..e90297c 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -26,6 +26,8 @@ down; the table updates as passes land, never silently. |---|---|---|---|---| | baseline | - | 0.494 s (30x) | 0.413 s (37x) | 0.061 s (5.5x) | | 1 | allocation-free Reader.matches() | 0.414 s (26x) | unchanged | unchanged | +| 2 | byte-backed Reader (owned UTF-8 storage, pointer-decoded scalars, byte-level literal match/consume) | 0.375 s (23x) | unchanged | unchanged | +| 3 | bulk content runs (plain-ASCII character data scanned and consumed at the byte level, String built once per run) | 0.335 s (20x) | unchanged | unchanged | ## Profile findings (first sample, parse path)