最適化に初めて取り組んだ話

しばらく最適化沼に浸かってたときの回想です。

きっかけ

ひょんなことからiPhoneXSからPixel4aに機種変更したのですが、これがパフォーマンスの悪化に気付くきっかけでした。何かうちのアプリだけやけに重くなってね…?と。ミリシタは変わらずサクサク動くのに…。

新しめのiPhoneドッグフーディングしてるとマジでパフォーマンスの悪化に気付かないなーと…。iPhoneという良く出来たハードにソフトウェアエンジニアは少し甘えすぎてるのかもしれません。

もしチーム全員が高級機だったら、絶対感覚が狂います。 頭ではわかってても、感覚としては低性能機のことがわかりません。 実感としてストレスを感じていないと、 「これは直さないとダメだよ!遊べないよ!」という切実さは出てこないのです

ADBでAndroid機の解像度を変える方法と、その用途 - KAYAC engineers' blog

推測するな計測せよ

って偉い人が言ってました。実際ゲームや動かすハードによって発生するボトルネックの箇所は多岐に渡るんで、計測しないことには分からないですね。

実機ビルドにUnityProfilerを繋いで眺めた感じ、全体的にCPUバウンドが酷いなぁという所感だったので、そのあたりを中心的に見ました。GPU処理は既に最適化されて大人しかったので、ちょっと残念(?)

CPUバウンドとの戦い

多量のGC.Alloc

ボトルネックの計測を始めてから目に付いたのが、アイコンをズラーッと並べる一覧UIのGC.Allocが大分酷いことになってました。大体ボトルネックが出る箇所って重たい処理 x 数の暴力ってところだったりするのですが、一覧UIは割とそれが出やすい典型的な場所ですね。しかも運営していくゲームってコンテンツがどんどん追加されてって、アイコン量がスケールしていくので、放置しておくと割と大変なことになってしまいます。と、物知り顔に語ってますが、自分も一覧系UIの怖さを知ったのは割と最適化に取り組んでからだったりします…。まぁスケールしても大丈夫なように組んでおくべきですね。

で、GC.Allocなんですが、これは増えすぎるとパフォーマンス上あまり良くないです。頻繁にGC.Collect(Stop the World)が走ることになるので体験も良くないし、参照型を扱うので処理時間も増える傾向に有るなぁと。CPUの実行ステップ数を減らすというよりは、GC.Allocを減らすのが最適化に繋がりましたね。数世代前のAndroidで、50MBぐらい減らして2秒は縮みました。

GC.Allocの減らし方ですが

1. 重たい処理をキャッシュしてしまう
2. 地道にアロケーション減らしていく

2.は日頃から意識したいところですね…。Unityは気をつけて書かないとヒープメモリを断続的にバクバク食ってしまうようなコードを書いてしまうので。

で、割とそんな細かい最適化ではどうしようも無いところまで悪化してたので、1のキャッシュを選択しました。丸っとキャッシュしてしまえば、都度再計算する必要も無くなりGC.Allocも大幅削減!!やったー!!というそんな良いことばかりじゃなくて、キャッシュって適切に実装しないとバグの温床*1になるので、実装コストとしては結構高かったです。

UnityAPI Call の削減

AddComponentが結構インパクトでかかったです。UIの基盤Componentみたいなものって良く有ると思うんですが、それが各UIで動的にAddComponentされてて、画面にズラーッと並ぶと数百AddComponentが一気に呼ばれるみたいなボトルネックになるんですよね。Componentが実際に必要になるまでAddComponentを遅延させる、事前にPrefabにComponentをアタッチするといった手段などで、一気にCallされるのを防いだりしました。

AddComponentのオーバーヘッド↓

1. Finding the component's script in the script cache, by name. This might also incur allocations if it's not already cached.
2. Allocating the memory for the MonoBehaviour.
3. Notifying other attached components that a new component has been added. The attached components can perform actions when a known component is added. The amount of work performed here depends on the number and type of attached components. E.g. a rigid body needs to know if a collider is added.
4. Running the new component's Awake method.

github.com

その他、気になったUnityAPI Callによるボトルネック。Unityさんが何かいい感じにやってくれるみたいなぼんやりとした認識だと危ういな…と

最適化の前に大鉈を振るえないか

そもそも部品を最適化する前に、それ必要か?みたいなところはよくよく考えたほうが良さそうです。

  • この機能が本当に居るのか?
  • 画面を構成するのに不要な処理では無いか?(本当に必要なものを逆算する)

前者は、他セクションと要相談ってところですが、後者に関してはリファクタリングの範疇なのでガンガンやっていけるかなと。最短のルート見つけた時はドーパミンがどばどば出ますね(最適化あるある)

最適化の難しさ

チームとして、継続的に取り組む必要が有る

運営型のゲームは、アップデート毎に様々な機能が足されていきます。ので、一旦最適化したところで、また悪化するなんて日常茶飯事です。(単発Callだとそこまで大したことないけど)重たいメソッド x (リスト系UIによる)数の暴力みたいな掛け算がアップデートによる機能追加で成立しちゃったとか、データ量のスケールでパフォーマンスが悪化するとか、そういったところですね。

これに気付くにはパフォーマンスの可視化が必要かなぁとは思ってます。手間もかかるしロースペックな実機で手触り確認するエンジニアはそんなに多くないので…。毎日目にするところに、わかりやすい形でパフォーマンス値が見れれば良いな、という今後の課題ですね。UPR的なパフォーマンスモニタを入れてるところは多かったりするんでしょうか…?

upr.unity.com

まとめ

運営していくゲームは一度最適化やったから終わりとならないのが、難しいところかなと思いました。個で出来ることには限度が有る…。

ただ、最適化面白かったのでまたやってみたいです。今度はグラフィックの最適化とかもやってみたいですね。

*1:必要な時にキャッシュクリアがされないなど