Profile, fix, and verify: Improve app responsiveness with Instruments
Use Instruments 27 to diagnose app hangs with Time Profiler, Top Functions, Run Comparisons, Swift executors, and System Trace.
TL;DR
- Start responsiveness investigations with Time Profiler, profile a release build, and decide whether the main thread is CPU-saturated or blocked while idle.
- Use OS signpost intervals, Top Functions, flame graphs, and Run Comparisons to isolate scattered CPU overhead and verify fixes across traces.
- Use the Swift executors instrument to find Main Actor congestion; move non-UI async work to the global executor with
@concurrentwhen safe. - Use System Trace and the Inspector to diagnose low-CPU hangs caused by synchronous system calls such as large file writes on the main thread.
Diagnostic flow for hangs and frame drops
The session presents a practical responsiveness workflow: capture a release-profile trace, identify the affected time interval, inspect main-thread CPU behavior, then choose the right Instruments view for the symptom.
High CPU on the main thread means the app is executing work that takes too long. Fixes are either algorithmic optimization or moving unavoidable work off the UI path. Low CPU during a UI freeze usually means the main thread is blocked waiting on a resource such as file I/O, a lock, or IPC; Time Profiler alone cannot explain those waits.
- Profile from Xcode with Product > Profile so Instruments records a release build rather than a debug build.
- Use the Swift Concurrency template when the app uses Swift Concurrency; it still includes Time Profiler.
- Add
os_signpostintervals around user-visible workflows so traces and run comparisons can be filtered to equivalent regions. - Use Time Profiler for active CPU work, Swift executors for actor/executor contention, and System Trace for blocked or off-core thread time.
Add a signpost interval around lasso selection
Signpost intervals appear in the Points of Interest track and provide reliable ranges for filtering and comparing profiling data.
import os.signpost
let signposter = OSSignposter(subsystem: "Demo App", category: .pointsOfInterest)
var lassoIntervalState: OSSignpostIntervalState? = nil
func lassoSelectionUpdated() {
lassoIntervalState = signposter.beginInterval("Lasso Selection")
// Update selection in canvas...
}
func lassoSelectionEnded() {
// Finalize lasso selection...
signposter.endInterval("Lasso Selection", lassoIntervalState!)
}Analyze CPU saturation with Time Profiler
Time Profiler samples call stacks at regular intervals, producing a call tree where weight represents how often a function appears in samples and self weight represents time spent executing directly in that function. Instruments can render the same sampling data as an outline call tree, a flame graph, or Top Functions.
Flame graphs are useful for scanning expensive call paths, but costs for widely used functions can be split across many branches. Top Functions removes the call hierarchy, merges scattered nodes, and sorts functions by self weight, making hidden hotspots easier to spot.
- In the lasso-selection example, the main thread stayed near 100% CPU, indicating expensive code rather than system blocking.
- Top Functions identified
swift_project_boxed_opaque_existentialas the largest self-time contributor. - The fix was to reduce existential overhead in drawing code by using concrete types and generics where performance mattered.
- Run Comparisons verified the optimized trace against the baseline over the same signposted interval; green marks improvements and red marks regressions or newly introduced work.
Existential parameter with runtime overhead in a hot path
An existential can hold any conforming type, but accessing the underlying value may require runtime work that can matter in tight loops.
protocol Foo { }
struct TypeA: Foo { }
struct TypeB: Foo { }
func bar(_ foo: any Foo) { }Alternatives that give the compiler more type information
Concrete overloads, generics, and enums can avoid some existential costs and enable stronger optimization in performance-critical code.
protocol Foo { }
struct TypeA: Foo { }
struct TypeB: Foo { }
func bar(_ a: TypeA) { }
func bar(_ b: TypeB) { }
func bar<T: Foo>(_ generic: T) { }
enum FooValue {
case a(TypeA)
case b(TypeB)
}
func bar(_ value: FooValue) { }Find executor contention with the Swift executors instrument
The Swift executors instrument visualizes the Main Actor, the global concurrent executor, and custom executors. It helps connect UI responsiveness problems to Swift tasks running on specific executors.
In the scrolling example, several renderThumbnail tasks ran for hundreds of milliseconds on the Main Actor. Because the code was called from SwiftUI, it inherited the Main Actor context, causing thumbnail rendering to compete with UI updates and interactions.
- Filter to the hang, then inspect the Main Actor and main-thread CPU usage.
- If the main thread is busy and long-running tasks appear on the Main Actor, move non-UI work off the actor rather than letting it compete with UI events.
- Adding
@concurrentto the task body routes the work to the global executor; the Swift compiler checks for race-safety issues introduced by the change. - The updated trace showed thumbnail rendering moving from the Main Actor track to the global executor track, enabling parallel rendering and preventing UI hangs.
Thumbnail rendering inherited by the Main Actor
Because the task is created from Main Actor-isolated code, the work can remain on the Main Actor and block UI progress.
let drawingData = note.drawingData
let canvasImages = note.decodeCanvas()
thumbnail = await Task(name: "Render Thumbnail") {
await renderThumbnail(
drawingData: drawingData,
canvasImages: canvasImages,
size: CGSize(width: 300, height: 240)
)
}.valueMove thumbnail rendering off the Main Actor
@concurrent moves the task body to the global executor so CPU-heavy non-UI work does not monopolize the Main Actor.
let drawingData = note.drawingData
let canvasImages = note.decodeCanvas()
thumbnail = await Task(name: "Render Thumbnail") { @concurrent in
await renderThumbnail(
drawingData: drawingData,
canvasImages: canvasImages,
size: CGSize(width: 300, height: 240)
)
}.valueDiagnose low-CPU hangs with System Trace
A UI hang with low main-thread CPU indicates the thread may be blocked, not slow. System Trace shows thread states, system calls, on-core execution, off-core blocking, and runnable time after a resource becomes available.
In the save workflow, the main thread used only about 20% CPU during a micro-hang. System Trace showed a large write system call with translucent off-core segments, and the Inspector revealed a write of more than 1.7 GB that took over 500 ms, with almost 300 ms spent waiting for disk.
- Use System Trace when Time Profiler shows little CPU activity during a hang.
- Pin the main thread in the Inspector and inspect activity lanes for blank/off-core time.
- Select syscall intervals to see their full duration across active and blocked portions.
- Use Inspector details such as syscall arguments, file descriptor, buffer address, size, and timing to identify synchronous blocking work.
Synchronous file save on the main thread
The atomic data.write call blocks the caller until storage responds; on the main thread this freezes UI interactions.
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
guard let data = try? encoder.encode(snapshots) else { return }
let id = signposter.beginInterval("Writing To File")
try? data.write(to: fileURL, options: .atomic)
signposter.endInterval("Writing To File", id)Move encoding and file I/O to a concurrent task
Running the save work on the concurrent thread pool removes the write syscall from the main thread and unblocks the Main Actor.
Task { @concurrent in
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
guard let data = try? encoder.encode(snapshots) else { return }
let id = signposter.beginInterval("Writing To File")
try? data.write(to: fileURL, options: .atomic)
signposter.endInterval("Writing To File", id)
}Verification and practical takeaways
The workflow is iterative: profile, narrow the interval, form a hypothesis from the right instrument, fix the code, then verify with another trace. Instruments 27 improves that loop with the Inspector, Top Functions, Swift executors visualization, and Run Comparisons saved in the document.
The final guidance is to match the tool to the symptom: Top Functions and Run Comparisons for CPU overload, Swift executors for actor congestion, and System Trace for idle or blocked threads.
- Always profile a release build for actionable performance data.
- Use signpost intervals to compare equivalent workflows across runs and reduce measurement noise.
- Treat new red functions in Run Comparisons carefully; they may be regressions or simply new code introduced by a refactor.
- Consult related material for deeper dives: CPU profiling with Instruments, Swift Concurrency adoption, hang analysis, and Swift generics.
Resources
Related Sessions
- What's new in Xcode 27
- Embracing Swift concurrency
- Optimize CPU performance with Instruments
- Analyze hangs with Instruments
- Embrace Swift generics
Build, deliver, and automate with Xcode Cloud
Set up Xcode Cloud builds, tests, TestFlight distribution, webhooks, and additional Git repositories directly from Xcode.
Discover container machines
Use Container machine in the container tool to create fast, persistent Linux environments on macOS for cross-platform development workflows.