POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

Petka Antonov

本記事は、原著者の許諾のもとに翻訳・掲載しております。

最近Reaktorが掲載した『 Promises made by a Reaktor developer had an impact on the industry article 』で約束した、Bluebird promiseライブラリの製作者であるプログラマのPetka Antonovからの知見です。

Bluebirdは広く使用されているJavaScript用のpromiseライブラリで、同じような機能が実装されているにも関わらず、他のpromiseよりも100倍速いという理由から最初に知られるようになったのは2013年でした。Bluebirdが高速な理由はライブラリ全体にJavaScriptの最適化の基礎を一貫して適応しているからです。この記事ではBluebirdの最適化に使用する3つの重要な基礎について詳細に説明します。

1. 関数オブジェクトの割り当てを最小限に抑える

オブジェクトの割り当ての中でも特に関数オブジェクトの割り当ては、実行の際に大量の内部データを要するためパフォーマンスに負担がかかります。JavaScriptの実用的な実装においては、割り当てられたオブジェクトがメモリ領域にただ残っているのを避けるためにガベージコレクションが実行され、常に使用されていないオブジェクトを探し出し、割り当てを解除できるようにします。JavaScriptでメモリを使用すればするほどCPUの処理能力はガベージコレクション実行に多くを使用されてしまい、実際のプログラムを実行するための処理能力が減ることになります。

JavaScriptでは、関数は第一級オブジェクトになります。つまり、他のオブジェクトと同じ性質やプロパティを持っています。例えば、別の関数を宣言する関数を含むプログラムがある場合、同じコードだったとしても、親関数を呼び出すたびに新しい固有のオブジェクトが生成されてしまいます。例としては次のようになります。

function trim(string) {
    function trimStart(string) {
        return string.replace(/^\s+/g, "");
    }

    function trimEnd(string) {
        return string.replace(/\s+$/g, "");
    }

    return trimEnd(trimStart(string))
}

毎回TRIM関数が呼び出されるたびに、trimStartとtrimEndを表すために不要な2つの関数オブジェクトが生成されてしまいます。不要な関数オブジェクトという理由は、これらが持つ固有のオブジェクトIDでプロパティを割り当てることができなかったり変数へのアクセスができなかったりするからです。これらオブジェクトはあくまでも含まれているコードの機能のためだけに使用されます。

この例を最適化するのは非常に簡単です。単にTRIM関数の外に関数を移動すればいいのです。この例はモジュール内に格納されていて、プログラムにおいてこのモジュールは1回しか読み込まれないため、関数に存在する表現は1つのみです。

function trimStart(string) {
    return string.replace(/^\s+/g, "");
}

function trimEnd(string) {
    return string.replace(/\s+$/g, "");
}

function trim(string) {
    return trimEnd(trimStart(string))
}

しかし、一般的に関数オブジェクトは避けがたく、普通には最適化できないように思えます。例えば、呼び出し先の関数の実行中に実行されるように、あらかじめ指定しておくコールバック関数を渡す時、呼び出す際に固有のコンテキストを常に必要とします。通常このようなコンテキストは簡単で直感的ですが非効率的な方法であるクロージャで実装されています。Node.js標準の非同期コールバックで、あるファイルをJSONで読み込む簡単な例は次のようになります。

var fs = require('fs');

function readFileAsJson(fileName, callback) {
    fs.readFile(fileName, 'utf8', function(error, result) {
        // This is a new function object created every time readFileAsJson is called
        // Since it's a closure, an internal Context object is also 
        // allocated for the closure state
        if (error) {
            return callback(error);
        }
        // The try-catch block is needed to handle a possible syntax error from invalid JSON
        try {
            var json = JSON.parse(result);
            callback(null, json);
        } catch (e) {
            callback(e);
        }
    })
}

この場合、fs.readFileに渡されたコールバック関数は、固有の変数の呼び出しの上にクロージャを生成してしまうため、readFileAsJsonの外に移動させることはできません。さらに、fs.readFileをインライン無名関数の代わりに名前のついた関数宣言で呼び出しても変わりはないことに注意してください。

Bluebird内で使用されている最適化は、ある意味コンテキストデータを保持する明示的なプレーンオブジェクトの使用なのです。複数の層を通過するコールバック関数の処理において、そのようなオブジェクトをただ一つ割り当てる必要があります。それぞれの層でコールバック関数が呼び出されるたびに新しいクロージャを生成して次の層に渡すのではなく、明示的なプレーンオブジェクトを追加の引数として渡します。例えば、操作に5つのコールバック関数処理が伴うとします。クロージャを使用するということは、5つの関数オブジェクトとcontextオブジェクトを割り当てることになりますが、明示的なプレーンオブジェクトの最適化を使用した場合は1つのプレーンオブジェクトの割り当てで済みます。

もしfs.readFile APIがcontextオブジェクトを受けられるよう修正できるようであれば、最適化を適用すると次のようになります。

var fs = require('fs-modified');

function internalReadFileCallback(error, result) {
    // The modified readFile calls the callback with the context object set to `this`,
    // which is just the original client's callback function
    if (error) {
        return this(error);
    }
    // The try-catch block is needed to handle a possible syntax error from invalid JSON
    try {
        var json = JSON.parse(result);
        this(null, json);
    } catch (e) {
        this(e);
    }
}

function readFileAsJson(fileName, callback) {
    // The modified fs.readFile would take the context object as 4th argument.
    // There is no need to create a separate plain object to contain `callback` so it
    // can just be made the context object directly.
    fs.readFile(fileName, 'utf8', internalReadFileCallback, callback);
}

見てのとおり、APIの両端の制御が必要となるため、contextオブジェクト引数を取らないAPIにはこの最適化を適応することができません。しかし使えるようになれば(例えば、複数の内部の層を制御している場合)、パフォーマンスはかなり改善されます。あまり知られていない事実ですが、いくつかの組み込みJavaScript配列API(Array.prototype.forEachなど)ではcontextオブジェクト引数を2つ目の引数として保持します。

2. オブジェクトサイズを最小に抑える

例えばPromiseでは、割り当てるオブジェクトのサイズを最小に抑えることは多くの場合必要となり、しかも大量のオブジェクトを最小化する必要があります。広く使用されているJavaScriptでは、オブジェクトが割り当てられるヒープはセグメントと領域に分割されています。小さいオブジェクトであれば大きいオブジェクトよりもセグメントや領域を使い切るのに時間がかかり、ガベージコレクションの出番は少なくなります。一般的に小さいオブジェクトは、ガベージコレクションがオブジェクトの生死を判断する際に使用するフィールドを多く持っていません。

ビット演算子を使用すれば、ブーリアンフィールドや制限付き整数フィールドをより狭い領域にパックすることができます。JavaScriptのビット演算子は32ビット整数で動くため、例えば32のブーリアンフィールドや4ビット整数8つや8ビット整数2つを1つのフィールドに入力することができます。コードを読みやすい状態に保つため、それぞれの論理フィールドに物理的フィールド上で正しいビット演算を実行するgetter関数とsetter関数のペアを持つ必要があります。(必要に応じて、拡張して複数の論理フィールドを保持できるようすることができる)パック整数に1つのブーリアンフィールドをまとめると次のようになります。

// Use 1 << 1 for the second bit, 1 << 2 for the third bit etc.
const READONLY = 1 << 0;

class File {
    constructor() {
        this._bitField = 0;
    }

    isReadOnly() {
        // Parentheses are required.
        return (this._bitField & READONLY) !== 0;
    }

    setReadOnly() {
        this._bitField = this._bitField | READONLY;
    }

    unsetReadOnly() {
        this._bitField = this._bitField & (~READONLY);
    }
}

アクセサメソッドは短いため、実行の際にインラインされ、関数呼び出しのオーバーヘッドはありません。

同時に使用されることのない複数のフィールドは、フィールド入力されている値のタイプを確認する際に使用するブーリアンで1つのフィールドに圧縮することができます。しかし、前でも述べたとおり、パックされた整数フィールドとしてブーリアンフィールドが実装されている場合は、領域の節約にしかなりません。

Promiseのアクション成功もしくはアクション失敗を格納する際にこの技をBluebirdでは使用しています。明示的なフィールドはありませんが、promiseのアクションが成功した場合は、その値はrejectionコールバックフィールドに格納され、もしpromiseが失敗された場合は、fulfillmentコールバックフィールドに値が格納されます。ここでも、この最適化のきれいではない部分を隠してくれるアクセサ関数を介してアクセスする必要があります。

もしオブジェクトがアイテムのリストを必要とした場合は、単にオブジェクトのインデックス付きプロパティに値を直接格納することで、個別の配列の割り当てを避けることができます。

そのため、次のようにせず、

class EventEmitter {
    constructor() {
        this.listeners = [];
    }

    addListener(fn) {
        this.listeners.push(fn);
    }
}

次のようにすれば、配列を避けることができます。

class EventEmitter {
    constructor() {
        this.length = 0;
    }

    addListener(fn) {
        var index = this.length;
        this.length++;
        this[index] = fn;
    }
}

もし、.lengthフィールドが小さい整数(例えば10ビットであれば、EventEmitterを最大1024リスナに限定することができます)に制限すれば、パックビットフィールドの一部にすることかできるだけでなく、他の制限付き整数フィールドやブーリアンフィールドの一部にすることができます。

3. Noop関数を使用して実行にコストのかかるオプション機能を上書きする

Bluebirdには使用するとライブラリ全体の性能を低下させるオプション機能がいくつかあります。警告やロングスタックトレース、キャンセル、Promise.prototype.bind、promise状態監視などの機能です。これら機能はライブラリ全体に対して呼び出されるフック関数が必要になります。例えば、promise状態監視機能はpromiseが生成されるたびに呼び出される関数を必要とします。

監視機能を有効にしていたとしてもフック関数を呼び出す前に、必ず有効になっていることを確認した方がいいと思います。しかし、インラインキャッシュとインライン関数のおかげでこの機能を無効にしていればコストを無くすことができます。最初にフックメソッドを空の関数に設定することで可能になります。

class Promise {

    // ...

    constructor(executor) {
        // ...
        this._promiseCreatedHook();
    }

    // Just an empty no-op method.
    _promiseCreatedHook() {}

}

これで監視機能を無効にしてしまうと、オプティマイザは何もしない関数を呼び出していると判断し削除してしまいます。そのため、コンストラクタの内部のフックメソッドの呼び出しは、事実上存在しないことになります。

機能させたい場合には、機能を有効にすることで関連する全てのnoop関数を実装されているものに上書きするようにする必要があります。

function enableMonitoringFeature() {
    Promise.prototype._promiseCreatedHook = function() {
        // Actual implementation here
    };

    // ...
}

このようにメソッドを上書きすると、promiseクラスのオブジェクトのために構築されたインラインキャッシュを無効にしてしまいます。そのため、promiseのオブジェクトが生成される前のアプリケーション起動時のみで実行してください。関数使用前に上書きをすれば、機能が有効になった後に構築されるインラインキャッシュは、noop関数が存在していたことに気が付きません。