效能測試和調優
你不知道的JavaScript讀書筆記
之前我們討論過巨集觀層面上的JavaScript
效能問題,討論了asm.js
、WebAssembly
和WebWorker
技術,接下來我們探究一下JavaScript
在微觀層面上的效能問題,並逐步瞭解這些效能問題是否真實存在,以及是否需要花大量時間去優化。
效能測試問題
如果我們要測試一段程式碼的執行速度(執行時間),我們通常第一時間會想到編寫以下程式碼進行測試:
var start = Date.now()
// do something
console.log('用時:' + (Date.now() - start))
複製程式碼
這在很長一段時間裡,我都認為這段程式碼能測試出絕大數多正確的結果,而事實上這段程式碼的結果非常不準確
- 它很有可能報告的時間是0,因為他的執行時間可能小於1ms。或者在一些早期引擎中,定時器的精度只有15ms,也就是這個運算至少要執行15ms才會有結果輸出。
- 對於一個單次的執行幾乎沒有任何參考價值,我們不能保證引擎或系統在此刻沒有受到其他因素干擾。
- 在獲得時間戳時可能存在延遲。
- 不能確定引擎是否對這段測試程式碼進行了優化。在真實程式中引擎是否會同樣優化這段程式碼,如果不能,這就會導致真實環境中程式碼執行變慢。
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) // 樣本方差
複製程式碼
第三個引數中的setup
和teardown
是我們尤其要注意的,第三個引數指定測試用例的一些額外資訊,其中的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)
複製程式碼
這段程式碼想比較Number
和parseInt
在型別轉換上的效能差異,但是由於引擎優化的存在,這種測試會變得沒有參考性,由於引擎優化沒有被納入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(..)
內建方法和自定義方法的效能,但是這建立的了一個不公平的對比:
- 在循序測試中,自定義方法會不斷被建立,這顯然會增加額外的開銷。
- 忽略了內建方法的額外工作:內建方法是將比較值強制裝換成字串進行比較,比如內建排序會把
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
引數體驗尾呼叫優化
上面的程式碼執行結果