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:
parent
0ce59c826b
commit
72eaeeb439
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
26
Sources/Parsing/ByteStorage.swift
Normal file
26
Sources/Parsing/ByteStorage.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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("]]") {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 > b"))
|
||||
}
|
||||
}
|
||||
|
||||
private extension PureXML.Model.Node {
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user