V8 效能優化殺手

lsvih發表於2017-07-03

V8 效能優化殺手

簡介

這篇文章將會給你一些建議,讓你避免寫出效能遠低於期望值的程式碼。在此特別指出有一些程式碼會導致 V8 引擎(涉及到 Node.JS、Opera、Chromium 等)無法對相關函式進行優化。

vhf 正在做一個類似的專案,試圖將 V8 引擎的效能殺手全部列出來:V8 Bailout Reasons

V8 引擎背景知識

V8 引擎中沒有直譯器,但有 2 種不同的編譯器:普通編譯器與優化編譯器。編譯器會將你的 JavaScript 程式碼編譯成組合語言後直接執行。但這並不意味著執行速度會很快。被編譯成組合語言後的程式碼並不能顯著地提高其效能,它只能省去直譯器的效能開銷,如果你的程式碼沒有被優化的話速度依然會很慢。

例如,在普通編譯器中 a + b 將會被編譯成下面這樣:

mov eax, a
mov ebx, b
call RuntimeAdd複製程式碼

換句話說,其實它僅僅呼叫了 runtime 函式。但如果 ab 能確定都是整型變數,那麼編譯結果會是下面這樣:

mov eax, a
mov ebx, b
add eax, ebx複製程式碼

它的執行速度會比前面那種去在 runtime 中呼叫複雜的 JavaScript 加法演算法快得多。

通常來說,使用普通編譯器將會得到前面那種程式碼,使用優化編譯器將會得到後面那種程式碼。走優化編譯器的程式碼可以說比走普通編譯器的程式碼效能好上 100 倍。但是請注意,並不是任何型別的 JavaScript 程式碼都能被優化。在 JS 中,有很多種情況(甚至包括一些我們常用的語法)是不能被優化編譯器優化的(這種情況被稱為“bailout”,從優化編譯器降級到普通編譯器)。

記住一些會導致整個函式無法被優化的情況是很重要的。JS 程式碼被優化時,將會逐個優化函式,在優化各個函式的時候不會關心其它的程式碼做了什麼(除非那些程式碼被內聯在即將優化的函式中。)。

這篇文章涵蓋了大多數會導致函式墜入“無法被優化的深淵”的情況。不過在未來,優化編譯器進行更新後能夠識別越來越多的情況時,下面給出的建議與各種變通方法可能也會變的不再必要或者需要修改。

主題

  1. 工具
  2. 不支援的語法
  3. 使用 arguments
  4. Switch-case
  5. For-in
  6. 退出條件藏的很深,或者沒有定義明確出口的無限迴圈

1. 工具

你可以在 node.js 中使用一些 V8 自帶的標記來驗證不同的程式碼用法對優化的影響。通常來說你可以建立一個包括特定模式的函式,然後使用所有允許的引數型別去呼叫它,再使用 V8 的內部去優化與檢查它:

test.js:

//建立包含需要檢查的情況的函式(檢查使用 `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;
    }
}

//識別型別資訊
exampleFunction();
//這裡呼叫 2 次是為了讓這個函式狀態從 uninitialized -> pre-monomorphic -> monomorphic
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複製程式碼

codereview.chromium.org/1962103003

為了檢驗我們做的這個工具是否真的有用,註釋掉 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複製程式碼

事實證明,使用這個工具來驗證處理方法是可行且必要的。

2. 不支援的語法

有一些語法結構是不支援被編譯器優化的,用這類語法將會導致包含在其中的函式不能被優化。

請注意,即使這些語句不會被訪問到或者不會被執行,它仍然會導致整個函式不能被優化。

例如下面這樣做是沒用的:

if (DEVELOPMENT) {
    debugger;
}複製程式碼

即使 debugger 語句根本不會被執行到,上面的程式碼將會導致包含它的整個函式都不能被優化。

目前不可被優化的語法有:

  • Generator 函式V8 5.7 對其做了優化)
  • 包含 for of 語句的函式 (V8 commit 11e1e20 對其做了優化)
  • 包含 try catch 語句的函式 (V8 commit 9aac80f / V8 5.3 / node 7.x 對其做了優化)
  • 包含 try finally 語句的函式 (V8 commit 9aac80f / V8 5.3 / node 7.x 對其做了優化)
  • 包含let 複合賦值的函式 (Chrome 56 / V8 5.6! 對其做了優化)
  • 包含 const 複合賦值的函式 (Chrome 56 / 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;
        }
    };
}複製程式碼

另外在此要特別提一下 evalwith,它們會導致它們的呼叫棧鏈變成動態作用域,可能會導致其它的函式也受到影響,因為這種情況無法從字面上判斷各個變數的有效範圍。

變通辦法

前面提到的不能被優化的語句用在生產環境程式碼中是無法避免的,例如 try-finallytry-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]);
//明確地報出 try-catch 會丟擲什麼
if(result === errorObject) {
    var error = errorObject.value;
}
else {
    //result 是返回值
}複製程式碼

3. 使用 arguments

有許多種使用 arguments 的方式會導致函式不能被優化。因此當使用 arguments 的時候需要格外小心。

3.1. 在非嚴格模式中,對一個已經被定義,同時在函式體中被 arguments 引用的引數重新賦值。典型案例:

function defaultArgsReassign(a, b) {
     if (arguments.length < 2) b = 5;
}複製程式碼

變通方法 是將引數值儲存在一個新的變數中:

function reAssignParam(a, b_) {
    var b = b_;
    //與 b_ 不同,可以安全地對 b 進行重新賦值
    if (arguments.length < 2) b = 5;
}複製程式碼

如果僅僅是像上面這樣用 arguments(上面程式碼作用為檢測第二個引數是否存在,如果不存在則賦值為 5),也可以用 undefined 檢測來代替這段程式碼:

function reAssignParam(a, b) {
    if (b === void 0) b = 5;
}複製程式碼

但是之後如果需要用到 arguments,很容易忘記需要在這兒加上重新賦值的語句。

變通方法 2:為整個檔案或者整個函式開啟嚴格模式 ('use strict')。

3.2. arguments 洩露:

function leaksArguments1() {
    return arguments;
}複製程式碼
function leaksArguments2() {
    var args = [].slice.call(arguments);
}複製程式碼
function leaksArguments3() {
    var a = arguments;
    return function() {
        return a;
    };
}複製程式碼

arguments 物件在任何地方都不允許被傳遞或者被洩露。

變通方法 可以通過建立一個陣列來代理 arguments 物件:

function doesntLeakArguments() {
                    //.length 僅僅是一個整數,不存在洩露
                    //arguments 物件本身的問題
    var args = new Array(arguments.length);
    for(var i = 0; i < args.length; ++i) {
                //i 是 arguments 物件的合法索引值
        args[i] = arguments[i];
    }
    return args;
}

function anotherNotLeakingExample() {
    var i = arguments.length;
    var args = [];
    while (i--) args[i] = arguments[i];
    return args
}複製程式碼

但是這樣要寫很多讓人煩的程式碼,因此得判斷是否真的值得這麼做。後面一次又一次的優化會代理更多的程式碼,越來越多的程式碼意味著程式碼本身的意義會被逐漸淹沒。

不過,如果你有 build 這個過程,可以將上面這一系列過程由一個不需要 source map 的巨集來實現,保證程式碼為合法的 JavaScript:

function doesntLeakArguments() {
    INLINE_SLICE(args, arguments);
    return args;
}複製程式碼

Bluebird 就使用了這個技術,上面的程式碼經過 build 之後會被擴充成下面這樣:

function doesntLeakArguments() {
    var $_len = arguments.length;
    var args = new Array($_len); 
    for(var $_i = 0; $_i < $_len; ++$_i) {
        args[$_i] = arguments[$_i];
    }
    return args;
}複製程式碼

3.3. 對 arguments 進行賦值:

在非嚴格模式下可以這麼做:

function assignToArguments() {
    arguments = 3;
    return arguments;
}複製程式碼

變通方法:犯不著寫這麼蠢的程式碼。另外,在嚴格模式下它會報錯。

那麼如何安全地使用 arguments 呢?

只使用:

  • arguments.length
  • arguments[i] i 需要始終為 arguments 的合法整型索引,且不允許越界
  • 除了 .length[i],不要直接使用 arguments
  • 嚴格來說用 fn.apply(y, arguments) 是沒問題的,但除此之外都不行(例如 .slice)。 Function#apply 是特別的存在。
  • 請注意,給函式新增屬性值(例如 fn.$inject = ...)和繫結函式(即 Function#bind 的結果)會生成隱藏類,因此此時使用 #apply 不安全。

如果你按照上面的安全方式做,毋需擔心使用 arguments 導致不確定 arguments 物件的分配。

4. Switch-case

在以前,一個 switch-case 語句最多隻能包含 128 個 case 程式碼塊,超過這個限制的 switch-case 語句以及包含這種語句的函式將不能被優化。

function over128Cases(c) {
    switch(c) {
        case 1: break;
        case 2: break;
        case 3: break;
        ...
        case 128: break;
        case 129: break;
    }
}複製程式碼

你需要讓 case 程式碼塊的數量保持在 128 個之內,否則應使用函式陣列或者 if-else。

這個限制現在已經被解除了,請參閱此 comment

5. For-in

For-in 語句在某些情況下會導致整個函式無法被優化。

這也解釋了”For-in 速度不快“之類的說法。

5.1. 鍵不是區域性變數:

function nonLocalKey1() {
    var obj = {}
    for(var key in obj);
    return function() {
        return key;
    };
}複製程式碼
var key;
function nonLocalKey2() {
    var obj = {}
    for(key in obj);
}複製程式碼

這兩種用法db都將會導致函式不能被優化的問題。因此鍵不能在上級作用域定義,也不能在下級作用域被引用。它必須是一個區域性變數。

5.2. 被遍歷的物件不是一個”簡單可列舉物件“

5.2.1. 處於”雜湊表模式“(又被稱為”歸一化物件“或”字典模式物件“ - 這種物件將雜湊表作為其資料結構)的物件不是簡單可列舉物件。
function hashTableIteration() {
    var hashTable = {"-": 3};
    for(var key in hashTable);
}複製程式碼

如果你給一個物件動態增加了很多的屬性(在建構函式外)、delete 屬性或者使用不合法的識別符號作為屬性,這個物件將會變成雜湊表模式。換句話說,當你把一個物件當做雜湊表來用,它就真的會變成雜湊表。請不要對這種物件使用 for-in。你可以用過開啟 Node.JS 的 --allow-natives-syntax,呼叫 console.log(%HasFastProperties(obj)) 來判斷一個物件是否為雜湊表模式。


5.2.2. 物件的原型鏈中存在可列舉屬性
Object.prototype.fn = function() {};複製程式碼

上面這麼做會給所有物件(除了用 Object.create(null) 建立的物件)的原型鏈中新增一個可列舉屬性。此時任何包含了 for-in 語法的函式都不會被優化(除非僅遍歷 Object.create(null) 建立的物件)。

你可以使用 Object.defineProperty 建立不可列舉屬性(不推薦在 runtime 中呼叫,但是在定義一些例如原型屬性之類的靜態資料的時候它很高效)。


5.2.3. 物件中包含可列舉陣列索引

ECMAScript 262 規範 定義了一個屬性是否有陣列索引:

陣列物件會給予一些種類的屬性名特殊待遇。對一個屬性名 P(字串形式),當且僅當 ToString(ToUint32(P)) 等於 P 並且 ToUint32(P) 不等於 232−1 時,它是個 陣列索引 。一個屬性名是陣列索引的屬性也叫做元素 。

一般只有陣列有陣列索引,但是有時候一般的物件也可能擁有陣列索引: normalObj[0] = value;

function iteratesOverArray() {
    var arr = [1, 2, 3];
    for (var index in arr) {

    }
}複製程式碼

因此使用 for-in 進行陣列遍歷不僅會比 for 迴圈要慢,還會導致整個包含 for-in 語句的函式不能被優化。


如果你試圖使用 for-in 遍歷一個非簡單可列舉物件,它會導致包含它的整個函式不能被優化。

變通方法:只對 Object.keys 使用 for-in,如果要遍歷陣列需使用 for 迴圈。如果非要遍歷整個原型鏈上的屬性,需要將 for-in 隔離在一個輔助函式中以降低影響:

function inheritedKeys(obj) {
    var ret = [];
    for(var key in obj) {
        ret.push(key);
    }
    return ret;
}複製程式碼

6. 退出條件藏的很深,或者沒有定義明確出口的無限迴圈

有時候在你寫程式碼的時候,你需要用到迴圈,但是不確定迴圈體內的程式碼之後會是什麼樣子。所以這時候你用了一個 while (true) { 或者 for (;;) {,在之後將終止條件放在迴圈體中,打斷迴圈進行後面的程式碼。然而你寫完這些之後就忘了這回事。在重構時,你發現這個函式很慢,出現了反優化情況 - 上面的迴圈很可能就是罪魁禍首。

重構時將迴圈內的退出條件放到迴圈的條件部分並不是那麼簡單。

  1. 如果程式碼中的退出條件是迴圈最後的 if 語句的一部分,且程式碼至少要執行一輪,那麼你可以將這個迴圈重構為 do{} while ();
  2. 如果退出條件在迴圈的開頭,請將它放在迴圈的條件部分中去。
  3. 如果退出條件在迴圈體中部,你可以嘗試”滾動“程式碼:試著依次將一部分退出條件前的程式碼移到後面去,然後在之前的位置留下它的引用。當退出條件可以放在迴圈條件部分,或者至少變成一個淺顯的邏輯判斷時,這個迴圈就不再會出現反優化的情況了。

掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章