Lab 2.3 — Instruments profiling

Duration: ~90 minutes Difficulty: Intermediate Prereqs: Chapter 2.6 (Instruments), Lab 2.2 helpful

Goal

Use the Time Profiler and Allocations instruments to find and fix:

  1. A CPU hotspot that makes scrolling stutter
  2. A memory leak that grows over time as the user interacts with the app

Both bugs are deliberately introduced. The point is to practice the measure → diagnose → fix → re-measure loop, not to write performant code from scratch.

Setup — create the starter app

Create a new SwiftUI iOS app called SlowFeed. Replace the auto-generated files with:

SlowFeedApp.swift

import SwiftUI

@main
struct SlowFeedApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

ContentView.swift

import SwiftUI
import CryptoKit

@MainActor
final class FeedStore: ObservableObject {
    @Published var items: [FeedItem] = (0..<500).map { FeedItem(index: $0) }
}

struct FeedItem: Identifiable, Hashable {
    let id = UUID()
    let index: Int
    var title: String { "Item #\(index)" }
}

// Deliberately expensive avatar generation — runs on the main thread, every cell rebuild
func generateAvatar(seed: String) -> String {
    var data = Data(seed.utf8)
    // Bug: 50,000 rounds of SHA256 per cell. On the main thread. Every layout pass.
    for _ in 0..<50_000 {
        data = Data(SHA256.hash(data: data))
    }
    let prefix = data.prefix(2).map { String(format: "%02x", $0) }.joined()
    return "🎨\(prefix)"
}

struct AvatarView: View {
    let seed: String

    var body: some View {
        Text(generateAvatar(seed: seed))
            .font(.title)
            .frame(width: 44, height: 44)
            .background(.quaternary, in: Circle())
    }
}

// Deliberately leaky — stores closures keyed by item, never cleans up
final class LeakyCache {
    static let shared = LeakyCache()
    private var callbacks: [UUID: () -> Void] = [:]

    func register(_ id: UUID, callback: @escaping () -> Void) {
        callbacks[id] = callback
    }
}

struct ContentView: View {
    @StateObject private var store = FeedStore()

    var body: some View {
        NavigationStack {
            List(store.items) { item in
                HStack(spacing: 12) {
                    AvatarView(seed: item.id.uuidString)
                    VStack(alignment: .leading) {
                        Text(item.title).font(.headline)
                        Text("Tap to like").font(.caption).foregroundStyle(.secondary)
                    }
                    Spacer()
                }
                .onAppear {
                    // Bug: registers a self-capturing closure every time the cell appears
                    LeakyCache.shared.register(item.id) {
                        _ = item.title // captures item strongly forever
                    }
                }
            }
            .navigationTitle("Slow Feed")
        }
    }
}

Run on a Simulator (Release scheme for best signal — see Step 1 below). Scroll the list. You’ll feel the stutter immediately on an Apple Silicon Mac too — the avatar generator is that expensive.

Step 1 — Configure a Release-build profile (5 min)

This is critical. Profiling in Debug gives lies.

  1. Product → Scheme → Edit Scheme (⌘<)
  2. Profile action → Build Configuration → Release
  3. Close

Now ⌘I will build with Release optimizations and launch Instruments — closer to real production behavior.

Step 2 — Time Profiler: find the CPU hotspot (20 min)

  1. Choose a physical device if you have one (more representative). Otherwise Simulator is OK for this lab.
  2. ⌘I → choose Time Profiler template → click “Choose”
  3. Instruments launches with your app
  4. Click the Record button (red dot, top left)
  5. In the app, scroll the list quickly for ~10 seconds
  6. Click Stop

In the recorded trace:

  1. Bottom-left Call Tree panel:
    • Check “Invert Call Tree” (leafs at top — where the time is spent)
    • Check “Hide System Libraries” (drop noise)
  2. Sort by “Weight” descending
  3. The top entry should be generateAvatar(seed:) — likely > 80% of CPU time

Drill into the row → expand the children → you’ll see SHA256 hashing dominating.

The diagnosis

The function is called from AvatarView.body, which SwiftUI calls on the main thread every time the cell appears (and sometimes multiple times). Each call burns ~30ms on a real device. 60 fps requires < 16.6ms per frame. We’re spending 2 frames per cell just on avatar generation.

The fix

Two changes:

  1. Cache the result — avatars don’t change for the same seed
  2. Compute off-main-thread if not cached, with a placeholder while loading
// Add a global cache (or @MainActor singleton; this is for simplicity)
actor AvatarCache {
    static let shared = AvatarCache()
    private var cache: [String: String] = [:]

    func avatar(for seed: String) async -> String {
        if let cached = cache[seed] { return cached }
        let generated = await Task.detached(priority: .userInitiated) {
            generateAvatar(seed: seed)
        }.value
        cache[seed] = generated
        return generated
    }
}

struct AvatarView: View {
    let seed: String
    @State private var avatar: String = "⏳"

    var body: some View {
        Text(avatar)
            .font(.title)
            .frame(width: 44, height: 44)
            .background(.quaternary, in: Circle())
            .task(id: seed) {
                avatar = await AvatarCache.shared.avatar(for: seed)
            }
    }
}

(For a real app, you’d want to drop the cell-side task(id:) for a more SwiftUI-idiomatic approach with Observable models, but for the lab this demonstrates the pattern.)

Profile again (⌘I → record → scroll → stop). The top of the inverted call tree should no longer feature generateAvatar significantly on subsequent scrolls (only on first appearance per seed).

CPU hotspot fixed. Scrolling is smooth.

Step 3 — Allocations: find the leak (25 min)

  1. ⌘I → choose Allocations template → “Choose”
  2. Click Record
  3. In the app, scroll down through all 500 items (so every cell appears at least once)
  4. Scroll back to top
  5. Scroll down again
  6. Click Stop

Now in the trace:

  1. The top-right table shows allocations by category
  2. Look at “All Heap & Anonymous VM” in the bottom panel — note the trend: memory grows monotonically as you scroll
  3. Click the Mark Generation flag icon at the top before a scroll, then again after — you’ve recorded a “diff”
  4. Click the generation row in the bottom panel; the right inspector shows what was newly allocated and still alive
  5. You’ll see hundreds of FeedItem and closures still alive — far more than the visible cell count

Diagnosis with the Memory Graph

For the what is retaining what? question, switch tools:

  1. Stop Instruments
  2. Back in Xcode, run the app
  3. Scroll all 500 items
  4. Click Debug Memory Graph in Xcode’s debug bar
  5. Search the left sidebar for LeakyCache
  6. Click it → the graph shows a dictionary with 500 entries, each a closure capturing a FeedItem
  7. The closures never get released because LeakyCache.shared.callbacks never removes them

The fix

Two options:

Option A (best): remove the onAppear registration entirely; we don’t actually need it.

Option B: bound the cache.

final class LeakyCache {
    static let shared = LeakyCache()
    private var callbacks: [UUID: () -> Void] = [:]
    private let maxEntries = 50

    func register(_ id: UUID, callback: @escaping () -> Void) {
        callbacks[id] = callback
        if callbacks.count > maxEntries {
            // Evict an arbitrary old entry
            if let first = callbacks.keys.first { callbacks.removeValue(forKey: first) }
        }
    }
}

Or remove the onAppear block entirely (the cleanest fix — the bug is that we register callbacks we never use).

Re-run Allocations. Memory should now stay bounded as you scroll.

Memory leak fixed.

Step 4 — Add os_signpost instrumentation (15 min)

Add named regions to your code so future profiling sessions get rich timeline annotations:

import os

let signposter = OSSignposter(subsystem: "com.example.SlowFeed", category: "Avatars")

extension AvatarCache {
    func avatar(for seed: String) async -> String {
        let id = signposter.makeSignpostID()
        let interval = signposter.beginInterval("avatar", id: id, "seed: \(seed.prefix(8))")
        defer { signposter.endInterval("avatar", interval) }

        if let cached = cache[seed] { return cached }
        let generated = await Task.detached(priority: .userInitiated) {
            generateAvatar(seed: seed)
        }.value
        cache[seed] = generated
        return generated
    }
}

Profile with Time Profiler again. In the timeline, click “+” at top-right → add the Points of Interest instrument. Your avatar intervals appear as a band on the timeline. Future you (or your teammate) can now see exactly when avatar generation happens, correlated with CPU spikes.

Step 5 — Verify and re-measure (15 min)

For each fix, measure before and after and write down the numbers. Sample template:

MetricBeforeAfter
Time in generateAvatar (Time Profiler)~85% of frame time< 5% (cached)
Memory after 500 scrolls (Allocations)grows ~2 MBbounded
Frame hitches (Animation Hitches)many0

If you can attach a physical device, run Animation Hitches as well; record before/after on the same device.

Validation checklist

  • Bugs were reproducible in the starter app
  • Time Profiler trace recorded; generateAvatar identified as the hotspot
  • CPU fix applied; re-measured shows hotspot gone
  • Allocations trace recorded; growth confirmed
  • Memory Graph Debugger used to confirm LeakyCache retention
  • Leak fix applied; re-measured shows bounded growth
  • os_signpost instrumentation added; visible in Points of Interest
  • Numbers recorded before/after each fix

Stretch goals

  1. Cold launch budget — Add os_signpost for app startup; profile with App Launch template; measure cold launch time; set a budget (< 400 ms on your test device).
  2. Animation Hitches profile — Run the Animation Hitches template before and after the avatar fix. Confirm hitches went from many to zero.
  3. Energy Log — Run the Energy Log template for 60 seconds of usage; record energy impact. Identify the highest-energy subsystem.
  4. CI gating — Write a script that fails the build if xctrace export shows the avatar function exceeding a threshold. (Advanced — but this is the pattern senior teams use.)

What you’ve internalized

  • The Release-build discipline for profiling
  • The inverted call tree pattern for finding CPU hotspots
  • Mark Generation snapshots for measuring “what should be freed but isn’t”
  • The Memory Graph Debugger as the complement to Allocations for retention analysis
  • os_signpost as the way to add app-specific annotations to perf traces
  • The measure → fix → re-measure loop that defines professional perf work

Phase 2 complete

You’ve now built the Xcode-mastery skill set: navigation, project structure, build settings, debugging, profiling, device strategy, version management, and Apple’s CI option. With these skills, you can join any iOS team and be productive in the build-and-debug loop on day one.

Next: Phase 3 — Foundation & Core Frameworks (coming up).