オブジェクトプールを使った静的メモリ JavaScript

HTML5 Rocks

はじめに

想像してみてください。あなたの作ったWebゲームもしくはWebアプリが、ある期間経った後ひどく遅くなった、という内容のメールを受け取ったとします。あなたはコードを確認したけれど、特に怪しい箇所は見受けられませんでした。Chromeのメモリパフォーマンスツールを開いて以下のような画面を見るまでは:

I wonder what all those saw-tooths are?

同僚の一人がそれを見てクスクス笑っています。彼はあなたがメモリー関連のパフォーマンス問題に直面していることに気付いたからです。

このようにメモリのグラフがノコギリ型になっている場合、潜在的なパフォーマンス上の問題があることを示しています。メモリの使用量が増えるにつれてタイムラインのチャートも上昇します。そしてチャートが急に下降したところで、ガベージコレクターが動作して、アプリ内で参照されていたメモリオブジェクトを削除しているのです

Look at all those GC Events!

グラフがこのようになっている場合、ガベージコレクションのイベントが大量に発生して、アプリケーションのパフォーマンスを悪化させているのです。この記事では、パフォーマンスへの影響が少ないメモリの使用方法について、解説したいと思います。 JavaScriptのメモリモデルガベージコレクションと呼ばれる技術を用いています。多くの言語において、プログラマはシステムのヒープメモリから直接メモリを獲得/解放する必要がありますが、ガベージコレクター(GC)はプログラマの代わりにこの作業を行ってくれます。つまり、プログラマがオブジェクトの参照を解除した時点ではメモリは解放されず、後でGCが最適と判断したタイミングで解放されます。このような判断を行うために、GCは統計的な分析を行う必要があり、それには相当の処理時間を要します。

コンピュータ・サイエンスにおいて、ガベージコレクター (GC) は自動メモリ管理の一形態です。ガベージコレクターは、プログラムがもうすでに使用していないオブジェクトに割り当てられたメモリ (ガベージ)を再利用します。

ガベージコレクションはしばしば手動メモリ管理と対比されます。手動メモリ管理においては、どのオブジェクトをメモリシステムに返却するかをプログラマが指定する必要があります。

GCがメモリを再利用する処理はタダではありません。通常、GC処理はプログラムが利用可能な処理時間を奪います。しかも実行タイミングはシステム自身によって決定され、あなたは一切コントロールできません。GC処理はプログラム実行中のいついかなるときにも起こり得ます。そして、完了するまでプログラム実行をブロックします。GC処理にかかる時間は一般的に知ることが出来ません。ある時点でプログラムがどのようにメモリを使用しているかによって、処理時間は変わります。

ハイパフォーマンスアプリケーションはスムーズなユーザー体験を保証するため、一定の性能要件を満たす必要があります。しかし、ガベージコレクションは任意のタイミングに任意の時間実行されるので、アプリケーションは必要な処理時間を奪われて、パフォーマンス上の要件を満たすことができなくなる恐れがあります。

メモリ撹拌の削減によるガベージコレクションの影響回避

前述の通り、GCの処理は、使用されていないオブジェクトがたくさんあり、ガベージコレクションの処理を実行するのが効果的である、と判断された場合に実行されます。ですので、ガベージコレクションによりアプリケーションの処理時間を奪われないようにするには、大量のオブジェクトを作成/破棄することをなるべくしないようにすればよいのです。このようにオブジェクトが頻繁に生成/破棄される状態を「メモリ撹拌」と呼びます。アプリケーション実行時にメモリ撹拌が発生するのを避けることで、GCの処理時間を削減することができるのです。生成/破棄されるオブジェクトの数を減らすということは、実際にはメモリをアロケートすることを止めるということを意味します。 そうすることにより、メモリのグラフは以下のように変化します:

I wonder what all those saw-tooths are?

以下のように変化:

Ahhhh, that's better.

このモデルでは、もうグラフにはノコギリ型のパターンは見られません。代わりに、グラフは最初に一気に上昇し、後はゆっくり増加しています。メモリ撹拌の問題が発生している場合、このような形のグラフを目指さなければなりません。

スタティックメモリJavaScript

スタティックメモリJavaScriptとは、アプリケーション起動時に以降に必要となるすべてのメモリを事前にアロケートし、実行中に不要となったオブジェクトを回収して、メモリを管理するテクニックです。 これは簡単な手順で実現可能です:

  1. アプリケーションの実際のユースケースにおいて、メモリ割当が必要なオブジェクトの最大数を(型ごとに)調べる。
  2. その最大数のオブジェクトを事前にアロケートし、以降はメインメモリではなく、最初に確保したメモリから借用/返却するようにコードを書き直す。
実際には、最初の手順を実行するには、2番目の手順の一部を実装する必要があります。ですので、2番目の手順から初めましょう。

オブジェクトプール

オブジェクトプールとは、使われていない、同じ型を持つオブジェクトをセットにして保持することを指します。新しいオブジェクトが必要となった場合、システムのヒープメモリから取ってくるのではなく、プール内の使用されていないオブジェクトを再利用します。その後、不要になったオブジェクトは、メインメモリではなくプールに返却します。そうすることで、オブジェクトはコード内で参照が途切れる (deleteされる)ことはないので、ガベージコレクションの対象にはなりません。オブジェクトプールを使用することで、プログラマは再びメモリのコントロールを手に入れることができ、ガベージコレクタのパフォーマンスへの影響を減らすことができるのです。

オブジェクトプールは、多くのハイパフォーマンスアプリケーションにおいて、メモリ撹拌の発生を抑えるためによく使われる手法です。オブジェクトプール自身は以下の2つの基本的な性格を持ちます:
  1. 使用中のオブジェクトの数が増えるにつれ、プールのメモリサイズも増えます。
  2. フレーム毎に生成/破棄されるオブジェクトの数を、必要最低限に抑えることができます。

通常、アプリケーションは異なる型の複数のオブジェクトを持つので、オブジェクトプールの正しい使い方は、メモリ撹拌が発生するオブジェクトの型ごとにプールを一つ用意します。

var newEntity = gEntityObjectPool.allocate();
newEntity.pos = {x: 215, y: 88};

//..... do some stuff with the object that we need to do

gEntityObjectPool.free(newEntity); //free the object when we’re done
newEntity = null; //free this object reference

大多数のアプリケーションにおいて、生成すべきオブジェクト数の上限というものが存在します。あなたは、アプリケーションを何度も実行しているうちに、この上限値についてかなり正確な感覚を身につけることができるでしょう。あとは、アプリ実行時にその数のオブジェクトを事前にアロケートするだけでよいのです。

オブジェクトの事前アロケート

オブジェクトプールを実装すると、アプリケーション実行中に必要となるオブジェクトの理論的な最大数が分かります。いろいろなテストの条件下でアプリケーションを実行してみることで、必要とされるメモリ要件について理解が深まります。得られたデータを列挙して分析することで、アプリケーションで要求されるメモリの上限値を把握しましょう。

そして、リリース版ではすべてのオブジェクトプールを適切な値で初期化します。これにより、アプリ起動時にすべてのオブジェクトが生成されるようになり、実行中に動的なメモリアロケーションが発生するのを抑えます。

function init() {
  //preallocate all our pools. 
  //Note that we keep each pool homogeneous wrt object types
  gEntityObjectPool.preAllocate(256);
  gDomObjectPool.preAllocate(888);
}

ここで何の値を選択するかは、アプリケーションの動作に密接に関係しています。理論的な最大数が常にベストな選択というわけではありません。例えば、非パワーユーザーの場合、平均値を選択する方が、メモリの使用量は小さくなります。

万能にあらず

静的なメモリ増加パターンはすべての種類のアプリケーションにおいて適応可能です。しかしながら、Chrome デベロッパーリレーションチームのフェローであるRenato Manginiが指摘しているように、この方式にはいくつかの短所もあります。

たとえハイパフォーマンスアプリケーションであったとしても、プールが有効でない場合もあります。オブジェクトプールおよびスタティックメモリー方式を採用する前に、以下のトレードオフ検討してみてください: 初期化時にメモリをアロケートするため、起動時間は長くなります。 メモリをあまり使用していないときも、使用メモリ量は減りません。アプリケーションはメモリをどん欲に消費します。 これを解消するには、プールに返却されたオブジェクトをある時点で解放する必要がありますが、メモリー撹拌が激しいエリアでこれを実行すると、かなりのオーバーヘッドを伴います。

おわりに

JavaScriptがWebに向いている理由の一つとして、早く、楽しく、そして簡単に覚えられる言語であるということが挙げられます。これは主に文法の制限が少ないことと、メモリの問題をうまく扱ってくれる点によります。つまり、あなたはコードを書くだけで、他の面倒なことはJavaScriptがやってくれるのです。 しかし、HTML5ゲームのようなハイパフォーマンスWebアプリケーションにとって、GCはしばしば、貴重なフレームレートを食いつぶし、ユーザー体験を損ないます。アプリケーションを注意深く設計し、オブジェクトプールを実装することで、フレームレートの負荷を低減し、他のより有用なことに処理時間を使うことができるのです。

ソースコード

オブジェクトプールの実装例はWeb上にたくさん存在するので、ここで新たにコードを作成するようなことはしません。そのかわり、これらを参照ください。それぞれの実装は微妙に異なり、個々のアプリケーションが異なる要件を持つことから、このことは重要です。

参考文献

Comments

0