From 0abbe348d7535c13d60e4b4999fc7f7e74f321d9 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Thu, 25 Jun 2026 00:56:52 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 2 ++ Sources/Emitting/Escaping.swift | 46 ++++++++++++++++++++++++++++ Tests/EscapingFastPathTests.swift | 51 +++++++++++++++++++++++++++++++ docs/benchmarks.md | 1 + 4 files changed, 100 insertions(+) create mode 100644 Tests/EscapingFastPathTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d7f13..4b28913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Sources/Emitting/Escaping.swift b/Sources/Emitting/Escaping.swift index b31a566..19c8dcf 100644 --- a/Sources/Emitting/Escaping.swift +++ b/Sources/Emitting/Escaping.swift @@ -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 { diff --git a/Tests/EscapingFastPathTests.swift b/Tests/EscapingFastPathTests.swift new file mode 100644 index 0000000..bf6fbde --- /dev/null +++ b/Tests/EscapingFastPathTests.swift @@ -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 & b < c > 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é") + // A carriage return is escaped only when asked. + #expect(Escaping.text("a\rb") == "a\rb") + #expect(Escaping.text("a\rb", escapeCarriageReturn: true) == "a 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 "q" 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's fine") + #expect(Escaping.attribute("x\ty\nz") == "x y z") + #expect(Escaping.attribute("a & b < c") == "a & b < c") + #expect(Escaping.attribute("ñ", asciiOnly: true) == "ñ") + } + + @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") + } +} diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 71eeabf..67df05c 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -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)