高速化日記 (3) - Pinning

ここ半年くらい断続的にアプリの "pinning" の面倒を見ている。

Android には pinning という仕組みがあり、一部アプリの APK および dex が常にメモリ上に "pin" される。つまり mlock() される。これのおかげで pin されたアプリはいつも最小の I/O で起動できる。カメラも pin されるアプリのひとつである。(なお実際にカメラが pin されるかどうかは電話機ベンダの設定次第。あとデバイス付属のアプリじゃなくてもデフォルトカメラに設定すれば pin される。)

mlock() は荒々しい特権システムコールで、下手に使うと仮想メモリを使い切りシステムを壊してしまう。なので pin される APK のサイズにはアプリあたり 80 MB の上限がある。カメラはあるときからこの上限に達している。そうした巨大 APK への処置として、アプリは APK のどの部分を pin するかアドレスのレンジを指定できる。pinlist.meta というファイルを使う。この仕組は去年くらいにはいったっぽい

この pinlist.meta は offset と size の列をバイナリで並べた低レベルなフォーマット。アプリ開発者は pin したい候補ファイルのパスを設定ファイルに列挙しておき、ビルドシステムがそれを pinlist.meta にコンバートする。ぶっちゃけこの仕組はカメラのために入ったものである、と思う。なぜなら他のアプリの APK はもうちょっと常識的なサイズなので range based pinning は必要ないからである。

あるとき「Systrace が I/O 待ちで真っ赤じゃん、ほんとに pinning 効いてるのか調べてくれ」と頼まれた。いろいろつつきまわったところ、大きな I/O の要因たる共有ライブラリ (.so ファイル) が pinning されていないことがわかった。なぜなら .so ファイルは dlopen() するために APK からファイルシステムに展開され、pinning の対象から外れるためである。

ただこれを避ける裏技(ではなく推奨設定)があり、その設定を使うと .so を APK から直接ロードできる。(bionic がサポートしている。) .so を pin するにはこの設定を有効にして APK 内に .so を留めておく必要があるのだが、 自分たちのアプリは設定が間違っていた。

というわけで設定を直したのが最初の仕事。


設定を直したところ .so が pinning されるのはいいのだが、それらのライブラリが巨大すぎて 80MB の pinning budget を使い切ってしまった。自分たちのアプリの APK はでかいので無理もないのだが、一方で予算がたらず必要なファイルを pin できていない懸念もある。pin するファイルの候補リストは適切なファイルを適切な優先順位で列挙しているのだろうか。

というか、いくら APK がでかいとはいえほんとにアプリの起動に APK のコンテンツを 80MB も使うの?アプリの起動時に実際に読まれてる APK のサイズわかってないじゃん?

などの疑問に答えられないかと調べて知ったのが mincore() というシステムコール。これはある仮想メモリのアドレスが実際にメモリの上にあるのかを教えてくれる。

つまり APK を mmap() してそれを mincore() で probe すれば APK のどこがメモリの上にあるかわかる。具体的にはまずアプリの pinning を無効化し、page cache をクリアし、アプリを起動し、それからこの mincore probe をすればアプリの起動時にアクセスされる APK コンテンツのサブセットを特定できる。このアドレスを APK (zip) ファイルのレイアウトと照合すれば、起動にアクセスされた APK 内のファイル名がわかる。

というわけでそんなスクリプトを書いてファイルを列挙してみた。だいたい期待したとおり動いているっぽい。実際は 4kb の page size より小さいファイルもいっぱいあるので若干ヘンなファイルも紛れ込んでいるが、すくなくとも巨大な so たちはそれっぽいかんじで列挙される。のでこれらの手順を適当に自動化し、pin するファイルの候補リストを生成できるようにした。結果としてリストからアクセスされていない .so を除去し、ひっそり使われている .so や協業しているチームがつっこんだ ML のモデルファイルなどを取り込むことができた。


これで「pin されるべきだしできるのにしてないファイル」はなくなったが、相変わらず 80MB の予算は使い切っている。Mincore probe によると実際にアクセスされる APK コンテンツは 50-60MB くらい。実行プロファイルに基づいてファイルを選んでいるなら予算が溢れるのはおかしい。

結論からいうと原因は .so ファイルへのアクセスが sparse なことだった。つまり pin した巨大な .so のコンテンツのうち起動時にアクセスされるのはごく一部で、pinner は使われないコンテンツもロードしてしまっている。その無駄領域が予算を浪費していた。

Platform は APK 内のアドレスに基づいてコンテンツを pin できる。問題はファイル名ベースの候補リストである。だから理論上は候補リストからの生成をバイパスし mincore probe の結果アドレスをそのまま pinlist.meta にすればよい。

が、この方法は運用がきびしい。APK のコンテンツをちょっといじるだけで結果が無効になってしまうし、結果を常に最新に保とうとするならビルドプロセスにアプリのプロファイリングを織り込まないといけない。PGO みたいなもん。しかも自分たちのアプリはエミュレータで動かないため実機が必要。大仕事すぎる。

はー下手にやってファイル更新係とかになるのもイヤだし無理だわー・・・。重要なファイルは pin されたからまあいいいや。このプロジェクトは切り上げ、他の仕事をやることにした。


しかし最近になってどうも OS の消費メモリ多すぎが問題になったらしく、おまえらその 80MB 全部使うなよ、と苦情がきた。自分としては起動が遅くなっても困るので適当にはぐらかしていたのだが、よほど困っているらしく偉い人から助っ人プログラマが派遣されてきた。現状を教えてくれというので上のようなノートを書いて渡す。

しばらくするとパッチがやってきた。最初のパッチは自分の書いた自動化スクリプトの修正だった。Zip のレイアウト計算にバグがあり、コンテンツのアドレスに Zip ヘッダが含まれていた。.so ファイルはコンテンツが page aligned なため、ヘッダを含めると余計なページがカウントされてしまう。影響は軽微だが、よくみつけたなと感心した。

けれど本当にびっくりしたのはその後に続いた変更だった。このひとスパースな .so アクセスによる無駄の問題を解決したである。具体的には .so ファイルのうちコードでない部分を除外する仕組みが追加された。

この変更、まずビルドプロセスの中から readelf コマンドを使い .so を読んで不要セクションのアドレスを特定する。そしてファイル名から APK のアドレスをつくるビルドシステムのステップを拡張して不要セクションの情報を最終的な pinlist.meta に反映する。これならビルド時にアプリを起動するみたいな大掛かりなことをしなくてもスパースアクセス問題を解決できる。結果として pin size は予算を大幅に下回り、OS は使えるメモリが増えてハッピーエンドとなった。

このアプローチの圧倒的な正しさ。それに自分は readelf なんて知らなかったしビルドシステムをいじろうと考えもしなかった。ひさしぶりに優秀なプログラマの素晴らしい仕事ぶりを目にして心が洗われた。

このひと社員名簿をみたかんじでは別に OS や最適化の専門家というわけではなさそうなのだけれど、わざわざ刺客として送り込まれてきたくらいだからなんかあるんだろうな。なんにしてもすごい。あとで peer bonus おくってちゃんと appreciate せねば。


追記

「神の仕事でしたわ」と西海岸風に賞賛を書いた peer bonus 送信完了。なお前回 peer bonus したのは N 年前だった。もっと活用せねばならぬと反省。