From Pinning To IORap (1): Pinner

Android にはアプリのディスクアクセスによるレイテンシを減らす仕組みがいくつかある。そのうち “Pinning” と “IORap” の2つと付き合う機会があった。これらは異なる方法で似た目的を果たしており、比べると面白い。この記事ではまず Pinning について紹介する。

アプリの起動とディスクアクセス

Android における “Pinning” は、特定のファイルをメモリ上に固定しておく高速化である。

Android を実行する電話機は多数あるアプリの要求に対して十分な容量の RAM を持たないため、システムは頻繁にファイルをページアウトする。一方 Android の仮想記憶は ZRAM のような例外を除きスワップを持たない。従ってページアウトできるのはコードやレイアウトの XML などクリーンなページだけだ。

だからコードやデータはすぐページから追い出される。ユーザがアプリを切り替えつつ電話をさわるのにあわせ、キャッシュされていたアプリのプロセスは再開されるたびディスクを読んでコードなどをページインしないといけない。プロセスが LMK に殺されていたら更にたくさんのファイルを読み直す羽目になる。このディスクアクセスがアプリの起動、に限らず様々なアクションを遅くする。

Pinning

Pinning はディスクアクセスを減らすために特定のファイルをメモリ上に固定 (pin) するシステムサービスの機能。Git の履歴 によれば 2016 年に導入された。システムがどのファイルを pin しているかは dumpsys コマンドで調べることができる。

$ adb shell dumpsys pinner
/system/framework/arm64/boot-framework.oat 12587008
/system/framework/framework.jar 24817664
/system/framework/oat/arm64/services.odex 26456064
/system/framework/services.jar 11083776
/system/framework/arm64/boot.oat 3596288
/system/framework/arm64/boot-core-libart.oat 1441792
/apex/com.android.runtime/javalib/core-oj.jar 4411392
/apex/com.android.runtime/javalib/core-libart.jar 2953216
/apex/com.android.media/javalib/updatable-media.jar 61440

Camera uid=10114 active=false
  /system/product/priv-app/Snap/Snap.apk 9519104
  /data/dalvik-cache/arm64/system@product@priv-app@Snap@Snap.apk@classes.vdex 36864
  /data/dalvik-cache/arm64/system@product@priv-app@Snap@Snap.apk@classes.dex 106496
Home uid=1000 active=true
  /system/priv-app/LineageSetupWizard/LineageSetupWizard.apk 4415488
  /data/dalvik-cache/arm64/system@priv-app@LineageSetupWizard@LineageSetupWizard.apk@classes.vdex 20480
  /data/dalvik-cache/arm64/system@priv-app@LineageSetupWizard@LineageSetupWizard.apk@classes.dex 45056
Total size: 101552128

システムサービスを実装する jar ファイルをはじめ、システムの中で頻繁に使われそうなライブラリが pin されている。つまり基本的にはシステムにはじめから入っているファイルを pin する。ただ例外として、Home (Launcher) アプリと Camera アプリ の APK や dex ファイルも pin される。

実装

Pinning は System server プロセス内の PinnerService が実装している。1000 行程度のシンプルなクラスで、設定ファイルに列挙されたファイル名に基づきファイルをひらいて mlock() システムコールでメモリ上に留める。

Pin の対象となるファイルのリストはデバイス単位の設定ファイル(XML resource)に定義されている。たとえば Pixel 4a の 設定はこれ。Camera アプリの pinning は PackageManager にデフォルトのカメラアプリの問い合わせた結果を pin するなど少し入り組んでいる。デバイス組み込みに限らず、ユーザの選んだアプリが pin される。

サブセットの pinning

APK の pinning はそれ以外のファイルより入り組んでいる。具体的には APK のサブセットだけを pin する機能がある。対象 APK 内の pinlist.meta ファイルを探し、このファイルが指定するバイトレンジを mlock() する。ファイルがなければ他のファイルと同じく APK をまるごと pin する。この仕組みは 2018 年に導入されたらしい。

バイトレンジ指定ファイル pinlist.meta の生成自体にも少し手間がかかっている。この pinlist は APK 内の特定のファイルの位置を指定している。つまり APK のレイアウトに依存している。一方で pinlist 自体も APK に含まれている。この鶏と卵問題を解決するため、pinlist は APK の署名ツールによって生成されている。つまり APK のレイアウトを決め、そのレイアウトにもとづく pinlist を生成し、APK (zip file) 末尾にそのファイルを足し、それから署名を行う。

Profile-Guided Pinlist

森田が仕事で手伝っている電話機組み込みのカメラアプリも pinning の恩恵を受けている。また巨大な APK をまるごと pin するのを避けるため Pinlist による subset pinning も使っている。

この subset pinning の導入はスムーズではなかった。最初に試したところ pinning の効果が消えてしまったのだ。仕方ないので本当の望んだファイルが pin されているのかをデバッグした。具体的には以下のような方法をとった:

  • アプリを起動+停止(stop the activity)し、その状態でシステムの page cache をクリアする。
  • Root 権限のコードで対象の APK を mmap() し、mincore() システムコール を使いながらマップしたファイルの中身が実際にメモリ上に存在するのかをページ単位で調べる。そして結果を記録する。
  • メモリ上にあったページの場所と APK のファイルレイアウトを照らし合わせ、どのファイルがメモリ上にあるかを列挙する。

これでメモリ上にあるファイル = pin 対象がわかる。

結局、ビルドシステムの設定間違いが複数組み合わさって APK 内の重要なファイルが pin されていないことがわかった。設定を直し一件落着。

バグを直したついでに pinlist の生成元となるファイルの指定リスト com.android.hints.pins.txt を眺めていると、その内容も見直したほうがいいことに気づいた。たとえばアプリの起動中にロードされるライブラリの一部が列挙されていない。そもそもこのリストを手で保守するのは筋が悪い気がする。自動化できないか。

先のデバッグ手法を少しひねると、アプリが起動中にアクセスする APK 内のファイル一覧すなわち pin すべきファイルを調べることができる:

  • Pinning を無効にする。
  • Page cahce をクリアし、アプリを起動する。
  • Root 権限のコードで対象の APK (…略…) の中身が実際にメモリ上に存在するのかをページ単位で調べる。そして結果を記録する。
  • メモリ上にあったページの場所と APK のファイルレイアウトを照らし合わせ、どのファイルがメモリ上にあるかを列挙する。

ここで列挙されたファイルはアプリが起動中にアクセスすることでページインした。だから pinning の候補と言える。こうして実データ (profiling) に基づき機械的に pins.txt を生成できた。(実際は優先度を決めるなどもうひと工夫必要。)

欠点

Profile Guided Pinning には欠点もある。

  • そもそも pinning が高価である。たとえば APK Play Store 上限の 100MB だとしたら、4GB RAM のデバイスならシステムの RAM 2.5% を常時専有する。ここまで巨大な APK はあまりないが、それなりの大きさがある。アプリ側でサブセットを小さく絞りコストを抑えている。
  • Pinlist は APK のビルド時に値が決まるため、デバイスや設定の違いからくるロードすべきファイルの差異を表現できない。 一番新しい(したがって使える機能の多い)デバイスで採取したデータを使っている。
  • アプリの起動完了からプロファイルの収集の間にラグがあるため、起動後にアクセスしたファイルもプロファイルに含まれてしまう。 明らかな不要ファイルのうちサイズの大きなものを手動で排除している。(排除リストを保守している。)
  • Profiling すなわち実機でのコード実行が伴うためリストの生成をビルドの一部として自動化できず、ファイルの更新がめんどくさい。 森田がリリース前に手動でスクリプトを動かし、生成された pins.txt の差分を目視でレビューのうえチェックインしている。やりたくない。

こうした問題の多くを解決しうる Platform の新機能が次に紹介する IORap である。

APK, ネイティブライブラリ、bionic

Android は zip ファイルである APK の中身をファイルに展開することなく直接ロードする。 XML リソースや各種 assets は Java の platform API を介するためこれらの API が zip のオンデマンド展開を実装すればいい。しかし APK のコンテンツ全てが Java API を通り過ぎるわけではない: JNI などにロードされるネイティブライブラリの .so ファイルは C/C++ によって dlopen() される。APK を展開いないままその中身を dlopen() する方法は自明でない。

Android は C 標準ライブラリの実装 bionic が zip 内ファイルの dlopen() を直接サポートすることでこの問題を解決した。(linker.cpp:open_library_in_zipfile.) この機能は 2015 年頃に実装されている。もともとは 2013 年に NDK の一部として実装されたようだが、現在は見当たらない。なぜか Chromium がフォークを保守している

APK からの直接 dlopen() をサポートするには:

  • APK 内で .so ファイルを圧縮しない
  • ファイルをページサイズ (4kb) に align しておく

といった制限がある。基本的には Gradle が良きにはからってくれるらしい。

参考リンク