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
ChainSegmentdescriptor 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, andboolproperty 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 oneTextWriter.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>.Countacquires aReaderWriterLockSlimper 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 freshStringBuilder(16)chunk by chunk — most of RenderToString's allocations. - Monomorphic descriptor cache on
ChainSegment. Dotted member access re-resolved the instance'sObjectDescriptorthrough the ambient context + type-keyed lookup on every segment per render; each segment now holds an immutable(factory, version, type) → descriptorentry, 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
ConditionalWeakTableprobe inSafeStringsuntil a safe-marked string is ever produced. - Read
ThrowOnUnresolvedBindingExpressiononly on the unresolved branch of path resolution. - Cheaper falsy checks: typed zero comparisons instead of
Convert.ToBooleandispatch, an O(1)ICollection.Countemptiness fast path, and enumerator disposal inAny().
Contributors
Full Changelog: 2.4.2...2.4.3