Reducing iOS Simulator CPU Overhead on macOS Executors
When running iOS E2E tests on CircleCI's macOS executors, freshly created simulators trigger a first-boot indexing storm that can push CPU load averages above 80 and delay test execution by several minutes. This article explains two techniques to dramatically reduce simulator CPU overhead: caching a warm simulator snapshot, and disabling the diagnosticd logging daemon.
Part 1: Cache and Restore a Warm Simulator Snapshot
The Problem
Every time you create and boot a fresh iOS simulator in CI, the simulator runs first-boot initialization: Spotlight indexing, widget extension setup, media extraction, launch services database construction, and more. This spawns 100+ background daemons, many of which are completely unnecessary for testing.
The worst offenders on a cold boot:
Process | Peak Typical CPU | What it does |
STExtractionService | 90-150% | Media/streaming extraction indexing |
assetsd | 30-65% | Asset catalog indexing |
Widget extensions (10+) | 5-30% each | CalendarWidget, FitnessWidget, NewsTag, etc. |
searchd | 15-18% | Spotlight search indexing |
Combined, these can consume over 300% CPU (3 vcpu worth) for the first 1-2 minutes after boot. On a shared CI executor, this starves your actual tests of resources and causes timeouts and flaky failures.
The Solution
Boot a simulator once, let it fully settle, snapshot the device data directory, and cache it. On subsequent runs, create a fresh simulator and overlay the warm data before booting. The simulator starts with all indexing already complete.
version: 2.1workflows:
ios-e2e:
jobs:
- warm-simulator
- run-tests:
requires:
- warm-simulatorjobs:
warm-simulator:
macos:
xcode: "16.4.0"
resource_class: m4pro.medium
steps:
- restore_cache:
keys:
- ios-sim-v1-
- run:
name: Create warm snapshot
command: |
[ -f /tmp/warm-sim-snapshot.tar.gz ] && exit 0
RUNTIME=$(xcrun simctl list runtimes -j | python3 -c "import json,sys;r=[x for x in json.load(sys.stdin)['runtimes'] if x['platform']=='iOS' and x['isAvailable']];print(r[-1]['identifier'])")
UDID=$(xcrun simctl create Warm "iPhone 15" "$RUNTIME")
xcrun simctl boot "$UDID"
sleep 60
xcrun simctl shutdown "$UDID" && sleep 5
cd ~/Library/Developer/CoreSimulator/Devices
tar czf /tmp/warm-sim-snapshot.tar.gz "$UDID"
echo "$UDID" > /tmp/warm-sim-udid.txt
- save_cache:
key: ios-sim-v1-{{ arch }}
paths:
- /tmp/warm-sim-snapshot.tar.gz
- /tmp/warm-sim-udid.txt run-tests:
macos:
xcode: "16.4.0"
resource_class: m4pro.medium
steps:
- checkout
- restore_cache:
keys:
- ios-sim-v1-
- run:
name: Boot from snapshot and run tests
command: |
RUNTIME=$(xcrun simctl list runtimes -j | python3 -c "import json,sys;r=[x for x in json.load(sys.stdin)['runtimes'] if x['platform']=='iOS' and x['isAvailable']];print(r[-1]['identifier'])")
UDID=$(xcrun simctl create E2E "iPhone 15" "$RUNTIME")
if [ -f /tmp/warm-sim-snapshot.tar.gz ]; then
TEMPLATE=$(cat /tmp/warm-sim-udid.txt)
tar xzf /tmp/warm-sim-snapshot.tar.gz -C /tmp
rsync -a /tmp/$TEMPLATE/data/ ~/Library/Developer/CoreSimulator/Devices/$UDID/data/
fi
xcrun simctl boot E2E
# your tests herePart 2: Disable diagnosticd to Eliminate Persistent CPU Drain
The Problem
Even after caching eliminates the first-boot indexing storm, two diagnosticd processes continue consuming significant CPU for the entire lifetime of the simulator:
Process | User | Steady-state CPU |
diagnosticd (simulator) | distiller | 40-50% |
diagnosticd (host) | root | 20-25% |
diagnosticd is Apple's unified logging daemon. It collects os_log messages, signposts, and activity traces from every running process.
The Solution
Unload the host-side diagnosticd via launchctl and kill it inside the simulator. Add these lines after booting:
# Unload host diagnosticd (prevents launchd from respawning it) sudo launchctl unload -w /System/Library/LaunchDaemons/com.apple.diagnosticd.plist# Kill diagnosticd inside the simulator xcrun simctl spawn "$UDID" killall diagnosticd
Simply killing diagnosticd is not enough — launchd will immediately respawn it. The launchctl unload -w command tells launchd to stop managing the service entirely.
Below is the before and after of the same test run:
Before optimizations:
After optimizations: