axiom-performance-profiling について
iOS 開発者向けのパフォーマンス分析リファレンスです。遅延、メモリ増加、バッテリー消費などの症状に応じた Instruments の選択と、結果の読み取り方を扱います。
主な機能
- 01症状に応じた分析ツールの選択
- 02Time Profiler による CPU 分析
- 03Allocations によるメモリ調査
- 04Core Data クエリの診断
- 05分析結果のよくある誤読の回避
活用シーン
- 01UI の遅延や CPU スパイクの特定
- 02メモリ使用量の増加調査
- 03Core Data の読み込み遅延分析
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-performance-profilingインストール後、Claude Code で/axiom-performance-profilingを入力して読み込まれたか確認します。
詳細
パフォーマンスのプロファイリング
概要
iOS アプリのパフォーマンス問題はそれぞれ異なるカテゴリに分かれており、診断には専用のツールを使います。このスキルでは、プレッシャーがかかる状況でも、適切なツールを選び、効果的に使い、結果を正しく解釈する方法を学べます。
基本原則:最適化の前に測定しましょう。パフォーマンスを推測すると、プロファイリングするよりも多くの時間を無駄にします。
必要条件:Xcode 15 以降、iOS 14 以降
関連スキル:axiom-swiftui-performance(Instruments 26 を使った SwiftUI 固有のプロファイリング)、axiom-memory-debugging(メモリリークの診断)
パフォーマンスのプロファイリングを使う場面
このスキルを使う場面
- ✅ アプリの動作が遅い(UI の反応が遅れる、読み込みに 5 秒以上かかる)
- ✅ 時間とともにメモリ使用量が増加する(Xcode でメモリ使用量の増加が表示される)
- ✅ バッテリーの消耗が早い(デバイスが熱くなる、数時間でバッテリーが切れる)
- ✅ ユーザーから苦情が出る前に、事前にプロファイリングしたい
- ✅ どの Instruments ツールを使えばよいかわからない
- ✅ プロファイリングの結果がわかりにくい、または矛盾している
次の場合は代わりに axiom-memory-debugging を使います
- retain cycle を伴う特定のメモリリークを調査する
- 詳細モードで Instruments Allocations を使用する
次の場合は代わりに axiom-swiftui-performance を使います
- SwiftUI の view body の更新を分析する
- SwiftUI Instrument を特に使用する
パフォーマンスの判断フロー
Instruments を開く前に、何を調査するのかを絞り込みましょう。
ステップ 1:どのような症状ですか?
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)
ステップ 2:再現できますか?
はい – Instruments を使って測定します(プロファイリングが最も正確です)
いいえ – 事前にプロファイリングします
- Core Data SQL デバッグを有効にして、N+1 クエリを検出する
- 通常の使用中(スクロール、読み込み、画面遷移)にアプリをプロファイリングする
- 変更前にベースラインの指標を確立する
ステップ 3:どの Instruments ツールを使いますか?
Time Profiler – 動作の遅さ、UI の反応遅延、CPU スパイク
Allocations – メモリ使用量の増加、メモリプレッシャー、オブジェクト数
Core Data – クエリのパフォーマンス、フェッチ時間、フォールトの発生
Energy Impact – バッテリーの消耗、継続的な電力消費
Network Link Conditioner – 接続に起因する動作の遅さ
System Trace – スレッドのブロック、メインスレッドのブロック、スケジューリング
Time Profiler の詳細
アプリの動作が遅い、または反応が鈍い場合は、Time Profiler を使います。各関数で消費される CPU 時間を測定します。
ワークフロー:記録して分析する
ステップ 1:Instruments を起動する
open -a Instruments
「Time Profiler」テンプレートを選択します。
ステップ 2:実行中のアプリに接続する
- シミュレータまたはデバイスでアプリを起動します
- Instruments で、ターゲットのドロップダウンからアプリを選択します
- 「Record」(赤い丸)をクリックします
- 動作の遅い部分を操作します(スクロール、ボタンのタップ、データの読み込み)
- 10~30 秒間操作したら記録を停止します
ステップ 3:コールスタックを読み取る
上部のパネルには、時間経過に伴う CPU 使用率のタイムラインが表示されます。次の点を確認します。
- 高いスパイク – 短時間の CPU 負荷が高い処理
- 継続的な高使用率 – 高コストな処理が継続している状態
- メインスレッドのブロック – UI スレッドが処理を実行している状態(UI の反応遅延の原因)
ステップ 4:ホットスポットを掘り下げる
コールツリーで「Heaviest Stack Trace」をクリックすると、CPU を最も多く使用している関数を確認できます。
Time Profiler Results
MyViewController.viewDidLoad() – 500ms (40% of total)
├─ DataParser.parse() – 350ms
│ └─ JSONDecoder.decode() – 320ms
└─ UITableView.reloadData() – 150ms
Self Time = その関数内で費やされた時間(呼び出し先の関数で費やされた時間を除く)
Total Time = その関数内と、呼び出し先のすべての関数で費やされた時間
よくある間違いと対処法
❌ 間違い 1:原因となる関数を誤認する
// ❌ 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
問題点:Total Time が長い関数は、関数自体の処理に時間がかかっているのではなく、処理の遅いコードを呼び出している可能性があります。
対処法:Total Time ではなく Self Time を確認します。関数が何を呼び出しているのか、掘り下げて確認します。
❌ 間違い 2:誤ったコードパスをプロファイリングする
// ❌ 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
対処法:CPU の測定精度を高めるため、必ず実機でプロファイリングします。
❌ 間違い 3:問題を切り分けない
// ❌ 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
対処法:アプリ全体ではなく、特定の遅い処理を再現します。
プレッシャーのかかる状況:「プロファイルで関数 X の CPU 使用率が 80%と表示される」
ついやってしまいがちなこと:「関数 X を最適化しなければ!」
実際には:関数 X は次のような状態かもしれません。
- コストの高いコードを呼び出している(最適化するのは X ではなく、呼び出される関数です)
- メインスレッドで実行されている(バックグラウンドに移動すればよく、すでに最適化済みです)
- 遅く見えるだけの必要な処理を行っている(ベースラインとして許容でき、ユーザーは気づきません)
代わりに行うこと:
-
合計時間ではなく、自己時間を確認する
- 自己時間が 80%?関数自体が実際にコストの高い処理を行っています
- 自己時間が 5%、合計時間が 80%?関数が遅いコードを呼び出しています
-
1 段階掘り下げる
- この関数は何を呼び出していますか?
- 遅いコードは、自分で制御できるライブラリ内にありますか?
-
タイムラインを確認する
- この 80%は継続的なもの(一定して遅い)ですか、それともスパイク(ときどき発生する停止)ですか?
- 継続的な場合=最適化が必要です
- スパイクの場合=キャッシュが役立つ可能性があります
-
「ユーザーは気づくか?」と考える
- バックグラウンドでの 500ms の処理=ユーザーは気づきません
- メインスレッドでの 500ms の処理=UI が停止し、ユーザーに見えます
- フレームごとのメインスレッドでの 50ms の処理=スムーズな UI(60fps)
所要時間:5 分(結果を読む)+ 2 分(掘り下げる)= 理解するまで 7 分
推測した場合のコスト:間違った関数の最適化に 2 時間 + 効果がなかったと気づくまで 1 時間 + 振り出しに戻る = 3 時間以上の無駄
メモリ割り当ての詳細分析
時間の経過とともにメモリが増加する場合や、メモリ圧迫の問題が疑われる場合は、Allocations を使用します。
ワークフロー:記録と分析
ステップ 1:Instruments を起動する
open -a Instruments
「Allocations」テンプレートを選択します。
ステップ 2:アタッチして記録する
- アプリを起動します
- Instruments でアプリを選択します
- Record をクリックします
- メモリを使用する操作(データの読み込み、画像の表示、画面の移動)を行います
- メモリが安定するか、ピークに達したら記録を停止します
ステップ 3:メモリの増加を見つける
メインチャートを確認します。
- 青い線 = 割り当ての合計
- 急な上昇 = メモリが割り当てられています
- 平坦な線 = メモリが安定しています(良好)
- 操作を停止しても減少しない = メモリリークの可能性があります(またはキャッシュ)
ステップ 4:残存オブジェクトを特定する
「Statistics」で:
- 「Persistent」(まだ生存しているオブジェクト)で並べ替えます
- 不自然に多いオブジェクト数を探します。
UIImage: 500 instances (300MB) – Should be <50 for normal app NSString: 50000 instances – Should be <1000 CustomDataModel: 10000 instances – Should be <100
よくある間違いと対処法
❌ 間違い 1:「メモリが増えた」と「メモリリーク」を混同する
// ❌ 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
問題:メモリの増加 ≠ メモリリークです。データの読み込み時にアプリが正当により多くのメモリを使用することがあります。
対処法:Allocations でオブジェクト数を確認します。画像やデータの数が読み込んだものと一致する場合は正常です。操作していないのにオブジェクト数が増え続ける場合は、メモリリークです。
❌ 間違い 2:キャッシュを考慮していない
// ❌ 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
対処法:意図的なキャッシュと実際のメモリリークを区別します。メモリリークは、メモリ圧迫時にも解放されません。
❌ 間違い 3:プロファイリングの時間が短すぎる
// ❌ 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
対処法:メモリが安定する様子を確認できるだけの時間、プロファイリングを行います。短い記録では、一時的なスパイクしか捉えられません。
プレッシャーのかかる状況:「メモリが500MBもある、これはリークだ!」
ありがちな考え:「キャッシュを削除し、オブジェクトの生成を減らし、データ構造を最適化する」
実際のところ:500MBは本当に大きいのでしょうか?
- iPhone 14 ProのRAMは6GBです
- Instagramは起動時に400~600MBを使用します
- 写真アプリは大規模なライブラリの閲覧時に500MB以上を使用します
- 500MBはまったく問題ない場合もあります
代わりにすること:
-
実機でベースラインを確立する
# On device, open Memory view in Xcode Xcode → Debug → Memory Debugger → Check "Real Memory" at app launch -
合計メモリではなく、オブジェクト数を確認する
- Allocations → Statistics →「Persistent」
- 画像、ビュー、データオブジェクトの数が想定の10倍になっていませんか?
- はいの場合は、そのオブジェクトの種類を調べます
- いいえの場合、メモリはおそらく問題ありません
-
メモリプレッシャー下でテストする
- Xcode → Debug → Simulate Memory Warning
- メモリが50%以上減りますか?キャッシュが原因です(正常です)
- メモリが高いままですか?永続オブジェクトを調べます
-
実際のユーザージャーニーをプロファイリングする
- ユーザーと同じようにデータを読み込む
- ユーザーと同じように画面を移動する
- バックグラウンドからアプリに戻る
- 各段階でメモリを確認する
所要時間:5分(Allocationsを起動)+3分(アプリの使用状況を記録)+2分(分析)= 10分
推測で対応した場合のコスト:「メモリ削減」のためにキャッシュを削除 → 画面を表示するたびにアプリがデータを再読み込み → アプリが遅くなる → ユーザーから不満が出る → 変更を元に戻す = 2時間以上の無駄
Core Dataの詳細
アプリがCore Dataを使用していて、データの読み込みが遅い場合は、Core Dataインストゥルメントを使用します。
ワークフロー:SQLデバッグを有効にしてプロファイリングする
ステップ1:Core DataのSQLログを有効にする
Xcodeで起動引数に追加します:
Edit Scheme → Run → Arguments Passed On Launch
Add: -com.apple.CoreData.SQLDebug 1
これで、SQLiteクエリがコンソールに出力されます:
CoreData: sql: SELECT ... FROM tracks WHERE artist = ? (time: 0.015s)
CoreData: sql: SELECT ... FROM albums WHERE id = ? (time: 0.002s)
ステップ2:N+1クエリ問題を特定する
一般的なユーザー操作(リストの読み込み、スクロール、フィルタリング)の間、コンソールを確認します:
❌ 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
ステップ3:Core Dataインストゥルメントでプロファイリングする
open -a Instruments
「Core Data」テンプレートを選択します。
時間のかかる操作を実行しながら記録します:
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
よくある間違いと修正方法
❌ 間違い1:リレーションシップを正しく使用していない
// ❌ 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
修正方法:relationshipKeyPathsForPrefetchingを使用して、関連オブジェクトを事前に読み込みます。
❌ 間違い2:バッチ処理を使用していない
// ❌ 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
修正方法:大規模なデータセットにはfetchBatchSizeを使用します。
❌ 間違い3:メモリ削減にFaultingを使用していない
// ❌ 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
修正方法:すべてのオブジェクトを事前に取得する必要がない限り、returnsObjectsAsFaultsはデフォルト(true)のままにします。
プレッシャーのかかる状況:「Core Dataのクエリが遅い、スキーマを再設計しよう!」
ありがちな考え:「スキーマに問題がある。すべてを再構築する必要がある」
実際のところ:「Core Dataが遅い」原因の99%は次のとおりです:
- ❌ インデックスがない
- ❌ N+1クエリ問題
- ❌ 一度に大量のデータを取得している
- ❌ バッチサイズやプリフェッチを使用していない
スキーマの再設計は、最後に試すことです。
代わりにすること:
-
SQLデバッグを有効にする(2分)
-com.apple.CoreData.SQLDebug 1を起動引数に追加する- 実行されるクエリを確認する
-
N+1パターンを探す(3分)
- 100個のオブジェクトを取得した後、関連データを個別にクエリしていませんか?
- リレーションシップのプリフェッチを追加する
-
必要に応じてインデックスを追加する(5分)
- 頻繁にフィルタリングする
@NSManaged var artist: Stringがありますか? - スキーマに
@Indexを追加する
- 頻繁にフィルタリングする
-
改善をテストする(2分)
- 同じ操作を再実行する
- クエリ数と合計時間を比較する
- 10倍速くなっていれば完了です
- まだ遅い場合は、ステップ5に進みます
-
その後で初めてスキーマ変更を検討する(30分以上)
- ただし、ここまで進むことはおそらくありません
所要時間:診断と修正に12分 = 12分
スキーマ再設計のコスト:設計に8時間+移行に4時間+テストに2時間+ロールバックに1時間 = 合計15時間
クイックリファレンス:その他のツール
エネルギーへの影響(バッテリー消費)
使用する場面:アプリのバッテリー消費が速い、デバイスが熱くなる
ワークフロー:
- Instruments を起動 → Energy Impact テンプレートを選択します
- アプリを通常どおり 5 分以上実行します
- 赤色またはオレンジ色の使用状況が継続している箇所を確認します(問題があります)
- 詳細を確認し、どのサブシステムがバッテリーを消費しているかを特定します
主なメトリクス:
- 継続的な電力 – 継続中のエネルギー消費(最小限に抑える必要があります)
- ピーク – 短時間の高い使用量(許容範囲です)
- CPU – プロセスの CPU 時間
- GPU – グラフィックスのレンダリング
- ネットワーク – モバイル通信/WiFi 無線
- 位置情報 – GPS の使用状況
よくある問題:
- 精度を 1m に設定した位置情報の継続的な更新(100m にする必要があります)
- デバイスを繰り返しスリープ解除するタイマーの実行
- 過剰なネットワーク呼び出し(代わりにリクエストをまとめます)
- 画面に表示されていない間もビューをアニメーション表示している
Network Link Conditioner(接続シミュレーション)
使用する場面:アプリの 4G での動作が遅いように見える、移動せずにテストしたい
設定:
- Additional Tools for Xcode をダウンロードします
- Network Link Conditioner をインストールします
- システム設定 → Network Link Conditioner を開きます
- プロファイル(3G、LTE、WiFi Slow など)を選択します
- プロファイルを有効にしてアクティベートします
- アプリを実行してテストします
主なプロファイル:
- 3G – 下り 1.6Mbps、上り 768Kbps、レイテンシ 150ms
- LTE – 下り 10Mbps、上り 5Mbps、レイテンシ 20ms
- WiFi Slow – 10Mbps、レイテンシ 100ms
- Custom – 独自のパラメーターを設定します
注:ネットワークに依存するテストシナリオについては、ui-testing でも取り上げています。
System Trace(スレッドのブロック、スケジューリング)
使用する場面:UI がフリーズしたりカクついたりするものの、Time Profiler では CPU 使用率が低い
よくある原因:ロック待ちをしているバックグラウンドタスクによってメインスレッドがブロックされている
ワークフロー:
- Instruments を起動 → System Trace テンプレートを選択します
- 問題を再現しながら記録します
- メインスレッドの空白部分(ブロック中または未実行)を確認します
- 詳細を確認し、何がブロックしているかを特定します
主なメトリクス:
- メインスレッドの空白部分 – 空白部分は、メインスレッドがアイドル状態またはブロック中であることを示します
- コアのスケジューリング – 各スレッドがいつ実行されるか
- ロック競合 – ロックを待っているスレッド
OSSignposter — カスタムパフォーマンス計測
Time Profiler では CPU 時間が全般的にどこで使われているかを確認できますが、OSSignposter を使うと、定義した特定の処理を計測できます。Apple プラットフォームでカスタムパフォーマンス計測を行うための主要なツールです。
使用する場面
- 特定の処理(データの読み込み、画像処理、同期サイクルなど)の所要時間を計測する
- アプリの処理用にカスタム Instruments レーンを作成する
- 自動パフォーマンステスト(XCTOSSignpostMetric)と連携する
- 複数のスレッドや await ポイントにまたがる処理を計測する
基本 API
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")
}
Instruments との連携
- Instruments を起動 → 「os_signpost」または「Points of Interest」インストゥルメントを追加します
- 計測対象の処理をアプリで実行し、記録します
- タイムラインにサインポストのインターバルが色付きのバーとして表示されます
- サブシステム/カテゴリでフィルタリングし、対象の処理に絞り込みます
サインポストと Time Profiler の使い分け
| 必要なもの | ツール |
|---|---|
| 一般的な CPU ホットスポット | Time Profiler |
| 特定の処理の所要時間 | OSSignposter |
| スレッドをまたぐ処理のタイミング | OSSignposter |
| 自動回帰テスト | OSSignposter + XCTOSSignpostMetric |
圧力シナリオ
シナリオ 1:「プロファイリングの結果が実行ごとに異なる」
問題:Time Profiler を 3 回実行したところ、200ms、150ms、280ms という結果が出ました。どれが正しいのでしょうか?
考えられる危険信号:
- 「結果が信用できない。プロファイリングは正確ではない」
- 「平均を出せばいいだけだ」
- 「ばらつきが大きすぎて、最適化できない」
実際のところ:ばらつきは普通です。実行ごとに、次の状態が異なります。
- キャッシュの状態(キャッシュが空だと遅くなります)
- システム負荷(ほかのアプリの実行状況)
- CPU の周波数(ブースト/スロットリング)
代わりにすべきこと:
-
キャッシュをウォームアップする(最初の実行は必ず遅くなります)
- アクションを 1 回実行します(キャッシュが空の状態)
- もう 1 回実行します(キャッシュが温まった状態)。この測定値を使います
-
システム負荷を制御する
- ほかのアプリを終了します
- プロファイリング中はデバイスに触れないでください
- シミュレーターではなく、デバイス上でプロファイリングします
-
パターンを見つける
- 複数回の実行:150ms、160ms、155ms(一貫している=良好)
- 複数回の実行:150ms、280ms、240ms(一貫していない=調査が必要)
- 一貫しない場合は断続的な問題があるため、原因を見つけます
-
最も遅い実行結果を信頼する(最悪のケースを想定)
- 範囲が 150~280ms なら、280ms を実際の値とみなします
- 最悪のケースに合わせて最適化します
所要時間:10 分(プロファイラーを 3 回実行)+ 2 分(結果の解釈)= 12 分
ばらつきを無視した場合の代償:断続的なパフォーマンスの問題を見逃す → ユーザーがときどきフリーズを経験する → レビューの評価が下がる
シナリオ 2:「Time Profiler と Allocations で異なる問題が見つかる」
問題:Time Profiler では JSON の解析が遅いと表示されます。Allocations ではメモリ使用量は正常です。どちらを修正すべきでしょうか?
答え:どちらも実際の問題ですが、優先順位を変えて対応します。
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)
よくある食い違い:
| Time Profiler | Allocations | 対応 |
|---|---|---|
| CPU 使用率が高い | メモリは正常 | 計算処理を最適化する(CPU 使用量を減らす) |
| CPU 使用率が低い | メモリが増加している | メモリリークを見つけるか、オブジェクトの生成を減らす |
| どちらも高い | どちらも高い | まずユーザーに影響するほうをプロファイリングする |
対応方法:
-
ユーザーへの影響に基づいて優先順位を決める
- 動作の遅さ(UI の遅延)=先に修正
- メモリ(バックグラウンドの問題)=次に修正
-
両者に関連があるか確認する
- JSON の解析でメモリリークが起きているか?(いいえ → 別々の問題)
- メモリの増加が CPU を遅くしているか?(可能性あり → 先にメモリを修正)
-
影響の大きい順に修正する
- JSON の解析が遅い:データの読み込みごとに影響します
- メモリは正常:ユーザーへの影響はありません
- → JSON の解析を修正します
所要時間:5 分(両方の結果を分析)= 5 分
間違った問題を修正した場合の代償:問題のないメモリ使用量の最適化に 4 時間費やす → ユーザー体験は改善しない
シナリオ 3:「締め切りに追われている状況でのプロファイリング」
状況:マネージャーから「2 時間後にリリースします。パフォーマンスは許容範囲ですか?」と聞かれました。
考えられる危険信号:
- 「プロファイリングには時間がかかる。ユーザーに聞けばいい」
- 「適切にプロファイリングする時間がない。このままリリースしよう」
- 「手早く 1 回実行すれば、問題ないかどうかわかる」
実際のところ:プロファイリングにかかる時間は合計 15~20 分です。残り時間の 1%にすぎません。
代わりにすべきこと:
-
クリティカルパスをプロファイリングする(3 分)
- ユーザーが最もよく行う操作(リストの読み込み、スクロール、検索)
- アプリ全体ではなく、遅い部分だけを確認します
-
適切な測定を 1 回記録する(5 分)
- 最初はキャッシュが空の状態
- 2 回目はキャッシュが温まった状態
- キャッシュが温まった状態の結果を使います
-
手早く解釈する(5 分)
- Time Profiler:メインスレッドで 100ms を超える処理があるか?(なければ問題ありません)
- Allocations:メモリが増加しているか?(していなければ問題ありません)
-
確信を持ってリリースする(2 分)
- 結果が許容範囲ならリリースします
- そうでなければ、修正または延期に充てられる時間は 90 分あります
所要時間:プロファイリング 15 分+分析 5 分= 20 分
プロファイリングをしなかった場合の代償:パフォーマンスがわからないままリリース → ユーザーが動作の遅さに遭遇 → レビューの評価が下がる → 2 週間後に緊急のホットフィックス
計算:今プロファイリングにかける 20 分 ≪ リリース後のサポートにかかる 2 週間以上
CLI ですばやく確認(Instruments 不要)
Xcode には、Instruments を開かずに素早く確認できる CLI プロファイリングツールが付属しています。
CPU のプロファイリング
# 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
使用するタイミング:本格的な xctrace セッションを実施する前の、CPU の簡易チェックに使います。どの関数が高負荷かを 5 秒で確認できます。
メモリプロファイリング
# Quick leak check — is there a leak at all?
xcrun leaks MyApp
heap、vmmap、stringdups、および CLI を使った診断ワークフロー全体については、axiom-memory-debugging を参照してください。
ヘッドレス Instruments(xctrace)
# 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
xctrace の包括的なリファレンスについては、axiom-xctrace-ref を参照してください。
クイックリファレンス
一般的な操作
// 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
判断ツリーの概要
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)
実際の使用例
例 1:Core Data の N+1 クエリ問題を特定する
シナリオ:アプリでアーティスト名付きのアルバム一覧を読み込みます。動作が遅く(アルバム 100 件の読み込みに 5 秒以上かかります)、Core Data が原因ではないかと考えています。
セットアップ:まず SQL ログを有効にします
# Edit Scheme → Run → Arguments Passed On Launch
-com.apple.CoreData.SQLDebug 1
コンソールに表示される内容:
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
スキルを使った診断:
- アルバム 100 件を取得した後、各アルバムのアーティストを個別にクエリしています。これは N+1 クエリ問題 です(Core Data Deep Dive、302〜325 行)
修正:
// ❌ 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
}
結果:0.350s → 0.050s(7 倍高速化)
例 2:UI の遅延の本当の原因を突き止める
シナリオ:ビューの読み込み時に、アプリの UI が 1〜2 秒停止します。共同リードから「どこでもバックグラウンドスレッドを追加しよう」と言われましたが、まずは測定したいと考えています。
スキルを使ったワークフロー(Time Profiler Deep Dive、82〜118 行):
- Instruments を開きます:
open -a Instruments
# Select "Time Profiler"
- 停止を記録します:
App launches
Time Profiler records
View loads
Stall happens (observe the spike in Time Profiler)
Stop recording
- 結果を調べます:
Call Stack shows:
viewDidLoad() – 1500ms
├─ loadJSON() – 1200ms (Self Time: 50ms)
│ └─ loadImages() – 1150ms (Self Time: 1150ms) ← HERE'S THE CULPRIT
├─ parseData() – 200ms
└─ layoutUI() – 100ms
- スキルを適用します(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
- 適切な箇所を修正します:
// ❌ 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)
}
}
}
結果:1500ms → 350ms(4 倍高速化、メインスレッドのブロックを解消)
重要な理由:スレッド処理についてやみくもに推測するのではなく、実際のボトルネック(1150ms)を修正しました。
例 3:メモリ増加とメモリリークの違い
シナリオ:アプリを 30 分間使用すると、Allocations でメモリ使用量が 150MB から 600MB に増加します。マネージャーは「メモリリークだ!」と言っていますが、本当にそうなのか確認する必要があります。
スキルを使ったワークフロー(Allocations Deep Dive、199〜277 行):
-
Instruments で Allocations を起動します
-
アプリを通常どおり 3 分間使用して記録します:
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
- Allocations Statistics を確認します:
Persistent Objects:
- UIImage: 1200 instances (300MB) ← Large count
- NSString: 5000 instances (4MB)
- CustomDataModel: 800 instances (15MB)
- スキルに質問します(220〜240 行):
- 画像 1200 枚は正当に読み込まれたものですか?(ユーザーが写真 1000 枚のフォトライブラリを読み込みました)→ はい
- メモリ警告を発生させるとメモリ使用量が減りますか?(Xcode でシミュレートします)→ はい。180MB まで減少します
- このキャッシュは意図どおりに機能していますか?→ はい
診断:リークではありません。これは 通常のキャッシュ動作 です(235〜248 行)
Memory growing = apps using data users asked for
Memory dropping under pressure = cache working correctly
Memory staying high indefinitely = possible leak
- 結論:
// ✅ This is working correctly
let imageCache = NSCache<NSString, UIImage>()
// Holds up to 1200 images by design
// Clears when system memory pressure happens
// No leak
結果:対応は不要です。「リーク」は、実際にはキャッシュが正常に機能している状態です。
リグレッションを防ぐパイプライン
パフォーマンス改善は、修正をリリースした時点で終わりではありません。リグレッションを検出しなければ、最適化はいつの間にか劣化していきます。この 3 段階のパイプラインでは、各フェーズでリグレッションを検出できます。
3 つの段階
| 段階 | ツール | タイミング | 検出対象 |
|---|---|---|---|
| 開発 | OSSignposter | コード作成時 | 特定の操作にかかる時間 |
| CI | XCTest パフォーマンステスト | プルリクエストごと | ベースラインからのリグレッション |
| 本番環境 | MetricKit | リリース後 | 実環境での劣化 |
段階 1:コードを計測する(OSSignposter)
上記の OSSignposter セクションを参照してください。パフォーマンスが重要なコードパスにサインポスト区間を追加します。
段階 2:XCTest パフォーマンステストで自動化する
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()
}
}
使用可能な XCTMetric の種類
- XCTClockMetric — 経過時間
- XCTCPUMetric — CPU 時間、リタイアした命令数、サイクル数
- XCTMemoryMetric — テスト中の物理メモリのピーク使用量
- XCTStorageMetric — ストレージへの論理書き込み
- XCTOSSignpostMetric — サインポスト区間の継続時間(段階 1 → 段階 2 を連携)
- XCTApplicationLaunchMetric — アプリの起動時間(コールド/ウォーム/最適化済み)
- XCTHitchMetric — ヒッチ時間の比率(スクロールとアニメーションのヒッチ)
ベースラインの設定
一度実行した後、Xcode のテスト結果に表示される値をクリックし、「Set Baseline」を選択します。それ以降の実行ではベースラインと比較され、リグレッションが許容範囲(デフォルトは 10%)を超えると失敗します。
アンチパターン:ベースラインのないパフォーマンステスト
// ❌ Test always passes — no baseline set
func testPerformance() {
measure { doWork() }
}
// ✅ Set baseline in Xcode after first run
// Tests fail when performance regresses beyond tolerance
サインポストとテストの連携(XCTOSSignpostMetric)
// 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()
}
}
ステージ 3:本番環境での監視(MetricKit)
MetricKit の統合について詳しくは、axiom-metrickit-ref を参照してください。監視すべき主なメトリクス:
MXAppLaunchMetric— 起動時間の悪化MXAppResponsivenessMetric— ハング率の上昇MXCPUMetric— フォアグラウンドセッションあたりの CPU 時間MXMemoryMetric— バージョン間でのピークメモリの増加
リソース
WWDC:2023-10160、2024-10217、2025-308、2025-312
ドキュメント:/library/archive/documentation/cocoa/conceptual/coredataperformance、/library/archive/technotes/tn2224、/os/ossignposter、/xctest/xctestcase/measure
スキル:axiom-memory-debugging、axiom-swiftui-performance、axiom-swift-concurrency、axiom-metrickit-ref
対象: iOS 14 以降、Swift 5.5 以降
ツール: Instruments、Core Data
履歴: 変更内容は git log を参照してください