新 V8為NODE.JS 帶來的性能變化
V8 的 TurboFan 將會如何影響我們的代碼優化方式
2017-07-27
作者:David Mark Clements
原文:GET READY: A NEW V8 IS COMING, NODE.JS PERFORMANCE IS CHANGING
原文經過了來自 V8 團隊的 Franziska Hinkelmann 和 Benedikt Meurer 的審閱。
Node.js 從它誕生的第一天開始,就依賴於 V8 JavaScript 引擎來執行我們的 JavaScript 代碼。V8 引擎是由 Google 打造的 JavaScript 虛擬機,被應用在它的 Chrome 瀏覽器中。從一開始,V8的主要目標就是讓 JavaScript 執行地更快,或者至少要勝過其它競爭者。對於 JavaScript 這樣一個具有高度動態性、弱類型語言而言,達到這個目標絕非易事。讓我們先來回顧一下 V8 以及 其它 JS 引擎在性能方面的演化歷史。
讓 V8 引擎可以快速執行 JavaScript 的核心要素是 JIT(Just In Time)編譯器。它是一個可以在運行時優化代碼的動態編譯器。V8 最早設計的 JIT 編譯器被取名為 FullCodegen,隨後 V8 團隊又實現了 Crankshaft,其中實現了許多 FullCodegen 中所沒有的性能優化。
感謝 Yang Guo 告訴我們 FullCodegen 是 V8 中首個經過優化的編譯器。
我從上世紀 90 年代就開始觀察和使用 JavaScript,我感到似乎無論在哪個 JavaScript 引擎中,代碼的執行速度經常是反直覺的,面對一些明顯執行很慢的 JavaScript 代碼我們很難知道其背後的原因。
近幾年來,我和 Matteo Collina 致力於研究如何寫出高性能的 Node.js 代碼。這意味著我們要搞清楚在 V8 引擎上怎樣的代碼會執行比較快或慢。
但現在,挑戰我們現有認知的時刻到了,因為 V8 團隊推出了一個新的 JIT 編譯器:Turbofan。
有一些大家眾所周知的代碼編寫方式會導致執行效率低下(儘管在 Turbofan 中可能並非如此),我和 Matteo 在進行 Crankshaft 性能研究過程中也有一些其它不大為人所知的發現。在這篇文章里,我們將討論所有這些點,通過一系列性能基準測試,來看一看它們在各版本 V8 中有什麼樣的變化。
當然,在為 V8 優化我們的代碼之前,我們應該首先把精力放在 API 設計、演算法和數據結構上。本文中的性能測試僅僅是為了比較在 Node 中 JavaScript 運行效率的變化。我們當然可以據此來改變我們編寫代碼的方式以改善代碼運行的效率,但是在這之前,我們應該首先使用一些更為通用的優化手段。
接下來我們將對 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 的混合體。
V8 6.0 被包含在 Node 8.3(也可能是 8.4)里,而寫作本文時最新的 V8 版本是 6.1,它被整合在 node-v8 的試驗倉庫(https://github.com/nodejs/node-v8)中。換句話說,V8 6.1 最終將會出現在 Node 未來的某個版本中,有可能是 Node.js 9。
在呈現測試結果的同時,我們也會討論這些變化對未來意味著什麼。這些基準測試都是用 benchmark.js 運行的,結果數值反映的是每秒鐘的執行次數,因此在每幅圖表中,結果值越高意味著性能越好。
Try / Catch
有一個大家眾所周知的反優化模式就是使用 。
在這個測試中我們對 1 到某個自然數進行累加求和,比較了四種不同編碼方式的性能:
把累加過程包含在一個 中(sum with try catch)
不對累加過程進行 (sum without try catch)
把累加過程寫成一個函數,再在一個 塊中執行它(sum wrapped)
把累加過程寫成一個函數,然後簡單地直接執行它(sum function)
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/try-catch.js
從結果可見,我們原本對於 會導致性能問題的觀點在 Node 6(V8 5.1)上還是正確的,但是 對性能的影響在 Node 8.0-8.2(V8 5.8)上已經顯著降低。
同時需要注意的是,在 Node 6(V8 5.1)和 Node 8.0-8.2(V8 5.8)中, 塊內執行一個函數要比在 塊外執行它要慢得多。
但是對於 Node 8.3+,在 塊內執行函數導致的性能下降卻幾乎可以忽略不計了。
儘管如此,也不要以為萬事大吉。我和 Matteo 在為一些關於性能的研討會準備材料的時候,發現了一個 V8 性能方面的 bug:某種特定的代碼邏輯組合可能會導致 Turbofan 陷入到一個反優化/再優化的無窮循環中去(這完全是一個性能「殺手」 —— 即破壞性能的編碼模式)。
從對象中移除屬性
多年以來,想要寫出高性能 JavaScript 代碼的人都不會使用 操作符(或至少在我們試圖優化一些高頻代碼時,我們會避免使用 )。
delete的問題根源在於 V8 對 JavaScript 對象的動態特性和原型鏈(也具有潛在的動態性)的處理方式。這些動態特性使得在引擎在實現對屬性的查找時變得異常複雜。
V8 引擎為了提高屬性和對象的處理速度,在 C++ 層面基於對象的「結構」為對象創建了 C++ 類。所謂對象的「結構」,就是指對象中所包含的屬性的鍵及其值(也包括原型鏈上的鍵和值);而這些 C++ 類就被稱為「hidden classes」。可是這種優化是發生在運行時的,如果一個對象的「結構」是不確定的,那麼 V8 就無法為其創建「hidden classes」,只能使用另一種慢地多的方式即哈希表查找的方式來進行屬性獲取。所以一直以來,當我們從對象中 一個屬性之後,後續的屬性查找模式就會變成哈希表查找。這就是我們要避免使用 的原因。我們通過把要移除的屬性賦值為 來達到類似 的效果,在大多數情況下這麼做完全可以滿足需要了,除了可能會在檢查屬性是否存在時會有點問題。而且 也不會在其輸出結果中包含 值(按照 JSON 規範, 並不是一個有效的值),所以這樣做對於對象序列化也沒有問題。
arguments arguments```對象)中存在的隱式對象,由於它像是數組但又不是數組,在使用時經常會碰到問題。
為了能夠對 對象使用數組方法,像數組一樣地使用它,我們需要把它的每個索引屬性都複製到一個真正的數組中。在過去,JavaScript 開發人員傾向於認為代碼越少則執行速度越快。對於那些需要運行在瀏覽器中的代碼而言,這個粗糙的規則確實會帶來在傳輸和載入體積方面的收益,但是在伺服器端,代碼的執行速度遠比代碼的體積重要,仍然套用這一規則可能會導致問題。很多人都習慣使用一種看上去很巧妙且簡便的方式來把 對象轉化為數組: 。通過執行數組的 方法,把 對象作為執行時的上下文即 傳入, 方法會把傳入的偽數組當做一個數組來操作。這樣我們就可以得到一個包含了整個參數對象成員的數組。
可是,直接把一個函數的 隱含對象從函數上下文中暴露出去(比如把它通過返回值輸出到函數之外,或者說正如在 所做的一樣, 對象被傳送給了另一個函數)會導致性能下降。現在讓我們看看是不是會這樣。
下面這個測試基於我們的四個 V8 版本,比較了這兩種做法的性能代價,即是對外泄露 對象的性能好,還是把 arguments 複製為一個數組(然後再以同樣的方式傳送到函數之外)的性能好:
把 對象暴露給另外一個函數(leaky arguments)
使用 技巧把 複製為一個數組(Array prototype.slice arguments)
使用 for 循環複製 中每個屬性(for-loop copy arguments)
使用 ES2015 中的 spread 操作符來轉化 為一個數組(spread operator)
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/arguments.js
我們還可以用線狀圖來呈現與上圖相同的數據,可以更加清楚地看出在性能上的變化:
總結來說,就是假如我們想寫出高性能的代碼來把函數輸入處理成數組(以我的經驗這種需求非常常見),在 Node 8.3 或者更高版本我們應該使用 spread 操作符。而在 Node 8.2 和更低版本上,我們應該使用 for 循環來把 中的值複製到一個新的(已經預分配了空間的)數組中去(你可以去看一下我們的測試代碼就知道具體該怎麼做)。
同時,在 Node 8.3+ 版本上,把 對象暴露給其它函數不再會導致性能下降,所以如果我們並不需要一個完整的數組,如果直接操作這個類數組結構也可以滿足需要的話,那麼我們完全可以直接把 對象傳遞出去,這樣做的性能反而更好。
柯里化與函數綁定
柯里化是一種讓我們可以在嵌套的閉包里存儲狀態的方式。
例如:
在這個例子中,函數 的參數 在函數 中被固定設置為 。
運用 EcmaScript 5 中提供的 方法,可以把上面這個例子簡化為:
但是由於 的性能明顯慢於閉包,一般情況下我們不會使用 。
下面這個測試比較了 和閉包在我們測試的幾個 V8 版本上的表現。
我們比較了下面四種情況:
一個函數,它調用另外一個函數,且調用時將第一個參數給定,形成一個柯里化函數(curry)
用箭頭形式寫一個函數,它調用另外一個函數,且調用時將第一個參數給定(fat arrow curry)
通過 方式來生成的一個柯里化函數(bind)
直接調用一個函數,不要使用柯里化(direct call)
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/currying.js
這張線狀圖清晰地反映出隨著 V8 的版本演進這幾種不同的代碼形式是如何在性能上逐步趨於一致的。有趣的是,使用箭頭函數形成的柯里化函數居然明顯比普通函數快(至少在我們的測試用例中是如此),性能甚至接近了直接調用方式。在 V8 5.1(Node 6)和 5.8(Node 8.0-8.2)上,相比之下 性能最差,而箭頭函數則是最快的方式。但從 V8 5.9(Node 8.3+)開始, 的速度提升了一個數量級,並在 V8 6.1(未來的 Node) 上變成了最快的一種方式(儘管這種領先優勢很小,可以忽略不計)。
綜合考慮我們所測試的各個 V8 版本,箭頭函數是最快的選項。在後續版本上其性能也與 相差無幾,而目前它比一般函數形式還要快。但這裡我們也要注意,可能還需要研究其它更多類型的柯里化方式,結合不同大小的數據結構,才能對這個問題有更全面的認識。
函數長度
函數的長度,包括它的簽名、空格、甚至內部的注釋都會影響這個函數是否會被 V8 內聯優化。可能你不太相信,但在你的函數中添加一段注釋,確實有可能會導致 10% 以內的性能下降。在 Turbofan 上這一點會有所變化嗎?讓我們來看一看。
在這個測試中,我們考察三種情況:
調用一個簡短無注釋的函數(sum small function)
把上面這個簡短函數所做的操作直接以內聯方式在測試中執行,同時在其前面添加一大段注釋(long all together)
向那個簡短函數中添加一大段注釋,然後調用它(sum long function)
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/function-size.js
在 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 不再像 Crankshaft 那樣用字元數來計算函數長度,而是基於函數內實際包含的操作指令數量,把函數的 AST(Abstract Syntax Tree,抽象語法樹)節點數量來作為判斷依據。這樣,從 V8 5.9(Node 8.3)開始,空格、變數名字元數、函數簽名和注釋都不再會影響一個函數是否會被內聯優化。
同時值得注意的是,可以看到函數執行的整體性能有所下降。
在這個問題上,我們給出的結論是,我們應該繼續讓函數保持簡短。在眼下這個階段,我們還是必須要避免在函數內部存在過多的注釋(甚至還有空格)。假如你對速度有極致追求,那麼自己手工內聯這些函數(也就是去除函數調用)才是最快的方式。當然,這裡我們還要注意要找到一個平衡點,複製粘貼其它函數到你的函數里也會導致你的函數本體尺寸變得過大,有可能不再會被內聯優化。所以手工內聯反而有可能會傷及自身,在大多數情況下最好還是把這件事交給編譯器去做。
32位整數 VS 雙精度浮點數
眾所周知,JavaScript 只有一個數值類型: 。
而 V8 是用 C++ 實現的,所以 V8 需要選擇一個底層類型來表達 JavaScript 中的數值。
接下來這個測試考察如下三種情況:
一個函數,它只處理在32位範圍內的整數(sum small)
一個函數,它處理的數值既有在32位範圍內的整數,也有超出此範圍而需要使用雙精度浮點數表達的整數(from small to big)
一個函數,他只處理需由雙精度浮點數表達的整數(all big)
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/numbers.js
另外值得注意的是,從 Node 6(V8 5.1)到 Node 8.1/8.2(V8 5.8),32 位範圍內的整數操作的速度略有下降,但到 Node 8.3+(V8 5.9+),下降就非常明顯了。而對大整數操作的速度則從 Node 8.3+(V8 5.9+)開始有了明顯的提升。這也恰恰說明,32 位範圍內的整數操作可能確實變慢了,並且並非是由函數調用以及 循環(在測試代碼中使用的)方面的性能變化所致。
感謝 Jakob Kummerow 和 Yang Guo 以及 V8 團隊在這一測試結果的精確性方面給予的幫助。
遍歷對象
從一個對象中提取所有的屬性值然後進行處理,這是一個在開發中很常見的任務。有很多方式可以實現這個功能。下面我們來看一下在被測試的幾個 V8/Node 版本上哪種方式是最快的。
我們比較了下面四種方式:
使用 - 循環和 來取得一個對象的所有值(for in)
使用 獲得一個包含對象所有 key 的數組,然後使用數組的 方法遍歷此數組,在遍歷函數中獲取對象的值(Object.keys functional)
使用 獲得一個包含對象所有 key 的數組,然後使用數組的 方法遍歷此數組,在遍歷函數中獲取對象的值。同時,這個遍歷函數寫成箭頭函數形式(Object.keys functional with arrow)
使用 獲得一個包含對象所有 key 的數組,然後用 循環遍及這一數組並獲取對象的值(Object.keys with for loop)
對於 V8 5.8、5.9、6.0和6.1,我們還測試了額外三種情況:
使用 獲得對象的值數組,並用數組的 方法遍歷它(Object.values functional)
使用 獲得對象的值數組,並用數組的 方法遍歷它。同時,這個遍歷函數寫成箭頭函數形式(Object.values functional with arrow)
用 循環遍歷 返回的數組(Object.values with for loop)
因為 V8 5.1(Node 6)不支持 EcmaScript 2017 原生的 方法,我們沒有對其進行上述這三種額外測試。
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/object-iteration.js
在 Node 6(V8 5.1)和 Node 8.0-8.2(V8 5.8)中,速度最快的方式是通過 - 來遍歷獲得對象的 key 進而取得對應的值,大約每秒可以進行 4000 萬次操作。而後面幾種基於 的方式最快的也只有每秒 800 萬次的速度,僅為前者的五分之一。
而在 V8 6.0(Node 8.3)上, - 的速度直線下降到了之前的四分之一,但即使這樣它也還是比其它方式更快。
再到 V8 6.1(Node未來版本), 的速度得到了提升並超過了 - ,但仍然遠遠不及 V8 5.1 和 5.8(Node 6 和 Node 8.0-8.2)時的 - 。
Turbofan 背後似乎有著這樣一個設計原則,就是它是為符合直覺的編碼形式進行性能調優的。也就是說,那些對開發者來說感覺最自然的編碼形式應該會得到引擎的優化。
但使用 來直接取值的做法要慢於先 然後循環取值的方式。這說明過程式的循環模式仍然要比函數式的編程模式要快。所以當我們對對象進行遍歷操作時,恐怕還是不能用 這麼簡單的方式。
而過去由於 - 的速度優勢,很多人都喜歡使用它,但現在就比較痛苦了:新的 V8 里這種方式的速度將會急劇下降,並且也暫時沒有可以達到之前那麼快速度的替代手段。
對象創建
在我們的代碼中,新對象的創建無處不在,因此這也是一個非常有意義的測試點。
我們將看一下下面三種情況:
使用對象字面值創建對象(literal)
用一個 EcmaScript 2015 類來創建對象(class)
使用構造函數來創建對象(constructor)
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/object-creation.js
在所有被測試的 V8 版本上,創建對象的時間消耗都差不多,除了在 Node 8.2(V8 5.8)上使用類的方式明顯慢於其它方式。這是由於在 V8 5.8 上混合使用 Crankshaft 和 Turbofan 所致,這個問題在 Node 8.3(V8 6.0)中已經被解決了。
「消除」對象創建
在準備這篇文章的過程中,我們曾發現 Turbofan 對某種特定的對象創建方式做了相當不錯的優化。本來我們誤以為這種優化對所有的對象創建方式都有效,但感謝來自 V8 團隊的幫助,讓我們理解了什麼樣的條件下才會觸發這種優化。
在前面「對象創建」一節中的測試里,我們把新創建的對象賦給一個變數,再把它設為 ,然後再重新為它賦值。這樣才避免了觸發下面我們將要看到的這種特殊的優化模式。
接下來這個測試中我們仍然考察與上面的測試相同的三種情況:
使用對象字面值創建對象(literal)
用一個 EcmaScript 2015 類來創建對象(class)
使用構造函數來創建對象(constructor)
不同的是,持有對象引用的變數不會再被下一次新創建的對象所覆蓋,這個對象會被傳遞給另外一個函數來執行一些別的操作。
讓我們來看一下這次的結果!
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/object-creation-inlining.js
你會發現,對於這種情況,V8 6.0(Node 8.3)和 6.1(Node 9)的速度有了一個飛躍式的提升,每秒操作次數超過了 5 億次。這主要是因為一旦你的代碼觸發了 Tubofan 的這種優化,實際上引擎在整個過程中並沒有真正地創建對象,相當於它沒做什麼事情。在這種場景下,Turbofan 能夠判斷出後續代碼邏輯的執行實際上並不需要創建一個真正的對象,所以它啟動優化跳過了對象創建。
上述的測試代碼並未完全表現出觸發這一優化的可能條件,其觸發條件其實是非常複雜的。
但現在我們可以確定的是有一種情況必然不會觸發 Turbofan 的這種優化:
對象的生命周期一定不能長於創建它的那個函數。也就是說,在創建對象的函數結束執行後,就不能再存在指向這一對象的引用了。這個對象可以被傳遞給其它函數,但如果我們把它添加到 上,或者把它賦給在函數作用域之外的一個外部變數,或者把它添加到其它對象中而這些對象的生命周期又長於創建那個對象的函數時,這一優化必然不會發生。
上面這個測試結果固然非常漂亮,可我們又很難預測出所有可以觸發優化的條件。但只要你的代碼滿足了它所需要的條件,它就會為你帶來巨大的速度提升。
感謝 Jakob Kummerow 以及 V8 團隊的其他成員幫助我們弄清楚了這一代碼行為背後的原因。在研究過程中,我們還發現了 V8 新的 GC 引擎 Orinoco 中的一個性能退化問題。如果你有興趣,可以查看 https://v8project.blogspot.it/2016/04/jank-busters-part-two-orinoco.html 和 https://bugs.chromium.org/p/v8/issues/detail?id=6663。
多態 VS 單態
當我們總是向一個函數傳入同一類型的參數時(比如說總是傳入一個字元串),就是在以單態的方式使用這個函數。
有的函數則被寫成了可以支持多態的形式。我們可以想像得出這樣一個函數,在同一個參數位置上它可以接受不同類型的輸入。例如一個函數可以接受一個字元串或者一個對象作為它的第一個參數。但是在這裡,我們所說的「類型」,並非是指字元串、數值或者對象這種類型概念,我們說的是對象的結構(實際上在 JavaScript 中,不同數據類型的對象也可以被認為是具有不同結構的對象)。
對象通過它的屬性和值來定義其結構。例如下面這段代碼, 和 具有相同的結構,但 和 的結構則與其它對象不同:
出於良好的介面設計目的,我們有時候會用同樣一段代碼來處理不同結構的對象,但是這樣會對性能帶來負面影響。
下面讓我們來看一下單態和多態代碼在我們測試中表現。
我們考察了兩種情況:
在一個函數中我們讓它處理不同結構的對象(polymorphic)
在一個函數中我們只讓它處理相同結構的對象(monomorphic)
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/polymorphic.js
這幅圖毋庸置疑地反映出無論在被測試的哪個 V8 版本上,單態函數的性能都要優於多態函數。可是,多態函數的性能在 V8 5.9 之後開始有所改善(也就是從 Node 8.3 開始,它使用的是 V8 6.0)。
在 Node.js 的代碼中多態函數是非常常見的,它們為 API 帶來了巨大的靈活性。感謝這個關於多態函數的性能提升,那些比我們的測試代碼更為複雜的 真實的 Node.js 應用將因此而受益。
如果我們正在寫一些需要特別優化的代碼,比如一個會被調用很多次的函數,那麼我們應該保證傳給這個函數的參數對象的結構保持一致。反之,如果一個函數只可能被調用一兩次,例如一些初始化函數,那麼把它設計成多態形式也是可以接受的。
感謝 Jakob Kummerow 為這個測試提供了一個更為可靠的代碼版本。
關鍵字
最後,我們來談一下 關鍵字。
一定要徹底去除你代碼中的 語句。零星遺留在代碼里的的 會降低性能。
我們來看兩個例子:
一個包含 關鍵字的函數(with debugger)
一個不包含 關鍵字的函數(without debugger)
代碼:https://github.com/davidmarkclements/v8-perf/blob/master/bench/debugger.js
非常明顯,僅僅是出現了一個 ,在所有的測試的 V8 版本上都帶來了巨大的性能下降。
另外,對於後續的幾個 V8 版本,without debugger 那個測試用例也出現了一些性能下降,關於這個問題我們留到最後總結的部分再討論。
一個真實世界中的測試:日誌工具的性能比較
除了上面這些小規模的性能測試之外,我們還可以通過一個真實世界中的實例來看一下 V8 版本變化帶來的整體性能影響。我和 Matteo 在開發 Pino 的過程中,搜集並對最常用的幾個 Node.js 日誌工具進行了性能測試。
下面這幅柱狀圖反映的是在 Node 6.11(Crankshaft)中,這些最常見的日誌工具記錄 1 萬行日誌所消耗的時間(越少越好):
而接下來這幅圖是在 V8 6.1(Turbofan)上進行相同測試的結果:
雖然在新的 JIT 編譯器 Turbofan 上所有日誌工具的速度都有所提升(大約是之前的兩倍),但其中 Winston 的改善幅度是最大的。似乎這正反映了我們在上述多個測試中所觀察到的所謂性能趨同現象:在 Crankshaft 中速度比較慢的代碼形式在 Turbofan 中獲得改善明顯更多,而在 Crankshaft 中執行比較快的代碼則在 Trubofan 中會有一些速度上的降低。在上面這個測試中,Winston 本來是最慢的,它可能使用了一些在 Crankshaft 中執行較慢但在 Tubofan 中快得多的代碼寫法;而 Pino 則曾經針對 Crankshaft 進行過專門優化,所以雖然在 Tubofan 上其速度也有提高,但幅度要小得多。
總結
上面的一些測試表明,隨著 V8 6.0 和 6.1 中 Turbofan 的全面應用,一些在 V8 5.1、5.8 和 5.9 中的較慢的做法會變得更快,但同時一些原先較快的代碼也會變慢,其增減幅度往往是一致的。
這種現象很大程度上源於 Turbofan(V8 6.0 及以上)中進行函數調用的開銷。Turbofan 在性能改善方面的思路就是對最常見的用法進行優化,消除那些明顯為人所知的性能痛點。這樣它一方面為運行於瀏覽器(Chrome)和伺服器端(Node)的應用帶來了在性能上總體的改善,但另一方面也伴隨著妥協,對於原本經過專門優化的最高性能代碼,顯然會帶來速度上的降低(可能將來還會再改善)。我們對日誌工具的測試也表明,Turbofan 為我們帶來的是全面的性能改善,即便是完全不同的代碼庫(比如 Winston 和 Pino)也都會得到速度上的提高。
如果你長期以來一直關注 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 CPU 和 16GB 2133 MHz LPDDR3 內存。其它測試(包括數值、屬性刪除、多態、對象創建)則是在一台 MacBook Pro 2014 上進行的。每台測試機器上都部署了所有被測試的 Node.js 版本。我們也儘力確保了測試過程中沒有其它程序來干擾測試結果。
也歡迎大家關注我們的公眾號
TAG:大前端工程師 |