Skip to content

perf: 83-88% faster template compilation via NoInlining JIT entry points - #668

Merged
rexm merged 2 commits into
masterfrom
perf/compile-experiments
Aug 9, 2026
Merged

perf: 83-88% faster template compilation via NoInlining JIT entry points#668
rexm merged 2 commits into
masterfrom
perf/compile-experiments

Conversation

@rexm

@rexm rexm commented Aug 9, 2026

Copy link
Copy Markdown
Member

Follow-up to #667, targeting the compile path. Template compilation is 83–88% faster; render performance is verified unchanged (that was a hard constraint — one intermediate change that cost ~4% on RenderToString was found by the guardrail benchmarks and walked back).

Results

Benchmark Before After Change
Compilation (nested 3-level template) 10.80 ms 1.85 ms −83%
CompileMany N=10 57.7 ms 6.97 ms −88%
CompileMany N=100 537.9 ms 78.1 ms −85%
RenderToString clean / html (guardrail, 3-launch A/B) 7.46 / 10.35 µs 7.53 / 10.29 µs within noise
RenderSimple / RenderNested / RenderList (guardrail) flat to slightly improved

All 1912 tests pass after each commit.

Root cause

Profiling showed ~95–99% of compile time inside Expression.Compile()LambdaCompiler.CreateDelegate, mostly unmanaged — the Handlebars visitor pipeline is under 4%. Synthetic-tree bisection isolated the driver: dynamic methods are JIT-compiled eagerly at CreateDelegate, and the JIT honors [AggressiveInlining] on EncodedTextWriter.Write<object> and the type-switch/encoder machinery behind it — inline-expanding all of it into every mustache call site of every template. A 40-statement template calling an empty-bodied struct method compiles in 0.07 ms; the identical tree shape calling Write(object) took 2.9 ms. Template compile cost tracks the fatness of inlined callees, not tree size.

The change

Emit thin [MethodImpl(NoInlining)] static entry points, JIT-compiled once per process instead of re-inlined into every template:

  • EncodedTextWriter.WriteObjectTo(in writer, object?) for {{expr}} statement writes (probe: 40× {{a.b}} 13.1 → 1.5 ms/compile)
  • CompiledHelperInvokers.Invoke for helper-literal path expressions

Zero-argument helper-literal statements ({{name}} — the hottest render shape) deliberately keep their inline dispatch: routing them through a wrapper cost ~2 ns per value per render (+4% RenderToString clean), and their interface dispatch can't be inline-expanded by the JIT anyway, so their compile cost is moderate. That trade-off is documented in the code.

Tried and rejected

  • FastExpressionCompiler (5.4.1 and 4.2.2) as the IExpressionCompiler backend: 1315/1912 tests fail with InvalidProgramException — invalid IL for these tree shapes (likely the in-parameter struct delegates). May be worth an upstream issue.
  • Visitor-pass fusion: permanently deprioritized — ~4% ceiling per the profile.

Remaining follow-ups (documented, not implemented): the {{#if}} condition route (~63 µs/statement, auto-inlining of the IsTruthyOrNonEmpty chain) and helpers-with-arguments (HelperFunctionBinder) could get the same treatment.

🤖 Generated with Claude Code

rexm and others added 2 commits August 9, 2026 11:36
…writes and helper-literal dispatch

Dynamic methods are JIT-compiled at CreateDelegate, and the JIT honored
AggressiveInlining on EncodedTextWriter.Write<object> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Aug 9, 2026

Copy link
Copy Markdown

@rexm
rexm enabled auto-merge August 9, 2026 15:45
@rexm
rexm merged commit c9a20a2 into master Aug 9, 2026
7 checks passed
@rexm
rexm deleted the perf/compile-experiments branch August 9, 2026 15:49
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