Skip to content

perf(datagrid): scroll-path improvements cut max main-thread stall by 62%#1212

Merged
datlechin merged 1 commit into
mainfrom
perf/datagrid-scroll-improvements
May 11, 2026
Merged

perf(datagrid): scroll-path improvements cut max main-thread stall by 62%#1212
datlechin merged 1 commit into
mainfrom
perf/datagrid-scroll-improvements

Conversation

@datlechin
Copy link
Copy Markdown
Member

Summary

Six targeted improvements on the data-grid scroll hot path identified by Time Profiler + MainThreadWatchdog measurements on a wide_events test table (1000 rows × 40 columns, MariaDB). Max main-thread stall during wide-table scroll drops from 3.5s → ~1.3s (-62%).

What changed

1. Skip needsDisplay = true when cell content is unchanged

DataGridCellView.configure was unconditionally marking the cell dirty after every reuse, even when the same row/column was rebound with identical state. Now tracks a needsRedraw flag that flips only when text, font, color, tint, visual state, kind, raw value, or focus actually changed.

2. Drop NSCache<RowIDKey, RowDisplayBox> for a Swift Dictionary

Profiler showed RowIDKey(id) wrapper allocation on every cache lookup (one per cell during scroll). New RowDisplayCache is a plain [RowID: RowDisplayBox] dictionary on @MainActor with manual count + cost caps and head-index FIFO eviction instead of Array.removeFirst() (O(n) memmove). Removes both the per-call allocation and the NSCache internal lock.

3. Date parser memoizes last successful index

DateFormattingService.format(dateString:) tried six parsers sequentially on every cache miss. Most cells in the same column share the same wire format, so consecutive parses succeed at the same index. Track lastSuccessfulParserIndex and try it first.

4. Wrap viewFor:row: in autoreleasepool

NSTableView delegate callbacks create transient Foundation objects (NSString bridges, NSAttributedString, NSNumber a11y ranges). Without a pool drain they accumulate until the next runloop boundary. Wrapping the call in autoreleasepool drains them per cell, spreading deallocation cost.

5. Drop per-cell wantsLayer

DataGridCellView had wantsLayer = true, giving each cell its own backing CALayer. With 30 visible rows × 40 columns = ~1200 cell layers; every scroll triggered AppKit to commit all of them. DataGridRowView already has wantsLayer = true and canDrawSubviewsIntoLayer = true, so the row layer now absorbs cell drawing. Layer count drops 40×. Profile showed CA::Transaction::commit cost falling from 4.53s to 3.97s.

6. Head-index eviction in RowDisplayCache

Same removeFirst() trap as #2: trace showed 132ms of memmove per eviction cycle. Uses an array + head index so eviction is O(1) amortized, with periodic compaction every 10k stale entries.

Test plan

  • Build, open MySQL tablepro_demowide_events (or any wide table with ~40 columns).
  • Scroll vertically by dragging the scrollbar fast end-to-end. Should feel smoother than before.
  • Scroll horizontally across all 40 columns. Should feel smoother.
  • Verify selection still highlights correctly (blue background, white text).
  • Verify pending delete (red strikethrough), pending truncate (orange), and modified (yellow) tints still render.
  • Verify focus border still shows when keyboard-navigating cells.
  • Verify date columns format correctly across multiple formats (MySQL 2026-05-11 12:00:00, ISO 2026-05-11T12:00:00Z).
  • Verify cache invalidation still works when sorting or applying filters.
  • swiftlint --strict clean on the 5 touched files.
  • Build clean via xcodebuild (no new errors or warnings).

Methodology

Measurements taken with MainThreadWatchdog (50ms threshold, OSLog warnings) and Time Profiler traces of scroll sessions. The watchdog and signpost instrumentation that drove this investigation is not included in this PR — it can be re-added on a debug branch if needed for follow-up work.

@datlechin datlechin merged commit 128c360 into main May 11, 2026
2 checks passed
@datlechin datlechin deleted the perf/datagrid-scroll-improvements branch May 11, 2026 08:35
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.

1 participant