JavaScript 引擎基礎:原型優化
本文就所有 JavaScript 引擎中常見的一些關鍵基礎內容進行了介紹——這不僅僅局限于 V8 引擎。作為一名 JavaScript 開發者,深入了解 JavaScript 引擎是如何工作的將有助于你了解自己所寫代碼的性能特征。
在前一篇文章中,我們討論了 JavaScript 引擎是如何通過 Shapes 和 Inline Caches 來優化對象與數組的訪問的。本文將介紹優化流程中的權衡與取舍,并對引擎在優化原型屬性訪問方面的工作進行介紹。
原文 JavaScript engine fundamentals: optimizing prototypes ,作者 @Benedikt 和 @Mathias ,譯者 hijiangtao 。以下開始正文。
如果你傾向看視頻演講,請移步 YouTube 查看更多。
1. 優化層級與執行效率的取舍前一篇文章介紹了現代 JavaScript 引擎通用的處理流程:
我們也指出,盡管從高級抽象層面來看,引擎之間的處理流程都很相似,但他們在優化流程上通常都存在差異。為什么呢? 為什么有些引擎的優化層級會比其他引擎更多? 事實證明,在快速獲取可運行的代碼與花費更多時間獲得最優運行性能的代碼之間存在取舍與平衡。
解釋器可以快速生成字節碼,但字節碼通常效率不高。 相比之下,優化編譯器雖然需要更長的時間,但最終會產生更高效的機器碼。
這正是 V8 在使用的模型。V8 的解釋器叫 Ignition,(就原始字節碼執行速度而言)它是所有引擎中最快的解釋器。V8 的優化編譯器名為 TurboFan,它最終會生成高度優化的機器碼。
啟動延遲與執行速度之間的這些權衡便是一些 JavaScript 引擎決定是否在流程中加入優化層的原因。例如,SpiderMonkey 在解釋器和完整的 IonMonkey 優化編譯器之間添加了一個 Baseline 層:
解釋器可以快速生成字節碼,但字節碼執行起來相對較慢。Baseline 生成代碼需要花費更長的時間,但能提供更好的運行時性能。最后,IonMonkey 優化編譯器花費最長時間來生成機器碼,但該代碼運行起來非常高效。
讓我們通過一個具體的例子,看看不同引擎中的優化流程都有哪些差異。這是一些在循環中會經常重復的代碼。
let result = 0;for (let i = 0; i < 4242424242; ++i) {result += i;}console.log(result);
V8開始在 Ignition 解釋器中運行字節碼。從某些方面來看,代碼是否足夠 hot 由引擎決定,引擎話負責調度 TurboFan 前端,它是 TurboFan 中負責處理集成分析數據和構建代碼在機器層面表示的一部分。這部分結果之后會被發送到另一個線程上的 TurboFan 優化器被進一步優化。
當優化器運行時,V8 會繼續在 Ignition 中執行字節碼。 當優化器處理完成后,我們獲得可執行的機器碼,執行流程便會繼續下去。
SpiderMonkey 引擎也開始在解釋器中運行字節碼。但它有一個額外的 Baseline 層,這意味著比較 hot 的代碼會首先被發送到 Baseline。 Baseline 編譯器在主線程上生成 Baseline 代碼,并在完成后繼續后面的執行。
如果 Baseline 代碼運行了一段時間,SpiderMonkey 最終會激活 IonMonkey 前端,并啟動優化器 - 這與 V8 非常相似。當 IonMonkey 進行優化時,代碼在 Baseline 中會一直運行。當優化器處理完成后,被執行的是優化后的代碼而不是 Baseline 代碼。
Chakra 的架構與 SpiderMonkey 非常相似,但 Chakra 嘗試并行處理更多內容以避免阻塞主線程。Chakra 不在主線程上運行編譯器,而是將不同編譯器可能需要的字節碼和分析數據復制出來,并將其發送到一個專用的編譯器進程。
當代碼準備就緒,引擎便開始運行 SimpleJIT 代碼而不是字節碼。 對于 FullJIT 來說流程也是同樣如此。這種方法的好處是,與運行完整的編譯器(前端)相比,復制所產生的暫停時間通常要短得多。但這種方法的缺點是這種 啟發式復制 可能會遺漏某些優化所需的某些信息,因此它在一定程度上是用代碼質量來換時間的消耗。
在 JavaScriptCore 中,所有優化編譯器都與主 JavaScript 執行 完全并發運行 ;根本沒有復制階段!相反,主線程僅僅是觸發了另一個線程上的編譯作業。然后,編譯器使用復雜的加鎖方式從主線程中獲取到要訪問的分析數據。
這種方法的優點在于它減少了主線程上由 JavaScript 優化引起的抖動。 缺點是它需要處理復雜的多線程問題并為各種操作付出一些加鎖的成本。
我們已經討論過在使用解釋器快速生成代碼或使用優化編譯器生成可高效執行代碼之間的一些權衡。但還有另一個權衡: 內存使用 !為了說明這一點,來看一個兩個數字相加的簡單 JvaScript 程序。
function add(x, y) {return x + y;}add(1, 2);
這是我們使用 V8 中的 Ignition 解釋器為 add 函數生成的字節碼:
StackCheckLdar a1Add a0, [0]Return
不要在意這些字節碼 - 你真的不需要閱讀它。關鍵是它只是 四條指令!
當代碼變得 hot,TurboFan 便會生成以下高度優化的機器碼:
leaq rcx,[rip+0x0]movq rcx,[rcx-0x37]testb [rcx+0xf],0x1jnz CompileLazyDeoptimizedCodepush rbpmovq rbp,rsppush rsipush rdicmpq rsp,[r13+0xe88]jna StackOverflowmovq rax,[rbp+0x18]test al,0x1jnz Deoptimizemovq rbx,[rbp+0x10]testb rbx,0x1jnz Deoptimizemovq rdx,rbxshrq rdx, 32movq rcx,raxshrq rcx, 32addl rdx,rcxjo Deoptimizeshlq rdx, 32movq rax,rdxmovq rsp,rbppop rbpret 0x18
這么 一大堆 代碼,尤其是與四條字節碼相比!通常,字節碼比機器碼更緊湊,特別是優化過的機器碼。但另一方面,字節碼需要解釋器才能執行,而優化過機器碼可以由處理器直接執行。
這就是為什么 JavaScript 引擎不簡單粗暴”優化一切”的主要原因之一。正如我們之前所見,生成優化的機器碼也需要很長時間,而最重要的是,我們剛剛了解到優化的機器碼也需要更多的內存。
小結:JavaScript 引擎之所以具有不同優化層,就在于使用解釋器快速生成代碼或使用優化編譯器生成高效代碼之間存在一個基本權衡。通過添加更多優化層可以讓你做出更細粒度的決策,但是以額外的復雜性和開銷為代價。此外,在優化級別和生成代碼所占用的內存之間也存在折衷。這就是為什么 JavaScript 引擎僅嘗試優化比較 hot 功能的原因所在。
2. 原型屬性訪問優化之前的文章解釋了 JavaScript 引擎如何使用 Shapes 和 Inline Caches 優化對象屬性加載?;仡櫼幌?,引擎將對象的 Shape 與對象值分開存儲。
Shapes 可以實現稱為 Inline Caches 或簡稱 ICs 的優化。通過組合,Shapes 和 ICs 可以加快代碼中相同位置的重復屬性訪問速度。
既然我們知道如何在 JavaScript 對象上快速進行屬性訪問,那么讓我們看一下最近添加到 JavaScript 中的特性:class。JavaScript 中 class 的語法如下所示:
class Bar {constructor(x) {this.x = x;}getX() {return this.x;}}
盡管這看上去是 JavaScript 中的一個全新概念,但它僅僅是基于原型編程的語法糖:
function Bar(x) {this.x = x;}Bar.prototype.getX = function getX() {return this.x;};
在這里,我們在 Bar.prototype 對象上分配一個 getX 屬性。這與其他任何對象的工作方式完全相同,因為原型只是 JavaScript 中的對象!在基于原型的編程語言(如 JavaScript)中,方法通過原型共享,而字段則存儲在實際的實例上。
讓我們來實際看看,當我們創建一個名為 foo 的 Bar 新實例時,幕后所發生的事情。
const foo = new Bar(true);
通過運行此代碼創建的實例具有一個帶有屬性 “x” 的 shape。 foo 的原型是屬于 class Bar 的 Bar.prototype 。
Bar.prototype 有自己的 shape,其中包含一個屬性 ’getX’ ,取值則是函數 getX ,它在調用時只返回 this.x 。 Bar.prototype 的原型是 Object.prototype ,它是 JavaScript 語言的一部分。由于 Object.prototype 是原型樹的根節點,因此它的原型是 null 。
如果你在這個類上創建另一個實例,那么兩個實例將共享對象 shape。兩個實例都指向相同的 Bar.prototype 對象。
2.2 原型屬性訪問好的,現在我們知道當我們定義一個類并創建一個新實例時會發生什么。但是如果我們在一個實例上調用一個方法會發生什么,比如我們在這里做了什么?
class Bar {constructor(x) { this.x = x; }getX() { return this.x; }}const foo = new Bar(true);const x = foo.getX();//^^^^^^^^^^
你可以將任何方法調用視為兩個單獨的步驟:
const x = foo.getX();// is actually two steps:const $getX = foo.getX;const x = $getX.call(foo);
第1步是加載這個方法,它只是原型上的一個屬性(其值恰好是一個函數)。第2步是使用實例作為 this 值來調用該函數。讓我們來看看第一步,即從實例 foo 中加載方法 getX 。
引擎從 foo 實例開始,并且意識到 foo 的 shape 上沒有 ’getX’ 屬性,所以它必須向原型鏈追溯。我們到了 Bar.prototype ,查看它的原型 shape,發現它在偏移0處有 ’getX’ 屬性。我們在 Bar.prototype 的這個偏移處查找該值,并找到我們想要的 JSFunction getX 。就是這樣!
但 JavaScript 的靈活性使得我們可以改變原型鏈鏈接,例如:
const foo = new Bar(true);foo.getX();// → trueObject.setPrototypeOf(foo, null);foo.getX();// → Uncaught TypeError: foo.getX is not a function
在這個例子中,我們調用 foo.getX() 兩次,但每次它都具有完全不同的含義和結果。 這就是為什么盡管原型只是 JavaScript 中的對象,但優化原型屬性訪問對于 JavaScript 引擎而言比優化常規對象的屬性訪問更具挑戰性的原因了。
粗略的來看,加載原型屬性是一個非常頻繁的操作:每次調用一個方法時都會發生這種情況!
class Bar {constructor(x) { this.x = x; }getX() { return this.x; }}const foo = new Bar(true);const x = foo.getX();//^^^^^^^^^^
之前,我們討論了引擎如何通過使用 Shapes 和 Inline Caches 來優化訪問常規屬性的。 我們如何在具有相似 shape 的對象上優化原型屬性的重復訪問呢? 我們在上面已經看過是如何訪問屬性的。
為了在這種特殊情況下實現快速重復訪問,我們需要知道這三件事:
foo 的 shape 不包含 ’getX’ 并且沒有改變過。這意味著沒有人通過添加或刪除屬性或通過更改其中一個屬性來更改對象 foo 。 foo 的原型仍然是最初的 Bar.prototype 。這意味著沒有人通過使用 Object.setPrototypeOf() 或通過賦予特殊的 _proto_ 屬性來更改 foo 的原型。 Bar.prototype 的形狀包含 ’getX’ 并且沒有改變。這意味著沒有人通過添加或刪除屬性或更改其中一個屬性來更改 Bar.prototype 。一般情況下,這意味著我們必須對實例本身執行1次檢查,并對每個原型進行2次檢查,直到找到我們正在尋找的屬性所在原型。 1 + 2N 次檢查(其中 N 是所涉及的原型的數量)對于這種情況聽起來可能不太糟糕,因為這里原型鏈相對較淺 - 但是引擎通常必須處理更長的原型鏈,就像常見的 DOM 類一樣。這是一個例子:
const anchor = document.createElement(’a’);// → HTMLAnchorElementconst title = anchor.getAttribute(’title’);
我們有一個 HTMLAnchorElement ,在其上調用 getAttribute() 方法。這個簡單的錨元素原型鏈就已經涉及6個原型!大多數有趣的 DOM 方法并不是直接存在于 HTMLAnchorElement 原型中,而是在原型鏈的更高層。
我們可以在 Element.prototype 上找到 getAttribute() 方法。這意味著我們每次調用 anchor.getAttribute() 時,JavaScript引擎都需要……
’getAttribute’HTMLAnchorElement.prototypeHTMLElement.prototype’getAttribute’Element.prototype’getAttribute’
總共有7次檢測!由于這是 Web 上一種非常常見的代碼,因此引擎會應用技巧來減少原型上屬性加載所需的檢查次數。
回到前面的例子,我們在 foo 上訪問 ’getX’ 時總共執行了3次檢查:
class Bar {constructor(x) { this.x = x; }getX() { return this.x; }}const foo = new Bar(true);const $getX = foo.getX;
在直到我們找到攜帶目標屬性的原型之前,我們需要對原型鏈上的每個對象進行 shape 的缺失檢查。如果我們可以通過將原型檢查折疊到缺失檢查來減少檢查次數,那就太好了。而這基本上就是引擎所做的: 引擎將原型鏈在 Shape 上,而不是直接鏈在實例上。
每個 shape 都指向原型。這也意味著每次 foo 原型發生變化時,引擎都會轉換到一個新 shape。 現在我們只需要檢查一個對象的 shape,這樣既可以斷言某些屬性的缺失,也可以保護原型鏈鏈接。
通過這種方法,我們可以將檢查次數從 1 + 2N 降到 1 + N ,以便在原型上更快地訪問屬性。但這仍相當昂貴,因為它在原型鏈的長度上仍然是線性的。 為了進一步將檢查次數減少到一個常量級別,引擎采用了不同的技巧,特別是對于相同屬性訪問的后續執行。
2.3 Validity cellsV8專門為此目的處理原型的 shape。每個原型都具有一個不與其他對象(特別是不與其他原型共享)共享且獨特的 shape,且每個原型的 shape 都具有與之關聯的一個特殊 ValidityCell 。
只要有人更改相關原型或其祖先的任何原型,此 ValidityCell 就會失效。讓我們來看看它是如何工作的。
為了加速原型的后續訪問,V8 建立了一個 Inline Cache,其中包含四個字段:
在第一次運行此代碼預熱 inline cache 時,V8 會記住目標屬性在原型中的偏移量,找到屬性的原型(本例中為 Bar.prototype ),實例的 shape(在這種情況下為 foo 的 shape),以及與實例 shape 鏈接的 直接原型 中 ValidityCell 的鏈接(在本例中也恰好是 Bar.prototype )。
下次 inline cache 命中時,引擎必須檢查實例的 shape 和 ValidityCell 。如果它仍然有效,則引擎可以直接到達 Prototype 上的 Offset 位置,跳過其他查找。
當原型改變時,shape 將重新分配,且先前的 ValidityCell 失效。因此,Inline Cache 在下次執行時會失效,從而導致性能下降。
回到之前的 DOM 示例,這意味著對 Object.prototype 的任何更改不僅會使 Object.prototype 本身的 inline cache 失效,而且還會使其下游的所有原型失效,包括 EventTarget.prototype , Node.prototype , Element.prototype 等,直到 HTMLAnchorElement.prototype 為止。
實際上,在運行代碼時修改 Object.prototype 意味著完全拋棄性能上的考慮。不要這樣做!
讓我們用一個具體的例子來探討這個問題。 假設我們有一個類叫做 Bar ,并且我們有一個函數 loadX ,它調用 Bar 對象上的方法。 我們用同一個類的實例多調用這個 loadX 函數幾次。
class Bar { /* … */ }function loadX(bar) {return bar.getX(); // IC for ’getX’ on `Bar` instances.}loadX(new Bar(true));loadX(new Bar(false));// IC in `loadX` now links the `ValidityCell` for// `Bar.prototype`.Object.prototype.newMethod = y => y;// The `ValidityCell` in the `loadX` IC is invalid// now, because `Object.prototype` changed.
loadX 中的 inline cache 現在指向 Bar.prototype 的 ValidityCell 。 如果你之后執行了類似于改變 Object.prototype (這是 JavaScript 中所有原型的根節點)的操作,則 ValidityCell 將失效,且現有的 inline cache 會在下次命中時丟失,從而導致性能下降。
修改 Object.prototype 被認為是一個不好的操作,因為它使引擎在此之前為原型訪問準備的所有 inline cache 都失效。 這是另一個 不推薦 的例子:
Object.prototype.foo = function() { /* … */ };// Run critical code:someObject.foo();// End of critical code.delete Object.prototype.foo;
我們擴展了 Object.prototype ,它使引擎在此之前存儲的所有原型 inline cache 均無效了。然后我們運行一些用到新原型方法的代碼。引擎此時則需要從頭開始,并為所有原型屬性的訪問設置新的 inline cache。最后,我們刪除了之前添加的原型方法。
清理,這聽起來像個好主意,對吧?然而在這種情況下,它只會讓情況變得更糟!刪除屬性會修改 Object.prototype ,因此所有 inline cache 會再次失效,而引擎又必須從頭開始。
總結:雖然原型只是對象,但它們由 JavaScript 引擎專門處理,以優化在原型上查找方法的性能表現。把你的原型放在一旁!或者,如果你確實需要修改原型,請在其他代碼運行之前執行此操作,這樣至少不會讓引擎所做的優化付諸東流。
5. Take-aways我們已經了解了 JavaScript 引擎是如何存儲對象與類的, Shapes 、 Inline Caches 和 ValidityCells 是如何幫助優化原型的?;谶@些知識,我們認為存在一個實用的 JavaScript 編碼技巧,可以幫助提高性能:不要隨意修改原型對象(即便你真的需要,那么請在其他代碼運行之前做這件事)。
(完)
來自:https://hijiangtao.github.io/2018/08/21/Prototypes/
相關文章: