KotenOCRは古典籍のOCR処理にONNX Runtimeを使用しているiOSアプリです。6つのONNXモデル(合計約230MB)を搭載しています。ダウンロード数が300に達した時点で、クラッシュ率が6.7%(20件)に上昇していることが確認されました。Xcode Organizerにクラッシュログが表示されなかったため、原因の特定には別のアプローチが必要でした。

調査体制

4つの調査方針を並行して進めました。

  • メモリ・モデルサイズの調査
  • 画像処理パイプラインの調査
  • ONNX Runtimeのスレッドセーフティの調査
  • カメラ・UIライフサイクルの調査

原因の特定

調査の結果、以下の4つの問題が確認されました。影響度の大きい順に記載します。

1. 起動時の全モデル一括ロード

6つのONNXモデルをアプリ起動時にすべてメモリに展開していました。ディスク上では約230MBですが、ONNX Runtimeがモデルをメモリに展開すると1.5〜2.5倍程度に膨張するため、RAM上では推定350〜550MB程度を占有していたと考えられます。

iOSにはjetsam(カーネルレベルのメモリ監視機構)があり、アプリ単位のメモリ上限を超えるとプロセスが強制終了されます。デバイスごとのRAMとアプリあたりのメモリ上限の目安は以下のとおりです。

デバイスRAMリスク
iPhone 82GBCRITICAL — iOS 16対応機種で最もメモリが少ない
iPhone X / XR / XS3GBHIGH
iPhone 114GBMEDIUM
iPhone 12以降4〜6GBLOW

iPhone 8やiPhone Xでは、アプリあたりのメモリ上限が200〜300MB程度とされており、全モデル一括ロードだけで上限を超過する状況でした。

2. DEIMDetectorの画像前処理

DEIMDetectorが入力画像をリサイズせずにフルサイズのまま正方形パディング用のCGContextを生成していました。12MPの写真(4032×3024)の場合、4032×4032ピクセルのCGContextが作成され、これだけで約63MBのメモリを消費します。

3. 無制限の並列認識タスク

withThrowingTaskGroupで最大100件の認識タスクを同時に実行可能な状態でした。各タスクがモデル推論時にメモリを確保するため、並列数に比例してメモリ使用量が増加します。

4. メモリ警告のハンドリング不在

UIApplication.didReceiveMemoryWarningNotificationを購読しておらず、iOSからのメモリ警告を受け取っても何も対処していませんでした。jetsam発動前の最後の解放機会を逃していたことになります。

実施した修正

Lazy Model Loading

起動時にすべてのモデルをロードする方式から、選択中のモードに必要なモデルのみをロードする方式に変更しました。初期メモリ使用量が約350MBから約150MBに減少しました。

func loadModelsForMode(_ mode: OCRMode) {
    // 選択中のモードに必要なモデルのみロード
    let requiredModels = mode.requiredModelKeys
    for key in requiredModels {
        if sessionCache[key] == nil {
            sessionCache[key] = try? createSession(for: key)
        }
    }
}

画像前処理の改善

DEIMDetectorの前処理で、パディング前にリサイズを行うように変更しました。メモリ使用量が63MBから約2.4MBに削減されました。

// 修正前: フル解像度のまま正方形パディング
// let paddedSize = max(image.width, image.height) // 4032

// 修正後: 先にリサイズしてからパディング
let resized = resize(image, to: modelInputSize) // 640x640等
let padded = pad(resized, to: targetSize)

並列処理のバッチ化

withThrowingTaskGroupの同時実行数を最大4に制限しました。

await withThrowingTaskGroup(of: RecognitionResult.self) { group in
    let maxConcurrent = 4
    for (index, region) in regions.enumerated() {
        if index >= maxConcurrent {
            _ = try await group.next()
        }
        group.addTask {
            try await self.recognizeText(in: region)
        }
    }
}

メモリ警告ハンドラの追加

didReceiveMemoryWarningNotificationを監視し、非アクティブなモードのモデルを解放するようにしました。

NotificationCenter.default.addObserver(
    forName: UIApplication.didReceiveMemoryWarningNotification,
    object: nil,
    queue: .main
) { [weak self] _ in
    self?.releaseInactiveModels()
}

MetricKitによるクラッシュ監視

Xcode Organizerにクラッシュログが表示されなかった問題への対策として、MetricKitを導入しました。Apple純正のフレームワークで、jetsam起因の終了やメモリ使用量のレポートをアプリ内で受け取ることができます。Firebase Crashlyticsの代替として検討した結果、依存ライブラリを増やさずに済むMetricKitを採用しました。

class MetricsManager: NSObject, MXMetricManagerSubscriber {
    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            // メモリ関連のメトリクスを記録
            if let memoryMetrics = payload.memoryMetrics {
                log(memoryMetrics.peakMemoryUsage)
            }
        }
    }

    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads {
            if let crashDiagnostics = payload.crashDiagnostics {
                // クラッシュ情報を記録
            }
        }
    }
}

調査を通じて得られた知見

  • サポート対象の最も古いデバイス(iPhone 8)でテストする重要性が確認されました。シミュレータや新しいデバイスでは再現しない問題が存在します。
  • ONNXモデルのメモリ上のフットプリントは、ディスク上のサイズの1.5〜2.5倍程度になることが調査で確認されました。230MBのモデル群が350〜550MBのRAMを消費していたことになります。
  • Swift ConcurrencyのwithThrowingTaskGroupによる並列推論は処理速度の面では有効ですが、メモリ制約のあるモバイル環境では同時実行数の上限設定が必要です。
  • didReceiveMemoryWarningNotificationのハンドリングは、jetsam発動前にメモリを解放できる最後の手段として機能します。