Edited at

Go で作ったゲームを Android/iOS 対応させた話 (3)

More than 1 year has passed since last update.

Go で作ったゲームを Android/iOS 対応させた話 (2) の続き。


日記


2016-05-29


スピード改善のための手動プロファイリング

前回も述べたとおり Nexus 5x においていのべーしょんが 30FPS しか出ず、何かがおかしい。描画は特別複雑なことをしていないので、 60FPS くらい出てほしいものである。

スピード改善といえばプロファイリングだが、 Go と Java でできたアプリのプロファイリングはかなり難しい。 Go でできた部分はネイティブバイナリになるので、 Android Studio 付属のプロファイラが全くつかえないのである。 runtime/pprof も可能かもしれないが共有ライブラリで使えるのかよくわからなかった。今回はプロファイラーツールを使うのを諦め、プログラムに直接時間を測定するコードを埋め込む、という原始的な手法を用いた。時間の掛かりそうなコードの前後で時間を測定し、特定の関数コールが遅いと判断したらその関数の中でさらに遅そうな関数コールを特定し、原因を絞っていくという方法である。

さてプロファイリングする上で罠がいくつかあったので紹介する。


OpenGL 関数の強制ブロッキング

golang.org/x/mobile/gl の関数は、 Worker の存在を見れば分かる通り、 OpenGL の関数呼び出しとは独立した別の goroutine 上で実行される。このライブラリの OpenGL 関数にはブロッキングな関数とノンブロッキングが関数があり、そのまま時間を測定してしまうと「ブロッキングな関数だけ妙に時間がかかる」という意味のないプロファイリング結果が得られてしまう。ブロッキングな関数が呼ばれた時に、キューに貯められていたノンブロッキングな関数がまとめて実行される可能性があるからだ。

この問題を解決するには、関数を強制的にブロッキングにしてしまえばよい。 gldebug ビルドタグをつけるとブロッキングになるのだが、 OpenGL 関数を呼ぶごとに glGetError を呼んでしまうので、パフォーマンス測定には向いていない。ではどうするかというと、 x/mobile/gl を書き換えるのである。以下はその修正差分である:

diff --git a/gl/work.go b/gl/work.go

index 574b03b..cdb3cb6 100644
--- a/gl/work.go
+++ b/gl/work.go
@@ -95,6 +95,7 @@ func Version() string {
}

func (ctx *context) enqueue(c call) uintptr {
+ c.blocking = true
ctx.work <- c

select {

要するに enqueue 直前で関数をブロッキングにする。 x/mobile/gl をアップデートしたりアプリをリリースするときには元に戻しておくのを忘れないようにすること。


プロファイリング結果の安定化

さあこれで、ある二点の時刻をとって比較すればプロファイリングできるぞ、と思いきや、実際に Android 実機 (Nexus 5x) でやってみると、プロファイリング結果が不安定であることがわかる。全く同じコードなのに 1 回目と 2 回目の測定で時間が倍くらい違うことはザラである。また USB を抜く・抜かないも大いに影響があるようで、 USB を抜いて充電しない状態だとクロック数がこれまた落ちる。同じ環境で合わせたつもりでも、 2 回の測定で結果が異なってしまい、遅い関数コールの特定を間違えてしまうのだ。

じゃあどうするのか。結果を安定させるために測定する回数を増やすのはもちろんのことだが、 2 点ではなく 3 点で比較し、ある関数コールが他部分よりも相対的にどれくらい時間がかかるのか、を調べるのである。

var (

count = 0
d1 = float64(0)
d2 = float64(0)
)

func Foo() {
n0 := time.Now().UnixNano()
// ...
n1 := time.Now().UnixNano()
// 測定対象
n2 := time.Now().UnixNano()
defer func() {
d1 += float64(n1 - n0)
d2 += float64(n2 - n0)
count++
count %= 1000
if count == 0 {
log.Printf("n1-n0 vs n2-n0: %.2f vs %.2f\n",
d1 / 1000 / float64(time.Millisecond),
d2 / 1000 / float64(time.Millisecond))
d1 = 0
d2 = 0
}
}()
}

3 点 (n0n1n2) の時間を測定し、 n1 - n0 および n2 - n0 にかかった時間 (1000 回呼ばれた時間の平均、単位は ms) を表示する。これらの相対的な差については、割と安定した結果が得られた。


プロファイリング結果

以下の OpenGL 関数コールがかなり遅いと分かった:


  • glBufferSubData

  • glViewport

glBufferSubData は Ebiten では頂点情報のバッファへの登録に使用している。この関数の呼び出しは実は最小 1 フレーム 1 回で済む。 Ebiten では基本的に矩形描画命令しかないからだ。ドローコールをその場で実行せずにキューか何かに溜め込んでおき、フレーム最後で glBufferSubData を 1 回呼び出して頂点情報をまとめてバッファに送ってしまえばよい。

glViewport も 1 回で済む。いままで Ebiten は描画対象のフレームバッファが変わる度にその大きさに合わせて glViewport を呼ぶということをしていた。実際そんなことをする必要は全く無くて、ビューポートの大きさは適当な大きさ固定でも、描画対象の大きさに合わせて行列変換してしまえばよい。なお後に発見するのだが、 iOS においては毎フレーム 1 回は呼び出す必要があるようなので気をつける必要がある。

結果的に上の 2 つの呼び出し最適化および細かい調整で、 60FPS を達成することができた。めでたしめでたし。

なおこのプロファイリングはこの日 1 日で終わったのではなく、この結果が得られるのに何日か費やした。


glUniformFloats 呼び出し回数の削減

シェーダプログラムに値を渡す命令である glUniformFloats の呼び出しを最小限にした、という非常に地味な修正。地味すぎてあまり効果がなかったと記憶している。

これコミットメッセージが間違ってるな…。


glDisableVertexAttribArray 呼び出し回数の削減

Ebiten 内ではどうせ 1 種類のプログラムしか使わないし、 glEnableVertexAttribArray 呼び出したらあとは放置でいいだろう的な発想の最適化。これも地味。


2016-05-31


オーディオ内 (JNI) で使用する Android API レベルの修正

オーディオ実装内で想定より高レベルな API を使ってしまっていたのを修正。 JNI だとコンパイル時に API Level のチェックは当然してくれないので、エミュレータなどで実際に動かしてはじめてひっかかったりする。つらい。


2016-06-01


無駄な glFlush の削除

glFlush は非常に遅いのでむやみに呼ぶのはやめましょう。


glBindFramebuffer 呼び出し回数の削減

最後にバインドしたフレームバッファを記録しておいて、それと同じだったら glBindFramebuffer 呼ばないようにするという地味な最適化。


glCheckFramebufferStatus の呼び出しの削減

glCheckFramebufferStatus がやたらめったら重かったので削除した、と記憶している。エラー処理としては若干不安だが、普通は失敗しないので、とりあえずこれでよしとする。


2016-06-03


描画命令のコマンド化

前述の glBufferSubData 回数を最小限にするために、描画命令を構造体にしてコマンドとしてキューに貯める修正をした。これは随分前に行った「描画命令実行の遅延化」とは独立しておこなわれたもので、複雑度が増してしまった。後に遅延化はこのコマンド実装と統合されることになる。


glBufferSubData の呼び出し回数の大幅削減

これで複数の描画命令に対してまとめて 1 回の glBufferSubData が呼ばれることになり、描画速度がだいぶマシになった。


2016-06-04


バグ修正: glDeleteFramebuffer 呼出し後のバインドされたフレームバッファ

glBindFramebuffer 呼び出し回数の削減」の最適化を行うために「現在のフレームバッファ」を記録していたのだが、 glDeleteFramebuffer のときに「現在バインドされているフレームバッファ」は 0 番のフレームバッファになる、という問題があったので修正。最適化による悪影響である。

参考: https://www.khronos.org/opengles/sdk/docs/man/xhtml/glDeleteFramebuffers.xml


If a framebuffer object that is currently bound is deleted, the binding reverts to 0 (the window-system-provided framebuffer).



2016-06-05


glUniformMatrix4fv の呼び出し回数の削減 (後に撤廃)

Ebiten では矩形の描画に 3 次アフィン行列を用いているが、拡大縮小回転だけではなく単なる平行移動 (特定の座標にそのまま描画) する際にも用いている。この最適化は、特定の条件 (拡大縮小などの変形が一切ない) のときに頂点情報を変更してしまうことで、行列情報をシェーダに渡す必要性を減らすものである。拡大縮小のないタイリング描画などで有効であろうと目論んだが、プログラムが複雑になる上にほとんど早くならなかったので、結局元に戻した


glBindTexture 呼び出し回数の削減

これも基本的に同じで、前回バインドしたテクスチャを覚えておいて、無駄に glBindTexture を呼ばないようにする、というものである。


glViewport 呼び出し回数の削減

これも全く同じく、 glViewport の最後に渡した引数を覚えておき、無駄に glViewport を呼ばないようにする。

glViewport は後に呼び出しそのものを大幅に削減することになる。


バグ修正: glDeleteFramebuffer 後のビューポート (1, 2)

glDeleteFramebuffer でバインド中のフレームバッファを削除した場合、 glViewport 呼び出し直ししなきゃいけないけども、先ほどの修正で glViewport の記録しておいた引数も修正しないと glViewport が呼ばれなくなってしまうので良くない、というバグの修正。


2016-06-06


glViewport の値を固定値にする

これで前述の最適化と相まって glViewport がほとんど呼ばれなくなり、速度が大いに向上した。この頃からすでに Nexus 5x でいのべーしょんが 60FPS 出るようになったと記憶している。


60FPS 出たものの、なぜか画面がちらついてしまう。一体なぜ。まだまだ続く。


ライセンス

この記事のライセンスは CC BY 4.0 とします。