Repository navigation
Avoid per-child control reads in keyed mapping around retained exits - #1445
Alexandre Zollinger Chohfi (azchohfi) merged 2 commits into
Conversation
#1429 made the keyed pass read every child of a keyed panel whenever any exit was in flight on the reconciler, and read each child twice more to build the logical-to-physical map when a panel held an exit or a null-mounted Memo. Each UIElementCollection read is a WinRT call that allocates ~52 B, so a 500-child keyed panel paid +93% to +189% allocation per render. - Detect an exit in a collection by probing the collection once per in-flight exit (IndexOf) instead of reading every child. - Resolve the keyed map with a forward cursor on demand: a skipped child never advances it, each physical child is read at most once, and the control it claims is handed to the update so it is not read again. - Add KX1-KX4 keyed-exit micro benches to the control-model catalog (27 benches). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
📦 Build metricsArtifact sizes for Packages (compressed .nupkg)
Assemblies in Microsoft.UI.Reactor
Assemblies in Microsoft.UI.Reactor.Advanced
Assemblies in Microsoft.UI.Reactor.Devtools
✅ smaller / |
🧪 Merged coverageCoverage for
✅ higher / |
- Cache the control each keyed-map slot claims so suffix updates reuse the cursor's read instead of reading every suffix child again. - Decide the identity map lazily: an all-skip pass never probes for exits. - Seed ProbeEachChild dirty checks with the mapped control. - Raise ExitProbeLimit to 64 with its cost rationale. - Cover suffix read counts, both sides of the probe limit, and the context / callback skip arms under a non-identity map. - Correct the KX bench header (rows update, they do not skip) and narrow the CHANGELOG wording. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues block the keyed-reconciliation performance changes.
No open issues 🎉
What changed in this PR
This PR addresses the keyed-reconciliation performance regression introduced by #1429 while preserving correct control mapping around retained exits and null-mounted Memo children.
Changes:
- Replaces eager mapping with an on-demand cursor that caches control reads and avoids mapping work when all children skip.
- Uses collection-local exit probes for up to 64 concurrent exits, with a scan fallback above that limit.
- Adds regression tests and four keyed-exit benchmarks, updating the micro-suite count from 23 to 27.
| File | Description |
|---|---|
tests/stress_perf/ci/Run-PerfBenchmark.ps1 |
Updates comments documenting the 27-benchmark suite. |
tests/stress_perf/ci/PerfLib.Tests.ps1 |
Updates benchmark-count and catalog drift assertions. |
tests/stress_perf/ci/PerfLib.ps1 |
Updates the expected benchmark count and report descriptions. |
tests/Reactor.Tests/ChildReconcilerKeyedSkipTests.cs |
Tests zero control reads for unchanged lists with mismatched physical counts. |
tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs |
Registers three new transition fixtures. |
tests/Reactor.AppTests.Host/SelfTest/Fixtures/TransitionTests.cs |
Tests read counts, exit-probe boundaries, and skip-path control mapping. |
tests/perf_bench/PerfBench.ControlModel/Benches/KeyedExitBenches.cs |
Adds four benchmarks covering exits and empty Memo slots. |
tests/perf_bench/PerfBench.ControlModel/Benches/AllBenches.cs |
Adds the keyed-exit benchmarks to the catalog. |
src/Reactor/Core/Reconciler.cs |
Adds bounded exit probing with a scan fallback. |
src/Reactor/Core/ChildReconciler.cs |
Implements lazy keyed mapping and cached control reads. |
CHANGELOG.md |
Documents the keyed-reconciliation performance improvement. |
Review effort: Balanced
Summary
Fixes the perf regression from #1429 and keeps its correctness fixes. Under #1429:
Each
UIElementCollectionread is a WinRT call that allocates about 52 B, which made a 500-child keyed panel allocate +93% to +189% per render.Reconciler.HoldsExitingControlIn) checks the collection once per in-flight exit with a nativeIndexOf, for up toExitProbeLimit(64) exits. Above that it falls back to a scan. It no longer reads every child, so an exit playing in one panel no longer costs other keyed panels a read per child.KeyedPanelMapreplaces the eager map with a forward cursor that is resolved on demand:Replaceslots the cursor has already passed, andReplacedoesn't change the count. Every count-changing mutation happens afterResolve().BenchCatalog(23 → 27).$script:MicroExpectedBenchCountand the PerfLib drift-guard assertions are bumped to match.Linked issue / spec
Follow-up to #1429 (spec 014, feature 2).
Perf
KX micro-suite, 10 paired rounds of fresh interleaved processes, scored with
Get-PerfMicroComparison. "main" is 90c9959 (which includes #1429) plus the KX benches. Each op re-renders 500 keyedBorderrows with fresh records, so every row is updated.Allocation is back to (or below) the pre-#1429 level of about 28.0 KB/op on 7f46468, now with #1429's correct mapping. Two earlier runs of the first revision agreed on allocation to within 0.1%.
On this PR's own
/perfcomment, the micro section will read Incomplete 23/27. That's expected: the baseline (main) doesn't have KX1–KX4 yet, so the four new benches have nothing to pair with. Use the table above for them.Test plan
ChildReconcilerKeyedSkipTests.Identical_Keyed_List_Over_Mismatched_Collection_Reads_No_Control: collection count ±1 vs. the old children → zeroGet.TransitionTests(registered inSelfTestFixtureRegistry):Transition_KeyedPassReadsEachControlAtMostOncecovers no exit, an unrelated-panel exit, a local exit, a leading empty Memo, and all combined. Asserts:Transition_KeyedExitProbeLimitBoundarytestsExitProbeLimitandExitProbeLimit + 1exits in a panel whose counts match only because a keyed Memo mounts nothing. Asserts that each child patches its own control and that the probe arm (or the scan arm) is the one that ran.Transition_KeyedMapSkipArmsUseOwnControlsuses a leading empty Memo and checks two things in both prefix and suffix position: context consumers re-render, and refreshed button callbacks dispatch the latest closure.ChildReconciler.cs+Reconciler.cs: the unit cases and 8 read-count checks fail.false: the 65-exit case fails.ControlAtusing the logical index: all four skip-arm checks fail.dotnet test tests/Reactor.Tests --filter-class "*ChildReconciler*" --filter-class "*Keyed*" --filter-class "*Animation*": 316/316 passed.dotnet test tests/Reactor.Tests(full, first revision): 15,311 passed, 64 skipped, 1 failed. The failure isCompilationLoaderTests.Cold_load_under_500ms…, a wall-clock CLI load-time budget (2.1 s on a loaded machine) that this change doesn't touch.dotnet run --project tests/Reactor.AppTests.Host -- --self-test --filter "Transition_": 0 failures, including every Fix keyed child mapping while exit transitions are retained #1429Transition_Keyed*/KeyedEmptyMemo/MixedKeyscenario.dotnet run --project tests/Reactor.AppTests.Host -- --self-test(full suite): one failure,AutoSuggestBoxCollectedStateTick_TIMEOUT, an AutoSuggestBox GC-collection fixture. It passed 2/2 in isolation and in the previous full run.pwsh tests/stress_perf/ci/PerfLib.Tests.ps1: all 380 assertions passed.tests/Reactor.Tests,tests/Reactor.AppTests.HostandPerfBench.ControlModel(each buildssrc/Reactor): 0 warnings, 0 errors.dotnet build Reactor.slnx --no-restore -c Releasecould not complete locally: restore of thesrc/vs-reactortest projects failed with nuget.org TLS errors (NU1301). CI covers it.Risk / breaking changes
ExitProbeLimitandHoldsExitingControlInare internal.