Node.js の EventEmitter のコードを読んで、速度最適化されたコードについて考える

大都会岡山 Advent Calendar 2012 20日目のエントリーです。
前回の記事は @DAI199 さんによる 中国で流行っていること、感じたこと(大都会岡山 AdventCalender2012 19日目) - tagamidaiki.com でした。

20 日目のエントリーは、大都会岡山で生まれ、大都会岡山のコミュニティの方々を尊敬し、そして札幌で暮らしている(!) @tricknotes が書かせていただきます。

ぼくの記事ではタイトルの通り、速度最適化された js コードについて見ていきたいと思います。

はじめに

「その書き方だと遅いから」「こっちの方が速いよ」なんていう言葉をちょくちょく耳にする機会があります。
でもそれって本当でしょうか?

コードの実行速度は処理系の実装と密に関係していて、パフォーマンス計測なしには語ることができません。
また、そのコードの実行のされ方によっても実行速度は大きく変わってくるでしょう。
(たとえば、実行時のホットスポットがどこになるかというのは、引数やオブジェクトの状態などに左右される場合があります。)

そこで今回は、 Node.jsEventEmitter を読んで、 JavaScript の速度最適化について考えてみることにしましょう。

Node.js を選んだ理由

Node.js はイベント駆動の非同期スタイルを強制していることで知られています。そして、このイベント駆動のインターフェースを提供しているモジュールが EventEmitter です。
Streamhttp.Server など多くの主要なモジュールがこの EventEmitter を継承することで、イベント駆動のインターフェースをユーザに提供しています。
(参考)

ということは、 EventEmitter 内でパフォーマンスの悪い箇所があれば、Node.js 全体のパフォーマンスに大きな影響を与えることになります。

実際に、この EventEmitter の中では多くの速度最適化が施されており、これが Node.js 全体の実行速度を維持するための重要なポイントとなっています。
(イベント名に __proto__ など、特定の名前が利用できないという不具合がありますが、それよりもスピードの方が重要であるという方針とのことです joyent/node#4366)

ではさっそく、この EventEmitter 中からいくつかコードを例に挙げながら、実際に速度を計測して本当に速度最適化されていることを確認していきましょう。

EventEmitterevents.js の中で定義されています。

本エントリーでの対象バージョンは Node.js 0.9.4-pre (9f4c0988) とします。

$ node -v
v0.9.4-pre
$ node
> process.versions
{ http_parser: '1.0',
  node: '0.9.4-pre',
  v8: '3.13.7.4',
  ares: '1.9.0-DEV',
  uv: '0.9',
  zlib: '1.2.3',
  openssl: '1.0.1c' }

最適化コードについて

○ Function#apply より Function#call

Function オブジェクトを、任意のオブジェクトをコンテキストとして実行するためのメソッドとして Function#apply と Function#call があります。
多くの処理系の場合と同じく、Node.js の場合もこの両者の間には大きな速度の差あるようです。

そのため、極力 Function#apply を使わないようなコードになっています。

だいぶ濃い感じになっていますね。
普通に書くと、以下のように1行で済むはずです。

handler.apply(this, Array.prototype.slice.call(arguments, 1));

実際、両者の間にどれくらいの速度差があるのか測定してみましょう。

// Function#apply と Function#call の比較
var count = 10000000;
var now, i;
var obj = {};
var key = 'key';

var fn = function() {};

now = Date.now();
for (i = 0; i < count; i++) {
  fn.apply(this, [1]);
}
console.log('* apply: %d ms', Date.now() - now);

now = Date.now();
for (i = 0; i < count; i++) {
  fn.call(this, 1);
}
console.log('* call: %d ms', Date.now() - now);
* apply: 519 ms
* call: 160 ms

たしかに Function#apply の方が遅いようですね。

○ delete より null

オブジェクトの特定のキーに紐付く値を削除する場合、値だけを削除する方法(null や undefined を代入する方法)とキーごと削除する方法(delete キーワードを使う方法)があります。Object#hasOwnProperty などを利用せず、値の truthy/falsy のチェックだけを行うのであれば、この両者の間で大きな動作の違いはありません。
ただ、Node.js の場合は前者の方が圧倒的に高速に動作します。
そのため、不要になったオブジェクトを破棄する際には、delete を利用せず null を代入するようになっています。

EventEmitter の中では、 EventEmitter#removeListenerEventEmitter#removeAllListeners の中で使われています。

// delete と null を代入の比較
var count = 1000000;
var now, i;
var obj = {};
var key = 'key';

now = Date.now();
for (i = 0; i < count; i++) {
  obj[key] = 1;
  obj[key] = null;
}
console.log('* obj[key] = null: %d ms', Date.now() - now);

now = Date.now();
for (i = 0; i < count; i++) {
  obj[key] = 1;
  delete obj[key];
}
console.log('* delete obj[key]: %d ms', Date.now() - now);
* obj[key] = null: 21 ms
* delete obj[key]: 405 ms

速度が処理系に依存することの参考までに、このベンチマークFireFox 16.0.2 (Max OS X) で実行してみました。
すると、以下のような結果となりました。

* obj[key] = null: 5520 ms
* delete obj[key]: 5857 ms

6%程度の差なのでそこまで大きな差ではありません。
この結果から、速度は処理系に依存していることがわかります。

○ Array#slice より for 文 & 代入

配列(もしくは arguments) を部分的に切り出したい場合、Array#slice を利用するのが簡単です。
ただ、Node.js では Array#slice を呼ばないように、 Array を要素数指定で初期化して、ひとつづつ値を詰めている箇所があります。

ここを簡単に記述するなら以下のようになります。

Array.prototype.slice.call(arguments, 1);

これについてはどの程度パフォーマンスに影響が出ているのでしょうか?

// Array#slice(1) と new Array() + for についての比較
var count = 10000000;
var now, i;
var obj = {};
var key = 'key';

var array = ['type', 1, 2, 3 /* any arguments */];

now = Date.now();
for (i = 0; i < count; i++) {
  array.slice(1);
}
console.log('* Array#slice(1): %d ms', Date.now() - now);

now = Date.now();
for (i = 0; i < count; i++) {
  var l = array.length;
  var _array = new Array();
  for (var j = 1; j < l; j++) {
    _array[j - 1] = array[j];
  }
}
console.log('* new Array(): %d ms', Date.now() - now);

now = Date.now();
for (i = 0; i < count; i++) {
  var l = array.length;
  var _array = new Array(l - 1);
  for (var j = 1; j < l; j++) {
    _array[j - 1] = array[j];
  }
}
console.log('* new Array(length): %d ms', Date.now() - now);
* Array#slice(1): 837 ms
* new Array(): 388 ms
* new Array(length): 267 ms

以上のことより、Array#slice を使った場合とそうでない場合では、速度差があることがわかります。

補足

ここまででは書いていませんでしたが、部分的にコードを取り出しただけのベンチマークでは不十分なケースもあります。

コードの書き方だけではなく、文脈に沿って最適化が行われる場合があるので、ちゃんとプロダクションコードに対してベンチーマークをとって全体で動かしてみるまで、速度最適化されたかどうかは判定できません。
(ということを @koichik さんに教えてもらいました。勉強になります!)


結論

ここまで見てきてみなさまお分かりでしょうが、速度に最適化することと人間に最適化することが共存できるとは限りません。速度に最適化し過ぎてリーダブルではないコードになってしまうと、メンテナンス性を著しく下げてしまうことでしょう。しかし、ここまでで見てきた EventEmitter の例のように、可読性を捨ててでも速度に最適化すべき場面というのも確かに存在します。
もちろんその場合には、プロジェクトの性質によってどこまで速度最適化をするかということを判断する必要があると思います。

また、本エントリーの内容は、Node.js 0.9.4-pre (9f4c0988) での挙動です。
今後、同じコードで同じ最適化の効果が得られると保証されているわけではありません。
(関連)

みなさまのアプリケーションでも、もしパフォーマンスが問題であると感じたときは、ホットスポットがどこにあるか計測しつつ、どこまで可読性を犠牲にするかということを考えながらチューニングしていくことをオススメします。

まとめ

以上でぼくの記事は終わりとさせていただきます。
次回の大都会岡山 Advent Calendar 2012sutorada さんです。
引き続き、大都会岡山 Advent Calendar 2012 をお楽しみください:-)