Skip to content

perf: cache per-render arrays/LINQ in DataGrid#669

Draft
azchohfi wants to merge 3 commits into
mainfrom
azchohfi-datagrid-perf-cache
Draft

perf: cache per-render arrays/LINQ in DataGrid#669
azchohfi wants to merge 3 commits into
mainfrom
azchohfi-datagrid-perf-cache

Conversation

@azchohfi

Copy link
Copy Markdown
Collaborator

Closes #663

Problem

The DataGrid (src/Reactor/Controls/DataGrid/) — which is the grid measured by the StressPerf.ReactorGrid data-grid stress workload — rebuilt arrays and re-ran LINQ on every render, even when the underlying sort/filter/column/width state was unchanged. These per-render allocations were first-order contributors to the C# vs Rust gap (Renders/sec, Avg Reconcile, Avg Diff, Avg Memory).

Changes

All edits are confined to src/Reactor/Controls/DataGrid/. New perf-only state members are internal (covered by InternalsVisibleTo Reactor.Tests), so the public API surface is unchanged.

DataGridState.cs

DataGridComponent.cs

Intentionally not done

Behavior preservation

Sorting, filtering, pagination, column show/hide/pin/resize, and range selection are unchanged. Every mutation path bumps the matching version so caches invalidate correctly.

Validation

  • dotnet build src/Reactor/Reactor.csproj -c Release0 warnings, 0 errors (no new AOT/trim warnings).
  • dotnet test tests/Reactor.Tests9681 passed, 0 failed, including 11 new DataGridPerfCacheTests (cache invalidation on sort/filter/column/width change; O(1) lookups match the old LINQ; shared layout cache reference stability; visible-columns cache; SelectRangeByKeyCache parity with the explicit-visibleOrder path).
  • DataGrid selftests (--self-test --filter DataGrid, real WinUI window) — 0 failures (paging, scroll, edit lifecycle, edit mutation, search+sort).

Note: dotnet build Reactor.slnx -c Release fails only on three unrelated tests/perf_bench/PerfBench.* XAML projects (XamlCompiler.exe exit 1 on ARM64) — confirmed pre-existing by reproducing the identical failure with these changes stashed.

The DataGrid rebuilt arrays and re-ran LINQ on every render even when the
underlying sort/filter/column/width state was unchanged. On the data-grid
stress workload (StressPerf.ReactorGrid, the grid being benchmarked) this
dominated Renders/sec, reconcile, diff, and memory.

DataGridState.cs:
- Add SortVersion/FilterVersion/ColumnVersion counters bumped by every
  mutation path (ToggleSort, SetFilter/ClearFilter/ClearAllFilters,
  Resize/Hide/Show/Reorder/Pin), used as cache keys.
- O(1) lookups via dictionaries kept in sync with the lists:
  GetSortDirection (_sortDirByField), GetFilter (_filterByField),
  GetColumnWidth/PinColumn (_columnIndexByName), replacing per-column
  LINQ FirstOrDefault/FindIndex.
- Columns getter serves a cached _visibleColumns list (version-keyed)
  instead of Where+ToList on every access.
- GetColumnLayout(...) caches the per-column widths + a single shared
  GridDefinition keyed on ColumnVersion + shape; the header and every data
  row reuse the one definition reference so the reconciler skips re-applying
  ColumnDefinitions.
- SelectRangeByKeyCache scans the internal row-key string[] by index instead
  of materializing the whole cache into a List<RowKey> on shift-click.

DataGridComponent.cs:
- Memoize the DataRequest (also stabilizes UseDataSource deps, fixing a
  spurious per-render pagination restart) and the sort key (UseMemo).
- Root grid + data/header rows build GridElements directly from cached
  GridDefinitions (static root-row-def cache; shared column layout) instead
  of allocating string[]/double[] + per-column double.ToString each render.
- Cache the once-wired LostFocus setter array in a UseRef.

The new perf-only state members are internal (InternalsVisibleTo Reactor.Tests),
so the public API surface is unchanged.

#126 (per-row Element?[] pooling) and #131B (validation-summary cache) are
intentionally not done; see the issue for the correctness rationale.

Adds DataGridPerfCacheTests covering cache invalidation, O(1)-lookup parity
with the old LINQ, the shared layout cache's reference stability, the
visible-columns cache, and SelectRangeByKeyCache parity.

Closes #663

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@azchohfi azchohfi marked this pull request as draft June 25, 2026 20:07
Comment thread src/Reactor/Controls/DataGrid/DataGridComponent.cs
Review-phase fixes for PR #669 (pr-review skill + multi-model cross-check):

- GetColumnLayout: key the width/GridDefinition cache on the caller-supplied
  columns list (reference + count), not only ColumnVersion+shape. ColumnVersion
  only tracks internal mutations (resize/hide/show/reorder/pin); a swapped
  el.Columns/auto-columns list could otherwise be served a stale, wrong-sized
  layout (and index out of range in the row renderer). [HIGH, confirmed]

- DataGridComponent: hoist both editable-grid UseRef hooks out of the
  if (el.Editable) branch so the hook call order is stable when Editable toggles
  between renders (avoids HookOrderException). [HIGH]

- Columns getter: build a fresh visible-column snapshot on invalidation instead
  of returning the internal list or clearing the cached buffer in place, so a
  previously returned IReadOnlyList is never mutated by a later column change
  (restores the snapshot semantics of the old Where(...).ToList()). [LOW]

Tests: add coverage for layout-cache invalidation on a columns-reference/count
change, content invalidation on reorder/pin/toggle-visibility, the Columns
snapshot guarantee, unknown-column lookup parity, and SelectRange reversed/
missing/empty parity. Full Reactor.Tests: 9688 passed / 0 failed.

Co-authored-by: Copilot <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.

Pull request overview

This PR targets DataGrid render-path allocation hotspots in src/Reactor/Controls/DataGrid/ by introducing version-keyed caches and O(1) lookups in DataGridState, and by memoizing / reusing stable layout objects in DataGridComponent. The intent is to reduce per-render LINQ/array churn without changing the public API surface.

Changes:

  • Add sort/filter/column version counters and dictionary-based O(1) lookups in DataGridState, plus cached visible-columns and cached column-layout (GridDefinition + widths) keyed by versions.
  • Update DataGridComponent to memoize DataRequest and the legacy sortKey, and to build GridElements directly from cached GridDefinitions (root grid + header/rows) to avoid per-render string/double arrays.
  • Add DataGridPerfCacheTests to validate cache invalidation, lookup parity with prior LINQ behavior, and range-selection parity.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
tests/Reactor.Tests/DataGridPerfCacheTests.cs Adds focused unit tests validating the new caching/versioning behavior and ensuring output parity with previous LINQ-based logic.
src/Reactor/Controls/DataGrid/DataGridState.cs Introduces version counters, lookup dictionaries, visible-columns caching, column-layout caching, and an allocation-free range selection path over the row-key cache.
src/Reactor/Controls/DataGrid/DataGridComponent.cs Memoizes request/sort key and reuses cached GridDefinitions by constructing GridElement directly, reducing per-render allocations and helping reconciler skip definition reapplication.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Cache the search TextBox's onChanged handler in a UseRef so it keeps a stable
reference across renders instead of allocating a fresh closure each time. It
captures only the stable `state`, so a once-built handler is equivalent to
rebuilding it every render. The ref is declared unconditionally (hooks must run
in a stable order; ShowSearch can toggle), mirroring the #131A LostFocus-setter
pattern.

This is the only cell/toolbar-facing handler created in the component's hook
context. Every other DataGrid handler (per-row click/expand/edit/select, header
sort/resize/reorder/pin) is built inside the static, virtualized RenderRow /
RenderHeaderRow methods, which run in VirtualList's renderItem factory with no
hook context — UseCallback/UseRef cannot be called there, so they can't be
stabilized without a separate state-backed delegate cache. On this branch the
Element skip path (Element.CanSkipUpdate) compares callback *presence*, not
delegate identity (ShallowEquals intentionally ignores delegate equality), so
those fresh closures do not currently defeat the skip path; reference-stabilizing
them only matters once the Core reference-comparison skip-path lands and is best
done as a coordinated follow-up.

Co-authored-by: Copilot <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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

@azchohfi

Copy link
Copy Markdown
Collaborator Author

/perf

@github-actions

Copy link
Copy Markdown

⚡ Reactor perf comparison

Workload: StressPerf.ReactorOptimized StocksGrid · --percent 50 --duration 10 · x64 Release · median of 12 paired runs (2 warmup dropped); Δ is the mean change with a 95% CI · PR head and main built and run interleaved on the same runner.

Regression vs main baseline

Metric main (baseline) This PR Δ (95% CI) Status
Renders/sec ↑ 2.63 2.61 -0.3% 95% CI [-3.1, +2.4] ≈ within noise
Avg Reconcile (ms) ↓ 145.4 146.9 -0.3% 95% CI [-4.3, +3.7] ≈ within noise
Avg Diff (ms) ↓ 132.4 134.1 +0.6% 95% CI [-3.7, +4.8] ≈ within noise
Avg Memory (MB) ↓ 294.1 293.6 -0.7% 95% CI [-2.1, +0.6] ≈ within noise

Allocation (Reactor) — lower is better

Metric main (baseline) This PR Δ (95% CI) Status
Alloc bytes/render ↓ 9605750 n/a
Gen0 GC / 1k renders ↓ 291.01 n/a

Cross-framework reference (same StocksGrid workload)

Metric vanilla WinUI3¹ Rust windows-reactor² Reactor (this PR)
Renders/sec ↑ 3.33 4.71 2.61
Avg Reconcile (ms) ↓ n/a 20.4 146.9
Avg Diff (ms) ↓ n/a 17.8 134.1
Avg Memory (MB) ↓ 264.6 196.3 293.6

↑ higher is better · ↓ lower is better. Within noise = the 95% confidence interval of the paired Δ includes 0 (no change resolvable at this sample size); ✅ improvement / ⚠️ regression require the CI to exclude 0.
Allocation metrics (alloc bytes/render, Gen0 GC) are the sensitive signal for allocation-reduction work, where the mean-ms / memory figures are largely flat. They read n/a for a harness built from a revision that predates them (rebase the PR onto main to populate them).
¹ vanilla WinUI3 = StressPerf.Direct (imperative; no virtual-DOM, so it has no reconcile/diff phase — those cells read n/a). Measured live on this runner.
² Rust = test_reactor_perf from microsoft/windows-rs — a port of this harness (same StocksGrid, same --percent/--duration CLI). Built from source and measured live on this runner.
Absolute numbers are runner-dependent — trust the Δ vs main, not the absolute values. Memory (working set) is the noisiest metric.
Runner: CPU: AMD EPYC 7763 64-Core Processor · 4 logical cores · 16 GB RAM · runner: GitHub Actions 1042823130.
Generated by .github/workflows/perf-compare.yml · PR 9f640ba vs main 66d38dc · 2026-06-26T07:33:57Z · run log.

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.

perf: cache per-render arrays/LINQ in DataGrid

2 participants