准备:新V8即将到来,Node.js的性能正在改变
V8的Turbofan的性能特點將如何對我們優化的方式產生影響
審閱:來自V8團隊的Franziska Hinkelmann和Benedikt Meurer.
**更新:Node.js 8.3.0已經發布了V8 6.0和Turbofan.
Node.js依靠V8 JavaScript引擎來運行代碼,其語言本身也是我們熟悉和喜愛的。V8 JavaScript引擎是Google為Chrome瀏覽器編寫的JavaScript虛擬機。從一開始,V8的一個主要目標是讓JavaScript運行地更快,或者至少比競爭對手更快。而對于一個高動態的松散類型的語言來說,這并不容易。本文介紹了有關V8和JS引擎性能的演變。
JIT(Just In Time)編譯器是V8引擎的核心部分,它允許高速執行JavaSctipt代碼。它是一個動態編譯器,可以在運行時對代碼進行優化。在一開始的V8引擎中JIT編譯器被稱為FullCodegen,后來V8團隊實現了Crankshaft,其中包含了很多在FullCodegen中沒有實現的性能優化。
修正:FullCodegen是V8引擎的第一個優化編譯器,感謝Yang Guo提供。
作為JavaScript的局外人和用戶,從90年代開始,似乎JavaSciprt中的快慢路徑(無論何種引擎)看起來都違背常理,而JavaScript代碼很慢的原因通常也難以理解。
近幾年,Matteo Collina和我一直關注如何編寫高性能的Node.js代碼,這意味著我們必須知道在用V8 JavaScript引擎運行代碼時哪些方法要快哪些方法要慢。
現在是時候挑戰這些有關性能方面的假設了,因為V8團隊已經編寫了一個新的JIT編譯器:Turbofan.
從眾所周知的“V8殺手”(一段會導致optimazation bail-out的代碼——該術語在Turbofan中已經沒有意義)開始,以及Matteo和我圍繞Crankshaft性能方面的一些發現,我們將對V8版本的進展進行一系列的觀察并給出微基準測試結果。
當然,在進行V8的邏輯路徑優化之前,我們應該首先關注API設計,算法和數據結構。這些微基準測試用來標識JavaScript在Node中的執行過程如何被改變。我們可以使用這些指示器來改變我們的代碼風格以及在應用優化之后提高性能的方式。
我們將在V8的5.1,5.8,5.9,6.0和6.1版本上查看微基準測試的性能。
我們將把每個不同的版本放到對應的環境中:V8 5.1引擎使用Node 6和Crankshaft JIT編譯器,V8 5.8使用Node 8.0和8.2并混合使用Crankshaft和Turbofan。
當前的6.0引擎屬于Node 8.3(或者可能是Node 8.4),而V8的6.1是最新版(在編寫本文時),它被集成到Node中,可以查看實驗中的node-v8 repo。也就是說,V8 6.1版本最終將會出現在未來的Node版本中,有可能是Node.js 9。
我們來看看微基準測試,而另一方面我們也將討論這些微基準測試對未來都意味著什么。所有的這些微基準測試都是通過benchmark.js來執行的,并且數值都是按秒繪制的,因此值越高越好。
try/catch的問題
? 其中一個比較著名的去優化模式是使用try/catch塊。
在這個微基準測試中,我們比較了以下四種情況:
- 有try/catch的function(sum try catch)
- 沒有try/catch的function(sum without try catch)
- 在try塊中調用function(sum wrapped)
- 簡單調用一個function,沒有try/catch(sum function)
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/try-catch.js
'use strict'var benchmark = require('benchmark') var suite = new benchmark.Suite()function sum (base, max) {var total = 0for (var i = base; i < max; i++) {total += i} }suite.add('sum with try catch', function sumTryCatch () {try {var base = 0var max = 65535var total = 0for (var i = base; i < max; i++) {total += i}} catch (err) {console.log(err.message)} })suite.add('sum without try catch', function noTryCatch () {var base = 0var max = 65535var total = 0for (var i = base; i < max; i++) {total += i} })suite.add('sum wrapped', function wrapped () {var base = 0var max = 65535try {sum(base, max)} catch (err) {console.log(err.message)} })suite.add('sum function', function func () {var base = 0var max = 65535sum(base, max) })suite.on('complete', require('./print'))suite.run()可以看到,在Node 6(V8 5.1)中圍繞try/catch所產生的性能問題是真實存在的,但是對Node 8.0-8.2(V8 5.8)版本的性能影響要小得多。
? 另外值得注意的是,從try塊內部調用一個函數要比從try塊外部調用一個函數慢得多——這一點在Node 6(V8 5.1)和Node8.0-8.2(V8 5.8)中都是一樣的。
不過,對于Node 8.3+而言,在try塊內部調用函數的性能可以忽略不計。
但也別高興得太早。在研究一些性能研討會的材料時,Mattero和我發現了一個性能問題,就是在某個特定的情況下會導致Turbofan的無限去優化/重新優化循環(這個被稱之為“殺手”——一種破壞性能的模式)。
移除Objects中的屬性
多年來,delete限制了很多希望能寫出高性能JavaScript代碼的人(至少對于我們正試圖編寫一個熱路徑的最優代碼來說是這樣的)。
Delete的問題被歸結為V8在處理JavaScript objects的動態特性和原型鏈(也可能是動態的)時,對于屬性的查找在實現級別上變得更加復雜。
對于快速生成一個屬性對象,V8引擎所采用的技術是在C++層根據對象的“形狀”來創建一個類。形狀本質上是一個屬性的key和value(包括原型鏈的key和value)。它們被稱之為“隱藏類”。但是,如果對象的形狀存在不確定性,V8會采用另一種屬性檢索模式:哈希表查找。這是對運行時對象的一種優化。哈希表查找方式明顯要慢許多。從以往來看,當我們將一個key從object中delete時,后續的屬性訪問將變成哈希表查找方式。這就是為什么我們要避免delete一個屬性,而是將值設置為undefined。就屬性的值而言,這樣操作的結果是一樣的,但在查看屬性是否存在時會有問題。不過,這對于對象的序列化操作來說通常都是沒問題的,因為JSON.stringify在輸出時不會包含undefined值(在JSON規范中undefined不是有效值)。
現在,讓我們來看看新的Turbofan是否解決了delete問題。
在這個微基準測試中我們比較了以下三種情況:
- 將一個對象的屬性設置為undefined,然后序列化對象。
- delete一個對象中非最后添加的屬性,然后序列化對象。
- delete一個對象中最后添加的屬性,然后序列化對象。
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/property-removal.js
'use strict'var benchmark = require('benchmark') var suite = new benchmark.Suite()function MyClass (x, y) {this.x = xthis.y = y }function MyClassLast (x, y) {this.y = ythis.x = x }// You can tell if an object is in hash table mode by calling console.log(%HasFastProperties(obj)) when the flag --allow-natives-syntax is enabled in Node.JS. // you can convert back to fast properties using // https://www.npmjs.com/package/to-fast-properties suite.add('setting to undefined', function undefProp () {var obj = new MyClass(2, 3)obj.x = undefinedJSON.stringify(obj) })suite.add('delete', function deleteProp () {var obj = new MyClass(2, 3)delete obj.xJSON.stringify(obj) })suite.add('delete last property', function deleteProp () {var obj = new MyClassLast(2, 3)delete obj.xJSON.stringify(obj) })suite.add('setting to undefined literal', function undefPropLit () {var obj = { x: 2, y: 3 }obj.x = undefinedJSON.stringify(obj) })suite.add('delete property literal', function deletePropLit () {var obj = { x: 2, y: 3 }delete obj.xJSON.stringify(obj) })suite.add('delete last property literal', function deletePropLit () {var obj = { y: 3, x: 2 }delete obj.xJSON.stringify(obj) })suite.on('complete', require('./print'))suite.run()? 在V8 6.0和6.1中(尚未在任何Node的發行版中使用),刪除對象中最后一個添加的屬性會在V8中命中快速路徑,因此這個操作會比直接將屬性值設置為undefined要快。這是一個好消息,因為這表明V8團隊正在努力提高delete操作的性能。但是,如果刪除的不是最后添加的屬性,delete操作仍然會導致其余屬性的查找性能下降。所以總的來說,我們還是要推薦繼續使用delete。
修正:之前我們認為delete可能并且應該在未來的Node.js版本中使用。感謝Jakob Kummerow告知我們,我們的基準測試只觸發了最后一個屬性被訪問的情況!
顯式并數組化Arguments
? 對普通JavaScript函數來說(ES6中的箭頭函數“=>”沒有arguments對象),一個常見的問題是隱式arguments對象為類數組,它不是一個真正的數組。
為了使用數組的方法和數組的大部分特性,arguments對象的索引屬性被復制到了數組中。在以前,JavaScripters傾向于將代碼量與運行速度等同起來,即代碼量越少則執行越快。這條規則會有效地減少瀏覽器端的代碼量,但對于服務端來說代碼的執行速度更重要。因此這樣一種簡單有效地將arguments對象轉換成數組的方式變得很流行:Array.prototype.slice.call(arguments). 調用數組的slice方法并將arguments對象作為該方法的this上下文傳入,該方法會將整個arguments對象作為一個數組來分割。
但是當一個函數的隱式arguments對象從上下文中被暴露出來時(例如,當它從函數返回或者通過Array.prototype.slice.call(arguments)傳遞給另一個函數時),通常會導致性能下降。現在是時候來挑戰這個假設了。
在下一個微基準測試中,我們測試了四個V8版本中的兩個相互關聯的問題:即暴露arguments參數所產生的開銷,以及將arguments參數復制到數組中的開銷(隨后可以從函數內部訪問該數組,從而替代暴露arguments對象)。
下面是具體的測試用例:
- 將arguments對象暴露給另一個函數——沒有數組轉換(leaky arguments)
- 使用Array.prototype.slice方式拷貝arguments對象的副本(Array.prototype.slice arguments)
- 使用for循環復制arguments中的每一個值到數組中(for loop copy arguments)
- 使用EcmaScript 2015的展開運算符將輸入的參數列表賦值給一個數組(spread operator)
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/arguments.js
'use strict'var benchmark = require('benchmark') var suite = new benchmark.Suite()function leakyArguments () {return other(arguments) }function copyArgs () {var array = new Array(arguments.length)for (var i = 0; i < array.length; i++) {array[i] = arguments[i]}return other(array) }function sliceArguments () {var array = Array.prototype.slice.apply(arguments)return other(array) }function spreadOp(...args) {return other(args) }function other (toSum) {var total = 0for (var i = 0; i < toSum.length; i++) {total += toSum[i]}return total }suite.add('leaky arguments', () => {leakyArguments(1, 2, 3) })suite.add('Array.prototype.slice arguments', () => {sliceArguments(1, 2, 3) })suite.add('for loop copy arguments', () => {copyArgs(1, 2, 3) })suite.add('spread operator', () => {spreadOp(1, 2, 3) })suite.on('complete', require('./print'))suite.run()讓我們來看看對應的折線圖,以著重觀察性能特征的變化:
重點是:將函數的輸入處理成一個數組,如果想要提高性能的話(依據我的經驗這個需求應該很常見),在Node 8.3及以上版本中我們應當使用擴展運算符。而在Node 8.2及以下版本中,應當使用for循環將arguments中的每一個值復制到新(預分配的)數組中(詳情可見代碼)。
更進一步,在Node 8.3+中,將arguments暴露給其它函數不會引起任何問題,因此當我們不需要一個完整的數組并處理類數組結構時,性能還可能有進一步的提升。
偏函數應用(柯里化)和函數綁定
偏函數應用(或者柯里化)使得我們可以捕獲嵌套閉包內的狀態。
例如:
function add (a, b) {return a + b } const add10 = function (n) {return add(10, n) } console.log(add10(20))在函數add中,參數a被函數add10部分地設置成了10。
在EcmaScript 5中,偏函數應用可以通過bind方法來實現:
function add (a, b) {return a + b } const add10 = add.bind(null, 10) console.log(add10(20))但是我們通常不會使用bind,因為它比使用閉包要慢。
這個基準測試使用函數的直接調用比較了bind和閉包在目標V8版本中的區別。
下面是我們的四個測試用例:
- 一個函數通過柯里化的方式調用另一個函數(curry)
- 箭頭函數“=>”通過柯里化的方式調用另一個函數(fat arrow curry)
- 通過bind創建的函數以柯里化的方式調用另一個函數(bind)
- 不用柯里化的方式直接調用一個函數(direct call)
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/currying.js
?
'use strict'var benchmark = require('benchmark') var suite = new benchmark.Suite()function sum (base, max) {var total = 0for (var i = base; i < max; i++) {total += i} }var bind = sum.bind(null, 0) var curry = function (max) {return sum(0, max) } var fatCurry = (max) => sum(0, max)suite.add('curry', function smallSum () {var max = 65535curry(max) })suite.add('fat arrow curry', function bigSum () {var max = 65535fatCurry(max) })suite.add('bind', function smallSum () {var max = 65535bind(max) })suite.add('direct call', function bigSum () {var base = 0var max = 65535sum(base, max) })suite.on('complete', require('./print'))suite.run()這個基準測試的折線圖可視化結果清楚地說明了這些方法在V8的更高版本中是如何融合的。有意思的是,使用箭頭函數的偏函數應用要比正常函數快得多(至少在我們的微基準測試中是這樣的)。事實上它完全可以媲美函數直接調用。對比來看在V8 5.1(Node 6)和5.8(Node 8.0-8.2)中bind方法是很慢的,顯然在偏函數應用中箭頭函數是最快的選擇。不過,從V8 5.9(Node 8.3+)開始,在未來的6.1版本中,bind的速度提高了一個數量級,成了最快的方法(幾乎可以忽略不計)。
在所有的版本中,柯里化最快的方法是使用箭頭函數。在后來的版本中使用箭頭函數的代碼將盡可能地接近使用bind方法的代碼,而目前它是比普通函數最快的方法。但需要說明的一點是,我們可能需要用不同的數據結構來測試更多類型的偏函數應用,以獲得更全面的了解。
函數字符數
函數的大小,包括簽名、空格甚至注釋都會影響函數是否可以使用V8內聯。是的,給函數添加注釋可能會導致性能降低10%。Turbofan會改變這個嗎?讓我們來看看。
在這個基準測試中我們查看了以下三種情況:
- 調用一個小函數(sum small function)
- 調用一個內聯代碼的小函數,其中填充了注釋(long all together)
- 調用一個用注釋填充的大函數(sum long function)
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/function-size.js
'use strict'// inlining example, v8 inlines sum() but cannot inline longSum because it is too long // Use --trace_inlining to show this // Output: "Did not inline longSum called from long (target text too big)." var benchmark = require('benchmark') var suite = new benchmark.Suite()function sum (base, max) {var total = 0for (var i = base; i < max; i++) {total += i} }function longSum (base, max) {// Lorem ipsum dolor sit amet, consectetur adipiscing elit.// Vestibulum vel interdum odio. Curabitur euismod lacinia ipsum non congue.// Suspendisse vitae rutrum massa. Class aptent taciti sociosqu ad litora torquent// per conubia nostra, per inceptos himenaeos. Morbi mattis quam ut erat vestibulum,// at laoreet magna pharetra. Cras quis augue suscipit, pulvinar dolor a, mollis est.// Suspendisse potenti. Pellentesque egestas finibus pulvinar.// Vestibulum eu rhoncus ante, id viverra eros. Nunc eget tempus augue.var total = 0for (var i = base; i < max; i++) {total += i} }suite.add('sum small function', function short () {var base = 0var max = 65535sum(base, max) })suite.add('long all together', function long () {var base = 0var max = 65535// Lorem ipsum dolor sit amet, consectetur adipiscing elit.// Vestibulum vel interdum odio. Curabitur euismod lacinia ipsum non congue.// Suspendisse vitae rutrum massa. Class aptent taciti sociosqu ad litora torquent// per conubia nostra, per inceptos himenaeos. Morbi mattis quam ut erat vestibulum,// at laoreet magna pharetra. Cras quis augue suscipit, pulvinar dolor a, mollis est.// Suspendisse potenti. Pellentesque egestas finibus pulvinar.// Vestibulum eu rhoncus ante, id viverra eros. Nunc eget tempus augue.var total = 0for (var i = base; i < max; i++) {total += i} })suite.add('sum long function', function long () {var base = 0var max = 65535longSum(base, max) })suite.on('complete', require('./print'))suite.run()在V8 5.1(Node 6)中sum small function和long all together是相同的。這足以說明內聯代碼是如何工作的。當我們調用這個小函數時,就如同V8將它的內容寫入到被調用的地方。因此當我們編寫一個函數時(即使有額外的注釋填充),實際上我們已經手動將這些內容寫入到調用的函數內聯中,所以這兩者的性能是相同的。另外我們在V8 5.1(Node 6)中也看到,調用一個填充了大量注釋的函數會導致執行速度慢很多。
在Node 8.0-8.2(V8 5.8)中,除了調用小函數的開銷明顯增大之外,其它幾乎沒有變化。這可能是由于Crankshaft和Turbofan同時作用產生的碰撞,當一個函數在Crankshaft中時另一個可能在Turbofan中,從而導致內聯代碼的分離(即在一組連續的內聯函數中產生跳躍)。
在5.9及更高版本(Node 8.3+)中,任何由不相關的字符例如空格或注釋引起的大小都不會對函數性能產生影響。這是因為Turbofan使用了AST(抽象語法樹Abstract Syntax Tree)來確定函數的大小,而不是像在Crankshaft中是通過字符數來計算的。它考慮函數的有效代碼,而不是檢查函數的字節數。因此從V8 5.9(Node 8.3+)開始,空格,變量名的字符數,函數的簽名以及注釋都不再作為函數是否內聯的因素。
值得注意的是,我們再次看到函數的整體性能在下降。
要點是應該依然保持小函數。目前我們仍然需要避免在函數內部添加大量的注釋(甚至是空白)。另外,如果你想要絕對的快速,手動內聯(去掉函數調用)是最快的方法。當然,這得在函數內聯與函數大小(實際可執行代碼)之間找到平衡,因此將其它函數的代碼復制到自己的函數中有可能會引起性能問題。也就是說,手動內聯也存在潛在的風險。在大多數情況下,最好把內聯的工作留給編譯器。
32位整數與double類型的整數
眾所周知,JavaScript僅有一個數字類型:Number.
? 但是,V8是用C++實現的,因此對于JavaScript數字來說,必須在底層進行類型選擇。
對整數而言(在JS中即沒有小數的數字),V8假定所有的數字都適合32位,除非不是。這看起來似乎是一個公平的選擇,因為大部分情況下數字都是在-2147483648和2147483647之間。假如一個JavaScript整數超過2147483647,JIT編譯器會動態地將數字的底層類型改成double(雙精度浮點數)——這可能也會對其它的優化產生潛在的影響。
這個基準測試包含了下面三個用例:
- 處理32位以內數字的函數(sum small)
- 處理32位和double類型數字的函數(from small to big)
- 處理double類型數字的函數(all big)
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/numbers.js
'use strict'var benchmark = require('benchmark') var suite = new benchmark.Suite()function sum (base, max) {var total = basefor (var i = base + 1; i < max; i++) {total += i}return total }suite.add('sum small', function smallSum () {var base = 0var max = 65535// 0 + 1 + ... + 65535 = 2147450880 < 2147483647 sum(base, max) })suite.add('from small to big', function bigSum () {var base = 32768var max = 98303// 32768 + 32769 + ... + 98303 = 4294934528 > 2147483647 sum(base, max) })suite.add('all big', function bigSum () {var base = 2147483648var max = 2147549183// 2147483648 > 2147483647 sum(base, max) })suite.on('complete', require('./print'))suite.run()從圖中我們可以看到,無論是Node 6(V8 5.1)還是Node 8(V8 5.8),甚至是將來的Node版本,該測試結果都是成立的。操作大于2147483647的整數將導致函數的運行速度為1/2~2/3。所以,如果你有一個很長的數字ID,將它們放到字符串中。
同樣值得注意的是,對32位以內的數字操作,在Node 6(V8 5.1)和Node 8.1(V8 5.8)之間速度增加,但在Node 8.3+(V8 5.9+)中速度明顯變慢。但是,對于double類型數字的操作在Node 8.3+ (V8 5.9+)中變得更快。這很可能是32位的數字處理速度變慢,而不是與函數調用的速度或者循環(在測試代碼中使用的)有關。
修正:感謝Jakob Kummerow和Yang Guo以及V8團隊給出了精確的測量結果。
對象的迭代
獲取一個對象的所有值并進行相關的操作十分常見,而且有很多方法可以實現。讓我們來看看在V8(和Node)版本中哪個是最快的。
這個基準測試針對所有的V8版本包含了以下四個用例:
- 在for-in循環中通過hasOwnProperty方法檢查以獲取對象的值(for in)
- 使用Object.keys以及Array的reduce方法來遍歷所有的key,然后獲取迭代器函數內部reduce方法提供的對象值(Object.keys functional)
- 與上面的方法類似,只不過將迭代器函數提供的reduce方法換成了箭頭函數(Object.keys functional with arrow)
- 使用for循環遍歷從Object.keys返回的數組,在循環中獲取對象的值(Object.keys with for loop)
我們還對V8 5.8,5.9和6.1做了另外的三個測試:
- 使用Object.values以及Array的reduce方法來遍歷所有的值(Object.values functional)
- 與上面的方法類似,只不過將迭代器函數提供的reduce方法換成了箭頭函數(Object.values functional with arrow)
- 使用for循環遍歷從Object.values返回的數組(Object.values with for loop)
我們沒有在V8 5.1(Node 6)中跑這些測試用例,因為不支持原生的EcmaScript 2017 Object.values方法。
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/object-iteration.js
'use strict'var benchmark = require('benchmark') var suite = new benchmark.Suite()suite.add('for-in', function forIn () {var obj = {x: 1,y: 1,z: 1}var total = 0for (var prop in obj) {if (obj.hasOwnProperty(prop)) {total += obj[prop]}} })suite.add('Object.keys functional', function forIn () {var obj = {x: 1,y: 1,z: 1}var total = Object.keys(obj).reduce(function (acc, key) {return acc + obj[key]}, 0) })suite.add('Object.keys functional with arrow', function forIn () {var obj = {x: 1,y: 1,z: 1}var total = Object.keys(obj).reduce((acc, key) => {return acc + obj[key]}, 0) })suite.add('Object.keys with for loop', function forIn () {var obj = {x: 1,y: 1,z: 1}var keys = Object.keys(obj)var total = 0for (var i = 0; i < keys.length; i++) {total += obj[keys[i]]} })if (process.versions.node[0] >= 8) {suite.add('Object.values functional', function forIn () {var obj = {x: 1,y: 1,z: 1}var total = Object.values(obj).reduce(function (acc, val) {return acc + val}, 0)})suite.add('Object.values functional with arrow', function forIn () {var obj = {x: 1,y: 1,z: 1}var total = Object.values(obj).reduce((acc, val) => {return acc + val}, 0)})suite.add('Object.values with for loop', function forIn () {var obj = {x: 1,y: 1,z: 1}var vals = Object.values(obj)var total = 0for (var i = 0; i < vals.length; i++) {total += vals[i]}})}suite.on('complete', require('./print'))suite.run()在Node 6(V8 5.1)和Node 8.0-8.2(V8 5.8)中,使用for-in循環來遍歷對象的key和value是迄今為止最快的方法。每秒大約操作4千萬次,比排第二位的Object.keys方法快5倍,后者每秒大約操作800萬次。
在V8 6.0(Node 8.3)中,for-in循環有時候會出現一些問題,導致其性能會降到之前版本的1/4,但仍比其它方法都快。
在V8 6.1(未來的Node版本)中,Object.keys的速度有了一個飛躍,變得比for-in循環還要快。但在V8 5.1和5.8(Node 6,Node 8.0-8.2)中速度沒有接近for-in循環。
可見Turbofan背后的工作原理是對最直觀的編碼行為進行優化。即優化對開發人員來說最熟悉的代碼。
使用Object.values直接獲取值比用Object.keys遍歷對象的key然后再獲取值要慢。重要的是,程序循環比函數式編程要快。因此在對象迭代過程中可能會做很多事情。
還有,對于那些使用for-in循環來提高程序性能的人而言,如果速度受到影響而又沒有任何可用的替代方法時,那將會非常痛苦。
注解:在V8中for-in循環的性能問題已經被修復,更多細節請參見http://benediktmeurer.de/2017/09/07/restoring-for-in-peak-performance/。這個修改將會被整合進Node 9中。
對象分配
對象的分配是無可避免的,所以這是一個重要的測試部分。
我們將查看以下三個測試用例:
- 通過對象的迭代進行對象分配(literal)
- 使用EcmaScript 2015的Class進行對象分配(class)
- 通過構造函數進行對象分配(constructor)
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/object-creation.js
'use strict'var benchmark = require('benchmark') var suite = new benchmark.Suite() var runs = 0// the for loop is needed otherwise V8 // can optimize the allocation of the object // away var max = 10000class MyClass {constructor (x) {this.x = x} }function MyCtor (x) {this.x = x }suite.add('noop', function noop () {})suite.add('literal', function literalObj () {var obj = nullfor (var i = 0; i < max; i++) {obj = { x: 1 }}return obj })suite.add('class', function classObj () {var obj = nullfor (var i = 0; i < max; i++) {obj = new MyClass(1)}return obj })suite.add('constructor', function constructorObj () {var obj = nullfor (var i = 0; i < max; i++) {obj = new MyCtor(1)}return obj })suite.on('cycle', () => runs = 0)suite.on('complete', require('./print'))suite.run()對象分配在所有V8版本的測試中都有相同的結果,除了Node 8.2(V8 5.8)中的class,它比其它的方式都慢。這是由于V8 5.8中的混合Crankshaft/Turbofan特性所致,在包含V8 6.0的Node 8.3中將解決這個問題。
修正:Jakob Kummerow在http://disq.us/p/1kvomfk中指出,在特定的微基準測試中Turbofan可以優化對象分配,從而導致不正確的測試結果,所以本文做了相應的調整。
對象分配的清除
在對本文的結果進行整理時,我們發現Turbofan會始終對某一類對象分配進行優化。起初我們還一直以為這個優化會針對所有的對象分配,感謝V8團隊的加入,使得我們能夠更好地理解該優化所涉及的部分。
在之前的對象分配微基準測試中,我們分配了一個變量,將值設置為null,然后多次重新分配該變量,以避免觸發我們現在要查看的特殊優化操作。
與上面一樣,這里的微基準測試也包含以下三個測試用例:
- 通過對象的迭代進行對象分配(literal)
- 使用EcmaScript 2015的Class進行對象分配(class)
- 通過構造函數進行對象分配(constructor)
不同之處在于,對象的引用不會被其它對象的分配所覆蓋,而是將該對象傳遞給另一個操作該對象的函數。
我們來看看測試結果!
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/object-creation-inlining.js
'use strict'var benchmark = require('benchmark') var suite = new benchmark.Suite() var runs = 0class MyClass {constructor (x) {this.x = x} }function MyCtor (x) {this.x = x }var res = 0function doSomething (obj) {res = obj.x }suite.add('literal', function base () {var obj = { x: 1 }doSomething(obj) })suite.add('class', function allNums () {var obj = new MyClass(1)doSomething(obj) })suite.add('constructor', function allNums () {var obj = new MyCtor(1)doSomething(obj) })suite.add('create', function allNums () {var obj = Object.create(Object.prototype)obj.x = 1doSomething(obj) })suite.on('cycle', () => runs = 0)suite.on('complete', require('./print'))suite.run()我們注意到在這個微基準測試中V8 6.0(Node 8.3)和6.1(Node 9)的速度大大提高,每秒超過5億次,主要因為一旦Turbofan應用優化,沒有其它任何額外的代碼需要執行。在這種特殊情況下,Turbofan能夠優化對象分配,因為它不需要對象實際存在就能夠確定后續的邏輯可以被執行。
微基準測試的代碼仍然沒有完全說明如何觸發這個優化,而且這個優化應用的條件非常復雜。
但是我們知道的其中一個條件是絕對不會讓對象被Turbofan優化掉的:
對象不能超出創建它的函數。意思是說,在堆棧中的每個函數完成之后,不應該再出現對該對象的引用。對象可以傳遞給其它函數,但是如果我們將該對象添加到this上下文中,或者將其分配給一個外部變量,又或者在堆棧完成之后將其添加到另一個對象,則無法應用優化。
這個影響很酷,但是很難預測這種優化發生的所有條件。盡管如此,當復雜的條件得到滿足時,它有可能會產生加速。
修正:感謝Jakob Kummerow和V8團隊的其他成員幫助我們發現此特定行為的根本原因。作為這項研究的一部分,我們發現了在V8新GC中的性能回歸,Orinoco,如果你對此有興趣可以查看https://v8project.blogspot.it/2016/04/jank-busters-part-two-orinoco.html and https://bugs.chromium.org/p/v8/issues/detail?id=6663
多態與單態代碼
當我們總是將同一類型的參數傳遞給一個函數時(比如總是傳遞一個string),我們就是以單態的方式使用這個函數。
有一些函數被寫成是多態的。我們可以把多態函數想象成這樣一個函數,它在同一參數位置上可以接受不同類型的值。例如,一個函數的第一個參數可以接受一個字符串或者一個對象。不過,這里我們所說的“類型”不是指string,number和object,而是指對象的形狀(雖然JavaScript的類型實際上也算作不同的對象形狀)。
一個對象的形狀由其屬性和值來定義。例如,在下面的代碼片段中,obj1和obj2是相同的形狀,但obj3和obj4與其余的形狀不同:
const obj1 = { a: 1 } const obj2 = { a: 5 } const obj3 = { a: 1, b: 2 } const obj4 = { b: 2 }用同一段代碼來處理不同形狀的對象,在某些情況下這是非常不錯的代碼接口,但是往往會影響程序性能。
讓我們來看看在我們的微基準測試中單態與多態的測試用例。
這里我們測試以下兩種情況:
- 一個處理具有不同屬性對象的函數(polymorphic)
- 一個處理具有相同屬性對象的函數(monomorphic)
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/polymorphic.js
'use strict'var benchmark = require('benchmark') var suite = new benchmark.Suite() var runs = 0suite.add('polymorphic', function polymorphic() {var objects = [{a:1}, {b:1, a:2}, {c:1, b:2, a:3}, {d:1, c:2, b:3, a:4}];var sum = 0;for (var i = 0; i < 10000; i++) {var o = objects[i & 3];sum += o.a;}return sum; })suite.add('monomorphic', function monomorphic() {var objects = [{a:1}, {a:2}, {a:3}, {a:4}];var sum = 0;for (var i = 0; i < 10000; i++) {var o = objects[i & 3];sum += o.a;}return sum; })suite.on('complete', require('./print'))suite.run()上圖的可視化數據明確地顯示出,在所有測試的V8版本中,單態函數的性能要優于多態函數。不過,從V8 5.9+開始(也就是從使用V8 6.0的Node 8.3開始),多態函數的性能有了一定的改進。
在Node.js的代碼中,多態函數十分普遍,它們以APIs的形式提供了很大的靈活性。由于對多態交互的這種改進,我們可以看到在更復雜的Node.js應用程序中的性能有所提升。
如果我們正在編寫的代碼需要優化,函數需要被多次調用,那么我們應該調用具有相同“形狀”參數的函數。另一方面,如果一個函數只被調用一兩次,例如instantiating function或者setup function,那么就可以選擇一個多態的API。
修正:感謝Jakob Kummerow提供了這個微基準測試的可靠版本。
Debugger關鍵字
最后,讓我們來討論一下debugger關鍵字。
確保將debugger語句從你的代碼中去掉。多余的debugger語句會影響程序的性能。
我們來看以下兩個測試用例:
- 一個包含debugger關鍵字的函數(with debugger)
- 一個不包含debugger關鍵字的函數(without debugger)
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/debugger.js
'use strict'var benchmark = require('benchmark') var suite = new benchmark.Suite()// node --trace_opt --trace_deopt --trace_inlining --code-comments --trace_opt_verbose debugger.js > out // look for [disabled optimization for 0x34e65f73db01 <SharedFunctionInfo withDebugger>, reason: DebuggerStatement] suite.add('with debugger', function withDebugger () {var base = 0var max = 65535var total = 0for (var i = base; i < max; i++) {debuggertotal += i} })suite.add('without debugger', function withoutDebugger () {var base = 0var max = 65535var total = 0for (var i = base; i < max; i++) {total += i} })suite.on('complete', require('./print'))suite.run()是的 ,只要debugger關鍵字出現,在所有測試的V8版本中,性能都會嚴重受到影響。
對于沒有debugger關鍵字的情況,性能出現了連續的下降,我們將在結論一節討論這個問題。
一個真實的基準測試:Logger的比較
除了我們的微基準測試外,我們還可以通過使用Mattero和我在創建Pino時放在一起的最流行的Node.js的logger作為基準測試來查看V8版本的整體效果。
下面的條形圖記錄了在Node.js 6.11(Crankshaft)中使用最流行的logger記錄一萬行日志所花的時間(越少越好):
而下面是使用V8 6.1(Turbofan)的測試結果:
盡管所有的logger基準測試的速度都有所提高(大約是2倍),但是在新的Turbofan JIT編譯器中Winston logger的性能提升最明顯。這似乎論證了在我們的微基準測試中,從各種不同的方法所看到的速度趨同性:在Crankshaft中速度較慢的方法在Turbofan中明顯變快,而在Crankshaft中速度較快的方法在Turbofan中趨近于緩慢。Winston是最慢的,可能在Crankshaft中使用的方法要慢而在Turbofan中則要快一些,而在Crankshaft的方法中Pino被優化為最快。另外我們觀察到Pino的速度有提高,但是不明顯。
總結
一些基準測試表明,V8 5.1, V8 5.8和5.9中緩慢的情況隨著V8 6.0和V8 6.1中Turbofan的全面啟用而變得更快,而速度較快的方法其增長速度也會減慢,這通常與緩慢情況的增長速度相匹配。
其中很大一部分是取決于Turbofan(V8 6.0及以上)中函數調用的成本。Turbofan的做法是優化那些常見的場景并消除“V8殺手”。這為瀏覽器(Chrome)和服務器應用程序(Node)帶來了很大的好處。這種權衡(至少在一開始)是在性能最好的情況下會降低速度。我們的logger基準測試對比顯示出,Turbofan特性的總體凈效應即使在代碼基數明顯不同的情況下(例如Winston與Pino)也可以全面改善性能。
如果你已經關注JavaScript性能一段時間了,并且為了適應底層引擎的怪異而對編碼行為做了調整,那么差不多是時候要去了解一些新的技術了。 如果你專注于最佳實踐,希望編寫出優秀的JavaScript代碼,則要感謝V8團隊的不懈努力,對于性能方面的改善即將到來。
本文由David Mark Clements和Matteo Collina撰寫,并由V8團隊的Franziska Hinkelmann和Benedikt Meurer進行了審閱。
?
本文的所有源代碼以及副本可以查看https://github.com/davidmarkclements/v8-perf
本文的原始數據可以在這里找到:https://docs.google.com/spreadsheets/d/1mDt4jDpN_Am7uckBbnxltjROI9hSu6crf9tOa2YnSog/edit?usp=sharing
大部分微基準測試的運行環境為Macbook Pro 2016,3.3 GHz Intel Core i7,16 GB 2133 MHz LPDDR3,其它如numbers,對象屬性移除,多態性,對象創建等部分的微基準測試的運行環境為MacBook Pro 2014,在不同Node.js版本之間的測試是在同一臺機器上進行的。 我們很謹慎以確保沒有其它程序的干擾。
?
原文地址:GET READY: A NEW V8 IS COMING, NODE.JS PERFORMANCE IS CHANGING.
總結
以上是生活随笔為你收集整理的准备:新V8即将到来,Node.js的性能正在改变的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Go基础--goroutine和chan
- 下一篇: IT 人士如何避免中年危机?