Current behavior 🐛
Every DependencyObject constructor unconditionally allocates two Dictionary instances that stay empty for the overwhelming majority of instances:
// src/Uno.UI/UI/Xaml/DependencyObject.Store.cs
private readonly Dictionary<DependencyProperty, ManagedWeakReference> _inheritedForwardedProperties = new Dictionary<DependencyProperty, ManagedWeakReference>(DependencyPropertyComparer.Default);
...
private readonly Dictionary<long, IDisposable> _propertyChangedTokens = new Dictionary<long, IDisposable>();
|
private readonly Dictionary<DependencyProperty, ManagedWeakReference> _inheritedForwardedProperties = new Dictionary<DependencyProperty, ManagedWeakReference>(DependencyPropertyComparer.Default); |
|
private readonly Dictionary<long, IDisposable> _propertyChangedTokens = new Dictionary<long, IDisposable>(); |
An empty Dictionary<,> costs 88 bytes on .NET (measured with GC.GetTotalAllocatedBytes around 50,000 allocations of new Dictionary<long, IDisposable>()), so this is 176 B allocated per DependencyObject, on every instance, whether or not it is ever used.
_propertyChangedTokens is only written by RegisterPropertyChangedCallback — rare. _inheritedForwardedProperties is only populated for elements that actually receive a forwarded inherited property — it stays empty for the large population of non-visual-tree DependencyObjects created during startup and layout: brushes, Setters, transforms, keyframes, storyboards, resource-dictionary values, etc.
Measured with a minimal DependencyObject subclass with nothing else touched, on current master (Skia Desktop, Release, 20,000 instances via GC.GetAllocatedBytesForCurrentThread):
| Type |
Bytes/instance |
MinimalDependencyObject : DependencyObject (empty) |
536.0 B |
Plain class, same field count, no DependencyObject |
80.0 B |
| Delta |
456.0 B |
176 B of that 456 B delta is these two dictionaries; the rest is other property-system state that was already eagerly allocated before this change (weak self-reference, property storage, etc.) and is out of scope for this issue.
Expected behavior 🎯
Both fields should be allocated lazily on first use, like _updatedProperties already is a few lines below in the same file ((_updatedProperties ??= new HashSet<DependencyProperty>(...)).Add(...)). A DependencyObject that never registers a PropertyChanged callback and never receives a forwarded inherited property should not pay for either dictionary.
How to reproduce it (as minimally and precisely as possible) 🔬
This is an allocation-only regression, not a visual one — reproduce by inspecting/measuring construction of any DependencyObject-derived type with nothing set (a bare Setter, SolidColorBrush, or a custom subclass all work). Example, runnable as an Uno Runtime Test or any code with access to a live DependencyObject:
using System.Reflection;
// 1) Prove both fields are always non-null right after construction, even
// though nothing in the property system was touched.
var d = new Microsoft.UI.Xaml.Media.SolidColorBrush(); // or any DependencyObject subtype
var t = typeof(Microsoft.UI.Xaml.DependencyObject);
var forwarded = t.GetField("_inheritedForwardedProperties", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(d);
var tokens = t.GetField("_propertyChangedTokens", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(d);
// Both are non-null Dictionary instances with Count == 0 - allocated for nothing.
// 2) Measure the per-instance allocation delta vs. a plain class.
const int N = 20000;
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
var doKeep = new object[N];
var before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < N; i++) { doKeep[i] = new Microsoft.UI.Xaml.Media.SolidColorBrush(); }
var afterDO = GC.GetAllocatedBytesForCurrentThread();
Console.WriteLine($"{(afterDO - before) / (double)N:F1} B/instance");
GC.KeepAlive(doKeep);
Steps:
- Create (or reuse) an Uno Platform app targeting Skia Desktop.
- Run the snippet above (e.g. from a runtime test, or a button click handler with
Console.WriteLine/logger output).
- Observe that both dictionary fields are non-null immediately after construction, and that constructing many instances allocates well beyond what a plain object of similar shape would.
Workaround 🛠️
None needed at the call-site level — this is pure overhead, not observable behavior. No user workaround exists (can't be avoided from application code since both fields are allocated inside the DependencyObject constructor itself).
Renderer 🎨
Affected platforms 📱💻🖥️
All platforms 🌍 (shared code in Uno.UI, not platform-specific)
Uno.Sdk version (and other relevant versions) 📦
- Reproduced on
master @ ffcff329eb3120c88cebf214629ca8a810f7880c
- TFM:
net11.0
- .NET SDK: 11.0.100 (RC)
Anything else we need to know? 💬
Root cause: d8cd44b77a6 ("Fold DependencyObjectStore into DependencyObject") made store initialization eager in the DependencyObject constructor (previously the whole store was created lazily on first property-system access). That PR's own description calls this "close to free" because most constructors already touch a dependency property and would force store creation anyway — true for most of the store, but these two dictionaries are the exception: they are written only from a few specific, rare code paths, not from ordinary GetValue/SetValue.
Write sites for _inheritedForwardedProperties:
|
_inheritedForwardedProperties[property] = SelfWeakReference; |
|
_inheritedForwardedProperties[parentProperty] = sourceInstance; |
|
_inheritedForwardedProperties.Clear(); |
(.Clear())
Read sites (already guard on emptiness, so a lazy field is a drop-in null check):
|
if (_inheritedForwardedProperties.Count == 0) |
(.Count == 0 early-return)
|
foreach (var sourceInstanceProperties in _inheritedForwardedProperties) |
(foreach, only reached past the guard above)
Write/read sites for _propertyChangedTokens:
|
_propertyChangedTokens.Add(_propertyChangedToken, registration); |
(.Add(...))
|
if (_propertyChangedTokens.TryGetValue(token, out var registration)) |
(TryGetValue)
|
_propertyChangedTokens.Remove(token); |
(.Remove(...))
Suggested fix: make both fields nullable and lazily initialize with ??= at their (few) write sites, the same idiom the file already uses for _updatedProperties:
private Dictionary<DependencyProperty, ManagedWeakReference>? _inheritedForwardedProperties;
...
private Dictionary<long, IDisposable>? _propertyChangedTokens;
The existing .Count == 0 / TryGetValue read sites become null-conditional checks, .Clear() becomes a no-op when null, and the Add/indexer write sites become (_inheritedForwardedProperties ??= new(...))[property] = .... No behavior change, ~176 B saved per DependencyObject that never touches either path — which is most of them.
Current behavior 🐛
Every
DependencyObjectconstructor unconditionally allocates twoDictionaryinstances that stay empty for the overwhelming majority of instances:uno/src/Uno.UI/UI/Xaml/DependencyObject.Store.cs
Line 80 in ffcff32
uno/src/Uno.UI/UI/Xaml/DependencyObject.Store.cs
Line 84 in ffcff32
An empty
Dictionary<,>costs 88 bytes on .NET (measured withGC.GetTotalAllocatedBytesaround 50,000 allocations ofnew Dictionary<long, IDisposable>()), so this is 176 B allocated perDependencyObject, on every instance, whether or not it is ever used._propertyChangedTokensis only written byRegisterPropertyChangedCallback— rare._inheritedForwardedPropertiesis only populated for elements that actually receive a forwarded inherited property — it stays empty for the large population of non-visual-treeDependencyObjects created during startup and layout: brushes,Setters, transforms, keyframes, storyboards, resource-dictionary values, etc.Measured with a minimal
DependencyObjectsubclass with nothing else touched, on currentmaster(Skia Desktop, Release, 20,000 instances viaGC.GetAllocatedBytesForCurrentThread):MinimalDependencyObject : DependencyObject(empty)DependencyObject176 B of that 456 B delta is these two dictionaries; the rest is other property-system state that was already eagerly allocated before this change (weak self-reference, property storage, etc.) and is out of scope for this issue.
Expected behavior 🎯
Both fields should be allocated lazily on first use, like
_updatedPropertiesalready is a few lines below in the same file ((_updatedProperties ??= new HashSet<DependencyProperty>(...)).Add(...)). ADependencyObjectthat never registers aPropertyChangedcallback and never receives a forwarded inherited property should not pay for either dictionary.How to reproduce it (as minimally and precisely as possible) 🔬
This is an allocation-only regression, not a visual one — reproduce by inspecting/measuring construction of any
DependencyObject-derived type with nothing set (a bareSetter,SolidColorBrush, or a custom subclass all work). Example, runnable as an Uno Runtime Test or any code with access to a liveDependencyObject:Steps:
Console.WriteLine/logger output).Workaround 🛠️
None needed at the call-site level — this is pure overhead, not observable behavior. No user workaround exists (can't be avoided from application code since both fields are allocated inside the
DependencyObjectconstructor itself).Renderer 🎨
Affected platforms 📱💻🖥️
All platforms 🌍 (shared code in
Uno.UI, not platform-specific)Uno.Sdk version (and other relevant versions) 📦
master@ffcff329eb3120c88cebf214629ca8a810f7880cnet11.0Anything else we need to know? 💬
Root cause:
d8cd44b77a6("FoldDependencyObjectStoreintoDependencyObject") made store initialization eager in theDependencyObjectconstructor (previously the whole store was created lazily on first property-system access). That PR's own description calls this "close to free" because most constructors already touch a dependency property and would force store creation anyway — true for most of the store, but these two dictionaries are the exception: they are written only from a few specific, rare code paths, not from ordinaryGetValue/SetValue.Write sites for
_inheritedForwardedProperties:uno/src/Uno.UI/UI/Xaml/DependencyObject.Store.cs
Line 806 in ffcff32
uno/src/Uno.UI/UI/Xaml/DependencyObject.Store.cs
Line 1391 in ffcff32
uno/src/Uno.UI/UI/Xaml/DependencyObject.Store.cs
Line 1492 in ffcff32
.Clear())Read sites (already guard on emptiness, so a lazy field is a drop-in null check):
uno/src/Uno.UI/UI/Xaml/DependencyObject.Store.cs
Line 1671 in ffcff32
.Count == 0early-return)uno/src/Uno.UI/UI/Xaml/DependencyObject.Store.cs
Line 1689 in ffcff32
foreach, only reached past the guard above)Write/read sites for
_propertyChangedTokens:uno/src/Uno.UI/UI/Xaml/DependencyObject.Store.cs
Line 956 in ffcff32
.Add(...))uno/src/Uno.UI/UI/Xaml/DependencyObject.Store.cs
Line 963 in ffcff32
TryGetValue)uno/src/Uno.UI/UI/Xaml/DependencyObject.Store.cs
Line 967 in ffcff32
.Remove(...))Suggested fix: make both fields nullable and lazily initialize with
??=at their (few) write sites, the same idiom the file already uses for_updatedProperties:The existing
.Count == 0/TryGetValueread sites become null-conditional checks,.Clear()becomes a no-op when null, and theAdd/indexer write sites become(_inheritedForwardedProperties ??= new(...))[property] = .... No behavior change, ~176 B saved perDependencyObjectthat never touches either path — which is most of them.