From 5e34ff983fe5302aa9c69a62c189105153c5e3c8 Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Tue, 4 Aug 2026 21:59:49 -0400 Subject: [PATCH 1/5] perf: bulk-write runs of unescaped characters in HTML encoders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- source/Handlebars/IO/HtmlEncoder.cs | 70 +++++++++++++++++--- source/Handlebars/IO/HtmlEncoderLegacy.cs | 80 ++++++++++++++++++++++- 2 files changed, 139 insertions(+), 11 deletions(-) diff --git a/source/Handlebars/IO/HtmlEncoder.cs b/source/Handlebars/IO/HtmlEncoder.cs index 95e3e942..c83c2919 100644 --- a/source/Handlebars/IO/HtmlEncoder.cs +++ b/source/Handlebars/IO/HtmlEncoder.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Runtime.CompilerServices; using System.Text; @@ -12,30 +13,83 @@ namespace HandlebarsDotNet /// public class HtmlEncoder : ITextEncoder { + /* + * Escape set based on: https://github.com/handlebars-lang/handlebars.js/blob/master/lib/handlebars/utils.js + * As of 2021-12-20 / commit https://github.com/handlebars-lang/handlebars.js/commit/3fb331ef40ee1a8308dd83b8e5adbcd798d0adc9 + */ + private static readonly char[] EscapeChars = { '&', '<', '>', '"', '\'', '`', '=' }; + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Encode(StringBuilder text, TextWriter target) { if(text == null || text.Length == 0) return; - + EncodeImpl(new StringBuilderEnumerator(text), target); } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Encode(string text, TextWriter target) { if(string.IsNullOrEmpty(text)) return; - - EncodeImpl(new StringEnumerator(text), target); + + var index = text.IndexOfAny(EscapeChars); + if (index == -1) + { + // Fast path: nothing to escape, write the whole string at once + target.Write(text); + return; + } + + var start = 0; + do + { + var runLength = index - start; + if (runLength != 0) WriteRun(text, start, runLength, target); + target.Write(GetEscapeSequence(text[index])); + + start = index + 1; + index = start < text.Length ? text.IndexOfAny(EscapeChars, start) : -1; + } while (index != -1); + + if (start < text.Length) WriteRun(text, start, text.Length - start, target); } - + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Encode(T text, TextWriter target) where T : IEnumerator { if (text is null) return; - + EncodeImpl(text, target); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void WriteRun(string text, int start, int length, TextWriter target) + { +#if NETSTANDARD2_0 + var end = start + length; + for (var i = start; i < end; i++) + { + target.Write(text[i]); + } +#else + target.Write(text.AsSpan(start, length)); +#endif + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string GetEscapeSequence(char value) + { + switch (value) + { + case '&': return "&"; + case '<': return "<"; + case '>': return ">"; + case '"': return """; + case '\'': return "'"; + case '`': return "`"; + default: return "="; // '=' + } + } + private static void EncodeImpl(T text, TextWriter target) where T : IEnumerator { /* diff --git a/source/Handlebars/IO/HtmlEncoderLegacy.cs b/source/Handlebars/IO/HtmlEncoderLegacy.cs index 2d6ba88a..d6444317 100644 --- a/source/Handlebars/IO/HtmlEncoderLegacy.cs +++ b/source/Handlebars/IO/HtmlEncoderLegacy.cs @@ -1,4 +1,5 @@ -using HandlebarsDotNet.StringUtils; +using HandlebarsDotNet.StringUtils; +using System; using System.Collections.Generic; using System.IO; using System.Runtime.CompilerServices; @@ -24,12 +25,32 @@ public void Encode(StringBuilder text, TextWriter target) EncodeImpl(new StringBuilderEnumerator(text), target); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Encode(string text, TextWriter target) { if (string.IsNullOrEmpty(text)) return; - EncodeImpl(new StringEnumerator(text), target); + var length = text.Length; + var start = 0; + var anyEscaped = false; + for (var index = 0; index < length; index++) + { + var value = text[index]; + if (!RequiresEscaping(value)) continue; + + anyEscaped = true; + if (index != start) WriteRun(text, start, index - start, target); + WriteEscaped(value, target); + start = index + 1; + } + + if (!anyEscaped) + { + // Fast path: nothing to escape, write the whole string at once + target.Write(text); + return; + } + + if (start < length) WriteRun(text, start, length - start, target); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -40,6 +61,59 @@ public void Encode(T text, TextWriter target) where T : IEnumerator EncodeImpl(text, target); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool RequiresEscaping(char value) + { + switch (value) + { + case '"': + case '&': + case '<': + case '>': + return true; + default: + return value > 159; + } + } + + private static void WriteEscaped(char value, TextWriter target) + { + switch (value) + { + case '"': + target.Write("""); + break; + case '&': + target.Write("&"); + break; + case '<': + target.Write("<"); + break; + case '>': + target.Write(">"); + break; + default: + target.Write("&#"); + target.Write((int)value); + target.Write(";"); + break; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void WriteRun(string text, int start, int length, TextWriter target) + { +#if NETSTANDARD2_0 + var end = start + length; + for (var i = start; i < end; i++) + { + target.Write(text[i]); + } +#else + target.Write(text.AsSpan(start, length)); +#endif + } + private static void EncodeImpl(T text, TextWriter target) where T : IEnumerator { while (text.MoveNext()) From ee7f8ab47e4261b6f0649891c41ba59edb2a39ee Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Tue, 4 Aug 2026 22:16:34 -0400 Subject: [PATCH 2/5] bench: add RenderToString suite covering the string-returning API 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 --- source/Handlebars.Benchmark/RenderToString.cs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 source/Handlebars.Benchmark/RenderToString.cs diff --git a/source/Handlebars.Benchmark/RenderToString.cs b/source/Handlebars.Benchmark/RenderToString.cs new file mode 100644 index 00000000..9b4e902d --- /dev/null +++ b/source/Handlebars.Benchmark/RenderToString.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using System.IO; +using BenchmarkDotNet.Attributes; +using HandlebarsDotNet; + +namespace HandlebarsNet.Benchmark +{ + // Render time for the string-returning template API — the most common way the + // library is consumed. Unlike the other Render* benchmarks (which write to + // TextWriter.Null and therefore only measure resolution overhead), this suite + // pays the real output cost: HTML encoding and StringBuilder-backed writes. + // + // Content=clean: values contain nothing that needs HTML escaping (typical data). + // Content=html: values are dense with characters that must be escaped (worst case). + [MemoryDiagnoser] + public class RenderToString + { + private HandlebarsTemplate _template; + private object _data; + + private const int ItemCount = 50; + + private const string Source = + "
    " + + "{{#each items}}" + + "
  • {{name}} — {{description}} " + + "x{{qty}}{{#if onSale}} SALE{{/if}}
  • " + + "{{/each}}" + + "
"; + + [Params("clean", "html")] + public string Content { get; set; } + + [GlobalSetup] + public void Setup() + { + var handlebars = Handlebars.Create(); + _template = handlebars.Compile(Source); + + var description = Content == "clean" + ? "Reliable everyday item for home and office use" + : "Fast & reliable item \"for\" home & office use"; + + var items = new List(ItemCount); + for (var i = 0; i < ItemCount; i++) + { + items.Add(new + { + name = $"Product {i:D4}", + description, + qty = i * 7 + 1, + onSale = i % 3 == 0 + }); + } + + _data = new { items }; + } + + [Benchmark] + public string Render() => _template(_data); + } +} From 421fb289be3ee77e4e7435ac2e7bc35b982d7caa Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Tue, 4 Aug 2026 22:29:25 -0400 Subject: [PATCH 3/5] perf: only use span writes for writers with efficient span support TextWriter's base Write(ReadOnlySpan) 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 --- source/Handlebars/IO/HtmlEncoder.cs | 14 ++++++++++---- source/Handlebars/IO/HtmlEncoderLegacy.cs | 14 ++++++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/source/Handlebars/IO/HtmlEncoder.cs b/source/Handlebars/IO/HtmlEncoder.cs index c83c2919..b114a669 100644 --- a/source/Handlebars/IO/HtmlEncoder.cs +++ b/source/Handlebars/IO/HtmlEncoder.cs @@ -64,15 +64,21 @@ public void Encode(T text, TextWriter target) where T : IEnumerator [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void WriteRun(string text, int start, int length, TextWriter target) { -#if NETSTANDARD2_0 +#if !NETSTANDARD2_0 + // StringWriter and StreamWriter override Write(ReadOnlySpan) with efficient + // implementations; for other writers TextWriter's base implementation rents and + // copies through ArrayPool, so they keep the original per-character writes. + if (target is StringWriter || target is StreamWriter) + { + target.Write(text.AsSpan(start, length)); + return; + } +#endif var end = start + length; for (var i = start; i < end; i++) { target.Write(text[i]); } -#else - target.Write(text.AsSpan(start, length)); -#endif } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/source/Handlebars/IO/HtmlEncoderLegacy.cs b/source/Handlebars/IO/HtmlEncoderLegacy.cs index d6444317..80d13283 100644 --- a/source/Handlebars/IO/HtmlEncoderLegacy.cs +++ b/source/Handlebars/IO/HtmlEncoderLegacy.cs @@ -103,15 +103,21 @@ private static void WriteEscaped(char value, TextWriter target) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void WriteRun(string text, int start, int length, TextWriter target) { -#if NETSTANDARD2_0 +#if !NETSTANDARD2_0 + // StringWriter and StreamWriter override Write(ReadOnlySpan) with efficient + // implementations; for other writers TextWriter's base implementation rents and + // copies through ArrayPool, so they keep the original per-character writes. + if (target is StringWriter || target is StreamWriter) + { + target.Write(text.AsSpan(start, length)); + return; + } +#endif var end = start + length; for (var i = start; i < end; i++) { target.Write(text[i]); } -#else - target.Write(text.AsSpan(start, length)); -#endif } private static void EncodeImpl(T text, TextWriter target) where T : IEnumerator From b2b81ac6f2af288522a61779e1f9e9e4ca8c4d5d Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Tue, 4 Aug 2026 23:10:44 -0400 Subject: [PATCH 4/5] perf: use SearchValues for escape scanning on net8.0 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 --- source/Handlebars/IO/HtmlEncoder.cs | 36 ++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/source/Handlebars/IO/HtmlEncoder.cs b/source/Handlebars/IO/HtmlEncoder.cs index b114a669..45769150 100644 --- a/source/Handlebars/IO/HtmlEncoder.cs +++ b/source/Handlebars/IO/HtmlEncoder.cs @@ -17,7 +17,9 @@ public class HtmlEncoder : ITextEncoder * Escape set based on: https://github.com/handlebars-lang/handlebars.js/blob/master/lib/handlebars/utils.js * As of 2021-12-20 / commit https://github.com/handlebars-lang/handlebars.js/commit/3fb331ef40ee1a8308dd83b8e5adbcd798d0adc9 */ - private static readonly char[] EscapeChars = { '&', '<', '>', '"', '\'', '`', '=' }; +#if NET8_0_OR_GREATER + private static readonly System.Buffers.SearchValues EscapeChars = System.Buffers.SearchValues.Create("&<>\"'`="); +#endif [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Encode(StringBuilder text, TextWriter target) @@ -31,7 +33,7 @@ public void Encode(string text, TextWriter target) { if(string.IsNullOrEmpty(text)) return; - var index = text.IndexOfAny(EscapeChars); + var index = IndexOfEscapeChar(text, 0); if (index == -1) { // Fast path: nothing to escape, write the whole string at once @@ -47,12 +49,40 @@ public void Encode(string text, TextWriter target) target.Write(GetEscapeSequence(text[index])); start = index + 1; - index = start < text.Length ? text.IndexOfAny(EscapeChars, start) : -1; + index = start < text.Length ? IndexOfEscapeChar(text, start) : -1; } while (index != -1); if (start < text.Length) WriteRun(text, start, text.Length - start, target); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int IndexOfEscapeChar(string text, int start) + { +#if NET8_0_OR_GREATER + // SearchValues pre-computes the lookup structure once; string.IndexOfAny(char[]) + // with more than 5 needles would rebuild a probabilistic map on every call. + var index = text.AsSpan(start).IndexOfAny(EscapeChars); + return index < 0 ? -1 : index + start; +#else + for (var i = start; i < text.Length; i++) + { + switch (text[i]) + { + case '&': + case '<': + case '>': + case '"': + case '\'': + case '`': + case '=': + return i; + } + } + + return -1; +#endif + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Encode(T text, TextWriter target) where T : IEnumerator { From 1ca1636adf16d5076a51fe49e2f06161cfda2689 Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Tue, 4 Aug 2026 23:23:18 -0400 Subject: [PATCH 5/5] perf: bulk-write only the clean prefix, per-char encode the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- source/Handlebars/IO/HtmlEncoder.cs | 32 +++----------- source/Handlebars/IO/HtmlEncoderLegacy.cs | 44 +++---------------- .../StringUtils/StringEnumerator.cs | 8 ++++ 3 files changed, 19 insertions(+), 65 deletions(-) diff --git a/source/Handlebars/IO/HtmlEncoder.cs b/source/Handlebars/IO/HtmlEncoder.cs index 45769150..2726368c 100644 --- a/source/Handlebars/IO/HtmlEncoder.cs +++ b/source/Handlebars/IO/HtmlEncoder.cs @@ -41,18 +41,11 @@ public void Encode(string text, TextWriter target) return; } - var start = 0; - do - { - var runLength = index - start; - if (runLength != 0) WriteRun(text, start, runLength, target); - target.Write(GetEscapeSequence(text[index])); - - start = index + 1; - index = start < text.Length ? IndexOfEscapeChar(text, start) : -1; - } while (index != -1); - - if (start < text.Length) WriteRun(text, start, text.Length - start, target); + // Bulk-write the clean prefix, then fall back to per-character encoding. + // Escape-dense content makes per-segment bulk writes counter-productive: + // the fixed cost of a span write outweighs a few Write(char) calls. + if (index != 0) WriteRun(text, 0, index, target); + EncodeImpl(new StringEnumerator(text, index), target); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -111,21 +104,6 @@ private static void WriteRun(string text, int start, int length, TextWriter targ } } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static string GetEscapeSequence(char value) - { - switch (value) - { - case '&': return "&"; - case '<': return "<"; - case '>': return ">"; - case '"': return """; - case '\'': return "'"; - case '`': return "`"; - default: return "="; // '=' - } - } - private static void EncodeImpl(T text, TextWriter target) where T : IEnumerator { /* diff --git a/source/Handlebars/IO/HtmlEncoderLegacy.cs b/source/Handlebars/IO/HtmlEncoderLegacy.cs index 80d13283..d060afe9 100644 --- a/source/Handlebars/IO/HtmlEncoderLegacy.cs +++ b/source/Handlebars/IO/HtmlEncoderLegacy.cs @@ -30,27 +30,19 @@ public void Encode(string text, TextWriter target) if (string.IsNullOrEmpty(text)) return; var length = text.Length; - var start = 0; - var anyEscaped = false; - for (var index = 0; index < length; index++) - { - var value = text[index]; - if (!RequiresEscaping(value)) continue; - - anyEscaped = true; - if (index != start) WriteRun(text, start, index - start, target); - WriteEscaped(value, target); - start = index + 1; - } + var index = 0; + while (index < length && !RequiresEscaping(text[index])) index++; - if (!anyEscaped) + if (index == length) { // Fast path: nothing to escape, write the whole string at once target.Write(text); return; } - if (start < length) WriteRun(text, start, length - start, target); + // Bulk-write the clean prefix, then fall back to per-character encoding. + if (index != 0) WriteRun(text, 0, index, target); + EncodeImpl(new StringEnumerator(text, index), target); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -76,30 +68,6 @@ private static bool RequiresEscaping(char value) } } - private static void WriteEscaped(char value, TextWriter target) - { - switch (value) - { - case '"': - target.Write("""); - break; - case '&': - target.Write("&"); - break; - case '<': - target.Write("<"); - break; - case '>': - target.Write(">"); - break; - default: - target.Write("&#"); - target.Write((int)value); - target.Write(";"); - break; - } - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void WriteRun(string text, int start, int length, TextWriter target) { diff --git a/source/Handlebars/StringUtils/StringEnumerator.cs b/source/Handlebars/StringUtils/StringEnumerator.cs index deb4d1df..3c305b59 100644 --- a/source/Handlebars/StringUtils/StringEnumerator.cs +++ b/source/Handlebars/StringUtils/StringEnumerator.cs @@ -18,6 +18,14 @@ public StringEnumerator(string text) _length = _text.Length; _index = -1; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public StringEnumerator(string text, int start) + { + _text = text; + _length = _text.Length; + _index = start - 1; + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool MoveNext() => ++_index < _length;