メモリ・GPU負荷の改善から考える、HTML5ゲーム「重い問題」の解決策

HTML5で「重い」問題をクリアしてリッチなゲームを作る #2/2 >> 1はこちら

メモリリークの原因をプロファイラで特定する

岡山知弘氏:じゃあ、次はメモリの負荷改善の話をします。メモリリークの原因をプロファイラで特定しましょう。

まずメモリリークという言葉について。JavaScriptやブラウザ環境では、メモリは定期的にクリアされています。ガベージコレクションという仕組みです。なんらかの不具合によってデータがメモリに残ったままであり続けることをメモリリークといいます。メモリリークがどんどん溜まるとメモリが使えなくなってブラウザが落ちたりクラッシュしたりします。

メモリリークの調査はGoogle ChromeのALLOCATION TIMELINEがすごく見やすい。灰色のラインは1回確保したけど、もう現在は消えているものです。青色のラインは現在メモリに載っているものです。

実行しながら確認できるので、ある状況でラインがバッと伸びたにもかかわらず、消えていないなどがすぐにわかります。例えば「このエフェクトが問題だな」とわかるんですね。とあるエフェクトが画面から消えているはずなのにずっとメモリに残っている。

画像やエフェクト、サウンドなどがいつのタイミングでメモリ上に発生して、消えていくかが追えるため、消えていないことがすぐわかります。予想以上にメモリが肥大化しないかも一緒にチェックしましょう。

ただ、リソースのリークは分かりづらいところもあります。ファイル上での画像サイズと実メモリでのサイズはけっこう違うんですね。例えば、200ピクセル×200ピクセルのPNGの画像が30KBぐらいのものが消えていなかったときに、でも実際メモリ上は100KB以上使用していたりするんですね。

なぜか? PNGやJPEGは圧縮されてます。それを解凍してビットマップという状態に変換してメモリに載せています。ビットマップとは1ピクセルごとにRGBAの情報を持っています。各1byteです。200×200×3byte=120KBみたいな感じでメモリに載っています。だから、実際にPNGとか20KBぐらいしかないはずなのにすごく膨らんでいるように見えるんですけど、実はこういう理由があるんです。

画像周りでメモリの占有量を減らしたいときに、WebGLの場合は圧縮テクスチャという選択肢があります。圧縮テクスチャとは、圧縮された状態でGPUに転送できる画像形式です。だからビットマップに変換しなくてもそのまま描画できます。ファイル容量が小さいし、メモリを占有する量も小さいという素晴らしい機能です。しかし、ブラウザの対応状況は念入りにチェックしましょう、ブラウザごとやバージョンによって対応方式が異なるため表示できずにエラーになる場合があるためです。

使用しているライブラリの挙動にも気をつけてください。内部でどういうことをやっているのか知っておかないと調査がとても難航します。

例えばPixiJSの場合。リッチなゲームを作っているとよくブラウザがクラッシュしていました。10分ぐらいプレイしたらクラッシュするみたいな。クラッシュもリロードを起こすだけなので理由がわからない。PixiJSの中身を見てみたら、描画速度改善のためか画像キャッシュを大量に貯めていたのが原因でした。無理やりオーバーライドしてそこらへんのソースコードをごそっと消したら正常に動いたこともあったりなどですね。このPixiJSの処理自体が悪いわけではなく、用途とマッチしないことが多々起こって発覚しづらい問題が出ることもある、という例です。

カンタンに起こせるメモリリーク

次は「カンタンに起こせるメモリリーク」というところですね。ここらへんはJavaScript特有の話になります。varを使わずに定義した変数はどうなるのか? グローバル変数になるんですね。これは知ってますかね。

こんなくだらないことでメモリリークするんですよ。

グローバル変数になっているので、意図したコードであればリークとは言わないかもしれないけど、ずっと生き続けるのでリークと定義しています。修正に修正を重ねているときに、気づかないことがたまにあるんですよね。いつの間にかWindowグローバルオブジェクトによくわからない変数が増えたりすることがあります。

最近だとCircleCIで静的チェック系のJSLintやJSHintを使って、GitHubにプルリクを送るときにチェックかけたりします。静的チェックさせて、おかしなコードがあったりするとマージができないようにします。最近の主流ですかね。

次ですね。関数はnewされる前提で書いたものですね。関数の中にthis.xやthis.yと書いて、インスタンスを生成しているつもりなんですけど、newを書き忘れています。この場合もグローバル変数になります。xとyが漏れます。newという言葉を書き忘れただけで、こういうことが起きちゃいます。

なので、僕は定期的に認識外のグローバル変数が増えていないかを1ヶ月に1回ぐらいチェックしていました。

ES6でclass構文が入ったので、最近の環境で制作している方はこういうミスはもう起きないと思うんですけどね。

クロージャで起こるメモリリーク

最後に、クロージャで起こるメモリリーク。クロージャは大変わかりづらいと言われています。……が、実はよくネットとかで見る情報、あれは間違っています。

別のスコープのローカル変数を参照できる仕組み自体がクロージャです。クロージャはこれだけです。

JSの場合は関数単位でスコープが作られます。だから関数単位で見てください。今でかいnonclosureSampleというスコープとfuncというローカル関数のスコープがあって、この小さいfuncというローカルのスコープから1個上のnonclosureSampleのスコープに、ポインタ的なつながりができています。だからtempというデータが扱えるようになります。これがクロージャという仕組みです。

クロージャを使ってプライベートな変数を作るコードがなぜかクロージャと呼ばれているところがあって、これは間違いです。なので惑わされないようにしてください。おそらく混乱します。

ちなみにtempがグローバル変数の場合は、クロージャが発生しません。ローカルの変数をスコープでつなぐことがクロージャなので。書いているスコープが違うからクロージャになるわけではなくて、ローカル変数とローカル関数の組み合わせで起きます。

ガベージコレクションとは?

ガベージコレクション(GC)というものがあります。GCについて軽く説明したんですけど、再度。メモリを定期的にクリアにする仕組みがあります。これがガベージコレクションです。もう少し詳しく言うと、WindowオブジェクトやDOMツリーなど、ブラウザの直管理のルートから辿れないオブジェクトをすべて消す仕組みです。

GCについて調べるとよくあるんですけど、循環参照でメモリリークするだとか出てくるんですが、あれは全部うそです。IE6とかの時代の話です。今は起きません。

GCについてもう少し話を続けます。最近のメジャーな実行環境はマークアンドスウィープというアルゴリズムが使われています。それが今さっき言ったルートからたどれないオブジェクトをすべて消すというアルゴリズムです。全オブジェクトを走査するのでめちゃくちゃ重たいです。すべてのオブジェクトをforで回していると思ってください。なので、めちゃくちゃ重たいです。だから気をつける必要がある。

昔は参照カウンタという方式でした。ポインタを通して参照されるとカウントが1upして、ずっと0のものは誰からも参照されていないので消せるという考え方で作られています。

でも、参照されるとカウントが1upするので、相互に参照すると永遠に0にならないことが起こるんですね。「AオブジェクトがBを見て、BオブジェクトがAを見る」という構造ができちゃうと、永遠に0にならないのでそのままリークする。

ただし、昔のブラウザの話です。だから今はDOMのイベント系やaddEventListenerなどを使うときは循環参照に気をつけるみたいな話は気にしなくていいです。

GCについてまだ話したいことがあるんですよ。マークアンドスウィープが重たいことに対する対応策なんですけど、対策のために世代別ガベージコレクションというのが入っています。

何かというと、ガベージコレクションで1回目の掃除しますよね。そのあとに、もし消せなかったんだったら、次のガベージコレクションじゃなくて、10回目のガベージコレクションで再度チェックします。また消せなかったら次は50回目という感じで、世代で分けていく方針を取っています。よって全部毎回チェックすることをしないので、軽いです。

ただし、全世代をチェックするタイミングが来るんですね。その瞬間に、本当にしっかり考えていないと、重たすぎてスパイクが起きたり、なんならブラウザがクラッシュして落ちたりします。

メモリからデータを消すときはそのままただクリアするわけじゃないんです。メモリを消すだけじゃなくて、断片がたくさん残っちゃっているので、それを整理するためにメモリを詰めるために移動したりするんです。移動する場合は結局メモリをコピーしないといけないから、またメモリが必要になります。たぶんそのときに落ちてます。

メモリリークしたコードの原因

長い用語説明だったんですけど、次のコード、めっちゃリークします。

説明します。

globalObjと書いていますけど、そもそもグローバルじゃないですね。ローカル変数ですね。ただ、一番上のスコープにあるのでいったんglobalObjと書いています。func関数が1個の大きいローカル関数で、それを毎秒呼び出します。

NOT_USE_CLOSUREという関数はクロージャです。ただ使っていませんが。

globalObjの中に0が1,000個詰まった大きな文字列であるheavyを入れていて、somethingというただのローカル関数も入れています。毎秒funcがずっと実行されます。その結果、毎秒重たいデータを作りますが、上書きしているように見えるにも拘らず、重たいデータがそのままリークします。

なぜリークするのか? globalObjはfunc関数の外部から参照できる。外部から参照される状態なので、結果的にsomething関数も外部からアクセス可能なものになります。

function関数内でクロージャーを作っていますね。NOT_USE_CLOSURE関数です。クロージャを作ると、実はクロージャの存在するスコープ内で作られたローカル関数のスコープはすべて共有されるんです。つまり、something関数とクロージャであるNOT_USE_CLOSURE関数にはスコープの繋がりができます。

で、NOT_USE_CLOSURE関数はクロージャなので、localClosureという変数に対してのアクセス権を持っているんですね。NOT_USE_CLOSURE関数スコープとfunc関数スコープはメモリ的なつながりができています。

ガベージコレクションの話を思い出していただきたいんですけど、ルートオブジェクトからたどれるものは消せないルール。だから、globalObjから辿り、something関数、NOT_USE_CLOSURE関数、localClosure変数は繋がりがあるので逐一消せなくなりますよね。

それでも、上書きされたら前のデータは消えそうですがそうはなりません。クロージャが成立する起因になったlocalClosureの変数は、クロージャの特殊性2つ目ですが、参照されうる可能性があるとメモリに残し続けます。

例えばクロージャの内部処理で数値がカウントアップしているのであれば、実行するたびに12345と増えていく。という特殊性です。クロージャとなっている関数からの視点で見れば、一つ上のスコープの存在である変数は値が保持されている状態になってほしいからです。値が保持されないならローカル変数と変わりません。

なので、func関数が呼ばれるたびにクロージャが作られて、各スコープも新たに作られて、外部から参照されうる状況とクロージャが合わさって消せないという状態が発生する。これによって、localClosureが保持され続けて、毎秒毎秒呼び出してどんどん溜まっていく流れが起きます。すごくわかりづらいんですけど。

リークしたコードの解決策

解決策です。まずクロージャになり得る変数に大きいデータを持たせないという話。わかりやすいルールです。こうするだけで、いったんリークを最小にできます。大きいデータを持たないので。根本解決はしません。

次ですね。ちゃんと理解して書きましょうという方法ですね。スコープ内でローカル関数を複数作る場合はクロージャを作らないとか、クロージャ自体を禁止するというルールをチームとして作るという方法。

次は、クロージャの起因となった変数が不要になったら、ちゃんとnullで参照を切るというレガシーなことをやります。わかりにくいんですけど、僕はこれが妥当だと判断しました。僕のプロジェクトではこれを実施しました。

クロージャにまつわるルールの策定は、チームの状況や熟練度に合わせて設定しないと守られなかったり間違えたりすることがあるので気をつけましょう。状況や前提となる仕様が複雑なので。

先ほど言ったとおり、僕はnullで参照を切る方法を延々とやり続けました。その結果、ブラウザが落ちまくったり、クラッシュしまくりだった、とある大型タイトルはほぼ解決しました。2回ぐらい……最初は1回だったかな、インゲームプレイするとクラッシュするというひどい状態だったんですね。それが解決しました。ただ、やはりコードの見た目はすごく汚いです。

とくに低レベルな処理とか……低レベルな処理とは、描画APIに近いところとかベースの処理に近い部分のことです。こうしたものを重点的にnullで参照切ったりました。PixiJSもオーバーラップして強制的にnullで参照を切ったり、大きなオブジェクトは細々とプロパティごとにnullで参照を切り続けました。

なぜ効果があったのか。リークの解決だけではないです。ゲームはJavaScriptのヒープに紐づくかたちで大量のオブジェクトが走査されます。消せるタイミングでnullで参照を細切れにしておくと、大元のオブジェクトが不要になるまで待つことなくて、消せるものはさっさとGCが入ります。

先ほど簡単に説明しましたけど、大量のオブジェクトを消す場合はそれ相応のメモリーが必要になるんですね。だから、細かくGCが走れるほうがマシン的にはうれしいです。

世代別やマークアンドスウィープという話に立ち戻ると、GCのタイミングで消去できない場合はどんどん後ろの世代に持ち越され、ある瞬間FullGCが走って落ちるみたいなことが起き得るんですね。

また、マークアンドスウィープのFullGCはそもそも重たいという話。大量消去・FullGC・メモリ整理のタイミングが重なると簡単にクラッシュします。

GPUの負荷改善

GPUの負荷改善の話です。GPUの負荷改善は理屈は簡単です。DrawCallを減らすだけでいいです。一応語弊なく言うと、シェーダなどのレンダリングパイプライン周りが改善項目となります。

ただ、これを改善する方法は、チームのルールづくりやライブラリの準備が必要だったりするので、小手先できるところはあまりないんです。小手先で唯一できることは、Texture Bufferを作って無駄な描画を減らしましょうくらいですかね。

例えば僕が作ったサンプルだと、マップチップが大量にあるので数千のオブジェクトが描画されるんですけど、それを1枚のテクスチャに焼きつけてやると、数千という描画が1回で済む。

あとは、全面にエフェクトやオブジェクトが表示されているときは、裏側の描画更新をストップさせたり。ちょっと裏が見えたりするとクオリティを犠牲にすることにはなります。

この方法で改善しようと思ったら、描画処理の根幹の設計、アーキテクトを想定した状態で作らないといけないので、こういう改善を行う可能性があることを知っておく必要があります。途中からアーキテクトを作り変えるのはかなりしんどいです。全てテストしなおしです。

他、描画順を整理すると速度改善する場合があります。描画方法が変更されるたびにDrawCallは増えます。例えば加算→通常→加算→通常みたいになると、DrawCallがそのたびに増えます。だから、加算は加算でまとめたり、そもそも加算合成のエフェクトを出さないといった話です。

描画順の整理方法なんですけど、チームのコミュニケーションの話でもあります。UIの制作担当者に「加算合成のエフェクトを作るのやめてくれ」と言うなど。量産体制に入ってたら、これは不可能になってくるので、最初に考えておく必要があります。

2パターン目の整理方法です。オブジェクト指向で作った場合、キャラクターのアイコンを表示するclassを作ったら、アイコンの背景やキャラクター、UIエフェクトがあるものを1つのclassで1パックで作ると思います。これを4人キャラがいたら4回という描画処理になると思います。

キャラごとに描画するという縦軸じゃなくて、横軸で「アイコンの背景で4回、キャラのアイコンを4回表示、UIエフェクト4回」とすれば、通常描画、通常描画、最後に加算合成みたいな感じで描画できるため、DrawCallは減ります。

ただ、これはこれで愚直に作っちゃうと、非常に汚い処理になっちゃうので、単純にもう少し下のレイヤーで描画順をコントロールするアーキテクトを設計しておけばオブジェクト指向を守りつつ、こういう改善がやりやすくなります。

あとは使用するシェーダを限定したりです。UI担当者にシェーダを作ってもらったりすると、無尽蔵に増えるのでやめましょう。

シェーダの書き方については、CPU用にやさしいループの書き方とかお話ししましたけど、その話と同じで、そもそもシェーダ、とくにピクセルシェーダ(フラグメントシェーダ)は、画面全体にシェーダを走らせるのであれば、例えば1,000ピクセル×600ピクセル分の600,000回のループが走るわけなんですね。そのためちょっとした速度改善で大幅に速度を改善したりする。

あとは条件が重たい話。「ここまでノンストップで処理を走らせられる」みたいに先の処理を読んでおく方法をチップは行います。……が、条件文はそれを阻害します。

この場合CPUの話なんですけど、上のカウントが50より大きかったら50にして、カウントが0より小さかったら0にする処理は、こういうことを書きがちだと思うんですけど、Mathのmaxとminを使えば、if文なしで書けたりする。単発で見ると関数呼び出しのオーバーヘッドで遅く見えても、実測では状況が異なる場合もあります。

JITコンパイラにやさしいコードを書く

最後ですね。さらに改善するために、今までメモリやCPU、GPUの話だったんですけど、もう少し実行環境に最適化されたものを考えていく。やれることはまだまだあって、JITコンパイラにやさしいコードを書く方法があります。

最適化されたコンパイルがされる場合と、そうでないコンパイルがされる場合があるんですね。Google ChromeのV8エンジンは基本的にすべてコンパイルします。ですけど、最適化された場合とそうでないときがあります。これが実行速度が数百倍違ったりします。

具体的に言うと、debugger とかはリリース時点で書いてるはずないですよね。evalやwithなどはよくバッドケースといわれるので、たぶん使ってないと思います。

evalの中で書いたソースコードのスコープは、グローバルスコープを指すんじゃなくてevalを使った場所のスコープを指すんですね。簡単にクロージャと同じ状況が起きるんですよ。メモリリークが普通に起きます。しかも、どういうコードが実行されるかわからないから最適化もできないです。結果遅い。しかもスコープが関わるので他の関数にすら実行速度の面で影響を与えます。だから「eval is evil」などと今でも言われるんですけど。

ほかにも、旧仕様なので使う場面はだいぶ減ったんですけど、__proto__やES6などのget/setを使うと基本的には最適化されません。引数を関数内で上書きすると、これも最適化されないです。

前提の話なんですけど、JITコンパイラは関数単位でコンパイルされるんです。だから、Aの関数は汚くても、Bの関数がきれいであれば、Bはちゃんと最適化されます。

ちょっと前はもっとルールがあったんですよ。しかし、今の時点で、例えばfor-inが遅いなどの話はもうなくなってて、v8が対応しちゃったんですね。情報の更新が非常に速いので、ちょっと割愛します。たぶん明日には変わっているかもしれないレベルですね。現状を知りたかったら調べてみてください。

最後に

今回の話で、内部の構造や仕様を追っていくと、取れる選択肢の幅がけっこう変わってくる体感を持っていただけたらうれしいなと思います。

ちょっと紹介なんですけど、ブラウザの仕様を追いきった例として、弊社のメンバーで@goccyというスーパーエンジニアがいます。ストレージがどうやって永続化されているかの資料はどこにもないんです。その資料をソースコードを追いかけて作ったんですね。このような姿勢は実行速度は本物の改善を行うときに非常に大切な思想になります。

他、ゲームのライブラリやエンジンについても話をしてきました。改善していく内に「だったらもう自分でライブラリ作ったほうが早くない?」と思うタイミングってあるんですよ。僕は車輪の再開発などと言わずに、すごく応援したくなります。

もしゲームのライブラリの制作に興味がある方がいらしたら、phina.jsというJavaScriptのゲーム制作ライブラリがあります。めちゃくちゃきれいなので、すごい勉強になるかなと思います。もし興味があれば読んでいただけたらなと思います。

以上になります。今日はありがとうございました。

(会場拍手)