From 2ccbb62c6557fa554edb22b27ee661a2b3cd9ed6 Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Sun, 9 Aug 2026 10:37:46 -0400 Subject: [PATCH 1/2] perf: keep template JIT thin - NoInlining entry points for statement writes and helper-literal dispatch Dynamic methods are JIT-compiled at CreateDelegate, and the JIT honored AggressiveInlining on EncodedTextWriter.Write and the helper options/context/arguments construction, inline-expanding that machinery into every mustache call site of every compiled template. Emit thin NoInlining static entry points instead (JIT-compiled once per process): - EncodedTextWriter.WriteObjectTo for {{expr}} statement writes - CompiledHelperInvokers.WriteInvoke/Invoke for zero-argument helper literals Compilation: 10.80 -> 1.75 ms (-84%). CompileMany: N=10 57.7 -> 5.8 ms, N=100 537.9 -> 69.4 ms (-87-90%). Probe: 40-statement dotted-path template 13.1 -> 1.5 ms/compile. Co-Authored-By: Claude Fable 5 --- .../Translation/Expression/PathBinder.cs | 24 ++++++++----- .../Helpers/CompiledHelperInvokers.cs | 35 +++++++++++++++++++ source/Handlebars/IO/EncodedTextWriter.cs | 8 +++++ 3 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 source/Handlebars/Helpers/CompiledHelperInvokers.cs diff --git a/source/Handlebars/Compiler/Translation/Expression/PathBinder.cs b/source/Handlebars/Compiler/Translation/Expression/PathBinder.cs index 22662fb9..1782a76b 100644 --- a/source/Handlebars/Compiler/Translation/Expression/PathBinder.cs +++ b/source/Handlebars/Compiler/Translation/Expression/PathBinder.cs @@ -9,6 +9,10 @@ namespace HandlebarsDotNet.Compiler { internal class PathBinder : HandlebarsExpressionVisitor { + private static readonly System.Reflection.MethodInfo WriteObjectToMethod = + typeof(EncodedTextWriter).GetMethod("WriteObjectTo", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)!; + private CompilationContext CompilationContext { get; } public PathBinder(CompilationContext compilationContext) @@ -64,16 +68,19 @@ protected override Expression VisitStatementExpression(StatementExpression sex) } var bindingContext = CompilationContext.Args.BindingContext; - var options = New(() => new HelperOptions(pathInfo, bindingContext)); - var contextValue = New(() => new Context(bindingContext)); - var args = New(() => new Arguments(0)); var textWriter = CompilationContext.Args.EncodedWriter; - return Call(() => helper.Value.Invoke(textWriter, options, contextValue, args)); + // Single NoInlining entry point instead of inline options/context/arguments + // construction + dispatch — see CompiledHelperInvokers for rationale. + return Call(() => CompiledHelperInvokers.WriteInvoke(textWriter, helper, pathInfo, bindingContext)); } var writer = CompilationContext.Args.EncodedWriter; var value = Arg(Visit(sex.Body)); - return writer.Call(o => o.Write(value)); + // Emit as a static call with the writer in (in-)argument position: LambdaCompiler + // compiles an instance call on the struct parameter whose argument is a call + // result dramatically slower (~0.3ms+ per statement) than the equivalent + // static-call shape used by the helper invocation route. + return Expression.Call(WriteObjectToMethod, writer.Expression, value.Expression); } protected override Expression VisitPathExpression(PathExpression pex) @@ -119,10 +126,9 @@ protected override Expression VisitPathExpression(PathExpression pex) } } - var options = New(() => new HelperOptions(pathInfo, bindingContext)); - var context = New(() => new Context(bindingContext)); - var argumentsArg = New(() => new Arguments(0)); - return Call(() => helper.Value.Invoke(options, context, argumentsArg)); + // Single NoInlining entry point instead of inline options/context/arguments + // construction + dispatch — see CompiledHelperInvokers for rationale. + return Call(() => CompiledHelperInvokers.Invoke(helper, pathInfo, bindingContext)); } } } diff --git a/source/Handlebars/Helpers/CompiledHelperInvokers.cs b/source/Handlebars/Helpers/CompiledHelperInvokers.cs new file mode 100644 index 00000000..3c0d7c47 --- /dev/null +++ b/source/Handlebars/Helpers/CompiledHelperInvokers.cs @@ -0,0 +1,35 @@ +using System.Runtime.CompilerServices; +using HandlebarsDotNet.PathStructure; +using HandlebarsDotNet.Runtime; + +namespace HandlebarsDotNet.Helpers +{ + /// + /// Static entry points emitted by the compiler for helper-literal statements + /// ({{name}} with no arguments). NoInlining keeps template JIT fast: dynamic methods + /// are compiled at CreateDelegate, and expanding the options/context/arguments + /// construction plus dispatch into every call site multiplied template JIT cost. + /// These methods are JIT-compiled once per process; templates emit one thin call. + /// + internal static class CompiledHelperInvokers + { + [MethodImpl(MethodImplOptions.NoInlining)] + internal static void WriteInvoke( + in EncodedTextWriter writer, + Ref> helper, + PathInfo pathInfo, + BindingContext bindingContext) + { + helper.Value.Invoke(writer, new HelperOptions(pathInfo, bindingContext), new Context(bindingContext), new Arguments(0)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + internal static object? Invoke( + Ref> helper, + PathInfo pathInfo, + BindingContext bindingContext) + { + return helper.Value.Invoke(new HelperOptions(pathInfo, bindingContext), new Context(bindingContext), new Arguments(0)); + } + } +} diff --git a/source/Handlebars/IO/EncodedTextWriter.cs b/source/Handlebars/IO/EncodedTextWriter.cs index e74f3a74..278732ec 100644 --- a/source/Handlebars/IO/EncodedTextWriter.cs +++ b/source/Handlebars/IO/EncodedTextWriter.cs @@ -114,6 +114,14 @@ public void Write(T value, bool encode) where T: IEnumerator [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Write(object? value) => Write(value); + // Static entry point used by compiled templates ({{expr}} statements). NoInlining is + // what makes template compilation fast: dynamic methods are JIT-compiled at + // CreateDelegate, and inlining the aggressive-inline Write type-switch and + // encoder machinery into every mustache call site multiplied template JIT cost ~6x. + // This wrapper is JIT-compiled once per process; templates emit one thin call. + [MethodImpl(MethodImplOptions.NoInlining)] + internal static void WriteObjectTo(in EncodedTextWriter writer, object? value) => writer.Write(value); + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Write(T? value) { From 9e39b8730326043be5c34ca3fb4b845f8e53831c Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Sun, 9 Aug 2026 10:54:22 -0400 Subject: [PATCH 2/2] perf: keep helper-literal statement dispatch inline; drop unused invoker The NoInlining entry point for zero-argument helper-literal statements ({{name}}) saved compile time but cost ~2ns per value per render on the hottest render shape (RenderToString clean +4%). The interface dispatch in that route cannot be inline-expanded by the JIT anyway, so its compile cost is moderate; keep it inline and retain the NoInlining entry points only where they are render-neutral (WriteObjectTo, path-expression Invoke). Render guardrail after this: RenderToString clean/html within noise of the render branch; Compilation 1.85ms (-83% vs 10.80ms), CompileMany N=100 78.1ms (-85% vs 537.9ms). Co-Authored-By: Claude Fable 5 --- .../Compiler/Translation/Expression/PathBinder.cs | 11 ++++++++--- source/Handlebars/Helpers/CompiledHelperInvokers.cs | 10 ---------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/source/Handlebars/Compiler/Translation/Expression/PathBinder.cs b/source/Handlebars/Compiler/Translation/Expression/PathBinder.cs index 1782a76b..9db57165 100644 --- a/source/Handlebars/Compiler/Translation/Expression/PathBinder.cs +++ b/source/Handlebars/Compiler/Translation/Expression/PathBinder.cs @@ -68,10 +68,15 @@ protected override Expression VisitStatementExpression(StatementExpression sex) } var bindingContext = CompilationContext.Args.BindingContext; + var options = New(() => new HelperOptions(pathInfo, bindingContext)); + var contextValue = New(() => new Context(bindingContext)); + var args = New(() => new Arguments(0)); var textWriter = CompilationContext.Args.EncodedWriter; - // Single NoInlining entry point instead of inline options/context/arguments - // construction + dispatch — see CompiledHelperInvokers for rationale. - return Call(() => CompiledHelperInvokers.WriteInvoke(textWriter, helper, pathInfo, bindingContext)); + // Kept inline (unlike the plain-path statement route below): this is the + // hottest render-time shape, and routing it through a NoInlining entry point + // costs ~2ns per value per render. Compile cost of this shape is moderate + // because the interface dispatch below cannot be inline-expanded by the JIT. + return Call(() => helper.Value.Invoke(textWriter, options, contextValue, args)); } var writer = CompilationContext.Args.EncodedWriter; diff --git a/source/Handlebars/Helpers/CompiledHelperInvokers.cs b/source/Handlebars/Helpers/CompiledHelperInvokers.cs index 3c0d7c47..56c7cb3f 100644 --- a/source/Handlebars/Helpers/CompiledHelperInvokers.cs +++ b/source/Handlebars/Helpers/CompiledHelperInvokers.cs @@ -13,16 +13,6 @@ namespace HandlebarsDotNet.Helpers /// internal static class CompiledHelperInvokers { - [MethodImpl(MethodImplOptions.NoInlining)] - internal static void WriteInvoke( - in EncodedTextWriter writer, - Ref> helper, - PathInfo pathInfo, - BindingContext bindingContext) - { - helper.Value.Invoke(writer, new HelperOptions(pathInfo, bindingContext), new Context(bindingContext), new Arguments(0)); - } - [MethodImpl(MethodImplOptions.NoInlining)] internal static object? Invoke( Ref> helper,