Skip to content

perf: bulk-write clean runs in HTML encoders (~10-20% faster string-heavy rendering) - #651

Merged
rexm merged 7 commits into
masterfrom
perf/html-encoder-bulk-writes
Aug 5, 2026
Merged

perf: bulk-write clean runs in HTML encoders (~10-20% faster string-heavy rendering)#651
rexm merged 7 commits into
masterfrom
perf/html-encoder-bulk-writes

Conversation

@rexm

@rexm rexm commented Aug 5, 2026

Copy link
Copy Markdown
Member

Summary

Speeds up HTML encoding — the hot path every {{expression}} output goes through — by writing clean runs of text in bulk instead of one TextWriter.Write(char) virtual call per character.

How it works:

  • Scan for the first character that needs escaping (SearchValues<char> on net8.0 — pre-computed lookup, vectorized; a plain scan loop on netstandard2.0/2.1).
  • If nothing needs escaping (the common case for typical property values), write the whole string with a single Write(string) call.
  • Otherwise, bulk-write the clean prefix and encode the remainder with the original per-character loop.
  • Bulk prefix writes use AsSpan() only for StringWriter/StreamWriter (which override Write(ReadOnlySpan<char>) efficiently); other writers keep per-char behavior, because TextWriter's base span implementation rents/copies via ArrayPool and can be slower than what it replaces.

Applied to both HtmlEncoder and HtmlEncoderLegacy. 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%. SearchValues fixes this on net8.0.
  • Bulk-writing every clean segment between escapes regressed escape-dense content ~11%: the fixed cost of a span write outweighs a few 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.
RenderToString is the new suite from #650 — it renders through the string-returning API (real StringWriter), where output cost is actually paid; the other suites render to TextWriter.Null.

Benchmark Case master This PR Δ time
RenderSimple dictionary 509.96 ns 399.20 ns −21.7%
RenderSimple expando 414.36 ns 350.20 ns −15.5%
RenderSimple object 856.85 ns 752.40 ns −12.2%
RenderList N=10, dictionary 2,420.21 ns 2,033.00 ns −16.0%
RenderList N=10, object 2,916.26 ns 2,660.00 ns −8.8%
RenderList N=100, dictionary 23.42 us 19.06 us −18.6%
RenderList N=100, object 27.48 us 22.05 us −19.8%
RenderList N=1000, dictionary 231.70 us 194.39 us −16.1%
RenderList N=1000, object 259.14 us 213.59 us −17.6%
RenderNested dictionary, rows=5 5,413.42 ns 4,979.00 ns −8.0%
RenderNested dictionary, rows=20 18.54 us 17.54 us −5.4%
RenderNested object, rows=5 6,764.33 ns 6,643.00 ns −1.8%
RenderNested object, rows=20 24.52 us 23.94 us −2.4%
RenderToString clean 13.95 us 10.97 us −21.4%
RenderToString html (escape-dense) 14.75 us 14.43 us −2.2%

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 (unencoded WriteSafeString): all within ±2%
  • Escape-dense content (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).

Note: includes the RenderToString benchmark suite commit (submitted separately as #650) so the numbers above are reproducible from this branch.

🤖 Generated with Claude Code

rexm and others added 7 commits August 4, 2026 21:59
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
rexm enabled auto-merge August 5, 2026 03:45
@sonarqubecloud

sonarqubecloud Bot commented Aug 5, 2026

Copy link
Copy Markdown

@rexm
rexm merged commit c5f0813 into master Aug 5, 2026
7 checks passed
@rexm
rexm deleted the perf/html-encoder-bulk-writes branch August 5, 2026 03:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant