Skip to content

perf(grid): rebuild only changed rows and slim down _GridCell to fit the 120 Hz frame budget #972

Closed

Description

@ZhuchkaTriplesix

Summary

Profile-mode frame timings show VirtualResultGrid still does not fit the 120 Hz budget (8.33 ms) on a large, wide result, even after #875 (no re-sort on edit) and #879 (no setState per scroll tick). The remaining cost is widget build, not sorting or raster: every rebuild recreates all visible _DataRow / _GridCell widgets, including rows whose data did not change.

Measurements

Setup: flutter run --profile -d linux, 120 Hz display, 5000 rows × 120 columns, frame timings from SchedulerBinding.addTimingsCallback, 6 s per run. Baseline = dev before #875/#879. Synthetic stress: pan 14 px per frame, or one staged cell edit per frame on a grid sorted by column 0.

Scenario build p50 frames over 8.33 ms frames in 6 s
Horizontal pan, before 23.7 ms 112 / 113 113
Horizontal pan, after #879 0.65 ms 50 / 157 157
Edit on sorted grid, before 28.3 ms 113 / 114 114
Edit on sorted grid, after #875 17.9 ms 178 / 179 179

Raster is stable at about 4 ms (p50), i.e. half the budget on its own.

  • Pan: most frames are cheap now, but about a third (the ones where the visible column window changes) take about 50 ms to build.
  • Edit: a single cell edit still costs about 18 ms of build because all visible rows are rebuilt.

The benchmark app used for this is benchmark/grid_perf_bench.dart (MODE=scroll / MODE=edit); it is not in the repo yet.

Hypothesis (not yet verified)

  • _DataRow widgets are new instances on every build, so Element.updateChild cannot skip them, and each visible row rebuilds all of its _GridCells (hundreds to thousands of cells on a large window).
  • _GridCell is heavy per instance (gesture / tooltip / decoration layers), so even a cheap change multiplies.

Needed first: function-level profile

The numbers above only say build is slow. A function-level CPU profile (DevTools CPU profiler / Timeline in profile mode, on a full-screen window) is still required to confirm where the time goes before choosing a fix. Note the window size matters a lot: more visible cells means more build work, so the profile must be taken with a full-size (maximized) window.

Scope

  • Capture the function-level profile for both scenarios and attach the top self-time frames.
  • Stop rebuilding rows whose displayed data and status did not change (for example an == on _DataRow keyed on a per-row version from the staging buffer, so Element.updateChild can skip it).
  • Reduce per-cell cost in _GridCell (fewer wrapper widgets / layers per cell).
  • Re-run the benchmark and record before / after numbers in the PR.
  • Optionally add benchmark/grid_perf_bench.dart (or a trimmed version) so the measurement is repeatable.

Acceptance

  • Edit on a sorted 5000 × 120 grid: build p90 under 8.33 ms in profile mode on a full-screen window.
  • Horizontal pan: frames where the column window changes stay under 8.33 ms build.
  • No change in selection, editing, staged-status colors or virtualization behaviour (existing grid tests stay green).

Out of scope

  • Column auto-fit sampling in LayoutBuilder.
  • Changing the staging buffer's copy of original rows.

Activity

  1. added
    frontendTheme parser epic label: frontend
    performanceTheme parser epic label: performance
    uiUser interface components and widgets
    data-gridInteractive data grid, cell editor, filtering, groupings
    on Sep 25, 2026
  2. ZhuchkaTriplesix commented on Sep 25, 2026

    @ZhuchkaTriplesix
    MemberAuthor

    Function-level profile (edit scenario, before the fix)

    Profile mode, Impeller (OpenGLES) on an AMD iGPU, 120 Hz display, window 4096×2576 px (dpr 2.0), 5000 rows × 120 columns, one staged cell edit per frame on a grid sorted by column 0. Samples: 2731, taken in-process via the VM service getCpuSamples.

    Inclusive time:

    • Element.updateChild → SliverMultiBoxAdaptorElement.performRebuild: ~67%. Every visible row is rebuilt for a single-cell edit.
    • Element.dependOnInheritedElement: ~25%, of which MediaQuery._maybeOf / InheritedModel.inheritFrom ~14–15%. This is per-cell inherited lookups (Tooltip / ContextMenu / MouseRegion wrappers) multiplied by thousands of cells.

    Self time (top): _HashSet.add 17% (dependency registration), Object._haveSameRuntimeType 7% (Widget.canUpdate across all rebuilt cells), Element.widget 4.6%, RawGestureDetectorState._syncAll 2%, _GridCell.build 1.2%.

    So the cost is the number of cells rebuilt, not the work inside one cell. Fix direction confirmed: stop rebuilding rows / cells whose inputs did not change.

    Same window, before vs after (see the PR for the fix)

    Scenario build p50 / p90 build frames over 8.33 ms
    Pan, dev before 0.67 / 79.6 ms 31 / 104
    Pan, after 0.67 / 1.46 ms 6 / 272
    Edit, dev before 31.7 / 49.7 ms 99 / 100
    Edit, after 2.50 / 3.78 ms 22 / 289

    Raster is unchanged and now the limit: about 6–7 ms p50 and 9–16 ms p90 at this window size. That is a separate follow-up (fewer draw ops per cell).

  3. added a commit that references this issue on Sep 25, 2026
  4. ZhuchkaTriplesix commented on Sep 27, 2026

    @ZhuchkaTriplesix
    MemberAuthor

    Resolved by #973 (already merged into dev; not auto-closed since this repo's default branch is main and PRs merge into dev).

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

Metadata

Metadata

Labels

data-gridInteractive data grid, cell editor, filtering, groupingsfrontendTheme parser epic label: frontendperformanceTheme parser epic label: performanceuiUser interface components and widgets

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions