Release 2.4.3 · Handlebars-Net/Handlebars.Net · GitHub
Skip to content

2.4.3

Latest

Choose a tag to compare

@rexm rexm released this 09 Aug 16:45
c9a20a2

A performance-focused release. Combined with the rendering work already shipped in 2.4.0, common rendering paths are now up to 56% faster than pre-2.4.0, and template compilation is up to 88% faster (#668). No API or behavior changes; all improvements are behavior-preserving and were validated against the full test suite (1912 tests) and A/B benchmark runs at every step.

Performance

Rendering — up to 56% faster since pre-2.4.0

Suite Case Pre-2.4.0 2.4.3 Δ (cumulative)
RenderList N=100, object 27.48 µs 12.16 µs −56%
RenderSimple object 856.85 ns 431 ns −50%
RenderNested rows=20, object 24.52 µs 12.44 µs −49%
RenderToString clean 13.95 µs 7.48 µs −46%

Methodology: the pre-2.4.0 baseline is the "Before" figure from each 2.4.0 change's own A/B benchmark (#652, #651); the 2.4.3 figure is this release's measured result. 2.4.1 and 2.4.2 shipped no rendering changes.

A few cases were only benchmarked starting at 2.4.2 -> 2.4.3:

Suite Case Baseline Result Δ Baseline release
RenderToString html 13.15 µs 10.37 µs −21% 2.4.2 (no pre-2.4.0 figure)
RenderList N=1000, dictionary 191.3 µs 126.7 µs −34% 2.4.2 (no pre-2.4.0 figure)
EndToEnd 26.1 µs 23.5 µs −10% 2.4.2 (no pre-2.4.0 figure)
RenderSimple dictionary 509.96 ns 399.20 ns −22% pre-2.4.0 (from 2.4.0's #651; not re-benchmarked in 2.4.3)

Allocation reductions:

Suite Case 2.4.2 2.4.3 Δ
RenderToString clean 30.2 KB 13.4 KB −56%
RenderToString html 33.4 KB 16.7 KB −50%

Compilation — up to 88% faster (#668)

Benchmark 2.4.2 2.4.3 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%

Template compile time turned out to be dominated by the JIT compiling each template's dynamic method at CreateDelegate, inline-expanding the [AggressiveInlining] write/encoder machinery into every mustache call site of every template. The compiler now emits thin NoInlining static entry points that are JIT-compiled once per process. Render performance was verified unchanged by A/B guardrail benchmarks (the one variant that cost ~4% at render time was rejected and kept inline).

Compatibility notes

  • No public API changes; all new members are internal. Template semantics are unchanged, including late registration of helpers, helper resolvers, and descriptor providers after compile.
  • The ChainSegment descriptor cache allocates a small entry (~48 B) when a segment observes a new instance type; heterogeneous collections rendered through dotted access can re-allocate per type flip, while the common homogeneous case allocates once per segment ever.

What changed

What changed:

Since pre-2.4.0 (#652, #651, #653):

  • ObjectDescriptor's member accessor is pre-bound to its described type instead of re-resolving through a shared type-keyed lookup on every access, and bool property reads return cached boxed instances instead of allocating a fresh box per read. (#652)
  • HTML encoders bulk-write clean runs of text via SearchValues<char> (net8.0+) or a plain scan (netstandard) instead of one TextWriter.Write(char) call per character, falling back to the original per-character path only where escaping is actually needed. (#651)
  • The boxed-integer cache used for {{#each}} iterator indexes grew from 20 to 1024 entries, eliminating a 24-byte-per-item allocation that was the dominant remaining allocation source in list rendering (e.g. 23.5 KB → 0 B for a 1000-item {{#each}}). (#653)

In this release (#667):

  • Cache helper-resolver presence in the late-bind descriptors. ObservableList<T>.Count acquires a ReaderWriterLockSlim per call, and every simple {{name}} paid it once per render — loops paid hundreds of lock acquisitions per render. The descriptors now subscribe once to the append-only resolver list and keep a flag; resolvers registered after compile still take effect.
  • Retain up to 32K chars in the pooled ReusableStringWriter. Outputs over 4096 chars discarded the pooled writer every render, re-growing a fresh StringBuilder(16) chunk by chunk — most of RenderToString's allocations.
  • Monomorphic descriptor cache on ChainSegment. Dotted member access re-resolved the instance's ObjectDescriptor through the ambient context + type-keyed lookup on every segment per render; each segment now holds an immutable (factory, version, type) → descriptor entry, self-invalidated via a version stamp when descriptor providers are registered.
  • Skip the frame-helper cascade walk when no frame-local helpers exist (decorator / in-render registration is tracked per frame).
  • Skip the ConditionalWeakTable probe in SafeStrings until a safe-marked string is ever produced.
  • Read ThrowOnUnresolvedBindingExpression only on the unresolved branch of path resolution.
  • Cheaper falsy checks: typed zero comparisons instead of Convert.ToBoolean dispatch, an O(1) ICollection.Count emptiness fast path, and enumerator disposal in Any().

Contributors

@rexm

Full Changelog: 2.4.2...2.4.3