BindableObject property access micro-optimizations#33584
BindableObject property access micro-optimizations#33584albyrock87 wants to merge 3 commits intodotnet:mainfrom
Conversation
| return context == null ? property.DefaultValue : context.Values.GetValue(); | ||
| } | ||
|
|
||
| internal LocalValueEnumerator GetLocalValueEnumerator() => new LocalValueEnumerator(this); |
There was a problem hiding this comment.
Removed unused methods/classes
b1d7bb1 to
39fba5c
Compare
|
Here's the SIMD implementation I used for the test. |
|
/azp run |
|
Azure Pipelines successfully started running 3 pipeline(s). |
Removed unnecessary else block that sets default values.
|
/azp run |
|
Azure Pipelines successfully started running 3 pipeline(s). |
kubaflo
left a comment
There was a problem hiding this comment.
Multimodal Review — BindableObject property access micro-optimizations
Overall Assessment: ✅ Looks good
Solid micro-optimization of a hot path. The benchmarks are convincing — Dictionary<int> + CollectionsMarshal wins clearly at ≥8 properties, and is competitive at lower counts. The approach is conservative and correct.
What works well
-
Dictionary<int, ...>keying — Switching fromBindableProperty(reference-type key → virtualGetHashCode/Equals) tointkeys eliminates that overhead entirely. Integer hashing in Dictionary is trivially cheap. -
CollectionsMarshal.GetValueRefOrAddDefaultinGetOrCreateContext— This is the highest-value change. The oldGetContext() ?? CreateAndAddContext()did two dictionary lookups on every first access. The new code does a single probe. Correctly guarded with#if NETSTANDARDfornetstandard2.0/netstandard2.1TFMs whereCollectionsMarshalis unavailable. -
ref var resultinGetValues<T>— Avoids repeated array indexing into the value-tuple array. Nice touch, and the removal of the now-redundantelsebranch (default values are already zero-initialized) was correctly identified by @MartyIX. -
LocalValueEnumerator/LocalValueEntryremoval — Confirmed zero usages across the repo. Dead code cleanup ✅ -
Interlocked.Incrementfor ID assignment — Thread-safe. And as @albyrock87 explained,BindablePropertyinstances are almost exclusively created as static fields during type initialization, so overflow is not a practical concern.
Suggestions (non-blocking)
1. Missing trailing newline in BindableObjectBenchmarker.cs
The new benchmark file doesn't end with a newline character. Most .NET tooling and dotnet format expect one. Minor but will show up as a diff artifact.
2. Consider [Benchmark(Baseline = true)]
Since the benchmark is meant to compare against the old implementation, marking one scenario as baseline would make future regression detection easier. Not required for this PR though.
3. Minor: _nextInternalId starting at int.MinValue
Functionally correct (keys just need uniqueness), but starting at 0 would be slightly more readable in debugger inspection. Non-blocking — the current approach maximizes the available ID space, which is a reasonable choice.
Correctness verification
- ✅ All
_propertiesusages checked —.Valuesiteration at lines 376 and 636 still works sinceBindablePropertyContext.Propertyholds theBindablePropertyreference. - ✅ No public API surface changes —
InternalIdisinternal, removed types wereinternal. - ✅
#if NETSTANDARDguard is correct —Controls.Core.csprojtargetsnetstandard2.0;netstandard2.1alongside platform TFMs. - ✅ No
PublicAPI.Unshipped.txtchanges needed.
🚦 Gate - Test Before and After Fix📊 Expand Full Gate —
|
|
@copilot write tests for this PR |
🤖 AI Summary📊 Expand Full Review —
|
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #33584 | Use int InternalId keys + CollectionsMarshal.GetValueRefOrAddDefault |
BindableObject.cs, BindableProperty.cs |
Original PR |
🔧 Fix — Analysis & Comparison
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| 1 | try-fix | Override GetHashCode() on BindableProperty with cached sequential int; keep Dictionary<BindableProperty,...> unchanged |
✅ PASS (96/96) | BindableProperty.cs only |
Minimal diff; doesn't get CollectionsMarshal benefit |
| 2 | try-fix | Replace dict with parallel arrays (_propKeys[] + _propValues[]) + linear scan |
✅ PASS (96/96) | BindableObject.cs |
O(n) for large sets; removes .Values enumeration cleanly |
| 3 | try-fix | ReferenceEqualityComparer.Instance + CollectionsMarshal.GetValueRefOrNullRef in GetContext/GetValues<T>; keep BindableProperty keys |
✅ PASS (96/96) | BindableObject.cs |
No InternalId needed; ref already reference-equal by default |
| 4 | try-fix | Single-entry inline cache (_cacheKey, _cacheCtx) as fast path before dict lookup |
✅ PASS (96/96) | BindableObject.cs |
Adds per-instance memory; uncertain real-world benefit |
| PR | PR #33584 | int InternalId keys + CollectionsMarshal.GetValueRefOrAddDefault + removed LocalValueEnumerator |
BindableObject.cs, BindableProperty.cs |
Supported by benchmarks; most comprehensive |
Cross-Pollination
| Model | Round | New Ideas? | Details |
|---|---|---|---|
| claude-opus-4.6 | 2 | No | NO NEW IDEAS |
| claude-sonnet-4.6 | 2 | Yes | Per-type static slot indices with direct array indexing (WPF-style); complex to implement, not pursued |
| gpt-5.3-codex | 2 | Yes | Hybrid small-map: inline array for first 4–8 entries, lazy dict promotion on overflow; complex, not pursued |
| gpt-5.4 | 2 | Yes | Same hybrid small-store idea; already surfaced above |
Exhausted: Yes — all models queried, new ideas are complex/risky and space is well-covered by 4 passing alternatives
Selected Fix: PR's fix — Has real benchmark data, handles all sizes in O(1), most comprehensive improvement. All 4 try-fix alternatives pass tests but trade off: Attempt 1 changes GetHashCode semantics, Attempt 2 is O(n) for large sets, Attempt 3 may offer minimal gain (reference equality is already default), Attempt 4 adds per-instance memory with uncertain benefit.
📋 Report — Final Recommendation
⚠️ Final Recommendation: REQUEST CHANGES
Phase Status
| Phase | Status | Notes |
|---|---|---|
| Pre-Flight | ✅ COMPLETE | Performance optimization PR, no linked issue |
| Gate | No tests detected in PR | |
| Try-Fix | ✅ COMPLETE | 4 attempts, 4 passing; PR's fix selected as best |
| Report | ✅ COMPLETE |
Summary
PR #33584 is a well-benchmarked community contribution that optimizes BindableObject property storage by switching from Dictionary<BindableProperty, BindablePropertyContext> to Dictionary<int, BindablePropertyContext> (using per-property integer IDs) and applying CollectionsMarshal.GetValueRefOrAddDefault to eliminate the double-lookup in GetOrCreateContext. The benchmarks show 10–25% improvement across typical property counts. The approach is sound, but the PR needs unit tests and has one correctness concern worth addressing before merge.
Root Cause
BindableObject uses a Dictionary<BindableProperty, BindablePropertyContext> for per-instance property storage. The default GetHashCode() on reference types incurs RuntimeHelpers.GetHashCode() overhead on every lookup. Additionally, GetOrCreateContext previously performed a double-lookup (check existence, then add), which is inefficient.
Fix Quality
Strengths:
- Backed by real
BenchmarkDotNetdata showing 10–25% throughput improvement - Integer key dictionary is faster due to trivial hash computation (
int.GetHashCode()is a single identity operation) CollectionsMarshal.GetValueRefOrAddDefaulteliminates double-lookup inGetOrCreateContext— a single atomic dictionary operation- Removal of
LocalValueEnumerator/LocalValueEntryis correct — grep confirms zero usages in the codebase InternalIdis assigned viaInterlocked.Increment(thread-safe), incremented only once perBindableProperty(static field), so overflow is effectively impossible#if NETSTANDARDfallback is included for platforms withoutCollectionsMarshal
Concerns requiring changes:
-
No unit tests (blocker): Gate was skipped because no tests were added. The PR modifies critical hot-path code (
GetValue/SetValue/GetOrCreateContext) used everywhere in MAUI. Unit tests verifying correctness of the new integer-keyed lookup,GetValues<T>, andGetOrCreateContextshould be added toBindableObjectUnitTests.cs. -
CollectionsMarshal.GetValueRefOrAddDefaultref safety (concern): Theref contextobtained fromGetValueRefOrAddDefaultis only valid until the next dictionary mutation. Ifproperty.DefaultValueCreator(this)triggers reentrant access toGetOrCreateContext(e.g., a property whose default value reads another property), the dictionary may resize and theref contextbecomes invalid — pointing to freed/relocated memory. This was safe in the oldCreateAndAddContextpattern because the property was added after the creator ran. A comment or guard should be added to document this assumption. -
Starting counter at
int.MinValue(minor):private static int _nextInternalId = int.MinValue;means the first property gets ID-2147483647. Starting from0(i.e., initializing to-1so the firstInterlocked.Incrementreturns0) would be more readable and conventional. This is cosmetic but worth noting. -
Benchmark file missing trailing newline (trivial):
BindableObjectBenchmarker.csis missing a final newline — minor style issue.
Try-Fix comparison:
All 4 alternative approaches pass unit tests, but the PR's approach is the best overall: it's O(1) for all sizes (unlike parallel arrays), avoids changing GetHashCode semantics (unlike Attempt 1), and is more impactful than the ReferenceEqualityComparer approach (Attempt 3, which may offer minimal gain since reference equality is already the default for these objects).
Verdict: Request changes to add unit tests and address the CollectionsMarshal ref safety concern with documentation. The optimization strategy is sound and should be approved once those are addressed.
Description of Change
I tried to improve the access time on BindableProperty given they're being accessed a lot during the application usage.
As we can see using Array + SIMD is actually performing better when the number of BindableProperty set on a BindableObject is less than ~10, though it becomes worse after that.
Array + SIMD also (obviously) allocates less memory.
Considering that SIMD may perform differently on different platforms I felt it would be better to simply improve the current Dictionary based implementation by leveraging:
Benchmarks