SKILL.md
---
name: axiom-performance
description: Use when app feels slow, memory grows, battery drains, or diagnosing ANY performance issue. Covers memory leaks, profiling, Instruments workflows, retain cycles, performance optimization.
license: MIT
---
# Performance
**You MUST use this skill for ANY performance issue including memory leaks, slow execution, battery drain, or profiling.**
<!-- AXIOM_AUDITOR_INLINE_BEGIN — auto-maintained by scripts/build-inlined-auditors.ts; do not hand-edit -->
> **Not on Claude Code?** Where this router says "Launch `some-auditor` agent", read that auditor's file in this suite and follow it inline — the same procedure, needing only file search and read.
>
> Available here: `skills/energy-auditor.md`, `skills/memory-auditor.md`, `skills/swift-performance-analyzer.md`.
>
> Agents that need Bash — builds, tests, simulators, crash symbolication — stay Claude Code-only; there is no inline equivalent for those.
<!-- AXIOM_AUDITOR_INLINE_END -->
## When to Use
Use this router when:
- App feels slow or laggy
- Memory usage grows over time
- Battery drains quickly
- Device gets hot during use
- High energy usage in Battery Settings
- Diagnosing performance with Instruments
- Memory leaks or retain cycles
- App crashes with memory warnings
## Routing Logic
### Memory Issues
**Memory leaks (Swift)** → See skills/memory-debugging.md
- Systematic leak diagnosis
- 5 common leak patterns
- Instruments workflows
- deinit not called
**Memory leak scan** → Launch `memory-auditor` agent or `/axiom:audit memory` (5-phase semantic audit: maps resource ownership, detects 6 leak patterns, reasons about missing cleanup, correlates compound risks, scores lifecycle health)
**Memory leaks (Objective-C blocks)** → See skills/objc-block-retain-cycles.md
- Block retain cycles
- Weak-strong pattern
- Network callback leaks
### Performance Profiling
**Performance profiling (GUI)** → See skills/performance-profiling.md
- Time Profiler (CPU), incl. Top Functions mode for scattered overhead (`OS27`)
- Allocations (memory growth)
- Core Data profiling (N+1 queries)
- Decision trees for tool selection
- Instruments 27 Run Comparisons, Swift executors instrument, Foundation Models instrument; Xcode 27 Organizer (Storage/hitches metrics, Metric Goals, Generate Recommendations)
**Automated profiling (CLI)** → See skills/xctrace-ref.md
- Headless xctrace profiling
- CI/CD integration patterns
- Command-line trace recording
- Programmatic trace analysis
**Run automated profile** → Use `performance-profiler` agent or `/axiom:profile`
- Records trace via xctrace
- Exports and analyzes data
- Reports findings with severity
**Compare two traces / detect regressions** → See skills/trace-comparison.md or `/axiom:compare-traces`
- Did this change slow down a hot path? Function-level CPU-share deltas
- CI gating with `xcprof compare --fail-on-regression` (non-zero exit)
- Regressions vs improvements, severity ranking, exit-code semantics
### Hang/Freeze Issues
**App hangs or freezes** → See skills/hang-diagnostics.md
- UI unresponsive for >1 second
- Main thread blocked (busy or waiting)
- Decision tree: busy vs blocked diagnosis
- Time Profiler vs System Trace selection
- 8 common hang patterns with fixes
- Watchdog terminations
**Corpus/aggregate hang triage (Sentry, ASC)** → `axiom-shipping (skills/production-triage.md)` + `triage-analyzer` agent
- Multiple grouped hang reports from an aggregator, not a single .ips file
- Classify `anr_idle_runloop` vs `anr_main_thread_block` across the corpus
- Flag suspension/idle-runloop false-positives (the #1 hang by user count is often noise)
- Cluster into root-cause families and rank by impact
### App Launch
**Slow app launch** → See skills/app-launch.md
- Slow first frame, frozen first screen, launch regression in Organizer
- Launch-phase model (pre-main / main→first frame / extended launch)
- Cold vs warm vs hot/resume vs notification launch — how to reproduce each
- App Launch instrument workflow, `dyld Activity`, measurement hygiene
- Pre-main fixes: linkage strategy (static vs dynamic vs mergeable, `MERGED_BINARY_TYPE`/`MERGEABLE_LIBRARY`), `+load`, main-thread deferral, priority inversion
- `XCTApplicationLaunchMetric` regression test, `MXAppLaunchMetric` field histograms, custom "app is interactive" signpost
- Push-notification launch path (tap→first pixel / tap→interactive targets)
### Energy Issues
**Battery drain, high energy** → See skills/energy.md
- Power Profiler workflow
- Subsystem diagnosis (CPU/GPU/Network/Location/Display)
- Anti-pattern fixes
- Background execution optimization
**Symptom-based diagnosis** → See skills/energy-diag.md
- "App at top of Battery Settings"
- "Device gets hot"
- "Background battery drain"
- Time-cost analysis for each path
**API reference with code** → See skills/energy-ref.md
- Complete WWDC code examples
- Timer, network, location efficiency
- BGContinuedProcessingTask (iOS 26)
- MetricKit setup
**Energy scan** → Launch `energy-auditor` agent or `/axiom:audit energy` (8 anti-patterns: timer abuse, polling, continuous location, animation leaks, background mode misuse, network inefficiency, GPU waste, disk I/O)
### Timer Safety
**Timer crash patterns (DispatchSourceTimer)** → See `axiom-integration` (skills/timer-patterns.md)
- 4 crash scenarios causing EXC_BAD_INSTRUCTION
- RunLoop mode gotcha (Timer stops during scroll)
- SafeDispatchTimer wrapper
- Timer vs DispatchSourceTimer decision
**Timer API reference** → See `axiom-integration` (skills/timer-patterns-ref.md)
- Timer, DispatchSourceTimer, Combine, AsyncTimerSequence APIs
- Lifecycle diagrams
- Platform availability
### Swift Performance
**Swift performance optimization** → See skills/swift-performance.md
- Value vs reference types, copy-on-write
- ARC overhead, generic specialization
- Collection performance
**Swift performance scan** → Launch `swift-performance-analyzer` agent or `/axiom:audit swift-performance` (unnecessary copies, ARC overhead, unspecialized generics, collection inefficiencies, actor isolation costs, memory layout)
**Modern Swift idioms** → See axiom-swift (skills/swift-modern.md)
- Outdated API patterns (Date(), CGFloat, DateFormatter)
- Foundation modernization (URL.documentsDirectory, FormatStyle)
- Claude-specific hallucination corrections
### MetricKit Integration
**MetricKit API reference** → See skills/metrickit-ref.md
- New Swift-first API (`OS27`): MetricManager AsyncSequence streams, typed MetricResult metrics (incl. Metal frame rate, storage), typed diagnostics with termination categories, launch-task tracking
- Per-state metrics (StateReporting framework, `OS27`): split hitch/hang/memory metrics by tab, mode, or experiment
- Crash reporter extensions (CrashReportExtension framework, `OS27`): process crashes at crash time with in-extension symbolication (Part 10)
- MXMetricPayload / MXDiagnosticPayload parsing (legacy, iOS 13–26)
- Field performance data collection
- Integration with crash reporting
### Runtime Console Capture
**Capture simulator console output** → `/axiom:console`
- Capture print(), os_log(), Logger output from simulator
- Structured JSON with level, subsystem, category
- Bounded collection with `--timeout` and `--max-lines`
- Filter by subsystem or regex
### Runtime State Inspection
**LLDB interactive debugging** → See axiom-build (skills/lldb.md)
- Set breakpoints, inspect variables at runtime
- Crash reproduction from crash logs
- Thread state analysis for hangs
- Swift value inspection (po vs v)
**LLDB command reference** → See axiom-build (skills/lldb-ref.md)
- Complete command syntax
- Breakpoint recipes
- Expression evaluation patterns
## Decision Tree
1. Memory climbing + UI stutter/jank? → memory-debugging FIRST (memory pressure causes GC pauses that drop frames), then performance-profiling if memory is fixed but stutter remains
2. Memory leak (Swift)? → memory-debugging
3. Memory leak (Objective-C blocks)? → objc-block-retain-cycles
4. App hang/freeze — is UI completely unresponsive (can't tap, no feedback)?
- YES → hang-diagnostics (busy vs blocked diagnosis)
- NO, just slow → performance-profiling (Time Profiler)
- First launch only? → Also check for synchronous I/O or lazy initialization in hang-diagnostics
- Multiple grouped hang reports from Sentry/ASC (corpus, not single file)? → `axiom-shipping (skills/production-triage.md)` + `triage-analyzer`
5. Slowdown when multiple async operations complete at once? → Cross-route to `axiom-concurrency` (callback contention, not profiling)
6. Slow app launch / slow first frame / launch regression / slow after push tap? → app-launch
7. Battery drain (know the symptom)? → energy-diag
8. Battery drain (need API reference)? → energy-ref
9. Battery drain (general)? → energy
10. MetricKit setup/parsing? → metrickit-ref
10a. Metrics split by app state (per-tab hitch rate, experiment arms) or StateReporting? → metrickit-ref (Part 1)
10b. Profiling agentic/LLM features (Foundation Models instrument, token metrics)? → performance-profiling, then axiom-ai
10c. Building a crash reporter extension (process crashes at crash time)? → metrickit-ref (Part 10)
11. Profile with GUI (Instruments)? → performance-profiling
12. Profile with CLI (xctrace)? → xctrace-ref
13. Run automated profile now? → performance-profiler agent
14. General slow/lag? → performance-profiling
14a. Slow GRDB/SQLite queries (EXPLAIN QUERY PLAN, index design, cursors)? → See axiom-data (skills/grdb-performance.md)
15. Want proactive memory leak scan? → memory-auditor (Agent)
16. Want energy anti-pattern scan? → energy-auditor (Agent)
17. Want Swift performance audit (ARC, generics, collections)? → swift-performance-analyzer (Agent)
18. Need to inspect variable/thread state at runtime? → See axiom-build (skills/lldb.md)
19. Need exact LLDB command syntax? → See axiom-build (skills/lldb-ref.md)
20. Timer stops during scrolling? → timer-patterns (RunLoop mode)
21. EXC_BAD_INSTRUCTION crash with DispatchSourceTimer? → timer-patterns (4 crash patterns)
22. Choosing between Timer, DispatchSourceTimer, Combine timer, async timer? → timer-patterns
23. Need timer API syntax/lifecycle? → timer-patterns-ref
24. Code review for outdated Swift patterns? → swift-modern
25. Claude generating legacy APIs (DateFormatter, CGFloat, DispatchQueue)? → swift-modern
26. Need to see runtime console output before profiling? → axiom-tools (skills/xclog-ref.md) or `/axiom:console`
27. Have an `.ips`, MetricKit, or legacy `.crash` text file to symbolicate/triage? → axiom-tools (skills/xcsym-ref.md) or `/axiom:analyze-crash`
## Anti-Rationalization
| Thought | Reality |
|---------|---------|
| "I know it's a memory leak, let me find it" | Memory leaks have 6 patterns. memory-debugging diagnoses the right one in 15 min vs 2 hours. |
| "I'll just run Time Profiler" | Wrong Instruments template wastes time. performance-profiling selects the right tool first. |
| "Battery drain is probably the network layer" | Energy issues span 8 subsystems. energy skill diagnoses the actual cause. |
| "App feels slow, I'll optimize later" | Performance issues compound. Profiling now saves exponentially more time later. |
| "It's just a UI freeze, probably a slow API call" | Freezes have busy vs blocked causes. hang-diagnostics has a decision tree for both. |
| "Memory is climbing AND scrolling stutters — two separate bugs" | Memory pressure causes GC pauses that drop frames. Fix the leak first, then re-check scroll performance. |
| "It only freezes on first launch, must be loading something" | First-launch hangs have 3 patterns: synchronous I/O, lazy initialization, main thread contention. hang-diagnostics diagnoses which. |
| "Launch feels slow — I'll trim some startup code" | Launch has 3 phases (pre-main / main→first frame / extended) and a watchdog. app-launch tells you which phase to profile, with measurement hygiene so the number means something. |
| "Launch is fine, it's fast on my phone" | Measure on your oldest supported device with a Release build. app-launch has the full hygiene checklist — newest-device numbers hide the regression. |
| "Resume from the app switcher is slow too" | Resume isn't a launch — never measure it as one. app-launch distinguishes cold/warm/hot/notification and how to reproduce each. |
| "Too many frameworks — I'll just statically link everything" | Static copies the library into every binary that links it (app + each extension), duplicates its global state, and breaks on Obj-C categories without `-ObjC`. app-launch has the static/dynamic/mergeable tradeoff and the `DYLD_PRINT_STATISTICS` check that tells you whether pre-main is even your problem. |
| "UI locks up when network requests finish — that's slow" | Multiple callbacks completing at once = main thread contention = concurrency issue. Cross-route to axiom-concurrency. |
| "I'll just add print statements to debug this" | Print-debug cycles cost 3-5 min each (build + run + reproduce). An LLDB breakpoint costs 30 seconds. axiom-build (skills/lldb.md) has the commands. |
| "I can't see what the app is logging" | xclog captures print() + os_log from the simulator with structured JSON. `/axiom:console`. |
| "I'll hand-parse this .ips JSON to see the top frame" | xcsym parses, discovers dSYMs, symbolicates, and categorizes in one call — structured JSON with pattern_tag. `/axiom:analyze-crash`. |
| "I'll just use Timer.scheduledTimer, it's simpler" | Timer stops during scrolling (`.default` mode), retains its target (leak). timer-patterns has the decision tree. |
| "DispatchSourceTimer crashed but it's intermittent, let's ship" | DispatchSourceTimer has 4 crash patterns that are ALL deterministic. timer-patterns diagnoses which one. |
| "Claude already knows modern Swift" | Claude defaults to pre-5.5 patterns (Date(), CGFloat, filter().count). swift-modern has the correction table. |
| "MetricKit is that old MX delegate API" | The 27 cycle rebuilt it: MetricManager AsyncSequence streams, typed metrics, per-state aggregation. metrickit-ref Part 1 has the new surface. |
| "My field metrics are one blended average, can't tell which screen is slow" | StateReporting splits every metric by app state you define (per-tab, per-experiment). metrickit-ref Part 1. |
| "No single function is hot, so the profile is useless" | Scattered overhead (dynamic dispatch, retain/release, existentials) hides in flame graphs. Top Functions mode merges it. performance-profiling. |
## Critical Patterns
**Memory Debugging** (memory-debugging):
- 6 leak patterns: timers, observers, closures, delegates, view callbacks, PhotoKit
- Instruments workflows
- Leak vs caching distinction
**Performance Profiling** (performance-profiling):
- Time Profiler for CPU bottlenecks
- Allocations for memory growth
- Core Data SQL logging for N+1 queries
- Self Time vs Total Time
**Energy Optimization** (energy):
- Power Profiler subsystem diagnosis
- 8 anti-patterns: timers, polling, location, animations, background, network, GPU, disk
- Audit checklists by subsystem
- Pressure scenarios for deadline resistance
## Example Invocations
User: "My app's memory usage keeps growing"
→ See skills/memory-debugging.md
User: "I have a memory leak but deinit isn't being called"
→ See skills/memory-debugging.md
User: "My app feels slow, where do I start?"
→ See skills/performance-profiling.md
User: "My Objective-C block callback is leaking"
→ See skills/objc-block-retain-cycles.md
User: "My app drains battery quickly"
→ See skills/energy.md
User: "Users say the device gets hot when using my app"
→ See skills/energy-diag.md
User: "What's the best way to implement location tracking efficiently?"
→ See skills/energy-ref.md
User: "Profile my app's CPU usage"
→ Use: `performance-profiler` agent (or `/axiom:profile`)
User: "How do I run xctrace from the command line?"
→ See skills/xctrace-ref.md
User: "I need headless profiling for CI/CD"
→ See skills/xctrace-ref.md
User: "My app hangs sometimes"
→ See skills/hang-diagnostics.md
User: "The UI freezes and becomes unresponsive"
→ See skills/hang-diagnostics.md
User: "Main thread is blocked, how do I diagnose?"
→ See skills/hang-diagnostics.md
User: "Triage my Sentry hangs" / "Which ANR reports are real blocks?"
→ See `axiom-shipping (skills/production-triage.md)` + `triage-analyzer` agent (or `/axiom:triage sentry`)
User: "My app takes 3 seconds to launch"
→ See skills/app-launch.md
User: "Xcode Organizer says my launch time regressed"
→ See skills/app-launch.md
User: "How do I reduce pre-main / dyld time?"
→ See skills/app-launch.md
User: "Should I use mergeable libraries / statically link my frameworks?"
→ See skills/app-launch.md
User: "App is slow to come up after tapping a push notification"
→ See skills/app-launch.md
User: "How do I write a launch performance test?"
→ See skills/app-launch.md
User: "How do I set up MetricKit?"
→ See skills/metrickit-ref.md
User: "How do I parse MXMetricPayload?"
→ See skills/metrickit-ref.md
User: "How do I use the new MetricManager / migrate off MXMetricManager?"
→ See skills/metrickit-ref.md (Part 1)
User: "Can I get hitch metrics per tab or per experiment?"
→ See skills/metrickit-ref.md (Part 1, StateReporting)
User: "How do I profile my Foundation Models / agentic feature?"
→ See skills/performance-profiling.md (Foundation Models instrument), then axiom-ai
User: "How do I compare two Instruments runs to verify a fix?"
→ See skills/performance-profiling.md (Run Comparisons) or skills/trace-comparison.md (CLI/CI)
User: "Scan my code for memory leaks"
→ Invoke: `memory-auditor` agent
User: "Check my app for battery drain issues"
→ Invoke: `energy-auditor` agent
User: "Audit my Swift code for performance anti-patterns"
→ Invoke: `swift-performance-analyzer` agent
User: "How do I inspect this variable in the debugger?"
→ Invoke: See axiom-build (skills/lldb.md)
User: "What's the LLDB command for conditional breakpoints?"
→ Invoke: See axiom-build (skills/lldb-ref.md)
User: "I need to reproduce this crash in the debugger"
→ Invoke: See axiom-build (skills/lldb.md)
User: "My list scrolls slowly and memory keeps growing"
→ See skills/memory-debugging.md first, then skills/performance-profiling.md if stutter remains
User: "App freezes for a few seconds on first launch then works fine"
→ See skills/hang-diagnostics.md
User: "UI locks up when multiple API calls return at the same time"
→ Cross-route: `/skill axiom-concurrency` (callback contention)
User: "My timer stops when the user scrolls"
→ Read: `axiom-integration` (skills/timer-patterns.md)
User: "EXC_BAD_INSTRUCTION crash in my timer code"
→ Read: `axiom-integration` (skills/timer-patterns.md)
User: "Should I use Timer or DispatchSourceTimer?"
→ Read: `axiom-integration` (skills/timer-patterns.md)
User: "How do I create an AsyncTimerSequence?"
→ Read: `axiom-integration` (skills/timer-patterns-ref.md)
User: "Review my Swift code for outdated patterns"
→ Invoke: See axiom-swift (skills/swift-modern.md)
User: "Is there a more modern way to do this?"
→ Invoke: See axiom-swift (skills/swift-modern.md)
User: "What is the app logging? I need to see console output"
→ Invoke: `/axiom:console`
User: "Capture the simulator logs while I reproduce this bug"
→ Invoke: `/axiom:console`
skills/app-launch.md
# App Launch Performance
Diagnose and fix slow app launch — from the moment the user taps the icon to the moment the first frame is interactive. The target Apple sets is **first frame within ~400 ms**, app interactive by the time the launch animation finishes. iOS runs a watchdog that *terminates* apps that overrun the launch budget.
This skill owns the launch-specific workflow. It cross-links — does not duplicate — Instruments/signpost mechanics (`performance-profiling`, `xctrace-ref`), `MXAppLaunchMetric` field data (`metrickit-ref`), and main-thread analysis (`hang-diagnostics`).
## Red Flags — Check This Skill When
| Symptom | This skill applies |
|---|---|
| App takes >1 s (new device) or >2 s (old device) to show its first screen | Yes |
| Launch is fine on your phone, slow on users' older phones | Yes — measure on the oldest supported device |
| First screen appears but is frozen for a moment before it responds | Yes — Phase 3 / extended launch |
| Xcode Organizer "Launches" pane flags a regression | Yes |
| App is slow to come up after tapping a push notification | Yes — notification-launch path |
| App is slow only when returning from the background (app switcher) | No — that's a *resume*, not a launch. Don't measure it as one. |
| App is responsive but generally sluggish during use | No → `performance-profiling` |
| UI is completely frozen mid-session | No → `hang-diagnostics` |
## Launch vs Resume — Get the Vocabulary Right
| Type | When | Cost | Reproduce |
|---|---|---|---|
| **Cold launch** | After reboot, or after the system evicted the app from memory | Highest, most variable | Restart device, wait ~30 s for boot work to settle, then launch |
| **Warm launch** | App relaunched soon after being force-quit; frameworks still cached in memory | Lower, more consistent — Apple recommends measuring this | Force-quit (swipe up in app switcher), wait ~5 s, launch |
| **Hot launch / resume** | User re-enters from app switcher or Home Screen; process still alive | Near-instant | Background the app, immediately return — **this is not a launch; never measure it as one** |
| **Notification launch** | User taps a push notification; a launch carrying a deep-link/action payload | Cold or warm + payload resolution | Background, send a push (`xcrun simctl push` or server), tap it |
## The Launch Phase Model
```dot
digraph launch {
rankdir=LR;
"icon tap" [shape=ellipse];
"Phase 1\npre-main" [shape=box];
"Phase 2\nmain → first frame" [shape=box];
"Phase 3\nfirst frame → interactive" [shape=box];
"icon tap" -> "Phase 1\npre-main" -> "Phase 2\nmain → first frame" -> "Phase 3\nfirst frame → interactive";
}
```
In Instruments these map to the **App Life Cycle timeline**: process initialization → UIKit initialization → UIKit initial scene rendering → initial frame rendering, plus the app-owned "extended" tail.
### Phase 1 — Pre-main (before your `main()` runs)
The dynamic loader (`dyld`) maps the executable, loads every linked framework/dylib, and resolves symbols. Then the runtime runs static initializers. Roughly 100 ms of fixed system work *plus* whatever your dependencies add.
What costs time here:
- **Number of dynamically-linked frameworks.** Each one adds dyld work. Built-in system frameworks (CoreFoundation, etc.) are nearly free (shared memory across processes); third-party embedded frameworks are not.
- **Static initializers that run before `main()`:** C++ static constructors, Objective-C `+load` methods, `__attribute__((constructor))` functions, and entries in `__DATA,__mod_init_func`.
- **Forced eager evaluation in Swift.** Swift global `let`/`var` and stored type properties are computed *lazily* on first access — they do **not** run pre-main. Pre-main Swift cost shows up only via Obj-C-interop `+load`, C/C++ constructors, or code you force to run eagerly.
- `dlopen` / `NSBundle.load` at launch — forfeits the dyld launch-closure win.
Measure it with the **`dyld Activity`** instrument (static-initializer timings) or the App Launch template's pre-main lanes.
### Phase 2 — main → first frame
System creates `UIApplication` and your delegate, then your code runs:
- UIKit (no scenes): `application(_:willFinishLaunchingWithOptions:)` → `application(_:didFinishLaunchingWithOptions:)` → create root view controllers here.
- UIKit (UIScene): `willFinish`/`didFinishLaunching` still fire, but **create root view controllers in `scene(_:willConnectTo:options:)`**, not in `didFinishLaunching`. Doing both is a common bug.
- SwiftUI: `App.init()` then `App.body` (`Scene`/root `View`). Heavy work in either blocks the first frame.
- Then layout + draw of the first frame's view hierarchy.
The launch cycle does not complete until your delegate methods *return*. Anything synchronous and slow here — disk I/O, network, decoding, big data loads — is straight-up launch time.
### Phase 3 — first frame → interactive (extended launch)
The launch metric stops at the first frame, but the user's experience doesn't. If your first frame has placeholders for async data, the app must already be interactive — and you should *measure* the extended tail yourself with signposts (and optionally `MXMetricManager.extendLaunchMeasurement(forTaskID:)` for field data).
## Decision Path
```dot
digraph decide {
"Slow launch?" [shape=diamond];
"Reproducible & clean?" [shape=diamond];
"Profile: App Launch template" [shape=box];
"Which phase dominates?" [shape=diamond];
"Fix Phase 1\n(dyld / static init)" [shape=box];
"Fix Phase 2\n(main → first frame)" [shape=box];
"Fix Phase 3\n(extended launch)" [shape=box];
"Lock down environment\n(reboot, release build,\nairplane mode, stable data,\noldest device)" [shape=box];
"Slow launch?" -> "Reproducible & clean?";
"Reproducible & clean?" -> "Lock down environment\n(reboot, release build,\nairplane mode, stable data,\noldest device)" [label="no"];
"Lock down environment\n(reboot, release build,\nairplane mode, stable data,\noldest device)" -> "Profile: App Launch template";
"Reproducible & clean?" -> "Profile: App Launch template" [label="yes"];
"Profile: App Launch template" -> "Which phase dominates?";
"Which phase dominates?" -> "Fix Phase 1\n(dyld / static init)" [label="pre-main"];
"Which phase dominates?" -> "Fix Phase 2\n(main → first frame)" [label="didFinishLaunching /\nApp.init / root view"];
"Which phase dominates?" -> "Fix Phase 3\n(extended launch)" [label="first frame → interactive"];
}
```
## Triage Without Instruments (deadline mode)
You do **not** need an Instruments session to start — a code-review pass of the launch path is a legitimate first move, and there's a zero-setup pre-main breakdown:
- **`DYLD_PRINT_STATISTICS=1`** (Xcode → Edit Scheme → Run → Arguments → Environment Variables) prints Phase-1 timings to the console at launch — total pre-main time, dylib loading, rebase/bind, ObjC setup, initializers — with no Instruments needed. If pre-main is small (~100–200 ms), the problem is your code (Phase 2/3) — go to the next bullet. If it's large, the cause is framework count and/or `+load` work — that's usually *not* a safe same-night fix (file it for the next release) so pivot to Phase 2 anyway for the quick win.
- **Read the launch path directly:** `application(_:didFinishLaunchingWithOptions:)` / `scene(_:willConnectTo:options:)` / `App.init()` / `App.body` / root `viewDidLoad` / first `View.body`. Walk the "Fixes by Phase → Phase 2" list below as a checklist — analytics/SDK init, synchronous network, `SELECT *` at launch, `ModelContainer`/`@Query` setup, heavy view hierarchy, priority inversions.
- **Bisect on a real device:** comment out SDK initializers one at a time, watch `DYLD_PRINT_STATISTICS` + the launch console log.
- **Verify the win** with the `XCTApplicationLaunchMetric` test (below), ideally on a real device — minutes, not an Instruments session.
Do the full Instruments + hygiene pass when you have time; this is the under-deadline path, not a replacement.
## Measurement Hygiene (do this before profiling)
Field devices are noisy; an unstable baseline tells you nothing. Before you measure:
- **Reboot the device** and wait a few minutes for boot-time work to settle.
- **Use a Release build** (Profile scheme) — debug overhead and missing optimizations distort everything.
- **Cut network variance** — airplane mode, or mock network dependencies in code.
- **Stabilize iCloud** — use an unchanging account/data, or sign out.
- **Use fixed mock data sets** — ideally one small and one large; load only what the first screen needs.
- **Pick a device set and stick to it** — include your oldest supported device; performance characteristics differ wildly from the newest.
- **Measure warm launches** for consistency; measure cold launches separately when that's the case you care about.
- **Profiling ≠ measuring.** Instruments adds overhead (a 6 ms phase can show 149 ms under the profiler). Profile to *find* the work; use `XCTApplicationLaunchMetric` to *measure* the real number.
## Tools
| Tool | Use it for | Cross-link |
|---|---|---|
| Instruments **App Launch** template | The triage workhorse — time profile + thread-state trace, broken into launch phases. Configure target, hit record, read which phase dominates and which thread is blocked. "Extended Launch" captures the full flow. | `performance-profiling` |
| Instruments **dyld Activity** | Static-initializer timings, dyld closure cost | `performance-profiling` |
| `xctrace record --template 'App Launch' --launch -- <app>` | Headless / CI launch profiling | `xctrace-ref` |
| Xcode Organizer — **Launch Time** pane | Field ms (50th/90th pct) by device & OS, version-over-version | — |
| Xcode Organizer — **Launches** pane | Longest functions during startup, with stack traces and a 14-day trend | — |
| `XCTApplicationLaunchMetric` (XCTest) | Regression gate in CI — see snippet below | `axiom-testing` |
| `MXAppLaunchMetric` (MetricKit) | Field histograms: `histogrammedTimeToFirstDraw`, `histogrammedOptimizedTimeToFirstDraw` (prewarmed), `histogrammedApplicationResumeTime`, `histogrammedExtendedLaunch`; `MXDiagnosticPayload.appLaunchDiagnostics` for slow-launch stacks | `metrickit-ref` |
| MetricKit 27 launch family `OS27` | Typed field metrics `.timeToFirstDraw` / `.optimizedTimeToFirstDraw` / `.applicationResumeTime` / `.extendedLaunch`, the `.appLaunch` diagnostic with launch stacks, and `MetricManager.trackLaunchTask(id:)` to instrument named extended-launch work (`@MainActor`, sync/async overloads) | `metrickit-ref` Part 1 |
| App Store Connect — "App Extended Launch Usage" report | Field extended-launch data (iOS 17.4+, daily) | — |
| Custom **Points of Interest** signpost | Marking your own "app is interactive" boundary | `performance-profiling` |
### Custom "app is interactive" signpost
When your real interactive point is *after* the first frame (async data, document open), bracket it with a signpost so it shows up in the Points of Interest instrument.
Swift:
```swift
import OSLog
let launchLog = OSSignposter(subsystem: "com.example.app", category: .pointsOfInterest)
// at the start of launch-critical setup
let state = launchLog.beginInterval("Launch → interactive")
// ... later, once the screen is genuinely usable
launchLog.endInterval("Launch → interactive", state)
```
Objective-C uses `os_signpost(OS_SIGNPOST_INTERVAL_BEGIN/END, log, "Launch → interactive")` with an `OSLog` created with the `OS_LOG_CATEGORY_POINTS_OF_INTEREST` category. In SwiftUI, begin in `App.init()` (or a root-view `task`) and end from the first view's `.onAppear` once data has loaded.
### Regression test (XCTest)
```swift
func testLaunchPerformance() {
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
```
One throwaway launch, then (by default) five measured iterations with statistics. `XCTApplicationLaunchMetric(waitUntilResponsive:)` extends the window to first-responsive. (`XCTApplicationLaunchMetric` supersedes the old `XCTOSSignpostMetric.applicationLaunch`.) For field monitoring, wrap your extended-launch tasks in `MXMetricManager.shared.extendLaunchMeasurement(forTaskID:)` / `finishExtendedLaunchMeasurement(forTaskID:)`.
## Fixes by Phase
### Phase 1 — Pre-main
- **Reduce dynamic framework count.** Consolidate small frameworks; change linkage where you can — see the strategy table below.
- **Move `+load` work to `+initialize`** (lazy, first message) or to an explicit init API you call after launch.
- **Don't force Swift globals/type properties eager.** Let them stay lazy. If a framework you own does heavy module-load work, expose an init-early API instead.
- **No `dlopen`/`NSBundle.load` on the launch path.**
#### Linkage strategy — static vs dynamic vs mergeable
Apple publishes **no numeric threshold** here — only that "each additional third-party framework that your app loads adds to the launch time", and that built-in frameworks "have a much lower impact on launch, because they use shared memory with other processes that use the same framework". Note dyld "caches a lot of this work in a launch closure when the user installs the app", so per-library cost is not paid in full on every launch. Measure with `DYLD_PRINT_STATISTICS=1` before deciding.
| Linkage | Launch cost | Choose it when |
|---|---|---|
| Static (`.a`, static framework) | None at load — code is in the app binary at build time, and unreferenced code can be dead-stripped | The module is used by one binary and doesn't need to be shared |
| Dynamic (embedded `.framework`/`.dylib`) | dyld load + symbol resolution per library, partly amortized by the launch closure | Genuinely shared between app and extensions, or loaded conditionally |
| **Mergeable** (Xcode 15+) | Static-like in optimized builds, dynamic in unoptimized ones | You want many small modules for build times without paying for them at launch — the default answer for first-party modules |
**Static linking is not free.** A static library is copied into *every* binary that links it: link it into both the app and three extensions and you ship four copies, and any global state in it is four separate copies too. When two dynamic frameworks each statically link the same library, both copies load into one process — this does *not* fail at link time; it surfaces at runtime as duplicated global state and the Obj-C runtime's "Class X is implemented in both …, one of the two will be used" warning, with a nondeterministic winner. Static libraries containing Obj-C categories need `-ObjC` in Other Linker Flags or the categories vanish — which force-loads every class and category from those libraries, giving back much of the dead-stripping benefit above.
**Mergeable libraries**, in Apple's words, get "app launch times similar to static linking in release builds, without losing dynamically linked build times in debug builds". Configure two settings:
| Setting | Goes on | Value | Effect |
|---|---|---|---|
| `MERGED_BINARY_TYPE` ("Create Merged Binary") | the *merging* target — only executables, dynamic libraries, and frameworks qualify | `automatic` | Every direct dependency that builds a dynamic library/framework is built mergeable and merged into this binary in optimized builds, reexported in unoptimized ones |
| `MERGED_BINARY_TYPE` | the merging target | `manual` | Only dependencies that opt in via `MERGEABLE_LIBRARY` are merged |
| `MERGEABLE_LIBRARY` ("Build Mergeable Library") | each *dependency* target you build | `YES` | Links mergeable in optimized builds, normal-dynamic-to-be-reexported otherwise. **No effect on static libraries** — it applies to dynamic libraries and frameworks only |
A **direct dependency** meets *both* of Apple's criteria: it is listed in the target's Link Binary with Libraries phase, **and** it is the product of another target in your project. Anything else — a pre-built library, or a dependency's own dependencies — is indirect. Under the hood merging is the linker's `-make_mergeable` (`-merge_framework`/`-merge-l` when merging, `-reexport_framework`/`-reexport-l` when not); you should not need to set these by hand.
For a large dependency graph, Apple's recommended structure is a **group library**: an intermediate framework target with `MERGED_BINARY_TYPE = automatic` that the mergeable libraries hang off, with the app depending only on that one target.
Mergeable-library gotchas:
| Gotcha | Consequence |
|---|---|
| `automatic` covers **direct** dependencies only | Xcode does not build *indirect* dependencies — a dependency's own dependencies — as mergeable. `MERGEABLE_LIBRARY` only helps for dependency targets in your project; you cannot set it on someone else's binary |
| Pre-built XCFrameworks | Merge under `automatic` *or* `manual` if the vendor already shipped `MergeableMetadata`, and not at all if they didn't. Mergeability is baked in at vendor build time, not chosen by you |
| Merging is keyed on **optimization, not configuration name** | Apple defines a debug build as unoptimized (`-O0`/`-Onone`, flagged by `IS_UNOPTIMIZED_BUILD`). A config *named* Release that still sets `-Onone` will not merge; a custom optimized "Profile" config will. Verify against an actually-optimized build |
| Dependency shared by app *and* an extension | Merging puts a copy in each binary. Apple explicitly flags this — keep it dynamic if extension binary size matters more than the app's launch |
| `SKIP_MERGEABLE_LIBRARY_BUNDLE_HOOK = YES` | Removes the hook that keeps the library's resource bundle findable, so `Bundle(for:)` stops returning it. Leave it off if you look up resources that way |
| Mergeable XCFrameworks (`MergeableMetadata`) | Require Xcode 15+; older Xcode fails the build outright |
| Debug and release layouts differ | In unoptimized builds Xcode copies dependency binaries into a location inside the merged product and adds an extra `@rpath` for them — expect the product structure to change between configurations |
**When not to bother.** Pre-main carries a floor of system work you cannot remove. If `DYLD_PRINT_STATISTICS` shows dyld is a small slice of your launch, relinking the whole project buys you nothing — the time is in Phase 2. Restructure linkage only when pre-main is measurably the dominant phase.
### Phase 2 — main → first frame
- **Defer everything not needed for the first frame** out of `didFinishLaunchingWithOptions` / `scene(_:willConnectTo:)` / `App.init` / `App.body` / root `viewDidLoad` / first `View.body`: analytics SDK init, network sync (→ background queue or `BGTask`), non-view services (persistence, location) → init on first use.
- **Load only first-screen data.** A table view shows ~10–20 cells; load those synchronously, fetch the rest in the background and update when done. Don't `SELECT *` at launch.
- **Watch SwiftData/Core Data stack cost.** Building a `ModelContainer` (or a Core Data stack) in `App.init()` and a `@Query` / `FetchRequest` on the root view both run on the launch path — keep store setup off the critical path where you can (migrations especially), scope `@Query` predicates/`fetchLimit` to what the first screen shows, and load the rest after first frame.
- **Get GCD priorities right.** A user-interactive main thread waiting on a background-QoS queue is a priority inversion — it stalls launch. Use the correct concurrency primitive so priority propagates; offload heavy main-actor work (cross-link `axiom-concurrency`).
- **Simplify the first view hierarchy** — flatten views, fewer Auto Layout constraints, lazily load views not visible at launch, avoid unnecessary custom `draw(_:)`.
### Phase 3 — first frame → interactive
- **Placeholders + async load** — render a usable frame immediately, fill in data asynchronously, keep the UI responsive throughout.
- **Signpost the extended phase** so you can see where the tail goes.
- **No speculative pre-warming.** Pre-building screens the user hasn't navigated to (e.g. a detail VC inside `cellForRowAt`) is a classic launch regression — measure before assuming a "pre-warm" helps.
## Push-Notification Launch
A notification tap is a launch entry path that arrives with a deep-link/action payload. Targets: **tap → first pixel ≈ 200 ms**, **tap → interactive content ≈ 1 s**.
- **Don't do heavy work in `UNUserNotificationCenterDelegate` handlers** (`didReceive`) — they run on the launch path. Resolve the deep link to a route, then render; defer network/database fetches until after the first pixel.
- **Cache deep-link routing data** so resolving a payload to a destination is cheap.
- **Background-app-refresh pre-warming is opportunistic, not guaranteed** — design the tap path to be fast from a cold state; treat any pre-warmed state as a bonus.
- **Profile it on a real device** with the App Launch template ("Extended Launch") plus a custom signpost around the notification-handling flow; simulator timing isn't representative. Send a test push with `xcrun simctl push`.
- Handler-side detail (categories, actions, content extensions) → cross-link `axiom-integration` (push notifications).
## Common Launch Mistakes
| Mistake | Why it bites | Fix |
|---|---|---|
| Measuring in the Simulator / a Debug build | Numbers are meaningless — different perf characteristics, debug overhead | Release build, real device, oldest supported model |
| Measuring a resume and calling it a launch | Resumes are ~free; you'll think launch is fine when it isn't | Force-quit (or reboot) before each measurement |
| Synchronous I/O or network in `didFinishLaunching` / `App.init` | The launch cycle blocks until those return | Background queue; load on first use |
| Loading all data at launch | Scales with the user's data, not the screen | Load the first screen's data only; lazy-load the rest |
| Heavy `+load` / many embedded dynamic frameworks | Runs before `main()`, before you can do anything | `+initialize`/runtime init; consolidate/merge frameworks |
| Speculative pre-warming of unseen screens | Adds guaranteed cost for a maybe-benefit | Measure first; usually just delete it |
| Big allocations during launch | Raises memory pressure → watchdog-termination risk | Allocate lazily; stream large data |
| "Profiled it, it's 400 ms" | Profiler overhead inflates numbers | Profile to find work; `XCTApplicationLaunchMetric` to measure |
## Quick Reference — Commands
```bash
# Headless launch profile
xctrace record --template 'App Launch' --launch -- /path/to/Your.app
# Clean-boot a simulator for consistent dev-time measurement (real device for real numbers)
xcrun simctl shutdown all && xcrun simctl erase <device-udid> && xcrun simctl boot <device-udid>
# Send a test push to a booted simulator (notification-launch testing)
xcrun simctl push <device-udid> com.example.app payload.apns
# payload.apns: {"aps":{"alert":"Test","sound":"default"}, "deep_link":"app://detail/42"}
# Run the launch regression test
xcodebuild test -scheme MyApp -destination 'platform=iOS,name=...' -only-testing:MyAppPerfTests/LaunchPerfTests
```
In Instruments: File → New → choose **App Launch** template → select your app as the target → Record. Triple-click a phase band to see its stack traces; gray thread = blocked, red = runnable-but-starved, orange = preempted, blue = running.
## Resources
**WWDC**: 2019-423, 2019-411, 2021-10181, 2022-110362, 2023-10268, 2024-10181
**Docs**: /xcode/reducing-your-app-s-launch-time, /xcode/configuring-your-project-to-use-mergeable-libraries, /metrickit/mxapplaunchmetric, /xctest/xctapplicationlaunchmetric, /uikit/about-the-app-launch-sequence
**Skills**: performance-profiling, xctrace-ref, metrickit-ref, hang-diagnostics, axiom-concurrency, axiom-integration
skills/energy-auditor.md
<!-- GENERATED from agents/energy-auditor.md by scripts/build-inlined-auditors.ts — do not edit. -->
# Energy Auditor
**Claude Code** — launch the `energy-auditor` agent, or run `/axiom:audit energy`. It runs this procedure in an isolated context with its own model tier.
**Every other harness** — follow this file inline. It is the same procedure, and it needs only file search and read.
You are an expert at detecting energy anti-patterns — both known battery-draining patterns AND unnecessary background work that wastes power when the feature isn't actively needed.
## Tool Use Is Mandatory
Run every Glob, Grep, and Read this prompt lists. Do not reason from training data instead of scanning.
- Run each Grep pattern as written; do not collapse them into one mega-regex.
- Run the Read verifications each section calls for.
- "Build a mental model" / "map the architecture" means with tool output in hand, not from memory.
## Files to Exclude
Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
## Phase 1: Map App Lifecycle and Background Behavior
### Step 1: Identify Background Activity
```
Glob: **/*.swift, **/Info.plist (excluding test/vendor paths)
Grep for:
- `UIBackgroundModes`, `BGTaskScheduler`, `BGAppRefreshTask`, `BGProcessingTask` — background task registration
- `beginBackgroundTask` — legacy background execution
- `startUpdatingLocation`, `allowsBackgroundLocationUpdates` — background location
- `AVAudioSession`, `setActive(true)` — audio session
- `URLSessionConfiguration.*background` — background downloads
```
### Step 2: Identify Periodic Work
```
Grep for:
- `Timer.scheduledTimer`, `Timer.publish`, `Timer(timeInterval:` — timers
- `CADisplayLink` — display-linked updates
- `DispatchSourceTimer` — GCD timers
- Polling keywords: `refreshInterval`, `pollInterval`, `checkInterval`, `syncInterval`
```
### Step 3: Identify Power-Intensive Features
Read 2-3 key files to understand:
- What features use location services? Are they always-on or on-demand?
- What triggers network requests? User action, timer, or push notification?
- Are there animations or GPU effects that run continuously?
- What's the audio/video session lifecycle?
### Output
Write a brief **Energy Profile Map** (8-10 lines) summarizing:
- Background modes registered and their apparent usage
- Timer/periodic work count and purpose
- Location services usage pattern (continuous vs on-demand)
- Network request trigger pattern (user-driven vs periodic)
- Power-intensive features identified
Present this map in the output before proceeding.
## Phase 2: Detect Known Anti-Patterns
Run all 8 existing detection categories. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification.
### Pattern 1: Timer Abuse (CRITICAL)
**Search**: `Timer.scheduledTimer`, `Timer.publish`, `Timer(timeInterval:`
**Verify**: Check for `.tolerance` (should match timer count); `timeInterval:\s*0\.` (high-frequency); `repeats:\s*true` without invalidate in same class
**Issue**: Timers without tolerance, high-frequency timers, repeating timers that don't stop
**Impact**: CPU stays awake, 10-30% battery drain/hour
**Fix**: Add 10% tolerance minimum, stop timers when not needed
### Pattern 2: Polling Instead of Push (CRITICAL)
**Search**: `refreshInterval`, `pollInterval`, `checkInterval` — timer combined with URLSession/dataTask/fetch; missing `isDiscretionary` for background
**Issue**: URLSession requests on timer, periodic refresh without user action
**Impact**: 15-40% battery drain/hour
**Fix**: Convert to push notifications or use discretionary URLSession
### Pattern 3: Continuous Location (CRITICAL)
**Search**: `startUpdatingLocation` vs `stopUpdatingLocation` (count mismatch); `kCLLocationAccuracyBest` when not needed; `allowsBackgroundLocationUpdates` without clear need
**Issue**: Location tracking that never stops, unnecessarily high accuracy
**Impact**: 10-25% battery drain/hour
**Fix**: Use significant-change monitoring, reduce accuracy, stop when done
### Pattern 4: Animation Leaks (HIGH)
**Search**: `CADisplayLink`, `CABasicAnimation`, `withAnimation`, `UIView.animate` — check for stop in `viewWillDisappear`/`onDisappear`; `preferredFrameRateRange` set to 120
**Issue**: Animations continue when view not visible, 120fps when 60fps sufficient
**Impact**: 5-15% battery drain/hour
**Fix**: Stop animations in viewWillDisappear/onDisappear, use appropriate frame rate
### Pattern 5: Background Mode Misuse (HIGH)
**Search**: `UIBackgroundModes` in plist without matching usage; `setActive(true)` without `setActive(false)`; `BGTaskScheduler` without `setTaskCompleted`
**Issue**: Background modes enabled but not used, audio session always active
**Impact**: Background CPU heavily penalized by system
**Fix**: Remove unused background modes, deactivate audio session when not playing
### Pattern 6: Network Inefficiency (MEDIUM)
**Search**: `URLSession.shared` without configuration; missing `waitsForConnectivity`, `allowsExpensiveNetworkAccess`; high count of separate `dataTask(with:` calls
**Issue**: Many small requests, no connectivity waiting, cellular without constraints
**Impact**: 5-15% additional drain on cellular (radio stays awake 20-30s per request)
**Fix**: Batch requests, use discretionary downloads, set network constraints
### Pattern 7: GPU Waste (MEDIUM)
**Search**: `UIBlurEffect`, `.blur(`, `Material.` over dynamic content; heavy `.shadow(`, `.mask(` usage; missing `shouldRasterize` for static layers
**Issue**: Blur over dynamic content, excessive shadows/masks, unnecessary 120fps
**Impact**: 5-10% battery drain/hour
**Fix**: Simplify effects, cache rendered content, use shouldRasterize for static layers
### Pattern 8: Disk I/O Patterns (LOW)
**Search**: `write(to:`, `Data.write` in loops; SQLite without WAL (`journal_mode`); frequent `UserDefaults.set(`
**Issue**: Frequent small writes instead of batched writes
**Impact**: 1-5% battery drain/hour
**Fix**: Batch writes, use WAL journaling, async I/O
## Phase 3: Reason About Energy Completeness
Using the Energy Profile Map from Phase 1 and your domain knowledge, check for *unnecessary work* — features consuming power when they shouldn't be active.
| Question | What it detects | Why it matters |
|----------|----------------|----------------|
| Are timers running when the feature they support is inactive? (e.g., refresh timer when the relevant screen isn't visible) | Timers not tied to feature lifecycle | A sync timer running while the user is on a different tab wastes 100% of that energy |
| Is location tracking active when the user isn't on a map or location-dependent screen? | Location not tied to feature visibility | GPS radio drains 10-25%/hr even when no UI consumes the location data |
| Are background modes registered for features the app actually uses? | Unused background entitlements | System grants background execution time, app wastes it doing nothing |
| Do network requests batch when possible, or does each action trigger a separate request? | Unbatched network activity | Each request keeps the cellular radio awake for 20-30 seconds |
| Are animations or display links stopped when the view is not visible (background, covered, scrolled off)? | Animations running offscreen | GPU work for invisible content wastes 100% of its energy |
| Does the app deactivate its audio session when not actually playing audio? | Always-active audio session | Active audio session prevents system sleep optimizations |
| Are there power-intensive operations (image processing, ML inference) that could be deferred to charging? | Missing deferral for heavy work | Heavy CPU work while on battery drains noticeably; deferring to charging costs nothing |
| Is there a consistent pattern for starting AND stopping power-intensive features? | Asymmetric start/stop | startUpdatingLocation without stopUpdatingLocation = location runs forever |
Require evidence from the Phase 1 map — don't speculate without reading the code.
## Phase 4: Cross-Reference Findings
Bump severity for these combinations:
| Finding A | + Finding B | = Compound | Severity |
|-----------|------------|-----------|----------|
| Timer without tolerance | High frequency (<1s interval) | CPU never sleeps | CRITICAL |
| Polling network requests | On cellular without constraints | Radio stays permanently awake | CRITICAL |
| Continuous location | In background mode | GPS drains battery even when app not visible | CRITICAL |
| Animation leak | 120fps frame rate | Maximum GPU power draw for invisible work | CRITICAL |
| Background mode registered | No matching feature code | System grants wasted background time | HIGH |
| Audio session always active | App is not an audio app | Prevents system sleep optimizations | HIGH |
| Multiple separate network requests | No batching strategy | Cellular radio restart penalty per request | HIGH |
| Timer running | Feature screen not visible | Energy spent on unused feature | HIGH |
Also note overlaps with other auditors:
- Timer without invalidate → compound with memory-auditor
- Animation without onDisappear cleanup → compound with memory-auditor
- Background URLSession → compound with networking-auditor
- Continuous location without stop → compound with concurrency-auditor (asymmetric lifecycle)
## Phase 5: Energy Health Score
```markdown
## Energy Health Score
| Metric | Value |
|--------|-------|
| Timer discipline | N timers, M with tolerance (Z%), repeating without invalidate: N |
| Location lifecycle | startUpdating: N, stopUpdating: M (match: yes/no), accuracy level |
| Network efficiency | N request patterns, M batched/discretionary (Z%) |
| Animation lifecycle | N animations/display links, M with visibility cleanup (Z%) |
| Background modes | N registered, M with matching code (Z%) |
| Estimated idle drain | [sum of pattern impacts] %/hour above baseline |
| **Health** | **EFFICIENT / WASTEFUL / DRAINING** |
```
Scoring:
- **EFFICIENT**: No CRITICAL issues, all timers have tolerance, location starts match stops, no unnecessary background modes, estimated <2% idle drain above baseline
- **WASTEFUL**: No CRITICAL issues, but some timers without tolerance, or unused background modes, or network batching opportunities missed
- **DRAINING**: Any CRITICAL issues, or continuous location without stop, or polling without push alternative, or estimated >5% idle drain above baseline
## Output Format
```markdown
# Energy Audit Results
## Energy Profile Map
[8-10 line summary from Phase 1]
## Summary
- CRITICAL: [N] issues (estimated [X]% battery drain/hour)
- HIGH: [N] issues
- MEDIUM: [N] issues
- LOW: [N] issues
- Phase 2 (anti-pattern detection): [N] issues
- Phase 3 (unnecessary work reasoning): [N] issues
- Phase 4 (compound findings): [N] issues
## Energy Health Score
[Phase 5 table]
## Verification Counts
- Timers: N created, M with tolerance, K invalidated
- Location: N start calls, M stop calls
- Network: N request patterns, M batched
- Animations: N created, M stopped on disappear
## Issues by Severity
### [SEVERITY] [Category]: [Description]
**File**: path/to/file.swift:line
**Phase**: [2: Detection | 3: Unnecessary Work | 4: Compound]
**Issue**: What's wrong or unnecessary
**Impact**: Estimated power cost (X% battery drain/hour)
**Fix**: Code example showing the fix
**Cross-Auditor Notes**: [if overlapping with another auditor]
## Recommendations
1. [Immediate actions — CRITICAL fixes (biggest battery impact)]
2. [Short-term — HIGH fixes (lifecycle cleanup, background mode audit)]
3. [Long-term — architectural improvements from Phase 3 findings]
4. [Verification — profile with Power Profiler in Instruments after fixes]
```
## Output Limits
If >50 issues in one category: Show top 10, provide total count, list top 3 files
If >100 total issues: Summarize by category, show only CRITICAL/HIGH details
## False Positives (Not Issues)
- Timers with tolerance already set
- One-shot timers (`repeats: false`)
- Location with appropriate distanceFilter set
- Push notification handlers (not polling)
- Discretionary network sessions
- Audio session with matching deactivation
- Background modes with matching feature code
- CADisplayLink in active game/animation screens (expected GPU usage)
## Field Termination Correlation
Energy anti-patterns surface in the field as system terminations, not slow-draining batteries. When the user has `.ips` artifacts, xcsym's `pattern_tag` flags the termination mode directly:
| pattern_tag | Energy anti-pattern it exposes |
|---|---|
| `cpu_resource_fatal` | CPU budget exceeded — tight timer loops, animation leaks, or busy-wait polling (Patterns 1, 4) |
| `background_task_expired` | `BGTask` didn't call `setTaskCompleted` (Pattern 5) or exceeded its 30s budget |
| `watchdog_termination` | Main-thread hang from a sync I/O/network call blocking rendering (Pattern 6/8) |
| `jetsam_oom` | Background memory growth — often a timer/animation retaining state across backgrounding |
```bash
xcsym crash --format=summary <path-to-ips>
```
Use the crashed-thread frames to pinpoint which Phase 1 background-activity owner is the culprit.
## Related
For detailed optimization patterns: `axiom-performance (skills/energy.md)` skill
For Power Profiler workflows: `axiom-performance (skills/energy-ref.md)` skill
For timer lifecycle issues: `axiom-integration` (skills/timer-patterns.md)
For symbolicating CPU/background/watchdog terminations: `axiom-tools (skills/xcsym-ref.md)`
skills/energy-diag.md
# Energy Diagnostics
Symptom-based troubleshooting for energy issues. Start with your symptom, follow the decision tree, get the fix.
**Related skills**: `axiom-performance (skills/energy.md)` (patterns, checklists), `axiom-performance (skills/energy-ref.md)` (API reference)
---
## Measurement Red Flags — Read Before Profiling
These two mistakes invalidate an entire profiling session. Catch them first.
| Red flag | Why it ruins the trace | Fix |
|----------|------------------------|-----|
| Profiling over a USB cable | System power metrics read ~0 when the device is charging — the trace looks clean while the bug is still there | Profile over wireless debugging (Window → Devices → Connect via network), unplugged |
| Can't reproduce drain at your desk | Real drain happens during the commute/in-pocket, not at a stationary desk on WiFi | Use on-device Performance Trace (below) — you cannot find this with a cabled Mac trace |
#### On-device Performance Trace for unreproducible drain
When the drain only shows up in real-world use (commute, pocket, cellular), capture it on the device itself, then bring it back to the Mac:
1. Settings → Developer → Performance Trace → Enable, set mode to Power Profiler
2. Add the Performance Trace control to Control Center (Add a Control → Performance Trace)
3. Start the trace from Control Center, use the app normally for the real-world scenario (captures up to ~10 hours)
4. Stop, then Settings → Developer → Performance Trace → share the trace to your Mac and open in Instruments
---
## Symptom 1: App at Top of Battery Settings
Users or you notice your app consuming significant battery.
### Diagnosis Decision Tree
```
App at top of Battery Settings?
│
├─ Step 1: Run Power Profiler (15 min)
│ ├─ CPU Power Impact high?
│ │ ├─ Continuous? → Timer leak or polling loop
│ │ │ └─ Fix: Check timers, add tolerance, convert to push
│ │ └─ Spikes during actions? → Eager loading or repeated parsing
│ │ └─ Fix: Use LazyVStack, cache parsed data
│ │
│ ├─ Network Power Impact high?
│ │ ├─ Many small requests? → Batching issue
│ │ │ └─ Fix: Batch requests, use discretionary URLSession
│ │ └─ Regular intervals? → Polling pattern
│ │ └─ Fix: Convert to push notifications
│ │
│ ├─ GPU Power Impact high?
│ │ ├─ Animations? → Running when not visible
│ │ │ └─ Fix: Stop in viewWillDisappear
│ │ └─ Blur effects? → Over dynamic content
│ │ └─ Fix: Remove or use static backgrounds
│ │
│ └─ Display Power Impact high?
│ └─ Light backgrounds on OLED?
│ └─ Fix: Implement Dark Mode (up to 70% savings)
│
└─ Step 2: Check background section in Battery Settings
├─ High background time?
│ ├─ Location icon visible? → Continuous location
│ │ └─ Fix: Switch to significant-change monitoring
│ ├─ Audio active? → Session not deactivated
│ │ └─ Fix: Deactivate audio session when not playing
│ └─ BGTasks running long? → Not completing promptly
│ └─ Fix: Call setTaskCompleted sooner
│
└─ Background time appropriate?
└─ Issue is in foreground usage → Focus on CPU/GPU fixes above
```
### Time-Cost Analysis
| Approach | Time | Accuracy |
|----------|------|----------|
| Run Power Profiler, identify subsystem | 15-20 min | High |
| Guess and optimize random areas | 4+ hours | Low |
| Read all code looking for issues | 2+ hours | Medium |
**Recommendation**: Always use Power Profiler first. It costs 15 minutes but guarantees you optimize the right subsystem.
---
## Symptom 2: Device Gets Hot
Device temperature increases noticeably during app use.
### Diagnosis Decision Tree
```
Device gets hot during app use?
│
├─ Hot during specific action?
│ │
│ ├─ During video/camera use?
│ │ ├─ Video encoding? → Expected, but check efficiency
│ │ │ └─ Fix: Use hardware encoding, reduce resolution if possible
│ │ └─ Camera active unnecessarily? → Not releasing session
│ │ └─ Fix: Call stopRunning() when done
│ │
│ ├─ During scroll/animation?
│ │ ├─ GPU-intensive effects? → Blur, shadows, many layers
│ │ │ └─ Fix: Reduce effects, cache rendered content
│ │ └─ High frame rate? → Unnecessary 120fps
│ │ └─ Fix: Use CADisplayLink preferredFrameRateRange
│ │
│ └─ During data processing?
│ ├─ JSON parsing? → Repeated or large payloads
│ │ └─ Fix: Cache parsed results, paginate
│ └─ Image processing? → Synchronous on main thread
│ └─ Fix: Move to background, cache results
│
├─ Hot during normal use (no specific action)?
│ │
│ ├─ Run Power Profiler to identify:
│ │ ├─ CPU high continuously → Timer, polling, tight loop
│ │ ├─ GPU high continuously → Animation leak
│ │ └─ Network high continuously → Polling pattern
│ │
│ └─ Check for infinite loops or runaway recursion
│ └─ Use Time Profiler in Instruments
│
└─ Hot only in background?
├─ Location updates continuous? → High accuracy or no stop
│ └─ Fix: Reduce accuracy, stop when done
├─ Audio session active? → Hardware kept powered
│ └─ Fix: Deactivate when not playing
└─ BGTask running too long? → System may throttle
└─ Fix: Complete tasks faster, use requiresExternalPower
```
### Time-Cost Analysis
| Approach | Time | Outcome |
|----------|------|---------|
| Power Profiler + Time Profiler | 20-30 min | Identifies exact cause |
| Check code for obvious issues | 1-2 hours | May miss non-obvious causes |
| Wait for user complaints | N/A | Reputation damage |
---
## Symptom 3: Background Battery Drain
App drains battery even when user isn't actively using it.
### Diagnosis Decision Tree
```
High background battery usage?
│
├─ Step 1: Check Info.plist background modes
│ │
│ ├─ "location" enabled?
│ │ ├─ Actually need background location?
│ │ │ ├─ YES → Use significant-change, lowest accuracy
│ │ │ └─ NO → Remove background mode, use when-in-use only
│ │ └─ Check: Is stopUpdatingLocation called?
│ │
│ ├─ "audio" enabled?
│ │ ├─ Audio playing? → Expected
│ │ ├─ Audio NOT playing? → Session still active
│ │ │ └─ Fix: Deactivate session, use autoShutdownEnabled
│ │ └─ Playing silent audio? → Anti-pattern for keeping app alive
│ │ └─ Fix: Use proper background API (BGTask)
│ │
│ ├─ "fetch" enabled?
│ │ └─ Check: Is earliestBeginDate reasonable? (not too frequent)
│ │
│ └─ "remote-notification" enabled?
│ └─ Expected for push updates, check didReceiveRemoteNotification efficiency
│
├─ Step 2: Check BGTaskScheduler usage
│ │
│ ├─ BGAppRefreshTask scheduled too frequently?
│ │ └─ Fix: Increase earliestBeginDate interval
│ │
│ ├─ BGProcessingTask not using requiresExternalPower?
│ │ └─ Fix: Add requiresExternalPower = true for non-urgent work
│ │
│ └─ Tasks not completing? (setTaskCompleted not called)
│ └─ Fix: Always call setTaskCompleted, implement expirationHandler
│
└─ Step 3: Check beginBackgroundTask usage
│
├─ endBackgroundTask called promptly?
│ └─ Fix: Call immediately after work completes, not at expiration
│
└─ Multiple overlapping background tasks?
└─ Fix: Track task IDs, ensure each is ended
```
### Common Background Drain Patterns
| Pattern | Power Profiler Signature | Fix |
|---------|-------------------------|-----|
| Continuous location | CPU lane + location icon | significant-change |
| Audio session leak | CPU lane steady | setActive(false) |
| Timer not invalidated | CPU spikes at intervals | invalidate in background |
| Polling from background | Network lane at intervals | Push notifications |
| BGTask too long | CPU sustained | Faster completion |
### Time-Cost Analysis
| Approach | Time | Outcome |
|----------|------|---------|
| Check Info.plist + BGTask code | 30 min | Finds common issues |
| On-device Power Profiler trace | 1-2 hours (real usage) | Captures real behavior |
| User-collected trace | Variable | Best for unreproducible issues |
---
## Symptom 4: High Energy Only on Cellular
Battery drains faster on cellular than WiFi.
### Diagnosis Decision Tree
```
High battery drain on cellular only?
│
├─ Expected: Cellular radio uses more power than WiFi
│ └─ But: Excessive drain indicates optimization opportunity
│
├─ Check URLSession configuration
│ │
│ ├─ allowsExpensiveNetworkAccess = true (default)?
│ │ └─ Fix: Set to false for non-urgent requests
│ │
│ ├─ isDiscretionary = false (default)?
│ │ └─ Fix: Set to true for background downloads
│ │
│ └─ waitsForConnectivity = false (default)?
│ └─ Fix: Set to true to avoid failed connection retries
│
├─ Check request patterns
│ │
│ ├─ Many small requests? → High connection overhead
│ │ └─ Fix: Batch into fewer larger requests
│ │
│ ├─ Polling? → Radio stays active
│ │ └─ Fix: Push notifications
│ │
│ └─ Large downloads in foreground? → Could wait for WiFi
│ └─ Fix: Use background URLSession with discretionary
│
└─ Check Low Data Mode handling
├─ Respecting allowsConstrainedNetworkAccess?
│ └─ Fix: Set to false for non-essential requests
│
└─ Checking ProcessInfo.processInfo.isLowDataModeEnabled?
└─ Fix: Reduce payload sizes, defer non-essential transfers
```
### Time-Cost Analysis
| Approach | Time | Outcome |
|----------|------|---------|
| Review URLSession configs | 15 min | Quick wins |
| Add discretionary flags | 30 min | Significant savings |
| Convert poll to push | 2-4 hours | Largest impact |
---
## Symptom 5: Energy Spike During Specific Action
Noticeable battery drain or heat when performing particular operation.
### Diagnosis Decision Tree
```
Energy spike during specific action?
│
├─ Step 1: Record Power Profiler during action
│ └─ Note which subsystem spikes (CPU/GPU/Network/Display)
│
├─ CPU spike?
│ │
│ ├─ Is it parsing data?
│ │ ├─ Same data parsed repeatedly?
│ │ │ └─ Fix: Cache parsed results (lazy var)
│ │ └─ Large JSON/XML payload?
│ │ └─ Fix: Paginate, stream parse, or use binary format
│ │
│ ├─ Is it creating views?
│ │ ├─ Many views at once?
│ │ │ └─ Fix: Use LazyVStack/LazyHStack
│ │ └─ Complex view hierarchies?
│ │ └─ Fix: Simplify, use drawingGroup()
│ │
│ └─ Is it image processing?
│ ├─ On main thread?
│ │ └─ Fix: Move to background queue
│ └─ No caching?
│ └─ Fix: Cache processed images
│
├─ GPU spike?
│ │
│ ├─ Starting animation?
│ │ └─ Fix: Ensure frame rate appropriate
│ │
│ ├─ Showing blur effect?
│ │ └─ Fix: Use solid color or pre-rendered blur
│ │
│ └─ Complex render? (shadows, masks, many layers)
│ └─ Fix: Simplify, use shouldRasterize, cache
│
├─ Network spike?
│ │
│ ├─ Large download started?
│ │ └─ Fix: Use background URLSession, show progress
│ │
│ ├─ Many parallel requests?
│ │ └─ Fix: Limit concurrency, batch
│ │
│ └─ Retrying failed requests?
│ └─ Fix: Exponential backoff, waitsForConnectivity
│
└─ Display spike?
└─ Unusual unless changing brightness programmatically
└─ Fix: Don't modify brightness, let system control
```
### Time-Cost Analysis
| Approach | Time | Outcome |
|----------|------|---------|
| Power Profiler during action | 5-10 min | Identifies subsystem |
| Time Profiler for CPU details | 10-15 min | Identifies function |
| Code review without profiling | 1+ hours | May miss actual cause |
---
## Quick Diagnostic Checklist
Use this when you need fast answers:
### 30-Second Check
- [ ] Profiling over USB cable? Power metrics read ~0 — switch to wireless debugging, unplugged
- [ ] Debug build? (Less optimized than release)
- [ ] Low Power Mode on? (May affect measurements)
### 5-Minute Check (Power Profiler)
- [ ] Which subsystem is dominant? (CPU/GPU/Network/Display)
- [ ] Sustained or spiky?
- [ ] Foreground or background?
### 15-Minute Investigation
- [ ] If CPU: Run Time Profiler to identify function
- [ ] If Network: Check request frequency and size
- [ ] If GPU: Check animation frame rates
- [ ] If Background: Check Info.plist modes
### Common Quick Fixes
| Finding | Quick Fix | Time |
|---------|-----------|------|
| Timer without tolerance | Add `.tolerance = 0.1` | 1 min |
| VStack with large ForEach | Change to LazyVStack | 1 min |
| allowsExpensiveNetworkAccess = true | Set to false | 1 min |
| Missing stopUpdatingLocation | Add stop call | 2 min |
| No Dark Mode | Add asset variants | 30 min |
| Audio session always active | Add setActive(false) | 5 min |
| Polling on a timer (e.g. every 5s) | Convert to push — polling uses ~100x more energy than push | 2-4 hours |
| Background push waking the radio | Send `apns-priority: 5` so the system batches/coalesces delivery | server-side |
| Off-screen CADisplayLink at high fps | Cap with `preferredFrameRateRange` (~20% GPU savings) or pause when off-screen | 5 min |
---
## Prove the Fix in the Field (MetricKit)
A clean local Power Profiler trace proves nothing about real users. After shipping a fix, confirm it landed with the MetricKit field that measures exactly what you changed. Implement `MXMetricManager` and read these from `MXMetricPayload`:
| Fix shipped | Field that proves it | What "fixed" looks like |
|-------------|----------------------|-------------------------|
| Right-sized background location | `locationActivityMetrics.cumulativeBackgroundLocationTime` | Drops sharply vs the leaking build |
| Polling converted to push | `networkTransferMetrics` (cellular + WiFi up/down bytes) | Fewer bytes, no steady-interval pattern |
| Best-accuracy GPS reduced | `locationActivityMetrics.cumulativeBestAccuracyTime` | Time shifts out of the best-accuracy bucket |
Compare the post-fix payload against the pre-fix baseline — without the baseline you can't tell improvement from noise.
---
## When to Escalate
### Use `axiom-performance (skills/energy.md)` skill when
- Need full audit checklist
- Want comprehensive patterns with code
- Planning proactive optimization
### Use `axiom-performance (skills/energy-ref.md)` skill when
- Need specific API details
- Want complete code examples
- Implementing from scratch
### Use `energy-auditor` agent when
- Want automated codebase scan
- Looking for anti-patterns at scale
- Pre-release energy audit
Run: `/axiom:audit energy`
---
**Last Updated**: 2025-12-26
**Platforms**: iOS 26+, iPadOS 26+
skills/energy-ref.md
# Energy Optimization Reference
Complete API reference for iOS energy optimization, with code examples from WWDC sessions and Apple documentation.
**Related skills**: `axiom-performance (skills/energy.md)` (decision trees, patterns), `axiom-performance (skills/energy-diag.md)` (troubleshooting)
---
## Part 1: Power Profiler Workflow
### Recording a Trace with Instruments
#### Tethered Recording (Connected to Mac)
```
1. Connect iPhone wirelessly to Xcode
- Xcode 27: pair the device in Device Hub — a paired device is reachable over
the local network (devicectl reports transportType localNetwork)
- Xcode 26 and earlier: Xcode → Window → Devices and Simulators, then
enable "Connect via network" for your device
2. Profile your app
- Xcode → Product → Profile (Cmd+I)
- Select Blank template
- Click "+" → Add "Power Profiler"
- Optionally add "CPU Profiler" for correlation
3. Record
- Select your app from target dropdown
- Click Record (red button)
- Use app normally for 2-3 minutes
- Click Stop
4. Analyze
- Expand Power Profiler track
- Examine per-app lanes: CPU, GPU, Display, Network
```
**Important**: Use wireless debugging. When device is charging via cable, system power usage shows 0.
#### On-Device Recording (Without Mac)
From WWDC25-226: Capture traces in real-world conditions.
```
1. Enable Developer Mode
Settings → Privacy & Security → Developer Mode → Enable
2. Enable Performance Trace
Settings → Developer → Performance Trace → Enable
Set tracing mode to "Power Profiler"
Toggle ON your app in the app list
3. Add Control Center shortcut
Control Center → Tap "+" → Add a Control → Performance Trace
4. Record
Swipe down → Tap Performance Trace icon → Start
Use app (can record up to 10 hours)
Tap Performance Trace icon → Stop
5. Share trace
Settings → Developer → Performance Trace
Tap Share button next to trace file
AirDrop to Mac or email to developer
```
### Interpreting Power Profiler Metrics
| Lane | Meaning | What High Values Indicate |
|------|---------|--------------------------|
| System Power | Overall battery drain rate | General energy consumption |
| CPU Power Impact | Processor activity score | Computation, timers, parsing |
| GPU Power Impact | Graphics rendering score | Animations, blur, Metal |
| Display Power Impact | Screen power usage | Brightness, content type |
| Network Power Impact | Radio activity score | Requests, downloads, polling |
**Key insight**: Values are scores for comparison, not absolute measurements. Compare before/after traces on the same device.
### Comparing Before/After (Example from WWDC25-226)
```swift
// Before optimization: CPU Power Impact = 21
VStack {
ForEach(videos) { video in
VideoCardView(video: video)
}
}
// After optimization: CPU Power Impact = 4.3
LazyVStack {
ForEach(videos) { video in
VideoCardView(video: video)
}
}
```
---
## Part 2: Timer Efficiency APIs
### NSTimer with Tolerance
```swift
// Basic timer with tolerance
let timer = Timer.scheduledTimer(
withTimeInterval: 1.0,
repeats: true
) { [weak self] _ in
self?.updateUI()
}
timer.tolerance = 0.1 // 10% minimum recommended
// Add to run loop (if not using scheduledTimer)
RunLoop.current.add(timer, forMode: .common)
// Always invalidate when done
deinit {
timer.invalidate()
}
```
### Combine Timer Publisher
```swift
import Combine
class ViewModel: ObservableObject {
private var cancellables = Set<AnyCancellable>()
func startPolling() {
Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .default)
.autoconnect()
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
}
func stopPolling() {
cancellables.removeAll()
}
}
```
### Dispatch Timer Source (Low-Level)
From Energy Efficiency Guide:
```swift
let queue = DispatchQueue(label: "com.app.timer")
let timer = DispatchSource.makeTimerSource(queue: queue)
// Set interval with leeway (tolerance)
timer.schedule(
deadline: .now(),
repeating: .seconds(1),
leeway: .milliseconds(100) // 10% tolerance
)
timer.setEventHandler { [weak self] in
self?.performWork()
}
timer.resume()
// Cancel when done
timer.cancel()
```
> For DispatchSourceTimer lifecycle safety and crash prevention, see `axiom-integration` (skills/timer-patterns.md).
### Event-Driven Alternative to Timers
From Energy Efficiency Guide: Prefer dispatch sources over polling.
```swift
// Monitor file changes instead of polling
let fileDescriptor = open(filePath.path, O_EVTONLY)
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fileDescriptor,
eventMask: [.write, .delete],
queue: .main
)
source.setEventHandler { [weak self] in
self?.handleFileChange()
}
source.setCancelHandler {
close(fileDescriptor)
}
source.resume()
```
---
## Part 3: Network Efficiency APIs
### URLSession Configuration
```swift
// Standard configuration with energy-conscious settings
let config = URLSessionConfiguration.default
config.waitsForConnectivity = true // Don't fail immediately
config.allowsExpensiveNetworkAccess = false // Prefer WiFi
config.allowsConstrainedNetworkAccess = false // Respect Low Data Mode
let session = URLSession(configuration: config)
```
### Discretionary Background Downloads
From WWDC22-10083:
```swift
// Background session for non-urgent downloads
let config = URLSessionConfiguration.background(
withIdentifier: "com.app.downloads"
)
config.isDiscretionary = true // System chooses optimal time
config.sessionSendsLaunchEvents = true
// Set timeouts
config.timeoutIntervalForResource = 24 * 60 * 60 // 24 hours
config.timeoutIntervalForRequest = 60
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
// Create download task with scheduling hints
let task = session.downloadTask(with: url)
task.earliestBeginDate = Date(timeIntervalSinceNow: 2 * 60 * 60) // 2 hours from now
task.countOfBytesClientExpectsToSend = 200 // Small request
task.countOfBytesClientExpectsToReceive = 500_000 // 500KB response
task.resume()
```
### Background Session Delegate
```swift
class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
// Move file from temp location
let destination = FileManager.default.urls(
for: .documentDirectory,
in: .userDomainMask
)[0].appendingPathComponent("downloaded.data")
try? FileManager.default.moveItem(at: location, to: destination)
}
func urlSessionDidFinishEvents(
forBackgroundURLSession session: URLSession
) {
// Notify app delegate to call completion handler
DispatchQueue.main.async {
if let handler = AppDelegate.shared.backgroundCompletionHandler {
handler()
AppDelegate.shared.backgroundCompletionHandler = nil
}
}
}
}
```
---
## Part 4: Location Efficiency APIs
### CLLocationManager Configuration
```swift
import CoreLocation
class LocationService: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
func configure() {
manager.delegate = self
// Use appropriate accuracy
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
// Reduce update frequency
manager.distanceFilter = 100 // Update every 100 meters
// Allow indicator pause when stationary
manager.pausesLocationUpdatesAutomatically = true
// For background updates (if needed)
manager.allowsBackgroundLocationUpdates = true
manager.showsBackgroundLocationIndicator = true
}
func startTracking() {
manager.requestWhenInUseAuthorization()
manager.startUpdatingLocation()
}
func startSignificantChangeTracking() {
// Much more energy efficient for background
manager.startMonitoringSignificantLocationChanges()
}
func stopTracking() {
manager.stopUpdatingLocation()
manager.stopMonitoringSignificantLocationChanges()
}
}
```
### iOS 26+ CLLocationUpdate (Modern Async API)
```swift
import CoreLocation
func trackLocation() async throws {
for try await update in CLLocationUpdate.liveUpdates() {
// Check if device became stationary
if update.stationary {
// System pauses updates automatically
// Consider switching to region monitoring
break
}
if let location = update.location {
handleLocation(location)
}
}
}
```
### CLMonitor for Significant Changes
```swift
import CoreLocation
func setupRegionMonitoring() async {
let monitor = CLMonitor("significant-changes")
// Add condition to monitor
let condition = CLMonitor.CircularGeographicCondition(
center: currentLocation.coordinate,
radius: 500 // 500 meter radius
)
await monitor.add(condition, identifier: "home-region")
// React to events
for try await event in monitor.events {
switch event.state {
case .satisfied:
// Entered region
handleRegionEntry()
case .unsatisfied:
// Exited region
handleRegionExit()
default:
break
}
}
}
```
### Location Accuracy Options
| Constant | Accuracy | Battery Impact | Use Case |
|----------|----------|----------------|----------|
| `kCLLocationAccuracyBestForNavigation` | ~1m | Extreme | Turn-by-turn only |
| `kCLLocationAccuracyBest` | ~10m | Very High | Fitness tracking |
| `kCLLocationAccuracyNearestTenMeters` | ~10m | High | Precise positioning |
| `kCLLocationAccuracyHundredMeters` | ~100m | Medium | Store locators |
| `kCLLocationAccuracyKilometer` | ~1km | Low | Weather, general |
| `kCLLocationAccuracyThreeKilometers` | ~3km | Very Low | Regional content |
---
## Part 5: Background Execution APIs
### beginBackgroundTask (Short Tasks)
```swift
class AppDelegate: UIResponder, UIApplicationDelegate {
var backgroundTask: UIBackgroundTaskIdentifier = .invalid
func applicationDidEnterBackground(_ application: UIApplication) {
backgroundTask = application.beginBackgroundTask(withName: "Save State") {
// Expiration handler - clean up
self.endBackgroundTask()
}
// Perform quick work
saveState()
// End immediately when done
endBackgroundTask()
}
private func endBackgroundTask() {
guard backgroundTask != .invalid else { return }
UIApplication.shared.endBackgroundTask(backgroundTask)
backgroundTask = .invalid
}
}
```
### BGAppRefreshTask
```swift
import BackgroundTasks
// Register at app launch
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.app.refresh",
using: nil
) { task in
self.handleAppRefresh(task: task as! BGAppRefreshTask)
}
return true
}
// Schedule refresh
func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min
try? BGTaskScheduler.shared.submit(request)
}
// Handle refresh
func handleAppRefresh(task: BGAppRefreshTask) {
scheduleAppRefresh() // Schedule next refresh
let fetchTask = Task {
do {
let hasNewData = try await fetchLatestData()
task.setTaskCompleted(success: hasNewData)
} catch {
task.setTaskCompleted(success: false)
}
}
task.expirationHandler = {
fetchTask.cancel()
}
}
```
### BGProcessingTask
```swift
import BackgroundTasks
// Register
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.app.maintenance",
using: nil
) { task in
self.handleMaintenance(task: task as! BGProcessingTask)
}
// Schedule with requirements
func scheduleMaintenance() {
let request = BGProcessingTaskRequest(identifier: "com.app.maintenance")
request.requiresNetworkConnectivity = true
request.requiresExternalPower = true // Only when charging
try? BGTaskScheduler.shared.submit(request)
}
// Handle
func handleMaintenance(task: BGProcessingTask) {
let operation = MaintenanceOperation()
task.expirationHandler = {
operation.cancel()
}
operation.completionBlock = {
task.setTaskCompleted(success: !operation.isCancelled)
}
OperationQueue.main.addOperation(operation)
}
```
### iOS 26+ BGContinuedProcessingTask
From WWDC25-227: Continue user-initiated tasks with system UI.
```swift
import BackgroundTasks
// Info.plist: Add identifier to BGTaskSchedulerPermittedIdentifiers
// "com.app.export" or "com.app.exports.*" for wildcards
// Register handler (can be dynamic, not just at launch)
func setupExportHandler() {
// `using:` is the dispatch queue — pass nil for a default background queue
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.app.export", using: nil) { task in
let continuedTask = task as! BGContinuedProcessingTask
var shouldContinue = true
continuedTask.expirationHandler = {
shouldContinue = false
}
// Report progress
continuedTask.progress.totalUnitCount = 100
continuedTask.progress.completedUnitCount = 0
// Perform work
for i in 0..<100 {
guard shouldContinue else { break }
performExportStep(i)
continuedTask.progress.completedUnitCount = Int64(i + 1)
}
continuedTask.setTaskCompleted(success: shouldContinue)
}
}
// Submit request
func startExport() {
let request = BGContinuedProcessingTaskRequest(
identifier: "com.app.export",
title: "Exporting Photos",
subtitle: "0 of 100 photos"
)
// Submission strategy
request.strategy = .fail // Fail if can't start immediately
// or default: queue if can't start
do {
try BGTaskScheduler.shared.submit(request)
} catch {
// Handle submission failure
showExportNotAvailable()
}
}
```
### EMRCA Principles (from WWDC25-227)
Background tasks must be:
| Principle | Meaning | Implementation |
|-----------|---------|----------------|
| **E**fficient | Lightweight, purpose-driven | Do one thing well |
| **M**inimal | Keep work to minimum | Don't expand scope |
| **R**esilient | Save progress, handle expiration | Checkpoint frequently |
| **C**ourteous | Honor preferences | Check Low Power Mode |
| **A**daptive | Work with system | Don't fight constraints |
---
## Part 6: Display & GPU Efficiency APIs
### Dark Mode Support
```swift
// Check current appearance
let isDarkMode = traitCollection.userInterfaceStyle == .dark
// React to appearance changes
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
updateColorsForAppearance()
}
}
// Use dynamic colors
let dynamicColor = UIColor { traitCollection in
switch traitCollection.userInterfaceStyle {
case .dark:
return UIColor.black // OLED: True black = pixels off = 0 power
default:
return UIColor.white
}
}
```
### Frame Rate Control with CADisplayLink
From WWDC22-10083:
```swift
class AnimationController {
private var displayLink: CADisplayLink?
func startAnimation() {
displayLink = CADisplayLink(target: self, selector: #selector(update))
// Control frame rate
displayLink?.preferredFrameRateRange = CAFrameRateRange(
minimum: 10, // Minimum acceptable
maximum: 30, // Maximum needed
preferred: 30 // Ideal rate
)
displayLink?.add(to: .current, forMode: .default)
}
@objc private func update(_ displayLink: CADisplayLink) {
// Update animation
updateAnimationFrame()
}
func stopAnimation() {
displayLink?.invalidate()
displayLink = nil
}
}
```
### Stop Animations When Not Visible
```swift
class AnimatedViewController: UIViewController {
private var animator: UIViewPropertyAnimator?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
startAnimations()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
stopAnimations() // Critical for energy
}
private func stopAnimations() {
animator?.stopAnimation(true)
animator = nil
}
}
```
---
## Part 7: Disk I/O Efficiency APIs
### Batch Writes
```swift
// BAD: Multiple small writes
for item in items {
let data = try JSONEncoder().encode(item)
try data.write(to: fileURL) // Writes each item separately
}
// GOOD: Single batched write
let allData = try JSONEncoder().encode(items)
try allData.write(to: fileURL) // One write operation
```
### SQLite WAL Mode
```swift
import SQLite3
// Enable Write-Ahead Logging
var db: OpaquePointer?
sqlite3_open(dbPath, &db)
var statement: OpaquePointer?
sqlite3_prepare_v2(db, "PRAGMA journal_mode=WAL", -1, &statement, nil)
sqlite3_step(statement)
sqlite3_finalize(statement)
```
### XCTStorageMetric for Testing
```swift
import XCTest
class DiskWriteTests: XCTestCase {
func testDiskWritePerformance() {
measure(metrics: [XCTStorageMetric()]) {
// Code that writes to disk
saveUserData()
}
}
}
```
---
## Part 8: Low Power Mode & Thermal Response APIs
### Low Power Mode Detection
```swift
import Foundation
class PowerStateManager {
private var cancellables = Set<AnyCancellable>()
init() {
// Check initial state
updateForPowerState()
// Observe changes
NotificationCenter.default.publisher(
for: .NSProcessInfoPowerStateDidChange
)
.sink { [weak self] _ in
self?.updateForPowerState()
}
.store(in: &cancellables)
}
private func updateForPowerState() {
if ProcessInfo.processInfo.isLowPowerModeEnabled {
reduceEnergyUsage()
} else {
restoreNormalOperation()
}
}
private func reduceEnergyUsage() {
// Increase timer intervals
// Reduce animation frame rates
// Defer network requests
// Stop location updates if not critical
// Reduce refresh frequency
}
}
```
### Thermal State Response
```swift
import Foundation
class ThermalManager {
init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(thermalStateChanged),
name: ProcessInfo.thermalStateDidChangeNotification,
object: nil
)
}
@objc private func thermalStateChanged() {
switch ProcessInfo.processInfo.thermalState {
case .nominal:
// Normal operation
restoreFullFunctionality()
case .fair:
// Slightly elevated, minor reduction
reduceNonEssentialWork()
case .serious:
// Significant reduction needed
suspendBackgroundTasks()
reduceAnimationQuality()
case .critical:
// Maximum reduction
minimizeAllActivity()
showThermalWarningIfAppropriate()
@unknown default:
break
}
}
}
```
---
## Part 9: MetricKit Monitoring APIs
The 27 cycle replaces this subscriber model with a Swift-first API (`MetricManager` + `AsyncSequence`, typed metrics incl. iOS-only background-time and pixel-luminance metrics) — see `axiom-performance (skills/metrickit-ref.md)` Part 1. The legacy setup below works on iOS 13–26.
### Basic Setup
```swift
import MetricKit
class MetricsManager: NSObject, MXMetricManagerSubscriber {
static let shared = MetricsManager()
func startMonitoring() {
MXMetricManager.shared.add(self)
}
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
processPayload(payload)
}
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
processDiagnostic(payload)
}
}
}
```
### Processing Energy Metrics
```swift
func processPayload(_ payload: MXMetricPayload) {
// CPU metrics — MXCPUMetric has no foreground/background split.
if let cpu = payload.cpuMetrics {
let totalCPUTime = cpu.cumulativeCPUTime // Measurement<UnitDuration> (iOS 13+)
let instructionsRetired = cpu.cumulativeCPUInstructions // Measurement<Unit>, dimensionless count (iOS 14+)
logMetric("cpu_time", value: totalCPUTime)
logMetric("cpu_instructions", value: instructionsRetired)
}
// Location metrics
if let location = payload.locationActivityMetrics {
let backgroundLocationTime = location.cumulativeBackgroundLocationTime
logMetric("background_location_seconds", value: backgroundLocationTime)
}
// Network metrics
if let network = payload.networkTransferMetrics {
let cellularUpload = network.cumulativeCellularUpload
let cellularDownload = network.cumulativeCellularDownload
let wifiUpload = network.cumulativeWifiUpload
let wifiDownload = network.cumulativeWifiDownload
logMetric("cellular_upload", value: cellularUpload)
logMetric("cellular_download", value: cellularDownload)
}
// Disk metrics
if let disk = payload.diskIOMetrics {
let writes = disk.cumulativeLogicalWrites
logMetric("disk_writes", value: writes)
}
// GPU metrics
if let gpu = payload.gpuMetrics {
let gpuTime = gpu.cumulativeGPUTime
logMetric("gpu_time", value: gpuTime)
}
}
```
### Xcode Organizer Integration
View field metrics in Xcode:
1. Window → Organizer
2. Select your app
3. Click "Battery Usage" in sidebar
4. Compare versions, filter by device/OS
Categories shown:
- Audio
- Networking
- Processing (CPU + GPU)
- Display
- Bluetooth
- Location
- Camera
- Torch
- NFC
- Other
---
## Part 10: Push Notifications APIs
### Alert Notifications Setup
From WWDC20-10095:
```swift
import UserNotifications
class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
func setup() {
UNUserNotificationCenter.current().delegate = self
UIApplication.shared.registerForRemoteNotifications()
}
func requestPermission() {
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .sound, .badge]
) { granted, error in
print("Permission granted: \(granted)")
}
}
}
// AppDelegate
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
sendTokenToServer(token)
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
print("Failed to register: \(error)")
}
```
### Background Push Notifications
```swift
// Handle background notification
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
// Check for content-available flag
guard let aps = userInfo["aps"] as? [String: Any],
aps["content-available"] as? Int == 1 else {
completionHandler(.noData)
return
}
Task {
do {
let hasNewData = try await fetchLatestContent()
completionHandler(hasNewData ? .newData : .noData)
} catch {
completionHandler(.failed)
}
}
}
```
### Server Payload Examples
```json
// Alert notification (user-visible)
{
"aps": {
"alert": {
"title": "New Message",
"body": "You have a new message from John"
},
"sound": "default",
"badge": 1
},
"message_id": "12345"
}
// Background notification (silent)
{
"aps": {
"content-available": 1
},
"update_type": "new_content"
}
```
### Push Priority Headers
| Priority | Header | Use Case |
|----------|--------|----------|
| High (10) | `apns-priority: 10` | Time-sensitive alerts |
| Low (5) | `apns-priority: 5` | Deferrable updates |
**Energy tip**: Use priority 5 for all non-urgent notifications. System batches low-priority pushes for energy efficiency.
---
## Troubleshooting Checklist
### Issue: App at Top of Battery Settings
- [ ] Run Power Profiler to identify dominant subsystem
- [ ] Check for timers without tolerance
- [ ] Check for polling patterns
- [ ] Check for continuous location
- [ ] Check for background audio session
- [ ] Verify BGTasks complete promptly
### Issue: Device Gets Hot
- [ ] Check GPU Power Impact for sustained high values
- [ ] Look for continuous animations
- [ ] Check for blur effects over dynamic content
- [ ] Verify Metal frame limiting
- [ ] Check CPU for tight loops
### Issue: Background Battery Drain
- [ ] Audit background modes in Info.plist
- [ ] Verify audio session deactivated when not playing
- [ ] Check location accuracy and stop calls
- [ ] Verify beginBackgroundTask calls end promptly
- [ ] Review BGTask scheduling
### Issue: High Cellular Usage
- [ ] Check allowsExpensiveNetworkAccess setting
- [ ] Verify discretionary flag on background downloads
- [ ] Look for polling patterns
- [ ] Check for large automatic downloads
---
## Expert Review Checklist
### Timers (10 items)
- [ ] Tolerance ≥10% on all timers
- [ ] Timers invalidated in deinit
- [ ] No timers running when app backgrounded
- [ ] Using Combine Timer where possible
- [ ] No sub-second intervals without justification
- [ ] Event-driven alternatives considered
- [ ] No synchronization via timer polling
- [ ] Timer invalidated before creating new one
- [ ] Repeating timers have clear stop condition
- [ ] Background timer usage justified
### Network (10 items)
- [ ] waitsForConnectivity = true
- [ ] allowsExpensiveNetworkAccess appropriate
- [ ] allowsConstrainedNetworkAccess appropriate
- [ ] Non-urgent downloads use discretionary
- [ ] Push notifications instead of polling
- [ ] Requests batched where possible
- [ ] Payloads compressed
- [ ] Background URLSession for large transfers
- [ ] Retry logic has exponential backoff
- [ ] Connection reuse via single URLSession
### Location (10 items)
- [ ] Accuracy appropriate for use case
- [ ] distanceFilter set
- [ ] Updates stopped when not needed
- [ ] pausesLocationUpdatesAutomatically = true
- [ ] Background location only if essential
- [ ] Significant-change for background
- [ ] CLMonitor for region monitoring
- [ ] Location permission matches actual need
- [ ] Stationary detection utilized
- [ ] Location icon explained to users
### Background Execution (10 items)
- [ ] endBackgroundTask called promptly
- [ ] Expiration handlers implemented
- [ ] BGTasks use requiresExternalPower when possible
- [ ] EMRCA principles followed
- [ ] Background modes limited to needed
- [ ] Audio session deactivated when idle
- [ ] Progress saved incrementally
- [ ] Tasks complete within time limits
- [ ] Low Power Mode checked before heavy work
- [ ] Thermal state monitored
### Display/GPU (10 items)
- [ ] Dark Mode supported
- [ ] Animations stop when view hidden
- [ ] Frame rates appropriate for content
- [ ] Secondary animations lower priority
- [ ] Blur effects minimized
- [ ] Metal has frame limiting
- [ ] Brightness-independent design
- [ ] No hidden animations consuming power
- [ ] GPU-intensive work has visibility checks
- [ ] ProMotion considered in frame rate decisions
---
## WWDC Session Reference
| Session | Year | Topic |
|---------|------|-------|
| 226 | 2025 | Power Profiler workflow, on-device tracing |
| 227 | 2025 | BGContinuedProcessingTask, EMRCA principles |
| 10083 | 2022 | Dark Mode, frame rates, deferral |
| 10095 | 2020 | Push notifications primer |
| 707 | 2019 | Background execution advances |
| 417 | 2019 | Battery life, MetricKit |
---
**Last Updated**: 2025-12-26
**Platforms**: iOS 26+, iPadOS 26+
skills/energy.md
# Energy Optimization
## Overview
Energy issues manifest as battery drain, hot devices, and poor App Store reviews. **Core principle**: Measure before optimizing. Use Power Profiler to identify the dominant subsystem (CPU/GPU/Network/Location/Display), then apply targeted fixes.
**Key insight**: Developers often don't know where to START auditing. This skill provides systematic diagnosis, not guesswork.
**Requirements**: iOS 26+, Xcode 26+, Power Profiler in Instruments
## Example Prompts
- "My app is always at the top of Battery Settings. How do I find what's draining power?"
- "Users report my app makes their phone hot. Where do I start debugging?"
- "I have timers and location updates. Are they causing battery drain?"
- "My app drains battery in the background even when users aren't using it."
- "How do I measure if my optimization actually improved battery life?"
---
## Red Flags — High Energy Likely
If you see ANY of these, suspect energy inefficiency:
- **Battery Settings**: Your app consistently at top of battery consumers
- **Device temperature**: Phone gets warm during normal app use
- **User reviews**: Mentions of "battery drain", "hot phone", "kills my battery"
- **Xcode Energy Gauge**: Shows sustained high or very high impact
- **Background runtime**: App runs longer than expected when not visible
- **Network activity**: Frequent small requests instead of batched operations
- **Location icon**: Appears in status bar when app shouldn't need location
#### Difference from normal energy use
- **Normal**: App uses energy during active use, minimal when backgrounded
- **Problem**: App uses significant energy even when user isn't interacting
## Mandatory First Steps
**ALWAYS run Power Profiler FIRST** before optimizing code:
### Step 1: Record a Power Trace (5 minutes)
```
1. Connect iPhone wirelessly to Xcode (wireless debugging)
2. Xcode → Product → Profile (Cmd+I)
3. Select Blank template
4. Click "+" → Add "Power Profiler" instrument
5. Optional: Add "CPU Profiler" for correlation
6. Click Record
7. Use your app normally for 2-3 minutes
8. Click Stop
```
**Why wireless**: When device is charging via cable, power metrics show 0. Use wireless debugging for accurate readings.
### Step 2: Identify Dominant Subsystem
Expand the Power Profiler track and examine per-app metrics:
| Lane | Meaning | High Value Indicates |
|------|---------|---------------------|
| CPU Power Impact | Processor activity | Computation, timers, parsing |
| GPU Power Impact | Graphics rendering | Animations, blur, Metal |
| Display Power Impact | Screen usage | Brightness, always-on content |
| Network Power Impact | Radio activity | Requests, downloads, polling |
**Look for**: Which subsystem shows highest sustained values during your app's usage.
### Step 3: Branch to Subsystem-Specific Fixes
Once you identify the dominant subsystem, use the decision trees below.
#### What this tells you
- **CPU dominant** → Check timers, polling, JSON parsing, eager loading
- **GPU dominant** → Check animations, blur effects, frame rates
- **Network dominant** → Check request frequency, polling vs push
- **Display dominant** → Check Dark Mode, brightness, screen-on time
- **Location** (shown in CPU) → Check accuracy, update frequency
#### Why diagnostics first
- Finding root cause with Power Profiler: **15-20 minutes**
- Guessing and testing random optimizations: **4+ hours, often wrong subsystem**
---
## Energy Decision Tree
```
User reports energy issue?
│
├─ CPU Power Impact dominant?
│ ├─ Continuous high impact?
│ │ ├─ Timers running? → Pattern 1: Timer Efficiency
│ │ ├─ Polling data? → Pattern 2: Push vs Poll
│ │ └─ Processing in loop? → Pattern 3: Lazy Loading
│ ├─ Spikes during specific actions?
│ │ ├─ JSON parsing? → Cache parsed results
│ │ ├─ Image processing? → Move to background, cache
│ │ └─ Database queries? → Index, batch, prefetch
│ └─ High background CPU?
│ ├─ Location updates? → Pattern 4: Location Efficiency
│ ├─ BGTasks running too long? → Pattern 5: Background Execution
│ └─ Audio session active? → Stop when not playing
│
├─ Network Power Impact dominant?
│ ├─ Many small requests?
│ │ └─ Batch into fewer large requests
│ ├─ Polling pattern detected?
│ │ └─ Convert to push notifications → Pattern 2
│ ├─ Downloads in foreground?
│ │ └─ Use discretionary background URLSession
│ └─ High cellular usage?
│ └─ Defer to WiFi when possible
│
├─ GPU Power Impact dominant?
│ ├─ Continuous animations?
│ │ └─ Stop when view not visible
│ ├─ Blur effects (UIVisualEffectView)?
│ │ └─ Reduce or remove, use solid colors
│ ├─ High frame rate animations?
│ │ └─ Audit secondary frame rates → Pattern 6
│ └─ Metal rendering?
│ └─ Implement frame limiting
│
├─ Display Power Impact dominant?
│ ├─ Light backgrounds on OLED?
│ │ └─ Implement Dark Mode (up to 70% savings)
│ ├─ High brightness content?
│ │ └─ Use darker UI elements
│ └─ Screen always on?
│ └─ Allow screen to sleep when appropriate
│
└─ Location causing drain? (check CPU lane + location icon)
├─ Continuous updates?
│ └─ Switch to significant-change monitoring
├─ High accuracy (kCLLocationAccuracyBest)?
│ └─ Reduce to kCLLocationAccuracyHundredMeters
└─ Background location?
└─ Evaluate if truly needed → Pattern 4
```
---
## Common Energy Patterns (With Fixes)
### Pattern 1: Timer Efficiency
**Problem**: Timers wake the CPU from idle states, consuming significant energy.
#### ❌ Anti-Pattern — Timer without tolerance
```swift
// BAD: Timer fires exactly every 1.0 seconds
// Prevents system from batching with other timers
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.updateUI()
}
```
#### ✅ Fix — Set tolerance for timer batching
```swift
// GOOD: 10% tolerance allows system to batch timers
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.updateUI()
}
timer.tolerance = 0.1 // 10% tolerance minimum
// BETTER: Use Combine Timer with tolerance
Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .default)
.autoconnect()
.sink { [weak self] _ in
self?.updateUI()
}
.store(in: &cancellables)
```
#### ✅ Best — Use event-driven instead of polling
```swift
// BEST: Don't use timer at all — react to events
NotificationCenter.default.publisher(for: .dataDidUpdate)
.sink { [weak self] _ in
self?.updateUI()
}
.store(in: &cancellables)
```
**Key points**:
- Set tolerance to **at least 10%** of interval
- Timer tolerance allows system to batch multiple timers into single wake
- Prefer event-driven patterns over polling timers
- Always invalidate timers when no longer needed
---
### Pattern 2: Push vs Poll
**Problem**: Polling (checking server every N seconds) keeps radios active and drains battery.
#### ❌ Anti-Pattern — Polling every 5 seconds
```swift
// BAD: Polls server every 5 seconds
// Radio stays active, massive battery drain
Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
self?.fetchLatestData() // Network request every 5 seconds
}
```
#### ✅ Fix — Use background push notifications
```swift
// GOOD: Server pushes when data changes
// Radio only active when there's actual new data
// 1. Register for remote notifications
UIApplication.shared.registerForRemoteNotifications()
// 2. Handle background notification
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
guard let _ = userInfo["content-available"] else {
completionHandler(.noData)
return
}
Task {
do {
let hasNewData = try await fetchLatestData()
completionHandler(hasNewData ? .newData : .noData)
} catch {
completionHandler(.failed)
}
}
}
```
**Server payload for background push**:
```json
{
"aps": {
"content-available": 1
},
"custom-data": "your-payload"
}
```
**Key points**:
- Background pushes are **discretionary** — system delivers at optimal time
- Use `apns-priority: 5` for non-urgent updates (energy efficient)
- Use `apns-priority: 10` only for time-sensitive alerts
- Polling every 5 seconds uses **100x more energy** than push
---
### Pattern 3: Lazy Loading & Caching
**Problem**: Loading all data upfront causes CPU spikes and memory pressure.
#### ❌ Anti-Pattern — Eager loading (from WWDC25-226)
```swift
// BAD: Creates and renders ALL views upfront
// From WWDC25-226: This caused CPU spike and hang
VStack {
ForEach(videos) { video in
VideoCardView(video: video) // Creates ALL thumbnails immediately
}
}
```
#### ✅ Fix — Lazy loading
```swift
// GOOD: Only creates visible views
// From WWDC25-226: Reduced CPU power impact from 21 to 4.3
LazyVStack {
ForEach(videos) { video in
VideoCardView(video: video) // Creates on-demand
}
}
```
#### ❌ Anti-Pattern — Repeated parsing (from WWDC25-226)
```swift
// BAD: Parses JSON file on every location update
// From WWDC25-226: Caused continuous CPU drain during commute
func videoSuggestionsForLocation(_ location: CLLocation) -> [Video] {
// Called every location change!
let data = try? Data(contentsOf: rulesFileURL)
let rules = try? JSONDecoder().decode([RecommendationRule].self, from: data)
return filteredVideos(using: rules)
}
```
#### ✅ Fix — Cache parsed data
```swift
// GOOD: Parse once, reuse cached result
// From WWDC25-226: Eliminated CPU drain
private lazy var cachedRules: [RecommendationRule] = {
let data = try? Data(contentsOf: rulesFileURL)
return (try? JSONDecoder().decode([RecommendationRule].self, from: data)) ?? []
}()
func videoSuggestionsForLocation(_ location: CLLocation) -> [Video] {
return filteredVideos(using: cachedRules) // No parsing!
}
```
**Key points**:
- Use `LazyVStack`, `LazyHStack`, `LazyVGrid` for large collections
- Cache parsed JSON, decoded data, computed results
- Move expensive operations out of frequently-called methods
---
### Pattern 4: Location Efficiency
**Problem**: Continuous location updates keep GPS active, draining battery rapidly.
#### ❌ Anti-Pattern — Continuous high-accuracy updates
```swift
// BAD: Continuous updates with best accuracy
// GPS stays active constantly, massive battery drain
let locationManager = CLLocationManager()
locationManager.desiredAccuracy = kCLLocationAccuracyBest
locationManager.startUpdatingLocation() // Never stops!
```
#### ✅ Fix — Appropriate accuracy and significant-change
```swift
// GOOD: Reduced accuracy, significant-change monitoring
let locationManager = CLLocationManager()
// Use appropriate accuracy (100m is fine for most apps)
locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
// Use distance filter to reduce updates
locationManager.distanceFilter = 100 // Only update every 100 meters
// For background: Use significant-change monitoring
locationManager.startMonitoringSignificantLocationChanges()
// Stop when done
func stopTracking() {
locationManager.stopUpdatingLocation()
locationManager.stopMonitoringSignificantLocationChanges()
}
```
#### ✅ Better — iOS 26+ CLLocationUpdate with stationary detection
```swift
// BEST: Modern async API with automatic stationary detection
for try await update in CLLocationUpdate.liveUpdates() {
if update.stationary {
// Device stopped moving — system pauses updates automatically
// Switch to CLMonitor for region monitoring
break
}
handleLocation(update.location)
}
```
**Accuracy comparison (battery impact)**:
| Accuracy | Battery Impact | Use Case |
|----------|---------------|----------|
| `kCLLocationAccuracyBest` | Very High | Navigation apps only |
| `kCLLocationAccuracyNearestTenMeters` | High | Fitness tracking |
| `kCLLocationAccuracyHundredMeters` | Medium | Store locators |
| `kCLLocationAccuracyKilometer` | Low | Weather apps |
| Significant-change | Very Low | Background updates |
---
### Pattern 5: Background Execution (EMRCA)
**Problem**: Background tasks that run too long or too often drain battery.
#### EMRCA Principles (from WWDC25-227)
Your background work must be:
- **E**fficient — Design lightweight, purpose-driven tasks
- **M**inimal — Keep background work to a minimum
- **R**esilient — Save incremental progress; respond to expiration signals
- **C**ourteous — Honor user preferences and system conditions
- **A**daptive — Understand and adapt to system priorities
#### ❌ Anti-Pattern — Long-running background task
```swift
// BAD: Requests unlimited background time
// System will terminate after ~30 seconds anyway
var backgroundTask: UIBackgroundTaskIdentifier = .invalid
func applicationDidEnterBackground(_ application: UIApplication) {
backgroundTask = application.beginBackgroundTask {
// Expiration handler — but task runs too long
}
// Long operation that may not complete
performLongOperation()
}
```
#### ✅ Fix — Proper background task handling
```swift
// GOOD: Finish quickly, save progress, notify system
var backgroundTask: UIBackgroundTaskIdentifier = .invalid
func applicationDidEnterBackground(_ application: UIApplication) {
backgroundTask = application.beginBackgroundTask(withName: "Save State") { [weak self] in
// Expiration handler — clean up immediately
self?.saveProgress()
if let task = self?.backgroundTask {
application.endBackgroundTask(task)
}
self?.backgroundTask = .invalid
}
// Quick operation
saveEssentialState()
// End task as soon as done — don't wait for expiration
application.endBackgroundTask(backgroundTask)
backgroundTask = .invalid
}
```
#### ✅ For Long Operations — Use BGProcessingTask
```swift
// BEST: Let system schedule at optimal time (charging, WiFi)
func scheduleBackgroundProcessing() {
let request = BGProcessingTaskRequest(identifier: "com.app.maintenance")
request.requiresNetworkConnectivity = true
request.requiresExternalPower = true // Only when charging
try? BGTaskScheduler.shared.submit(request)
}
// Register handler at app launch
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.app.maintenance",
using: nil
) { task in
self.handleMaintenance(task: task as! BGProcessingTask)
}
```
#### ✅ iOS 26+ — BGContinuedProcessingTask for user-initiated work
```swift
// NEW iOS 26: Continue user-initiated tasks with progress UI
let request = BGContinuedProcessingTaskRequest(
identifier: "com.app.export",
title: "Exporting Photos",
subtitle: "23 of 100 photos"
)
try? BGTaskScheduler.shared.submit(request)
```
---
### Pattern 6: Frame Rate Auditing
**Problem**: Secondary animations running at higher frame rates than needed increase GPU power.
#### ❌ Anti-Pattern — Uncontrolled frame rates
```swift
// BAD: Secondary animation runs at 60fps
// When primary content only needs 30fps, this wastes power
UIView.animate(withDuration: 2.0, delay: 0, options: [.repeat]) {
self.subtitleLabel.alpha = 0.5
} completion: { _ in
self.subtitleLabel.alpha = 1.0
}
```
#### ✅ Fix — Control frame rate with CADisplayLink
```swift
// GOOD: Explicitly set preferred frame rate
let displayLink = CADisplayLink(target: self, selector: #selector(updateAnimation))
displayLink.preferredFrameRateRange = CAFrameRateRange(
minimum: 10,
maximum: 30, // Match primary content
preferred: 30
)
displayLink.add(to: .current, forMode: .default)
```
**From WWDC22-10083**: Up to **20% battery savings** by aligning secondary animation frame rates with primary content.
### Pattern 7: Respond to the System Resource-Pressure Signal `OS27`
**Problem**: The system can now tell your app it prefers *reduced resource usage* — under thermal, battery, or performance pressure. An app that ignores the hint keeps spending power the device is actively trying to reclaim.
`systemPrefersReducedResourceUsage` surfaces the same signal on every UI layer. The SwiftUI environment value is all-platform (`@available(anyAppleOS 27, *)`); the UIKit trait and typed notification live wherever UIKit does (iOS/macCatalyst/tvOS/visionOS 27 — not watchOS). When it reads `true`, cut discretionary work: pause non-essential animations, lower frame rates, defer prefetching and background refresh, drop to lower-fidelity assets.
#### ✅ SwiftUI — read the environment value
```swift
@available(anyAppleOS 27, *)
struct Dashboard: View {
@Environment(\.systemPrefersReducedResourceUsage) private var reduceUsage
var body: some View {
LiveTicker()
.animation(reduceUsage ? nil : .default, value: reduceUsage)
}
}
```
#### ✅ UIKit — read the trait and react to changes
```swift
@available(iOS 27, tvOS 27, visionOS 27, *)
@MainActor func configure(_ vc: UIViewController) {
applyPowerBudget(vc.traitCollection.systemPrefersReducedResourceUsage)
vc.registerForTraitChanges([UITraitSystemPrefersReducedResourceUsage.self]) { (v: UIViewController, _) in
applyPowerBudget(v.traitCollection.systemPrefersReducedResourceUsage)
}
}
```
For app-level reactions outside a view hierarchy, observe the typed notification `.systemPrefersReducedResourceUsageDidChange` (a `NotificationCenter.MainActorMessage`) — `NotificationCenter.default.addObserver(of: UIApplication.shared, for: .systemPrefersReducedResourceUsageDidChange) { _ in … }`. See `axiom-concurrency (swift-concurrency-ref)` for the typed-notification observing idiom.
---
## Audit Checklists
### Timer Audit
- [ ] All timers have tolerance set (≥10% of interval)?
- [ ] Timers invalidated when no longer needed?
- [ ] Using Combine Timer instead of NSTimer where possible?
- [ ] No polling patterns that could use push notifications?
- [ ] Timers stopped when app enters background?
### Network Audit
- [ ] Requests batched instead of many small requests?
- [ ] Using discretionary URLSession for non-urgent downloads?
- [ ] `waitsForConnectivity` set to avoid failed connection attempts?
- [ ] `allowsExpensiveNetworkAccess` set to false for deferrable work?
- [ ] Push notifications instead of polling?
### Location Audit
- [ ] Using appropriate accuracy (not `kCLLocationAccuracyBest` unless navigation)?
- [ ] `distanceFilter` set to reduce update frequency?
- [ ] Stopping updates when no longer needed?
- [ ] Using significant-change for background updates?
- [ ] Background location justified and explained to users?
### Background Execution Audit
- [ ] `endBackgroundTask` called promptly when work completes?
- [ ] Long operations use `BGProcessingTask` with `requiresExternalPower`?
- [ ] Background modes in Info.plist limited to what's actually needed?
- [ ] Audio session deactivated when not playing?
- [ ] EMRCA principles followed?
### Display/GPU Audit
- [ ] Dark Mode supported (70% OLED power savings)?
- [ ] Animations stopped when view not visible?
- [ ] Secondary animations use appropriate frame rates?
- [ ] Blur effects minimized or removed?
- [ ] Metal rendering has frame limiting?
### Disk I/O Audit
- [ ] Writes batched instead of frequent small writes?
- [ ] SQLite using WAL journaling mode?
- [ ] Avoiding rapid file creation/deletion?
- [ ] Using SwiftData/Core Data instead of serialized files for frequent updates?
---
## Pressure Scenarios
### Scenario 1: "Just poll every 5 seconds for real-time updates"
**The temptation**: "Push notifications are complex. Polling is simpler."
**The reality**:
- Polling every 5 seconds: Radio active **100% of time**
- Push notifications: Radio active **only when data changes**
- Users WILL see your app at top of Battery Settings
- App Store reviews WILL mention "battery hog"
**Time cost comparison**:
- Implement polling: 30 minutes
- Implement push: 2-4 hours
- Fix bad reviews + reputation damage: Weeks
**Pushback template**: "Push notification setup takes a few hours, but polling will guarantee we're at the top of Battery Settings. Users actively uninstall apps that drain battery. The 2-hour investment prevents ongoing reputation damage."
---
### Scenario 2: "Use continuous location for best accuracy"
**The temptation**: "Users expect accurate location. Let's use `kCLLocationAccuracyBest`."
**The reality**:
- `kCLLocationAccuracyBest`: GPS + WiFi + Cellular triangulation = **massive drain**
- `kCLLocationAccuracyHundredMeters`: Good enough for 95% of use cases
- Location icon in status bar = users checking Battery Settings
**Time cost comparison**:
- Implement high accuracy: 10 minutes
- Debug "why does my app drain battery" complaints: Hours
- Refactor to appropriate accuracy: 30 minutes
**Pushback template**: "100-meter accuracy is sufficient for [use case]. Navigation apps like Google Maps need best accuracy, but we're showing [store locations / weather / general area]. The accuracy difference is imperceptible to users, but battery difference is massive."
---
### Scenario 3: "Keep animations running, users expect smooth UI"
**The temptation**: "Animations make the app feel alive and polished."
**The reality**:
- Animations running when view not visible = pure waste
- High frame rate secondary animations = GPU drain
- GPU power is significant portion of total device power
**Time cost comparison**:
- Add animation: 15 minutes
- Add visibility checks: 5 minutes extra
- Debug "phone gets hot" reports: Hours
**Pushback template**: "We can keep the animation, but should pause it when the view isn't visible. This is a 5-minute change that prevents GPU drain when users aren't looking at the screen."
---
### Scenario 4: "Ship now, optimize later"
**The temptation**: "Energy optimization is polish. We can do it in v1.1."
**The reality**:
- Battery drain is **immediately visible** to users
- First impressions drive reviews
- "Battery hog" reputation is hard to shake
- Power Profiler baseline takes **15 minutes**
**Time cost comparison**:
- Power Profiler check before launch: 15 minutes
- Fix energy issues post-launch: Days (plus reputation damage)
- Regain user trust: Months
**Pushback template**: "A 15-minute Power Profiler session before launch catches major energy issues. If we ship with battery problems, users will see us at top of Battery Settings on day one and leave 1-star reviews. Let me do a quick check — it's faster than damage control."
---
## Real-World Examples
### Example 1: Video Streaming App with Eager Loading (WWDC25-226)
**Symptom**: CPU power impact jumped from 1 to 21 when opening Library pane. UI hung.
**Diagnosis using Power Profiler**:
1. Recorded trace while opening Library pane
2. CPU Power Impact lane showed massive spike
3. Time Profiler showed `VideoCardView` body called hundreds of times
4. Root cause: `VStack` creating ALL video thumbnails upfront
**Fix**:
```swift
// Before: VStack (eager)
VStack {
ForEach(videos) { video in
VideoCardView(video: video)
}
}
// After: LazyVStack (on-demand)
LazyVStack {
ForEach(videos) { video in
VideoCardView(video: video)
}
}
```
**Result**: CPU power impact dropped from 21 to 4.3. UI no longer hung.
---
### Example 2: Location-Based Suggestions with Repeated Parsing (WWDC25-226)
**Symptom**: User commuting reported massive battery drain. Developer couldn't reproduce at desk.
**Diagnosis using on-device Power Profiler**:
1. User collected trace during commute (Settings → Developer → Performance Trace)
2. Trace showed periodic CPU spikes correlating with movement
3. Time Profiler showed `videoSuggestionsForLocation` consuming CPU
4. Root cause: JSON file parsed on EVERY location update
**Fix**:
```swift
// Before: Parse on every call
func videoSuggestionsForLocation(_ location: CLLocation) -> [Video] {
let data = try? Data(contentsOf: rulesFileURL)
let rules = try? JSONDecoder().decode([RecommendationRule].self, from: data)
return filteredVideos(using: rules)
}
// After: Parse once, cache
private lazy var cachedRules: [RecommendationRule] = {
let data = try? Data(contentsOf: rulesFileURL)
return (try? JSONDecoder().decode([RecommendationRule].self, from: data)) ?? []
}()
func videoSuggestionsForLocation(_ location: CLLocation) -> [Video] {
return filteredVideos(using: cachedRules)
}
```
**Result**: Eliminated CPU spikes during movement. Battery drain resolved.
---
### Example 3: Music App with Always-Active Audio Session
**Symptom**: App drains battery even when not playing music.
**Diagnosis**:
1. Power Profiler showed sustained background CPU activity
2. Audio session remained active after playback stopped
3. System kept audio hardware powered on
**Fix**:
```swift
// Before: Never deactivate
func playTrack(_ track: Track) {
try? AVAudioSession.sharedInstance().setActive(true)
player.play()
}
func stopPlayback() {
player.stop()
// Audio session still active!
}
// After: Deactivate when done
func stopPlayback() {
player.stop()
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
// Even better: Use AVAudioEngine auto-shutdown
let engine = AVAudioEngine()
engine.isAutoShutdownEnabled = true // Automatically powers down when idle
```
**Result**: Background audio hardware powered down. Battery drain eliminated.
---
## Responding to Low Power Mode
Detect and adapt when user enables Low Power Mode:
```swift
// Check current state
if ProcessInfo.processInfo.isLowPowerModeEnabled {
reduceEnergyUsage()
}
// React to changes
NotificationCenter.default.publisher(for: .NSProcessInfoPowerStateDidChange)
.sink { [weak self] _ in
if ProcessInfo.processInfo.isLowPowerModeEnabled {
self?.reduceEnergyUsage()
} else {
self?.restoreNormalOperation()
}
}
.store(in: &cancellables)
func reduceEnergyUsage() {
// Pause optional activities
// Reduce animation frame rates
// Increase timer intervals
// Defer network requests
// Stop location updates if not critical
}
```
---
## Monitoring Energy in Production
### MetricKit Setup
```swift
import MetricKit
class EnergyMetricsManager: NSObject, MXMetricManagerSubscriber {
static let shared = EnergyMetricsManager()
func startMonitoring() {
MXMetricManager.shared.add(self)
}
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
if let cpuMetrics = payload.cpuMetrics {
// Monitor CPU time
let foregroundCPU = cpuMetrics.cumulativeCPUTime
logMetric("foreground_cpu", value: foregroundCPU)
}
if let locationMetrics = payload.locationActivityMetrics {
// Monitor location usage
let backgroundLocation = locationMetrics.cumulativeBackgroundLocationTime
logMetric("background_location", value: backgroundLocation)
}
}
}
}
```
### Xcode Organizer
Check **Battery Usage** pane in Xcode Organizer for field data:
- Foreground vs background energy breakdown
- Category breakdown (Audio, Networking, Processing, Display, etc.)
- Version comparison to detect regressions
---
## Quick Reference
### Power Profiler Workflow
```
1. Connect device wirelessly
2. Product → Profile → Blank → Add Power Profiler
3. Record 2-3 minutes of usage
4. Identify dominant subsystem (CPU/GPU/Network/Display)
5. Apply targeted fix from patterns above
6. Record again to verify improvement
```
### Key Energy Savings
| Optimization | Potential Savings |
|--------------|------------------|
| Dark Mode on OLED | Up to 70% display power |
| Frame rate alignment | Up to 20% GPU power |
| Push vs poll | 100x network efficiency |
| Location accuracy reduction | 50-90% GPS power |
| Timer tolerance | Significant CPU savings |
| Lazy loading | Eliminates startup CPU spikes |
## Resources
**WWDC**: 2025-226, 2025-227, 2022-10083, 2020-10095, 2019-417
**Skills**: skills/energy-ref.md, skills/energy-diag.md, skills/performance-profiling.md, skills/memory-debugging.md
skills/hang-diagnostics.md
# Hang Diagnostics
Systematic diagnosis and resolution of app hangs. A hang occurs when the main thread is blocked for more than 1 second, making the app unresponsive to user input.
## Why xcsym rejected my hang .ips
xcsym's `crash` subcommand explicitly rejects `.ips` files of type `hang` because hang analysis has a different workflow from crash analysis. If xcsym returned `{"error":"hang_report"}` (bug_type=298), you're in the right place — this skill is the authoritative path for hang diagnosis. See `axiom-tools (skills/xcsym-ref.md)` for the crash-focused workflow.
## Red Flags — Check This Skill When
| Symptom | This Skill Applies |
|---------|-------------------|
| App freezes briefly during use | Yes — likely hang |
| UI doesn't respond to touches | Yes — main thread blocked |
| "App not responding" system dialog | Yes — severe hang |
| Xcode Organizer shows hang diagnostics | Yes — field hang reports |
| MetricKit MXHangDiagnostic received | Yes — aggregated hang data |
| Animations stutter or skip | Maybe — could be hitch, not hang |
| App feels slow but responsive | No — performance issue, not hang |
## What Is a Hang
A **hang** is when the main runloop cannot process events for more than 1 second. The user taps, but nothing happens.
```
User taps → Main thread busy/blocked → Event queued → 1+ second delay → HANG
```
**Key distinction**: The main thread handles ALL user input. If it's busy or blocked, the entire UI freezes.
### Hang vs Hitch vs Lag
| Issue | Duration | User Experience | Tool |
|-------|----------|-----------------|------|
| **Hang** | >1 second | App frozen, unresponsive | Time Profiler, System Trace |
| **Hitch** | 1-3 frames (16-50ms) | Animation stutters | Animation Hitches instrument |
| **Lag** | 100-500ms | Feels slow but responsive | Time Profiler |
**This skill covers hangs.** For hitches, see `axiom-swiftui` (performance reference). For general lag, see `axiom-performance (skills/performance-profiling.md)`.
## The Two Causes of Hangs
Every hang has one of two root causes:
### 1. Main Thread Busy
The main thread is doing work instead of processing events.
**Subcategories**:
| Type | Example | Fix |
|------|---------|-----|
| **Proactive work** | Pre-computing data user hasn't requested | Lazy initialization, compute on demand |
| **Irrelevant work** | Processing all notifications, not just relevant ones | Filter notifications, targeted observers |
| **Suboptimal API** | Using blocking API when async exists | Switch to async API |
### 2. Main Thread Blocked
The main thread is waiting for something else.
**Subcategories**:
| Type | Example | Fix |
|------|---------|-----|
| **Synchronous IPC** | Calling system service synchronously | Use async API variant |
| **File I/O** | `Data(contentsOf:)` on main thread | Move to background queue |
| **Network** | Synchronous URL request | Use URLSession async |
| **Lock contention** | Waiting for lock held by background thread | Reduce critical section, use actors |
| **Semaphore/dispatch_sync** | Blocking on background work | Restructure to async completion |
## Decision Tree — Diagnosing Hangs
```
START: App hangs reported
│
├─→ Do you have hang diagnostics from Organizer or MetricKit?
│ │
│ ├─→ YES: Examine stack trace
│ │ │
│ │ ├─→ Stack shows your code running
│ │ │ → BUSY: Main thread doing work
│ │ │ → Profile with Time Profiler
│ │ │
│ │ └─→ Stack shows waiting (semaphore, lock, dispatch_sync)
│ │ → BLOCKED: Main thread waiting
│ │ → Profile with System Trace
│ │
│ └─→ NO: Can you reproduce?
│ │
│ ├─→ YES: Profile with Time Profiler first
│ │ │
│ │ ├─→ High CPU on main thread
│ │ │ → BUSY: Optimize the work
│ │ │
│ │ └─→ Low CPU, thread blocked
│ │ → Use System Trace to find what's blocking
│ │
│ └─→ NO: Enable MetricKit in app
│ → Wait for field reports
│ → Check Organizer > Hangs
```
## Tool Selection
| Scenario | Primary Tool | Why |
|----------|-------------|-----|
| **Reproduces locally** | Time Profiler | See exactly what main thread is doing |
| **Blocked thread suspected** | System Trace | Shows thread state, lock contention |
| **Field reports only** | Xcode Organizer | Aggregated hang diagnostics |
| **Want in-app data** | MetricKit | MXHangDiagnostic with call stacks |
| **Need precise timing** | System Trace | Nanosecond-level thread analysis |
| **Re-scope a known hang to app code** | xcprof | Auto-flags candidate stalls; `--start-ms/--end-ms` window + `--user-binary` attribution (see Hang Window Workflow) |
| **User can reproduce, you can't** | sysdiagnose triggered during the hang | Stackshot has per-thread backtraces of every process (see Getting Hang Data from End Users) |
## Time Profiler Workflow for Hangs
1. **Launch Instruments** → Select Time Profiler template
2. **Record during hang** → Reproduce the freeze
3. **Stop recording** → Find the hang period in timeline
4. **Select hang region** → Drag to select frozen timespan
5. **Examine call tree** → Look for main thread work
**What to look for**:
- Functions with high "Self Time" on main thread
- Unexpectedly deep call stacks
- System calls that shouldn't be on main thread
## Hang Window Workflow
`xcprof analyze` runs main-thread hang detection automatically on every invocation (not opt-in): the `## Main thread (approximate)` section reports the largest gap between consecutive main-thread samples (`max gap`) and a `candidate stalls` count — a strong signal that a hang occurred and how long the worst one was. It's *approximate* because cpu-profile only samples running threads, so a large gap is a candidate stall, not a confirmed one. The actionable follow-up is to re-scope to the hang window and attribute the samples to your own code.
**Step 1 — Bound the window.** xcprof reports the stall's *duration* (`max gap`), not its start time, so estimate the window from when you observed the freeze: a MetricKit hang report's timestamp, a user-visible stall, or the Instruments timeline (`--open`). Example: a ~5s freeze around the 2s mark → window ≈ 2000–7000ms.
**Step 2 — Re-scope and attribute to app code.**
```sh
xcprof analyze MyApp.trace \
--start-ms 2000 --end-ms 7000 \
--user-binary MyApp
```
`--start-ms`/`--end-ms` restrict the sample set to that window (echoed back as a `scope:` line). The `## Top user-code frames` table is emitted on every run; `--user-binary` (comma-separated) just *sharpens* it — narrowing user-code attribution to the named binaries plus the recording target, so the table lists your app's functions instead of every non-system frame.
**Expected output** (shape — real output is markdown sections + tables):
```
## Summary
- duration: 12.400s · mode: immediate · end: time-limit
- scope: 2000–7000ms (812 samples in window)
## Main thread (approximate)
- samples: 812 · cpu share: 71.2% · max gap: 4980ms (threshold 250ms) · candidate stalls: 1
## Top user-code frames
| function | binary | self | inclusive |
|---|---|---|---|
| ImageStore.thumbnail(for:) | MyApp | 58.0% (~2900ms) | 62.0% (~3100ms) |
| FeedView.body.getter | MyApp | 12.0% (~600ms) | 24.0% (~1200ms) |
```
The answer is now "`ImageStore.thumbnail(for:)` runs ~2.9s on the main thread" (→ move the decode off-main), not an opaque deepest system frame.
> Release builds without symbols attribute nothing ("none attributed"). Pass `--dsym <path>` (or rely on Spotlight UUID discovery) so frames resolve to names.
## System Trace Workflow for Blocked Hangs
1. **Launch Instruments** → Select System Trace template
2. **Record during hang** → Capture thread states
3. **Find main thread** → Filter to main thread
4. **Look for red/orange** → Blocked states
5. **Examine blocking reason** → Lock, semaphore, IPC
**Thread states**:
- **Running (blue)**: Executing code
- **Preempted (orange)**: Runnable but not scheduled
- **Blocked (red)**: Waiting for resource
## Common Hang Patterns and Fixes
### Pattern 1: Synchronous File I/O
**Before (hangs)**:
```swift
// Main thread blocks on file read
func loadUserData() {
let data = try! Data(contentsOf: largeFileURL) // BLOCKS
processData(data)
}
```
**After (async)**:
```swift
func loadUserData() {
// `Task.detached` is intentional — `Task {}` would inherit the caller's
// @MainActor isolation and run the file I/O on main. In Swift 6.2+,
// prefer marking a helper `@concurrent` instead of detached.
Task.detached {
let data = try Data(contentsOf: largeFileURL)
await MainActor.run {
self.processData(data)
}
}
}
```
### Pattern 2: Unfiltered Notification Observer
**Before (processes all)**:
```swift
NotificationCenter.default.addObserver(
self,
selector: #selector(handleChange),
name: .NSManagedObjectContextObjectsDidChange,
object: nil // Receives ALL contexts
)
```
**After (filtered)**:
```swift
NotificationCenter.default.addObserver(
self,
selector: #selector(handleChange),
name: .NSManagedObjectContextObjectsDidChange,
object: relevantContext // Only this context
)
```
### Pattern 3: Expensive Formatter Creation
**Before (creates each time)**:
```swift
func formatDate(_ date: Date) -> String {
let formatter = DateFormatter() // EXPENSIVE
formatter.dateStyle = .medium
return formatter.string(from: date)
}
```
**After (cached)**:
```swift
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}()
func formatDate(_ date: Date) -> String {
Self.dateFormatter.string(from: date)
}
```
### Pattern 4: dispatch_sync to Main Thread
**Before (deadlock risk)**:
```swift
// From background thread
DispatchQueue.main.sync { // BLOCKS if main is blocked
updateUI()
}
```
**After (async)**:
```swift
DispatchQueue.main.async {
self.updateUI()
}
```
### Pattern 5: Semaphore for Async Result
**Before (blocks main thread)**:
```swift
func fetchDataSync() -> Data {
let semaphore = DispatchSemaphore(value: 0)
var result: Data?
URLSession.shared.dataTask(with: url) { data, _, _ in
result = data
semaphore.signal()
}.resume()
semaphore.wait() // BLOCKS MAIN THREAD
return result!
}
```
**After (async/await)**:
```swift
func fetchData() async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
```
### Pattern 6: Lock Contention
**Before (shared lock)**:
```swift
class DataManager {
private let lock = NSLock()
private var cache: [String: Data] = [:]
func getData(for key: String) -> Data? {
lock.lock() // Main thread waits for background
defer { lock.unlock() }
return cache[key]
}
}
```
**After (actor)**:
```swift
actor DataManager {
private var cache: [String: Data] = [:]
func getData(for key: String) -> Data? {
cache[key] // Actor serializes access safely
}
}
```
### Pattern 7: App Launch Hang (Watchdog)
**Before (too much work)**:
```swift
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
loadAllUserData() // Expensive
setupAnalytics() // Network calls
precomputeLayouts() // CPU intensive
return true
}
```
**After (deferred)**:
```swift
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Only essential setup
setupMinimalUI()
return true
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Defer non-essential work
Task {
await loadUserDataInBackground()
}
}
```
### Pattern 8: Image Processing on Main Thread
**Before (blocks UI)**:
```swift
func processImage(_ image: UIImage) {
let filtered = applyExpensiveFilter(image) // BLOCKS
imageView.image = filtered
}
```
**After (background processing)**:
```swift
func processImage(_ image: UIImage) {
imageView.image = placeholder
// `Task.detached` here because the enclosing function is @MainActor-isolated;
// `Task {}` would inherit isolation and run the filter on main. In Swift 6.2+,
// prefer making the filter `@concurrent` and using a regular `Task {}`.
Task.detached(priority: .userInitiated) {
let filtered = applyExpensiveFilter(image)
await MainActor.run {
self.imageView.image = filtered
}
}
}
```
## Xcode Organizer Hang Diagnostics
**Window > Organizer > Select App > Hangs**
The Organizer shows aggregated hang data from users who opted into sharing diagnostics.
**Reading the report**:
1. **Hang Rate**: Hangs per day per device
2. **Call Stack**: Where the hang occurred
3. **Device/OS breakdown**: Which configurations affected
**Interpreting call stacks**:
- **Your code at top**: Main thread busy with your work
- **System API at top**: You called blocking API on main thread
- **pthread_mutex/semaphore**: Lock contention or explicit waiting
The Xcode 27 Organizer goes further: the redesigned Overview pairs the hang-rate chart with the underlying diagnostics on one screen, Metric Goals calibrate an achievable hang-rate target against similar apps and your own baselines, and **Generate Recommendations** runs an agentic analysis over the diagnostic data to localize the hang and propose fixes. A new hitches metric also surfaces choppy animations beyond scrolling. See `axiom-performance (skills/performance-profiling.md)` for the Instruments-27 side (Swift executors instrument for main-actor congestion, Inspector for blocked-thread syscalls).
## MetricKit Hang Diagnostics
On the 27 cycle, hang diagnostics arrive as typed `DiagnosticReport` values (`OS27` — not watchOS/tvOS):
```swift
import MetricKit
let manager = MetricManager() // keep alive
for await report in manager.diagnosticReports {
if case .hang(let hang) = report.result {
uploadHangDiagnostic(duration: hang.hangDuration,
callStack: hang.callStackTree)
}
}
```
`report.environment` includes the signpost intervals and reported app states active around the hang — see `axiom-performance (skills/metrickit-ref.md)` Part 1.
On earlier releases, adopt the legacy subscriber:
```swift
import MetricKit
class MetricsSubscriber: NSObject, MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
if let hangDiagnostics = payload.hangDiagnostics {
for diagnostic in hangDiagnostics {
analyzeHang(diagnostic)
}
}
}
}
private func analyzeHang(_ diagnostic: MXHangDiagnostic) {
// Duration of the hang
let duration = diagnostic.hangDuration
// Call stack tree (needs symbolication)
let callStack = diagnostic.callStackTree
// Send to your analytics
uploadHangDiagnostic(duration: duration, callStack: callStack)
}
}
```
**Key MXHangDiagnostic properties**:
- `hangDuration`: How long the hang lasted
- `callStackTree`: MXCallStackTree with frames
There is no built-in grouping identifier — derive your own signature from the symbolicated call stack to group similar hangs.
## Watchdog Terminations
The watchdog kills apps that hang during key transitions:
| Transition | Time Limit | Consequence |
|------------|-----------|-------------|
| **App launch** | ~20 seconds | App killed, crash logged |
| **Background transition** | ~5 seconds | App killed |
| **Foreground transition** | ~10 seconds | App killed |
**Watchdog disabled in**:
- Simulator
- Debugger attached
- Development builds (sometimes)
**Watchdog kills are logged as crashes** with exception type `EXC_CRASH (SIGKILL)` and termination reason code `0x8badf00d` ("ate bad food"), namespace `SPRINGBOARD` (or `FRONTBOARD`). Don't confuse it with `Namespace RUNNINGBOARD, Code 0xDEAD10CC` — that is a different termination (the app held a file or SQLite lock while being suspended), not a watchdog timeout.
**A hang with no watchdog kill is normal.** Enforcement centers on launch (`scene-create`) and lifecycle transitions; mid-session responsiveness monitoring (`scene-update`) is not guaranteed to fire for every frozen app. Treat a missing watchdog report as no signal — not as evidence the app isn't hanging.
## Getting Hang Data from End Users
A hang raises no signal or exception, so crash SDKs (Bugsnag, Sentry, Crashlytics) report nothing unless their separate app-hang detection is enabled. Two capture paths need no developer tools on the user's device:
### sysdiagnose stackshot — must be captured DURING the hang
sysdiagnose includes a stackshot (`stacks-*.ips`): per-thread backtraces of every running process at the moment of capture — the iOS equivalent of Android's `bugreport` thread dump. It is a snapshot: it only shows the hang if triggered while the app is frozen.
1. While the app is hung — press both volume buttons + the side button together briefly; a short vibration confirms capture started
2. Wait for the archive to be written (up to ~10 minutes)
3. Retrieve: Settings > Privacy & Security > Analytics & Improvements > Analytics Data > `sysdiagnose_….tar.gz` — share via AirDrop
4. Stackshot frames are unsymbolicated (program-counter offsets) — symbolicate against the app and framework dSYMs
A sysdiagnose captured after the app recovers or relaunches shows nothing useful about the hang — the most common reason "sysdiagnose has no stacks for my process".
### Analytics Data .ips reports
The same Settings > Analytics Data list holds `.ips` reports users can share directly: crashes, watchdog kills (`0x8badf00d`), and hang reports. An empty list is no signal (see Watchdog Terminations above).
**Prefer MetricKit for ongoing capture** — MXHangDiagnostic needs no user action at all; see the MetricKit section above and `axiom-performance (skills/metrickit-ref.md)`.
## Pressure Scenarios
### Scenario 1: Manager Says "Just Add a Loading Spinner"
**Situation**: App hangs during data load. Manager suggests adding spinner to "fix" it.
**Why this fails**: Adding a spinner doesn't prevent the hang—the UI still freezes, the spinner won't animate, and the app remains unresponsive.
**Correct response**: "A spinner won't animate during a hang because the main thread is blocked. We need to move this work off the main thread so the spinner can actually spin and the app stays responsive."
### Scenario 2: "It Works Fine in Testing"
**Situation**: QA can't reproduce the hang. Logs show it happens in production.
**Analysis**:
1. Field devices have different data sizes
2. Network conditions vary (slow connection = longer sync)
3. Background apps consume memory/CPU
4. Watchdog is disabled in debug builds
**Action**:
- Add MetricKit to capture field diagnostics
- Test with production-sized datasets
- Test without debugger attached
- Check Organizer for hang reports
### Scenario 3: "We've Always Done It This Way"
**Situation**: Legacy code calls synchronous API on main thread. Refactoring is "too risky."
**Why it matters**: Even if it worked before:
- Data may have grown larger
- OS updates may have changed timing
- New devices have different characteristics
- Users notice more as apps get faster
**Approach**:
1. Add metrics to measure current hang rate
2. Refactor incrementally with feature flags
3. A/B test to show improvement
4. Document risk of not fixing
## Anti-Patterns to Avoid
| Anti-Pattern | Why It's Wrong | Instead |
|--------------|----------------|---------|
| `DispatchQueue.main.sync` from background | Can deadlock, always blocks | Use `.async` |
| Semaphore to convert async to sync | Blocks calling thread | Stay async with completion/await |
| File I/O on main thread | Unpredictable latency | Background queue |
| Unfiltered notification observer | Processes irrelevant events | Filter by object/name |
| Creating formatters in loops | Expensive initialization | Cache and reuse |
| Synchronous network request | Blocks on network latency | URLSession async |
## Hang Prevention Checklist
Before shipping, verify:
- [ ] No `Data(contentsOf:)` or file reads on main thread
- [ ] No `DispatchQueue.main.sync` from background threads
- [ ] No semaphore.wait() on main thread
- [ ] Formatters (DateFormatter, NumberFormatter) are cached
- [ ] Notification observers filter appropriately
- [ ] Launch work is minimized (defer non-essential)
- [ ] Image processing happens off main thread
- [ ] Database queries don't run on main thread
- [ ] MetricKit adopted for field diagnostics
## Resources
**WWDC**: 2021-10258, 2022-10082, 2026-268
**Docs**: /xcode/analyzing-responsiveness-issues-in-your-shipping-app, /metrickit/mxhangdiagnostic
**Skills**: axiom-performance (skills/metrickit-ref.md), axiom-performance (skills/performance-profiling.md), axiom-concurrency, axiom-build (skills/lldb.md) (interactive thread inspection at freeze point)
skills/memory-auditor.md
<!-- GENERATED from agents/memory-auditor.md by scripts/build-inlined-auditors.ts — do not edit. -->
# Memory Auditor
**Claude Code** — launch the `memory-auditor` agent, or run `/axiom:audit memory`. It runs this procedure in an isolated context with its own model tier.
**Every other harness** — follow this file inline. It is the same procedure, and it needs only file search and read.
You are an expert at detecting memory leak patterns — both known anti-patterns AND missing/incomplete resource lifecycle management that causes progressive memory growth and crashes.
## Tool Use Is Mandatory
Run every Glob, Grep, and Read this prompt lists. Do not reason from training data instead of scanning.
- Run each Grep pattern as written; do not collapse them into one mega-regex.
- Run the Read verifications each section calls for.
- "Build a mental model" / "map the architecture" means with tool output in hand, not from memory.
## Files to Exclude
Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
## Phase 1: Map Resource Ownership
### Step 1: Identify Resource-Owning Classes
```
Glob: **/*.swift (excluding test/vendor paths)
Grep for:
- `Timer.scheduledTimer`, `Timer.publish` — timer ownership
- `addObserver`, `NotificationCenter`, `.sink`, `.assign(to:` — observer ownership
- `var.*Task<`, `Task {` stored in properties — async task ownership
- `var.*delegate:`, `var.*Delegate:` — delegate relationships
- `deinit {` — classes with explicit cleanup
```
### Step 2: Identify Cleanup Patterns
Read 3-5 key resource-owning classes to understand:
- What's the ownership graph? (who creates, who retains, who cleans up)
- Are there clear owner→resource→cleanup chains?
- Which classes have `deinit` and which don't?
- Are there objects that accumulate resources without bounds?
### Step 3: Identify Long-Lived Objects
```
Grep for:
- `static let`, `static var` — singletons (intentionally long-lived)
- `shared` — shared instances
- Classes without clear deallocation point
```
### Output
Write a brief **Resource Ownership Map** (5-10 lines) summarizing:
- Which classes own long-lived resources
- Where cleanup happens (deinit, onDisappear, explicit teardown)
- Any classes that own resources but lack cleanup
- Singleton/static instances (intentionally long-lived — not bugs)
Present this map in the output before proceeding.
## Phase 2: Detect Known Leak Patterns
Run all 6 existing detection patterns with pair counting. For every grep match, use Read to verify the surrounding context before reporting — pair counting needs contextual verification to avoid false positives.
### Pattern 1: Timer Leaks (CRITICAL/HIGH)
**Issue**: `Timer.scheduledTimer(repeats: true)` without `.invalidate()`
**Search**: `Timer\.scheduledTimer.*repeats.*true`, `Timer\.publish`
**Verify**: Count timers vs `.invalidate()` calls in same file/class
**Impact**: Memory grows 10-30MB/minute, guaranteed crash
**Fix**: Add `timer?.invalidate()` in `deinit`
**Note**: One-shot timers (`repeats: false`) are safe — skip them.
### Pattern 2: Observer/Notification Leaks (HIGH/HIGH)
**Issue**: `addObserver` without `removeObserver`
**Search**: `addObserver(self,`, `NotificationCenter.default.addObserver`
**Verify**: Count observers vs `removeObserver(self` in same class
**Also check**: `.sink {`, `.assign(to:`, `Timer.publish` without `AnyCancellable` storage (`var.*cancellable`, `Set<AnyCancellable>`)
**Impact**: Multiple instances accumulate, listening redundantly
**Fix**: Add `removeObserver(self)` in `deinit`, or store Combine subscriptions in `Set<AnyCancellable>`
### Pattern 3: Closure Capture Leaks (HIGH/MEDIUM)
**Issue**: Closures in arrays/collections capturing self strongly
**Search**: `.append.*{.*self\.` without `[weak self]`; `var.*:.*\[.*->` (closure arrays); `DispatchQueue.*{.*self\.`, `Task.*{.*self\.` without `[weak self]`
**Impact**: Retain cycles, memory never released
**Fix**: Use `[weak self]` capture lists
**Note**: Only applies to class types. Struct self capture is fine.
**Swift 6.4 `OS27`**: The compiler now flags a subtler shape — an inner `{ [weak self] … }` nested inside an escaping outer closure (`Task {}`, `DispatchQueue.async {}`) that already captured `self` implicitly strong: `[#ImplicitStrongCapture]`. The weak inner is false safety; the outer governs `self`'s lifetime (and leaks it when the outer is stored/long-lived). Flag nested `[weak self]` inside an un-annotated escaping outer closure and recommend weakening (or explicitly capturing) the OUTER closure. See `axiom-performance (skills/memory-debugging.md)`.
### Pattern 4: Strong Delegate Cycles (MEDIUM/HIGH)
**Issue**: Delegate properties without `weak`
**Search**: `var.*delegate:` without `weak`, `var.*Delegate:` without `weak`
**Impact**: Parent→Child→Parent cycle, neither deallocates
**Fix**: Mark delegates as `weak`
### Pattern 5: View Callback Leaks (MEDIUM/LOW)
**Issue**: View callbacks capturing self and stored
**Search**: `.onAppear {` or `.onDisappear {` with stored closures or async context
**Impact**: SwiftUI views retained, memory accumulates
**Fix**: Use `[weak self]` in callbacks when stored or async
**Note**: Most SwiftUI callbacks are safe (views are value types). Only flag when there's clear evidence of class-based storage.
### Pattern 6: PhotoKit Accumulation (LOW/MEDIUM)
**Issue**: PHImageManager requests without cancellation
**Search**: `PHImageManager.*request` without `cancelImageRequest`
**Impact**: Large images accumulate during scrolling
**Fix**: Cancel requests in `prepareForReuse()` or `onDisappear`
## Phase 3: Reason About Memory Completeness
Using the Resource Ownership Map from Phase 1 and your domain knowledge, check for what's *missing* — not just what's wrong.
| Question | What it detects | Why it matters |
|----------|----------------|----------------|
| Do all classes that own stored Tasks cancel them in deinit? | Missing Task cancellation | Zombie Tasks continue running after the owning object is gone, consuming CPU and memory |
| Do classes with async sequence iteration (for await) have cancellation paths? | Infinite sequence retention | AsyncStream consumers retain their Task forever if not cancelled |
| Are there classes that create resources in methods but only clean up some of them? | Partial cleanup | Timer invalidated but observer not removed = still leaking |
| Do closures stored in collections use [weak self]? | Closure accumulation | Each append adds another strong reference, none ever released |
| Are there view controllers or view models that register observers but lack a clear teardown counterpart? | Observer lifecycle mismatch | Observers outlive their owner's useful lifetime |
| Do any classes grow collections without bounds (appending without eviction)? | Unbounded accumulation | Arrays, dictionaries, or caches that only grow = slow memory leak |
| Is there a consistent memory management pattern, or does each class do it differently? | Inconsistent lifecycle strategy | Ad-hoc cleanup means some paths are always missed |
Require evidence from the Phase 1 map — don't speculate without reading the code.
## Phase 4: Cross-Reference Findings
Bump severity for these combinations:
| Finding A | + Finding B | = Compound | Severity |
|-----------|------------|-----------|----------|
| No deinit | Owns stored Task + timer + observer | No cleanup path exists for multiple resources | CRITICAL |
| [weak self] missing in closure | Closure stored in collection | Accumulating retain cycles | CRITICAL |
| Timer without invalidate | No deinit on owning class | Timer runs forever, class never deallocates | CRITICAL |
| PHImageManager requests | In ScrollView/List cell | Image accumulation during scrolling | HIGH |
| Observer added in init | No removeObserver anywhere | Permanent observer leak | HIGH |
| Stored Task without cancel | No onDisappear/deinit cleanup | Zombie async work after navigation | HIGH |
| Unbounded collection growth | In long-lived singleton | Memory grows for entire app lifetime | HIGH |
Also note overlaps with other auditors:
- Missing Task cancellation + no deinit → compound with concurrency auditor
- Closure captures in async context → compound with concurrency auditor
- PHImageManager in List cell → compound with SwiftUI performance
## Phase 5: Resource Lifecycle Health Score
```markdown
## Memory Health Score
| Metric | Value |
|--------|-------|
| Resource ownership coverage | X classes own resources, Y have cleanup (Z%) |
| Timer lifecycle | N repeating timers, M invalidate calls (match: yes/no) |
| Observer lifecycle | N observers, M removals (match: yes/no) |
| Task lifecycle | N stored Tasks, M with deinit/onDisappear cancellation (Z%) |
| Combine subscriptions | N .sink/.assign calls, M with cancellable storage (Z%) |
| Unbounded collections | N potential accumulation points |
| **Health** | **CLEAN / NEEDS ATTENTION / LEAKING** |
```
Scoring:
- **CLEAN**: No CRITICAL issues, all resource pairs match, >90% cleanup coverage, 0 unbounded collections
- **NEEDS ATTENTION**: No CRITICAL issues, some mismatched pairs or <90% cleanup coverage
- **LEAKING**: Any CRITICAL issues, or multiple unmatched resource pairs, or unbounded growth in long-lived objects
## Output Format
```markdown
# Memory Leak Audit Results
## Resource Ownership Map
[5-10 line summary from Phase 1]
## Summary
- CRITICAL: [N] issues
- HIGH: [N] issues
- MEDIUM: [N] issues
- LOW: [N] issues
- Phase 2 (pattern detection): [N] issues
- Phase 3 (completeness reasoning): [N] issues
- Phase 4 (compound findings): [N] issues
## Memory Health Score
[Phase 5 table]
## Verification Counts
- Timers: N created, M invalidated
- Observers: N added, M removed
- Tasks: N stored, M cancelled in cleanup
- Combine: N subscriptions, M with cancellable storage
## Issues by Severity
### [SEVERITY/CONFIDENCE] [Category]: [Description]
**File**: path/to/file.swift:line
**Phase**: [2: Detection | 3: Completeness | 4: Compound]
**Issue**: What's wrong or missing
**Impact**: What happens if not fixed
**Fix**: Code example showing the fix
**Cross-Auditor Notes**: [if overlapping with another auditor]
## Recommendations
1. [Immediate actions — CRITICAL fixes]
2. [Short-term — HIGH fixes and lifecycle cleanup]
3. [Long-term — architectural improvements from Phase 3 findings]
4. [Instruments verification — suggested profiling workflows]
```
## Output Limits
If >50 issues in one category: Show top 10, provide total count, list top 3 files
If >100 total issues: Summarize by category, show only CRITICAL/HIGH details
## False Positives (Not Issues)
- `weak var delegate` — Already safe
- Closures with `[weak self]` — Already safe
- Static/singleton timers (intentionally long-lived)
- One-shot timers with `repeats: false`
- Most SwiftUI callbacks (views are value types)
- Task captures where self is a struct (value type)
- Combine subscriptions stored in `Set<AnyCancellable>` or `AnyCancellable` property
## Field Crash Correlation
If the user has `.ips`, MetricKit, or legacy `.crash` text artifacts from the field (TestFlight, Xcode Organizer `.xccrashpoint` bundles, MetricKit payloads), symbolicate them before inferring the leak pattern. xcsym's `pattern_tag` flags the memory failure mode directly:
| pattern_tag | What the audit should look for |
|---|---|
| `jetsam_oom` | Unbounded collection growth, undisposed caches, large images retained in view hierarchy |
| `zombie_or_heap_corruption` | Use-after-free — missing `[weak self]` in a Task or closure, over-retained delegate |
| `bad_memory_access` | Dangling reference after deallocation — cross-reference Phase 2 Pattern 4 (delegate cycles) |
```bash
xcsym crash --format=summary <path-to-ips>
```
Use the `crashed_thread.frames` to localize which owner class needs deeper Phase 1 ownership mapping.
## Related
For Instruments workflows: `axiom-performance (skills/memory-debugging.md)` skill
For Memory Graph Debugger: `axiom-performance (skills/memory-debugging.md)` skill
For Task lifecycle issues found during audit: `axiom-concurrency` skill
For symbolicating field crashes (jetsam, heap corruption): `axiom-tools (skills/xcsym-ref.md)`
skills/memory-debugging.md
# Memory Debugging
## Overview
Memory issues manifest as crashes after prolonged use. **Core principle** 90% of memory leaks follow 3 patterns (retain cycles, timer/observer leaks, collection growth). Diagnose systematically with Instruments, never guess.
## Example Prompts
- "My app crashes after 10-15 minutes with no error messages"
- "Memory jumps from 50MB to 200MB+ on a specific action — leak or cache?"
- "View controllers don't deallocate after dismiss"
- "Timers/observers causing memory leaks — how to verify?"
- "App uses 200MB and I don't know if that's normal"
---
## Red Flags — Memory Leak Likely
- Progressive memory growth: 50MB → 100MB → 200MB (not plateauing)
- App crashes after 10-15 minutes with no error in Xcode console
- Memory warnings appear repeatedly in device logs
- View controllers don't deallocate after dismiss (visible in Memory Graph Debugger)
- Same operation run multiple times causes linear memory growth
**Leak vs normal**: Normal = stays at 100MB. Leak = 50MB → 100MB → 150MB → 200MB → CRASH.
## Mandatory First Steps
**ALWAYS diagnose FIRST** (before reading code):
1. Check device logs for "Memory pressure critical", "Jetsam killed", "Low Memory"
2. Use Memory Graph Debugger (below) — shows object count growth
3. Xcode → Product → Profile → Memory. Perform action 5 times, note if memory keeps growing
**What this tells you**: Flat = not a leak. Linear growth = classic leak. Spike then flat = normal cache. Spikes stacking = compound leak.
**Why diagnostics first**: Finding leak with Instruments: 5-15 min. Guessing: 45+ min.
## Detecting Leaks — Step by Step
### Step 1: Memory Graph Debugger (Fastest)
1. Open app in simulator
2. Debug → Memory Graph Debugger (or toolbar icon)
3. Look for PURPLE/RED circles with "⚠" badge
4. Click them → Xcode shows retain cycle chain
### Step 2: Instruments (Detailed Analysis)
1. Product → Profile (Cmd+I) → "Memory" template
2. Perform action 5-10 times
3. Memory line goes UP for each action? = Leak confirmed
Key instruments: Heap Allocations (object count), Leaked Objects (direct detection), VM Tracker (by type).
### Step 3: Deallocation Check
```swift
// Add deinit logging to suspect classes
class MyViewController: UIViewController {
deinit { print("✅ MyViewController deallocated") }
}
@MainActor
class ViewModel: ObservableObject {
deinit { print("✅ ViewModel deallocated") }
}
```
Navigate to view, navigate away. See "✅ deallocated"? Yes = no leak. No = retained somewhere.
## Jetsam (Memory Pressure Termination)
**Jetsam is not a bug** — iOS terminates background apps to free memory. Not a crash (no crash log), but frequent kills hurt UX.
| Termination | Cause | Solution |
|-------------|-------|----------|
| **Memory Limit Exceeded** | Your app used too much memory | Reduce peak footprint |
| **Jetsam** | System needed memory for other apps | Reduce background memory to <50MB |
### Measure Peak, Not Resting
The table above says "reduce peak footprint" for one row and "reduce background memory" for the other, and that split is real — the two terminations are judged against different numbers:
| Termination | Judged against | Number to reduce |
|---|---|---|
| Memory Limit Exceeded | Your instantaneous footprint, checked continuously | Peak |
| Jetsam under system pressure | Your footprint at the moment the kernel picks victims — for a backgrounded app, its resting figure | Resting |
So peak is what explains a limit kill you cannot reproduce; resting is what governs whether you survive someone else's memory pressure. The kernel tracks both, plus your headroom, in one call:
```swift
import os
func footprint() -> (currentMB: Double, peakMB: Double, headroomMB: Double)? {
var info = task_vm_info_data_t()
var count = mach_msg_type_number_t(
MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size
)
let kr = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
}
}
guard kr == KERN_SUCCESS else { return nil }
return (Double(info.phys_footprint) / 1_048_576,
Double(info.ledger_phys_footprint_peak) / 1_048_576,
Double(os_proc_available_memory()) / 1_048_576)
}
```
- `phys_footprint` is what Xcode's memory gauge shows.
- `ledger_phys_footprint_peak` is the high-water mark since launch.
- `os_proc_available_memory()` is your remaining headroom — the same value as the struct's `limit_bytes_remaining`. iOS, tvOS, and watchOS only; not macOS. Without it a peak has nothing to be judged against: 300 MB is unremarkable on an iPad Pro reporting 5 GB of headroom and fatal in an app extension with 50 MB. `current + headroom` gives you that device's actual limit, which is the number to put beside a colleague's "we're only at 180 MB".
**A zero headroom reading is ambiguous — never read it as "plenty of room".** `<os/proc.h>` defines 0 as: the calling process is not an app, **or** it has already exceeded its memory limit. Those are opposite situations. Disambiguate with `phys_footprint` from the same call — a small footprint beside a 0 means the reading does not apply to this process; a large one means you are already over the line.
**The peak never resets.** There is no API to clear it, and it outlives the free that hides the spike from the gauge. A single read after your test hands you the process-lifetime maximum — which may have been set during launch, or on a screen the user visited ten minutes ago. **Read it before the interaction and after, and take the delta**, or relaunch between runs.
**Sampling is not a substitute.** A transient spike outruns any polling rate you are willing to pay for. In the lazy-container case below, polling on every step of a scroll still missed a peak several times higher than anything it recorded.
**Simulator readings are not device readings.** In the simulator your process is a macOS process: `phys_footprint` measures host memory, there is no iOS dirty-memory limit, and `os_proc_available_memory()` is not meaningful. Peak work belongs on hardware.
**Where peaks hide**: transitions, not steady states — a list scrolling back through rows it already passed, a document reopening, a share sheet loading previews, an image pipeline decoding faster than it releases. The measured case, with numbers, is `axiom-swiftui (skills/layout-ref.md)` — Lazy Container Gotchas, where iOS 27 releases rows continuously and still peaks several times above rest.
**In production**, `MXMetricPayload.memoryMetrics.peakMemoryUsage` is the field counterpart to this call — see Monitoring with MetricKit below.
### Reducing Jetsam Rate
Clear caches on backgrounding:
```swift
// SwiftUI
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
imageCache.clearAll()
URLCache.shared.removeAllCachedResponses()
}
}
```
### State Restoration
Users shouldn't notice jetsam. Use `@SceneStorage` (SwiftUI) or `stateRestorationActivity` (UIKit) to restore navigation position, drafts, and scroll position.
### Monitoring with MetricKit
```swift
class JetsamMonitor: NSObject, MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
guard let exitData = payload.applicationExitMetrics else { continue }
let bgData = exitData.backgroundExitData
if bgData.cumulativeMemoryPressureExitCount > 0 {
// Send to analytics
}
}
}
}
```
On `iOS27`, MetricKit adds a **memory exception diagnostic** — when the app (or an extension) is killed for exceeding its memory limit, a `DiagnosticReport` with `.memoryException` arrives carrying the call stack at termination, and the `.backgroundTermination`/`.foregroundTermination` metrics break out `memoryLimitTerminationCount`. See `axiom-performance (skills/metrickit-ref.md)` Part 1.
```
App memory grows while in USE? → Memory leak (fix retention)
App memory grows only WHILE SCROLLING a long list, on iOS 26? → Not a leak.
Lazy containers and List never free a visited row's state on 26; the memory is
reachable, so leak detection reports nothing. Move heavy payloads out of per-row
state. See axiom-swiftui (skills/layout-ref.md) — Lazy Container Gotchas
App killed in BACKGROUND? → Jetsam (reduce bg memory)
```
## Common Memory Leak Patterns (With Fixes)
### Pattern 1: Timer Leaks (Most Common — 50% of leaks)
**Why `[weak self]` alone doesn't fix timer leaks**: The RunLoop retains scheduled timers. `[weak self]` only prevents the closure from retaining `self` — the Timer object itself continues to exist and fire. You must explicitly `invalidate()` to break the RunLoop's retention.
#### ❌ Leak — Timer never invalidated
```swift
progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.updateProgress()
}
// Timer never stopped → RunLoop keeps it alive and firing forever
```
#### ✅ Best fix: Combine (auto-cleanup)
```swift
cancellable = Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .default)
.autoconnect()
.sink { [weak self] _ in self?.updateProgress() }
// No deinit needed — cancellable auto-cleans when released
```
**Alternative**: Call `timer?.invalidate(); timer = nil` in both the appropriate teardown method (`viewWillDisappear`, stop method, etc.) AND `deinit`.
> For timer crash patterns (EXC_BAD_INSTRUCTION) and RunLoop mode issues, see `axiom-integration` (skills/timer-patterns.md).
### Pattern 2: Observer/Notification Leaks (25% of leaks)
#### ❌ Leak — No removeObserver
```swift
NotificationCenter.default.addObserver(self, selector: #selector(handle),
name: AVAudioSession.routeChangeNotification, object: nil)
// No matching removeObserver → accumulates listeners
```
#### ✅ Best fix: Combine publisher
```swift
NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification)
.sink { [weak self] _ in self?.handleChange() }
.store(in: &cancellables) // Auto-cleanup with viewModel
```
**Alternative**: `NotificationCenter.default.removeObserver(self)` in `deinit`.
### Pattern 3: Closure Capture Leaks (15% of leaks)
#### ❌ Leak — Closure in array captures self
```swift
updateCallbacks.append { [self] track in
self.refreshUI(with: track) // Strong capture → cycle
}
```
#### ✅ Fix: Use [weak self]
```swift
updateCallbacks.append { [weak self] track in
self?.refreshUI(with: track)
}
```
Clear callback arrays in `deinit`. Use `[unowned self]` only when certain self outlives the closure.
### Pattern 4: Strong Reference Cycles
#### ❌ Leak — Mutual strong references
```swift
player?.onPlaybackEnd = { [self] in self.playNextTrack() }
// self → player → closure → self (cycle)
```
#### ✅ Fix: [weak self] in closure
```swift
player?.onPlaybackEnd = { [weak self] in self?.playNextTrack() }
```
### Pattern 5: View/Layout Callback Leaks
Use the delegation pattern with `AnyObject` protocol (enables weak references) instead of closures that capture view controllers.
### Pattern 6: PhotoKit Image Request Leaks
`PHImageManager.requestImage()` returns a `PHImageRequestID` that must be cancelled. Without cancellation, pending requests queue up and hold memory when scrolling.
```swift
class PhotoCell: UICollectionViewCell {
private var imageRequestID: PHImageRequestID = PHInvalidImageRequestID
func configure(with asset: PHAsset, imageManager: PHImageManager) {
if imageRequestID != PHInvalidImageRequestID {
imageManager.cancelImageRequest(imageRequestID)
}
imageRequestID = imageManager.requestImage(for: asset, targetSize: PHImageManagerMaximumSize,
contentMode: .aspectFill, options: nil) { [weak self] image, _ in
self?.imageView.image = image
}
}
override func prepareForReuse() {
super.prepareForReuse()
if imageRequestID != PHInvalidImageRequestID {
PHImageManager.default().cancelImageRequest(imageRequestID)
imageRequestID = PHInvalidImageRequestID
}
imageView.image = nil
}
}
```
Similar patterns: `AVAssetImageGenerator` → `cancelAllCGImageGeneration()`, `URLSession.dataTask()` → `cancel()`.
## Weak Inner Capture Inside a Strong Outer Closure `OS27`
Swift 6.4 (Xcode 27) adds the **default-on** `[#ImplicitStrongCapture]` warning. It fires when an inner closure captures `self` with `[weak self]` while an **outer escaping closure already captured `self` implicitly strong**. The weak inner is a false sense of safety — the outer closure governs `self`'s lifetime, so the inner's `[weak self]` shortens nothing. This is the exact cycle that used to surface only in Instruments; now the compiler flags it at build time.
Fires only for **escaping** outer closures (`Task {}`, `DispatchQueue.async {}`, stored closures). Non-escaping outers (`forEach`, `map`) don't capture past the call, so they never trigger it. Severity tracks the outer closure's lifetime: a one-shot async hop retains `self` only briefly, but a **stored** outer closure (`store.onChange = { self.x = { [weak self] … } }`) holds `self` for the store's lifetime — a real leak the weak inner does nothing to prevent.
#### ❌ Warns — weak inner, implicit strong outer
```swift
DispatchQueue.main.async { // implicitly captures self STRONG
self.doWork()
self.handler = { [weak self] in // false safety — self already retained above
self?.doWork()
}
}
```
```
warning: 'weak' ownership of capture 'self' differs from implicitly-captured
strong reference in outer scope [#ImplicitStrongCapture]
note: 'self' implicitly strongly captured here
note: add 'self' as a capture list item to silence
```
#### ✅ Fix by intent, not by silencing
The warning asks one question — did you mean for the outer closure to retain `self`?
| Intent | Fix |
|--------|-----|
| `self` SHOULD live for the outer closure | `[self]` on the OUTER closure — makes the strong capture explicit |
| `self` should NOT be retained (why you wrote weak) | `[weak self]` on the OUTER closure + `guard let self else { return }` |
```swift
// Intent: don't retain self — weaken the OUTER, not just the inner
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.doWork()
self.handler = { [weak self] in self?.doWork() }
}
```
**Silencing is not fixing.** `[weak self = self]` on the inner closure also clears the warning, but `self` is still strongly held by the outer — you've muted the diagnostic without changing the retention. Pick the fix that matches intent (see Pattern 3 and Pattern 4 above). Diagnostic group id: `[#ImplicitStrongCapture]`.
## Systematic Debugging Workflow
### Phase 1: Confirm Leak (5 min)
Profile with Memory template, repeat action 10 times. Flat = not a leak (stop). Steady climb = leak (continue).
### Phase 2: Locate Leak (10-15 min)
Memory Graph Debugger → purple/red circles → click → read retain cycle chain.
Common locations: Timers (50%), Notifications/KVO (25%), Closures in collections (15%), Delegate cycles (10%).
### Phase 3: Fix and Verify (5 min)
Apply fix from patterns above. Add `deinit { print("✅ deallocated") }`. Run Instruments again — memory should stay flat.
### Compound Leaks
Real apps often have 2-3 leaks stacking. Fix the largest first, re-run Instruments, repeat until flat.
## Non-Reproducible / Intermittent Leaks
When Instruments prevents reproduction (Heisenbug) or leaks only happen with specific user data:
**Lightweight diagnostics** (when Instruments can't be attached):
1. **deinit logging as primary diagnostic** — Add `deinit { print("✅ ClassName deallocated") }` to all suspect classes. Run 20+ sessions. When the leak occurs (e.g., 1 in 5 runs), missing deinit messages reveal which objects are retained.
2. **Isolate the trigger** — Test each navigation path independently. Rapidly toggle background/foreground if timing-dependent. Narrow to the specific path that leaks.
3. **MetricKit for field diagnostics** — Monitor peak memory in production via `MXMetricPayload.memoryMetrics.peakMemoryUsage`. Alert when exceeding threshold (e.g., 400MB). This catches leaks that only manifest with real user data volumes. When `MXCrashDiagnostic` payloads arrive, symbolicate with xcsym (`xcsym crash --from-metrickit <file>`) — a `pattern_tag=jetsam_oom` confirms the threshold-exceedance hypothesis and the crashed-thread frames localize the retaining owner.
**Common cause of intermittent leaks**: Notification observers added on lifecycle events (`viewWillAppear`, `applicationDidBecomeActive`) without removing duplicates first. Each re-registration accumulates a listener — timing determines whether the duplicate fires.
**TestFlight verification**: Ship diagnostic build to affected users. Add `os_log` memory milestones. Monitor MetricKit for 24-48 hours after fix deployment.
## Common Mistakes
- **[weak self] without invalidate()** — Timer keeps running, consuming CPU. ALWAYS call `invalidate()` or `cancel()`
- **Invalidate without nil** — `timer?.invalidate()` stops firing but reference remains. Always follow with `timer = nil`
- **Local AnyCancellable** — Goes out of scope immediately, subscription dies. Store in `Set<AnyCancellable>` property
- **deinit with only logging** — Add actual cleanup (invalidate timers, remove observers), not just print statements
- **Wrong Instruments template** — Memory shows usage. Leaks detects actual leaks. Use both
## Instruments Quick Reference
| Scenario | Tool | What to Look For |
|----------|------|------------------|
| Progressive memory growth | Memory | Line steadily climbing = leak |
| Specific object leaking | Memory Graph | Purple/red circles = leak objects |
| Direct leak detection | Leaks | Red "! Leak" badge = confirmed leak |
| Memory by type | VM Tracker | Objects consuming most memory |
| Cache behavior | Allocations | Objects allocated but not freed |
## CLI Quick Checks (No Instruments)
Xcode ships CLI tools for fast memory diagnostics without opening Instruments. Use these for quick checks during development.
### leaks — Detect Leaks in Running Process
```bash
# Check running app by name (positional argument, not --process)
xcrun leaks MyApp
# Check by PID
xcrun leaks 12345
# Show full stack traces for each leak
xcrun leaks --fullStacks MyApp
# Analyze a memgraph file (from Xcode's Debug Memory Graph)
xcrun leaks MyApp.memgraph
```
**When to use**: Quick leak check without recording an Instruments trace. Run after exercising a suspect code path.
### heap — Inspect Live Heap Allocations
```bash
# Show heap summary by class (process name is positional)
xcrun heap MyApp
# Show all instances of a specific class
xcrun heap --addresses=MyViewController MyApp
# Sort by size (find biggest consumers)
xcrun heap -s MyApp
# Analyze a memgraph
xcrun heap MyApp.memgraph
```
**When to use**: Finding what's consuming memory right now. Answers "how many MyViewController instances exist?" without Instruments.
### vmmap — Virtual Memory Map
```bash
# Summary view (dirty, clean, swapped)
xcrun vmmap --summary MyApp.memgraph
# Full memory regions
xcrun vmmap MyApp.memgraph
```
**When to use**: Understanding memory composition. Shows dirty pages (your data), clean pages (mapped files), and compressed memory.
### stringdups — Find Duplicate Strings
```bash
# Find duplicate strings in running process (positional argument)
xcrun stringdups MyApp
# Analyze a memgraph
xcrun stringdups MyApp.memgraph
```
**When to use**: Reducing memory footprint from repeated string allocations. No GUI equivalent.
### malloc_history — Track Allocation Origins
```bash
# Enable malloc logging first: set MallocStackLogging=1 in scheme env vars
# Then query a specific address
xcrun malloc_history <pid> <address>
# Show all allocations sorted by size
xcrun malloc_history <pid> -allBySize
```
**When to use**: Tracing where a leaked object was allocated. Requires `MallocStackLogging=1` environment variable in scheme.
### Quick Diagnosis Workflow
```bash
# 1. Is there a leak? (30 seconds)
xcrun leaks MyApp
# 2. What's on the heap? (30 seconds)
xcrun heap -s MyApp
# 3. Any duplicate strings wasting memory? (30 seconds)
xcrun stringdups MyApp
# 4. Where is memory allocated? (requires memgraph)
xcrun vmmap --summary MyApp.memgraph
```
**Time cost**: 2 minutes for a full CLI memory check vs 10+ minutes launching Instruments.
### xctrace (Headless Instruments)
```bash
# Record memory trace without GUI
xcrun xctrace record --instrument 'Allocations' --attach 'MyApp' --time-limit 30s --output memory.trace
# Record leak detection
xcrun xctrace record --instrument 'Leaks' --attach 'MyApp' --time-limit 30s --output leaks.trace
```
## Real-World Impact
**Before**: 50+ PlayerViewModel instances with uncleared timers → 50MB → 200MB → Crash (13min)
**After**: Timer properly invalidated → 50MB stable for hours
**Key insight** 90% of leaks come from forgetting to stop timers, observers, or subscriptions. Always clean up in `deinit` or use reactive patterns that auto-cleanup.
---
## Resources
**WWDC**: 2021-10180, 2020-10078, 2018-416
**Docs**: /xcode/gathering-information-about-memory-use, /metrickit/mxbackgroundexitdata
**Skills**: skills/performance-profiling.md, skills/objc-block-retain-cycles.md, skills/metrickit-ref.md, axiom-build (skills/lldb.md), axiom-tools (skills/xcsym-ref.md)
skills/metrickit-ref.md
# MetricKit API Reference
Complete API reference for collecting field performance metrics and diagnostics using MetricKit.
## Overview
MetricKit provides aggregated, on-device performance and diagnostic data from users who opt into sharing analytics. Metric reports arrive daily; diagnostic reports arrive immediately when captured for in-session events (hangs, CPU/disk-write exceptions) — crash diagnostics necessarily arrive on the app's next run (or on-demand in development).
The framework has two API generations. The 27 cycle rebuilt it as a Swift-first API (`MetricManager`, `AsyncSequence` streams, `Codable` reports) — Part 1. The legacy `MX*` Objective-C surface (Parts 2–6, with legacy-API integration examples in Part 7) is soft-deprecated in the 27 SDK ("Use MetricResult instead", `API_TO_BE_DEPRECATED`) but remains the only API before 27.
## When to Use This Reference
Use this reference when:
- Setting up MetricKit collection in your app (new `MetricManager` or legacy subscriber)
- Migrating from the legacy `MX*` subscriber API to the 27 `MetricManager` API
- Parsing `MetricReport`/`DiagnosticReport` (27) or MXMetricPayload/MXDiagnosticPayload (legacy)
- Splitting metrics by app state (tab, mode, experiment) with the StateReporting framework
- Symbolicating MetricKit call-stack crash data
- Understanding background exit reasons (jetsam, watchdog)
- Integrating MetricKit with existing crash reporters
For hang diagnosis workflows, see `axiom-performance (skills/hang-diagnostics.md)`.
For general profiling with Instruments, see `axiom-performance (skills/performance-profiling.md)`.
For memory debugging including jetsam, see `axiom-performance (skills/memory-debugging.md)`.
## Common Gotchas
1. **Metrics are daily, not real-time** — metric reports arrive once a day; diagnostic reports are delivered immediately when captured for in-session events (hangs, exceptions), while crash diagnostics arrive on the next run
2. **Call stacks require symbolication** — call-stack frames are unsymbolicated; keep dSYMs
3. **Opt-in only** — Only users who enable "Share with App Developers" contribute data
4. **Aggregated, not individual** — You get counts and averages, not per-user traces
5. **Simulator doesn't work** — MetricKit only collects on physical devices
6. **Keep the manager alive** — both `MetricManager` (27) and a legacy subscriber must outlive the subscription; subscribe at app startup or you lose reports
7. **State reporting is rate-limited** — transitions reported faster than user-interaction timescales can go unlogged
### Version Support
| Feature | Available |
|---------|-----------|
| Basic metrics (battery, CPU, memory) | iOS 13+ |
| Diagnostic payloads | iOS 14+ |
| Hang diagnostics | iOS 14+ |
| Immediate diagnostic delivery | iOS 15+ |
| Launch diagnostics | iOS 16+ |
| Swift-first API (`MetricManager`, typed metrics/diagnostics) | `OS27` (not watchOS/tvOS; visionOS = diagnostics subset) |
| Per-state metrics (StateReporting framework) | `OS27` (the StateReporting framework itself spans all platforms) |
| Metal frame rate metric, launch-task tracking | `OS27` |
| Memory exception diagnostics | `iOS27` |
## Part 1: The New Swift API `OS27`
Available on iOS 27, iPadOS 27, macOS 27, and Mac Catalyst 27; visionOS 27 receives the diagnostics subset only; not available on watchOS or tvOS. The companion StateReporting framework is available on **all** platforms at 27, including watchOS and tvOS. Apple's guidance (WWDC 2026-222): migrate from `MXMetricManager` to `MetricManager` — all new capabilities are exclusive to the new API.
### MetricManager Setup
`MetricManager` replaces the subscriber/delegate model with `AsyncSequence` streams. Create it at app startup and keep it alive for the app's lifetime — a deallocated manager stops delivering, and a late subscription loses reports.
```swift
import MetricKit
let manager = MetricManager()
// At startup, in a detached task or a dedicated service class:
for await report in manager.metricReports {
let json = try JSONEncoder().encode(report) // MetricReport is Codable
sendToServer(json)
}
```
### MetricReport Structure
A daily `MetricReport` contains `intervalEntries` — one full-day aggregate (`entries.fullDayEntry`) plus smaller breakdown windows (typically a few hours each, present only when they have data). Each entry's `values` is `[MetricResult]`, filterable by `metricGroup`:
```swift
for await report in manager.metricReports {
let entries = report.intervalEntries
for entry in entries {
let memoryMetrics = entry.values.filter { $0.metricGroup == .memory }
for metric in memoryMetrics {
if case .peakMemory(let peak) = metric {
processPeakMemory(peak.value) // Measurement<UnitInformationStorage>
}
}
}
}
```
`MetricReport.environment` carries `osVersion`, `deviceType`, `lowPowerModeEnabled`, `isTestFlightApp`, `bundleIdentifier`, `latestApplicationVersion`, `includesMultipleApplicationVersions`, and `hasExceededStateLimit` (see States below).
### Metric Inventory (MetricResult cases)
Typed metric structs use `Measurement`, generic `Histogram<DimensionType>` (buckets with typed bounds), and `AverageStatistics<DimensionType>` (average/count/standardDeviation). Cases without a platform note are available wherever the API is (iOS/macOS/Catalyst).
| Case | Payload | Notes |
|------|---------|-------|
| `.hangTime` | `Histogram<UnitDuration>` | |
| `.hitchTime` | ratio + totalHitchTime + totalAnimationTime | animation hitches beyond scrolling |
| `.scrollHitchTime` | ratio + totalHitchTime + totalScrollTime | |
| `.timeToFirstDraw`, `.optimizedTimeToFirstDraw`, `.applicationResumeTime`, `.extendedLaunch` | `Histogram<UnitDuration>` | launch family |
| `.foregroundTermination`, `.backgroundTermination` | per-category counts | both include watchdog; background adds taskTimeout, fileLock, highCPU, systemPressure |
| `.cpuTime`, `.cpuInstructionsCount` | duration / count | |
| `.gpuTime` | duration | |
| `.peakMemory`, `.suspendedMemory` | storage / `AverageStatistics` | iOS only |
| `.totalWiFiUpload`, `.totalWiFiDownload` | storage | |
| `.totalCellularUpload`, `.totalCellularDownload` | storage | iOS only |
| `.logicalDiskWrites` | storage | |
| `.totalDiskSpaceCapacity`, `.totalFileCount`, `.totalFileSize` | capacity/spaceUsed; binary/data file counts; binary/data/cache/clone sizes | iOS only — the same storage breakdown the Xcode 27 Organizer's Storage metric reports |
| `.pixelLuminance` | `AverageStatistics<AveragePixelLuminance>` | iOS only |
| `.cellularConditionTime` | `Histogram<SignalBars>` | iOS only |
| `.locationActivityTime` | per-accuracy-bucket durations | iOS only |
| `.totalForegroundTime`, `.totalBackgroundTime`, `.totalBackgroundAudioTime`, `.totalBackgroundLocationTime` | durations | iOS only |
| `.metalFrameRate` | framesPerSecond, frameCount, activeDrawingDuration, layerName | new capability — render performance for games |
| `.signpostInterval` | duration histogram + optional averageMemory, cpuTime, logicalWrites, hitch ratios | per signpost name/category |
`MetricGroup` constants for filtering: `.cpu`, `.memory`, `.diskIO`, `.networkTransfer`, `.display`, `.animation`, `.applicationResponsiveness`, `.cellularCondition`, `.locationActivity`, `.gpu`, `.signpost`, `.appLaunch`, `.appRuntime`, `.appTermination`, `.diskSpaceUsage`, `.frameStatistics`.
### Launch Task Tracking
`trackLaunchTask` instruments named work that contributes to your extended launch (feeds the `.extendedLaunch` metric family). It is `@MainActor`, has sync and async overloads, propagates the operation's typed error, and reports tracking problems via `onTrackingError` (`LaunchTaskError.Reason`: `.invalidID`, `.maxCountExceeded`, `.pastDeadline`, `.duplicateTask`, `.taskUnknown`, `.internalFailure`):
```swift
@MainActor
func loadInitialFeed() async {
let feed = await manager.trackLaunchTask(id: "load-feed") {
await feedStore.loadCachedFeed()
}
render(feed)
}
```
### Diagnostics (DiagnosticReport)
Each `DiagnosticReport` carries **one** typed `result` (legacy payloads bundled arrays), plus `timeRange` and an `environment` that includes `signpostData: [SignpostRecord]` (subsystem/category/name/interval of signposts captured with the diagnostic) and `states` (active reported states — neither on visionOS). Delivery is immediate when the event is captured (for crashes, on the app's next run — the crashed process can't receive its own report):
```swift
for await report in manager.diagnosticReports {
switch report.result {
case .crash(let crash):
let category = crash.terminationCategory // how this crash is accounted in metrics
process(crash.callStackTree, crash.terminationReason, category)
case .hang(let hang):
process(hang.callStackTree, hang.hangDuration)
case .memoryException(let memory): // iOS27 only: app/extension killed over memory limit
process(memory.callStackTree)
case .cpuException(let cpu):
process(cpu.callStackTree, cpu.totalCPUTime)
case .diskWriteException(let diskWrite):
process(diskWrite.callStackTree, diskWrite.totalBytesWritten)
case .appLaunch(let launch): // not visionOS
process(launch.callStackTree, launch.launchDuration)
default: break
}
}
```
`CrashDiagnostic.TerminationCategory` (`.badAccess`, `.abnormal`, `.illegalInstruction`, `.watchdog`, `.taskTimeout`, `.fileLock`) ties each crash to the corresponding termination-metric count, so a trend in `.foregroundTermination`/`.backgroundTermination` correlates directly with individual diagnostics. `CrashDiagnostic` also exposes `signal`, `exceptionType`/`exceptionCode`, `virtualMemoryRegionInfo`, and a typed `ObjectiveCExceptionReason` (composedMessage, className, exceptionName).
### CallStackTree (typed)
The new `CallStackTree` is a Swift struct — no JSON spelunking. `callStackThreads` holds `CallStackThread` values (`rootFrames`, `threadAttributed`); frames expose `binaryUUID`, `address`, `offsetIntoBinaryTextSegment`, `sampleCount`, `subFrames`. `forEachFrame` walks the tree; `binaryInfo` maps UUIDs to binary names:
```swift
crash.callStackTree.forEachFrame { frame in
let binary = frame.binaryName(from: crash.callStackTree)
record(binary, frame.offsetIntoBinaryTextSegment, frame.sampleCount)
}
```
Frames are still unsymbolicated — the dSYM workflow in Part 5 applies unchanged, and `CallStackTree` is `Codable` so you can persist it for xcsym.
### Per-State Metrics (StateReporting framework)
Without states, a metric is one blended number across all usage (WWDC 2026-222's example: a 15 ms/s scroll-hitch rate that hid a smooth 1 ms/s Spending tab and a critical 71 ms/s Reports tab). The StateReporting framework lets MetricKit aggregate metrics and diagnostics **per app state you define**.
Model: a **domain** (reverse-DNS string) covers one axis of app state and has at most one active state at a time; separate domains run concurrently (e.g. active-tab and experiment-arm). A state is identified by its **label + stable metadata**; reporting the same pair is a no-op. There are no begin/end pairs — report the state you're *entering*; `nil` clears the active state. **Volatile metadata** adds context within a state and is discarded at the next transition.
```swift
import MetricKit
import StateReporting
let tabs: StateReportingDomain = "com.example.app.tabs"
let manager = MetricManager(enabledStateReportingDomains: [tabs])
// Anywhere in the app — reporters are per-domain singletons:
let reporter = StateReporter.reporter(for: tabs.rawValue)
reporter.reportTransition(to: "Reports") // entering the Reports tab
reporter.reportTransition(to: nil) // no state active
```
To update volatile metadata mid-state without starting a new transition, call `reporter.reportVolatileMetadataUpdate(_:)` (no-op when no state is active; `nil` clears it).
Attach structured metadata with the `@ReportableMetadata` macro (values: string, date, integer, floating-point; `@ReportableMetadataKey("name")` renames a property, `@ReportableMetadataIgnored` excludes one):
```swift
@ReportableMetadata
struct ViewConfiguration {
let listSize: String
let isSorted: Bool
}
let configured = StateReporter.reporter(
for: tabs.rawValue,
stableMetadata: ViewConfiguration.self
)
configured.reportTransition(
to: "Reports",
stableMetadata: ViewConfiguration(listSize: "large", isSorted: false)
)
```
Read results from `MetricReport.stateEntries` (empty until you report states) — each `StateEntry` has `state` (`domain`, `label`, `duration`, `stableMetadata`) and `values: [MetricResult]` aggregated over time spent in that state. To group the encoded report by domain:
```swift
let encoder = JSONEncoder()
encoder.userInfo[MetricReport.encodingFormatKey] =
MetricReport.EncodingFormat.byStateReportingDomain
let json = try encoder.encode(report)
```
State best practices:
- Scope domains narrowly — one app area or axis per domain
- States are stable, meaningful phases — not transient UI events
- Plan the state count: too many states fragments the data; there are upper limits (`environment.hasExceededStateLimit` tells you when you hit them)
- Transitions faster than user-interaction timescales get rate-limited and dropped
- Validate with the Points of Interest instrument before shipping
### Migration Map (MX* → 27 API)
| Legacy | New |
|--------|-----|
| `MXMetricManager.shared.add(subscriber)` | `MetricManager()` + `for await` on `metricReports`/`diagnosticReports` |
| `MXMetricPayload` | `MetricReport` (Codable) |
| `MXDiagnosticPayload` (arrays of diagnostics) | `DiagnosticReport` (one typed `result` each) |
| `payload.jsonRepresentation()` | `JSONEncoder().encode(report)` |
| `MXCallStackTree` (raw JSON) | `CallStackTree` structs (`forEachFrame`, typed frames) |
| `MXAppExitMetric` fg/bg exit counts | `.foregroundTermination` / `.backgroundTermination` |
| `MXCrashDiagnostic` | `CrashDiagnostic` + `terminationCategory` |
| `MXMetricManager.makeLogHandle(category:)` | `MetricManager.logHandle(category:)` (`mxSignpost` itself is unchanged) |
| `histogrammedTimeToFirstDraw` etc. | `.timeToFirstDraw`, `.optimizedTimeToFirstDraw`, `.applicationResumeTime`, `.extendedLaunch` |
## Part 2: Setup (Legacy)
The `MX*` API below is soft-deprecated in the 27 SDK but is the only MetricKit API on iOS 13–26.
### Basic Integration
```swift
import MetricKit
class AppMetricsSubscriber: NSObject, MXMetricManagerSubscriber {
override init() {
super.init()
MXMetricManager.shared.add(self)
}
deinit {
MXMetricManager.shared.remove(self)
}
// MARK: - MXMetricManagerSubscriber
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
processMetrics(payload)
}
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
processDiagnostics(payload)
}
}
}
```
### Registration Timing
Register subscriber early in app lifecycle:
```swift
@main
struct MyApp: App {
private let metricsSubscriber = AppMetricsSubscriber()
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
```
Or in AppDelegate:
```swift
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
metricsSubscriber = AppMetricsSubscriber()
return true
}
```
### Development Testing
In iOS 15+, trigger immediate delivery via Debug menu:
**Xcode > Debug > Simulate MetricKit Payloads**
No code needed in debug builds — payloads are delivered immediately in development.
## Part 3: MXMetricPayload (Legacy)
`MXMetricPayload` contains aggregated performance metrics from the past 24 hours.
### Payload Structure
```swift
func processMetrics(_ payload: MXMetricPayload) {
// Time range for this payload
let start = payload.timeStampBegin
let end = payload.timeStampEnd
// App version that generated this data
let version = payload.metaData?.applicationBuildVersion
// Access specific metric categories
if let cpuMetrics = payload.cpuMetrics {
processCPU(cpuMetrics)
}
if let memoryMetrics = payload.memoryMetrics {
processMemory(memoryMetrics)
}
if let launchMetrics = payload.applicationLaunchMetrics {
processLaunches(launchMetrics)
}
// ... other categories
}
```
### CPU Metrics (MXCPUMetric)
```swift
func processCPU(_ metrics: MXCPUMetric) {
// Cumulative CPU time
let cpuTime = metrics.cumulativeCPUTime // Measurement<UnitDuration>
// iOS 14+: CPU instruction count
if #available(iOS 14.0, *) {
let instructions = metrics.cumulativeCPUInstructions // Measurement<Unit>
}
}
```
### Memory Metrics (MXMemoryMetric)
```swift
func processMemory(_ metrics: MXMemoryMetric) {
// Peak memory usage
let peakMemory = metrics.peakMemoryUsage // Measurement<UnitInformationStorage>
// Average suspended memory
let avgSuspended = metrics.averageSuspendedMemory // MXAverage<UnitInformationStorage>
}
```
### Launch Metrics (MXAppLaunchMetric)
```swift
func processLaunches(_ metrics: MXAppLaunchMetric) {
// First draw (cold launch) histogram
let firstDrawHistogram = metrics.histogrammedTimeToFirstDraw
// Resume time histogram
let resumeHistogram = metrics.histogrammedApplicationResumeTime
// Optimized time to first draw (iOS 15.2+)
if #available(iOS 15.2, *) {
let optimizedLaunch = metrics.histogrammedOptimizedTimeToFirstDraw
}
// Parse histogram buckets
for bucket in firstDrawHistogram.bucketEnumerator {
if let bucket = bucket as? MXHistogramBucket<UnitDuration> {
let start = bucket.bucketStart // e.g., 0ms
let end = bucket.bucketEnd // e.g., 100ms
let count = bucket.bucketCount // Number of launches in this range
}
}
}
```
### Application Exit Metrics (MXAppExitMetric) — iOS 14+
```swift
@available(iOS 14.0, *)
func processExits(_ metrics: MXAppExitMetric) {
let fg = metrics.foregroundExitData
let bg = metrics.backgroundExitData
// Foreground (onscreen) exits
let fgNormal = fg.cumulativeNormalAppExitCount
let fgWatchdog = fg.cumulativeAppWatchdogExitCount
let fgMemoryLimit = fg.cumulativeMemoryResourceLimitExitCount
let fgMemoryPressure = fg.cumulativeMemoryPressureExitCount
let fgBadAccess = fg.cumulativeBadAccessExitCount
let fgIllegalInstruction = fg.cumulativeIllegalInstructionExitCount
let fgAbnormal = fg.cumulativeAbnormalExitCount
// Background exits
let bgSuspended = bg.cumulativeSuspendedWithLockedFileExitCount
let bgTaskTimeout = bg.cumulativeBackgroundTaskAssertionTimeoutExitCount
let bgCPULimit = bg.cumulativeCPUResourceLimitExitCount
}
```
### Scroll Hitch Metrics (MXAnimationMetric) — iOS 14+
```swift
@available(iOS 14.0, *)
func processHitches(_ metrics: MXAnimationMetric) {
// Scroll hitch rate (hitches per scroll)
let scrollHitchRate = metrics.scrollHitchTimeRatio // Double (0.0 - 1.0)
}
```
### Disk I/O Metrics (MXDiskIOMetric)
```swift
func processDiskIO(_ metrics: MXDiskIOMetric) {
let logicalWrites = metrics.cumulativeLogicalWrites // Measurement<UnitInformationStorage>
}
```
### Network Metrics (MXNetworkTransferMetric)
```swift
func processNetwork(_ metrics: MXNetworkTransferMetric) {
let cellUpload = metrics.cumulativeCellularUpload
let cellDownload = metrics.cumulativeCellularDownload
let wifiUpload = metrics.cumulativeWifiUpload
let wifiDownload = metrics.cumulativeWifiDownload
}
```
### Signpost Metrics (MXSignpostMetric)
Track custom operations with signposts:
```swift
// In your code: emit signposts
import os.signpost
let log = MXMetricManager.makeLogHandle(category: "ImageProcessing")
func processImage(_ image: UIImage) {
mxSignpost(.begin, log: log, name: "ProcessImage")
// ... do work ...
mxSignpost(.end, log: log, name: "ProcessImage")
}
// In metrics subscriber: read signpost data
func processSignposts(_ metrics: MXSignpostMetric) {
let name = metrics.signpostName
let category = metrics.signpostCategory
// Histogram of durations (signpostIntervalData is Optional — unwrap before use)
// MXHistogram<NSUnitDuration>
let histogram = metrics.signpostIntervalData?.histogrammedSignpostDuration
// Total count
let count = metrics.totalCount
}
```
### Exporting Payload as JSON
```swift
func exportPayload(_ payload: MXMetricPayload) {
// JSON representation for upload to analytics
let jsonData = payload.jsonRepresentation()
// Or as Dictionary
if let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] {
uploadToAnalytics(json)
}
}
```
## Part 4: MXDiagnosticPayload (Legacy) — iOS 14+
`MXDiagnosticPayload` contains diagnostic reports for crashes, hangs, disk write exceptions, and CPU exceptions.
### Payload Structure
```swift
@available(iOS 14.0, *)
func processDiagnostics(_ payload: MXDiagnosticPayload) {
// Crash diagnostics
if let crashes = payload.crashDiagnostics {
for crash in crashes {
processCrash(crash)
}
}
// Hang diagnostics
if let hangs = payload.hangDiagnostics {
for hang in hangs {
processHang(hang)
}
}
// Disk write exceptions
if let diskWrites = payload.diskWriteExceptionDiagnostics {
for diskWrite in diskWrites {
processDiskWriteException(diskWrite)
}
}
// CPU exceptions
if let cpuExceptions = payload.cpuExceptionDiagnostics {
for cpuException in cpuExceptions {
processCPUException(cpuException)
}
}
}
```
### MXCrashDiagnostic
```swift
@available(iOS 14.0, *)
func processCrash(_ diagnostic: MXCrashDiagnostic) {
// Call stack tree (needs symbolication)
let callStackTree = diagnostic.callStackTree
// Crash metadata
let signal = diagnostic.signal // e.g., SIGSEGV
let exceptionType = diagnostic.exceptionType // e.g., EXC_BAD_ACCESS
let exceptionCode = diagnostic.exceptionCode
let terminationReason = diagnostic.terminationReason
// Virtual memory info
let virtualMemoryRegionInfo = diagnostic.virtualMemoryRegionInfo
// Unique identifier for grouping similar crashes
// (not available - use call stack signature)
}
```
### MXHangDiagnostic
```swift
@available(iOS 14.0, *)
func processHang(_ diagnostic: MXHangDiagnostic) {
// How long the hang lasted
let duration = diagnostic.hangDuration // Measurement<UnitDuration>
// Call stack when hang occurred
let callStackTree = diagnostic.callStackTree
}
```
### MXDiskWriteExceptionDiagnostic
```swift
@available(iOS 14.0, *)
func processDiskWriteException(_ diagnostic: MXDiskWriteExceptionDiagnostic) {
// Total bytes written that triggered exception
let totalWrites = diagnostic.totalWritesCaused // Measurement<UnitInformationStorage>
// Call stack of writes
let callStackTree = diagnostic.callStackTree
}
```
### MXCPUExceptionDiagnostic
```swift
@available(iOS 14.0, *)
func processCPUException(_ diagnostic: MXCPUExceptionDiagnostic) {
// Total CPU time that triggered exception
let totalCPUTime = diagnostic.totalCPUTime // Measurement<UnitDuration>
// Total sampled time
let totalSampledTime = diagnostic.totalSampledTime
// Call stack of CPU-intensive code
let callStackTree = diagnostic.callStackTree
}
```
## Part 5: MXCallStackTree (Legacy)
`MXCallStackTree` contains stack frames from diagnostics. Frames are NOT symbolicated—you must symbolicate using your dSYM.
### Symbolicating MetricKit crashes
Write the `MXCrashDiagnostic.jsonRepresentation()` bytes to a file, then:
```bash
xcsym crash crash.json --format=standard
```
xcsym auto-detects MetricKit format. Note that MetricKit crashes from users don't ship dSYMs — pair with `xcsym verify crash.json` to confirm your archive's dSYM matches the binary the user was running (keep dSYMs for every App Store build). See `axiom-tools (skills/xcsym-ref.md)` for the full subcommand reference.
Manual fallback — match each frame's `binaryUUID` to a dSYM and resolve the address with atos:
```bash
mdfind "com_apple_xcode_dsym_uuids == A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
atos -arch arm64 -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp -l 0x100000000 0x105234567
```
Or use a crash reporting service that handles symbolication (Crashlytics, Sentry, etc.).
### Structure
```swift
@available(iOS 14.0, *)
func parseCallStackTree(_ tree: MXCallStackTree) {
// JSON representation
let jsonData = tree.jsonRepresentation()
// Parse the JSON
guard let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
let callStacks = json["callStacks"] as? [[String: Any]] else {
return
}
for callStack in callStacks {
guard let threadAttributed = callStack["threadAttributed"] as? Bool,
let frames = callStack["callStackRootFrames"] as? [[String: Any]] else {
continue
}
// threadAttributed = true means this thread caused the issue
if threadAttributed {
parseFrames(frames)
}
}
}
func parseFrames(_ frames: [[String: Any]]) {
for frame in frames {
// Binary image UUID (match to dSYM)
let binaryUUID = frame["binaryUUID"] as? String
// Address offset within binary
let offsetIntoBinaryTextSegment = frame["offsetIntoBinaryTextSegment"] as? Int
// Binary name (e.g., "MyApp", "UIKitCore")
let binaryName = frame["binaryName"] as? String
// Address (for symbolication)
let address = frame["address"] as? Int
// Sample count (how many times this frame appeared)
let sampleCount = frame["sampleCount"] as? Int
// Sub-frames (tree structure)
let subFrames = frame["subFrames"] as? [[String: Any]]
}
}
```
### JSON Structure Example
```json
{
"callStacks": [
{
"threadAttributed": true,
"callStackRootFrames": [
{
"binaryUUID": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
"offsetIntoBinaryTextSegment": 123456,
"binaryName": "MyApp",
"address": 4384712345,
"sampleCount": 10,
"subFrames": [
{
"binaryUUID": "F1E2D3C4-B5A6-7890-1234-567890ABCDEF",
"offsetIntoBinaryTextSegment": 78901,
"binaryName": "UIKitCore",
"address": 7234567890,
"sampleCount": 10
}
]
}
]
}
]
}
```
## Part 6: MXBackgroundExitData (Legacy)
Track why your app was terminated in the background:
```swift
@available(iOS 14.0, *)
func analyzeBackgroundExits(_ data: MXBackgroundExitData) {
// Normal exits (user closed, system reclaimed)
let normal = data.cumulativeNormalAppExitCount
// Memory issues
let memoryLimit = data.cumulativeMemoryResourceLimitExitCount // Exceeded memory limit
let memoryPressure = data.cumulativeMemoryPressureExitCount // Jetsam
// Crashes
let badAccess = data.cumulativeBadAccessExitCount // SIGSEGV
let illegalInstruction = data.cumulativeIllegalInstructionExitCount // SIGILL
let abnormal = data.cumulativeAbnormalExitCount // Other crashes
// System terminations
let watchdog = data.cumulativeAppWatchdogExitCount // Timeout during transition
let taskTimeout = data.cumulativeBackgroundTaskAssertionTimeoutExitCount // Background task timeout
let cpuLimit = data.cumulativeCPUResourceLimitExitCount // Exceeded CPU quota
let lockedFile = data.cumulativeSuspendedWithLockedFileExitCount // File lock held
}
```
### Exit Type Interpretation
| Exit Type | Meaning | Action |
|-----------|---------|--------|
| `normalAppExitCount` | Clean exit | None (expected) |
| `memoryResourceLimitExitCount` | Used too much memory | Reduce footprint |
| `memoryPressureExitCount` | Jetsam (system reclaimed) | Reduce background memory to <50MB |
| `badAccessExitCount` | SIGSEGV crash | Check null pointers, invalid memory |
| `illegalInstructionExitCount` | SIGILL crash | Check invalid function pointers |
| `abnormalExitCount` | Other crash | Check crash diagnostics |
| `appWatchdogExitCount` | Hung during transition | Reduce launch/background work |
| `backgroundTaskAssertionTimeoutExitCount` | Didn't end background task | Call `endBackgroundTask` properly |
| `cpuResourceLimitExitCount` | Too much background CPU | Move to BGProcessingTask |
| `suspendedWithLockedFileExitCount` | Held file lock while suspended | Release locks before suspend |
## Part 7: Integration Patterns (Legacy examples)
The examples below use the legacy subscriber. The patterns themselves — analytics upload, crash-reporter merge, threshold alerting — carry over to the new API: encode `MetricReport` with `JSONEncoder` instead of `jsonRepresentation()`.
### Upload to Analytics Service
```swift
class MetricsUploader {
func upload(_ payload: MXMetricPayload) {
let jsonData = payload.jsonRepresentation()
var request = URLRequest(url: analyticsEndpoint)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = jsonData
URLSession.shared.dataTask(with: request) { _, response, error in
if let error = error {
// Queue for retry
self.queueForRetry(jsonData)
}
}.resume()
}
}
```
### Combine with Crash Reporter
```swift
class HybridCrashReporter: MXMetricManagerSubscriber {
let crashlytics: Crashlytics // or Sentry, etc.
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
// MetricKit captures crashes that traditional reporters might miss
// (e.g., watchdog kills, memory pressure exits)
if let crashes = payload.crashDiagnostics {
for crash in crashes {
crashlytics.recordException(
name: crash.exceptionType?.description ?? "Unknown",
reason: crash.terminationReason ?? "MetricKit crash",
callStack: parseCallStack(crash.callStackTree)
)
}
}
}
}
}
```
### Alert on Regressions
```swift
class MetricsMonitor: MXMetricManagerSubscriber {
let thresholds = MetricThresholds(
launchTime: 2.0, // seconds
hangRate: 0.01, // 1% of sessions
memoryPeak: 200 // MB
)
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
checkThresholds(payload)
}
}
private func checkThresholds(_ payload: MXMetricPayload) {
// Check launch time
if let launches = payload.applicationLaunchMetrics {
let p50 = calculateP50(launches.histogrammedTimeToFirstDraw)
if p50 > thresholds.launchTime {
sendAlert("Launch time regression: \(p50)s > \(thresholds.launchTime)s")
}
}
// Check memory
if let memory = payload.memoryMetrics {
let peakMB = memory.peakMemoryUsage.converted(to: .megabytes).value
if peakMB > Double(thresholds.memoryPeak) {
sendAlert("Memory peak regression: \(peakMB)MB > \(thresholds.memoryPeak)MB")
}
}
}
}
```
## Part 8: Best Practices
### Do
- **Subscribe early** — create `MetricManager` (or register the MX* subscriber) at launch; a late subscription loses reports
- **Keep dSYM files** — Required for symbolicating call stacks
- **Upload payloads to server** — Local processing loses data on uninstall
- **Set up alerting** — Detect regressions before users report them
- **Test with simulated payloads** — Xcode Debug menu in iOS 15+
### Don't
- **Don't rely solely on MetricKit** — 24-hour delay, requires user opt-in
- **Don't ignore background exits** — Jetsam and task timeouts affect UX
- **Don't skip symbolication** — Raw addresses are unusable
- **Don't process on main thread** — Payload processing can be expensive
### Privacy Considerations
- MetricKit data is **aggregated and anonymized**
- Data only from users who **opted into sharing analytics**
- No personally identifiable information
- Safe to upload to your servers
## Part 9: MetricKit vs Xcode Organizer
| Feature | MetricKit | Xcode Organizer |
|---------|-----------|-----------------|
| **Data source** | Devices running your app | App Store Connect aggregation |
| **Delivery** | Daily to your subscriber | On-demand in Xcode |
| **Customization** | Full access to raw data | Predefined views |
| **Symbolication** | You must symbolicate | Pre-symbolicated |
| **Historical data** | Only when subscriber active | Last 16 versions |
| **Requires code** | Yes | No |
**Use both**: Organizer for quick overview, MetricKit for custom analytics and alerting.
The Xcode 27 Organizer adds a redesigned Overview, Storage and animation-hitches metrics (fed by the corresponding 27 MetricKit metrics), calibrated Metric Goals, and agentic Generate Recommendations — see `axiom-performance (skills/performance-profiling.md)`.
## Part 10: CrashReportExtension — Crash Reporter Extensions `OS27`
A NEW framework (iOS 27/macOS 27; also in the visionOS 27 SDK; the extension protocol is explicitly unavailable on tvOS/watchOS) for shipping a crash reporter as an app extension. Where MetricKit delivers crash *diagnostics* on the app's next run (Part 1), a crash reporter extension is invoked by the system when a crash report is ready to be processed, in its own process separate from the crashed app — the extension point for third-party crash reporters. You can persist the report or send it to a server you control.
```swift
import CrashReportExtension
import ExtensionFoundation
@main
struct MyCrashReporter: CrashReporterExtension {
init() {}
func processCrashReport(process: CrashedProcess) {
let reason = process.reason // CrashReason: exception code + codes
let images = process.binaryImages // [BinaryImageInfo]
let faultAddress: UInt64 = addressFromBacktrace() // an address you pull from the crashed process
let frames = process.symbolicateAddress(faultAddress) // [SymbolicatedFrame]
// persist or upload the report
}
}
```
| Type | Members |
|------|---------|
| `CrashReporterExtension` | `AppExtension` protocol; implement `processCrashReport(process:)`; default `configuration` provided |
| `CrashedProcess` | `reason: CrashReason`, `corpsePort: mach_port_t`, `binaryImages: [BinaryImageInfo]`, `symbolicateAddress(_:) -> [SymbolicatedFrame]`, `symbolicateAddresses(_:)`, `symbolAddress(imageName:symbolName:)` |
| `CrashReason` | `exception: Int32`, `codes: [UInt64]` |
| `SymbolicatedFrame` | `symbol`, `symbolOffset`, `sourceFile?`, `sourceLine?`, `isInline` — `Codable`, `Sendable` |
| `BinaryImageInfo` | `path`, `uuid?`, `baseAddress`, `size`, `cpuType`, `cpuSubType` — `Codable`, `Sendable` |
Symbolication happens in the extension at crash time (`symbolicateAddress` returns multiple frames when inlining applies — note `isInline`), so reports can carry symbol names without shipping dSYMs to a server. For analyzing crash *files* on your Mac (`.ips`, MetricKit JSON), use Axiom's `xcsym` instead — see `axiom-tools (skills/xcsym-ref.md)`.
## Resources
**WWDC**: 2019-417, 2020-10081, 2021-10087, 2026-222
**Docs**: /metrickit, /metrickit/metricmanager, /statereporting, /crashreportextension, /metrickit/mxmetricmanager, /metrickit/mxdiagnosticpayload
**Skills**: axiom-performance (skills/hang-diagnostics.md), axiom-performance (skills/performance-profiling.md), axiom-performance (skills/app-launch.md), axiom-performance (skills/memory-debugging.md), axiom-shipping (skills/testflight-triage.md), axiom-tools (skills/xcsym-ref.md)
skills/objc-block-retain-cycles.md
# Objective-C Block Retain Cycles
## Overview
Block retain cycles are the #1 cause of Objective-C memory leaks. When a block captures `self` and is stored on that same object (directly or indirectly through an operation/request), you create a circular reference: self → block → self. **Core principle** 90% of block memory leaks stem from missing or incorrectly applied weak-strong patterns, not genuine Apple framework bugs.
## Red Flags — Suspect Block Retain Cycle
If you see ANY of these, suspect a block retain cycle, not something else:
- Memory grows steadily over time during normal app use
- UIViewController instances not deallocating (verified in Instruments)
- Crash: "Sending message to deallocated instance" from network/async callback
- Network requests or animations prevent view controller from closing
- Weak reference becomes nil unexpectedly in a block
- NSLog, NSAssert, or string formatting hiding self references
- Completion handler fires after the view controller "should be gone"
- ❌ **FORBIDDEN** Rationalizing as "It's probably normal memory usage"
- Memory leaks are never "normal"
- Apps should return to baseline memory after user dismisses a screen
- Do not rationalize this as "good enough" or "monitor it later"
**Critical distinction** Block retain cycles accumulate silently. A single cycle might be 100KB, but after 50 screens viewed, you have 5MB of dead memory. **MANDATORY: Test on real device (oldest supported model) after fixes, not just simulator.**
## Mandatory First Steps
**ALWAYS run these FIRST** (before changing code):
```objc
// 1. Identify the leak with Allocations instrument
// In Xcode: Xcode > Open Developer Tool > Instruments
// Choose Allocations template
// Perform an action (open/close a screen with the suspected block)
// Check if memory doesn't return to baseline
// Record: "Memory baseline: X MB, after action: Y MB, still allocated: Z objects"
// 2. Use Memory Debugger to trace the cycle
// Run app, pause at suspected code location
// Debug > Debug Memory Graph
// Search for the view controller that should be deallocated
// Right-click > Show memory graph
// Look for arrows pointing back to self (the cycle)
// Record: "ViewController retained by: [operation/block/property]"
// 3. Check if block is assigned to self or self's properties
// Search for: setBlock:, completion:, handler:, callback:
// Check: Is the block stored in self.property?
// Check: Is the block passed to something that retains it (network operation)?
// Record: "Block assigned to: [property or operation]"
// 4. Search for self references in the block
// Look for: [self method], self.property, self-> access
// Look for HIDDEN self references:
// - NSLog(@"Value: %@", self.property)
// - NSAssert(self.isValid, @"message")
// - Format strings: @"Name: %@", self.name
// Record: "self references found in block: [list]"
// Example output:
// Memory not returning to baseline ✓
// ViewController retained by: AFHTTPRequestOperation
// Operation retains: successBlock
// Block references self: [self updateUI], NSLog with self.property
// → DIAGNOSIS: Block retain cycle confirmed
```
#### What this tells you
- **Memory stays high** → Leak confirmed, not false alarm
- **ViewController retained by operation** → Block is the culprit
- **Block references self** → Pattern: weak-strong needed
- **Hidden self in NSLog/NSAssert** → Need to check ALL macro calls
- **No self references found** → Maybe not a block cycle, investigate elsewhere
#### MANDATORY INTERPRETATION
Before changing ANY code, you must confirm ONE of these:
1. If memory doesn't return to baseline AND ViewController still allocated → Block retain cycle exists
2. If memory returns to baseline → Not a retain cycle, investigate other causes
3. If cycle exists but you can't find self references → Check for hidden references (macros, indirect property access)
4. If you find the cycle but don't understand the chain → Trace backward through retained objects in Memory Graph
#### If diagnostics are contradictory or unclear
- STOP. Do NOT proceed to patterns yet
- Add more diagnostics: Print the object graph, list retained objects
- Ask: "If memory is low, why is the ViewController still allocated?"
- Run Instruments > Leaks instrument if memory graph is confusing
## Decision Tree
```
Block memory leak suspected?
├─ Memory stays high after dismiss?
│ ├─ YES
│ │ ├─ ViewController still allocated in Memory Graph?
│ │ │ ├─ YES → Proceed to patterns
│ │ │ └─ NO → Not a block cycle, check other leaks
│ │ └─ NO → Not a leak, normal memory usage
│ │
│ └─ Crash: "Sending message to deallocated instance"?
│ ├─ Happens in block/callback?
│ │ ├─ YES → Block captured weakSelf but it became nil
│ │ │ └─ Apply Pattern 4 (Guard condition is wrong or missing)
│ │ └─ NO → Different crash, not block-related
│ └─ Crash is timing-dependent (only on device)?
│ └─ YES → Weak reference timing issue, apply Pattern 2
│
├─ Block assigned to self or self.property?
│ ├─ YES → Apply Pattern 1 (weak-strong mandatory)
│ ├─ Assigned through network operation/timer/animation?
│ │ └─ YES → Apply Pattern 1 (operation retains block indirectly)
│ └─ Block called immediately (inline execution)?
│ ├─ YES → Optional to use weak-strong (no cycle possible)
│ │ └─ But recommend for consistency with other blocks
│ └─ NO → Block stored or passed to async method → Use Pattern 1
│
├─ Multiple nested blocks?
│ └─ YES → Apply Pattern 3 (must guard ALL nested blocks)
│
├─ Block contains NSAssert, NSLog, or string format with self?
│ └─ YES → Apply Pattern 2 (macro hides self reference)
│
└─ Implemented weak-strong pattern but still leaking?
├─ Check: Is weakSelf used EVERYWHERE?
├─ Check: No direct `self` references mixed in?
├─ Check: Nested blocks also guarded?
└─ Check: No __unsafe_unretained used?
```
## Common Patterns
### Pattern Selection Rules (MANDATORY)
#### Apply ONE pattern at a time, in this order
1. **Always start with Pattern 1** (Weak-Strong Basics)
- If block assigned to self or self's properties → Pattern 1
- If block passed to operation/request that retains it → Pattern 1
- Only proceed to Pattern 2 if pattern still leaks
2. **Then Pattern 2** (Hidden self in Macros)
- Only if memory still leaks after applying Pattern 1
- Check for NSAssert, NSLog, string formatting
- If found, apply Pattern 2
3. **Then Pattern 3** (Nested Blocks)
- Only if block has nested callbacks
- Each nested block needs its own guard
- If found, apply Pattern 3
4. **Then Pattern 4** (Guard Condition Edge Cases)
- Only if crash happens with weakSelf approach
- Check guard condition is correct
- Verify strongSelf used everywhere
#### FORBIDDEN
- ❌ Applying multiple patterns at once
- ❌ Skipping Pattern 1 because "I already know weak-strong"
- ❌ Using __unsafe_unretained as workaround
- ❌ Using strong self "just this once"
- ❌ Rationalizing: "The block is too small for a leak"
---
### Pattern 1: Weak-Strong Pattern (MANDATORY)
**PRINCIPLE** Any block that captures `self` must use weak-strong pattern if block is retained by self (directly or transitively).
#### ❌ WRONG (Creates retain cycle)
```objc
[self.networkManager GET:@"url" success:^(id response) {
self.data = response; // self is retained by block
[self updateUI]; // block is retained by operation
} failure:^(NSError *error) {
[self handleError:error]; // CYCLE!
}];
```
#### ✅ CORRECT (Breaks the cycle)
```objc
__weak typeof(self) weakSelf = self;
[self.networkManager GET:@"url" success:^(id response) {
typeof(self) strongSelf = weakSelf;
if (strongSelf) {
strongSelf.data = response;
[strongSelf updateUI];
}
} failure:^(NSError *error) {
__weak typeof(self) weakSelf2 = self;
typeof(self) strongSelf = weakSelf2;
if (strongSelf) {
[strongSelf handleError:error];
}
}];
```
#### Why this works
1. `__weak typeof(self) weakSelf = self;` creates a weak reference outside the block
2. Block captures weakSelf (weak reference), not self (strong reference)
3. When block executes, convert to strongSelf (temporary strong ref)
4. Check if strongSelf is nil (object was deallocated)
5. Use strongSelf for the duration of the block
6. strongSelf released when block exits → No cycle
#### Important details
- Declare weakSelf OUTSIDE the block, not inside
- Use `typeof(self)` for type safety (works in both ARC and non-ARC)
- Guard condition MUST use `if (strongSelf)`, not just declare it
- Never use direct `self` inside the block once weakSelf is declared
- Apply to EVERY block that captures self
- ANY block that captures `self` must use weak-strong pattern
- This includes: `[self method]`, `self.property`, `self->ivar`
- Property access (`self.property = value`) captures self just like method calls
- Blocks passed to frameworks:
- If framework documentation says 'block is called asynchronously' → Use weak-strong pattern (framework retains the block)
- If framework documentation says 'block is called immediately' → Still safe to use weak-strong (better practice)
- If unsure about framework behavior → Always use weak-strong (doesn't hurt)
#### Capturing variables (avoiding indirect self references)
```objc
// ✅ SAFE: Capture simple values extracted from self
__weak typeof(self) weakSelf = self;
[self.manager fetch:^(id response) {
typeof(self) strongSelf = weakSelf;
if (strongSelf) {
NSString *name = strongSelf.name; // Extract value
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"Name: %@", name); // Captured the STRING, not self
});
}
}];
// ❌ WRONG: Capture properties directly in nested blocks
__weak typeof(self) weakSelf = self;
[self.manager fetch:^(id response) {
typeof(self) strongSelf = weakSelf;
if (strongSelf) {
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"Name: %@", strongSelf.name); // Captures strongSelf again!
});
}
}];
```
When nesting blocks, extract simple values first, then pass them to the inner block. This avoids creating an indirect capture of self through property access.
**Time cost** 30 seconds per block
---
### Pattern 2: Hidden self in Macros
**PRINCIPLE** Macros like NSAssert, NSLog, and string formatting can secretly capture self. You must check them.
#### ❌ WRONG (NSAssert captures self)
```objc
[self.button setTapAction:^{
NSAssert(self.isValidState, @"State must be valid"); // self captured!
[self doWork]; // Another self reference
}];
// Leak exists even though you think only [self doWork] captures self
```
#### ✅ CORRECT (Check for hidden captures)
```objc
__weak typeof(self) weakSelf = self;
[self.button setTapAction:^{
typeof(self) strongSelf = weakSelf;
if (strongSelf) {
// NSAssert still references self indirectly through strongSelf
NSAssert(strongSelf.isValidState, @"State must be valid");
[strongSelf doWork];
}
}];
```
#### Common hidden self references
- `NSAssert(self.condition, ...)` → Use strongSelf instead
- `NSLog(@"Value: %@", self.property)` → Use strongSelf.property
- `NSError *error = [NSError errorWithDomain:@"MyApp" ...]` → Safe, doesn't capture self
- String formatting: `@"Name: %@", self.name` → Use strongSelf.name
- Inline conditionals: `self.flag ? @"yes" : @"no"` → Use strongSelf.flag
#### How to find them
1. Search block for all instances of `self.`
2. Mark them: `[self method]`, `self.property`, `self->ivar`
3. Check if any are inside macro calls (NSAssert, NSLog, etc.)
4. Replace with strongSelf
**Time cost** 1 minute per block to audit
---
### Pattern 3: Nested Blocks (Each Needs Guard)
**PRINCIPLE** Nested blocks create a chain: outer block captures self, inner block captures outer block variable (which holds strongSelf), creating a new cycle. Each nested block needs its own weak-strong pattern.
#### ❌ WRONG (Guarded outer block only)
```objc
__weak typeof(self) weakSelf = self;
[self.manager fetchData:^(NSArray *result) {
typeof(self) strongSelf = weakSelf;
if (strongSelf) {
// Inner block captures strongSelf!
[strongSelf.analytics trackEvent:@"Fetched"
completion:^{
strongSelf.cachedData = result; // Still strong reference!
[strongSelf updateUI];
}];
}
}];
```
#### ✅ CORRECT (Guard every nested block)
```objc
__weak typeof(self) weakSelf = self;
[self.manager fetchData:^(NSArray *result) {
typeof(self) strongSelf = weakSelf;
if (strongSelf) {
// Declare new weak reference for inner block
__weak typeof(strongSelf) weakSelf2 = strongSelf;
[strongSelf.analytics trackEvent:@"Fetched"
completion:^{
typeof(strongSelf) strongSelf2 = weakSelf2;
if (strongSelf2) {
strongSelf2.cachedData = result;
[strongSelf2 updateUI];
}
}];
}
}];
```
#### Why this works
- Each nesting level needs its own weakSelf/strongSelf pair
- Outer block: weakSelf → strongSelf
- Inner block: weakSelf2 → strongSelf2
- Each level is independent and safe
#### Important details
- Don't reuse the same weakSelf variable in nested blocks
- Each nesting level gets a new pair (weakSelf2, strongSelf2)
- Guard condition MANDATORY for each level
- Use consistent naming: weakSelf, weakSelf2, weakSelf3 (for readability)
#### Common nested block patterns that need Pattern 3
- Completion handlers in callbacks
- `dispatch_async(queue, ^{ ... })`
- `dispatch_after(time, queue, ^{ ... })`
- `[NSTimer scheduledTimerWithTimeInterval:... block:^{ ... }]`
- `[UIView animateWithDuration:... animations:^{ ... }]`
Each of these is a block that might capture strongSelf, requiring its own weak-strong pattern.
#### Example with dispatch_async
```objc
__weak typeof(self) weakSelf = self;
[self.manager fetchData:^(NSArray *result) {
typeof(self) strongSelf = weakSelf;
if (strongSelf) {
__weak typeof(strongSelf) weakSelf2 = strongSelf;
dispatch_async(dispatch_get_main_queue(), ^{
typeof(strongSelf) strongSelf2 = weakSelf2;
if (strongSelf2) {
strongSelf2.data = result;
[strongSelf2 updateUI];
}
});
}
}];
```
**Time cost** 1 minute per nesting level
---
### Pattern 4: Guard Condition Edge Cases
**PRINCIPLE** The guard condition `if (strongSelf)` must be correct. Common mistakes: forgetting the guard, wrong condition, or mixing self and strongSelf.
#### ❌ WRONG (Multiple guard failures)
```objc
__weak typeof(self) weakSelf = self;
[self.button setTapAction:^{
typeof(self) strongSelf = weakSelf;
// MISTAKE 1: Forgot guard condition
self.counter++; // CRASH! self is deallocated, accessing freed object
// MISTAKE 2: Guard exists but used wrong variable
if (weakSelf) {
[weakSelf doWork]; // weakSelf is weak, might become nil again
}
// MISTAKE 3: Mixed self and strongSelf
if (strongSelf) {
self.flag = YES; // Used self instead of strongSelf!
[strongSelf doWork];
}
}];
```
#### ✅ CORRECT (Proper guard and consistent usage)
```objc
__weak typeof(self) weakSelf = self;
[self.button setTapAction:^{
typeof(self) strongSelf = weakSelf;
if (strongSelf) {
// CORRECT: Use strongSelf everywhere, never self
strongSelf.counter++;
strongSelf.flag = YES;
[strongSelf doWork];
}
// If strongSelf is nil, entire block skips gracefully
}];
```
#### Why this works
1. `if (strongSelf)` checks if object still exists
2. If it does, strongSelf is a strong reference (safe)
3. If it doesn't (object deallocated), block skips
4. Using strongSelf everywhere prevents accidental self references
#### Critical rules (MANDATORY, no exceptions)
- ✅ ALWAYS check `if (strongSelf)` before using it
- ✅ ALWAYS use strongSelf inside the if block, NEVER direct self
- ✅ strongSelf is guaranteed valid for the entire block scope
- ❌ NEVER use `if (!strongSelf) return;` (confuses logic)
- ❌ NEVER skip the guard to "save code"
- ❌ NEVER mix weakSelf and strongSelf access
- ❌ NEVER use strongSelf without guard (GUARANTEED crash)
#### What happens if you get it wrong
- No guard: Crashes with "Sending message to deallocated instance"
- Wrong condition: Object still deallocated, still crashes
- Mixed self/strongSelf: One accidental self defeats entire pattern
- Using strongSelf without guard: GUARANTEED crash when object is deallocated
#### Inside the guard
```objc
if (strongSelf) {
strongSelf.data1 = value1;
[strongSelf doWork1];
[strongSelf doWork2]; // All safe
}
// ❌ WRONG: Using strongSelf after guard ends
strongSelf.data = value2; // CRASH! Outside guard
```
#### What NOT to do
```objc
// ❌ FORBIDDEN: strongSelf without guard guarantees crash
typeof(self) strongSelf = weakSelf;
strongSelf.data = value; // CRASH if weakSelf is nil!
// ✅ MANDATORY: Always guard before using strongSelf
if (strongSelf) {
strongSelf.data = value; // Safe
}
```
**Time cost** 10 seconds per block to verify guard is correct
---
## Quick Reference Table
| Issue | Check | Fix |
|-------|-------|-----|
| Memory not returning to baseline | Does ViewController still exist in Memory Graph? | Apply Pattern 1 (weak-strong) |
| Crash: "message to deallocated instance" | Is guard condition missing or wrong? | Apply Pattern 4 (correct guard) |
| Applied weak-strong but still leaking | Are ALL self references using strongSelf? | Check for mixed self/strongSelf |
| Block contains NSAssert or NSLog | Do they reference self? | Apply Pattern 2 (use strongSelf in macros) |
| Nested blocks | Is weak-strong applied to EACH level? | Apply Pattern 3 (guard every block) |
| Not sure if block creates cycle | Is block assigned to self or self.property? | If yes, apply Pattern 1 |
---
## When You're Stuck After 30 Minutes
If you've spent >30 minutes and the leak still exists:
#### STOP. You either
1. Skipped a mandatory diagnostic step (most common)
2. Didn't apply weak-strong to ALL blocks (nested blocks missed)
3. Have hidden self reference (NSAssert, NSLog, string format)
4. Applied pattern but mixed in direct `self` references
5. Have a different kind of leak (not block-related)
#### MANDATORY checklist before claiming "skill didn't work"
- [ ] I ran all 4 diagnostic blocks (Allocations, Memory Graph, block search, self reference search)
- [ ] I confirmed memory doesn't return to baseline in Instruments
- [ ] I confirmed ViewController is still allocated (not deallocated)
- [ ] I traced the retention chain (what's holding the ViewController?)
- [ ] I found ALL blocks that capture self (global search: `[self` in the file)
- [ ] I checked for hidden self references (NSAssert, NSLog, string formatting)
- [ ] I applied weak-strong pattern to outer blocks
- [ ] I applied weak-strong pattern to nested blocks (every nesting level)
- [ ] I verified NO direct `self` references remain (only strongSelf)
- [ ] I ran Instruments again and memory returned to baseline
- [ ] I tested on real device, not just simulator
- [ ] I cleared Xcode derived data between runs
#### If ALL boxes are checked and still leaking
- You have a non-block leak (Core Data, timer, delegate, notification)
- Use Instruments > Leaks instrument to identify the actual cycle
- Profile for 2-3 minutes: open screen, close screen, repeat 5 times
- Look at "Leaks" panel—it shows exactly what's not being released
- Time cost: 15-30 minutes to identify the real culprit
#### If you identify it's NOT a block leak
- Do not rationalize: "Maybe blocks are fine, I'll ship anyway"
- Find the actual cycle (could be delegate, timer, property observer, notification)
- Fix the real issue, not a false positive
#### Time cost transparency
- Pattern 1: 30 seconds per block
- Pattern 2: 1 minute per block (audit for hidden self)
- Pattern 3: 1 minute per nesting level
- Nested diagnostics if stuck: 15-30 minutes
- Total for straightforward leak: 5-10 minutes
---
## Common Mistakes
❌ **Forgetting the guard condition**
- `strongSelf.property = value;` without `if (strongSelf)`
- Crash when object is deallocated
- Fix: ALWAYS use `if (strongSelf) { ... }`
❌ **Mixing self and strongSelf in same block**
- `self.flag = YES; [strongSelf doWork];`
- One direct `self` reference defeats the entire pattern
- Fix: ONLY use strongSelf inside the block
❌ **Applying pattern to outer block only**
- Nested block still captures strongSelf strongly
- Still leaks
- Fix: Apply weak-strong to EVERY block
❌ **Using __unsafe_unretained as "workaround"**
- ❌ FORBIDDEN pattern—unsafe and crashes
- Creates crashes when object is deallocated
- Not a solution, worse problem
- Fix: Use weak-strong pattern instead
❌ **Not checking for hidden self references**
- `NSLog(@"Value: %@", self.property)` in a block
- Leak still exists even after applying weak-strong
- Fix: Audit for NSAssert, NSLog, string formatting
❌ **Rationalizing "it's a small leak"**
- Single block leak might be 100KB
- After 50 screens, accumulates to 5MB
- Eventually app crashes from memory pressure
- Fix: Fix every block leak, don't rationalize
❌ **Assuming blocks in system frameworks are safe**
- UIView animations, AFNetworking, dispatch, timers
- ALL can retain blocks that reference self
- Fix: Apply weak-strong pattern regardless of source
❌ **Testing only in simulator**
- Simulator memory pressure is different
- Leak might not appear until real device under load
- Fix: Test on real device, oldest supported model
## Real-World Impact
**Before** Block memory leak debugging 2-3 hours per issue
- Run Allocations, not sure what to look at
- Search everywhere, no clear diagnostic path
- Try random fixes, hope one works
- Ship anyway after sunk cost fallacy
- Customer reports crashes or slowdown
**After** 5-10 minutes with systematic diagnosis
- Run Allocations, confirm memory not returning to baseline
- Memory Graph shows exactly what's retained
- Find all blocks capturing self with global search
- Apply weak-strong pattern (30 seconds per block)
- Test in Instruments, memory returns to baseline
- Done
**Key insight** Block retain cycles are 100% preventable with weak-strong pattern. There are no exceptions, no "special cases" where strong self is acceptable.
---
**Last Updated**: 2025-11-30
**Status**: TDD-tested with pressure scenarios
**Framework**: Objective-C, blocks (closure), ARC
skills/performance-profiling.md
# Performance Profiling
## Overview
iOS app performance problems fall into distinct categories, each with a specific diagnosis tool. This skill helps you **choose the right tool**, **use it effectively**, and **interpret results correctly** under pressure.
**Core principle**: Measure before optimizing. Guessing about performance wastes more time than profiling.
**Requires**: Xcode 15+, iOS 14+
**Related skills**: `axiom-swiftui` (performance reference — SwiftUI-specific profiling with Instruments 26), `axiom-performance (skills/memory-debugging.md)` (memory leak diagnosis)
## When to Use Performance Profiling
#### Use this skill when
- ✅ App feels slow (UI lags, loads take 5+ seconds)
- ✅ Memory grows over time (Xcode shows increasing memory usage)
- ✅ Battery drains fast (device gets hot, battery depletes in hours)
- ✅ You want to profile proactively (before users complain)
- ✅ You're unsure which Instruments tool to use
- ✅ Profiling results are confusing or contradictory
#### Use `axiom-performance (skills/memory-debugging.md)` instead when
- Investigating specific memory leaks with retain cycles
- Using Instruments Allocations in detail mode
#### Use `axiom-swiftui` (performance reference) instead when
- Analyzing SwiftUI view body updates
- Using SwiftUI Instrument specifically
## Performance Decision Tree
Before opening Instruments, narrow down what you're actually investigating.
### Step 1: What's the Symptom?
```
App performance problem?
├─ App feels slow or lags (UI interactions stall, scrolling stutters)
│ └─ → Use Time Profiler (measure CPU usage)
├─ Memory grows over time (Xcode shows increasing memory)
│ └─ → Use Allocations (measure object creation)
├─ Data loading is slow (parsing, database queries, API calls)
│ └─ → Use Core Data instrument (if using Core Data)
│ └─ → Use Time Profiler (if it's computation)
└─ Battery drains fast (device gets hot, depletes in hours)
└─ → Use Energy Impact (measure power consumption)
```
### Step 2: Can You Reproduce It?
**YES** – Use Instruments to measure it (profiling is most accurate)
**NO** – Use profiling proactively
- Enable Core Data SQL debugging to catch N+1 queries
- Profile app during normal use (scrolling, loading, navigation)
- Establish baseline metrics before changes
### Step 3: Which Instruments Tool?
**Time Profiler** – Slowness, UI lag, CPU spikes (Top Functions mode for scattered overhead, `OS27`)
**Allocations** – Memory growth, memory pressure, object counts
**Core Data** – Query performance, fetch times, fault fires
**Energy Impact** – Battery drain, sustained power draw
**Network Link Conditioner** – Connection-related slowness
**System Trace** – Thread blocking, main thread blocking, scheduling
**Swift executors** – Tasks congesting the main actor (`OS27`)
**Foundation Models** – Agentic/LLM feature latency and token usage
---
## Time Profiler Deep Dive
Use Time Profiler when your app feels slow or laggy. It measures CPU time spent in each function.
### Workflow: Record and Analyze
#### Step 1: Launch Instruments
```bash
open -a Instruments
```
Select "Time Profiler" template.
#### Step 2: Attach to Running App
1. Start your app in simulator or device
2. In Instruments, select your app from the target dropdown
3. Click Record (red circle)
4. Interact with the slow part (scroll, tap buttons, load data)
5. Stop recording after 10-30 seconds of interaction
#### Step 3: Read the Call Stack
The top panel shows a timeline of CPU usage over time. Look for:
- **Tall spikes** – Brief CPU-intensive operations
- **Sustained high usage** – Continuous expensive work
- **Main thread blocking** – UI thread doing work (causes UI lag)
#### Step 4: Drill Down to Hot Spots
In the call tree, click "Heaviest Stack Trace" to see which functions use the most CPU:
```
Time Profiler Results
MyViewController.viewDidLoad() – 500ms (40% of total)
├─ DataParser.parse() – 350ms
│ └─ JSONDecoder.decode() – 320ms
└─ UITableView.reloadData() – 150ms
```
**Self Time** = Time spent IN that function (not in functions it calls)
**Total Time** = Time spent in that function + everything it calls
### Common Mistakes & Fixes
#### ❌ Mistake 1: Blaming the Wrong Function
```swift
// ❌ WRONG: Profile shows DataParser.parse() is 80% CPU
// Conclusion: "DataParser is slow, let me optimize it"
// ✅ RIGHT: Check what DataParser is calling
// If JSONDecoder.decode() is doing 99% of the work,
// optimize JSON decoding, not DataParser
```
**The issue**: A function with high Total Time might be calling slow code, not doing slow work itself.
**Fix**: Look at Self Time, not Total Time. Drill down to see what each function calls.
#### ❌ Mistake 2: Profiling the Wrong Code Path
```swift
// ❌ WRONG: Profile app in Simulator
// Simulator CPU is different than real device
// Results don't reflect actual device performance
// ✅ RIGHT: Profile on actual device
// Device settings: Developer Mode enabled, Xcode attached
```
**Fix**: Always profile on actual device for accurate CPU measurements.
#### ❌ Mistake 3: Not Isolating the Problem
```swift
// ❌ WRONG: Profile entire app startup
// Sees 2000ms startup time, many functions involved
// ✅ RIGHT: Profile just the slow part
// "App feels slow when scrolling" → profile only scrolling
// Separate concerns: startup slow vs interaction slow
```
**Fix**: Reproduce the specific slow operation, not the entire app.
### Pressure Scenario: "Profile Shows Function X is 80% CPU"
**The temptation**: "I must optimize function X!"
**The reality**: Function X might be:
- **Calling expensive code** (optimize the called function, not X)
- **Running on main thread** (move to background, it's already optimized)
- **Necessary work that looks slow** (baseline is acceptable, user won't notice)
**What to do instead**:
1. **Check Self Time, not Total Time**
- Self Time 80%? Function is actually doing expensive work
- Self Time 5%, Total Time 80%? Function is calling slow code
2. **Drill down one level**
- What is this function calling?
- Is the slow code in a library you control?
3. **Check the timeline**
- Is this 80% sustained (steady slow) or spikes (occasional stalls)?
- Sustained = optimization needed
- Spikes = caching might help
4. **Ask: Will users notice?**
- 500ms background work = user won't notice
- 500ms on main thread = UI stall, user sees it
- 50ms on main thread per frame = smooth UI (60fps)
**Time cost**: 5 min (read results) + 2 min (drill down) = **7 minutes to understand**
**Cost of guessing**: 2 hours optimizing wrong function + 1 hour realizing it didn't help + back to square one = **3+ hours wasted**
---
## Allocations Deep Dive
Use Allocations when memory grows over time or you suspect memory pressure issues.
Before recording, get the peak from the process itself — `ledger_phys_footprint_peak` via `task_vm_info` is a single call, needs no trace, and cannot be missed by a sampling interval. If it sits far above the resting figure, you know you are hunting a transient spike rather than growth, which changes what you record and where you look. See `axiom-performance (skills/memory-debugging.md)` — Measure Peak, Not Resting.
### Workflow: Record and Analyze
#### Step 1: Launch Instruments
```bash
open -a Instruments
```
Select "Allocations" template.
#### Step 2: Attach and Record
1. Start your app
2. In Instruments, select your app
3. Click Record
4. Perform actions that use memory (load data, display images, navigate)
5. Stop recording after memory stabilizes or peaks
#### Step 3: Find Memory Growth
Look at the main chart:
- **Blue line** = Total allocations
- **Sharp climb** = Memory being allocated
- **Flat line** = Memory stable (good)
- **No decline after stopping actions** = Possible leak (or caching)
#### Step 4: Identify Persistent Objects
Under "Statistics":
- Sort by "Persistent" (objects still alive)
- Look for surprisingly large object counts:
```
UIImage: 500 instances (300MB) – Should be <50 for normal app
NSString: 50000 instances – Should be <1000
CustomDataModel: 10000 instances – Should be <100
```
### Common Mistakes & Fixes
#### ❌ Mistake 1: Confusing "Memory Grew" with "Memory Leak"
```swift
// ❌ WRONG: Memory went from 100MB to 500MB
// Conclusion: "There's a leak, memory keeps growing!"
// ✅ RIGHT: Check what caused the growth
// Loaded 1000 images (normal)
// Cached API responses (normal)
// User has 5000 contacts (normal)
// Memory is being used correctly
```
**The issue**: Growing memory ≠ leak. Apps legitimately use more memory when loading data.
**Fix**: Check Allocations for object counts. If images/data count matches what you loaded, it's normal. If object count keeps growing without actions, that's a leak.
#### ❌ Mistake 2: Not Accounting for Caching
```swift
// ❌ WRONG: Allocations shows 1000 UIImages in memory
// Conclusion: "Memory leak, too many images!"
// ✅ RIGHT: Check if this is intentional caching
// ImageCache holds up to 1000 images by design
// When memory pressure happens, cache is cleared
// Normal behavior
```
**Fix**: Distinguish between intended caching and actual leaks. Leaks don't release under memory pressure.
#### ❌ Mistake 3: Profiling Too Short
```swift
// ❌ WRONG: Record for 5 seconds, see 200MB
// Conclusion: "App uses 200MB, optimize memory"
// ✅ RIGHT: Record for 2-3 minutes, see full lifecycle
// Load data: 200MB
// Navigate away: 180MB (20MB still cached)
// Navigate back: 190MB (cache reused)
// Real baseline: ~190MB at steady state
```
**Fix**: Profile long enough to see memory stabilize. Short recordings capture transient spikes.
### Pressure Scenario: "Memory is 500MB, That's a Leak!"
**The temptation**: "Delete caching, reduce object creation, optimize data structures"
**The reality**: Is 500MB actually large?
- iPhone 14 Pro has 6GB RAM
- Instagram uses 400-600MB on load
- Photos app uses 500MB+ when browsing large library
- 500MB might be completely normal
**What to do instead**:
1. **Establish baseline on real device**
```bash
# On device, open Memory view in Xcode
Xcode → Debug → Memory Debugger → Check "Real Memory" at app launch
```
2. **Check object counts, not total memory**
- Allocations → Statistics → "Persistent"
- Are images, views, or data objects 10x expected count?
- If yes, investigate that object type
- If no, memory is probably fine
3. **Test under memory pressure**
- Xcode → Debug → Simulate Memory Warning
- Does memory drop by 50%+? It's caching (normal)
- Does memory stay high? Investigate persistent objects
4. **Profile real user journey**
- Load data (like user does)
- Navigate around (like user does)
- Return to app (from background)
- Check memory at each step
**Time cost**: 5 min (launch Allocations) + 3 min (record app usage) + 2 min (analyze) = **10 minutes**
**Cost of guessing**: Delete caching to "reduce memory" → app reloads data every screen → slower app → users complain → revert changes = **2+ hours wasted**
---
## Core Data Deep Dive
Use Core Data instrument when your app uses Core Data and data loading is slow.
### Workflow: Enable SQL Debugging and Profile
#### Step 1: Enable Core Data SQL Logging
Add to your launch arguments in Xcode:
```
Edit Scheme → Run → Arguments Passed On Launch
Add: -com.apple.CoreData.SQLDebug 1
```
Now SQLite queries print to console:
```
CoreData: sql: SELECT ... FROM tracks WHERE artist = ? (time: 0.015s)
CoreData: sql: SELECT ... FROM albums WHERE id = ? (time: 0.002s)
```
#### Step 2: Identify N+1 Query Problem
Watch the console during a typical user action (load list, scroll, filter):
```
❌ BAD: Loading 100 tracks, then querying album for each
SELECT * FROM tracks (time: 0.050s) → 100 tracks
SELECT * FROM albums WHERE id = 1 (time: 0.005s)
SELECT * FROM albums WHERE id = 2 (time: 0.005s)
SELECT * FROM albums WHERE id = 3 (time: 0.005s)
... 97 more queries
Total: 0.050s + (100 × 0.005s) = 0.550s
✅ GOOD: Fetch tracks WITH album relationship (eager loading)
SELECT tracks.*, albums.* FROM tracks
LEFT JOIN albums ON tracks.albumId = albums.id
(time: 0.050s)
Total: 0.050s
```
#### Step 3: Profile with Core Data Instrument
```bash
open -a Instruments
```
Select "Core Data" template.
Record while performing slow action:
```
Core Data Results
Fetch Requests: 102
Average Fetch Time: 12ms
Slow Fetch: "SELECT * FROM tracks" (180ms)
Fault Fires: 5000
→ Object accessed, requires fetch from database
→ Should use prefetching
```
### Common Mistakes & Fixes
#### ❌ Mistake 1: Not Using Relationships Correctly
```swift
// ❌ WRONG: Fetch tracks, then access album for each
let tracks = try context.fetch(Track.fetchRequest())
for track in tracks {
print(track.album.title) // Fires individual query for each
}
// Total: 1 + N queries
// ✅ RIGHT: Fetch with relationship prefetching
let request = Track.fetchRequest()
request.returnsObjectsAsFaults = false
request.relationshipKeyPathsForPrefetching = ["album"]
let tracks = try context.fetch(request)
for track in tracks {
print(track.album.title) // Already loaded
}
// Total: 1 query
```
**Fix**: Use `relationshipKeyPathsForPrefetching` to load related objects upfront.
#### ❌ Mistake 2: Not Using Batching
```swift
// ❌ WRONG: Fetch 50,000 records all at once
let request = Track.fetchRequest()
let allTracks = try context.fetch(request) // Huge memory spike
// ✅ RIGHT: Batch fetch in chunks
let request = Track.fetchRequest()
request.fetchBatchSize = 500 // Fetch 500 at a time
let allTracks = try context.fetch(request) // Memory efficient
```
**Fix**: Use `fetchBatchSize` for large datasets.
#### ❌ Mistake 3: Not Using Faulting to Reduce Memory
```swift
// ❌ WRONG: Keep all objects in memory
let request = Track.fetchRequest()
request.returnsObjectsAsFaults = false // Keep all in memory
let allTracks = try context.fetch(request) // 50,000 objects
// Memory spike if you don't use all of them
// ✅ RIGHT: Use faults (lazy loading)
let request = Track.fetchRequest()
// request.returnsObjectsAsFaults = true (default)
let allTracks = try context.fetch(request) // Just references
// Only load objects you actually access
```
**Fix**: Leave `returnsObjectsAsFaults` as default (true) unless you need all objects upfront.
### Pressure Scenario: "Core Data Queries Are Slow, Redesign Schema!"
**The temptation**: "The schema is wrong, I need to restructure everything"
**The reality**: 99% of "slow Core Data" is due to:
- ❌ Missing indexes
- ❌ N+1 query problem
- ❌ Fetching too much data at once
- ❌ Not using batch size or prefetching
Redesigning the schema is the LAST thing to try.
**What to do instead**:
1. **Enable SQL debugging** (2 min)
- Add `-com.apple.CoreData.SQLDebug 1` launch argument
- Watch what queries execute
2. **Look for N+1 pattern** (3 min)
- Fetching 100 objects, then individual queries for related data?
- Add relationship prefetching
3. **Add indexes if needed** (5 min)
- `@NSManaged var artist: String` with frequent filtering?
- Add `@Index` in schema
4. **Test improvement** (2 min)
- Re-run the same action
- Compare query count and total time
- If 10x faster, you're done
- If still slow, go to step 5
5. **Only THEN consider schema changes** (30+ min)
- But you probably won't get here
**Time cost**: 12 minutes to diagnose + fix = **12 minutes**
**Cost of schema redesign**: 8 hours design + 4 hours migration + 2 hours testing + 1 hour rollback = **15 hours total**
---
## Quick Reference: Other Tools
### Energy Impact (Battery Drain)
**When to use**: App drains battery fast, device gets hot
**Workflow**:
1. Launch Instruments → Energy Impact template
2. Run app normally for 5+ minutes
3. Look for red/orange sustained usage (bad)
4. Drill down to see which subsystems drain battery
**Key metrics**:
- **Sustained Power** – Ongoing energy use (should be minimal)
- **Peaks** – Brief high usage (acceptable)
- **CPU** – Process CPU time
- **GPU** – Graphics rendering
- **Network** – Cellular/WiFi radio
- **Location** – GPS usage
**Common issues**:
- Continuous location updates with 1m accuracy (should be 100m)
- Running timers that wake the device repeatedly
- Excessive network calls (batch requests instead)
- Animating views while not visible
### Network Link Conditioner (Connection Simulation)
**When to use**: App seems slow on 4G, want to test without traveling
**Setup**:
1. Download Additional Tools for Xcode
2. Install Network Link Conditioner
3. Open System Preferences → Network Link Conditioner
4. Choose profile (3G, LTE, WiFi Slow, etc.)
5. Enable and activate profile
6. Run app to test
**Key profiles**:
- **3G** – 1.6Mbps down, 768Kbps up, 150ms latency
- **LTE** – 10Mbps down, 5Mbps up, 20ms latency
- **WiFi Slow** – 10Mbps, 100ms latency
- **Custom** – Set your own parameters
**Note**: Also covered in ui-testing for network-dependent test scenarios.
### System Trace (Thread Blocking, Scheduling)
**When to use**: UI freezes or is janky, but Time Profiler shows low CPU
**Common cause**: Main thread blocked by background task waiting on lock
**Workflow**:
1. Launch Instruments → System Trace template
2. Record while reproducing issue
3. Look for main thread gaps (blocked, not running)
4. Drill down to see what's blocking it
**Key metrics**:
- **Main thread gaps** – Empty spaces = main thread idle/blocked
- **Core scheduling** – Which threads run when
- **Lock contention** – Threads waiting for locks
---
## Instruments 27 & Xcode 27 Organizer `OS27`
WWDC 2026-268's diagnostic flow: start with Time Profiler, then branch on what the CPU is doing during the slowdown. **CPU high** → code bottleneck (optimize or offload). **CPU busy but tasks contending** → execution contention (Swift executors instrument). **CPU idle** → the thread is blocked on a resource (System Trace + Inspector) — Time Profiler only sees active CPU cycles and is blind here.
### Top Functions
A new Time Profiler analysis mode (same segmented control as the flame graph). A flame graph distributes the cost of functions called from many places — runtime functions and helpers fracture into slivers across every calling branch. Top Functions discards the call hierarchy and merges every scattered node into one block, ranked by **self** weight; selecting a function shows a flame graph of all code paths that call it.
Reach for it when no single call path is hot but the app still hangs — scattered overhead like dynamic dispatch, retain/release, safety checks, or existential unwrapping (`swift_project_boxed_opaque_existential` ranking first means `any Protocol` boxing is eating cycles; prefer concrete types, generics, or enums). WWDC 2026-258 sums it up: "expensive operations performed many times".
### Run Comparisons
Instruments can now compare profiling data across runs in a single document: filter both runs to the same `os_signpost` interval (this is what makes the comparison reliable), select the same track, click the compare button in the middle bar, and pick the baseline run. Deltas are computed per matched function — red = regression, green = improvement — viewable as call tree, flame graph, or Top Functions, with comparisons saved into the document. Expect renamed/new functions (e.g. after refactoring) to show as "regressions" alongside the removed originals — judge the net.
For headless/CI regression gating, `xcprof compare` remains the path — see `axiom-performance (skills/trace-comparison.md)`.
### Inspector Panel & System Trace Blocking
The new Inspector panel (right side) surfaces details and actions for the current selection: pin a thread, set the inspection range, and — in System Trace — read a syscall's exact arguments (file descriptor, buffer, size) and its on-core vs off-core time split (opaque = running, translucent = blocked). A "20% CPU" hang usually means the main thread is *blocked*, not slow: 2026-268's demo found a synchronous 1.7 GB `data.write(to:options:)` on the main thread this way; the fix is moving the work off the main actor (`Task { @concurrent in … }`).
### Swift Executors Instrument
New in the Swift Concurrency template: visualizes the main actor, the global concurrent executor, and custom executors as tracks. Use it when hangs line up with tasks running *on the main actor* — the demo's `renderThumbnail` tasks inherited main-actor context from SwiftUI and competed with UI updates; `Task(name:)` labels make the offending tasks identifiable in the track. Cross-route to `axiom-concurrency` for the `@concurrent` fix patterns.
### Foundation Models Instrument (agentic features)
Profiling LLM/agentic features built on FoundationModels: the improved Foundation Models Instrument (Product > Profile > Foundation Models template) shows instructions/tool-set lifetimes, prompt-processing vs response-generation time, and a sessions → requests → inferences tree with token counts. Key metrics: Time to First Token (shorten prompts), Tokens per Second (benchmark/regression), Total Latency (stream partial results). Prompt/response logging is on only during the trace — keep trace files safe. Full FM-side guidance: `axiom-ai (skills/foundation-models-ref.md)`.
### Xcode 27 Organizer
Four additions (WWDC 2026-258):
- **Redesigned Overview** — diagnostics and metrics on one screen, highest-impact issues first
- **Storage metric** — your app's footprint broken into documents, data, and binary size (binary size affects cellular download and launch time); **hitches metric** — animation hitches beyond scrolling (SwiftUI, Liquid Glass), where the old scrolling-only metric missed choppy animations. MetricKit 27 reports the same storage and hitch dimensions in-app (`metrickit-ref` Part 1)
- **Metric Goals** — last year's launch-time recommendations expanded to hang rate, disk writes, battery, storage, and hitches; calibrated against technically similar apps and your own historical baselines
- **Generate Recommendations** — agentic guided analysis: the agent works through the diagnostic data with you to find the regression cause and propose fixes
---
## OSSignposter — Custom Performance Instrumentation
While Time Profiler shows where CPU time goes generally, OSSignposter lets you measure specific operations you define. It's the primary tool for custom performance instrumentation on Apple platforms.
### When to Use
- Measuring duration of specific operations (data load, image processing, sync cycle)
- Creating custom Instruments lanes for your app's operations
- Bridging to automated performance testing (XCTOSSignpostMetric)
- Measuring operations that span multiple threads or await points
### Basic API
```swift
import os
let signposter = OSSignposter(subsystem: "com.app", category: "DataLoad")
// Interval measurement (start → end)
func loadData() async throws -> [Item] {
let signpostID = signposter.makeSignpostID()
let state = signposter.beginInterval("Load Items", id: signpostID)
defer { signposter.endInterval("Load Items", state) }
return try await fetchItems()
}
// Point of interest (single event)
func cacheHit(for key: String) {
signposter.emitEvent("Cache Hit")
}
```
### Integration with Instruments
1. Launch Instruments → add "os_signpost" or "Points of Interest" instrument
2. Record your app performing the instrumented operations
3. Signpost intervals appear as colored bars in the timeline
4. Filter by subsystem/category to focus on your operations
### When to Use Signposts vs Time Profiler
| Need | Tool |
|------|------|
| General CPU hotspots | Time Profiler |
| Specific operation duration | OSSignposter |
| Cross-thread operation timing | OSSignposter |
| Automated regression testing | OSSignposter + XCTOSSignpostMetric |
---
## Pressure Scenarios
### Scenario 1: "Profiling Shows Different Results Each Run"
**The problem**: You run Time Profiler 3 times, get 200ms, 150ms, 280ms. Which is correct?
**Red flags you might think**:
- "Results are unreliable, profiling isn't accurate"
- "Let me just average them"
- "This is too variable, I can't optimize"
**The reality**: Variance is NORMAL. Different runs hit different:
- Cache states (cold cache = slower)
- System load (other apps running)
- CPU frequency (boost/throttle)
**What to do instead**:
1. **Warm up the cache** (first run always slower)
- Perform the action once (cold cache)
- Perform again (warm cache) – use this measurement
2. **Control system load**
- Close other apps
- Don't touch device during profiling
- Profile on device (not simulator)
3. **Look for the pattern**
- Multiple runs: 150ms, 160ms, 155ms (consistent = good)
- Multiple runs: 150ms, 280ms, 240ms (inconsistent = investigate)
- Inconsistency = intermittent problem, find it
4. **Trust the slowest run** (worst case scenario)
- If range is 150-280ms, assume 280ms is real
- Optimize for worst case
**Time cost**: 10 min (run profiler 3x) + 2 min (interpret) = **12 minutes**
**Cost of ignoring variance**: Miss intermittent performance issue → users see occasional freezes → bad reviews
---
### Scenario 2: "Time Profiler and Allocations Show Different Problems"
**The problem**: Time Profiler shows JSON parsing is slow. Allocations show memory use is normal. Which to fix?
**The answer**: Both are real, prioritize differently.
```
Time Profiler: JSONDecoder.decode() = 500ms
Allocations: Memory = 250MB (normal for app size)
Result: App is slow AND memory is fine
Action: Optimize JSON decoding (not memory)
```
**Common conflicts**:
| Time Profiler | Allocations | Action |
|---|---|---|
| High CPU | Normal memory | Optimize computation (reduce CPU) |
| Low CPU | Memory growing | Find leak or reduce object creation |
| Both high | Both high | Profile which is user-visible first |
**What to do**:
1. **Prioritize by user impact**
- Slowness (UI lag) = fix first
- Memory (background issue) = fix second
2. **Check if they're related**
- Does JSON parsing leak memory? (No → separate issues)
- Does memory growth slow CPU? (Maybe → fix memory first)
3. **Fix in order of impact**
- Slow JSON parsing: Affects every data load
- Normal memory: No user impact
- → Fix JSON parsing
**Time cost**: 5 min (analyze both results) = **5 minutes**
**Cost of fixing wrong problem**: Spend 4 hours optimizing memory that's fine → no improvement to user experience
---
### Scenario 3: "Profiling Under Deadline Pressure"
**The situation**: Manager says "We ship in 2 hours. Is performance acceptable?"
**Red flags you might think**:
- "Profiling takes too long, let me just ask users"
- "I don't have time to profile properly, ship as-is"
- "One quick run will tell me if it's fine"
**The reality**: Profiling takes 15-20 minutes total. That's 1% of your remaining time.
**What to do instead**:
1. **Profile the critical path** (3 min)
- What users do most (load list, scroll, search)
- Not the entire app, just the slow part
2. **Record one proper run** (5 min)
- Cold cache first time
- Warm cache second time
- Use warm cache results
3. **Interpret quickly** (5 min)
- Time Profiler: Any >100ms on main thread? (If no, fine)
- Allocations: Any memory growing? (If no, fine)
4. **Ship with confidence** (2 min)
- If results are acceptable, ship
- If not, you have 90 minutes to fix or delay
**Time cost**: 15 min profiling + 5 min analysis = **20 minutes**
**Cost of not profiling**: Ship with unknown performance → Users hit slowness → Bad reviews → Emergency hotfix 2 weeks later
**Math**: 20 minutes of profiling now << 2+ weeks of post-launch support
---
## CLI Quick Checks (No Instruments)
Xcode ships CLI profiling tools for fast checks without opening Instruments.
### CPU Profiling
```bash
# Quick 5-second CPU sample of running app
xcrun sample MyApp 5
# Sample by PID, save to file for analysis
xcrun sample 12345 5 -file output.txt
```
**When to use**: Quick CPU check before committing to a full xctrace session. Shows which functions are hot in 5 seconds.
### Memory Profiling
```bash
# Quick leak check — is there a leak at all?
xcrun leaks MyApp
```
For `heap`, `vmmap`, `stringdups`, and a full CLI diagnosis workflow, see `axiom-performance (skills/memory-debugging.md)`.
### Headless Instruments (xctrace)
```bash
# CPU profile from CLI
xcrun xctrace record --instrument 'CPU Profiler' --attach 'MyApp' --time-limit 10s --output cpu.trace
# Memory allocations from CLI
xcrun xctrace record --instrument 'Allocations' --attach 'MyApp' --time-limit 30s --output alloc.trace
```
See `axiom-performance (skills/xctrace-ref.md)` for comprehensive xctrace reference.
## Quick Reference
### Common Operations
```swift
// Time Profiler: Launch Instruments
open -a Instruments
// Core Data: Enable SQL logging
// Edit Scheme → Run → Arguments Passed On Launch
-com.apple.CoreData.SQLDebug 1
// Allocations: Check persistent objects
Instruments → Allocations → Statistics → sort "Persistent"
// Memory warning: Simulate pressure
Xcode → Debug → Simulate Memory Warning
// Energy Impact: Profile battery drain
Instruments → Energy Impact template
// Network Link Conditioner: Simulate 3G
System Preferences → Network Link Conditioner → 3G profile
```
### Decision Tree Summary
```
Performance problem?
├─ App feels slow/laggy?
│ └─ → Time Profiler (measure CPU)
├─ Memory grows over time?
│ └─ → Allocations (find object growth)
├─ Data loading is slow?
│ └─ → Core Data instrument (if using Core Data)
│ └─ → Time Profiler (if computation slow)
└─ Battery drains fast?
└─ → Energy Impact (measure power)
```
---
## Real-World Examples
### Example 1: Identifying N+1 Query Problem in Core Data
**Scenario**: Your app loads a list of albums with artist names. It's slow (5+ seconds for 100 albums). You suspect Core Data.
**Setup**: Enable SQL logging first
```bash
# Edit Scheme → Run → Arguments Passed On Launch
-com.apple.CoreData.SQLDebug 1
```
**What you see in console**:
```
CoreData: sql: SELECT ... FROM albums WHERE ... (time: 0.050s)
CoreData: sql: SELECT ... FROM artists WHERE id = 1 (time: 0.003s)
CoreData: sql: SELECT ... FROM artists WHERE id = 2 (time: 0.003s)
... 98 more individual queries
Total: 0.050s + (100 × 0.003s) = 0.350s
```
**Diagnosis using the skill**:
- Fetching 100 albums, then individual query for each album's artist = **N+1 query problem** (Core Data Deep Dive, lines 302-325)
**Fix**:
```swift
// ❌ WRONG: Each album access triggers separate artist query
let request = Album.fetchRequest()
let albums = try context.fetch(request)
for album in albums {
print(album.artist.name) // Extra query for each
}
// ✅ RIGHT: Prefetch the relationship
let request = Album.fetchRequest()
request.returnsObjectsAsFaults = false
request.relationshipKeyPathsForPrefetching = ["artist"]
let albums = try context.fetch(request)
for album in albums {
print(album.artist.name) // Already loaded
}
```
**Result**: 0.350s → 0.050s (7x faster)
---
### Example 2: Finding Where UI Lag Really Comes From
**Scenario**: Your app UI stalls for 1-2 seconds when loading a view. Your co-lead says "Add background threading everywhere." You want to measure first.
**Workflow using the skill** (Time Profiler Deep Dive, lines 82-118):
1. **Open Instruments**:
```bash
open -a Instruments
# Select "Time Profiler"
```
2. **Record the stall**:
```
App launches
Time Profiler records
View loads
Stall happens (observe the spike in Time Profiler)
Stop recording
```
3. **Examine results**:
```
Call Stack shows:
viewDidLoad() – 1500ms
├─ loadJSON() – 1200ms (Self Time: 50ms)
│ └─ loadImages() – 1150ms (Self Time: 1150ms) ← HERE'S THE CULPRIT
├─ parseData() – 200ms
└─ layoutUI() – 100ms
```
4. **Apply the skill** (lines 173-175):
```
loadJSON() has Self Time: 50ms, Total Time: 1200ms
→ loadJSON() isn't slow, something it CALLS is slow
→ loadImages() has Self Time: 1150ms
→ loadImages() is the actual bottleneck
```
5. **Fix the right thing**:
```swift
// ❌ WRONG: Thread everything
DispatchQueue.global().async { loadJSON() }
// ✅ RIGHT: Thread only the slow part
func loadJSON() {
let data = parseJSON() // 50ms, fine on main
// Move ONLY the slow part to background
DispatchQueue.global().async {
let images = loadImages() // 1150ms, now background
DispatchQueue.main.async {
updateUI(with: images)
}
}
}
```
**Result**: 1500ms → 350ms (4x faster, main thread unblocked)
**Why this matters**: You fixed the ACTUAL bottleneck (1150ms), not guessing blindly about threading.
---
### Example 3: Memory Growing vs Memory Leak
**Scenario**: Allocations shows memory growing from 150MB to 600MB over 30 minutes of app use. Your manager says "Memory leak!" You need to know if it's real.
**Workflow using the skill** (Allocations Deep Dive, lines 199-277):
1. **Launch Allocations in Instruments**
2. **Record normal app usage for 3 minutes**:
```
User loads data → memory grows to 400MB
User navigates around → memory stays at 400MB
User goes to Settings → memory at 400MB
User comes back → memory at 400MB
```
3. **Check Allocations Statistics**:
```
Persistent Objects:
- UIImage: 1200 instances (300MB) ← Large count
- NSString: 5000 instances (4MB)
- CustomDataModel: 800 instances (15MB)
```
4. **Ask the skill questions** (lines 220-240):
- Are 1200 images legitimately loaded? (User loaded photo library with 1000 photos) → YES
- Does memory drop if you trigger memory warning? (Simulate with Xcode) → YES, drops to 180MB
- Is this caching working as designed? → YES
**Diagnosis**: NOT a leak. This is **normal caching** (lines 235-248)
```
Memory growing = apps using data users asked for
Memory dropping under pressure = cache working correctly
Memory staying high indefinitely = possible leak
```
5. **Conclusion**:
```swift
// ✅ This is working correctly
let imageCache = NSCache<NSString, UIImage>()
// Holds up to 1200 images by design
// Clears when system memory pressure happens
// No leak
```
**Result**: No action needed. The "leak" is actually the cache doing its job.
---
## Regression-Proofing Pipeline
Performance work isn't done when the fix ships. Without regression detection, optimizations quietly degrade over time. The three-stage pipeline catches regressions at every phase.
### The Three Stages
| Stage | Tool | When | Catches |
|-------|------|------|---------|
| Dev | OSSignposter | Writing code | Specific operation timing |
| CI | XCTest performance tests | Every PR | Regression vs baseline |
| Production | MetricKit | After release | Real-world degradation |
### Stage 1: Instrument Your Code (OSSignposter)
See OSSignposter section above. Add signpost intervals to performance-critical code paths.
### Stage 2: Automate with XCTest Performance Tests
```swift
func testDataLoadPerformance() throws {
let options = XCTMeasureOptions()
options.iterationCount = 10
measure(metrics: [
XCTClockMetric(), // Wall clock time
XCTCPUMetric(), // CPU time and cycles
XCTMemoryMetric(), // Peak physical memory
], options: options) {
loadData()
}
}
```
#### Available XCTMetric Types
- **XCTClockMetric** — Wall clock duration
- **XCTCPUMetric** — CPU time, instructions retired, cycles
- **XCTMemoryMetric** — Peak physical memory during test
- **XCTStorageMetric** — Logical writes to storage
- **XCTOSSignpostMetric** — Duration of signposted intervals (bridges Stage 1 → Stage 2)
- **XCTApplicationLaunchMetric** — App launch time (cold/warm/optimized)
- **XCTHitchMetric** — Hitch time ratio (scrolling and animation hitches)
#### Setting Baselines
After running once, click the value in Xcode's test results → "Set Baseline". Subsequent runs compare against baseline and fail if regression exceeds tolerance (default 10%).
#### Anti-Pattern: Baseline-Less Performance Tests
```swift
// ❌ Test always passes — no baseline set
func testPerformance() {
measure { doWork() }
}
// ✅ Set baseline in Xcode after first run
// Tests fail when performance regresses beyond tolerance
```
#### Bridging Signposts to Tests (XCTOSSignpostMetric)
```swift
// In production code
let signposter = OSSignposter(subsystem: "com.app", category: "Sync")
func syncData() {
let id = signposter.makeSignpostID()
let state = signposter.beginInterval("Full Sync", id: id)
defer { signposter.endInterval("Full Sync", state) }
// ... sync logic
}
// In test
func testSyncPerformance() {
let metric = XCTOSSignpostMetric(
subsystem: "com.app",
category: "Sync",
name: "Full Sync"
)
measure(metrics: [metric]) {
syncData()
}
}
```
### Stage 3: Monitor in Production (MetricKit)
See `axiom-performance (skills/metrickit-ref.md)` for comprehensive MetricKit integration. Key metrics to monitor:
- `MXAppLaunchMetric` — Launch time regression
- `MXAppResponsivenessMetric` — Hang rate increase
- `MXCPUMetric` — CPU time per foreground session
- `MXMemoryMetric` — Peak memory growth across versions
---
## Resources
**WWDC**: 2023-10160, 2024-10217, 2025-308, 2025-312, 2026-258, 2026-268, 2026-243
**Docs**: /library/archive/documentation/cocoa/conceptual/coredataperformance, /library/archive/technotes/tn2224, /os/ossignposter, /xctest/xctestcase/measure, /xcode/analyzing-cpu-profiles-with-call-tree-views, /xcode/improving-your-app-s-performance
**Skills**: axiom-performance (skills/memory-debugging.md), axiom-performance (skills/trace-comparison.md), axiom-swiftui, axiom-concurrency, axiom-performance (skills/metrickit-ref.md), axiom-ai (skills/foundation-models-ref.md)
---
**Targets:** iOS 14+, Swift 5.5+
**Tools:** Instruments, Core Data
**History:** See git log for changes
skills/swift-performance-analyzer.md
<!-- GENERATED from agents/swift-performance-analyzer.md by scripts/build-inlined-auditors.ts — do not edit. -->
# Swift Performance Analyzer
**Claude Code** — launch the `swift-performance-analyzer` agent, or run `/axiom:audit swift-performance`. It runs this procedure in an isolated context with its own model tier.
**Every other harness** — follow this file inline. It is the same procedure, and it needs only file search and read.
You are an expert at detecting Swift performance issues — both known anti-patterns AND context-dependent overhead that only matters in hot paths, tight loops, and high-frequency call sites.
**Scope**: Swift-level performance (ARC, copies, generics, actors). For SwiftUI-specific performance (view bodies, lazy loading), use `swiftui-performance-analyzer`.
## Tool Use Is Mandatory
Run every Glob, Grep, and Read this prompt lists. Do not reason from training data instead of scanning.
- Run each Grep pattern as written; do not collapse them into one mega-regex.
- Run the Read verifications each section calls for.
- "Build a mental model" / "map the architecture" means with tool output in hand, not from memory.
## Files to Exclude
Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
Also skip SwiftUI view files (files with `struct.*: View`) — use `swiftui-performance-analyzer` for those.
## Phase 1: Map Allocation Hotspots
### Step 1: Identify Type Characteristics
```
Glob: **/*.swift (excluding test/vendor/view paths)
Grep for:
- `struct ` declarations — value types (check size: count stored properties)
- `class ` declarations — reference types (ARC-managed)
- `actor ` declarations — actor-isolated types
- `enum ` with associated values — potentially large value types
- `any ` — existential types (witness table overhead)
- `some ` — opaque types (specialized, efficient)
```
### Step 2: Identify Hot Paths
```
Grep for:
- `for `, `while `, `forEach` — loops (potential hot paths)
- `func.*(_ .*:` — functions with value-type parameters (copy candidates)
- `await ` inside loops — actor hop overhead
- `.append(`, `.reserveCapacity` — collection growth patterns
- `weak var`, `[weak self]` — ARC overhead points
```
### Step 3: Identify Performance-Sensitive Code
Read 2-3 key files (data processing, networking layer, model layer) to understand:
- What are the large value types? (structs with arrays, many properties)
- Where are the tight loops? (data processing, parsing, rendering)
- What's the actor boundary pattern? (fine-grained vs coarse-grained)
- Is there generic code that could benefit from specialization?
### Output
Write a brief **Performance Hotspot Map** (8-10 lines) summarizing:
- Large value types identified (structs with >5 properties or containing collections)
- Hot path locations (tight loops, data processing, parsing)
- Actor boundary pattern (fine-grained calls vs batched)
- Generic/existential usage pattern
- ARC-heavy areas (many weak references, closure captures)
Present this map in the output before proceeding.
## Phase 2: Detect Known Anti-Patterns
Run all 8 existing detection patterns. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification.
### 1. Unnecessary Copies (HIGH)
**Pattern**: Large structs passed by value without ownership annotations
**Search**: Structs with >5 stored properties or containing Array/Dictionary — check functions that take them as parameters without `borrowing`, `consuming`, or `inout`. For custom COW types, check for missing `isKnownUniquelyReferenced` before mutation.
**Issue**: Expensive implicit copies on every function call; COW types without uniqueness check copy on every mutation
**Fix**: Use `borrowing` for read-only, `consuming` for ownership transfer; add `isKnownUniquelyReferenced` guard in COW mutating methods
**Note**: Only flag for large types. Small structs (2-3 fields, no collections) are fine by value.
### 2. Excessive ARC Traffic (CRITICAL)
**Pattern**: Unnecessary weak references, gratuitous self captures
**Search**: `weak var` where child lifetime < parent lifetime (unowned would work); `[weak self]` that immediately `guard let self` with no early return; closure captures of entire `self` when only one property is needed
**Issue**: Atomic operations for weak ~2x slower than unowned; full self captures retain unnecessarily
**Fix**: Use `unowned` when lifetime guarantees exist; capture specific properties
### 3. Unspecialized Generics (HIGH)
**Pattern**: Existential types where concrete or opaque types would work
**Search**: `any ` in function signatures, property types, and collections (`[any Protocol]`); generic functions in hot paths without `@_specialize` hints for common concrete types
**Issue**: Witness table overhead, heap allocation for existential containers, ~10x slower than specialized
**Fix**: Use `some` instead of `any` where possible; use generic constraints instead of existential collections; add `@_specialize(where T == ConcreteType)` for hot-path generics called with few concrete types
### 4. Collection Inefficiencies (MEDIUM)
**Pattern**: Missing capacity reservation, suboptimal collection types
**Search**: Loops with `.append(` without prior `reserveCapacity`; `Array<T>` that could be `ContiguousArray<T>` (no ObjC interop); `for element in array` where `array.lazy.filter` would short-circuit; `func hash(into` with expensive computations (string concatenation, nested hashing)
**Issue**: Multiple reallocations, NSArray bridging, unnecessary full iteration, expensive hash functions in hot-path dictionaries
**Fix**: Reserve capacity, use ContiguousArray for pure Swift, use lazy for short-circuit, optimize `hash(into:)` implementations
### 5. Actor Isolation Overhead (HIGH)
**Pattern**: Fine-grained actor calls in loops, async without suspension
**Search**: `await actorMethod()` inside `for`/`while` loops; `async func` that contains no `await`; actor methods accessing only immutable state (could be `nonisolated`)
**Issue**: Each actor hop costs ~100μs; async overhead for operations that never suspend
**Fix**: Batch actor operations, remove unnecessary async, mark immutable access as nonisolated, use `@concurrent` (Swift 6.2+) for CPU work that should run off the actor
### 6. Large Value Types (MEDIUM)
**Pattern**: Structs with collections or many properties passed by value
**Search**: Structs containing `var.*: \[`, `var.*: Dictionary`, `var.*: Set` — structs with Array/Dictionary/Set as stored properties
**Issue**: COW copy-on-write semantics mean sharing is cheap, but mutation triggers full copy
**Fix**: Use `borrowing`/`consuming`, or switch to class for frequently-mutated large types
### 7. Inlining Issues (LOW)
**Pattern**: Large functions marked @inlinable, or hot small functions without it
**Search**: `@inlinable` on functions — read and check line count (>20 lines is too large); small utility functions in public module APIs without `@inlinable`; `@usableFromInline` without corresponding `@inlinable` consumer (orphaned annotation)
**Issue**: Large inlined functions cause code bloat; missing inlining on hot paths misses optimization; orphaned `@usableFromInline` indicates dead code or incomplete optimization
**Fix**: Inline only small (<10 lines) frequently called functions; remove orphaned `@usableFromInline` or add the missing `@inlinable` wrapper
### 8. Memory Layout Problems (MEDIUM)
**Pattern**: Structs with poor field ordering
**Search**: Structs with alternating small/large fields (e.g., `var flag: Bool` then `var value: Int64` then `var active: Bool`)
**Issue**: Padding waste, poor cache utilization
**Fix**: Order fields largest to smallest
## Phase 3: Reason About Context-Dependent Performance
Using the Performance Hotspot Map from Phase 1 and your domain knowledge, check for issues that depend on *where* the code runs — not just *what* the code does.
| Question | What it detects | Why it matters |
|----------|----------------|----------------|
| Are any of the Phase 2 patterns inside tight loops or data processing pipelines? | Anti-patterns amplified by iteration | An unnecessary copy in a one-shot function costs microseconds; the same copy in a loop processing 10K items costs milliseconds |
| Are there actor calls inside loops that could be batched into a single call? | Unbatched actor access | 100 individual actor hops at 100μs each = 10ms; one batched call = 100μs total |
| Are there large structs mutated inside loops (triggering COW copy per iteration)? | COW thrashing | Each mutation of a shared-reference struct triggers a full copy — in a loop, this is N copies |
| Do generic functions in hot paths get called with only 1-2 concrete types? | Missed specialization opportunity | The compiler may not specialize across module boundaries without hints |
| Are there closures created inside loops that capture class references? | Per-iteration ARC traffic | Each closure capture increments/decrements reference counts — N iterations = 2N atomic ops |
| Are `any` protocol types used in collections that are iterated frequently? | Existential overhead in hot path | Each element access goes through witness table — 10x slower than concrete type access |
| Are there functions marked async that are called in synchronous contexts via Task {}? | Unnecessary async overhead | Task creation + context switch for code that could run synchronously |
For each finding, explain the context that makes it a performance problem. Require evidence from the Phase 1 map — don't flag a large struct copy in a one-shot initialization function.
## Phase 4: Cross-Reference Findings
Bump severity for these combinations:
| Finding A | + Finding B | = Compound | Severity |
|-----------|------------|-----------|----------|
| Large struct copy | Inside tight loop | N copies per iteration | CRITICAL |
| Actor hop in loop | No batching alternative | 100μs × N per loop iteration | CRITICAL |
| `any` protocol collection | Iterated in hot path | Witness table lookup per element per iteration | CRITICAL |
| Weak self capture | In closure created per-loop-iteration | 2N atomic ops per loop | HIGH |
| Missing reserveCapacity | Loop appends >100 items | ~14 reallocations for 10K items | HIGH |
| Async function | Never awaits internally | Unnecessary Task overhead on every call | HIGH |
| Large struct mutation | Shared reference (COW) | Full copy on each mutation | HIGH |
| Unspecialized generic | Called from only 1-2 concrete types | Missed optimization in performance-critical code | MEDIUM |
Also note overlaps with other auditors:
- Actor hop overhead → compound with concurrency-auditor (isolation correctness)
- Closure captures → compound with memory-auditor (retain cycles)
- Collection operations in view body → compound with swiftui-performance-analyzer
- Weak/unowned in delegate pattern → compound with memory-auditor
## Phase 5: Swift Performance Health Score
```markdown
## Performance Health Score
| Metric | Value |
|--------|-------|
| Value type efficiency | N large structs, M with ownership annotations (Z%) |
| ARC discipline | N weak references, M appropriate (Z% correct weak/unowned) |
| Generic specialization | N `any` usages, M that could be `some` or concrete (Z% specialized) |
| Collection efficiency | N append loops, M with reserveCapacity (Z%) |
| Actor efficiency | N actor calls in loops, M batched (Z%) |
| Hot path cleanliness | N hot paths identified, M free of amplified anti-patterns (Z%) |
| **Health** | **OPTIMIZED / OVERHEAD / BOTTLENECKED** |
```
Scoring:
- **OPTIMIZED**: No CRITICAL issues, hot paths free of amplified anti-patterns, >80% appropriate ownership/ARC, no `any` in hot paths
- **OVERHEAD**: No CRITICAL issues in hot paths, but some unnecessary copies, missing reserveCapacity, or gratuitous ARC traffic
- **BOTTLENECKED**: Any CRITICAL issues in hot paths, or actor hops in tight loops, or large struct copies in iteration
## Output Format
```markdown
# Swift Performance Audit Results
## Performance Hotspot Map
[8-10 line summary from Phase 1]
## Summary
- CRITICAL: [N] issues
- HIGH: [N] issues
- MEDIUM: [N] issues
- LOW: [N] issues
- Phase 2 (anti-pattern detection): [N] issues
- Phase 3 (context reasoning): [N] issues
- Phase 4 (compound findings): [N] issues
## Performance Health Score
[Phase 5 table]
## Issues by Severity
### [SEVERITY] [Category]: [Description]
**File**: path/to/file.swift:line
**Phase**: [2: Detection | 3: Context | 4: Compound]
**Context**: [hot path / one-shot / loop body — from Phase 1 map]
**Issue**: What's wrong or suboptimal
**Impact**: Estimated cost (e.g., "~100μs × N iterations")
**Fix**: Code example showing the fix
**Cross-Auditor Notes**: [if overlapping with another auditor]
## Quick Wins
1. [Highest impact, easiest fix]
2. [Second highest impact]
3. [Third highest impact]
## Recommendations
1. [Immediate actions — CRITICAL fixes in hot paths]
2. [Short-term — HIGH fixes (ARC, generics, collections)]
3. [Long-term — architectural improvements from Phase 3 findings]
4. [Verification — profile with Instruments Time Profiler after fixes]
```
## Output Limits
If >50 issues in one category: Show top 10, provide total count, list top 3 files
If >100 total issues: Summarize by category, show only CRITICAL/HIGH details
## False Positives (Not Issues)
- Small structs (2-3 fields, no collections) passed by value — copy is cheaper than indirection
- `weak var delegate` that is genuinely optional (delegate may be deallocated first)
- `any Protocol` in cold paths (configuration, setup, one-shot initialization)
- Arrays that grow to <100 items without reserveCapacity
- `async func` that wraps a single `await` call (legitimate async wrapper)
- ContiguousArray not used when ObjC bridging is needed
- @inlinable absent on internal (non-public) functions
- Large structs that are created once and never copied (stored in @State, let binding)
## Related
For Instruments workflows: `axiom-performance (skills/swift-performance.md)` skill
For SwiftUI-specific performance: `swiftui-performance-analyzer` agent
For memory lifecycle issues: `axiom-performance (skills/memory-debugging.md)` skill
For actor isolation patterns: `axiom-concurrency` skill
For behavior-preserving clarity simplification: `swift-simplifier` agent (defer to it for clarity-only changes; this agent owns speed)
skills/swift-performance.md
# Swift Performance Optimization
## Purpose
**Core Principle**: Optimize Swift code by understanding language-level performance characteristics—value semantics, ARC behavior, generic specialization, and memory layout—to write fast, efficient code without premature micro-optimization.
**Swift Version**: Swift 6.2+ (for InlineArray, Span, `@concurrent`)
**Xcode**: 16+
**Platforms**: iOS 18+, macOS 15+
**Related Skills**:
- `axiom-performance (skills/performance-profiling.md)` — Use Instruments to measure (do this first!)
- `axiom-swiftui` (performance reference) — SwiftUI-specific optimizations
- `axiom-build (skills/build-performance.md)` — Compilation speed
- `axiom-concurrency` — Correctness-focused concurrency patterns
## When to Use This Skill
### ✅ Use this skill when
- App profiling shows Swift code as the bottleneck (Time Profiler hotspots)
- Excessive memory allocations or retain/release traffic
- Implementing performance-critical algorithms or data structures
- Writing framework or library code with performance requirements
- Optimizing tight loops or frequently called methods
- Dealing with large data structures or collections
- Code review identifying performance anti-patterns
## Quick Decision Tree
```
Performance issue identified?
│
├─ Profiler shows excessive copying?
│ └─ → Part 1: Noncopyable Types
│ └─ → Part 2: Copy-on-Write
│
├─ Retain/release overhead in Time Profiler?
│ └─ → Part 4: ARC Optimization
│
├─ Generic code in hot path?
│ └─ → Part 5: Generics & Specialization
│
├─ Collection operations slow?
│ └─ → Part 7: Collection Performance
│
├─ Async/await overhead visible?
│ └─ → Part 8: Concurrency Performance
│
├─ Struct vs class decision?
│ └─ → Part 3: Value vs Reference
│
└─ Memory layout concerns?
└─ → Part 9: Memory Layout
```
---
## The Four Principles of Swift Performance
From WWDC 2024-10217: Swift's low-level performance characteristics come down to four areas. Each maps to a Part in this skill.
| Principle | What It Costs | Skill Coverage |
|-----------|--------------|----------------|
| **Function Calls** | Dispatch overhead, optimization barriers | Part 5 (Generics), Part 6 (Inlining) |
| **Memory Allocation** | Stack vs heap, allocation frequency | Part 3 (Value vs Reference), Part 7 (Collections) |
| **Memory Layout** | Cache locality, padding, contiguity | Part 9 (Memory Layout), Part 11 (Span) |
| **Value Copying** | COW triggers, defensive copies, ARC traffic | Part 1 (Noncopyable), Part 2 (COW), Part 4 (ARC) |
Understanding which principle is causing your bottleneck determines which Part to use.
---
## Part 1: Noncopyable Types (~Copyable)
**Swift 6.0+** introduces noncopyable types for performance-critical scenarios where you want to avoid implicit copies.
### When to Use
- Large types that should never be copied (file handles, GPU buffers)
- Types with ownership semantics (must be explicitly consumed)
- Performance-critical code where copies are expensive
### Basic Pattern
```swift
// Noncopyable type
struct FileHandle: ~Copyable {
private let fd: Int32
init(path: String) throws {
self.fd = open(path, O_RDONLY)
guard fd != -1 else { throw FileError.openFailed }
}
deinit {
close(fd)
}
// Must explicitly consume
consuming func close() {
_ = consume self
}
}
// Usage
func processFile() throws {
let handle = try FileHandle(path: "/data.txt")
// handle is automatically consumed at end of scope
// Cannot accidentally copy handle
}
```
### Ownership Annotations
```swift
// consuming - takes ownership, caller cannot use after
func process(consuming data: [UInt8]) {
// data is consumed
}
// borrowing - temporary access without ownership
func validate(borrowing data: [UInt8]) -> Bool {
// data can still be used by caller
return data.count > 0
}
// inout - mutable access
func modify(inout data: [UInt8]) {
data.append(0)
}
```
### Performance Impact
- **Eliminates implicit copies**: Compiler error instead of runtime copy
- **Zero-cost abstraction**: Same performance as manual memory management
- **Use when**: Type is expensive to copy (>64 bytes) and copies are rare
---
## Part 2: Copy-on-Write (COW)
Swift collections use COW for efficient memory sharing. Understanding when copies happen is critical for performance.
### How COW Works
```swift
var array1 = [1, 2, 3] // Single allocation
var array2 = array1 // Share storage (no copy)
array2.append(4) // Now copies (array1 modified array2)
```
For custom COW implementation, see Copy-Paste Pattern 1 (COW Wrapper) below.
### Performance Tips
```swift
// ❌ Accidental copy in loop
for i in 0..<array.count {
array[i] = transform(array[i]) // Copy on first mutation if shared!
}
// ✅ Reserve capacity first (ensures unique)
array.reserveCapacity(array.count)
for i in 0..<array.count {
array[i] = transform(array[i])
}
// ❌ Multiple mutations trigger multiple uniqueness checks
array.append(1)
array.append(2)
array.append(3)
// ✅ Single reservation
array.reserveCapacity(array.count + 3)
array.append(contentsOf: [1, 2, 3])
```
### Defensive Copies
From WWDC 2024-10217: Swift sometimes inserts *defensive copies* when it cannot prove a value won't be mutated through a shared reference.
```swift
class DataStore {
var items: [Item] = [] // COW type stored in class
}
func process(_ store: DataStore) {
for item in store.items {
// Swift may defensively copy `items` because:
// 1. store.items is a class property (another reference could mutate it)
// 2. The loop needs a stable snapshot
handle(item)
}
}
```
**How to avoid**: Copy to a local variable first — one explicit copy instead of repeated defensive copies:
```swift
func process(_ store: DataStore) {
let items = store.items // One copy
for item in items {
handle(item) // No more defensive copies
}
}
```
**In profiler**: Defensive copies appear as unexpected `swift_retain`/`swift_release` pairs or `Array.__allocating_init` calls when you didn't expect allocation.
---
## Part 3: Value vs Reference Semantics
Choosing between `struct` and `class` has significant performance implications.
### Decision Matrix
| Factor | Use Struct | Use Class |
|--------|-----------|-----------|
| **Size** | ≤ 64 bytes | > 64 bytes or contains large data |
| **Identity** | No identity needed | Needs identity (===) |
| **Inheritance** | Not needed | Inheritance required |
| **Mutation** | Infrequent | Frequent in-place updates |
| **Sharing** | No sharing needed | Must be shared across scope |
### Small Structs (Fast)
```swift
// ✅ Fast - fits in registers, no heap allocation
struct Point {
var x: Double // 8 bytes
var y: Double // 8 bytes
} // Total: 16 bytes - excellent for struct
struct Color {
var r, g, b, a: UInt8 // 4 bytes total - perfect for struct
}
```
### Large Structs (Slow)
```swift
// ❌ Slow - excessive copying
struct HugeData {
var buffer: [UInt8] // 1MB
var metadata: String
}
func process(_ data: HugeData) { // Copies 1MB!
// ...
}
// ✅ Use reference semantics for large data
final class HugeData {
var buffer: [UInt8]
var metadata: String
}
func process(_ data: HugeData) { // Only copies pointer (8 bytes)
// ...
}
```
### Indirect Storage for Flexibility
For large data that needs value semantics externally with reference storage internally, use the COW Wrapper pattern — see Copy-Paste Pattern 1 below.
---
## Part 4: ARC Optimization
Automatic Reference Counting adds overhead. Minimize it where possible.
### Weak vs Unowned Performance
```swift
class Parent {
var child: Child?
}
class Child {
// ❌ Weak adds overhead (optional, thread-safe zeroing)
weak var parent: Parent?
}
// ✅ Unowned when you know lifetime guarantees
class Child {
unowned let parent: Parent // No overhead, crashes if parent deallocated
}
```
**Performance**: `unowned` is ~2x faster than `weak` (no atomic operations).
**Use when**: Child lifetime < Parent lifetime (guaranteed).
### Closure Capture Optimization
```swift
class DataProcessor {
var data: [Int]
// ❌ Captures self strongly, then uses weak - unnecessary weak overhead
func process(completion: @escaping () -> Void) {
DispatchQueue.global().async { [weak self] in
guard let self else { return }
self.data.forEach { print($0) }
completion()
}
}
// ✅ Capture only what you need
func process(completion: @escaping () -> Void) {
let data = self.data // Copy value type
DispatchQueue.global().async {
data.forEach { print($0) } // No self captured
completion()
}
}
}
```
### Closure Capture Costs
From WWDC 2024-10217: Closures have different performance profiles depending on whether they escape.
```swift
// Non-escaping closure — stack-allocated context, zero ARC overhead
func processItems(_ items: [Item], using transform: (Item) -> Result) -> [Result] {
items.map(transform) // Closure context lives on stack
}
// Escaping closure — heap-allocated context, ARC on every captured reference
func processItemsLater(_ items: [Item], transform: @escaping (Item) -> Result) {
// Closure context heap-allocated as anonymous class instance
// Each captured reference gets retain/release
self.pending = { items.map(transform) }
}
```
**Why this matters**: `@Sendable` closures are always escaping, meaning every Task closure heap-allocates its capture context.
**In hot paths**: Prefer non-escaping closures. If you see `swift_allocObject` in Time Profiler for closure contexts, look for escaping closures that could be non-escaping.
### Observable Object Lifetimes
**From WWDC 2021-10216**: Object lifetimes end at **last use**, not at closing brace.
```swift
// ❌ Relying on observed lifetime is fragile
class Traveler {
weak var account: Account?
deinit {
print("Deinitialized") // May run BEFORE expected with ARC optimizations!
}
}
func test() {
let traveler = Traveler()
let account = Account(traveler: traveler)
// traveler's last use is above - may deallocate here!
account.printSummary() // weak reference may be nil!
}
// ✅ Explicitly extend lifetime when needed
func test() {
let traveler = Traveler()
let account = Account(traveler: traveler)
withExtendedLifetime(traveler) {
account.printSummary() // traveler guaranteed to live
}
}
```
Object lifetimes can change between Xcode versions, Debug vs Release, and unrelated code changes. Enable "Optimize Object Lifetimes" (Xcode 13+) during development to expose hidden lifetime bugs early.
---
## Part 5: Generics & Specialization
Generic code can be fast or slow depending on specialization.
### Specialization Basics
```swift
// Generic function
func process<T>(_ value: T) {
print(value)
}
// Calling with concrete type
process(42) // Compiler specializes: process_Int(42)
process("hello") // Compiler specializes: process_String("hello")
```
### Existential Overhead
```swift
protocol Drawable {
func draw()
}
// ❌ Existential container - expensive (heap allocation, indirection)
func drawAll(shapes: [any Drawable]) {
for shape in shapes {
shape.draw() // Dynamic dispatch through witness table
}
}
// ✅ Generic with constraint - can specialize
func drawAll<T: Drawable>(shapes: [T]) {
for shape in shapes {
shape.draw() // Static dispatch after specialization
}
}
```
**Performance**: Generic version ~10x faster (eliminates witness table overhead).
### Existential Container Overhead
**From WWDC 2016-416**: `any Protocol` uses a 40-byte existential container (5 words on 64-bit). The container stores type metadata + protocol witness table (16 bytes) plus a 24-byte inline value buffer. Types ≤24 bytes are stored directly in the buffer (fast, ~5ns access); larger types require a heap allocation with pointer indirection (slower, ~15ns). `some Protocol` eliminates all container overhead (~2ns).
**When `some` isn't available** (heterogeneous collections require `any`):
- **Reduce type sizes to ≤24 bytes** — keep protocol-conforming types small enough for inline storage (3 words: e.g., `Point { x, y, z: Double }` fits exactly)
- **Use enum dispatch instead** — eliminates containers entirely, trades open extensibility for performance:
```swift
// ❌ Existential: 40 bytes/element, witness table dispatch
let shapes: [any Drawable] = [circle, rect]
// ✅ Enum: value-sized, static dispatch via switch
enum Shape { case circle(Circle), rect(Rect) }
func draw(_ shape: Shape) {
switch shape {
case .circle(let c): c.draw()
case .rect(let r): r.draw()
}
}
```
- **Batch operations** — amortize per-element existential overhead by processing in chunks rather than one-at-a-time
- **Measure first** — existential overhead (~10ns/access) only matters in tight loops; for UI-level code it's negligible
### `@_specialize` Attribute
Force specialization for common types when the compiler doesn't do it automatically:
```swift
@_specialize(where T == Int)
@_specialize(where T == String)
func process<T: Comparable>(_ value: T) -> T { value }
// Generates specialized versions + generic fallback
```
---
## Part 6: Inlining
Inlining eliminates function call overhead but increases code size.
### When to Inline
```swift
// ✅ Small, frequently called functions
@inlinable
public func fastAdd(_ a: Int, _ b: Int) -> Int {
return a + b
}
// ❌ Large functions - code bloat
@inlinable // Don't do this!
public func complexAlgorithm() {
// 100 lines of code...
}
```
### Cross-Module Optimization
```swift
// Framework code
public struct Point {
public var x: Double
public var y: Double
// ✅ Inlinable for cross-module optimization
@inlinable
public func distance(to other: Point) -> Double {
let dx = x - other.x
let dy = y - other.y
return sqrt(dx*dx + dy*dy)
}
}
// Client code
let p1 = Point(x: 0, y: 0)
let p2 = Point(x: 3, y: 4)
let d = p1.distance(to: p2) // Inlined across module boundary
```
### `@usableFromInline`
```swift
// Internal helper that can be inlined
@usableFromInline
internal func helperFunction() { }
// Public API that uses it
@inlinable
public func publicAPI() {
helperFunction() // Can inline internal function
}
```
**Trade-off**: `@inlinable` exposes implementation, prevents future optimization.
---
## Part 7: Collection Performance
Choosing the right collection and using it correctly matters.
### Array vs ContiguousArray
```swift
// ❌ Array<T> - may use NSArray bridging (Swift/ObjC interop)
let array: Array<Int> = [1, 2, 3]
// ✅ ContiguousArray<T> - guaranteed contiguous memory (no bridging)
let array: ContiguousArray<Int> = [1, 2, 3]
```
**Use `ContiguousArray` when**: No ObjC bridging needed (pure Swift), ~15% faster.
### Reserve Capacity
```swift
// ❌ Multiple reallocations
var array: [Int] = []
for i in 0..<10000 {
array.append(i) // Reallocates ~14 times
}
// ✅ Single allocation
var array: [Int] = []
array.reserveCapacity(10000)
for i in 0..<10000 {
array.append(i) // No reallocations
}
```
### Dictionary Hashing
```swift
struct BadKey: Hashable {
var data: [Int]
// ❌ Expensive hash (iterates entire array)
func hash(into hasher: inout Hasher) {
for element in data {
hasher.combine(element)
}
}
}
struct GoodKey: Hashable {
var id: UUID // Fast hash
var data: [Int] // Not hashed
// ✅ Hash only the unique identifier
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
```
### InlineArray (Swift 6.2)
Fixed-size arrays stored directly on the stack—no heap allocation, no COW overhead. Uses value generics to encode size in the type.
```swift
// Traditional Array - heap allocated, COW overhead
var sprites: [Sprite] = Array(repeating: .default, count: 40)
// InlineArray - stack allocated, no COW (value generic syntax)
var sprites = InlineArray<40, Sprite>(repeating: .default)
```
**Conformances**: `BitwiseCopyable`, `Sendable` (both conditional). Supports `~Copyable` element types. InlineArray is **not** a `Collection`/`Sequence` — those conformances require `Copyable` and are deliberately absent so `~Copyable` elements stay supported. Use index-based access (`count`, `indices`, subscript) or `.span`/`.mutableSpan` instead; `map`/`filter`/`for-in` are not available directly.
**When to Use InlineArray**:
- Fixed size known at compile time
- Performance-critical paths (tight loops, hot paths)
- Want to avoid heap allocation entirely
- Small to medium sizes (practical limit ~1KB stack usage)
InlineArray is stack-allocated (no heap), eagerly copied (not COW), and provides `.span`/`.mutableSpan` for zero-copy access. Measure your own benchmarks for allocation/copy/mutation trade-offs vs Array.
**Copy Semantics Warning**:
```swift
// ❌ Unexpected: InlineArray copies eagerly
func processLarge(_ data: InlineArray<1000, UInt8>) {
// Copies all 1000 bytes on call!
}
// ✅ Use Span to avoid copy
func processLarge(_ data: Span<UInt8>) {
// Zero-copy view, no matter the size
}
// Best practice: Store InlineArray, pass Span
struct Buffer {
var storage = InlineArray<1000, UInt8>(repeating: 0)
func process() {
helper(storage.span) // Pass view, not copy
}
}
```
**When NOT to Use InlineArray**:
- Dynamic sizes (use Array)
- Large data (>1KB stack usage risky)
- Frequently passed by value (use Span instead)
- Need COW semantics (use Array)
### Lazy Sequences
```swift
// ❌ Eager evaluation - processes entire array
let result = array
.map { expensive($0) }
.filter { $0 > 0 }
.first // Only need first element!
// ✅ Lazy evaluation - stops at first match
let result = array
.lazy
.map { expensive($0) }
.filter { $0 > 0 }
.first // Only evaluates until first match
```
---
## Part 8: Concurrency Performance
Async/await and actors add overhead. Use appropriately.
### Actor Isolation Overhead
```swift
actor Counter {
private var value = 0
// ❌ Actor call overhead for simple operation
func increment() {
value += 1
}
}
// Calling from different isolation domain
for _ in 0..<10000 {
await counter.increment() // 10,000 actor hops!
}
// ✅ Batch operations to reduce actor overhead
actor Counter {
private var value = 0
func incrementBatch(_ count: Int) {
value += count
}
}
await counter.incrementBatch(10000) // Single actor hop
```
### Async Overhead
Each async suspension costs ~20-30μs. Keep synchronous operations synchronous—don't mark a function `async` if it doesn't need to await.
### Task Creation Cost
```swift
// ❌ Creating task per item (~100μs overhead each)
for item in items {
Task {
await process(item)
}
}
// ✅ Single task for batch
Task {
for item in items {
await process(item)
}
}
// ✅ Or use TaskGroup for parallelism
await withTaskGroup(of: Void.self) { group in
for item in items {
group.addTask {
await process(item)
}
}
}
```
### `@concurrent` Attribute (Swift 6.2)
```swift
// Force background execution
@concurrent
func expensiveComputation() -> Int {
// Always runs on background thread, even if called from MainActor
return complexCalculation()
}
// Safe to call from main actor without blocking
@MainActor
func updateUI() async {
let result = await expensiveComputation() // Guaranteed off main thread
label.text = "\(result)"
}
```
For `nonisolated` performance patterns and detailed actor isolation guidance, see `axiom-concurrency` (swift-concurrency reference).
---
## Part 9: Memory Layout
Understanding memory layout helps optimize cache performance and reduce allocations.
### Struct Padding
```swift
// ❌ Poor layout (24 bytes due to padding)
struct BadLayout {
var a: Bool // 1 byte + 7 padding
var b: Int64 // 8 bytes
var c: Bool // 1 byte + 7 padding
}
print(MemoryLayout<BadLayout>.size) // 24 bytes
// ✅ Optimized layout (16 bytes)
struct GoodLayout {
var b: Int64 // 8 bytes
var a: Bool // 1 byte
var c: Bool // 1 byte + 6 padding
}
print(MemoryLayout<GoodLayout>.size) // 16 bytes
```
### Alignment
```swift
// Query alignment
print(MemoryLayout<Double>.alignment) // 8
print(MemoryLayout<Int32>.alignment) // 4
// Structs align to largest member
struct Mixed {
var int32: Int32 // 4 bytes, 4-byte aligned
var double: Double // 8 bytes, 8-byte aligned
}
print(MemoryLayout<Mixed>.alignment) // 8 (largest member)
```
### Cache-Friendly Data Structures
```swift
// ❌ Poor cache locality
struct PointerBased {
var next: UnsafeMutablePointer<Node>? // Pointer chasing
}
// ✅ Array-based for cache locality
struct ArrayBased {
var data: ContiguousArray<Int> // Contiguous memory
}
// Array iteration ~10x faster due to cache prefetching
```
### Exclusivity Checks
From WWDC 2025-312: Runtime exclusivity enforcement (`swift_beginAccess`/`swift_endAccess`) appears in Time Profiler when the compiler cannot prove memory safety statically.
**What they are**: Swift enforces that no two accesses to the same variable overlap if one is a write. For struct properties, this is checked at compile time. For class stored properties, runtime checks are inserted.
**How to identify**: Look for `swift_beginAccess` and `swift_endAccess` in Time Profiler or Processor Trace flame graphs.
```swift
// ❌ Class properties require runtime exclusivity checks
class Parser {
var state: ParserState
var cache: [Int: Pixel]
func parse() {
state.advance() // swift_beginAccess / swift_endAccess
cache[key] = pixel // swift_beginAccess / swift_endAccess
}
}
// ✅ Struct properties checked at compile time — zero runtime cost
struct Parser {
var state: ParserState
var cache: InlineArray<64, Pixel>
mutating func parse() {
state.advance() // No runtime check
cache[key] = pixel // No runtime check
}
}
```
**Real-world impact**: In WWDC 2025-312's QOI image parser, moving properties from a class to a struct eliminated all runtime exclusivity checks, contributing to a measurable speedup as part of a >700x total improvement.
---
## Part 10: Typed Throws (Swift 6)
Typed throws can be faster than untyped by avoiding existential overhead.
### Untyped vs Typed
```swift
// Untyped - existential container for error
func fetchData() throws -> Data {
// Can throw any Error
throw NetworkError.timeout
}
// Typed - concrete error type
func fetchData() throws(NetworkError) -> Data {
// Can only throw NetworkError
throw NetworkError.timeout
}
```
### Performance Impact
```swift
// Measure with tight loop
func untypedThrows() throws -> Int {
throw GenericError.failed
}
func typedThrows() throws(GenericError) -> Int {
throw GenericError.failed
}
// Benchmark: typed ~5-10% faster (no existential overhead)
```
### When to Use
- **Typed**: Library code with well-defined error types, hot paths
- **Untyped**: Application code, error types unknown at compile time
---
## Part 11: Span Types
**Swift 6.2+** introduces Span—a non-escapable, non-owning view into memory that provides safe, efficient access to contiguous data.
### What is Span?
Span is a modern replacement for `UnsafeBufferPointer` that provides:
- **Spatial safety**: Bounds-checked operations prevent out-of-bounds access
- **Temporal safety**: Lifetime inherited from source, preventing use-after-free
- **Zero overhead**: No heap allocation, no reference counting
- **Non-escapable**: Cannot outlive the data it references
```swift
// Traditional unsafe approach
func processUnsafe(_ data: UnsafeMutableBufferPointer<UInt8>) {
data[100] = 0 // Crashes if out of bounds!
}
// Safe Span approach
func processSafe(_ data: MutableSpan<UInt8>) {
data[100] = 0 // Traps with clear error if out of bounds
}
```
### When to Use Span vs Array vs UnsafeBufferPointer
| Use Case | Recommendation |
|----------|---------------|
| **Own the data** | Array (full ownership, COW) |
| **Temporary view for reading** | Span (safe, fast) |
| **Temporary view for writing** | MutableSpan (safe, fast) |
| **C interop, performance-critical** | RawSpan (untyped bytes) |
| **Unsafe performance** | UnsafeBufferPointer (legacy, avoid) |
### Basic Span Usage
```swift
let array = [1, 2, 3, 4, 5]
let span = array.span // Read-only view
print(span[0]) // Subscript access
for i in span.indices { // Iterate by index — Span is not a Sequence/Collection
let element = span[i] // (Span.Index == Int, so `for i in 0..<span.count` also works)
}
let slice = span[1..<3] // Span slice, no copy
```
### MutableSpan for Modifications
```swift
var array = [10, 20, 30, 40, 50]
let mutableSpan = array.mutableSpan
mutableSpan[0] = 100 // Modifies array in-place, bounds-checked
```
### RawSpan for Untyped Bytes
```swift
func parsePacket(_ data: RawSpan) -> PacketHeader? {
guard data.count >= MemoryLayout<PacketHeader>.size else { return nil }
// Safe byte-level access via subscript
return PacketHeader(version: data[0], flags: data[1],
length: UInt16(data[3]) << 8 | UInt16(data[2]))
}
let header = parsePacket(bytes.rawSpan) // .rawSpan on any [UInt8]
```
All Swift 6.2 collections provide `.span` and `.mutableSpan` properties, including `Array`, `ContiguousArray`, and `UnsafeBufferPointer` (migration path). Span access speed matches `UnsafeBufferPointer` (~2ns) with bounds checking.
### Non-Escapable Lifetime Safety
Span's lifetime is bound to its source. The compiler prevents returning a Span from a function where the source would be deallocated — unlike `UnsafeBufferPointer`, which allows this bug silently.
```swift
func dangerousSpan() -> Span<Int> {
let array = [1, 2, 3]
return array.span // ❌ Error: Cannot return non-escapable value
}
```
InlineArray also provides `.span`/`.mutableSpan` — see Part 7 for InlineArray usage and copy-avoidance via Span.
### Migration from UnsafeBufferPointer
```swift
// ❌ Old: unsafe, no bounds checking
func parseLegacy(_ buffer: UnsafeBufferPointer<UInt8>) -> Header {
Header(magic: buffer[0], version: buffer[1]) // Silent OOB crash
}
// ✅ New: safe, bounds-checked, same performance
func parseModern(_ span: Span<UInt8>) -> Header {
Header(magic: span[0], version: span[1]) // Traps on OOB
}
// Bridge: existing UnsafeBufferPointer → Span
let span = buffer.span // Wrap unsafe in safe span
parseModern(span)
```
### OutputSpan — Safe Initialization
OutputSpan/OutputRawSpan replace `UnsafeMutableBufferPointer` for initializing new collections without intermediate allocations.
```swift
// Binary serialization: write header bytes safely
@lifetime(&output)
func writeHeader(to output: inout OutputRawSpan) {
output.append(0x01) // version (UInt8 overload, safe)
output.append(0x00) // flags (UInt8 overload, safe)
// Multi-byte values use the generic `append(_:as:)`, which is @unsafe —
// under strict memory safety the call site needs the `unsafe` expression form:
unsafe output.append(UInt16(42), as: UInt16.self) // length
}
```
Use for building byte arrays, binary serialization, image pixel data. Apple's Swift Binary Parsing library (apple/swift-binary-parsing) is built entirely on Span types.
### When NOT to Use Span
- **Ownership**: Span can't be stored in structs/classes — use Array for owned data, provide `.span` access via computed property
- **Return values**: Span is non-escapable — process in scope, return owned data
- **Long-lived references**: Span lifetime is bound to source — use Array if data must outlive the current scope
---
## Copy-Paste Patterns
### Pattern 1: COW Wrapper
```swift
final class Storage<T> {
var value: T
init(_ value: T) { self.value = value }
}
struct COWWrapper<T> {
private var storage: Storage<T>
init(_ value: T) {
storage = Storage(value)
}
var value: T {
get { storage.value }
set {
if !isKnownUniquelyReferenced(&storage) {
storage = Storage(newValue)
} else {
storage.value = newValue
}
}
}
}
```
### Pattern 2: Performance-Critical Loop
```swift
func processLargeArray(_ input: [Int]) -> [Int] {
var result = ContiguousArray<Int>()
result.reserveCapacity(input.count)
for element in input {
result.append(transform(element))
}
return Array(result)
}
```
### Pattern 3: Inline Cache Lookup
```swift
private var cache: [Key: Value] = [:]
@inlinable
func getCached(_ key: Key) -> Value? {
return cache[key] // Inlined across modules
}
```
---
## Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| **Premature optimization** | Complex COW/ContiguousArray with no profiling data | Start simple, profile, optimize what matters |
| **Weak everywhere** | `weak` on every delegate (atomic overhead) | Use `unowned` when lifetime is guaranteed (see Part 4) |
| **Actor for everything** | Actor isolation on simple counters (~100μs/call) | Use lock-free atomics (`ManagedAtomic`) for simple sync data |
---
## Compiler Performance Hints
The compiler can flag several of the anti-patterns above for you. Build with `-Wwarning PerformanceHints` (off by default) to surface them:
| Hint | Flags | See |
|---|---|---|
| `ExistentialType` | `any P` returns/params forcing heap allocation + dynamic dispatch | Part 5 |
| `ReturnTypeImplicitCopy` | Returning an array/large value that triggers implicit copies | Part 7 |
| `UntypedThrows` | Untyped `throws` (heap-allocates the error box per `throw`) | Part 10 |
```bash
swiftc -Wwarning PerformanceHints … # surface as warnings
swiftc -Werror PerformanceHints … # fail the build on them
```
Scope it to hot modules — these patterns are negligible in UI-level code (see Part 5's measure-first note). The flag and group predate Swift 6.4 — `ExistentialType` and `ReturnTypeImplicitCopy` already emit on Xcode 26 under flat names like `[#ExistentialType]`; `UntypedThrows` is new in the 6.4 toolchain (Xcode 27), which also namespaces the group as `PerformanceHints::<hint>`. Treat it as a tooling tip, not a 27-only feature.
---
## Code Review Checklist
### Memory Management
- [ ] Large structs (>64 bytes) use indirect storage or are classes
- [ ] COW types use `isKnownUniquelyReferenced` before mutation
- [ ] Collections use `reserveCapacity` when size is known
- [ ] Weak references only where needed (prefer unowned when safe)
### Generics
- [ ] Protocol types use `some` instead of `any` where possible
- [ ] Hot paths use concrete types or `@_specialize`
- [ ] Generic constraints are as specific as possible
### Collections
- [ ] Pure Swift code uses `ContiguousArray` over `Array`
- [ ] Dictionary keys have efficient `hash(into:)` implementations
- [ ] Lazy evaluation used for short-circuit operations
- [ ] Hot-path `==` on large CoW collections evaluated for the `isTriviallyIdentical(to:)` O(1) fast path (Swift 6.4, SE-0494; `false` ≠ not-equal, fresh-built inputs pessimize — see `axiom-swift (skills/swift-modern.md)`)
### Concurrency
- [ ] Synchronous operations don't use `async`
- [ ] Actor calls are batched when possible
- [ ] Task creation is minimized (use TaskGroup)
- [ ] CPU-intensive work uses `@concurrent` (Swift 6.2)
### Optimization
- [ ] Profiling data exists before optimization
- [ ] Inlining only for small, frequently called functions
- [ ] Memory layout optimized for cache locality (large structs)
---
## Pressure Scenarios
### Scenario 1: "Just make it faster, we ship tomorrow"
**The Pressure**: Manager sees "slow" in profiler, demands immediate action.
**Red Flags**:
- No baseline measurements
- No Time Profiler data showing hotspots
- "Make everything faster" without targets
**Time Cost Comparison**:
- Premature optimization: 2 days of work, no measurable improvement
- Profile-guided optimization: 2 hours profiling + 4 hours fixing actual bottleneck = 40% faster
**How to Push Back Professionally**:
```
"I want to optimize effectively. Let me spend 30 minutes with Instruments
to find the actual bottleneck. This prevents wasting time on code that's
not the problem. I've seen this save days of work."
```
### Scenario 2: "Use actors everywhere for thread safety"
**The Pressure**: Team adopts Swift 6, decides "everything should be an actor."
**Red Flags**:
- Actor for simple value types
- Actor for synchronous-only operations
- Async overhead in tight loops
**Time Cost Comparison**:
- Actor everywhere: 100μs overhead per operation, janky UI
- Appropriate isolation: 10μs overhead, smooth 60fps
**How to Push Back Professionally**:
```
"Actors are great for isolation, but they add overhead. For this simple
counter, lock-free atomics are 10x faster. Let's use actors where we need
them—shared mutable state—and avoid them for pure value types."
```
### Scenario 3: "Inline everything for speed"
**The Pressure**: Someone reads that inlining is faster, marks everything `@inlinable`.
**Red Flags**:
- Large functions marked `@inlinable`
- Internal implementation details exposed
- Binary size increases 50%
**Time Cost Comparison**:
- Inline everything: Code bloat, slower app launch (3s → 5s)
- Selective inlining: Fast launch, actual hotspots optimized
**How to Push Back Professionally**:
```
"Inlining trades code size for speed. The compiler already inlines when
beneficial. Manual @inlinable should be for small, frequently called
functions. Let's profile and inline the 3 actual hotspots, not everything."
```
---
## Real-World Examples
### Example 1: Image Processing Pipeline
**Problem**: Processing 1000 images takes 30 seconds.
**Investigation**:
```swift
// Original code
func processImages(_ images: [UIImage]) -> [ProcessedImage] {
var results: [ProcessedImage] = []
for image in images {
results.append(expensiveProcess(image)) // Reallocations!
}
return results
}
```
**Solution**:
```swift
func processImages(_ images: [UIImage]) -> [ProcessedImage] {
var results = ContiguousArray<ProcessedImage>()
results.reserveCapacity(images.count) // Single allocation
for image in images {
results.append(expensiveProcess(image))
}
return Array(results)
}
```
**Result**: 30s → 8s (73% faster) by eliminating reallocations.
### Example 2: Generic Specialization
**Problem**: Protocol-based rendering is slow.
**Investigation**:
```swift
// Original - existential overhead
func render(shapes: [any Shape]) {
for shape in shapes {
shape.draw() // Dynamic dispatch
}
}
```
**Solution**:
```swift
// Specialized generic
func render<S: Shape>(shapes: [S]) {
for shape in shapes {
shape.draw() // Static dispatch after specialization
}
}
// Or use @_specialize
@_specialize(where S == Circle)
@_specialize(where S == Rectangle)
func render<S: Shape>(shapes: [S]) { }
```
**Result**: 100ms → 10ms (10x faster) by eliminating witness table overhead.
---
## Resources
**WWDC**: 2025-312, 2024-10217, 2024-10170, 2021-10216, 2016-416
**Docs**: /swift/inlinearray, /swift/span, /swift/outputspan
**Skills**: axiom-performance (skills/performance-profiling.md), axiom-concurrency, axiom-swiftui
---
skills/trace-comparison.md
# Trace Comparison (Regression Detection)
`xcprof compare <baseline> <current>` diffs two `.trace` recordings into a regression/improvement view and gates CI on the result. It replaces the old "export both traces and eyeball the XML" workflow with function-level deltas and a non-zero exit code your pipeline can fail on.
## When to Use
- Verifying a change didn't slow down a hot path ("did this PR regress CPU?").
- Gating merges on performance in CI (fail the build when a function's CPU share jumps).
- Confirming an optimization actually helped (the improvement list quantifies it).
It is **CPU-share regression detection**. For absolute "is this fast enough" thresholds on a single trace, use `xcprof analyze`; compare needs two traces.
For interactive (GUI) comparison, Instruments in Xcode 27 has built-in **Run Comparisons** — filter both runs to the same `os_signpost` interval, pick a baseline run, and read per-function deltas as a call tree, flame graph, or Top Functions view (`axiom-performance (skills/performance-profiling.md)`). `xcprof compare` remains the headless/CI path.
## The Two-Trace Workflow
The diff is only meaningful when **both recordings exercise the same workload** — drive the identical user flow (a UI test, a benchmark entry point, a scripted CLI run) both times, or the deltas measure workload differences, not code regressions.
```bash
export XCPROF_TRACE_ROOT="$(mktemp -d)" # sandbox the output
# 1. Baseline — build the BEFORE revision, record while exercising the hot path.
xcprof record --preset cpu --attach MyApp --time-limit 15s --no-prompt \
--output "$XCPROF_TRACE_ROOT/baseline.trace"
# 2. Make the change (or check out the PR), rebuild, record the SAME flow.
xcprof record --preset cpu --attach MyApp --time-limit 15s --no-prompt \
--output "$XCPROF_TRACE_ROOT/current.trace"
# 3. Compare.
xcprof compare "$XCPROF_TRACE_ROOT/baseline.trace" "$XCPROF_TRACE_ROOT/current.trace" --human
```
## Reading the Output
Compare emits compact JSON by default, `--human` for markdown, `--both` for markdown then JSON. Each delta carries:
| Field | Meaning |
|-------|---------|
| `incl_pct_delta` | change in inclusive CPU-cycle share, in percentage points (the headline metric) |
| `self_pct_delta` | change in self (leaf) share, percentage points — pinpoints the function whose own body got hotter |
| `incl_ms_delta` / `self_ms_delta` | approximate wall-time shift (sample-share × window); informational |
| `severity` | `\|incl_pct_delta\| × max(baseline,current inclusive ms)` — the "% delta × absolute time" rank; lists sort by it |
| `kind` | `changed` (in both), `new` (current only), `gone` (baseline only) |
A frame is a **regression** when its inclusive share rose by ≥ `--threshold-pct`, an **improvement** when it fell by ≥ that much; anything between is noise and dropped. `regressed: true` (and the CI exit code) fires when the regression list is non-empty.
Percentage points — not raw cycles or ms — because two traces have different total work; only *share* is comparable across runs. A function rising 51%→85% regressed even if the traces ran for different durations.
**But a share is relative to its trace's total — read the summary's baseline-vs-current totals first.** If the current trace's total CPU is higher, the run regressed regardless of any per-function share drop: a function can do *more absolute work while its share shrinks*, so it lands in the **improvement** list and gets dropped (`incl_pct_delta` is negative). When the totals diverge, the per-function view tells you *where the new work landed*, not *what got faster* — never read a falling share as an optimization until the totals match (re-record like-for-like). Concretely: `22% of 1.2s = 0.26s` → `17.5% of 2.05s = 0.36s` is a **36% slowdown** wearing an "improvement" label.
## CI Recipe
`--fail-on-regression` turns a regression into a non-zero exit, so a pipeline step gates on it directly:
```bash
#!/usr/bin/env bash
set -euo pipefail
xcprof compare baseline.trace current.trace \
--fail-on-regression --threshold-pct 5 --both
# exits 3 if any function's inclusive CPU share rose ≥ 5 percentage points
```
GitHub Actions:
```yaml
name: Performance regression gate
on: pull_request
jobs:
perf:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# record-trace.sh: check out the given ref, build, launch, record into $1
- name: Record baseline
run: ./scripts/record-trace.sh "${{ github.event.pull_request.base.sha }}" baseline.trace
- name: Record current
run: ./scripts/record-trace.sh "${{ github.sha }}" current.trace
- name: Compare and gate
run: xcprof compare baseline.trace current.trace --fail-on-regression --threshold-pct 5 --both
```
Start the threshold loose (10–15pp) to catch only gross regressions, then tighten as the recording's workload becomes more deterministic. A flaky workload produces noisy deltas; a fixed UI test or benchmark target keeps the gate trustworthy.
## Exit Codes
| Exit | Meaning |
|------|---------|
| `0` | compared cleanly — no regression met the threshold, OR `--fail-on-regression` wasn't set |
| `2` | usage/environment error (trace missing, bad args, export failed) |
| `3` | a regression met `--threshold-pct` AND `--fail-on-regression` was set (the CI gate) |
| `8` | output-write error (the diff itself succeeded) |
`3` is distinct from `2` so an agent can tell "the app got slower" from "the tool broke."
## Caveats
- **Symbolicate both traces.** Frames are matched by `(binary, function name)`. Raw-address frames (`0x…`) don't match across builds (ASLR), so they're excluded from the diff and counted in a note. Pass `--dsym <path>` (or rely on UUID auto-discovery) for symbol-level deltas on release builds.
- **Top-frame cutoff.** Compare diffs each trace's top frames; a function absent from the other trace's top list is treated as `0%`. A frame just below the cutoff in one trace can therefore overstate its delta slightly. The severity rank and threshold keep trivial frames out of the gate.
- **ms is approximate.** Percentage-point deltas are exact (cycle share); ms deltas are a sample-share estimate and shift with window length. Gate on `--threshold-pct`, not ms.
- **Network deltas are totals only.** Per-connection matching across runs is unreliable (ephemeral ports, per-run serials), so only total rx/tx byte deltas are reported.
## Resources
**Tools**: `xcprof compare` (companion: `xcprof record`, `xcprof analyze`)
**Skills**: xctrace-ref, performance-profiling, axiom-tools (skills/xcprof-ref.md)
skills/xctrace-ref.md
# xctrace CLI Reference
Command-line interface for Instruments profiling. Enables headless performance analysis without GUI.
## Overview
`xctrace` is the CLI tool behind Instruments.app. Use it for:
- Automated profiling in CI/CD pipelines
- Headless trace collection without GUI
- Programmatic trace analysis via XML export
- Performance regression detection
**Requires**: Xcode 12+ (xctrace 12.0+). This reference tested with Xcode 26.2.
## Quick Reference
```bash
# Record a 10-second CPU profile
xcrun xctrace record --instrument 'CPU Profiler' --attach 'MyApp' --time-limit 10s --output profile.trace
# Export to XML for analysis
xcrun xctrace export --input profile.trace --toc # See available tables
xcrun xctrace export --input profile.trace --xpath '/trace-toc/run[@number="1"]/data/table[@schema="cpu-profile"]'
# List available instruments
xcrun xctrace list instruments
# List available templates
xcrun xctrace list templates
```
## Recording Traces
### Basic Recording
```bash
# Using an instrument (recommended for CLI automation)
xcrun xctrace record --instrument 'CPU Profiler' --attach 'AppName' --time-limit 10s --output trace.trace
# Using a template (may fail on export in Xcode 26+)
xcrun xctrace record --template 'Time Profiler' --attach 'AppName' --time-limit 10s --output trace.trace
```
**Note**: In Xcode 26+, use `--instrument` instead of `--template` for reliable export. Templates may produce traces with "Document Missing Template Error" on export.
### Target Selection
```bash
# Attach to running process by name
xcrun xctrace record --instrument 'CPU Profiler' --attach 'MyApp' --time-limit 10s
# Attach to running process by PID
xcrun xctrace record --instrument 'CPU Profiler' --attach 12345 --time-limit 10s
# Profile all processes
xcrun xctrace record --instrument 'CPU Profiler' --all-processes --time-limit 10s
# Launch and profile
xcrun xctrace record --instrument 'CPU Profiler' --launch -- /path/to/app arg1 arg2
# Target specific device (simulator or physical)
xcrun xctrace record --instrument 'CPU Profiler' --device 'iPhone 17 Pro' --attach 'MyApp' --time-limit 10s
xcrun xctrace record --instrument 'CPU Profiler' --device 947DF45C-4ACB-4B3E-A043-DF2CD59A59B3 --all-processes --time-limit 10s
```
### Recording Options
| Flag | Description |
|------|-------------|
| `--output <path>` | Output .trace file path |
| `--time-limit <time>` | Recording duration (e.g., `10s`, `1m`, `500ms`) |
| `--no-prompt` | Skip privacy warnings (use in automation) |
| `--append-run` | Add run to existing trace |
| `--run-name <name>` | Name the recording run |
## Core Instruments
### CPU Profiler
CPU sampling for finding hot functions.
```bash
xcrun xctrace record --instrument 'CPU Profiler' --attach 'MyApp' --time-limit 10s --output cpu.trace
```
**Schema**: `cpu-profile`
**Columns**: time, thread, process, core, thread-state, weight (cycles), stack
### Allocations
Memory allocation tracking.
```bash
xcrun xctrace record --instrument 'Allocations' --attach 'MyApp' --time-limit 30s --output alloc.trace
```
**Schema**: `allocations`
**Use for**: Finding memory growth, object counts, allocation patterns
### Leaks
Memory leak detection.
```bash
xcrun xctrace record --instrument 'Leaks' --attach 'MyApp' --time-limit 30s --output leaks.trace
```
**Schema**: `leaks`
**Use for**: Detecting unreleased memory, retain cycles
### SwiftUI
SwiftUI view body analysis.
```bash
xcrun xctrace record --instrument 'SwiftUI' --attach 'MyApp' --time-limit 10s --output swiftui.trace
```
**Schema**: `swiftui`
**Use for**: Finding excessive view updates, body re-evaluations
### Swift Concurrency
Actor and Task analysis.
```bash
xcrun xctrace record --instrument 'Swift Tasks' --instrument 'Swift Actors' --attach 'MyApp' --time-limit 10s --output concurrency.trace
```
**Schemas**: `swift-task`, `swift-actor`
**Use for**: Task scheduling, actor isolation, async performance
## All Available Instruments
```
Activity Monitor Audio Client Audio Server
Audio Statistics CPU Counters CPU Profiler
Core Animation Activity Core Animation Commits Core Animation FPS
Core Animation Server Core ML Data Faults
Data Fetches Data Saves Disk I/O Latency
Disk Usage Display Filesystem Activity
Filesystem Suggestions Foundation Models Frame Lifetimes
GCD Performance GPU HTTP Traffic
Hangs Hitches Leaks
Location Energy Model Metal Application Metal GPU Counters
Metal Performance Overview Metal Resource Events Network Connections
Neural Engine Points of Interest Power Profiler
Processor Trace RealityKit Frames RealityKit Metrics
Runloops Sampler SceneKit Application
Swift Actors Swift Tasks SwiftUI
System Call Trace System Load Thread States
Time Profiler VM Tracker Virtual Memory Trace
```
## Exporting Traces
### Table of Contents
```bash
# See all available data tables in a trace
xcrun xctrace export --input trace.trace --toc
```
Output structure:
```xml
<trace-toc>
<run number="1">
<info>
<target>...</target>
<summary>...</summary>
</info>
<processes>...</processes>
<data>
<table schema="cpu-profile" .../>
<table schema="thread-info"/>
<table schema="process-info"/>
</data>
</run>
</trace-toc>
```
### XPath Export
```bash
# Export specific table by schema
xcrun xctrace export --input trace.trace --xpath '/trace-toc/run[@number="1"]/data/table[@schema="cpu-profile"]'
# Export process info
xcrun xctrace export --input trace.trace --xpath '/trace-toc/run[@number="1"]/data/table[@schema="process-info"]'
# Export thread info
xcrun xctrace export --input trace.trace --xpath '/trace-toc/run[@number="1"]/data/table[@schema="thread-info"]'
```
### CPU Profile Schema
```xml
<schema name="cpu-profile">
<col><mnemonic>time</mnemonic><name>Sample Time</name></col>
<col><mnemonic>thread</mnemonic><name>Thread</name></col>
<col><mnemonic>process</mnemonic><name>Process</name></col>
<col><mnemonic>core</mnemonic><name>Core</name></col>
<col><mnemonic>thread-state</mnemonic><name>State</name></col>
<col><mnemonic>weight</mnemonic><name>Cycles</name></col>
<col><mnemonic>stack</mnemonic><name>Backtrace</name></col>
</schema>
```
Each row contains:
- `sample-time`: Timestamp in nanoseconds
- `thread`: Thread ID and name
- `process`: Process name and PID
- `core`: CPU core number
- `thread-state`: Running, Blocked, etc.
- `cycle-weight`: CPU cycles
- `backtrace`: Call stack with function names
## Process Discovery
### Find Running Simulator Apps
```bash
# List apps in booted simulator
xcrun simctl spawn booted launchctl list | grep UIKitApplication
# Output format: PID Status com.apple.UIKitApplication:com.bundle.id[xxxx][rb-legacy]
```
### Find Device UUID
```bash
# List booted simulators (JSON)
xcrun simctl list devices booted -j
# List all devices
xcrun simctl list devices
```
### Find Process by Name
```bash
# Get PID of running app
pgrep -f "MyApp"
# List all processes with app name
ps aux | grep MyApp
```
## Automation Patterns
### CI/CD Integration
```bash
#!/bin/bash
# performance-test.sh
APP_NAME="MyApp"
TRACE_DIR="./traces"
TIME_LIMIT="30s"
# Boot simulator if needed
xcrun simctl boot "iPhone 17 Pro" 2>/dev/null || true
# Wait for app to launch
sleep 5
# Record CPU profile
xcrun xctrace record \
--instrument 'CPU Profiler' \
--device "iPhone 17 Pro" \
--attach "$APP_NAME" \
--time-limit "$TIME_LIMIT" \
--no-prompt \
--output "$TRACE_DIR/cpu.trace"
# Export for analysis
xcrun xctrace export \
--input "$TRACE_DIR/cpu.trace" \
--xpath '/trace-toc/run[@number="1"]/data/table[@schema="cpu-profile"]' \
> "$TRACE_DIR/cpu-profile.xml"
# Parse and check thresholds
# (Use xmllint, python, or custom tool to parse XML)
```
### Before/After Comparison
Don't export both traces and diff the XML by hand — `xcprof compare` does function-level deltas with a CI-gating exit code. Both recordings must exercise the **same workload**:
```bash
# Record baseline (before-revision build), then after changes
xcprof record --preset cpu --attach MyApp --time-limit 10s --no-prompt --output baseline.trace
# ...rebuild...
xcprof record --preset cpu --attach MyApp --time-limit 10s --no-prompt --output current.trace
# Diff them; --fail-on-regression sets exit 3 when a function's CPU share jumps
xcprof compare baseline.trace current.trace --fail-on-regression --threshold-pct 5 --human
```
Full workflow, CI recipe, and exit-code semantics: skills/trace-comparison.md.
## Troubleshooting
### "Document Missing Template Error" on Export
**Cause**: Recording used `--template` flag in Xcode 26+
**Fix**: Use `--instrument` instead:
```bash
# Instead of
xcrun xctrace record --template 'Time Profiler' ...
# Use
xcrun xctrace record --instrument 'CPU Profiler' ...
```
### "Unable to attach to process"
**Causes**:
1. Process not running
2. Insufficient permissions
3. System Integrity Protection blocking
**Fix**:
```bash
# Verify process exists
pgrep -f "AppName"
# For simulator apps, verify simulator is booted
xcrun simctl list devices booted
# Try with --all-processes instead of --attach
xcrun xctrace record --instrument 'CPU Profiler' --all-processes --time-limit 5s
```
### Empty Trace Export
**Cause**: Recording too short or no activity during recording
**Fix**: Increase `--time-limit` or ensure app is actively used during recording
### Symbolication Issues
Raw addresses in backtraces (e.g., `0x18f17ed94`) instead of function names.
**Fix**: Ensure dSYMs are available:
```bash
# Symbolicate trace (if needed)
xcrun xctrace symbolicate --input trace.trace --dsym /path/to/App.dSYM
```
## Post-Processing with filtercalltree
`filtercalltree` transforms call tree output for targeted analysis. It takes text files in the format produced by `sample`.
```bash
# 1. Capture a call tree with sample (writes to /tmp/*.sample.txt)
xcrun sample MyApp 5
# 2. Invert the call tree (show hottest leaf frames first)
xcrun filtercalltree -i /tmp/MyApp_*.sample.txt
# 3. Charge library costs to callers (attribute UIKitCore time to your code)
xcrun filtercalltree -chargeLibrary=UIKitCore /tmp/MyApp_*.sample.txt
# 4. Combine: invert + charge system libraries + prune noise
xcrun filtercalltree -i -chargeSystemLibraries -pruneCount 5 /tmp/MyApp_*.sample.txt
```
| Flag | Effect |
|------|--------|
| `-i` | Invert call tree — hottest leaf frames first (like Instruments "Invert Call Tree") |
| `-chargeLibrary=X` | Attribute framework X time to your calling code (repeatable) |
| `-chargeSystemLibraries` | Charge all /System and /usr libraries to callers |
| `-pruneCount N` | Remove branches with fewer than N samples |
| `-pruneMallocSize S` | Remove branches with malloc size below S (e.g., `500K`, `1.2M`) |
## Companion CLI Tools
These tools complement xctrace for specific profiling needs:
```bash
# Quick CPU sample without full trace (5-second sample)
xcrun sample MyApp 5
# Sample by PID, save to file
xcrun sample 12345 5 -file output.txt
```
`sample` is lighter than xctrace — use for quick CPU checks when you don't need the full Instruments pipeline.
## Limitations
1. **Privacy restrictions**: Some instruments require privacy permissions granted in System Preferences
2. **Device support**: Physical device profiling requires Developer Mode enabled
3. **Background apps**: Limited profiling of backgrounded apps
4. **Export format**: XML only (no JSON export)
5. **Template vs Instrument**: In Xcode 26+, templates may not export properly
## Resources
**Skills**: axiom-performance (skills/performance-profiling.md), axiom-performance (skills/memory-debugging.md), axiom-swiftui
**Docs**: /xcode/instruments, /os/logging/recording-performance-data