iOS Performance Profiling Workflow

Updated: 2026-07-07 15:48 ET · Audited: 2026-06-26

TL;DR: How to capture and analyze Xcode Instruments Time Profiler traces for the iOS simulator build: attach with xctrace record --template 'Time Profiler' --attach <pid>, export the time-profile schema, and extract main-thread call stacks with the included Python script. Traces live in test-logs/ (git-ignored), named <feature>-<scenario>-<duration>.trace. See Interpreting results for common hotspot patterns and fixes already applied to this codebase.

Use this guide to capture, analyze, and act on Xcode Instruments Time Profiler traces for the ALPA Mobile iOS simulator target.

Prerequisites

ToolInstall / verify
Xcode Command Line Toolsxcode-select --install
xctraceShips with Xcode — confirm with xctrace version
.NET MAUI simulator builddotnet build … -f net10.0-ios -r iossimulator-arm64

1. Build and launch the app

# Build Debug for simulator
dotnet build ALPAMobile/ALPAMobile.csproj \
  -f net10.0-ios -r iossimulator-arm64 -c Debug

# Launch the simulator app and note the PID printed to the console
# (or read it with: xcrun simctl launch <device-uuid> org.alpa.alpaMobile)

2. Attach and record a trace

Traces are written to test-logs/ (git-ignored).

# List available simulators
xcrun simctl list devices booted

# Record 120 s — attach to a running PID
xctrace record \
  --template 'Time Profiler' \
  --device <simulator-uuid> \
  --attach <pid> \
  --time-limit 120s \
  --output 'test-logs/<name>.trace' \
  --no-prompt
Naming convention: test-logs/<feature>-<scenario>-<duration>.trace
e.g. test-logs/login-to-home-120s.trace

Finding the simulator UUID

xcrun simctl list | grep 'iPhone 17 Pro Max'

3. Inspect the trace

# Table of contents: see all schemas in the trace
xctrace export \
  --input test-logs/<name>.trace \
  --toc 2>/dev/null | less

# Export potential hangs
xctrace export \
  --input test-logs/<name>.trace \
  --xpath '/trace-toc/run[@number="1"]/data/table[@schema="potential-hangs"]' \
  2>/dev/null | grep -o 'start-usecs="[^"]*"\|duration-usecs="[^"]*"' | head -20

4. Extract symbolicated call stacks (Python)

The time-profile schema contains pre-symbolicated frames — no dSYM wrangling required for simulator builds.

# Export to file first (5–6 MB typical)
xctrace export \
  --input test-logs/<name>.trace \
  --xpath '/trace-toc/run[@number="1"]/data/table[@schema="time-profile"]' \
  2>/dev/null > /tmp/time_profile.xml

Then use this analysis script. The key lookup chain is:

  1. frame id="N" name="SYMBOL" → build {id: name} dict
  2. backtrace id="N" → list of frame id/ref elements
  3. sample-time + thread ref="<main-thread-id>" in each <row> → sample
  4. Cross-reference backtrace ref="N" against the dict from step 2
import re

with open('/tmp/time_profile.xml') as f:
    data = f.read()

# 1. Symbol table
frame_names = {m.group(1): m.group(2)
               for m in re.finditer(r'<frame id="(\d+)" name="([^"]+)"', data)}

# 2. Backtrace table
backtrace_frames = {}
for m in re.finditer(r'<backtrace id="(\d+)">(.*?)</backtrace>', data, re.DOTALL):
    fids = re.findall(r'<frame[^>]+(?:id|ref)="(\d+)"', m.group(2))
    backtrace_frames[m.group(1)] = fids

# 3. Find main thread ID
thread_fmts = {m.group(1): m.group(2)
               for m in re.finditer(r'<thread id="(\d+)"[^>]*fmt="([^"]+)"', data)}
main_ids = {tid for tid, fmt in thread_fmts.items() if 'Main Thread' in fmt}

# 4. Collect samples in a time window (nanoseconds since trace start)
hang_start = 49_210_615_958
hang_end   = 49_471_613_375

rows = data.split('</row>\n<row>')
samples = []
for row in rows:
    ts_m = re.search(r'<sample-time[^>]*>(\d+)</sample-time>', row)
    if not ts_m or not (hang_start <= int(ts_m.group(1)) <= hang_end):
        continue
    if not any(f'thread ref="{tid}"/>' in row for tid in main_ids):
        continue
    bt_m = re.search(r'<backtrace[^>]+(?:id|ref)="(\d+)"', row)
    if bt_m:
        samples.append(bt_m.group(1))

# 5. Print top stacks
seen = {}
for bt_id in samples:
    seen[bt_id] = seen.get(bt_id, 0) + 1
for bt_id, count in sorted(seen.items(), key=lambda x: -x[1])[:5]:
    print(f'\n{count}x:')
    for fid in backtrace_frames.get(bt_id, [])[:15]:
        print(' ', frame_names.get(fid, f'ref:{fid}'))

5. Identify hang windows

xctrace export \
  --input test-logs/<name>.trace \
  --xpath '/trace-toc/run[@number="1"]/data/table[@schema="potential-hangs"]' \
  2>/dev/null > /tmp/hangs.xml

grep -o 'start-usecs="[^"]*"\|duration-usecs="[^"]*"\|Main Thread' /tmp/hangs.xml

Timestamps in the time-profile XML use nanoseconds from trace start. Convert: hang_start_ns = start_usecs * 1000.

6. Interpreting results

PatternLikely cause
mono_interp_exec_method at top of all stacksMono interpreter executing managed code — find the UIKit entry point below it
UICollectionView _updateVisibleCellsNow → MonoCell creation or ItemsSource swap on main thread
UITextField / UIKeyboardStateManager on navKeyboard state churn during page transition
_UINavigationBarVisualProviderModernIOS …Navigation bar layout recalculation
reloadData → MonoItemsSource replaced with a new collection instance unnecessarily

Fixes applied to this codebase

FixFileHotspot
HideSoftInputOnTapped="True"Pages/LoginPage.xamlUITextField/Keyboard −36–40%
ItemSizingStrategy="MeasureFirstItem"Pages/HomePage.xamlCollectionView layout −50%
ID-equality guard before MenuItems =ViewModels/HomePageViewModel.csDocumentsReadyMessage 261 ms hang eliminated

7. Baseline comparison workflow

When validating a fix, capture a before trace without the fix and an after trace with the fix under identical test conditions, then compare sample counts for the same hotspot symbol.

before = 300   # raw count in baseline trace
after  = 258   # raw count in fixed trace
total_before = 13000
total_after  = 13200
norm_before = before / total_before * 10000
norm_after  = after  / total_after  * 10000
print(f'Before: {norm_before:.2f}  After: {norm_after:.2f}  Delta: {(norm_after - norm_before)/norm_before*100:+.1f}%')

8. Trace file conventions