Seven seconds to render SwiftUI

Seven (7) seconds to render a single Text box with "Hello World!" on an iMac Retina 5K, 27-inch, with only one widget changing and measuring time with this code...

func timeScreenUpdate() {
    Task {
        while true {
            let start = DispatchTime.now()
            status = "Hello World!"
            let end = DispatchTime.now()
            let nanoTime = end.uptimeNanoseconds - start.uptimeNanoseconds
            let timeInterval = Double(nanoTime) / 1_000_000_000
            print(timeInterval) // in seconds!
            await Task.yield()
        }
    }
}

Is this because SwiftUI is runs on only one thread?

The widget is not shown, is it a view with some ~1500 subviews? Probably too much to chew for "normal" rendering of SwiftUI. Check out the "drawing group" from this wwdc video. Looks relevant to your case.

Or you may reimplement this view as a single with path / shape. Or even a custom drawn image (CGBitmapContext → CGImage → UIImage → Image). If I had to guess - the last method is probably the most performant.

Also check out the new charts view.

It's unclear to me whether this is even a valid method of measuring anything, much less SwiftUI rendering speed. I suggest you use the SwiftUI instrument in Instruments to get a much more accurate picture of your rendering performance and optimize from there.

1 Like

I did a quick test to draw random colored rects (like TV noise):

1000 views →  ~100 fps
10,000 views →  ~10 fps
100,000 views →  ~1 fps

Given this, from about a couple thousand views I'd probably switch to some custom drawing.

This discussion would be more on-topic over at the Apple developer forums.

Good advice, but I'll bet no one from Apple responds.

SwiftUI, concurrency, responsive, or not.

I created a test that renders 1000 x 1000 random coloured pixels (1MP) into a bitmap context, and converts that to CGImage → UIImage → Image to render the final image in SwiftUI → getting 120fps out of it with no issue. For 10MP → fps dropped down to 30fps, and for 100MP → down to 3fps.

I haven't seen your view so I can only assume it has many subviews in it (like a subview per ... graph point?) - in which case that would be expectably too slow.

Other potential slowness is diffable machinery of SwiftUI - that would also go once you change your rendering code for the graph to use one big view.

Single / multi threaded doesn't play much role here. Just imagine that instead of 7 seconds per frame it takes 1 second per frame (somehow using 8 cores for rendering) - still too slow. The app must render at least 30/60fps to be usable and it is achievable even if everything is done on the main thread.

There's a more heavy machinery - Metal, however if 120fps for 1MP graph is good enough for you - that heavy machinery is not needed.

Thanks, but it's not the graph that's slowing things down. The graph (a realtime RF spectrum) is very fast when run on its own in a standalone test app. The screen grab here is running without real-time data - except for the repeating "Hello World" text.

I think the issue is having 70 Buttons. I don't see how I could have interactive Buttons (that can change text and/or colour) on a pre-rendered CGImage, but I'm interested to know if that's possible.

A better screen grab is posted on the Developer forum. Link in previous post.

I see, I misinterpreted your question then.

A test with 100 simple buttons on top of the image that renders 1M random coloured pixels works fast for me (no fps slowdown → 120fps). 1000 buttons → 30fps. 10K buttons → 2fps.

You may comment out parts of your program to figure out what takes so much.

I'm listening, but not understanding. Please could you post your test code, or ZIP it and message to me.

I just tried commenting out various sub-views - right down to this...


And I still can't quit without pressing Command-Q. What's left of the root view is being redrawn about 4 times per second - driven by the speed of the incoming data.

This is interesting and probably reveals how little I know about concurrency.

Looks like async/await/yield related issue in your code. Something is blocking main thread.

Simplified example that works fast for me (but it is not doing much).
import SwiftUI

private var startTime = CFAbsoluteTimeGetCurrent()
private var bodyCount = 0

class Model: ObservableObject {
    @Published var infoString = " "
    @Published var changeCount = 0
    
    init() {
        Timer.scheduledTimer(withTimeInterval: 1/200, repeats: true) { [self] _ in
            change()
        }
    }
    
    func change() {
        changeCount += 1
    }
}
    
struct ContentView: View {
    @StateObject private var model = Model()
    
    var body: some View {
        
        bodyCount += 1
        let currentTime = CFAbsoluteTimeGetCurrent()
        if currentTime - startTime >= 2 {
            let fps = Double(bodyCount) / (currentTime - startTime)
            let s = String(format: "%.1f", fps)
            model.infoString = "body call rate is \(s) (this is >= fps)"
            print("body call rate is \(s), this is the upper bound for fps (fps is usually limited to 60 or 120 fps).")
            print(model.infoString)
            bodyCount = 0
            startTime = currentTime
        }
        let buttonsPerRow = 10
        let buttonsRows = 10

        return ZStack {
            VStack {
                Text(model.infoString).font(.title)
                
                ForEach(0 ..< buttonsRows) { j in
                    HStack {
                        ForEach(0 ..< buttonsPerRow) { i in
                            let buttonTitle = "Button " + String(j*buttonsPerRow + i)
                            Button(buttonTitle) {
                                print("button \(buttonTitle) pressed")
                            }
                        }
                    }
                }
            }
            .padding()
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

For the FPS calculation I didn't use your code (which is too dodgy btw!). I used body count per second (which is still a bit dodgy as it doesn't reflect the true FPS). I don't know how to calculate the true FPS in the app (other than perhaps via some external means like instruments).

I'd also highly recommend to "litter" (in a good way) the app with dispatchPrecondition calls so you know with absolute certainty that "at this point in the app I am on the main thread" / "at this point I am off the main thread" and "at this point I am on a given queue". I used it before when it was in its assert(!Thread.isMain) form and I use it now in its current modern form - it's an eye opener.

BTW... if you reduce your app to demonstrate the issue is not SwiftUI specific → perhaps change the subject and maybe we can enlist this topic again to help people avoid a similar (supposedly concurrency programming) mistake.

Well, you've certainly opened my eyes. I started this project a year ago and decided to learn Swift - thinking it would easy. It was hard enough before they introduced concurrency, but now...

I find Apple's documentation to be unhelpful, so I scour the Internet searching for clues. Unfortunately, most clues are copy pasted from other copy-pasters who copied from an old WDDC video - 99% of which written written for the IOS crowd. Am I'm the only person on the planet attempting to build for the desktop? [retorical]

However, most of my posts on this forum do attract attention - and I'm humbly grateful for this.

I began "concurrency-fying" the 2 linux servers last week. They are companion to this project, so now it's obvious the UI app will also need a big rethink. If only there were some appropriate examples for booting and closing - as in myappApp.swift and @main in linux, and exactly where the networks code belongs.

The old school way was to only expose dependencies to the consumers needing them - as in a hierarchical tree. But now, I'm beginning to think all the classes, structs and actors should be instantiated horizontally - and so facilitate sideway interaction in both directions.

If you can spare the time, I would love to hear your views on this.

Thanks for sharing the code. I'm on Monterey 12,4 with Xcode 13.4.1, so maybe you don't see this compile warning.

ForEach (0 ..< buttonsRows) { j in
// Non-constant range: argument must be an integer literal

However, I'm only getting 6 frames / second.

That's for my example?! Wow, I thought my 7 years old 15 inch MacBook Pro is slow! I am getting 60/120 fps (not sure what my system is actually giving me) with the body count per second reported just under 200. And interestingly the app works much faster on a 3 years old iPhone XS Max. What kind of computer you've got?

Yep, I haven't updated to Monterey yet, so have to use Xcode 13.2 for now.

I see, slightly newer / faster than mine, you should get a similar 200 fps reported via bodyCount. Just to be sure - that's for my example unmodified? Release build? The app is running in foreground? (this matters!). Anything else suspicious running on the machine?