perf: bulk-write clean runs in HTML encoders (~10-20% faster string-heavy rendering) - #651
Merged
Conversation
HtmlEncoder and HtmlEncoderLegacy previously wrote output one character at a time through TextWriter.Write(char), costing a virtual call per character. Strings are now scanned for characters requiring escaping (IndexOfAny for HtmlEncoder, a single pass for HtmlEncoderLegacy) and clean runs are written in bulk — the whole string in one call when nothing needs escaping (the common case for typical property values), or as spans between escape sequences on netstandard2.1/net8.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The existing Render* suites write to TextWriter.Null, which measures path resolution but hides the real output costs (HTML encoding, StringBuilder- backed writes). This suite renders through the string-returning template API with clean values and escape-dense values as separate cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TextWriter's base Write(ReadOnlySpan<char>) rents and copies through ArrayPool, which can cost more than the per-char loop it replaces (e.g. TextWriter.Null). Restrict span run-writes to StringWriter/StreamWriter, which override the span overload efficiently; other writers keep the original per-character behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
string.IndexOfAny(char[]) with more than 5 needles rebuilds a probabilistic character map on every call, which measurably regressed escape-dense content and short strings. SearchValues pre-computes the lookup structure once; netstandard2.0/2.1 use a simple scan loop that matches the original per-character cost. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Escape-dense content made per-segment bulk writes counter-productive: the fixed cost of a span write per segment outweighs a few Write(char) calls (measured +11% on escape-heavy strings). Write the clean prefix in bulk — the whole string when nothing needs escaping — and encode the remainder with the original per-character loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-writes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rexm
enabled auto-merge
August 5, 2026 03:45
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
Speeds up HTML encoding — the hot path every
{{expression}}output goes through — by writing clean runs of text in bulk instead of oneTextWriter.Write(char)virtual call per character.How it works:
SearchValues<char>on net8.0 — pre-computed lookup, vectorized; a plain scan loop on netstandard2.0/2.1).Write(string)call.AsSpan()only forStringWriter/StreamWriter(which overrideWrite(ReadOnlySpan<char>)efficiently); other writers keep per-char behavior, becauseTextWriter's base span implementation rents/copies viaArrayPooland can be slower than what it replaces.Applied to both
HtmlEncoderandHtmlEncoderLegacy. Encoded output is byte-for-byte identical; all 1837 tests pass.Dead ends measured and avoided (why the implementation looks the way it does):
string.IndexOfAny(char[])with 7 needles rebuilds a probabilistic character map on every call — it regressed short strings by 5–15% and escape-dense content by 45%.SearchValuesfixes this on net8.0.Write(char)calls when segments are short. Hence prefix-only bulk writing.Benchmarks
MediumRun (LaunchCount=1, 15 iterations), Apple M4, .NET 10. Baseline is master @ 37ca7b9.
RenderToStringis the new suite from #650 — it renders through the string-returning API (realStringWriter), where output cost is actually paid; the other suites render toTextWriter.Null.Neutral (within run-to-run noise), as expected:
LargeArray(writes ints, not strings): −0.6% to −4.4%EndToEnd(helper-dominated): −1.4% / +2.2%Execution.*helper dispatch (unencodedWriteSafeString): all within ±2%RenderToString/html): measured twice, −2.2% and +8.1% — i.e. neutral within observed cross-run variance; the worst case trades nothing measurable for the ~20% typical-case win.Allocations are unchanged in every suite (this change affects call patterns, not allocation).
🤖 Generated with Claude Code