iOS・macOS

LazyVStackの行高推定が収束せずCPU100%でハングしたのでしきい値でVStackに切り替えた

  • macOS
  • SwiftUI
  • Vigilare
  • パフォーマンス

Vigilareで、特定の環境だけ再現するCPU100%のハングを調査しました。実機で採取したCPUサンプルをもとに仮説をひとつずつ検証して外していった結果、たどり着いたのはSwiftUIのLazyVStackが不均一な行の高さを推定する処理が収束しなくなる、フレームワーク側の挙動でした。行数にしきい値を設けてVStackへ切り替えることで、実運用での再現は止まっています。

VigilareFloating Reminders for macOSKeep your Apple Reminders always on top with a floating window for macOS. Works in fullscreen apps, with quick actions, list filtering, and a built-in Markdown editor.

同じ画面では数日前にもLazyVStack起因のハングを直しています。そのときの原因はForEachに渡す配列を毎回作り直していたことで、今回はその修正が入った状態から始まった別の不具合です。

ブログSwiftUIの画面遷移でメインスレッドが20秒ハングしたのでsampleで実測して直したタスク管理アプリVigilareで、詳細画面から一覧へ戻る操作がメインスレッドを20秒以上ハングさせていました。macOSのハングレポートとsampleコマンドで実測した原因は、LazyVStackに渡すForEachのデータソースが描画のたびに新しい配列で作り直されていたことでした。原因の特定から、他の画面に同じ設計ミスがないか確認するまでの調査記録です。

macOSとiOSでコードを共有している構成については別記事で扱っています。

ブログVigilare の macOS と iOS で共有するコアを VigilareKit に切り出したVigilare は macOS と iOS の両方を同じ Apple Reminders 連携と同じタスク管理ロジックで動かしています。共通する部分を VigilareKit という Swift Package に切り出して、Models / Stores / Services / UseCases / ViewModels / DesignSystem を共有し、Views だけプラットフォーム別に持つ構造にしました。何を共有して何を別物にしたか、判断の軸を扱います。

一覧の表示件数は数十件なのにCPU100%で固まる

VigilareはApple Remindersと同期するタスク管理アプリです。あるMacで、Todayタブの表示件数はせいぜい数十件しかないのに、そこでスクロールしようとするとCPU使用率が100%に張り付いて、10分待っても復帰しないという報告が入りました。手元の環境では再現しません。

再現するMacはリマインダーの総数が数千件規模、再現しない手元の環境は総数が少なめでした。表示件数ではなく保持している総件数に依存していそうだ、というのが最初の見立てです。

総件数が原因なら計算量で説明できるはずという前提から始めた

リストの切り替えやアプリ起動時に走る処理を洗い出し、それぞれの計算量を見積もりました。フィルター適用、ソート、グルーピング、キャッシュされたビューモデルの再構築のいずれも、保持している総件数に対して線形の処理で、2乗に比例するような項は見当たりません。数千件規模なら最悪でも数百ミリ秒のオーダーで収まる計算です。

10分待っても復帰しないという症状は、この見積もりでは説明がつきません。計算が重いのではなく、収束しない何かが起きていると見方を変えました。この時点で「総件数が多いと遅い」ではなく「何かが無限に繰り返されている」という方向に疑いを移しています。

実機のCPUサンプルとA/B検証で仮説をひとつずつ外していった

ハング中にsampleコマンドで採取したスタックトレースを解析しながら、思いついた仮説を順番に潰していきました。

仮説検証方法結果
設定読み込みのたびに全件JSONデコードしている処理が重いオンメモリでベンチマーク実装として無駄ではあるが、件数差による増分は数十ミリ秒程度で、10分以上のハングの説明にならない
行の背景色を塗る.background()修飾子がSafeAreaInsetsの再帰解決を誘発している該当箇所を3パターンの実装に差し替えて実機検証3回とも指標が変化せず、後のダンプでは経路自体が出現しないケースもあり否定
グループを識別する型がEquatableに準拠しておらず差分検出が効いていないEquatable準拠を追加して実機検証効果なし
サイドバー側のForEachがレイアウトのたびに新しい配列を作っているサイドバーを表示しないレイアウトでも再現するかを確認サイドバー非表示でも再現し、無関係と判明
Microsoft Defenderとの相性プロセスごとの統計を取得して確認該当プロセスの記録が統計に一切出現せず否定
ウィンドウ幅の判定としきい値の間でレイアウトが往復しているしきい値をまたぐ操作を繰り返して確認ローカルでは再現せず

いずれも「直しても症状が変わらない」という否定的な結果でした。当てずっぽうに直しては実機で確認する、を繰り返すコストは高く、途中から実機ダンプそのものをもっと正確に読む方向に切り替えています。

ハング中だけを切り出したCPUサンプルが単一の経路を示した

それまでのダンプは、リスト切り替えやスクロール操作の過程も含めて採取していたため、複数の要因が混ざって見えていました。デバッガーをアタッチした状態で、ハングが発生した後だけを狙ってサンプルを採取し直したところ、結果は大きく変わりました。

メインスレッドの数千サンプルすべてが、分岐のない単一の呼び出し列を指していました。最終到達点も全サンプルで完全に同一です。

GraphHost.flushTransactions()
  → GraphHost.runTransaction
    → AG::Subgraph::update
      → ViewLayoutEngine.update(layout:context:children:)
        → HVStack.updateCache(_:subviews:)
          → StackLayout.makeChildren()
            → LayoutEngine.layoutPriority()  ← 全サンプルがここ

自社コードのフレームは一度も現れません。純粋にSwiftUI内部のスタックレイアウト処理だけが、同じ場所を指し続けていました。重い処理が続いているのではなく、同じ処理が終わらずに繰り返されていることが、この1枚で裏付けられた形です。

症状の型が一致する公開の不具合報告

このスタックの型と一致する公開報告を探すと、cmuxリポジトリのIssueがほぼ同じ経路(LazySubviewPlacements.placeSubviewsからLazyStack.placeForEachState.forEachItemと続く経路)を報告していました。ただしcmux側の根本原因のひとつは「レイアウトのたびにArray(...)を新規生成し、個々の要素IDは安定していてもコレクション自体の同一性が壊れている」ことで、Vigilareの該当コードはもともとキャッシュ済みの配列をForEachに渡しており、この原因はそのまま当てはまりません。一致していたのはスタックの型だけで、引き金は別ということです。

Apple Developer Forumsにも近い症状の報告があります。ネストしたLazyVStackとアクセシビリティ機能の組み合わせでハングするスレッドでは、Apple DTSのエンジニアが「LazyVStackはビューを必要になったタイミングで確保するが、その最中に状態変化が起きるとレースコンディションになる」と回答し、Feedback番号が発行されています。ScrollView内のLazyHStackで深刻なハングが起きる別のスレッドでも、DTSが「Feedbackを起票してほしい」と回答するにとどまり、公式な原因説明は出ていません。いずれもVigilareで踏んだものと完全に同じ不具合ではありませんが、LazyVStackまわりで処理が収束しなくなる問題が、Apple社内でも把握されている領域であることが伝わってきます。

行数のしきい値でLazyVStackを迂回する

LazyVStackは表示されていない行の高さを、実測済みの行から推定して埋めます。行の高さが不均一だと、この推定とScrollView側のコンテンツサイズ確定が噛み合わず、再計算が再計算を呼ぶ形になっているというのが、ここまでの調査から立てた説明です。

行の高さが不均一なリストで、実測済みの行から未表示行の高さを推定し、その推定を前提にScrollViewのコンテンツサイズが決まり、レイアウトが再計算され、実測値が変わってまた推定をやり直す、という一周が閉じる。調査から立てた説明であり、Apple公式の原因説明が出ているわけではない

表示件数が数十行程度のリストは、LazyVStackの遅延読み込みで得るものがほとんどありません。そこで、行数がしきい値以下のときは高さ推定を持たないVStackで描画し、本当に件数の多いリストだけLazyVStackを使う方式に変えました。

public enum ReminderListLayoutPolicy {
  public static let lazyThreshold = 300

  public static func usesLazyLayout(rowCount: Int) -> Bool {
    rowCount > lazyThreshold
  }
}

適用した直後の実機検証では、ハングが解消しませんでした。原因は行数の数え方にありました。折りたたまれた状態のグループも含めて総行数を数えていたため、完了済み・却下済みのリマインダーが数千件畳まれている環境では、実際に表示されている行が20行程度でもしきい値を超えてLazyVStackのままだったのです。

畳まれているグループは、そもそもForEach自体が構築されないので描画コストがかかりません。そこで、数える対象を展開されているグループの行だけに絞り直しました。

private var mountedRowCount: Int {
  groups.reduce(0) { $0 + (expandedKeys.contains($1.key) ? $1.reminders.count : 0) }
}

同じデータ・同じ環境のまま、コンテナの型だけを切り替えたこの修正で、通常利用でのハングは再現しなくなりました。表示内容と環境をそろえたままコンテナだけを変えて症状が消えたことは、LazyVStackが発生の必要条件だったことの実践的な裏付けにもなっています。

巨大なグループを展開すると経路が戻ってくる

しきい値方式には残る境界があります。畳まれているグループを実際に展開してしきい値を超えると、その時点でLazyVStackに切り替わり、同じ経路が復活し得ます。展開はユーザーの操作で起きる一度きりのイベントで、バックグラウンドで無限に繰り返されるわけではないため、当面は許容範囲としています。

恒久的には、グループというコンテナ分岐そのものをやめて、行の種類を1本の配列にまとめたうえで表示件数に上限を設け、どんな状態でもLazyVStackを使わない構成にする案を検討中です。実装はまだで、まずは現行のしきい値方式を一定期間運用して再発がないことを確認してから着手します。

自社コードのフレームがどのダンプにも一度も現れなかったこと、条件をそろえた検証用アプリでも同じ停止が再現できたことから、SwiftUI側へのフィードバック報告も準備しています。こちらもまだ提出前の段階です。