macOS SwiftUI Table Performance Issue

Has anyone else created a macOS SwiftUI app that uses a Table with a largish (~1000) number of entries? My app works OK at about 100 entries, but slows down as the number of entries increase. How slow? An instrumented test with 1219 entries shows a Hang of over 13 seconds from simply clicking/selecting an item in the table.

Instruments says the time is mostly spent in _CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION. Digging deeper I see AG::Subgraph::update(unsigned int) and descendants account for about half of the hang time.

My app is using @Observable on macOS 14 and is being tested on an M2 Max Studio.

There are other reported hangs. All seem to be in Swift/SwiftUI code.

Accepted Reply

Well, that was interesting.

I started a new version of my project, bringing code over bit-by-bit and doing performance testing after every change. It boils down to this

Table(...) {
} rows: {
    ForEach(someArray) { item in
        TableRow(item)
    }
}

That worked fine. But I don't want to show every item. So I added this:

    ForEach(someArray) { item in
        if item.isValid {
            TableRow(item)
        }
    }

Checking the item when building the row breaks things badly. Now to figure out a workaround.

Replies

In Instruments, have you used the SwiftUI tracks to see what view bodies or property invalidations may be contributing to the hang?

The CFRunloop methods you reference can be called for many different reasons. If you setup any CFRunloop observers you can find yourself in there. And AG::Subgraph::update can mean a lot of different things. It generally means that some property (that perhaps you own) is invalidating (like an observable or state or some other view property) and has caused a view update. Even so much as a tap could trigger it, which is why that symbol alone is not evidence of a problem. It's what that symbol has underneath it, and what triggered that. This is where the View Properties track in Instruments can be very helpful.

What I would do is, use the SwiftUI tracks to see if any code you own is getting invoked and is taking a long time. If you are using Observable (that's awesome by the way!) then you should consider what about selecting an item in a table does to that object that could maybe cause invalidations. Perhaps something per-item in your data model happens, which as you scale the number of rows causes the hang to grow.

Nothing in the SwiftUI track accounts for the time. Ditto the View Properties track. There is very little in either track.

I thought I'd narrowed down the cause of the slowdown because this specific statement took 2.53 seconds in one test.

mostSelected = self[proposedSelection.first]

That code is running in a function called from an onChange modifier for the table. mostSelected is a property on an @Observable class. The delay was due to waiting for the MainActor to become free to process the update. If I forced the code onto the MainActor using DispatchQueue.main.async then there is no delay -- but the overall hang is the same. I simply explicitly told the update to wait instead of having it block on whatever it blocks. I also instrumented the subscript operator. It is not the cause of the delay, either.

The code is on github: https://github.com/marchyman/GeoTag in the branch macos14 for any that care to take a look.

Four months later... None of the changes I've made have done more than shave a few milliseconds off of a 3 and a half second hang. That is the approx wait after clicking a row for the selection to be fully processed with about 1000 items in the table. Each item is an Observable object. The Object has 8 or 9 observed properties. From a users perspective the blue bar of the selection shows immediately, then there is a hang, and finally the rest of the UI updates.

Almost all of the hang time is in __CFRunLoopDoObservers split between CFRunLoopRunSpecific and __CFRunLoopRun.

I've commented out much of the code and reduced the table to one column. With approx 1000 items in the table it takes about 900 msec to change the selected item. Measurment is between the time I click on the item and the UI is ready to process the next click. Instruments shows:

  • every time I select an item the foreach loop runs for every row in the table. That accounts for about 200 msec.
  • the remaining portion of the hang is in SwiftUI code. None of my instrumented code is called.

I don't know what SwiftUI is doing, but it is doing it slowly.

Well, that was interesting.

I started a new version of my project, bringing code over bit-by-bit and doing performance testing after every change. It boils down to this

Table(...) {
} rows: {
    ForEach(someArray) { item in
        TableRow(item)
    }
}

That worked fine. But I don't want to show every item. So I added this:

    ForEach(someArray) { item in
        if item.isValid {
            TableRow(item)
        }
    }

Checking the item when building the row breaks things badly. Now to figure out a workaround.