From 0ce59c826b2bc4358b7cbc01053ba85737715ae2 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 10 Jun 2026 18:21:08 +0200 Subject: [PATCH] perf(parsing): allocation-free Reader.matches(), first measured pass matches() ran on every scan decision and materialized an Array of the literal per call, the hottest comparison in the parse profile. It now walks the literal directly. Parse time on the 4.2 MB benchmark corpus drops 16 percent (0.49 s to 0.41 s, libxml2 ratio 30x to 26x); profile findings and the optimization log recorded in docs/benchmarks.md. --- CHANGELOG.md | 4 ++++ Sources/Parsing/Reader.swift | 15 ++++++++++----- docs/benchmarks.md | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24c4cc6..30f0a20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Scanner-side validity findings are now located structured values (validation-framework audit follow-up): `DocumentType.validityFindings` carries `ValidityFinding` values (reason plus the declared name they are about) instead of strings, `DTDSchema.declarationErrors` holds ready `ValidationError`s, and every declaration-level finding renders at its declaration's coding path (`x`, `r/@a`) instead of "at root of document", matching the XSD consistency-errors precedent. Finding texts are unchanged. +### Changed + +- 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 - Stylesheet composition cycles no longer crash (#130 critic loop): an include or import cycle (a.xsl includes b.xsl includes a.xsl) recursed without bound and killed the host with a stack overflow, a denial of service reachable from stylesheet data. The compile chain now tracks the active load chain by resolved href and drops re-entry, while diamond composition (two units legitimately loading the same sheet) keeps loading both copies with their own import precedences, per 2.6.2. id() also gains a per-document index built on first use instead of re-walking the whole document per call, matching key(). Cycle and diamond regression tests added. diff --git a/Sources/Parsing/Reader.swift b/Sources/Parsing/Reader.swift index 40012e3..49c885d 100644 --- a/Sources/Parsing/Reader.swift +++ b/Sources/Parsing/Reader.swift @@ -112,11 +112,16 @@ extension PureXML.Parsing { /// True if the upcoming characters equal `literal`, without consuming. mutating func matches(_ literal: String) -> Bool { - let target = Array(literal) - ensure(target.count) - guard buffer.count >= target.count else { return false } - for index in target.indices where buffer[index] != target[index] { - return false + // No per-call Array materialization: literals are short constants + // and this runs on every scan decision (the hottest comparison in + // the parse profile). + let count = literal.count + ensure(count) + guard buffer.count >= count else { return false } + var index = 0 + for character in literal { + if buffer[index] != character { return false } + index += 1 } return true } diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 9c665c6..c4f5bd9 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -20,6 +20,25 @@ cross-implementation correctness check. These are the honest starting numbers the #139 optimization passes burn down; the table updates as passes land, never silently. +## Optimization log + +| pass | change | parse | xpath | serialize | +|---|---|---|---|---| +| 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 | + +## Profile findings (first sample, parse path) + +Top of stack, release build: String.Iterator.next (the per-character pull +closure chain), _stringCompareWithSmolCheck (Character equality in +matches/peek), String._uncheckedFromUTF8 and _allASCII (small-string +materialization), bridgeObjectRelease/swift_release (ARC on Character +buffers), UnicodeScalarView.distance. The Reader's architecture: one +closure call per scalar, [Character] lookahead, Character-typed +classification. The structural fix is a byte-level (UTF-8) scanner with +scalar classification tables; that is its own #139 pass, designed, not +patched. + ## Known structure of the gap - Parse and XPath dominate; serialize is closest.