以下はOptimization killersの日本語訳です。
Optimization killers
Introduction
このドキュメントには、あなたが非常に悪いコードを量産するのを避けるためのアドバイスが含まれています。
具体的には、Node.js、Opera、Crhomium等のV8 JavaScriptエンジンが最適化コンパイルを行えないパターンを列挙しています。
vhfは他にも、同じようにV8の全ての最適化キラーをリストアップしようとしてる別のプロジェクトにも取り組んでいます。
Some V8 background
V8はインタプリタを持っておらず、かわりに2種類のコンパイル機能を持っています。すなわち汎用 ( generic ) と最適化 ( optimizing ) です。
従ってJavaScriptは常にコンパイルされ、ネイティブコードとして実行されることになります。
つまり、JavaScriptの実行は常に早いってこと?
いえ、残念ながらそうではありません。
コードがネイティブかそうでないかは、実はパフォーマンスにとってそこまで重要ではありません。
インタプリタ特有のオーバーヘッドはなくなりますが、コードが最適化されているか否かに比べればたいした違いではありません。
たとえばa+b
は、汎用コンパイラでは以下のようにコンパイルされます。
mov eax, a
mov ebx, b
call RuntimeAdd
つまり、単にランタイム関数を呼び出すだけです。
ところで、aとbが共に整数だった場合は以下のようにコンパイルされます。
mov eax, a
mov ebx, b
add eax, ebx
ランタイム呼び出しよりもちょっぱやです。
基本的に、汎用コンパイラでは前者の、最適化コンパイラでは後者のネイティブコードに変換されます。
最適化コンパイラによってコンパイルされたコードは、汎用コンパイラでできたコードの100倍以上高速に動作します。
そして、ここに罠が存在します。
単にJavaScriptを書くだけでは、必ずしも最適化コンパイラが動いてくれるとは限らないのです。
最適化コンパイラが作業を拒否してコードが駄目になってしまう書き方には多くのパターンがあり、そのうち幾つかはベストプラクティスとされてすらいます。
コードが最適化されるのは関数単位であるということに注意しないといけません。
コンパイラは基本的に、コードの他の部分が何をしているのかを知っている必要は無く、ひとつの関数が最適化可能であれば最適化します。
ただし、最適化の結果コードがインライン化されている場合は例外です。
このガイドでは、関数の最適化を邪魔するコードの記述について解説します。
なお、これらは変更される可能性があり、コンパイラのバージョンアップによって、回避策を使わずとも最適化コンパイラが仕事をしてくれるようになる場合があります。
Topics
Tooling
Node.jsでは幾つかのフラグによって、コードがどのように最適化に影響を及ぼすかを検証することができます。
調査対象のコードを含む関数を作成し、全ての型の引数でその関数を呼び出し、その後V8の内部関数を呼び出して最適化の検査を行います。
// 調べたいコードを含む関数 ( 今回は`eval` )
function exampleFunction() {
return 3;
eval('');
}
function printStatus(fn) {
switch(%GetOptimizationStatus(fn)) {
case 1: console.log("Function is optimized"); break;
case 2: console.log("Function is not optimized"); break;
case 3: console.log("Function is always optimized"); break;
case 4: console.log("Function is never optimized"); break;
case 6: console.log("Function is maybe deoptimized"); break;
case 7: console.log("Function is optimized by TurboFan"); break;
default: console.log("Unknown optimization status"); break;
}
}
// 事前に2回呼び出しが必要らしい
exampleFunction();
exampleFunction();
%OptimizeFunctionOnNextCall(exampleFunction);
// 検査実行
exampleFunction();
// 確認
printStatus(exampleFunction);
実行結果。
$ node --trace_opt --trace_deopt --allow-natives-syntax test.js
(v0.12.7) Function is not optimized
(v4.0.0) Function is optimized by TurboFan
新しい環境ではTurboFanによって、トップレベルのevalについては最適化されるようになりました。
eval
を削除して再実行すると、古い環境でも最適化されます。
$ node --trace_opt --trace_deopt --allow-natives-syntax test.js
[optimizing 000003FFCBF74231 <JS Function exampleFunction (SharedFunctionInfo 00000000FE1389E1)> - took 0.345, 0.042, 0.010 ms]
Function is optimized
最適化が機能しているかどうかを確認するためにはツールを使用することが必要です。
Unsupported syntax
最適化コンパイラではサポートされていない構文があります。
そのような構文を使用していると、その関数は最適化されません。
if (DEVELOPMENT) {
debugger;
}
絶対にdebugger
に到達することがなかったとしても、上記が含まれる関数が最適化されることはありません。
以下は現時点で最適化されない構文のリストです。
・ジェネレータ → V8 5.7で最適化
・for-of文 → V8 11e1e20で最適化
・try-catch文 → V8 5.3で最適化
・try-finally文 → V8 5.3で最適化
・letに計算式を代入 → V8 5.6で最適化
・constに計算式を代入 → V8 5.6で最適化
・__proto__、get、setを含むオブジェクトリテラル
おそらく今後も最適化されることはない構文は以下のとおりです。
・debugger
・eval()
・with
以下のような関数を書いてしまうと、その関数が最適化されることはありません。
function containsObjectLiteralWithProto() {
return {__proto__: 3};
}
function containsObjectLiteralWithGetter() {
return {
get prop() {
return 3;
}
};
}
function containsObjectLiteralWithSetter() {
return {
set prop(val) {
this.val = val;
}
};
}
さらにeval
やwith
については、どの変数が定義されるかを静的に知ることが不可能になるため、他の関数のコンパイル結果にまで影響を及ぼす可能性があります。
Workarounds
try-finally
やtry-catch
といった文を全てコードから排除することは難しいでしょう。
影響を最小限にするには、メインコードに出てこないように必要最低限の部分だけに押し込んでおくことが必要です。
var errorObject = {value: null};
function tryCatch(fn, ctx, args) {
try {
return fn.apply(ctx, args);
}
catch(e) {
errorObject.value = e;
return errorObject;
}
}
var result = tryCatch(mightThrow, void 0, [1,2,3]);
// chatchではなく明確にエラーオブジェクトを返す
if(result === errorObject) {
var error = errorObject.value;
}
else {
// resultは返り値
}
Managing arguments
関数は、引数の与え方によっていとも簡単に最適化されなくなります。
引数は非常に注意深く扱わなければなりません。
Reassigning a defined parameter while also mentioning arguments in the body (in sloppy mode only).
引数を関数内で上書きするとNG。
function defaultArgsReassign(a, b) {
if (arguments.length < 2) b = 5;
}
対策としては、新しい変数を作ります。
function reAssignParam(a, b_) {
var b = b_;
// bは上書きしても大丈夫
if (arguments.length < 2) b = 5;
}
唯一、未定義の場合のみ上書きしても問題ありません。
function reAssignParam(a, b) {
if (b === void 0) b = 5;
}
もしくは単にファイルの頭にuse strict
と書いておけば対策できます。
Leaking arguments
function leaksArguments1() {
return arguments;
}
function leaksArguments2() {
var args = [].slice.call(arguments);
}
function leaksArguments3() {
var a = arguments;
return function() {
return a;
};
}
argumentsはそのまま変数の外に出してはいけません。
別の変数に入れてから出力しないといけません。
function doesntLeakArguments() {
// 別の変数に代入してから返す
var args = new Array(arguments.length);
for(var i = 0; i < args.length; ++i) {
args[i] = arguments[i];
}
return args;
}
function anotherNotLeakingExample() {
var i = arguments.length;
var args = [];
while (i--) args[i] = arguments[i];
return args
}
これは最適化が有効になるよう明示的に記載したコードです。
しかし、コードが長くなるうえ、一見無意味にしか見えず、何をやっているか分からない目障りなコードになりがちです。
ビルドツールを使っているのであれば、ソースコード上では素直なJavaScriptにしておき、ビルド時に変換するマクロを導入するとよいでしょう。
function doesntLeakArguments() {
INLINE_SLICE(args, arguments);
return args;
}
これはbluebirdで使われている手法であり、ビルド時に以下のように展開されます。
function doesntLeakArguments() {
var $_len = arguments.length;
var args = new Array($_len);
for(var $_i = 0; $_i < $_len; ++$_i) {
args[$_i] = arguments[$_i];
}
return args;
}
Assignment to arguments
argumentsの上書きもいけません。
function assignToArguments() {
arguments = 3;
return arguments;
}
もっとも、こんな馬鹿なコードを書いてる時点で駄目です。
またstrictモードでは例外が出ます。
What is safe arguments usage?
argumentsを使っていい場合は以下に限られます。
・arguments.length
・arguments[i]、ただし必ず有効なインデックスであること
・.length
と[i]
以外でのargumentsへの直接アクセスは禁止
・strictモードでのfn.apply(y, arguments)
は大丈夫
・関数にプロパティやbind
を追加している場合はapply
も禁止
Switch-case
以前はswitch文ではcaseが最大128個しか使えず、それ以上は最適化しませんでした。
function over128Cases(c) {
switch(c) {
case 1: break;
case 2: break;
case 3: break;
...
case 128: break;
case 129: break;
}
}
上限に引っかかる場合は、if文などを使ってcaseを減らさないといけませんでした。
その後このcaseの上限は取り除かれました。
For-in
for-in
を使うと関数が最適化されないことがあります。
いわゆる「for-inが遅い」問題は大抵これが原因です。
The key is not a local variable
function nonLocalKey1() {
var obj = {}
for(var key in obj);
return function() {
return key;
};
}
var key;
function nonLocalKey2() {
var obj = {}
for(key in obj);
}
キーはローカル変数でないといけません。
上のスコープでも、下のスコープでも最適化されません。
The object being iterated is not a "simple enumerable"
Objects that are in "hash table mode" are not simple enumerables**
単純な列挙型以外のオブジェクトをfor-in
に渡すと最適化されません。
連想配列や辞書型などのハッシュテーブルは単純な列挙型ではありません。
function hashTableIteration() {
var hashTable = {"-": 3};
for(var key in hashTable);
}
コンストラクタ以外のところで動的にプロパティを追加したり、プロパティを削除したり、有効な識別子ではないプロパティ名を使用したりすると、オブジェクトはすぐハッシュテーブルになります。
要するに、オブジェクトをハッシュテーブルとして扱うとオブジェクトはハッシュテーブルになります。
そして、そのようなオブジェクトはfor-in
に渡してはいけません。
Node.jsで--allow-natives-syntax
が有効になっている場合、console.log(%HasFastProperties(obj))
でそのオブジェクトがハッシュテーブルであるか否かを確認可能です。
The object has enumerable properties in its prototype chain**
Object.prototype.fn = function() {};
オブジェクトのprototypeにプロパティを追加すると、Prototype汚染により全てのfor-in
が一切最適化されなくなります。
ただしこの場合はObject.create(null)
でオブジェクトを作れば汚染とは無縁でいられます。
Object.defineProperty
を使って列挙できないプロパティを作ることも可能です。
実行時呼び出しとして使用するのはお勧めできませんが、Prototypeにプロパティを静的に定義したいときなどに有効かもしれません。
The object contains enumerable array indices**
プロパティがArrayオブジェクトであるかどうかのはecma-262に定義されています。
A property name P (in the form of a String value) is an array index if and only if ToString(ToUint32(P)) is equal to P and ToUint32(P) is not equal to 232−1. A property whose property name is an array index is also called an element
通常これらは配列になりますが、normalObj[0] = value;
のように、普通のオブジェクトが配列インデックスを持つこともできます。
function iteratesOverArray() {
var arr = [1, 2, 3];
for (var index in arr) {
}
}
配列以外のオブジェクトに対してfor-in
を使うと、それはfor
ループより遅いだけではなく、そのループを含む関数全体が最適化されません。
回避策は、常にObject.keysを使用し、for
ループを使って反復処理を行うことです。
どうしてもプロトタイプチェーンを含めた全てのプロパティが必要なのであれば、該当のループ部分だけを抜き出した関数を別途用意します。
function inheritedKeys(obj) {
var ret = [];
for(var key in obj) {
ret.push(key);
}
return ret;
}
Infinite loops with deep logic exit conditions or unclear exit conditions
ループが必要になったものの、ループの脱出条件が未だ決まらずコードがはっきりしない場合があります。
そのような場合に、とりあえずwhile (true) {
とかfor (;;) {
といった無限ループ条件を書いて、ループ中でbreak
を記述するようなことがよくあります。
そしてそのまま忘れ去られます。
複雑な脱出条件のある無限ループは、最適化されません。
break
をやめてループの終了条件を正しく記述するリファクタリングは、決してトリビアルなリファクタリングではありません。
ループの最後にif
があり、そこでループ脱出条件を決めている場合、ループ内のコードを少なくとも一回は実行する必要があるため、do{ } while ();
に書き換えます。
ループ脱出条件がループの先頭にあるのであれば、それをループ文の条件部分に移動します。
脱出条件がループの中央にあるのであれば、脱出条件をループの条件部分に下記、脱出条件より上のコードはループの最下部とループの始まる前の2箇所に移動させます。
上記のようにリファクタリングすれば、ループも最適化されるようになります。
感想
JavaScriptってインタプリタじゃなかったのか(化石脳)
ここで言う最適化コンパイラというのはJavaScript実行エンジン「V8」のJITコンパイラ、CrankshaftおよびTurboFanのことで、Closure CompilerやPrepack等のソースコード最適化とは別の話です。
最適化は関数単位で行われるので、どうあがいても最適化できない処理が出てきたら、その最低限の部分だけ抜き出して別の関数にするという実装方針がいいようです。
つまりvar hoge = (function() {})();
とかの書き方は全滅ってことなのだろうか?(わかってない)
evalが今後最適化されることはないと言ってるのにトップレベルevalが最適化されるあたりがよくわからなかった。
あとこの記事どうも中途半端で、他にも関数の引数が512以上でNGとか、yieldは存在自体駄目とかあるみたい。
あとは足し算する関数function add(a, b){return a + b;}
は2個用意しないといけないらしい。
最適化の道は険しい。