Node.jsのパフォーマンス最適化を阻むものの見つけ方

皆さんは“Node.jsではコードが動的に最適化される”という記事を幾つか読んだことがあるかもしれません。本稿では、この文言が意味するところとコードが最適化される箇所の見つけ方について見ていきます。

Node.jsのパフォーマンス最適化を阻むものについて述べた本稿をご覧いただいたあとには、以下のことができるようになります。

  • 関数の最適化がJavaScriptエンジン(V8)で行なわれているかの解明
  • 最適化された関数が最適化戻し(De-optimization)されていないかの解明
  • 関数が最適化できない理由の解明

このアジェンダは大げさですが、記事は至ってシンプルです。目指しているのは、上記の方法論を多くのNode.js開発者に役立てることです。

V8におけるNode.jsのパフォーマンス最適化についての簡単な概要

Node.jsをどの仮想マシン上でも使用可能にするという計画はあるものの、2017年1月現在ではNode.jsのインスタンスのベース部分はV8 JavaScriptエンジンです。本稿のスコープではその部分にフォーカスします。

JavaScriptのコードのスタティックな分析は非常に複雑なものです。その結果、他の言語とは違ってJavaScriptのコードをコンパイル時に最適化するのは難しくなります。

V8ではコードは動的に最適化される。つまり、コードはランタイムの振る舞いに従って最適化される。

クリックしてツイート

最適化のプロセスは実行中に発生します。V8はコードの挙動を分析し、経験則(ヒューリスティック)を発展させ、観測に基づいた最適化を遂行します。

例えば、V8は型アサーションを遂行できるかどうかを見極めるために、関数の入力と出力を監視しています。関数の引数が常に同じ型であれは、そのアサーションを使って関数を最適化しても問題ないでしょう。

V8には別のやり方で遂行する最適化もありますが、引数の型に基づいた最適化は恐らく最も説明しやすいものです。

最適化の分析

以下の小さなスニペットをご覧ください。

// index.js

function myFunc(nb) {  
    return nb + nb;
}

for (let i = 0; i < 2000; ++i) {  
    myFunc(i);
}

このファイルを実行する場合、通常、皆さんが使用するのは$ node index.jsコマンドだと思います。最適化をトレースするため、コマンドラインに引数を追加します。

では、実行しましょう。

$ node --trace-opt index.js | grep myFunc

| grep myFuncの部分が末尾にあるのは、単に観察する関数に関連したログを付けるためです。

結果は標準出力上で表示されます。

vladimir@vlad:~/WebstormProjects/perf$ node --trace-deopt --trace-opt index.js | grep myFunc  
[marking 0x2bc3091e7fc9 <JS Function myFunc (SharedFunctionInfo 0x1866a5c5eeb1)> for recompilation, reason: small function, ICs with typeinfo: 1/1 (100%), generic ICs: 0/1 (0%)]
[compiling method 0x2bc3091e7fc9 <JS Function myFunc (SharedFunctionInfo 0x1866a5c5eeb1)> using Crankshaft]
[optimizing 0x2bc3091e7fc9 <JS Function myFunc (SharedFunctionInfo 0x1866a5c5eeb1)> - took 0.009, 0.068, 0.036 ms]
[completed optimizing 0x2bc3091e7fc9 <JS Function myFunc (SharedFunctionInfo 0x1866a5c5eeb1)>]

関数は再コンパイル用にマークが付けられています。これが関数の最適化の第一歩です。

その後、関数は再コンパイルされ、最適化されます。

続いて最適化戻し(De-optimization)

// index.js

function myFunc(nb) {  
    return nb + nb;
}

for (let i = 0; i < 2000; ++i) {  
    myFunc(i);
}

for (let i = 0; i < 2000; ++i) {  
    myFunc(i + '');
}

前述のコードとほとんど同じですが、ここでは数値だけで関数を呼び出した後に文字列で呼び出します。+演算子が数値の加算や文字列の連結に使用できるため、これも完全に有効なコードです。

以下を使って、このコードを実行してみましょう。

$ node --trace-deopt --trace-opt index.js | grep myFunc
vladimir@vlad:~/WebstormProjects/perf$ node --trace-deopt --trace-opt index.js | grep myFunc  
[marking 0xc6b3e5e7fb9 <JS Function myFunc (SharedFunctionInfo 0x87d8115eec1)> for recompilation, reason: small function, ICs with typeinfo: 1/1 (100%), generic ICs: 0/1 (0%)]
[compiling method 0xc6b3e5e7fb9 <JS Function myFunc (SharedFunctionInfo 0x87d8115eec1)> using Crankshaft]
[optimizing 0xc6b3e5e7fb9 <JS Function myFunc (SharedFunctionInfo 0x87d8115eec1)> - took 0.010, 0.076, 0.021 ms]
[completed optimizing 0xc6b3e5e7fb9 <JS Function myFunc (SharedFunctionInfo 0x87d8115eec1)>]
[deoptimizing (DEOPT eager): begin 0xc6b3e5e7fb9 <JS Function myFunc (SharedFunctionInfo 0x87d8115eec1)> (opt #0) @1, FP to SP delta: 24, caller sp: 0x7ffe2cde6f40]
  reading input frame myFunc => node=4, args=2, height=1; inputs:
      0: 0xc6b3e5e7fb9 ; [fp - 16] 0xc6b3e5e7fb9 <JS Function myFunc (SharedFunctionInfo 0x87d8115eec1)>
  translating frame myFunc => node=4, height=0
    0x7ffe2cde6f10: [top + 0] <- 0xc6b3e5e7fb9 ;  function    0xc6b3e5e7fb9 <JS Function myFunc (SharedFunctionInfo 0x87d8115eec1)>  (input #0)
[deoptimizing (eager): end 0xc6b3e5e7fb9 <JS Function myFunc (SharedFunctionInfo 0x87d8115eec1)> @1 => node=4, pc=0x30c7754496c6, caller sp=0x7ffe2cde6f40, state=NO_REGISTERS, took 0.047 ms]
[removing optimized code for: myFunc]
[evicting entry from optimizing code map (notify deoptimized) for 0x87d8115eec1 <SharedFunctionInfo myFunc>]

ログの最初の部分は、先ほどのものに非常によく似ています。

しかし、続く部分では関数が最適化前の状態に戻されており、V8は、(”myFuncの入力が数値”)より前になされた型の推定が偽であると検出しています。

誤った経験則(ヒューリスティック)

ここまでの簡単な例で、関数の最適化と最適化戻し(De-optimization)をトレースする方法を見てきました。また、V8による経験則(ヒューリスティック)がいかに脆弱であるかも確認しました。これらから、以下のように言うことができると思います。

JavaScriptの型付けの強さに関係なく、V8には最適化のルールがある。従って、引数として一貫した型付けを持ち、関数の値を返すことが望ましい。

非最適化

前述の例では、最適化される前、再コンパイル用に関数にマークが付けられました。

ただし、場合によっては、V8は関数を最適化不可としてマークすることもあります。次のコードを実行してみましょう。

// try.js
function myFunc(nb) {  
    try {
        return nb + nb;
    }
    catch (err) {
        return err;
    }
}

for (let i = 0; i < 2000; ++i) {  
    myFunc(i);
}
vladimir@vlad:~/WebstormProjects/perf$ node --trace-deopt --trace-opt try.js | grep myFunc  
[disabled optimization for 0x3a450705eeb1 <SharedFunctionInfo myFunc>, reason: TryCatchStatement]

このmyFuncでは、最適化用にマークが付けられる代わりに、”最適化不可”としてマークされています。その理由は、ログ内の”TryCatchStatement”を見れば明らかです。

基本的にtry-catchのステートメントを含む関数は、最適化できないものと見なされます。

そのロジックは至って簡単です。JavaScriptには、実行時の振る舞いが大きく異なるパターンがあります。V8はそれらの関数に対して、最適化戻しのアリ地獄にはまらないよう、あらかじめ最適化しないことを決定しているのです。

最適化戻しのアリ地獄

関数が実行時に最適化と最適化戻しを何度も繰り返すことで、V8は最適化戻しのアリ地獄にはまります。

最適化と最適化戻しが数サイクル行われると、V8はメソッドを最適化できないものとしてフラグを立てます。しかし、このサイクルで失われる時間は相当なもので、プロセスのパフォーマンスとメモリ消費に大きな影響を与えます。

その他の非最適化のケース

V8による最適化を止めるパターンは、他にも数多くあります。詳しくはGithubのリポジトリにリストされているのでご覧ください。

非最適化について

次に、さほどエレガントとは言えませんが、try-catchステートメントの例を使った非最適化のパターンを扱うメソッドを見ていきたいと思います。では、始めましょう。

function tryThis (run, caught) {

    try {
        return run();
    }
    catch (err) {
        return caught(err);
    }
}

function myFunc(nb) {  
    return tryThis(() => nb + nb, (err) => err)
}

for (let i = 0; i < 2000; ++i) {  
    myFunc(i);
}
vladimir@vlad:~/WebstormProjects/perf$ node --trace-opt tryHack.js | grep -E 'myFunc|tryThis'  
[disabled optimization for 0x33aa5d55ecf1 <SharedFunctionInfo tryThis>, reason: TryCatchStatement]
[marking 0x5099c3e7e89 <JS Function myFunc (SharedFunctionInfo 0x33aa5d55edb1)> for recompilation, reason: small function, ICs with typeinfo: 1/1 (100%), generic ICs: 0/1 (0%)]
[compiling method 0x5099c3e7e89 <JS Function myFunc (SharedFunctionInfo 0x33aa5d55edb1)> using Crankshaft]
[marking 0x5099c3f4c11 <JS Function tryThis (SharedFunctionInfo 0x33aa5d55f729)> for recompilation, reason: small function, ICs with typeinfo: 1/1 (100%), generic ICs: 0/1 (0%)]
[marking 0x5099c3fb269 <JS Function tryThis (SharedFunctionInfo 0x33aa5d55f729)> for recompilation, reason: small function, ICs with typeinfo: 1/1 (100%), generic ICs: 0/1 (0%)]
[optimizing 0x122928c04f49 <JS Function myFunc (SharedFunctionInfo 0x33aa5d55edb1)> - took 0.013, 0.103, 0.052 ms]
[completed optimizing 0x122928c04f49 <JS Function myFunc (SharedFunctionInfo 0x33aa5d55edb1)>]
[marking 0x122928c94901 <JS Function tryThis (SharedFunctionInfo 0x33aa5d55f729)> for recompilation, reason: small function, ICs with typeinfo: 1/1 (100%), generic ICs: 0/1 (0%)]

このログからは以下のことが分かります。

  • try-catchステートメントが含まれているため、tryThisに対して最適化が無効になっている。
  • myFuncが最適化されている。
  • tryThisには再コンパイル用のマークが付けられているものの、この関数では無効になっているため実行されない。

以上のことから、V8の最適化については、もう1つ言うことができるでしょう。

非最適化パターンを、最適化されない個別の関数に分離する。

まとめ

この記事では、Node.jsの最適化、最適化戻し、そして非最適化をトレースする方法を見てきました。Node.jsのコード最適化に臨むに当たり、いい出発点となるでしょう。

最適化と最適化戻しを高度に追求したい場合、IRHydraというツールがあります。Eugene Obrezkovのブログに、Node.jsでの簡単な使い方が紹介されているので見てみてください。

この記事が興味深いと思われた方はぜひ共有してください。また、Sqreenや私の記事に関して疑問点や話したいことなどがあれば、Twitterやeメール(vladimir@sqreen.io)で私宛に遠慮なくご連絡いただければと思います。

Sqreenのブログをフォローすれば、今後も私の記事を読んでいただけます。内容はNode.js(およびそのセキュリティ)についてです。それから、もし皆さんがプロダクション環境でNode.jsのアプリケーションを動かしている場合は、ぜひ私たちのSqreenのこともチェックしてみてください。アプリケーションのセキュリティ関連のイベントを監視し、攻撃から保護するためのシンプルなソリューションを提供しています。特筆すべきはSqreenのインストールで、コードにnpmパッケージを追加するのと同じくらい簡単です。