你不知道的JavaScript——效能測試和調優

廣州蘆葦科技web前端發表於2019-01-14

效能測試和調優

你不知道的JavaScript讀書筆記

之前我們討論過巨集觀層面上的JavaScript效能問題,討論了asm.jsWebAssemblyWebWorker技術,接下來我們探究一下JavaScript在微觀層面上的效能問題,並逐步瞭解這些效能問題是否真實存在,以及是否需要花大量時間去優化。

效能測試問題

如果我們要測試一段程式碼的執行速度(執行時間),我們通常第一時間會想到編寫以下程式碼進行測試:

var start = Date.now()

// do something

console.log('用時:' + (Date.now() - start))

複製程式碼

這在很長一段時間裡,我都認為這段程式碼能測試出絕大數多正確的結果,而事實上這段程式碼的結果非常不準確

  1. 它很有可能報告的時間是0,因為他的執行時間可能小於1ms。或者在一些早期引擎中,定時器的精度只有15ms,也就是這個運算至少要執行15ms才會有結果輸出。
  2. 對於一個單次的執行幾乎沒有任何參考價值,我們不能保證引擎或系統在此刻沒有受到其他因素干擾。
  3. 在獲得時間戳時可能存在延遲。
  4. 不能確定引擎是否對這段測試程式碼進行了優化。在真實程式中引擎是否會同樣優化這段程式碼,如果不能,這就會導致真實環境中程式碼執行變慢。

Benchmark.js

基於以上自寫測試用例的弊端,我們首先需要做的是重複,簡單的說,就是用迴圈把測試程式碼包起來,但這並不是一個簡單的迴圈多次求平均值的過程,相關的考慮因素還有定時器精度,結果分佈情況等。可靠的測試應該結合統計學的合理實踐,所以在自己沒有更好的解決方法之前,選用成熟的測試工具是一個正確的決定,Benchmark.js就是一個這樣的js庫。

npm方式安裝benchmark

npm install benchmark --save
複製程式碼

編寫一個測試檔案

// index.js
var Benchmark = require('benchmark');

function foo () {
  var arr = new Array(10000)

  for(var i = 0;i < arr.length;i++) {
    arr[i] = 0
  }
}

var bench = new Benchmark(
  'foo test', // 測試名
  foo, // 測試內容
  {
    setup: `console.log('start')`, // 每個測試迴圈開始時呼叫
    teardown: `console.log('over')` // 每個測試迴圈結束時呼叫
  }
)
bench.run() // 開始測試

console.log(bench.hz) // 每秒執行數
console.log(bench.stats.moe) // 出錯邊界
console.log(bench.stats.variance) // 樣本方差
複製程式碼

第三個引數中的setupteardown是我們尤其要注意的,第三個引數指定測試用例的一些額外資訊,其中的setup表示每個測試周期開始時執行的方法,可以只是方法體,也可以是指定方法,teardown表示每個測試周期結束時執行的方法,型別同上。也就是執行上面的程式碼setup不止執行一次,具體執行次數由Benchmark.prototype.circle決定。

效能優化的注意點

效能優化是否存在真實意義

比如在一次測試環境中,測試運算A每秒可執行10 000 000次,運算B每秒可執行8 000 000,這隻能在數學意義上來講B比A慢了20%。 我們換個比較方法,從上面的結果不難推出A單次執行需要100ns,據說人眼通常能分辨100ms以下的事件,人腦可以處理的最快速度是13ms。也就是運算A要執行650 000次才能有希望被人類感知到,而在web應用中,幾乎很少會進行類似的操作。 比較這麼微小的差異和比較++a a++在效能上的差異一樣,意義不大。

引擎優化

由於引擎優化的存在,所以你不能確定一個運算A是否始終比運算B快,下面的程式碼

var a = '12'

// 測試1
var A = Number(a)

// 測試2
var B = parseInt(a)
複製程式碼

這段程式碼想比較NumberparseInt在型別轉換上的效能差異,但是由於引擎優化的存在,這種測試會變得沒有參考性,由於引擎優化沒有被納入es的規範內容,可能有些引擎在執行測試程式碼的時候進行了啟發式優化,它發現A和B都沒有在後續被使用,所以在整個測試中實際上什麼事情都沒有發生,而在真實環境中,可能又並非如此。所以我們必須讓測試環境更可能的接近真實環境。

jsPerf.com

很多情況下需要測試不同環境下的程式碼執行情況,比如在chrome和在手機版chrome中的結果對比,在滿電手機和電量2%以下手機的執行結果對比。jsPerf.com是一個共享測試用例和測試結果的平臺。

過早優化是萬惡之源

程式設計師們浪費了大量的時間用於思考,或擔心他們的程式中非關鍵部分的速度,這些針對效率的努力在除錯和維護方面帶來了強烈的負面效果。我們應該在,比如說97%的時間裡,忘掉小處的效率:過早優化是萬惡之源。但我們不應該錯過關鍵的3%的機會。 《計算訪談6》

不應該在非關鍵部分花太多時間,比如你的應用是一個動畫表現的應用,就應該重點優化動畫迴圈相關的程式碼。

測試用例舉例

// 測試1
var x = [1,2,3,4,5]
x.sort()

// 測試2
var x = [1,2,3,4,5]

x.sort(function (a,b) {
  return a - b
})
複製程式碼

這兩個測試對比sort(..)內建方法和自定義方法的效能,但是這建立的了一個不公平的對比:

  1. 在循序測試中,自定義方法會不斷被建立,這顯然會增加額外的開銷。
  2. 忽略了內建方法的額外工作:內建方法是將比較值強制裝換成字串進行比較,比如內建排序會把18排在2前面。
// 測試1
var x = false;
var y = x ? 1 : 2;

// 測試2
var x;
var y = x ? 1 : 2;
複製程式碼

上面這個測試如果是想比較Boolean值強制型別轉換對效能的影響,那麼就建立了一個不公平的對比,因為測試2少做了x的賦值操作。要消除這個影響,應該這樣做:

// 測試2
var x = undefined;
var y = x ? 1 : 2;
複製程式碼

最後我們來實際測試一下,在for迴圈中是否需要預先將arr.length設定好

var Benchmark = require('benchmark');

var suite = new Benchmark.Suite; // Benchmark.Suite是用來比較多個測試用例的類
var arr = new Array(1000)

suite.add('len', function () { // 新增一個測試用例

  for (var i = 0; i < arr.length; i++) {
    arr[i] = 1
  }
  
}, {
  setup: function () {
    arr = new Array(1000)
  }
})
.add('preLen', function () {

  for (var i = 0, len = arr.length; i < len; i++) {
    arr[i] = 1
  }

}, {
  setup: function () {
    arr = new Array(1000)
  }
})
.run()

console.log(suite[0].hz) 
console.log(suite[1].hz) 
// 1160748.8603394227 // 1188525.8945115102 // 1182959.0564495493
// 1167161.734632912 // 1196721.6273367293 // 1195146.3296931305
複製程式碼

以上程式碼的測試環境為nodejs@v8.11.4,測試結果可以看出將arr.length提前儲存反而會造成反優化,其實背後的原因就是在v8等現代JavaScript引擎中對這種迴圈已經做過優化,不會在每次迴圈都會去訪問arr.length,所以開發者不再需要考慮這方面的問題,不要想在這方面能比引擎更聰明,結果只會適得其反。

尾呼叫優化

es規範通常不會涉及效能方面的要求,但es6中有一個例外,那就是尾呼叫優化(Tail Call Optimization, TCO),簡單的說,尾呼叫就是在一個函式的末尾進行的函式呼叫。

在遞迴中,尾呼叫優化可能起到非常重要的作用

// 非尾呼叫
function foo () {
  foo()
}

// 非尾呼叫
function foo () {
  return 1 + foo()
}

// 尾呼叫
function foo () {
  return foo()
}
複製程式碼

呼叫一個新的函式需要額外預留一塊記憶體來管理呼叫幀,稱為棧幀,在沒有TCO的遞迴呼叫中,遞迴層級太多會導致棧溢位,遞迴無法執行。而在支援TCO的環境並正確書寫TCO規範的遞迴函式,第二層的遞迴函式中直接使用上層函式的棧幀,依次類推。這樣不僅速度快,也更節省記憶體。

感謝評論區大佬的指正,TCO雖然是es6的一部分,但實質是個非常有爭議的提案,主流瀏覽器幾乎沒有實現它,chrome實現過一段時間,chrome已經棄用 ,這是一份支援尾呼叫優化的引擎列表compat-table,可以看到Safari@12支援尾呼叫優化,有興趣的小夥伴可以去驗證一下。 遞迴通常是堆疊溢位的“高發區”,我們可以將遞迴改為迴圈的方式避免使用遞迴

"use strict"
var a = 0,
    b = 0;
function demo () {
    a++

    if (a < 100000) {
        return demo()
    }

    return a
}

setTimeout(() => {
    console.log('遞迴: ' + demo())
},1000)

function demo2 () {
    b++

    if (b < 100000) {

        return function () {
            return demo2()
        }
    }

    return b
}

function runner (fn) {
    let val = fn()

    while (typeof val == 'function') {
        val = val()
    }

    return val
}

setTimeout(() => {
    console.log('迴圈:' + runner(demo2))
})
複製程式碼

我們可以使用nvm安裝node@6.2.0使用--harmony_tailcalls引數體驗尾呼叫優化 上面的程式碼執行結果

enter description here

相關文章