From d359208ff638caf9b3cfe0296d6a5eea401bf594 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 15 Jul 2026 04:05:29 +0200 Subject: [PATCH] perf(serialize): precompute the attribute quote, bulk-copy comment/PI escaping Two profile-guided serialize passes (#139, docs/benchmarks.md passes 29-30). The attribute writer now precomputes the quote character as a String once per element instead of interpolating a Character into every attribute, avoiding the grapheme-breadcrumb machinery that repeats twice per attribute for a value that never changes within an element. Escaping.comment and processingInstruction's escaping fallback (taken whenever a comment has a hyphen or PI data has a '?', a case pass 8 could skip past but not speed up) is rewritten in the same byte-scan bulk-copy shape as passes 23-24, so a value with one rare trigger no longer pays a full character-by-character rebuild. Both are same-observable-behavior changes. The quote change is byte- identical by construction. The comment/PI rewrite is verified against a new differential suite that reimplements the original character-loop as an independent oracle and checks adjacency/trailing/combining-mark cases plus two 20000-case seeded fuzzes, since a byte match inside a combining-mark cluster is not, on its own, the bare trigger character. Full local suite (1925 tests) unchanged. Measured with interleaved same-state A/B: pass 29 ~1.5% at a 100k-item corpus; pass 30 ~2x on a dedicated comment/PI micro-benchmark (the standard corpus has no comment/PI content, so real documents with copyright banners or hyphenated comment prose see this, not the headline ratio). A same- session multi-append restructuring attempt measured a clear ~6% regression and was reverted; a suspected hasInlineContent double-scan was profiled at ~0.2% of samples and not worth a pass. Both outcomes are recorded in docs/benchmarks.md rather than dropped. --- CHANGELOG.md | 31 +++++ Sources/Emitting/Escaping.swift | 38 +++++- Sources/Emitting/Serializer.swift | 3 +- ...ommentAndPIEscapingDifferentialTests.swift | 120 ++++++++++++++++++ docs/benchmarks.md | 84 ++++++++++++ 5 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 Tests/CommentAndPIEscapingDifferentialTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index f61fac9..6f77528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Two serialize-path optimization passes (#139, `docs/benchmarks.md` passes + 29-30), found by sample-profiling the serialize loop: the attribute writer + precomputes the quote character as a `String` once per element instead of + interpolating a `Character` into every attribute (a `Character` + interpolation goes through the string's grapheme-breadcrumb machinery, work + repeated twice per attribute for a value that never changes within an + element), and `Escaping.comment`/`processingInstruction`'s escaping + fallback (taken whenever a comment has a hyphen or PI data has a `?`, a + case pass 8 could only skip past, not speed up) is rewritten in the same + byte-scan bulk-copy shape as passes 23-24, so a comment or PI value with + one rare trigger no longer pays a full character-by-character rebuild. Both + are same-observable-behavior changes: the quote change is byte-identical by + construction (a `Character` and its single-scalar `String` render + identically); the comment/PI rewrite is verified against a new differential + suite (`Tests/CommentAndPIEscapingDifferentialTests.swift`) that + reimplements the original character-loop as an independent oracle and + checks hand-picked adjacency/trailing/combining-mark cases plus two + 20000-case seeded fuzzes, since a byte match inside a combining-mark + cluster is not, on its own, the bare trigger character. Full local suite + (1925 tests) unchanged. Measured with interleaved same-state A/B: pass 29 + ~1.5% at a 100k-item corpus (clean, two pairs); pass 30 ~2x on a dedicated + comment/PI micro-benchmark (no comment/PI content exists in the standard + corpus, so it does not move the headline ratio there, but real-world + documents with copyright banners or hyphenated prose in comments exercise + it directly). A same-session multi-append restructuring attempt (splitting + a single interpolated `output +=` into several direct appends) measured a + clear ~6% regression and was reverted rather than landed; a suspected + `hasInlineContent` double-scan of an element's children was profiled and + found to contribute ~0.2% of samples, not worth a pass. Both outcomes are + recorded in `docs/benchmarks.md` rather than silently dropped. + - A fourth parse-path optimization pass (#139, `docs/benchmarks.md` pass 28): `scanAttributes`' duplicate-name check reuses one stored scratch `Set` (cleared with `removeAll(keepingCapacity: true)` per element) diff --git a/Sources/Emitting/Escaping.swift b/Sources/Emitting/Escaping.swift index 3be58ec..90b2ddb 100644 --- a/Sources/Emitting/Escaping.swift +++ b/Sources/Emitting/Escaping.swift @@ -168,15 +168,30 @@ extension PureXML.Emitting { } var result = "" var previousHyphen = false - for character in value { + var runStart = value.startIndex + // Find each candidate hyphen byte, copy the verbatim run before it in + // one append, and resolve it to its full grapheme (a byte match inside + // a combining-mark cluster is not a bare "-" and is copied like any + // other character); only a genuine "-" can extend or start a run. + while let marker = value.utf8[runStart...].firstIndex(where: { $0 == 0x2D }) { + if runStart < marker { + result += value[runStart ..< marker] + previousHyphen = false + } + let character = value[marker] if character == "-", previousHyphen { - result.append(" ") + result += " " } result.append(character) previousHyphen = character == "-" + runStart = value.index(after: marker) + } + if runStart < value.endIndex { + result += value[runStart...] + previousHyphen = false } if previousHyphen { - result.append(" ") + result += " " } return result } @@ -192,12 +207,25 @@ extension PureXML.Emitting { } var result = "" var previousQuestion = false - for character in data { + var runStart = data.startIndex + // Candidates are '?' (may start the offending pair) and '>' (may close + // it); every other character, including one that merely shares a byte + // with a combining-mark cluster, is bulk-copied and resets the flag. + while let marker = data.utf8[runStart...].firstIndex(where: { $0 == 0x3F || $0 == 0x3E }) { + if runStart < marker { + result += data[runStart ..< marker] + previousQuestion = false + } + let character = data[marker] if character == ">", previousQuestion { - result.append(" ") + result += " " } result.append(character) previousQuestion = character == "?" + runStart = data.index(after: marker) + } + if runStart < data.endIndex { + result += data[runStart...] } return result } diff --git a/Sources/Emitting/Serializer.swift b/Sources/Emitting/Serializer.swift index 07e8674..bb86b75 100644 --- a/Sources/Emitting/Serializer.swift +++ b/Sources/Emitting/Serializer.swift @@ -103,9 +103,10 @@ public extension PureXML.Emitting { } output += "<" + element.name.description let quote = options.attributeQuote.character + let quoteString = String(quote) for attribute in element.attributes { let value = Escaping.attribute(attribute.value, quote: quote, asciiOnly: options.asciiOnly) - output += " \(attribute.name.description)=\(quote)\(value)\(quote)" + output += " \(attribute.name.description)=\(quoteString)\(value)\(quoteString)" } if element.children.isEmpty, options.selfCloseEmptyElements { diff --git a/Tests/CommentAndPIEscapingDifferentialTests.swift b/Tests/CommentAndPIEscapingDifferentialTests.swift new file mode 100644 index 0000000..3252fb4 --- /dev/null +++ b/Tests/CommentAndPIEscapingDifferentialTests.swift @@ -0,0 +1,120 @@ +import Testing +@testable import PureXML + +/// Reference oracle for `Escaping.comment`/`processingInstruction`: the original +/// character-by-character algorithm the byte-scanning bulk-copy rewrite must +/// match exactly, including when a hyphen/`?`/`>` combines with a following +/// combining mark into a single extended grapheme cluster (a byte match that is +/// not, on its own, that bare character). +private enum ReferenceEscaping { + static func comment(_ value: String) -> String { + var result = "" + var previousHyphen = false + for character in value { + if character == "-", previousHyphen { + result.append(" ") + } + result.append(character) + previousHyphen = character == "-" + } + if previousHyphen { + result.append(" ") + } + return result + } + + static func processingInstruction(_ data: String) -> String { + var result = "" + var previousQuestion = false + for character in data { + if character == ">", previousQuestion { + result.append(" ") + } + result.append(character) + previousQuestion = character == "?" + } + return result + } +} + +private struct DifferentialRNG: RandomNumberGenerator { + private var state: UInt64 + init(seed: UInt64) { + state = seed == 0 ? 0x9E37_79B9_7F4A_7C15 : seed + } + + mutating func next() -> UInt64 { + state ^= state >> 12 + state ^= state << 25 + state ^= state >> 27 + return state &* 0x2545_F491_4F6C_DD1D + } +} + +@Suite("Comment/PI escaping: bulk-copy rewrite matches the character-by-character oracle") +struct CommentAndPIEscapingDifferentialTests { + private typealias Escaping = PureXML.Emitting.Escaping + + private static let combiningMarks: [Character] = ["\u{0301}", "\u{0332}", "\u{20D0}"] + + @Test("comment: hand-picked adjacency and combining-mark cases") + func test_comment_handPicked() { + let cases = [ + "", "-", "--", "---", "----", + "a-b", "a--b", "a---b", "a----b", + "-a", "--a", "a-", "a--", + "trailing-", "trailing--", "trailing---", + "-\u{0301}", "-\u{0301}-", "a-\u{0301}-b", "--\u{0301}-", + "hyphen-\u{0332}ated words are fine-ish- probably", + ] + for value in cases { + #expect( + Escaping.comment(value) == ReferenceEscaping.comment(value), + "mismatch for \(value.debugDescription)", + ) + } + } + + @Test("processing instruction: hand-picked adjacency and combining-mark cases") + func test_pi_handPicked() { + let cases = [ + "", "?", ">", "?>", "??>", "?>>", "??>>", "?a>", "a?>b", + "?\u{0301}>", "?\u{0301}>b", "a?\u{0301}>", "?>?>?>", + "?a?b>c", "a>b?c", + ] + for value in cases { + #expect( + Escaping.processingInstruction(value) == ReferenceEscaping.processingInstruction(value), + "mismatch for \(value.debugDescription)", + ) + } + } + + @Test("comment: seeded fuzz across hyphen/combining-mark alphabets") + func test_comment_fuzz() { + var rng = DifferentialRNG(seed: 12345) + let alphabet: [Character] = ["a", "b", "-"] + Self.combiningMarks + for _ in 0 ..< 20000 { + let length = Int.random(in: 0 ..< 12, using: &rng) + let value = String((0 ..< length).map { _ in alphabet[Int.random(in: 0 ..< alphabet.count, using: &rng)] }) + #expect( + Escaping.comment(value) == ReferenceEscaping.comment(value), + "mismatch for \(value.debugDescription)", + ) + } + } + + @Test("processing instruction: seeded fuzz across ?/>/combining-mark alphabets") + func test_pi_fuzz() { + var rng = DifferentialRNG(seed: 67890) + let alphabet: [Character] = ["a", "b", "?", ">"] + Self.combiningMarks + for _ in 0 ..< 20000 { + let length = Int.random(in: 0 ..< 12, using: &rng) + let value = String((0 ..< length).map { _ in alphabet[Int.random(in: 0 ..< alphabet.count, using: &rng)] }) + #expect( + Escaping.processingInstruction(value) == ReferenceEscaping.processingInstruction(value), + "mismatch for \(value.debugDescription)", + ) + } + } +} diff --git a/docs/benchmarks.md b/docs/benchmarks.md index b9c856d..c399630 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -76,6 +76,8 @@ offsets throughout; libxml2's xmlReadMemory takes an int byte count). | 22 | build the mutable `TreeNode` tree directly from the event stream instead of building the value `Node` tree and converting it: `parseTree` ran `TreeNode(parse(...))`, allocating two full trees (the value tree, then a converted reference tree). `Parser.buildTree` is the direct counterpart to `build` (same event loop, stack discipline, and well-formedness guards) that accumulates `TreeNode` children per frame and adopts them when an element closes, so the tree is allocated once. Identical tree (structural differential against the convert path + the whole suite and conformance corpora, which all run through `parseTree`, + adversarial review; same-state A/B, ~8%) | 0.090 s (~5.5x) | unchanged | unchanged | | 23 | bulk-copy the verbatim runs when escaping output: `Escaping.text`/`attribute` rebuilt a value that needed escaping character by character (`result += String(character)` per unescaped character), so re-escaping a text node that holds a single `&` rebuilt it grapheme by grapheme; they now copy each run between escapable characters in one append and only the markers are rewritten. The `*NeedsEscaping` fast path (no rebuild when nothing needs escaping) is unchanged. Byte-identical output (per-character-oracle differential with multibyte cases + Apache Xalan gold output + C14N suite + adversarial 400k fuzz; same-state A/B, ~7%) | unchanged | unchanged | 0.029 s (~2.8x) | | 24 | find the characters to escape by scanning bytes, not graphemes: the bulk-escape loop still iterated `Character`s and compared each to `&`/`<`/`>` (a grapheme compare per character); since every trigger is an ASCII marker, or in ASCII-only mode a non-ASCII lead byte, `value.utf8.firstIndex(where:)` locates the next one without decoding the run into graphemes (the marker byte lands on a grapheme boundary, so the string slice and subscript stay valid). Byte-identical output (the oracle differential extended with combining-mark, ZWJ, and flag clusters + the gold/C14N suites + adversarial grapheme-alignment review; same-state A/B, ~23%) | unchanged | unchanged | 0.022 s (~2.2x) | +| 29 | precompute the attribute quote as a `String` once per element instead of interpolating the `Character` into every attribute's `"...\(quote)...\(quote)"`: a `Character` interpolation goes through `Character.write(to:)` and the string's grapheme-breadcrumb machinery, work repeated twice per attribute for a value that is the same one or two bytes every time in a given element. `quoteString` is built once outside the attribute loop and interpolated as a `String` (a plain code-unit append, no grapheme handling). Output is unchanged (a `Character` and its single-scalar `String` render identically); full local suite (1921 tests, including the gold/C14N/Xalan serializer suites) unchanged. Same-state interleaved A/B: clean at the 100k-item corpus (two pairs, patched 0.1696-0.1698 s vs baseline 0.1717-0.1728 s, both patched runs beating both baseline runs, ~1.5%); noisier but still favorable at the 20k corpus (4 pairs, one round overlapped). Trust the larger, cleaner corpus per this log's rule. | unchanged | unchanged | ~1.5% at 100k corpus | +| 30 | `Escaping.comment`/`processingInstruction`'s escaping fallback (taken whenever a comment has a hyphen or PI data has a `?`, which pass 8 could only skip past, not speed up): the fallback still rebuilt the whole value one `Character` at a time even though the space it must insert is rare (only at a `--`/`?>` collision), so a long comment with one hyphen anywhere still paid a full character-by-character copy. Rewritten in the same bulk-copy shape as passes 23/24: scan the byte view for the candidate trigger byte (`-` for comment; `?` or `>` for PI, since either can matter to the adjacency check), copy the verbatim run before it in one append, and resolve the actual decision through `Character` equality at that position exactly as the original loop did, so a hyphen/`?`/`>` that is not its own grapheme (a combining mark attached to it, changing the composed `Character`) is copied verbatim exactly as before, never misread from its lone UTF-8 byte. Verified by a dedicated differential suite (`Tests/CommentAndPIEscapingDifferentialTests.swift`) that reimplements the original character-loop as an independent oracle and checks equality over hand-picked adjacency/trailing/combining-mark cases plus two 20000-case seeded fuzzes (one per function); full local suite (1925 tests) passes. No comment/PI content exists in the standard benchmark corpus, so this was measured with a dedicated micro-benchmark (a document of 300000 alternating realistic comment/PI nodes - hyphenated prose, copyright lines, `?`-bearing PI data - driven through the public `PureXML.serialize` entry point, since `Escaping` is internal): same-state interleaved A/B, clean separation across 4 runs, old 0.451-0.455 s vs new 0.223-0.224 s, ~2x. | unchanged | unchanged | ~2x on comment/PI-bearing content (no effect on the standard corpus, which has none) | | 25 | `scanAttributeValue`'s closing-quote check: after the content loop peeks the next character to decide whether it is the closing quote (breaking either on a match or on end of input, the only two cases), `reader.consume(String(quote))` re-derived the same answer through `matches`/`consume`'s general path, which allocates a fresh `String(quote)` and iterates it via `Sequence`/`Character` machinery twice (once to compare, once to count how many times to `advance()`), on every single attribute value in the document. Checking the already-buffered peek directly (`reader.peek() == quote`) and calling `advance()` once is the same observable behavior (same throw on end-of-input, same consumption on a match) for far less work. Verified: full local suite (1852 tests) and the full W3C XSTS suite (14383 groups, ratchet unchanged) pass unchanged. | 0.129 s (fresh session baseline; see note below) | unchanged | unchanged | | 26 | `NamespaceContext.enterElement`, called once per element: (a) `checkNameShape`'s `name.localName.contains(":")` used a `Character` literal, resolving to `Sequence.contains(_:)`'s grapheme-by-grapheme walk to find one ASCII byte; `localName.utf8.contains(0x3A)` finds the same colon via the UTF-8 byte view, since 0x3A is never a lead or continuation byte of a multi-byte sequence. (b) `attributes.map { try resolveAttribute($0, at: mark) }` allocated a fresh backing array on every element even though `resolveAttribute` returns its argument completely unchanged whenever the attribute has no prefix or the `xmlns` prefix (the common case: most attributes in most documents are unprefixed); checking `attributes.contains { prefix != nil && prefix != "xmlns" }` first (no allocation) and reusing `attributes` directly when nothing would change gives the identical `resolvedAttributes` value for the common case without the allocation. Verified: full local suite and full W3C XSTS suite pass unchanged. | 0.121 s (~6.00x from 0.129 s at pass 25's baseline) | unchanged | unchanged | | 27 | Sample-profiling isolated `scanContentMarkup()` (the inlined dispatcher covering `scanStartTag`/`scanAttributes`/`scanAttributeValue`/`NamespaceContext.enterElement`) at ~46% of total parse time. Three further findings in the same shapes as passes 25/26: (a) `scanAttributeValue`'s opening quote check still ran on the Character path (`reader.peek()` + `reader.advance()`) even though the byte is known ASCII and non-CR/LF; `peekByte()` plus `reader.consume(_:)` reaches the byte fast path directly (`peekByte()` only returns non-nil when the lookahead buffer is already empty, so `consume`'s own `matchesBytes` engages with no Character buffering at all), extended to the closing-quote check too (a byte peek before the Character peek, avoiding buffering the quote as a Character only to compare and discard it). (b) `NamespaceContext.checkExpandedNames` allocated a `Set`'s storage and built a concatenated key per element even when 0 or 1 attribute carries a namespace URI, the overwhelming majority of elements; the check can only ever throw when two URI-bearing attributes collide, so counting first (no allocation) and returning early below 2 skips the allocation entirely in that case (for the built-in `xml` namespace URI specifically, the key string is long enough to need its own heap allocation, so this also cuts a real per-`xml:*`-attribute allocation). (c) `EntityDecoder.decode`/`decodeLenient`'s `raw.contains("&")` guard was the same grapheme-walk-for-one-ASCII-byte shape pass 26(a) already fixed elsewhere; `raw.utf8.contains(0x26)` finds the same `&` via the UTF-8 byte view. Verified: full local suite (1858 tests, 6 new covering the quote-handling edge cases: empty value, single-quoted, a quote-kind nested inside the other, unterminated at end of input, and byte/Character-path agreement for all of them) and full W3C XSTS suite (14383 groups) pass unchanged. | 0.099 s (~4.75x from 0.121 s at pass 26's baseline) | unchanged | unchanged | @@ -134,6 +136,88 @@ patched. `Character` and dropping it costs about the same as `consume`'s literal-matching path. Sample attribution at this granularity overstates single-character round trips; trust only the interleaved A/B. +- **Dead end, do not redo: name interning (libxml2's `xmlDict`), at ANY name + length.** A bounded byte-span-to-`String` intern cache in `takeASCIIName` + (256-slot open addressing on `ByteStorage`, FNV over the span, correctness- + and XSTS-verified) was measured in two rounds. Round one, on the standard + short-name corpus, was neutral. That first result looked explainable by + Swift's small-string optimization alone (**strings up to 15 UTF-8 bytes are + stored inline, so building a short ASCII name allocates nothing**), which + would have left interning a live option for documents whose names exceed + 15 bytes and therefore heap-allocate per occurrence. Round two tested + exactly that boundary with two corpora, one all names <=15 bytes and one + all names >15 bytes (element and attribute names 20-26 bytes, few distinct, + each repeated 100k times), interning gated to fire only above the + small-string threshold. Result (two interleaved A/B pairs each, XSTS clean): + neutral-to-slightly-negative on short names, and **decisively NEGATIVE on + long names, ~4-5% slower, both pairs** — the opposite of the hypothesis. + The complete mechanism, which the boundary test forced out: a Swift + `String` is reference-counted, so interning does not merely avoid an + allocation, it trades that allocation for **ARC traffic on the shared + instance** — every cache probe copies a `String` out of a slot (a retain), + and a hit returns another retain. For the name sizes that occur in XML, + the atomic retain/release traffic to share one heap `String` costs more + than the small `malloc` + memcpy + UTF-8 validation it was meant to + replace. `xmlDict` pays off in C precisely because a C name is a raw + pointer with no refcount: sharing is free (pointer equality). Swift's + `String` has no free-to-share form, so the technique does not port at any + length. Consequence for future rounds: interning names, and more broadly + any candidate whose only saving is "materialize one shared refcounted + value instead of N cheap independent ones," is negative by construction in + Swift and not worth an A/B. The remaining real costs on this corpus sit in + reference-counted allocations that no sharing scheme can cheapen: `TreeNode` + instances, the weak-parent side tables (#8, decided/deferred), attribute + arrays, and the per-event enum machinery, plus the scanning-architecture + split (#142). +- **Dead end, do not redo: replacing a single interpolated `output += + "...\(a)...\(b)..."` with several sequential `output += "..."; output += + a; ...` appends, in the serializer's element/attribute/close/CDATA/ + comment/PI writers.** Sample-profiling the serialize loop (100k-item + corpus, 20 s capture) showed `DefaultStringInterpolation.appendLiteral` + and `_SmallString.init(_:appending:)` as two of the heaviest leaf frames, + suggesting the interpolation's intermediate buffer was the cost and that + splitting it into direct appends into `output` would avoid it. Measured: + the opposite. Same-state interleaved A/B at the 20k corpus, multi-append + consistently slower than the unmodified interpolated baseline across 5 + runs with no overlap (patched 0.0354-0.0359 s vs baseline 0.0335-0.0336 + s, ~6% slower). The mechanism: `output +=` is not free per call regardless + of what is being appended, since each call re-checks + `isKnownUniquelyReferenced` and remaining native capacity before the + copy; one interpolated append per site pays that check once and copies + the fully-assembled piece in one `memmove`, while N sequential appends + pay the check N times for the same total bytes copied. The interpolation + machinery's own overhead (building the intermediate) was real but smaller + than the per-call check/capacity overhead it was traded for. Isolating the + variables mattered: the narrower fix in pass 29 (precompute the `Character` + quote as a `String` once, keep everything else as ONE interpolated append + per attribute) kept the single-append discipline and measured a small + real win, confirming the appends-per-site count, not the interpolation + machinery itself, was the sensitive variable. Consequence for future + rounds: when a site already does its work in one `output +=` call, + decomposing it into more calls is negative by construction here even if + it removes allocation; the fix that wins is reducing what goes *into* + the one call, not increasing the call count. +- **Checked, not indicted: `write(element:)`'s `hasInlineContent` check + (`element.children.contains { $0 is .text/.cdata }`).** A plausible-looking + suspect from code reading (it walks `element.children` a second time, + redundant with `push`'s walk right after it), but sample-profiling the + 100k-item corpus put `childFormatting(of:parentFormatted:hasInlineContent:)` + (which consumes its result) at 14 of ~7800 top-of-stack samples (~0.2%); + `hasInlineContent`'s own closure does not even appear as a distinct frame, + meaning it was inlined into `write` and contributes a sliver of that + function's already-small 186 samples (~2.4%). On this corpus's shape the + check is early-exit-cheap almost everywhere (``/``/`` + match their single text child immediately) and the one costly full scan + (the `` root against its 100k `` children, none of which + match) is paid exactly once, not per element. A same-state experiment that + hardcoded the result to `false` to try to isolate a ceiling was invalid as + a measurement: forcing it changes `childFormatting`'s decision for + ``/``/`` (they stop early-returning `false`) and adds + real indentation output those elements should not get, which is slower and + writes more bytes (24095401 vs 20995400) for reasons unrelated to the + check's own cost. No pass taken; not worth an A/B without a corpus that is + wide-fan-out at multiple nesting levels rather than just the root, which + this campaign's standard corpus is not. ## Rules