高速化日記 (5) - JNI

カメラは画像処理とかの性質上 C++ のコードを使いがちなのだが、各チームどころか各個人がアドホックに使うものだから .so ファイルの数が大変なことになっていた。社内のグループで "そうはいってもネイティブライブラリ減らすのって大変ですよね。我々も N 個から N-1 になかなかできずに苦労してます" というコメントがあったが「いや我々 M (>>>超えられない壁>>> N ) 個くらいあるのでとりあえず low hunging fruits からやっつけますわ」などと会話をしたら、M の大きさに相手は戦慄していた。

そんなん Blaze (Bazel) のせいで俺のせいじゃねーーー。とおもって放置していたのだが、全社的に性能問題への締め付けが厳しくなった結果 OS の中の人からチクチク言われることが増え、仕方ない尻拭いするか・・・と直したら色々速くなった。あ、ごめん・・・。みたいな気分。


以下ではロードする .so の数が多いと何が問題なのかという世の中の大半の人にとっては極めてどうでもいい話をします。

まず前提としてこれらの .so は必要な依存関係を全て含んだ自己完結 SO である。OS 付属のライブラリを除くとぜんぶ必要なコードが入っており、ある SO が他の SO のシンボルを参照したりはしない。(一つの理由としては、そういうのが苦手なビルドシステムを使っているため。) 結果としてけっこうコードが重複している。コードの重複はヒープを圧迫することはあれ必ずしも性能低下につながるとは限らないが、こいつらは傾向として雑に書かれた C++ のため、static initializer がけっこうある。ABSL みたいな共有コードの initializer は何度も走る。まあ、無駄。

また SO をロードする bionic のローダは global lock を持っている。symbol table を保護する必要があるので割と仕方がないとは思うが、たとえば Dagger の object graph provisioning が複数のスレッド上で並行しておこると lock contention になる。そして初期化のコードはがんばって CPU を使うべく並列化してあるので、実際ばんばん衝突する。しかもライブラリを読むとかいうのはしばしばファイルアクセスが伴うのですごい待たされる。厳しい。

JNI メソッドの symbol lookup は、それなりにコストがある。 ベストプラクティスは RegisterNatives という JNI の API を使った early binding だが、export するシンボル名を convention に揃えることで Java runtime に late binding させることもできる。その方がコードを書くのはラクなので、雑なコードはしばしば late binding になっている。

がしかし!その late binding は当然 symbol lookup が伴うので潜在的に lock contention の可能性がある。というか dogfooding 上司のよこした on device trace をみるとめっちゃ競合してる。厳しい。しかし知るかよーーさぼんじゃねーーー!

しかもしかも、JNI の symbol 名は SO 単位でテーブルが作られ、lookup はそのテーブルのリストを順にスキャンしていくのである! (参考: FindNativeMethodInternal()) そうですよねー常識的に考えて O(M*log(S)) ですよねー・・・つらし・・・俺のせいじゃねー・・・。

手抜き C++ プログラマたちのせいで OS の人から突き上げられるのにいいかげんイライラが限度に達し, 半月かけてチーム内の SO をマージした。レビューをたとむと「え、それが遅いって証拠あるの?」とかいってくる子もいて心底うんざりしたが、「OS 方面からのお達しですので」と言い張り心を無にして完遂。組織の壁を超えた連結は諦めつつ、そっちはそっちでまとめてね、と協業チームに頼んでくっつけてもらうくらいはした。なお GCAM の皆さんとは仲良しかつ自分がファンなのでこっちでやってあげた。ははは。そういうもんです。

なお Chrome のようにスパルタネイティブ集団がつくるアプリにはこうした問題は一切ない。謎の tooling で自動的にこうした問題が解決されている。すばらしい。(と一瞬思ったが、あるときじっとコードを眺めていたら .java のコードを C preprocessor に通しているのを発見して目が覚めた。ガラケーアプリかいな。)


こうした問題がおこるのは、基本的には C++ を画像処理 DSL だとおもっている(間違ってないけど)困った人たちのせいなのだが、その雑さを許してしまう Blaze/Bazel というビルドシステムのせいでもある。

Gradle と違い、Bazel で JNI のコードを書くのはすごいラク。Bazel はクロス言語のビルドシステムという顔をしているが、現実的には C++ と Java のビルドシステムである。だからこの二つの言語を混ぜるのは超得意。ただ混ぜるのが簡単すぎるので、ピンポイントでちょこっと C++ で NDK の API を叩きたい誘惑に屈しがち。結果として細かい SO が大量にできることがある。

いや出来ないよね普通。おかしいよね。ほんとに。

なお最近の Bazel は cc_libraryandrod_binary の依存関係のどこかに入れておくとそれをかき集めていいかんじに単一の SO を作ってくれる。しかしこれは割と最近の機能なので、自分たちのように歴史あるコードベースは従来の suboptimal な方法に従っているのだった。まあ実際に遅延ロードしたいネイティブコードもあるので、問答無用で一つにまとめられるのはそれはそれで困る。

まあどうでもいいです。人々は油断するとすぐ新しい SO をつくってくるので本当に腹が立つ。たぶん linter なり presubmit check なりで取り締まる必要があるのだろうな。そんなタスクを登録したまま忘れてた。来週気が向いたらやろう。

高速化の達成度と自分の達成感が噛み合わない mixed feeling な仕事だった。