You’ve migrated your code to Swift 6+ and Strict Concurrency. The compiler is happy, there are no warnings related to concurrency, and your app works as expected. However, some parts feel slow or you’re just looking to optimize the performance. Where to start?

Measurement is the foundation of performance improvements

You can only increase the performance if you know your baseline. Being able to properly measure your code is essential to be able to determine performance optimizations. How would you otherwise know something improved?

In this lesson, we’re going to look into Xcode Instruments for Swift Concurrency. I’ll give you some basic fundamental guidance along the way, since you might not all be familiar with Xcode Instruments. Eventually, you should be able to analyze your code using Xcode instruments and find potential areas of code improvements.

Note

Xcode instruments provides several performance insights. In this lesson, we’ll optimize a sample application step-by-step. In the next lesson, we’re going to look at reducing suspension points specifically.

Sample App: Gradient Wallpaper Generator

For this lesson, I created a sample application. It’s a gradient wallpaper generator that currently generates a fixed number of wallpapers when you press the button.

When you run the app in debug mode, you’ll notice that there’s no progress bar visible and the UI is blocked during the generation:

Yes, it really is that slow!

In the sample project, you’ll find two folders:

At any time, you can switch from the bad performance to the good performance by adjusting ConcurrencyPerformanceApp.swift:

@main
struct ConcurrencyPerformanceApp: App {
    var body: some Scene {
        WindowGroup {
            BadGradientsGeneratorView()
        }
    }
}

However, I encourage you to start with the bad example code and follow along this lesson for optimizations.

What to look for when doing performance optimizations

Before we dive into Xcode Instruments, I want you to know what to look for when you’re optimizing performance. In practice, most issues fall into a few common buckets:

Luckily, Xcode instruments allows us to indicate each of those and help us indicate where to apply performance improvements.

Profiling your app using instruments

Note

This is an advanced lesson, and Xcode instruments are not for beginners. It’s impossible for me to explain the fundamentals of instruments in this lesson, so I’m assuming you have basic knowledge of how they work.

You can start profiling your app using instruments by pressing CMD + I or Product → Profile. This will launch Xcode Instruments and, as long as your scheme is configured as default, run your app in release mode. Due to this, you’ll notice your app might perform slightly better compared to running a debug build.

Once the build completes, you’re asked to select an instrument template. These are predefined templates, and each template contains a set of instruments. These instruments give you insights into the performance of your app’s code.

In this lesson, we’re going to select the Swift Concurrency template. This will open up a new window, with a recording button in the top left corner.

On the lefthandside, we can see a few Swift Concurrency specific instruments:

Running our bad state application using Instruments

Let’s have a look at the current state of our application.

  1. Start recording inside instruments
  2. Press the Generate Wallpapers button

Here’s the result inside instruments:

There are a few things to point out:

These insights give us a few potential improvements:

The Swift Tasks instrument shows that our regenerateWallpapers method is the root cause. Let’s have a look at this method:

@MainActor
@Observable
final class BadGradientsGeneratorViewModel {
    let numberOfWallpapersToGenerate = 100
    var wallpapers: [Wallpaper] = []
    var isGenerating: Bool = false

    func regenerateWallpapers() {
        wallpapers.removeAll()
        isGenerating = true

        Task {
            for _ in 0..<numberOfWallpapersToGenerate {
                let generator = BadGradientWallpaperGenerator(
                    width: 640,
                    height: 360,
                    controlPointCount: 2,
                    nearestPointsToSample: 1
                )

                let image = generator.generate()
                wallpapers.insert(Wallpaper(image: image), at: 0)

                if self.wallpapers.count == self.numberOfWallpapersToGenerate {
                    self.isGenerating = false
                }
            }
        }
    }
}

While the compiler is happy, our build succeeds without warnings, we do have a problem in this code. The task inherits isolation context from BadGradientsGeneratorViewModel, which is attributed with @MainActor. But that’s not all! Our project’s default actor isolation is also set to MainActor, which means that our BadGradientWallpaperGenerator runs on the main actor by default:

/// This is not marked with nonisolated, so it runs on the main actor by default.
struct BadGradientWallpaperGenerator {
    // ...
}

Moving work away from the main actor

The first step you might consider is moving away from the main actor. We add nonisolated to our BadGradientWallpaperGenerator:

nonisolated BadGradientWallpaperGenerator {
    // ...
}

We introduce a separate actor to ensure wallpaper generation happens in the background:

actor WallpapersGenerator {
    func generate() -> Wallpaper {
        let generator = BadGradientWallpaperGenerator(
            width: 640,
            height: 360,
            controlPointCount: 2,
            nearestPointsToSample: 1
        )

        let image = generator.generate()
        return Wallpaper(image: image)
    }
}

And we rewrite our view model to make use this new generator:

@MainActor
@Observable
final class BadGradientsGeneratorViewModel {
    let numberOfWallpapersToGenerate = 100
    var wallpapers: [Wallpaper] = []
    var isGenerating: Bool = false

    let wallpapersGenerator = WallpapersGenerator()

    func regenerateWallpapers() {
        wallpapers.removeAll()
        isGenerating = true

        Task {
            for _ in 0..<numberOfWallpapersToGenerate {
                let wallpaper = await wallpapersGenerator.generate()
                wallpapers.insert(wallpaper, at: 0)

                if self.wallpapers.count == self.numberOfWallpapersToGenerate {
                    self.isGenerating = false
                }
            }
        }
    }
}

If we run our application now, we can see that our UI is at least no longer freezing:

This starts to look great, but it’s still taking a very long time to generate those wallpapers.

Let’s have a look into instruments:

We can see that our actor’s queue size never exceeds 1. This is because we’re running one task at a time. We did move work away from the main thread, which aligns with what we’ve seen in the video.

Running wallpaper generation in parallel

Let’s continue optimizing and run wallpaper generation in parallel. The first thing you might consider doing is introducing a task group:

func regenerateWallpapers_v3() {
    wallpapers.removeAll()
    isGenerating = true

    Task {
        await withTaskGroup { group in
            for index in 0..<numberOfWallpapersToGenerate {
                group.addTask(name: "Wallpaper \(index)") {
                    let wallpaper = await self.wallpapersGenerator.generate()
                    return wallpaper
                }
            }

            for await wallpaper in group {
                wallpapers.insert(wallpaper, at: 0)

                if self.wallpapers.count == self.numberOfWallpapersToGenerate {
                    self.isGenerating = false
                }
            }
        }
    }
}

Note

I’m introducing _v3 here as you might noticed. This is because I’ve added all iterations individually to the sample code, so it’s easier for you to follow along.

Running this code optimization in instruments shows something interesting:

Let’s discuss:

  1. The number of Swift Tasks starts at a high number and lowers one-by-one
  2. The same happens for the actor. We start with a high queue, and we go down one-by-one
  3. We can see that the main thread gets activity one-by-one
  4. A background thread is doing some work one-by-one
  5. Each wallpaper gets generated one-by-one

Neat little tip: Notice that our custom name "Wallpaper \(index)" allows us to easily indicate the tasks inside instruments.

You might guess here, but the one-by-one is crucial here. Even though we’ve optimized for parallelization, we still don’t generate wallpapers simultaneously. This is caused by the fact that we’re still using an actor in-between:

actor WallpapersGenerator {
    func generate() -> Wallpaper {
        let generator = BadGradientWallpaperGenerator(
            width: 640,
            height: 360,
            controlPointCount: 2,
            nearestPointsToSample: 1
        )

        let image = generator.generate()
        return Wallpaper(image: image)
    }
}

I introduced this actor on purpose to better demonstrate you the iteration of performance optimizations. Sometimes a solution looks like a good way to go, but instruments will tell you it’s not.

Optimizing parallel execution by removing the actor

The fun fact is: we don’t even need an actor anymore. There’s no shared mutable state inside the WallpapersGenerator, so it’s safe to say we can run without it. There are a few alternatives to consider:

I prefer to go with the latter, so I introduce a new wallpaper generator:

struct ConcurrentWallpaperGenerator {
    @concurrent
    static func generate() async -> Wallpaper {
        let generator = BadGradientWallpaperGenerator(
            width: 640,
            height: 360,
            controlPointCount: 2,
            nearestPointsToSample: 1
        )

        let image = generator.generate()
        return Wallpaper(image: image)
    }
}

Note the use of @concurrent here so we switch away from the @MainActor isolation domain.

We can also simplify our earlier code by removing the task group. A task group would be helpful in case we want to handle group cancellation or reduce the result. However, for our current case, a simple task will do:

func regenerateWallpapers_v4() {
    wallpapers.removeAll()
    isGenerating = true

    for index in 0..<numberOfWallpapersToGenerate {
        Task(name: "Wallpaper \(index)") {
            let wallpaper = await ConcurrentWallpaperGenerator.generate()

            self.wallpapers.insert(wallpaper, at: 0)

            if self.wallpapers.count == self.numberOfWallpapersToGenerate {
                self.isGenerating = false
            }
        }
    }
}

The result of all these performance improvements is quite impressive:

Summary

While your code might be compiling successfully without any warnings, you can still easily make mistakes that reduce the performance of your app. It’s essential to look at main thread usage, parallelization, and actor optimization. If done right, you can drastically improve your app’s performance.

Note

A small teaser: We can improve the performance even more. You’ll find out later in this course module!

In the next lesson, we’re going to look into reducing suspension points.