perf(datagrid): scroll-path improvements cut max main-thread stall by 62%#1212
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Six targeted improvements on the data-grid scroll hot path identified by Time Profiler +
MainThreadWatchdogmeasurements on awide_eventstest 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 = truewhen cell content is unchangedDataGridCellView.configurewas unconditionally marking the cell dirty after every reuse, even when the same row/column was rebound with identical state. Now tracks aneedsRedrawflag that flips only when text, font, color, tint, visual state, kind, raw value, or focus actually changed.2. Drop
NSCache<RowIDKey, RowDisplayBox>for a SwiftDictionaryProfiler showed
RowIDKey(id)wrapper allocation on every cache lookup (one per cell during scroll). NewRowDisplayCacheis a plain[RowID: RowDisplayBox]dictionary on@MainActorwith manual count + cost caps and head-index FIFO eviction instead ofArray.removeFirst()(O(n) memmove). Removes both the per-call allocation and theNSCacheinternal 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. TracklastSuccessfulParserIndexand try it first.4. Wrap
viewFor:row:inautoreleasepoolNSTableView 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
autoreleasepooldrains them per cell, spreading deallocation cost.5. Drop per-cell
wantsLayerDataGridCellViewhadwantsLayer = 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.DataGridRowViewalready haswantsLayer = trueandcanDrawSubviewsIntoLayer = true, so the row layer now absorbs cell drawing. Layer count drops 40×. Profile showedCA::Transaction::commitcost falling from 4.53s to 3.97s.6. Head-index eviction in
RowDisplayCacheSame
removeFirst()trap as #2: trace showed 132ms ofmemmoveper eviction cycle. Uses an array + head index so eviction is O(1) amortized, with periodic compaction every 10k stale entries.Test plan
tablepro_demo→wide_events(or any wide table with ~40 columns).2026-05-11 12:00:00, ISO2026-05-11T12:00:00Z).swiftlint --strictclean on the 5 touched files.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.