Default HTML serialization rebuilds text that needs no escaping
perfloop-oss/anglesharp · ALLOCATION HOT LOOP
https://perfloop.ai/t/oss/case_1jv77e14xn
Verdict
VERIFIED · settled 2026-10-18 · merged as AngleSharp/AngleSharp#1388
What happened: The paired measurements met the required improvement.
Hypothesis
Default DOM-to-HTML serialization rebuilds text even when no character needs escaping. On the committed W3C page, this affects thousands of text values whose unchanged contents are already held by the DOM. Avoiding those copies could reduce allocation substantially, but the optimized operation has not been measured.
At revision d4cef0c88fe50bdb95268d8ec7b3d69cec9d353d, FormatExtensions.ToHtml() selects HtmlMarkupFormatter.Instance. Node.ToHtml calls formatter.Text for character data outside the literal-text branch, after handling comments and processing instructions separately. HtmlMarkupFormatter.Text passes the existing string to EscapeText. EscapeText always obtains a builder, appends every character, and calls ToPool, which materializes a string before returning the builder. The writer consumes that result without requiring a distinct string identity. Confidence is medium.
The benefiting operation is complete default document.ToHtml() serialization of the unchanged W3C Selectors draft captured in src/AngleSharp.Benchmarks/page.html. Its heading and W3C document links establish the captured input, rather than an invented attribute or text distribution. docs/tutorials/03-Examples.md:33-42 documents the consumer operation of parsing a web page and serializing its DOM back to a string. README.md:29 and 90-96 describe allocation reduction and HTML construction. The claim is limited to this captured-document workload, not a production traffic distribution.
The W3C page ToHtml allocation/count check built Release/net10.0 and used default pooling: enabled, MaxCount=4, SizeLimit=85000. After 64 warmups, 512 complete calls allocated 1,046,321 bytes per serialization on the calling thread. An output-equivalent counting formatter, run outside the allocation measurement, observed 2,819 Text calls. Of these, 2,761 had nonempty text containing none of ampersand, nonbreaking space, greater-than, or less-than. Those values contained 86,098 UTF-16 characters. The check also passed a control containing all four escape characters.
The inferred removable string payload is 172,196 bytes per successful full serialization, about 16.5% of that measured allocation baseline, before string headers or changes in buffer reuse. This is not a measured gain. The final HTML has 123,858 characters and its final string and assembly buffers remain necessary; the repeated unchanged-text payload is nevertheless of the same order as the final string. Profile receipt call_z1ZTabHPOmblLHihJbvqV4Wv covers 10,000 full default serializations of this pre-parsed page, plus setup and warmup. It shows string materialization and pool frames, but does not isolate this text branch or measure allocated bytes. It establishes neither a text-only CPU share nor lock contention.
The required Case check is complete document.ToHtml() on this captured page with a stable pre-parsed DOM and the same pool settings, comparing allocated bytes per completed call and identical returned HTML. A character-scan microbenchmark alone is insufficient. Confirm lower full-operation allocation; reject the performance claim if buffer-reuse changes erase the saving. Escaped text, empty input, literal-text nodes, null-input behavior, and custom formatters are regression controls, not the benefiting population. Production frequency and elapsed-time benefit remain unmeasured limits.
Change to test: Skip buffer construction for text that has none of the four escape characters, returning the existing immutable string instead. Preserve the current escaping and null/empty-input behavior without adding public API.
Where it lives
Evidence
W3C page ToHtml · 11 sample pairs
| metric | baseline | candidate | paired median change | confidence range | required | result |
|---|---|---|---|---|---|---|
B/op |
1042499 |
806752 |
−22.7% (−236420) |
−240767 to −230883 |
gain > 0; gain > 0.05 × |baseline median|; arm medians separated |
PASSED |
ns/op |
8474518 |
7014303 |
−19.8% (−1676977) |
−2649008 to −404325 |
loss ≤ 0.05 × |baseline median| |
PASSED |
Checks: 6 of 6 passed. Verification: no defect found.