Skip to content

perf: render-path optimizations (-21-36% render time, -50% allocations) - #667

Merged
rexm merged 7 commits into
masterfrom
perf/render-experiments
Aug 9, 2026
Merged

perf: render-path optimizations (-21-36% render time, -50% allocations)#667
rexm merged 7 commits into
masterfrom
perf/render-experiments

Conversation

@rexm

@rexm rexm commented Aug 9, 2026

Copy link
Copy Markdown
Member

Seven render-path optimizations, found via profiling + per-hypothesis benchmarking against a pinned baseline. Each commit was validated independently: the full test suite (1912 tests) passes after every commit, and every number below comes from BenchmarkDotNet MediumRun A/B runs on the same machine (noisy cases re-run with --launchCount 3-4).

Results vs baseline

Suite Baseline This PR Time Alloc
RenderToString clean 10.49 µs / 30.2 KB 7.48 µs / 13.4 KB −29% −56%
RenderToString html 13.15 µs / 33.4 KB 10.37 µs / 16.7 KB −21% −50%
RenderList 100 (object) 18.95 µs 12.16 µs −36%
RenderList 1000 (dictionary) 191.3 µs 126.7 µs −34%
RenderNested 20 (object) 19.18 µs 12.44 µs −35%
RenderSimple (object) 636 ns 431 ns −32%
EndToEnd 26.1 µs 23.5 µs −10%
LargeArray / Compilation / Execution flat flat

The changes

  • Cache helper-resolver presence in late-bind descriptors. ObservableList<T>.Count acquires a ReaderWriterLockSlim per call, and LateBindHelperDescriptor checked it once per {{name}} per render — every dot-free segment routes through the late-bind path, so loops paid hundreds of lock acquisitions per render. The descriptors now subscribe once to the append-only resolver list and keep a volatile flag; resolvers registered after compile still take effect.
  • Retain up to 32K chars in the pooled ReusableStringWriter. Outputs over 4096 chars caused the pooled writer to be discarded every render, re-growing a fresh StringBuilder(16) chunk by chunk. This was 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; ObjectDescriptorFactory gained a version stamp bumped on provider registration so entries self-invalidate.
  • Skip the frame-helper cascade walk when no frame helpers exist. BindingContext tracks whether any frame in the ancestry ever exposed its helper registries for writing (decorators / in-render registration); until then the late-bind descriptors skip the per-invocation cascade lookup.
  • Skip the ConditionalWeakTable probe in SafeStrings until the first Mark. Applications that never produce safe-marked strings no longer pay a CWT lookup per string written.
  • Read ThrowOnUnresolvedBindingExpression only on the unresolved branch instead of two dispatched config reads per resolve.
  • Cheaper falsy checks: typed zero comparisons replace the IsNumber isinst cascade + Convert.ToBoolean IConvertible dispatch; IsFalsyOrEmpty gets an O(1) ICollection.Count fast path (avoids boxing struct enumerators); Any() now disposes the enumerator.

Tradeoffs / notes

  • The ChainSegment cache allocates a small entry when the observed instance type changes, so heterogeneous collections with dotted access thrash it (~32 B per type flip); homogeneous data allocates once per segment ever.
  • HasFrameHelpers is set conservatively whenever a frame's registry is obtained for writing and inherits down the frame chain; decorator and in-render registration semantics are preserved by the existing test coverage.
  • Documented-but-not-implemented follow-ups: per-{{#each}}-site iterator cache, FixedSizeDictionary idiv→mask (analysis says ~1%).

🤖 Generated with Claude Code

rexm and others added 7 commits August 9, 2026 09:28
Outputs larger than 4096 chars caused the pooled writer to be discarded on
every render, so each render re-grew a fresh StringBuilder(16) chunk by chunk.
RenderToString: -56% allocations (30.9KB -> 13.4KB), ~-8% time (clean).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every string written to output paid a CWT lookup even when the application
never produces safe-marked strings (no return-helpers/subexpressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…branch

Avoids two dispatched configuration property reads per path resolve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ness

Replaces the IsNumber isinst cascade + Convert.ToBoolean IConvertible dispatch
with a typed switch, adds an ICollection.Count fast path to IsFalsyOrEmpty
(avoids boxing struct enumerators), and disposes the enumerator in Any().
RenderSimple dictionary/expando: ~-6%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… frame helpers exist

BindingContext now tracks whether any frame in the ancestry ever exposed its
helper registries for writing (decorators / in-render registration). Until
then, LateBind(Block)HelperDescriptor skips the per-invocation cascade lookup
that every simple {{name}} pays per render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ObservableList.Count acquires a ReaderWriterLockSlim on every call, and the
late-bind descriptors checked it once per {{name}} per render. The descriptors
now subscribe to the append-only resolver list once and keep a volatile flag,
eliminating the per-invocation lock acquisition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TryAccessMember resolved the instance's ObjectDescriptor through the ambient
context + type-keyed lookup on every dotted-segment access per render. Each
ChainSegment now keeps an immutable (factory, version, type) -> descriptor
entry; ObjectDescriptorFactory gained a version stamp bumped on provider
registration so stale entries self-invalidate.
RenderNested -23-35%, RenderSimple object -32%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Aug 9, 2026

Copy link
Copy Markdown

@rexm
rexm merged commit 7f4e827 into master Aug 9, 2026
7 checks passed
@rexm
rexm deleted the perf/render-experiments branch August 9, 2026 14:13
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