Sitelet https://github.com/unoplatform/uno/issues/24752
Skip to content

DependencyObject constructor always allocates two empty Dictionary fields, even when unused #24752

Description

@MartinZikmund

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:

  1. Create (or reuse) an Uno Platform app targeting Skia Desktop.
  2. Run the snippet above (e.g. from a runtime test, or a button click handler with Console.WriteLine/logger output).
  3. 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 🎨

  • Skia

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:

Read sites (already guard on emptiness, so a lazy field is a drop-in null check):

Write/read sites for _propertyChangedTokens:

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.

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/performance 📈Categorizes an issue or PR as relevant to performancedifficulty/starter 🚀Categorizes an issue for which the difficulty level is reachable by newcomersgood first issueDenotes an issue ready for a new contributor, according to the "help wanted" guidelines.kind/bugSomething isn't workingkind/regressionSomething was working, now it isn'tplatform/allCategorizes an issue or PR as relevant to the all platformstriage/untriagedIndicates an issue requires triaging or verification

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions