perf(emitting): skip escaping rebuild for values that need no escaping (#279)

The serializer's escaping functions rebuilt every text, attribute, comment,
and processing-instruction value character by character, even when the value
contained nothing to escape, which is the common case. Each now scans the
value's UTF-8 bytes for the bytes it would escape (the markup characters, the
active attribute quote, the whitespace characters, a comment hyphen, a PI
question mark, and any non-ASCII byte in ASCII-only mode) and returns the
value unchanged when none is present, taking the original character path only
when something must be escaped.

Measured serialize roughly halved, ~5.5x to ~2.7x slower than libxml2 on the
20k-item generated corpus, with byte-identical output. EscapingFastPathTests
pins both the unchanged fast path and the escaped slow path for each function,
and the Apache Xalan XSLT gold-output corpus (a serialize-heavy differential)
stays green. No output changed.
This commit is contained in:
Mihaela Mihaljevic 2026-06-25 00:56:52 +02:00 committed by GitHub
parent 38caec7098
commit 0abbe348d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 100 additions and 0 deletions

View File

@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Serialization roughly halved (measured `serialize` ~5.5x -> ~2.7x slower than libxml2 on the 20k-item generated corpus, `bash scripts/benchmark.sh 20000 5`, with byte-identical output). The escaping functions (`text`, `attribute`, `comment`, `processingInstruction`) rebuilt every value character by character even when nothing needed escaping, which is the common case. They now scan the value's UTF-8 bytes for the escapable bytes (markup characters, the active attribute quote, the whitespace characters, a comment hyphen, a PI question mark, and any non-ASCII byte in ASCII-only mode) and return the value unchanged when none is present, falling back to the original character path only when something must be escaped. Guarded by `EscapingFastPathTests`, which pins both the unchanged fast path and the escaped slow path for each function, and verified against the Apache Xalan XSLT gold-output corpus (a serialize-heavy differential). No output changed.
- Attribute-value parsing skips the reference-decode pass when the value contains no `&`. The byte-run scanner already visits every value byte, so it now notes any `&`; an ampersand-free value (the common case) returns its normalized run directly instead of calling the entity decoder, which re-scanned the whole value for `&` before doing nothing. Measured against a same-machine-state baseline, every run came in below every baseline run (~4% parse improvement on the 20k-item generated corpus). The bulk byte-run scanners (`contentRunBytes`, `attributeRunBytes`) moved to `ReaderByteRuns.swift` with their cursor mutation centralized through a single `advanceByteRun` method, keeping `Reader`'s position fields write-encapsulated while the file stays under the length cap. The byte and Character paths are held to identical decoded values by `ParserPathDifferentialTests` (extended with ampersand-free, entity, and character-reference attribute cases); no spec behavior changed.
- Parser throughput on plain-ASCII documents roughly halved against the libxml2 reference (measured `parse` 20.1x -> 9.5x slower than libxml2 on the 20k-item generated corpus, `bash scripts/benchmark.sh 20000 5`; ~53% wall-clock reduction). The work was profile-driven (macOS `sample`, not guesswork): the scanner, not tree construction, dominated parse time (SAX is ~84% of parse), and within it the cost was per-`Character` `String` machinery (`String.Iterator.next`, `_stringCompareWithSmolCheck`, `_uncheckedFromUTF8`, bridge-object ARC). The root cause was that a single buffered lookahead `Character` defeated every existing byte fast path (each requires an empty buffer), and the content/markup/attribute dispatch all peeked a `Character` before scanning. The fix keeps the reader on the byte path: a non-buffering `peekByte` for dispatch, a byte-mode `skipSpace`, an all-ASCII `takeASCIIName`, an `attributeRunBytes` bulk scanner (with the 3.3.3 whitespace-to-space transform, deferring any carriage return to the Character path so CRLF is not double-counted), and a `sawAmpersand` flag so ampersand-free text skips reference decoding entirely. The single highest-leverage change was detecting the closing `<` of a text run via `peekByte` instead of buffering it, which had been cascading the next element's whole dispatch and name scan onto the slow path. The Character path remains the exact fallback for a streaming source, spliced entity text, and any non-ASCII byte; byte and Character paths are kept from diverging by `scanContentMarkup` (shared dispatch) and by `ByteFastPathTests`, which pins the fast-path/fallback boundaries (non-ASCII names, CRLF and character references in attribute values, ampersand-free vs entity-bearing text, over-long names, raw `<` in attributes). Verified against the full local suite (1668 tests) plus the Apache Xalan XSLT gold-output corpus, the W3C XML conformance corpora (xmltest, OASIS, Sun, Eduni, IBM, Japanese), and the RELAX NG spec suite, all green; no spec behavior changed.

View File

@ -6,6 +6,11 @@ extension PureXML.Emitting {
/// libxml2), so attributes survive attribute-value normalization.
enum Escaping {
static func text(_ value: String, asciiOnly: Bool = false, escapeCarriageReturn: Bool = false) -> String {
// Fast path: most content has no character that needs escaping, so it
// is returned unchanged rather than rebuilt character by character.
if !textNeedsEscaping(value, asciiOnly: asciiOnly, escapeCarriageReturn: escapeCarriageReturn) {
return value
}
var result = ""
for character in value {
switch character {
@ -19,6 +24,22 @@ extension PureXML.Emitting {
return result
}
/// Whether ``text`` would change `value`: it escapes `&`, `<`, `>` always,
/// a carriage return when asked, and (in ASCII-only mode) any non-ASCII
/// byte. Scanned at the byte level, since every trigger is ASCII or, for
/// ASCII-only mode, a non-ASCII lead byte.
private static func textNeedsEscaping(_ value: String, asciiOnly: Bool, escapeCarriageReturn: Bool) -> Bool {
for byte in value.utf8 {
switch byte {
case 0x26, 0x3C, 0x3E: return true
case 0x0D where escapeCarriageReturn: return true
case 0x80... where asciiOnly: return true
default: continue
}
}
return false
}
/// A character verbatim, or, in ASCII-only mode, each of its non-ASCII
/// scalars as a numeric character reference so the output is pure ASCII.
private static func plain(_ character: Character, asciiOnly: Bool) -> String {
@ -31,6 +52,11 @@ extension PureXML.Emitting {
}
static func attribute(_ value: String, quote: Character = "\"", asciiOnly: Bool = false) -> String {
// Fast path: a value with no character to escape is returned unchanged.
let quoteByte: UInt8 = quote == "'" ? 0x27 : 0x22
if !attributeNeedsEscaping(value, quoteByte: quoteByte, asciiOnly: asciiOnly) {
return value
}
var result = ""
for character in value {
if character == quote {
@ -50,10 +76,27 @@ extension PureXML.Emitting {
return result
}
/// Whether ``attribute`` would change `value`: it escapes the quote, the
/// markup characters, the whitespace characters, and (in ASCII-only mode)
/// any non-ASCII byte. Every trigger is ASCII bar the non-ASCII lead.
private static func attributeNeedsEscaping(_ value: String, quoteByte: UInt8, asciiOnly: Bool) -> Bool {
for byte in value.utf8 {
switch byte {
case quoteByte, 0x26, 0x3C, 0x3E, 0x09, 0x0A, 0x0D: return true
case 0x80... where asciiOnly: return true
default: continue
}
}
return false
}
/// Makes `value` safe inside a comment: a comment may not contain `--`
/// or end with `-`, so a space is inserted after each offending hyphen
/// (the XSLT 1.0 16 recovery, so `after-` becomes `after- `).
static func comment(_ value: String) -> String {
// Fast path: a comment with no hyphen has no `--` and no trailing `-`,
// so it is returned unchanged rather than rebuilt character by character.
if !value.utf8.contains(0x2D) { return value }
var result = ""
var previousHyphen = false
for character in value {
@ -69,6 +112,9 @@ extension PureXML.Emitting {
/// contain `?>`, so a space is inserted between an offending `?` and `>`
/// (the XSLT 1.0 7.3 recovery, so `a?>b` becomes `a? >b`).
static func processingInstruction(_ data: String) -> String {
// Fast path: data with no '?' cannot contain `?>`, so it is returned
// unchanged rather than rebuilt character by character.
if !data.utf8.contains(0x3F) { return data }
var result = ""
var previousQuestion = false
for character in data {

View File

@ -0,0 +1,51 @@
import Testing
@testable import PureXML
/// The escaping functions take a fast path that returns the value unchanged when
/// nothing needs escaping, and the original character-by-character path when
/// something does. These tests pin both: a value with no escapable character
/// must round-trip identically (and, being the fast path, be the same instance's
/// content), and a value with an escapable character must still be escaped.
@Suite("Escaping fast-path equivalence")
struct EscapingFastPathTests {
private typealias Escaping = PureXML.Emitting.Escaping
@Test("text: plain content is unchanged, markup is escaped")
func test_text() {
#expect(Escaping.text("plain content, no markup") == "plain content, no markup")
#expect(Escaping.text("a & b < c > d") == "a &amp; b &lt; c &gt; d")
#expect(Escaping.text("café résumé") == "café résumé")
// ASCII-only escapes non-ASCII even with no markup character.
#expect(Escaping.text("café", asciiOnly: true) == "caf&#xE9;")
// A carriage return is escaped only when asked.
#expect(Escaping.text("a\rb") == "a\rb")
#expect(Escaping.text("a\rb", escapeCarriageReturn: true) == "a&#xD;b")
}
@Test("attribute: plain value is unchanged, specials are escaped")
func test_attribute() {
#expect(Escaping.attribute("plain value") == "plain value")
#expect(Escaping.attribute("a \"q\" b") == "a &quot;q&quot; b")
// The inactive quote is not escaped.
#expect(Escaping.attribute("it's fine", quote: "\"") == "it's fine")
#expect(Escaping.attribute("it's fine", quote: "'") == "it&apos;s fine")
#expect(Escaping.attribute("x\ty\nz") == "x&#9;y&#10;z")
#expect(Escaping.attribute("a & b < c") == "a &amp; b &lt; c")
#expect(Escaping.attribute("ñ", asciiOnly: true) == "&#xF1;")
}
@Test("comment: hyphen-free is unchanged, -- gets a separating space")
func test_comment() {
#expect(Escaping.comment("a plain comment") == "a plain comment")
#expect(Escaping.comment("single - hyphen is fine") == "single - hyphen is fine")
#expect(Escaping.comment("bad -- pair") == "bad - - pair")
#expect(Escaping.comment("trailing-") == "trailing- ")
}
@Test("processing instruction: question-free is unchanged, ?> gets a space")
func test_processingInstruction() {
#expect(Escaping.processingInstruction("plain data") == "plain data")
#expect(Escaping.processingInstruction("a ? b") == "a ? b")
#expect(Escaping.processingInstruction("a?>b") == "a? >b")
}
}

View File

@ -59,6 +59,7 @@ offsets throughout; libxml2's xmlReadMemory takes an int byte count).
| 5 | `sawAmpersand` short-circuit (ampersand-free text returns verbatim, skipping the reference-decode/split/findings pass) and detecting the text-run-closing `<` via `peekByte` instead of buffering it (the buffered `<` had cascaded the next element's whole dispatch and name scan onto the Character path) | 0.157 s (9.5x) | unchanged | unchanged |
| 6 | byte-level markup dispatch (the single byte after `<` selects end-tag/PI/declaration/start-tag, replacing up to five literal string comparisons per element with one byte peek) | 0.147 s (9.0x) | unchanged | unchanged |
| 7 | `sawAmpersand` short-circuit for attribute values (the byte scanner notes any `&`; an ampersand-free value skips the reference-decode pass and its full re-scan); measured against same-state baseline, every run below every baseline run | 0.143 s (~4% vs baseline) | unchanged | unchanged |
| 8 | serialize escaping fast path (text/attribute/comment/PI return the value unchanged when no character needs escaping, instead of rebuilding it character by character; byte-level scan for the escapable bytes) | unchanged | unchanged | 0.032 s (2.7x, from 5.5x) |
## Profile findings (first sample, parse path)