🍎

Xcodeでアプリのディスク書き込み量をモニタリングする方法

2021/06/10に公開

はじめに

AppleのドキュメントでReducing Disk Writesというものがあり、アプリのパフォーマンス改善に役立ちそうだったのでこの記事で翻訳していきます。

Reducing Disk Writes

ディスクに書き込むデータの量を減らすことで、アプリの応答性を向上させましょう。

概要

SSD(Solid-State Storage Drive)は、すべてのiOSデバイスに搭載されている恒久的なストレージ技術です。SSDからのデータへのアクセスは、RAMからのアクセスよりもはるかに低速です。データへのアクセスにディスクストレージを多用するアプリは、パフォーマンスが低下するリスクがあります。

ディスクへの書き込みとディスクからの読み込みを交互に行うと、読み込み操作の待ち時間が長くなります。SSDへの書き込みは読み込みよりも遅い操作であり、ディスクは書き込み操作を終えてからでないと、未処理のデータ読み込み要求を満たすことができません。ドライブは、データを読み取る要求を保留中の書き込みの後ろに待ち行列を作り、アプリがデータを待つ時間が長くなります。 iOSは、データを待っている間、要求元のスレッドをブロックします。

iOSがSSDの同じ領域に書き込める回数は、ディスクが消耗するまでの限られた回数に限られます。アプリの書き込み回数が多いほど、ドライブの消耗が早くなります。

XcodeとInstrumentsを使用して、アプリケーションのディスク書き込みパフォーマンスを理解し、応答性を改善してディスクの摩耗を減らす機会を特定します。

ディスク書き込み処理の例外の修正と表示

短期間に過剰なディスク書き込みがあった場合、システムは例外を投げます。Xcode Organizerウィンドウの Disk Writes reportsペインで、アプリのバージョンごとに集約された例外ログを表示するか、 MetricKitでキャプチャします。


Disk Writes reportsペイン

Report Listの各レポートには、例外を発生させた関数呼び出しと、それがディスクの総書き込み数に占める割合が表示されます。レポートをクリックすると、スタックトレースのサンプルのほか、iOSソフトウェアのバージョン、デバイスのモデル、総書き込み量、受信したログの数、14日間のレポート傾向などの詳細がInspectorに表示されます。

修正する例外を選択するには、ディスク書き込みの合計割合、影響を受けたオペレーティングシステムとデバイスに関する情報を使用します。レポートリスト内の特定のレポートの関数シグネチャと対応するスタックトレースを使用して、書き込みの増加の原因となっているコードを特定します。コードを修正したら、レポートを解決済みとしてマークします。

アプリのディスク使用量に関する指標を収集する

Xcode Organizer ウィンドウの Disk Writes metricsペイン、またはMetricKitを使用して、アプリケーションがディスクに書き込むデータの日次量を確認できます。

このペインには、アプリの出荷バージョンの1日あたりの論理ディスクの書き込みがメガバイト単位で表示されます。バージョンを比較することで予想外の増加を検知します。フィルタリングを使用して、デバイス間の違いを見つけたり、典型的な書き込みデータ量 (50パーセンタイル) または最大量 (90パーセンタイル) を表示したりします。MetricKitも同じデータを報告します。

下のスクリーンショットでは、最新バージョンのFrutaで書き込まれた最大のデータ量が、以前のバージョンよりも26.4MB/日多いことがわかります。


Disk Writes metricsペイン

記録されたデータ量がアプリにとって妥当かどうかを評価します。予想以上に多い場合は、データの書き込み頻度が高すぎる可能性があります。例えば、アプリのファイルの合計が100KBで、毎日500MBのデータをディスクに書き込んでいる場合、同じデータを毎日何回ディスクに書き込んでいるのかを調査するとよいでしょう。

バージョン別のディスク書き込み頻度のグラフを使って、ディスク使用量の傾向を確認してみましょう。書き込み回数が毎日増加しているアプリは、より多くのデータを正当に処理しているのかもしれませんし、すでにあるデータを非効率的に処理しているのかもしれません。グラフの山は、ユーザーがアプリの新しいコンテンツを作成またはダウンロードしたことを示しているかもしれませんし、アプリが同じコンテンツを通常よりも高頻度で修正したことを示しているかもしれません。グラフの谷は、アプリが実際に必要とするデータの最小のサブセットを特定するのに役立つかもしれません。

大幅なディスク書き込みの原因となるコードの特定

File Activityテンプレートを使用して、Instrumentsでアプリをプロファイリングしましょう。Instrumentsは、アプリケーションによるストレージの論理的および物理的使用を追跡します。

Instruments - Filesystem Activity

Filesystem Activity instrumentsは、ファイルシステムデータを読み書きしたり、メモリにマッピングしたりするシステムコールの形で、論理的なファイルシステムの使用を記録します。Instrumentsは各イベントをそのサイズ、継続時間と関連付け、バックトレースを使用してアプリケーションのコード内でファイルシステムの使用に関わる箇所を特定します。Disc Usage instrumentsおよびDisc I/O Latency instrumentsは、これらのファイルシステムイベントがどのようにストレージメディアの物理的使用を引き起こすかを示し、ディスクへの読み取りおよび書き込みのサイズとレイテンシーを記録します。

Filesystem Suggestions instrumentsは、ファイルシステムの使用における一般的なアンチパターンを識別し、それらに対処するための提案を提供します。

複数の書き込み操作を一括して行う

同じファイルを何度も開いたり閉じたりすると、ファイルを開いて何度も変更する頻度に比べて、ディスクへの書き込み頻度が高くなります。ディスク使用量の観点からは、1つのファイルに対する小さな書き込み操作をできるだけ多く集めて、1つの大きなバッチとして実行するのが最適です。しかし、書き込みのためのデータの大きなバッチを構築すると、メモリ上のアプリのサイズが大きくなります。これらの2つのリソースの使用を効果的にバランスさせるように、アプリの永続化モデルを設計してください。関連項目: Reducing Your App’s Memory Use

頻繁に変更されるドキュメントには、Core DataまたはSQLiteデータベースを使用する

SQLiteは、ストレージへの効率的なアクセスのために高度に最適化されています。SQLiteデータベースへの接続では、メモリ内キャッシュとディスクへのバッチ式書き込みをうまく利用して、高いパフォーマンスとデバイスへの負担の最小化を実現しています。また、アプリケーションが新しいコンテンツを挿入したり、既存のコンテンツを更新したりする際に、効率的に更新できるように設計されたデータ構造を使用しています。Core Dataは、SQLiteの効率的なディスク使用パターンを利用しており、以下に示すベストプラクティスに準拠しています。

これまで多くのアプリでは、ユーザードキュメントの書き込みにプロパティリスト、JSON、XMLなどのシリアル化されたフォーマットを使用してきました。これらのフォーマットは、バンドルのメタデータのような読み取り専用のコンテンツや、ネットワーク経由での転送には適していますが、ユーザードキュメントの保存には最適ではありません。シリアル化されたドキュメントを変更するには、ファイル全体を書き換える必要があり、操作の待ち時間が長くなり、デバイスの消耗も激しくなります。そのため、ユーザーが編集したドキュメントを保存するにはSQLiteの方が適しています。

SQLiteのベスト・プラクティスを採用する

SQLite の効率性を最大限に活用するために、アプリケーションは可能な限りデータベースへの接続を開いたままにし、明らかに必要な場合にのみ接続を閉じるべきです。SQLiteの接続を開いたり閉じたりするのは高価な操作で、SQLiteは保留中のすべての変更を、一貫性チェックやジャーナリングログなどの追加のメタデータと一緒に書き出すことになります。

複数の INSERT、UPDATE、DELETE文をトランザクションに集約する。SQLiteは、同一ページに対する複数の変更が同一トランザクション内で発生した場合、それらを単一の書き込み操作に集約します。論理的に関連した複数の変更が時間をかけて行われる場合、例えばアプリケーションが1つのドキュメントの複数のフィールドを編集する場合などは、変更をトランザクションに集約します。トランザクションを使用すると、トランザクションが不可分であるという追加の利点があります。SQLiteはすべての変更をコミットするか、データベースをトランザクション前の状態にロールバックします。どちらの場合でも、SQLiteはデータベースを中間的な、潜在的に不整合な状態にしておくことはありません。

データベースのテーブルに適切なインデックスを使用して、検索にかかる時間を短縮し、ディスクへの不要な書き込みを避けましょう。SQLite形式でメッセージを保存しているメールアプリを考えてみましょう。すべての受信メッセージを時系列で表示するために、このアプリはSELECT * FROM messages WHERE folder LIKE 'Inbox' ORDER BY sent_timeというクエリを実行します。sent_timeにインデックスがあれば、SQLiteはメッセージを順番に読み込んで、マッチする行を返します。インデックスがない場合、SQLiteはメモリ上に一時的なB-treeを作成し、テーブル全体を読み込んで、そのB-tree上でソートを行わなければなりません。このB-treeがメモリ内のキャッシュには大きすぎる場合、SQLiteはそれをディスクに書き込み、SELECTクエリの速度を低下させます。

部分インデックス(WHERE句を持つインデックス)は、特定の列にNULLが含まれているものを除外するなど、テーブルのすべての行を考慮しない問い合わせに最適です。部分インデックスは、完全なインデックスよりも少ないディスクスペースしか使用しませんが、インデックスを使用することができるクエリにはパフォーマンス上の利点があります。

WAL(write-ahead logging)モードのジャーナリングを使用することで、同一ページへの複数の書き込みをまとめ、SQLiteの書き込みバリアの使用を減らし、ライターと並行して複数のデータベース読み取りスレッドをサポートできます。

SQLiteがいつ空きリストからページを取り戻すかを指示するために、明示的なVACUUMクエリを発行しないでください。auto_vacuum()プラグマを2に設定し、incremental_vacuum()プラグマを使用して、ページが存在する場合、空きリストからページを削除してください。

Core Dataでは、SQLite永続化ストアを使用する際に、これらのベストプラクティスをすべて実装しています。

急激なファイルの作成と削除を避ける

ファイルを作成すると、ファイルのサイズに加えて8kbのメタデータ書き込みが発生します。これは、iOSがファイルへの参照を含むように包含ディレクトリを更新するためです。また、ファイルを削除すると、ファイルが含まれるディレクトリから削除されるため、このコストが発生します。ファイルの作成と削除が急速に行われると、ファイルシステムに多くの小さな変更が加えられ、パフォーマンスが低下し、デバイスの負担が増大します。

ファイルの名前を変更したり移動したりすると、ファイルシステムのメタデータを更新する際に最大で16kbの書き込みが発生します。この影響は、Foundationの"atomic"オペレーションのうち、NSStringNSArrayNSDictionary、およびNSDataに対する非推奨のwrite(to:atomically:)メソッドを使用してファイルを作成する際の8kbの作成コストに追加されます。これらのメソッドは、一時ファイルを作成し、コンテンツを書き込み、目的のファイルが存在する場合はリンクを解除し、一時ファイルの名前を最終目的地に変更します。これらのメソッドの呼び出しは、同じオブジェクトの write(to:) メソッドに置き換えてください。

明示的なストレージ同期の最小化

fsync(_:)システムコールとfcntl(_:_:)オペレーションのF_FULLFSYNCは、保留中のファイルシステムの変更をUnified Buffer CacheからディスクにフラッシュするようiOSに指示します。完全な同期を実行すると、ストレージに変更を書き込むタイミングに関するオペレーティングシステムの判断が上書きされ、必要以上に多くの書き込みが行われる可能性があります。

アプリはfsyncをバリアとして使用することがあり、iOSは保留中のファイルシステムの変更を完了してから後続の操作を進めます。このような状況では、影響を受けるディスクリプタにF_BARRIERFSYNC fcntlオペレーションを使用してください。F_FULLFSYNCは、データの永続性を強く期待する必要がある場合にのみ使用してください。F_FULLFSYNCは、iOSがデータをディスクに書き込むことをベストエフォートで保証するものですが、突然の電源断の場合にはデータが失われる可能性があります。

ディスク書き込み頻度の悪化を防ぐ

XCTestを使って、コードのディスク使用量を測定するパフォーマンス・テストを書くことができます。XCTStorageMetricのインスタンスをmeasure(metrics:block:)関数に渡すテストを作成します。measure(metrics:block:)のblock引数の中で、ディスクにデータを書き込むコードを呼び出します。このテストでは、ブロックによってファイルシステムに書き込まれたデータの量を測定します。ベースラインの期待値を設定して、書き込まれたデータの量がベースラインを大幅に超えた場合にテストが失敗するようにすることができます。

func testDiskUse() {
  self.measure(metrics: [XCTStorageMetric()]) {
     // A disk-intensive operation
  }
}

おわりに

DeepLにお世話になりながら翻訳しました。技術用語などは英語のままの方がわかりやすいかと思ったのでいくつかのものは英語表示のままにしました。自分が後で思い出すために書いた記事ですが、誰かのお役に立てれば幸いです。
修正や更新などは随時行いますので、誤りや情報が不足しているところがあればご指摘いただけると幸いです。

Discussion