Repository navigation
perf(grid): rebuild only changed rows and slim down _GridCell to fit the 120 Hz frame budget #972
Description
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)
_DataRowwidgets are new instances on every build, soElement.updateChildcannot skip them, and each visible row rebuilds all of its_GridCells (hundreds to thousands of cells on a large window)._GridCellis 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_DataRowkeyed on a per-row version from the staging buffer, soElement.updateChildcan 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
ZhuchkaTriplesix commentedon Sep 25, 2026
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 whichMediaQuery._maybeOf/InheritedModel.inheritFrom~14–15%. This is per-cell inherited lookups (Tooltip / ContextMenu / MouseRegion wrappers) multiplied by thousands of cells.Self time (top):
_HashSet.add17% (dependency registration),Object._haveSameRuntimeType7% (Widget.canUpdateacross all rebuilt cells),Element.widget4.6%,RawGestureDetectorState._syncAll2%,_GridCell.build1.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)
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).
Merge pull request #973 from QueryaHub/issue/972-grid-row-rebuild-120hz
ZhuchkaTriplesix commentedon Sep 27, 2026
Resolved by #973 (already merged into
dev; not auto-closed since this repo's default branch ismainand PRs merge intodev).