Skip to content

Optimize data structures and defer layout work#160

Merged
bryankeller merged 1 commit into
masterfrom
bk/performance-optimizations-3
Jun 9, 2026
Merged

Optimize data structures and defer layout work#160
bryankeller merged 1 commit into
masterfrom
bk/performance-optimizations-3

Conversation

@bryankeller

@bryankeller bryankeller commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Details

Another round of perf fixes - these are the trickiest ones. There are two main improvements:

  1. Lazy invalidation - instead of eagerly invalidating some of our bookkeeping data structures, potentially multiple times during a single layout pass, we instead defer that work and just set a flag indicating that there's some future work that needs to be done
  2. Switch from using dictionaries to flat arrays for index to row / row to index mappings. The keys for these dictionaries were already integers, so arrays are a natural replacement since the key simply becomes the position in the array.

All changes are gated behind the new experiment optimizations flag.

How Has This Been Tested

Tested in demo app and Airbnb app. Additionally, all unit tests pass.

Types of changes

  • Docs change / refactoring / dependency upgrade
  • Perf fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

@bryankeller bryankeller added the enhancement New feature or request label Jun 9, 2026
@bryankeller bryankeller requested a review from brynbodayle June 9, 2026 18:01
Comment on lines +656 to +681
if MagazineLayout._enableExperimentalOptimizations {
newHeaderLocationsForFlattenedIndices.removeAll(keepingCapacity: true)
newFooterLocationsForFlattenedIndices.removeAll(keepingCapacity: true)
newBackgroundLocationsForFlattenedIndices.removeAll(keepingCapacity: true)
newItemLocationsForFlattenedIndices.removeAll(keepingCapacity: true)

for sectionIndex in 0..<sectionModels.count {
if sectionModels[sectionIndex].headerModel != nil {
let elementLocation = ElementLocation(elementIndex: 0, sectionIndex: sectionIndex)
newHeaderLocationsForFlattenedIndices.append(elementLocation)
}

if sectionModels[sectionIndex].footerModel != nil {
let elementLocation = ElementLocation(elementIndex: 0, sectionIndex: sectionIndex)
newFooterLocationsForFlattenedIndices.append(elementLocation)
}

if sectionModels[sectionIndex].backgroundModel != nil {
backgroundLocationsForFlattenedIndices[flattenedBackgroundIndex] = ElementLocation(
elementIndex: 0,
sectionIndex: sectionIndex)
flattenedBackgroundIndex += 1
if sectionModels[sectionIndex].backgroundModel != nil {
let elementLocation = ElementLocation(elementIndex: 0, sectionIndex: sectionIndex)
newBackgroundLocationsForFlattenedIndices.append(elementLocation)
}

for itemIndex in 0..<sectionModels[sectionIndex].numberOfItems {
let elementLocation = ElementLocation(elementIndex: itemIndex, sectionIndex: sectionIndex)
newItemLocationsForFlattenedIndices.append(elementLocation)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same logic as the existing else branch, but using the new arrays instead of dictionaries

Comment on lines +684 to +720
headerLocationsForFlattenedIndices.removeAll()
footerLocationsForFlattenedIndices.removeAll()
backgroundLocationsForFlattenedIndices.removeAll()
itemLocationsForFlattenedIndices.removeAll()

var flattenedHeaderIndex = 0
var flattenedFooterIndex = 0
var flattenedBackgroundIndex = 0
var flattenedItemIndex = 0
for sectionIndex in 0..<sectionModels.count {
if sectionModels[sectionIndex].headerModel != nil {
headerLocationsForFlattenedIndices[flattenedHeaderIndex] = ElementLocation(
elementIndex: 0,
sectionIndex: sectionIndex)
flattenedHeaderIndex += 1
}

if sectionModels[sectionIndex].footerModel != nil {
footerLocationsForFlattenedIndices[flattenedFooterIndex] = ElementLocation(
elementIndex: 0,
sectionIndex: sectionIndex)
flattenedFooterIndex += 1
}

if sectionModels[sectionIndex].backgroundModel != nil {
backgroundLocationsForFlattenedIndices[flattenedBackgroundIndex] = ElementLocation(
elementIndex: 0,
sectionIndex: sectionIndex)
flattenedBackgroundIndex += 1
}

for itemIndex in 0..<sectionModels[sectionIndex].numberOfItems {
itemLocationsForFlattenedIndices[flattenedItemIndex] = ElementLocation(
elementIndex: itemIndex,
sectionIndex: sectionIndex)
flattenedItemIndex += 1
for itemIndex in 0..<sectionModels[sectionIndex].numberOfItems {
itemLocationsForFlattenedIndices[flattenedItemIndex] = ElementLocation(
elementIndex: itemIndex,
sectionIndex: sectionIndex)
flattenedItemIndex += 1
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

existing code, just indented and put in an else branch

Comment on lines +443 to +445
private var newItemIndicesForRowIndices = [ClosedRange<Int>?]()
private var newRowIndicesForItemIndices = [Int?]()
private var newItemRowHeightsForRowIndices = [CGFloat]()

@bryankeller bryankeller Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

more dictionary -> array flattening. I also simplified some types, like using a ClosedRange<Int> to represent the item indices in a particular row of content, rather than using an array for that. The indices in a row are always contiguous, so no reason to incur array allocation overhead just to say "row 5 contains item indices 9...10"

Comment on lines +678 to +691
if MagazineLayout._enableExperimentalOptimizations {
newItemIndicesForRowIndices.grow(toInclude: rowIndex, fillingWith: nil)
if let range = newItemIndicesForRowIndices[rowIndex] {
newItemIndicesForRowIndices[rowIndex] = range.lowerBound...itemIndex
} else {
newItemIndicesForRowIndices[rowIndex] = itemIndex...itemIndex
}
newRowIndicesForItemIndices.grow(toInclude: itemIndex, fillingWith: nil)
newRowIndicesForItemIndices[itemIndex] = rowIndex
} else {
itemIndicesForRowIndices[rowIndex] = itemIndicesForRowIndices[rowIndex] ?? []
itemIndicesForRowIndices[rowIndex]?.append(itemIndex)
rowIndicesForItemIndices[itemIndex] = rowIndex
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

like all of these if/else cases, the false branch is existing unchanged code, and the true branch is the equivalent logic but operating on the new arrays rather than the old dictionaries.

@bryankeller bryankeller force-pushed the bk/performance-optimizations-3 branch 2 times, most recently from 256e4ca to 4c2ab4e Compare June 9, 2026 18:54
@bryankeller bryankeller force-pushed the bk/performance-optimizations-3 branch from 4c2ab4e to a52e6e1 Compare June 9, 2026 20:10
Comment on lines 920 to +939
@@ -936,7 +936,7 @@ public final class MagazineLayout: UICollectionViewLayout {
}

if context.invalidateLayoutMetrics && shouldInvalidateLayoutMetrics {
prepareActions.formUnion([.updateLayoutMetrics])
prepareActions.formUnion(.updateLayoutMetrics)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prevent unnecessary array allocations

@bryankeller bryankeller merged commit 417eb4d into master Jun 9, 2026
1 check passed
@bryankeller bryankeller deleted the bk/performance-optimizations-3 branch June 9, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants