dotnet / dotnet/maui

[Perf] Cache PropertyChangedEventArgs and PropertyChangingEventArgs on BindableProperty

Open
#34,092 0 comments 0 reactions 0 assignees View on GitHub
perf/general
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.