[Perf] Cache PropertyChangedEventArgs and PropertyChangingEventArgs on BindableProperty
- Dominant language
- C#
- Stars
- 23.3k
- Forks
- 2k
- Avg merge
- 1d 14h
- Merged PRs (30d)
- 296
Description
## Summary
Every `BindableProperty.SetValue` call allocates new `PropertyChangedEventArgs` and `PropertyChangingEventArgs` instances. Since BindableProperty names are fixed at creation time and never change, these args can be cached on the `BindableProperty` itself using a simple lazy field (`??=`), completely eliminating the per-call allocation.
## Current behavior
`BindableObject.OnPropertyChanged` is called from `SetValueCore` → `OnBindablePropertySet` on every property change. It allocates a `new PropertyChangedEventArgs(propertyName)` each time:
https://github.com/dotnet/maui/blob/2c26b34760881ae04cb3b82f865196fc96978a50/src/Controls/src/Core/BindableObject.cs#L401-L402
The same happens for `PropertyChangingEventArgs`:
https://github.com/dotnet/maui/blob/2c26b34760881ae04cb3b82f865196fc96978a50/src/Controls/src/Core/BindableObject.cs#L408-L409
Additionally, `Element.OnPropertyChanged` allocates **a second** `PropertyChangedEventArgs` with the same property name to forward to effects:
https://github.com/dotnet/maui/blob/2c26b34760881ae04cb3b82f865196fc96978a50/src/Controls/src/Core/Element/Element.cs#L713
This means every `SetValue` on an `Element` allocates at least **two** `PropertyChangedEventArgs` and one `PropertyChangingEventArgs` — all with the same property name string.
During layout/measure cycles, hundreds of bindable properties are set per frame. A page with 50 views and 10 properties each = 500 `SetValue` calls = 1,500 unnecessary event args allocations per layout pass.
## Proposed change
Add lazy-cached fields on `BindableProperty`:
```csharp
// BindableProperty.cs
PropertyChangedEventArgs? _cachedPropertyChangedEventArgs;
PropertyChangingEventArgs? _cachedPropertyChangingEventArgs;
internal PropertyChangedEventArgs GetPropertyChangedEventArgs()
=> _cachedPropertyChangedEventArgs ??= new PropertyChangedEventArgs(PropertyName);
internal PropertyChangingEventArgs GetPropertyChangingEventArgs()
=> _cachedPropertyChangingEventArgs ??= new PropertyChangingEventArgs(PropertyName);
```
Then use the cached args in `SetValueCore` and `Element.OnPropertyChanged` instead of allocating new ones.
This is safe because:
- `PropertyChangedEventArgs` and `PropertyChangingEventArgs` are immutable (read-only `PropertyName`)
- `BindableProperty.PropertyName` never changes after construction
- `BindableProperty` instances are long-lived statics — one allocation per property for the lifetime of the app
- The `??=` pattern is benign under races — worst case is two identical instances created on first use, one gets discarded
## Benchmark
Independent benchmark reproducing the pattern (1,000 iterations, simulating different properties):
```
| Method | Mean | Gen0 | Allocated | Alloc Ratio |
|----------------------------------- |-----------:|-------:|----------:|------------:|
| Current_NewPerCall | 5,105 ns | 3.8223 | 24,000 B | 1.00 |
| Optimized_CachedOnBindableProperty | 1,142 ns | - | 0 B | 0.00 |
```
**4.5x faster, zero allocation** for `PropertyChanged`.
```
| Method | Mean | Gen0 | Allocated | Alloc Ratio |
|----------------------------------- |-----------:|-------:|----------:|------------:|
| Current_NewPerCall | 4,882 ns | 3.8223 | 24,000 B | 1.00 |
| Optimized_CachedOnBindableProperty | 653 ns | - | 0 B | 0.00 |
```
**7.5x faster, zero allocation** for `PropertyChanging`.
Benchmark source code
```csharp
[MemoryDiagnoser]
public class PropertyChangedEventArgsBenchmark
{
private sealed class FakeBindableProperty
{
public string PropertyName { get; }
private PropertyChangedEventArgs? _cache;
public PropertyChangedEventArgs ChangedEventArgs
=> _cache ??= new PropertyChangedEventArgs(PropertyName);
public FakeBindableProperty(string name) => PropertyName = name;
}
private static readonly FakeBindableProperty[] Properties = [
new("Text"), new("BackgroundColor"), new("Width"), new("Height"), new("IsVisible"),
new("Opacity"), new("Margin"), new("Padding"), new("FontSize"), new("TextColor")
];
private event PropertyChangedEventHandler? PropertyChanged;
[GlobalSetup]
public void Setup() => PropertyChanged += (s, e) => { };
[Benchmark(Baseline = true)]
public void Current_NewPerCall()
{
for (int i = 0; i < 1000; i++)
{
var prop = Properties[i % Properties.Length];
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(prop.PropertyName));
}
}
[Benchmark]
public void Optimized_CachedOnBindableProperty()
{
for (int i = 0; i < 1000; i++)
{
var prop = Properties[i % Properties.Length];
PropertyChanged?.Invoke(this, prop.ChangedEventArgs);
}
}
}
```
Contributor guide
Research direction
Start in src/Controls/src/Core/BindableObject.cs at SetValueCore/OnBindablePropertySet and inspect the allocations in OnPropertyChanged and PropertyChangingEventArgs creation. Then read src/Controls/src/Core/Element/Element.cs at OnPropertyChanged and BindableProperty.cs; done means these paths reuse per-property event args without changing notification behavior or allocating them on each SetValue call.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- performance
- Issue type
- Refactor
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100