perf: render-path optimizations (-21-36% render time, -50% allocations) - #667
Merged
Conversation
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>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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
The changes
ObservableList<T>.Countacquires aReaderWriterLockSlimper call, andLateBindHelperDescriptorchecked 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.ReusableStringWriter. Outputs over 4096 chars caused the pooled writer to be discarded every render, re-growing a freshStringBuilder(16)chunk by chunk. This was most of RenderToString's allocations.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;ObjectDescriptorFactorygained a version stamp bumped on provider registration so entries self-invalidate.BindingContexttracks 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.ConditionalWeakTableprobe inSafeStringsuntil the firstMark. Applications that never produce safe-marked strings no longer pay a CWT lookup per string written.ThrowOnUnresolvedBindingExpressiononly on the unresolved branch instead of two dispatched config reads per resolve.IsNumberisinst cascade +Convert.ToBooleanIConvertible dispatch;IsFalsyOrEmptygets an O(1)ICollection.Countfast path (avoids boxing struct enumerators);Any()now disposes the enumerator.Tradeoffs / notes
ChainSegmentcache 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.HasFrameHelpersis 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.{{#each}}-site iterator cache,FixedSizeDictionaryidiv→mask (analysis says ~1%).🤖 Generated with Claude Code