Improve TypedBinding performance by ~29%#33656
Conversation
Optimizations applied: - Cache Apply() delegate to avoid lambda allocation on every property change - Cache binding mode after first calculation - Skip Subscribe() loop if already subscribed - Cache default value to avoid repeated GetDefaultValue() calls - Skip TryConvert when TProperty matches property.ReturnType - Cache isTSource type check result - Cache dispatcher reference to avoid cast on every call Also adds BindingComparisonBenchmarker for measuring SetValue vs Binding vs TypedBinding vs SourceGeneratedBinding performance. Results (100 value updates after binding once): - SourceGeneratedBinding: 5.05 µs → 3.65 µs (28% faster, 1.57x of SetValue) - TypedBinding: 5.19 µs → 3.80 µs (27% faster, 1.64x of SetValue) - Memory: 12.5 KB → 6.25 KB (50% reduction)
There was a problem hiding this comment.
Pull request overview
This PR optimizes TypedBinding and SourceGeneratedBinding performance for value propagation scenarios (where bindings are set once and values are updated many times). The optimizations include caching delegates, binding modes, default values, type checks, and dispatchers to reduce allocations and repeated computations.
Changes:
- Added new benchmark
BindingComparisonBenchmarkerto measure binding performance - Implemented 7 performance optimizations in TypedBinding.cs with cached fields
- Reported 27-28% performance improvement and 50% memory reduction based on benchmark results
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/Core/tests/Benchmarks/Benchmarks/BindingComparisonBenchmarker.cs | New benchmark comparing SetValue, Binding, TypedBinding, and SourceGeneratedBinding performance across 100 iterations |
| src/Controls/src/Core/TypedBinding.cs | Performance optimizations including cached mode, default values, type checks, dispatcher, Apply delegate, and skip-convert flag with corresponding state management in Unapply |
| // Use cached type check after first apply (source type doesn't change) | ||
| var isTSource = _isTSource; | ||
| if (!_hasDefaultValue) | ||
| { | ||
| isTSource = sourceObject is TSource; | ||
| _isTSource = isTSource; | ||
| if (!isTSource && sourceObject is not null) | ||
| { | ||
| BindingDiagnostics.SendBindingFailure(this, "Binding", $"Mismatch between the specified x:DataType ({typeof(TSource)}) and the current binding context ({sourceObject.GetType()})."); | ||
| } | ||
| } |
There was a problem hiding this comment.
The cached type check _isTSource is never reset in Unapply() (line 292-296 resets other cached values but not _isTSource). This causes incorrect behavior when ApplyCore is called with a different source object.
For example, line 520 calls ApplyCore(null, ...) directly without calling Unapply() first. In this case, the condition if (!_hasDefaultValue) on line 311 evaluates to false (since default value was already cached), so the type check is skipped entirely and the stale _isTSource value is used. This will cause incorrect behavior when the source changes from a valid TSource object to null.
| // Cache dispatcher to avoid cast + property access on every call | ||
| if (!_dispatcherCached) | ||
| { | ||
| _cachedDispatcher = (sender as BindableObject)?.Dispatcher; | ||
| _dispatcherCached = true; | ||
| } | ||
|
|
||
| _cachedDispatcher.DispatchIfRequired(_applyAction); |
There was a problem hiding this comment.
The dispatcher caching implementation is conceptually incorrect. The dispatcher should come from the binding target (as seen in BindingExpression.cs line 731 where it uses the target's dispatcher), but this code caches it from the source object.
In typical binding scenarios, the source is a view model (POCO with INotifyPropertyChanged) and the target is a BindableObject (UI element). Since the source is usually not a BindableObject, the cached dispatcher will be null in most cases. While DispatchIfRequired handles null via EnsureDispatcher, this defeats the optimization purpose since EnsureDispatcher is called on every property change anyway.
Additionally, when the PropertyChangedProxy is resubscribed to a different source object (via the Part setter at line 547-559), the cached dispatcher is never updated, which could lead to using an incorrect dispatcher if the sources have different dispatchers or BindableObject status.
| _cachedMode = BindingMode.Default; | ||
| _hasDefaultValue = false; | ||
| _cachedDefaultValue = null; | ||
| _skipConvert = false; |
There was a problem hiding this comment.
The field _isTSource should be reset in Unapply() along with the other cached values. Add _isTSource = false; to properly reset the cached type check result.
| _skipConvert = false; | |
| _skipConvert = false; | |
| _isTSource = false; |
- Reset _isTSource in Unapply() to handle BindingContext type changes - Remove ineffective dispatcher caching (sender is usually ViewModel, not BindableObject) - Add clarifying comment about dispatcher null handling
StephaneDelcroix
left a comment
There was a problem hiding this comment.
Regarding the review comments:
Comment 1 & 3 (_isTSource not reset): This was already fixed in commit 7a8b3c2. The Unapply() method now includes _isTSource = false; at line 296.
Comment 2 (Dispatcher caching): Marked as outdated - this was removed in the same commit after identifying that the source is typically a ViewModel (not BindableObject), making the cached dispatcher null in most cases.
All three review comments have been addressed in the current code.
Squashed from 26 commits (dev/srozsival/binding-improvements-worker4): - Remove ForSingleNestingLevel factory method - Add new TypedBinding constructor API: TypedBinding(getter, setter, handlersCount, handlers) - Add IPropertyChangeHandler abstraction for handler strategy pattern - Add LegacyPropertyChangeHandler (backward compat with old Tuple-based API) - Add PropertyChangeHandler (new efficient handler using INotifyPropertyChanged) - Unify TypedBinding and TypedBinding2 designs into single TypedBinding class - Update BindingSourceGen to emit new TypedBinding constructor calls - Update CompiledBindingMarkup source generator for new API - Ban old ForSingleNestingLevel usage in BannedSymbols.txt - Add TypedBinding2UnitTests.cs (~2000 lines of new tests) - Update benchmarks to compare TypedBinding variants Note: Rebased onto net11.0 as a squash to avoid conflicts with intermediate states. net11.0 already has the ~29% ApplyCore perf improvements (PR #33656); this PR adds the new flexible handler API on top. Co-authored-by: Simon Rozsival <simon@rozsival.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
> [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ## Description This PR improves TypedBinding and SourceGeneratedBinding performance for value propagation scenarios (binding set once, values updated many times). ### Optimizations Applied 1. **Cache Apply() delegate** - Avoids lambda allocation on every property change 2. **Cache binding mode** - Avoids `GetRealizedMode()` call on every Apply 3. **Skip Subscribe() loop** - Only subscribes to INPC once per binding lifetime 4. **Cache default value** - Avoids repeated `GetDefaultValue()` calls 5. **Skip TryConvert** - When `TProperty` matches `property.ReturnType` 6. **Cache isTSource** - Type check result (source type doesn't change) ### Benchmark Results **Before (net10.0 baseline):** | Method | Mean | Ratio | Allocated | |----------------------- |---------:|------:|----------:| | SetValue | 20.77 ns | 1.00 | 40 B | | Binding | 52.03 ns | 2.50 | 128 B | | TypedBinding | 47.47 ns | 2.28 | 128 B | | SourceGeneratedBinding | 45.45 ns | 2.19 | 128 B | **After (this PR):** | Method | Mean | Ratio | Allocated | |----------------------- |---------:|------:|----------:| | SetValue | 20.64 ns | 1.00 | 40 B | | Binding | 52.00 ns | 2.52 | 128 B | | TypedBinding | 32.90 ns | 1.59 | 64 B | | SourceGeneratedBinding | 34.10 ns | 1.65 | 64 B | **Summary:** | Metric | Before | After | Improvement | |--------|--------|-------|-------------| | TypedBinding | 47.47 ns (2.28x) | **32.90 ns (1.59x)** | **31% faster** | | SourceGeneratedBinding | 45.45 ns (2.19x) | **34.10 ns (1.65x)** | **25% faster** | | Memory per operation | 128 B | **64 B** | **50% less** | ### New Benchmark Adds `BindingComparisonBenchmarker` to `Core.Benchmarks` for comparing SetValue, Binding, TypedBinding, and SourceGeneratedBinding performance. ```bash cd src/Core/tests/Benchmarks dotnet run -c Release -- --filter "*BindingComparison*" ```
Note
Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!
Description
This PR improves TypedBinding and SourceGeneratedBinding performance for value propagation scenarios (binding set once, values updated many times).
Optimizations Applied
GetRealizedMode()call on every ApplyGetDefaultValue()callsTPropertymatchesproperty.ReturnTypeBenchmark Results
Before (net10.0 baseline):
After (this PR):
Summary:
New Benchmark
Adds
BindingComparisonBenchmarkertoCore.Benchmarksfor comparing SetValue, Binding, TypedBinding, and SourceGeneratedBinding performance.