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.
This commit is contained in:
Mihaela Mihaljevic 2026-06-10 18:48:57 +02:00
parent 0ce59c826b
commit 72eaeeb439
7 changed files with 175 additions and 6 deletions

View File

@ -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

View File

@ -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)

View File

@ -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<UInt8>
let count: Int
init(_ bytes: [UInt8]) {
count = bytes.count
pointer = UnsafeMutablePointer<UInt8>.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()
}
}
}

View File

@ -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("]]") {

View File

@ -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

View File

@ -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("<s>My name is Snow ]]> Man</s>")
}
// A lone '>' after plain text stays legal content.
let document = try PureXML.parse("<s>a > b</s>")
let serialized = PureXML.serialize(document)
#expect(serialized.contains("a &gt; b"))
}
}
private extension PureXML.Model.Node {

View File

@ -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)