[Perf] Eliminate value-type boxing in BindableObject.SetValue
- Dominant language
- C#
- Stars
- 23.3k
- Forks
- 2k
- Avg merge
- 1d 14h
- Merged PRs (30d)
- 296
Description
## Summary
`BindableObject.SetValue` is the central code path for all property updates in .NET MAUI — layout, styles, bindings, handler propagation. The current implementation is fully `object`-based, which **boxes every value type on every call**. We instrumented the property system with runtime diagnostics and measured that **65% of all property sets are value types** — meaning the majority of `SetValue` calls pay for unnecessary boxing allocations.
## Problem — measured on a real app
We instrumented `SetterSpecificityList` and `BindablePropertyContext` to track every property set in a representative Sandbox app on Mac Catalyst (~75 visual elements: `CollectionView` with 30 data-templated items, form controls, 4 resource styles, bindings with deliberate Style+Binding overlap). Key findings from a single page load:
**65% of all property sets (2,198 / 3,386) store a value type** (`Boolean`, `Single`, `Color`, `Double`, `Point`, `Thickness`, etc.) into `SetterSpecificityList` — boxing on every write, unboxing on every read and comparison.
| Setter specificity | Total sets | Value type sets | VT % | Key boxed types |
|---|---:|---:|---:|---|
| FromHandler | 1,154 | 1,136 | **98%** | Boolean (1,104), Double (28) |
| Style | 213 | 213 | **100%** | Color (78), Double (40), Thickness (38) |
| ManualValueSetter | 1,827 | 753 | 41% | Single (306), Point (153), Double (81) |
| FromBinding | 192 | 96 | 50% | Color (64), Double (32) |
| **Total** | **3,386** | **2,198** | **65%** | |
Notable findings:
- **Handler propagation is almost pure boxing**: `IsEnabled` and `InputTransparent` cascade `bool` through the visual tree — 1,104 box allocations per screen load (24 bytes each).
- **Style application is 100% value types**: every `Setter.Value` for `Color`, `Double`, `Thickness`, `FontAttributes` gets boxed. Zero reference types flow through styles.
- **83% of `Bindings` lists (2,825 / 3,386) are allocated but never used** — every `BindablePropertyContext` eagerly creates a `SetterSpecificityList` even though most properties never have a binding.
### `SetterSpecificityList` entry count distribution
We also tracked how many entries each `SetterSpecificityList` instance actually holds at peak. Out of **6,450 tracked instances**:
| Max entry count | Instances | Percent | What this means |
|---:|---:|---:|---|
| 0 | 3,741 | 58.0% | Allocated but never written to (includes 2,825 unused `Bindings` lists) |
| 1 | 96 | 1.5% | Single entry — only `DefaultValue` or a single setter |
| 2 | 2,565 | 39.8% | Two entries — the typical case: `DefaultValue` + one setter (Style/Handler/Manual) |
| 3 | 48 | 0.7% | Three entries — Style + Binding overlap (e.g., `FontSize` set by both a Style and a Binding) |
**99.3% of instances have ≤2 entries.** The max count ever observed is **3** — never 4 or higher. This means:
- **Inline `_top` + `_second` fields cover 99.3% of cases** without any array allocation
- The `_rest` overflow array is allocated in <1% of cases and never holds more than 1 element
- The current implementation uses parallel sorted arrays with binary search — far more complex than needed for collections that almost never exceed 2 items
### Root cause
`SetterSpecificityList` has a `where T : class` constraint. This forces all value types to be stored as `SetterSpecificityList`, boxing on write, unboxing on read. This single constraint blocks the entire typed pipeline.
### Where boxing happens
Each `SetValue` call for a value type hits:
1. **Boxing on entry** — `double` → `object`
2. **Unboxing for comparison** — `Equals(object, object)` must unbox both values
3. **Unnecessary `TryConvert`** — `value.GetType()` + type comparison, even when types already match
4. **Coerce delegate** — receives boxed value, unboxes, potentially re-boxes
5. **`SetterSpecificityList`** — stores all values as `object`
6. **`PropertyChanged`/`PropertyChanging` callbacks** — receive boxed arguments
## Proposal
A series of internal refactors (no public API changes) delivered as incremental PRs (4 total, down from 5 — lazy `Bindings` init is subsumed by making `SetterSpecificityList` a struct):
### PR 1: Refactor `SetterSpecificityList` to a value type
Remove the `where T : class` constraint and change `SetterSpecificityList` from a `class` to a `struct`. Replace the current parallel-array + binary-search implementation with a `_top/_second/_rest` inline design:
```csharp
struct SetterSpecificityList
{
Entry _top; // highest-priority entry (always inline)
Entry _second; // second-highest (always inline)
RestList? _rest; // overflow — allocated only for 3+ entries (<1% of cases)
int _count;
struct Entry { T Value; SetterSpecificity Specificity; }
}
```
This design is directly motivated by the measured entry count distribution above:
- 99.3% of instances need at most 2 entries → **zero array allocations** for the common case
- The 0.7% with 3 entries allocate a single-element overflow array
- No binary search needed — with ≤2 inline entries, comparison is a simple if/else
Making it a struct means `SetterSpecificityList` is **embedded inline** in `BindablePropertyContext` — no separate heap allocation per list. This eliminates thousands of small GC-tracked objects at startup. The `Bindings` list that was previously eagerly allocated as a separate object (unused 83% of the time) becomes a zero-cost inline field — it occupies ~48 bytes in the context but requires no separate allocation and no GC tracking.
We benchmarked struct vs class `SetterSpecificityList` in isolation (Apple M1 Max, .NET 10.0.1, BenchmarkDotNet):
| Scenario | Class (current) | Struct (proposed) | Improvement |
|---|---|---|---|
| Create 100 contexts, set 2 entries each | 2,220 ns / 9,600 B | **758 ns / 7,200 B** | **2.9× faster, 25% less memory** |
| Create 100 contexts, set 3 entries each | 5,219 ns / 21,600 B | **4,036 ns / 19,200 B** | **1.3× faster, 11% less memory** |
| Allocate 1000 empty contexts | 14,228 ns / 96,000 B | **5,093 ns / 72,000 B** | **2.8× faster, 25% less memory** |
| Update existing value 1000× | 509 ns / 64 B | 551 ns / 72 B | ~equal (within noise) |
We also benchmarked a `ConditionalWeakTable`-based approach to eliminate the `_rest` pointer entirely, but the hash-table overhead made the 3-entry path **16× slower** (65 μs vs 4 μs). Keeping `_rest` as an inline nullable field is the right tradeoff — 8 bytes well spent.
Same API surface, drop-in replacement, independently faster for reference types too.
### PR 2: Add typed pipeline
Layer internal typed primitives:
1. `BindableProperty` — internal subclass with typed delegates and `IEqualityComparer`
2. `BindablePropertyContext` — typed storage with `SetterSpecificityList`
3. `SetValueCore` / `SetValueActual` — typed fast path, no boxing
4. `GetValue` — typed read, no unboxing
### PR 3: Convert hot-path properties
Convert `X`, `Y`, `Width`, `Height`, `IsEnabled`, `IsVisible`, `Opacity` etc. to `BindableProperty` internally.
### PR 4: TypedBinding fast path
When `TypedBinding` targets a `BindableProperty` with no Converter/StringFormat, route directly to `SetValueCore`.
### Compatibility
- All existing `object`-based APIs preserved — no breaking changes
- Typed path used only when type information matches at runtime (`property is BindableProperty`)
- Falls back to existing untyped path otherwise
## Benchmark results (prototype)
All benchmarks run on Apple M1 Max, .NET 10.0.1, BenchmarkDotNet.
| What | Before | After | Improvement |
|---|---|---|---|
| `SetValue` throughput (1000×) | 26.5 μs | 15.6 μs | **41% faster** |
| `SetValue` allocations (1000×) | 24,000 B | **0 B** | **100% eliminated** |
| Layout 4 props × 100 elements | 11.7 μs | 6.6 μs | **43% faster** |
| Layout allocations | 9,600 B | **0 B** | **100% eliminated** |
| `SetterSpecificityList` creation | 25.9 ns | 6.0 ns | **4.3× faster** |
| `SetterSpecificityList` memory/property | 184 B | 64 B | **65% less** |
| TypedBinding push | baseline | 1.35× | **35% faster, 0 B alloc** |
### Back-of-the-napkin startup estimate
Using a startup trace from `dotnet new maui -sc`: MAUI property-system work is ~494 ms of ~5,995 ms main-thread startup. Applying measured gains (~35–43%) to that bucket suggests roughly **170–210 ms startup savings** (~3–3.5% total). Conservative and directional — exact savings depend on app composition.
## Related issues
- #28100 — Generic `BindableProperty` (community proposal for public typed API — our work implements the internal foundation this would build on)
- #5574 — Better XAML compilation for Styles (closed/not-planned — point 4 mentions "SetValue chain should bypass conversion code" which is exactly what our typed pipeline does at runtime)
- #5018 — XamlC should optimize `ConvertFromInvariantString` (closed/completed — compile-time optimization that complements our runtime optimization)
## Diagnostics methodology
Full details are in our experiment report (on the `set-value-perf-improvements` branch, file `SetValue-Primitives-Experiment-Proposal.md`). The instrumentation code and Sandbox app modifications are also on that branch for reproducibility.
Contributor guide
Research direction
Start with BindableObject.SetValue and SetterSpecificityList, then read SetValue-Primitives-Experiment-Proposal.md on the set-value-perf-improvements branch. Treat this as a four-part refactor: first validate the struct storage design, then the typed pipeline, hot-path conversions, and TypedBinding path. Done means preserving existing object-based APIs while measuring the proposed allocation and throughput improvements.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- frontend, performance
- Issue type
- Refactor
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 25/100