Skip to content

Avoid per-child control reads in keyed mapping around retained exits - #1445

Merged
Alexandre Zollinger Chohfi (azchohfi) merged 2 commits into
mainfrom
azchohfi-keyed-exit-reconcile-perf
Oct 29, 2026
Merged

Alexandre Zollinger Chohfi (azchohfi) merged 2 commits into
mainfrom
azchohfi-keyed-exit-reconcile-perf

Conversation

@azchohfi

@azchohfi Alexandre Zollinger Chohfi (azchohfi) commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes the perf regression from #1429 and keeps its correctness fixes. Under #1429:

  • Exits anywhere taxed every keyed panel. While any exit was in flight on the reconciler, every keyed panel re-render read all of its children.
  • The map read each child twice. When a panel held a retained exit or a null-mounted keyed Memo, the logical→physical map read each child twice more.

Each UIElementCollection read is a WinRT call that allocates about 52 B, which made a 500-child keyed panel allocate +93% to +189% per render.

  • Exit detection (Reconciler.HoldsExitingControlIn) checks the collection once per in-flight exit with a native IndexOf, for up to ExitProbeLimit (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.
  • KeyedPanelMap replaces the eager map with a forward cursor that is resolved on demand:
    • It reads each physical child at most once and caches the control each slot claimed, so prefix and suffix updates reuse that read instead of reading again.
    • A pass whose children all take the structural skip never uses the map, so it neither reads controls nor probes for exits.
    • Resolving the map ahead of a mutation is sound because the prefix and suffix walks only Replace slots the cursor has already passed, and Replace doesn't change the count. Every count-changing mutation happens after Resolve().
  • KX1–KX4 keyed-exit micro benches are added to BenchCatalog (23 → 27). $script:MicroExpectedBenchCount and 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 keyed Border rows with fresh records, so every row is updated.

Bench main B/op this PR B/op Δ alloc Δ ns (95% CI)
KX1 no exit (control) 28,017 28,017 0.0% +6.2% [−16.5, +29.0] (noise)
KX2 exit in another panel 54,141 28,219 −47.9% −21.4% [−34.0, −8.8]
KX3 exit retained in same panel 81,011 27,098 −66.5% −37.7% [−48.1, −27.3]
KX4 leading keyed empty Memo 81,063 27,082 −66.6% −30.7% [−35.5, −25.9]

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 /perf comment, 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

  • Unit test ChildReconcilerKeyedSkipTests.Identical_Keyed_List_Over_Mismatched_Collection_Reads_No_Control: collection count ±1 vs. the old children → zero Get.
  • Selftest fixtures in TransitionTests (registered in SelfTestFixtureRegistry):
    • Transition_KeyedPassReadsEachControlAtMostOnce covers no exit, an unrelated-panel exit, a local exit, a leading empty Memo, and all combined. Asserts:
      • 0 reads and 0 probes when every child skips.
      • Exactly 1 read per realized child for updates through the prefix walk and through the suffix walk (after a leading insertion).
      • Each changed child patches its own control.
    • Transition_KeyedExitProbeLimitBoundary tests ExitProbeLimit and ExitProbeLimit + 1 exits 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_KeyedMapSkipArmsUseOwnControls uses 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.
  • Mutation checks: each new assertion fails against the code it guards. Specifically:
    • Main's ChildReconciler.cs + Reconciler.cs: the unit cases and 8 read-count checks fail.
    • The first-revision cursor: the suffix read count (5 reads where 3 are expected) and the eager-probe checks fail.
    • The scan arm returning false: the 65-exit case fails.
    • ControlAt using 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 is CompilationLoaderTests.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 #1429 Transition_Keyed* / KeyedEmptyMemo / MixedKey scenario.
  • 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.
    • The full 27-bench micro suite finishes in 47 s, within the 180 s per-round budget.
  • Release builds of tests/Reactor.Tests, tests/Reactor.AppTests.Host and PerfBench.ControlModel (each builds src/Reactor): 0 warnings, 0 errors.
    • dotnet build Reactor.slnx --no-restore -c Release could not complete locally: restore of the src/vs-reactor test projects failed with nuget.org TLS errors (NU1301). CI covers it.

Risk / breaking changes

  • No public API change. ExitProbeLimit and HoldsExitingControlIn are internal.
  • This is a perf-sensitive path (keyed child reconciliation). Mapping semantics match Fix keyed child mapping while exit transitions are retained #1429: same key and sibling-key matching, same absent-slot sentinel. Only the timing and number of collection reads change.
  • Above 64 concurrent exits, exit detection falls back to one read per child. That is the pre-PR behaviour and is limited to mass exits.

#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>
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build metrics

Artifact sizes for 8a9a468 vs the base branch (6f5e152).

Packages (compressed .nupkg)

Artifact base PR Δ
Microsoft.UI.Reactor.nupkg 1.98 MB 1.98 MB -3.57 KB (-0.18%) ✅
Microsoft.UI.Reactor.Advanced.nupkg 495.7 KB 495.7 KB +0 B (0.00%) ≈
Microsoft.UI.Reactor.Devtools.nupkg 287.9 KB 287.9 KB +3 B (0.00%) ≈

Assemblies in Microsoft.UI.Reactor

Artifact base PR Δ
Reactor.Analyzers.dll 411.5 KB 411.5 KB +0 B (0.00%) ≈
Reactor.dll 2.72 MB 2.71 MB -8.00 KB (-0.29%) ✅
Reactor.Localization.Generator.dll 16.0 KB 16.0 KB +0 B (0.00%) ≈
Reactor.Wrappers.Abstractions.dll 10.5 KB 10.5 KB +0 B (0.00%) ≈
Reactor.Wrappers.Generator.dll 145.5 KB 145.5 KB +0 B (0.00%) ≈

Assemblies in Microsoft.UI.Reactor.Advanced

Artifact base PR Δ
Reactor.Advanced.dll 1.00 MB 1.00 MB +0 B (0.00%) ≈

Assemblies in Microsoft.UI.Reactor.Devtools

Artifact base PR Δ
Microsoft.UI.Reactor.Devtools.dll 787.0 KB 787.0 KB +0 B (0.00%) ≈

✅ smaller / ⚠️ larger / ≈ within noise. Sizes come from a Release dotnet pack on the CI runner: packages are the compressed .nupkg download size, assemblies the uncompressed DLL inside it.
workflow run.

@azchohfi Alexandre Zollinger Chohfi (azchohfi) added the agent-preparing Agent is addressing feedback or completing required validation and CI label Oct 29, 2026
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

🧪 Merged coverage

Coverage for 8a9a468 vs the base branch (6f5e152) — unit + selftest merged.

Metric base PR Δ
Line 88.17% 87.97% -0.20 pp ⚠️
Branch 65.95% (1009/1530) 66.56% (1009/1516) +0.61 pp ✅

✅ higher / ⚠️ lower / ≈ within noise. Δ is in percentage points; coverage is unit + selftest merged (Debug x64) on the CI runner. Cobertura reports attached to the workflow run as artifacts.

- 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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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

@azchohfi Alexandre Zollinger Chohfi (azchohfi) added ready-for-review Agent work and technical checks complete; awaiting review, not approval or merge and removed agent-preparing Agent is addressing feedback or completing required validation and CI labels Oct 29, 2026
@azchohfi
Alexandre Zollinger Chohfi (azchohfi) merged commit 8f17bac into main Oct 29, 2026
48 checks passed
@azchohfi
Alexandre Zollinger Chohfi (azchohfi) deleted the azchohfi-keyed-exit-reconcile-perf branch October 29, 2026 23:44
@azchohfi Alexandre Zollinger Chohfi (azchohfi) removed the ready-for-review Agent work and technical checks complete; awaiting review, not approval or merge label Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants