From 6be96e51ce930187bf0948a301d8f06b998f1989 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Thu, 25 Jun 2026 13:37:27 +0200 Subject: [PATCH] perf(parse): scan for entity-decoder markers at the byte level (#294) EntityExpander.expand located the next '&', '<', and ';' with String.firstIndex over graphemes, so every literal byte of a run was decoded into a grapheme just to look for a one-byte marker. All three markers are ASCII, so a UTF-8 index is a valid string index: scan over raw.utf8 instead, and the found index still subscripts the string directly (the marker byte is never a continuation byte, so its index lands on a scalar and grapheme boundary). The decoded text is unchanged, including runs where the marker follows multibyte UTF-8: a precomposed letter, a combining sequence, or an astral character all leave the marker on a grapheme boundary. Measured parse ~5.5% faster on the 20k-item generated corpus via a same-state A/B (6.5x to ~6.1x slower than libxml2). Verified by EntityDecodeBulkTests (extended with four multibyte-boundary cases), the W3C XML conformance corpora, the Xalan XSLT gold-output corpus, the full suite, and the WASM build. --- CHANGELOG.md | 2 ++ Sources/Parsing/EntityDecoder.swift | 9 ++++++--- Tests/EntityDecodeBulkTests.swift | 11 +++++++++++ docs/benchmarks.md | 1 + 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b91316c..78bddfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- The entity decoder finds its markers at the byte level. `EntityExpander.expand` located the next `&`, `<`, and `;` with `String.firstIndex` over graphemes, decoding every literal byte of the run into a grapheme just to look for a one-byte marker. All three markers are ASCII, so a UTF-8 index is a valid string index: the scan now runs over `raw.utf8` and the found index still subscripts the string directly. The decoded text is unchanged, including text where the markers follow multibyte UTF-8 (precomposed, combining, and astral characters all keep the marker on a grapheme boundary). Measured `parse` ~5.5% faster on the 20k-item generated corpus via a same-machine-state A/B. Verified by the decode differential (`EntityDecodeBulkTests`, extended with multibyte-boundary cases) and the W3C XML conformance corpora. + - Materializing the mutable `TreeNode` tree from a parsed value `Node` adopts each node's prepared children directly instead of appending them one at a time. Building a node ran `append` for every child, which detaches the child from any previous parent, rejects a cycle, and grows the children array element by element. The children produced by the value-`Node` conversion are freshly built and parentless, so those guards are always no-ops and the repeated growth is wasted; `TreeNode.init(adopting:)` adopts the already-built array and only wires each child's parent. The resulting tree is identical (same children in the same order, the same parent links). The adopting initializer is used only by the conversion; every public factory keeps the detaching, cycle-checking `append` path for externally supplied children. Measured `parse` ~12% faster on the 20k-item generated corpus via a same-machine-state A/B. Verified by a parent-wiring differential (`TreeNodeAdoptingInitTests`), the W3C XML conformance corpora, and an adversarial equivalence review. - Element and attribute names split their prefix without a second pass over the name. The byte scanner (`takeASCIIName`) already visits every byte of an all-ASCII name, so it now also records the offset of the name's first colon at the cost of one comparison per byte. `QualifiedName(ascii:colonOffset:)` then splits at the known offset, and the common unprefixed name (no colon) is built with no scan at all, where `QualifiedName(_:)` previously ran `firstIndex(of:)` grapheme by grapheme over the whole name on every element and attribute. The resulting names are identical for every colon placement (none, leading, trailing, middle, doubled). Measured `parse` ~12% faster on the 20k-item generated corpus via a same-machine-state A/B. Verified by a colon-placement differential (`QualifiedNameColonHintTests`), the W3C XML conformance corpora, and an adversarial equivalence review. diff --git a/Sources/Parsing/EntityDecoder.swift b/Sources/Parsing/EntityDecoder.swift index 021dc56..cbb9a65 100644 --- a/Sources/Parsing/EntityDecoder.swift +++ b/Sources/Parsing/EntityDecoder.swift @@ -185,8 +185,11 @@ private struct EntityExpander { // Copy the literal run up to the next reference or markup character // in bulk rather than one character at a time: only '&' (a // reference) and '<' (a possible CDATA section) need per-character - // handling, and the text between them is the bulk of any run. - guard let special = raw[iterator...].firstIndex(where: { $0 == "&" || $0 == "<" }) else { + // handling, and the text between them is the bulk of any run. The + // scan is at the byte level (both are ASCII, so a UTF-8 index is a + // valid string index), which avoids decoding every literal byte into + // a grapheme just to look for two markers. + guard let special = raw.utf8[iterator...].firstIndex(where: { $0 == 0x26 || $0 == 0x3C }) else { try appendRun(raw[iterator...], counts: counts) return } @@ -218,7 +221,7 @@ private struct EntityExpander { iterator = raw.index(after: iterator) continue } - guard let semicolon = raw[iterator...].firstIndex(of: ";") else { + guard let semicolon = raw.utf8[iterator...].firstIndex(of: 0x3B) else { throw PureXML.Parsing.ParseError.invalidReference(String(raw[iterator...]), mark) } let body = String(raw[raw.index(after: iterator) ..< semicolon]) diff --git a/Tests/EntityDecodeBulkTests.swift b/Tests/EntityDecodeBulkTests.swift index 4afb7f3..ce66a11 100644 --- a/Tests/EntityDecodeBulkTests.swift +++ b/Tests/EntityDecodeBulkTests.swift @@ -37,6 +37,17 @@ struct EntityDecodeBulkTests { #expect(try decode("pre ☺ post") == "pre \u{263A} post") } + @Test("multibyte literal text around references decodes correctly") + func test_multibyteRuns() throws { + // The marker scan is at the byte level; these confirm a reference byte + // that follows multibyte UTF-8 (precomposed, combining, and astral) is + // still located at a valid grapheme boundary, so the run splits cleanly. + #expect(try decode("café&thé") == "café&thé") + #expect(try decode("e\u{0301}&e\u{0301}") == "e\u{0301}&e\u{0301}") + #expect(try decode("\u{1F600}<\u{1F601}") == "\u{1F600}<\u{1F601}") + #expect(try decode("naïve text with no references") == "naïve text with no references") + } + @Test("declared entity replacement expands and counts against the budget") func test_declaredReplacement() throws { #expect(try decode("<&e;>", entities: ["e": "REPLACEMENT"]) == "") diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 6cfe134..3df2279 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -72,6 +72,7 @@ offsets throughout; libxml2's xmlReadMemory takes an int byte count). | 18 | bulk-copy literal runs in the entity decoder: `EntityExpander.expand` rebuilt every reference-bearing text run one character at a time (`result.append(character)` with a per-character budget charge), so a text node holding a single `&` was copied grapheme by grapheme; it now scans to the next `&`/`<` and appends the literal run in one go (charging the budget by the run length). Also skips the element-content reference-findings pass (an `&` scan plus a qualified-name render per text node) when there is no DTD. Identical decoded text (W3C XML conformance corpora + 6-case differential + adversarial review; same-state A/B, ~5%) | 0.138 s (~8.5x) | unchanged | unchanged | | 19 | carry the name's first-colon offset out of the byte scanner so `QualifiedName` splits the prefix without a second pass: `takeASCIIName` already visits every name byte, so it records the first `:` (one compare per byte); `QualifiedName(ascii:colonOffset:)` then splits at the known offset, and the common unprefixed name (no colon) takes no scan at all instead of a grapheme-by-grapheme `firstIndex(of:)` over the whole name. Identical names (colon-placement differential + W3C conformance + adversarial review; same-state A/B, ~12%) | 0.124 s (~7.7x) | unchanged | unchanged | | 20 | adopt prepared children when materializing the tree instead of appending them one at a time: building the mutable `TreeNode` tree from the parsed value `Node` ran `append` per child, which detaches the child from any old parent, checks for a cycle, and grows the children array element by element; the converted children are freshly built and parentless, so `TreeNode.init(adopting:)` takes the prepared array directly and only wires each child's parent. Identical tree (parent-wiring differential + W3C conformance + adversarial review; same-state A/B, ~12%) | 0.106 s (~6.5x) | unchanged | unchanged | +| 21 | scan for the entity decoder's markers at the byte level: `EntityExpander.expand` found the next `&`, `<`, and `;` with `String.firstIndex` over graphemes; since all three are ASCII, a UTF-8 index is a valid string index, so the scan runs over `raw.utf8` and skips decoding every literal byte into a grapheme just to look for a marker. Same decoded text (the bulk + 4 multibyte-boundary cases in the decode differential + W3C conformance; same-state A/B, ~5.5%) | 0.099 s (~6.1x) | unchanged | unchanged | ## Profile findings (first sample, parse path)