「譯」JS 引擎核心: 原型優化

armslave00發表於2018-09-05

原文連結:JavaScript engine fundamentals: optimizing prototypes

作者序

本系列主要介紹那些 JS 引擎中用到的核心設計。本文的作者是 V8 引擎的開發者 Benedikt and Mathias ,但不用擔心,這些內容是適用於各大 JS 引擎的。作為一個 JS 開發者,深入瞭解 JS 引擎的工作原理可以有助於你去解讀自己程式碼的一些效能特徵。

在上一篇文章中(原文譯文),我們討論了 JS 引擎是如何使用 Shapes(形,V8 中對這種資料結構的命名,具體原理請參考上一篇文章) 和 Inline Caches(暫譯內聯快取,一種用於優化訪問效能的資料結構,具體原理同樣參考上一篇文章) 來優化對物件與陣列的訪問效能的。本文將會解釋優化管線中的權衡以及引擎是如何優化原型屬性訪問的效能的。

**小貼士:**如果你更傾向於看視訊來學習,可以跳過本文直接看這個 視訊。(需要梯子,推薦使用酸酸乳)

優化層級與執行權衡(Optimization tiers and execution trade-offs)

我們的上一篇文章討論了現代 JS 引擎都存在著一個相同的執行管線設計:

js-engine-pipeline

我們同時也指出了雖然引擎之間在優化管線設計上總體來說是相同的,這其中還是依然存在著一些差異點的。為什麼呢?**為什麼一些引擎會比別的引擎使用更多優化層級呢?**我們瞭解到在儘快開始執行程式碼與先花費時間然後最終獲得更高效能的執行程式碼之間存在著權衡。

tradeoff-startup-speed

直譯器(interpreter)可以快速地產出位元組碼(bytecode),但是位元組碼通常都不是那麼的高效。一個優化編譯器(optimizing compiler)則會花費多一點的時間,但最終產出相比之下非常高效的機器碼(machine code)。

這正是 V8 引擎在使用的模型。V8 引擎中的直譯器叫做 Ignition,並且他是目前所有引擎裡面最快速的直譯器(僅就位元組碼執行速度而言)。V8 的優化編譯器叫做 TurboFan,他最終會產出高度優化後的機器碼。

tradeoff-startup-speed-v8

這一啟動等待時間與執行速度之間的權衡,正是一些 JS 引擎選擇在中間增加優化層級的原因。例如 SpiderMonkey 在直譯器與他們的優化編譯器 IonMonkey 之間增加了 Baseline 層。

tradeoff-startup-speed-spidermonkey

直譯器產出位元組碼的速度很快,但位元組碼的執行速度相對較慢。Baseline 會多花一點時間去生成程式碼,但也提供了更佳的執行時效能。然後 IonMonkey 會花更加多的時間去產出機器碼,不過機器碼在執行時真的非常高效。

讓我們通過一個具體的例子來看下不同引擎的執行管線在處理上有什麼不同之處。下面是一段在一個長迴圈中不斷被執行的程式碼。

let result = 0;
for (let i = 0; i < 4242424242; ++i) {
	result += i;
}
console.log(result);
複製程式碼

V8 首先會在 Ignition 直譯器上執行位元組碼。然後在某個時間點,引擎會發現這段程式碼比較 hot(經常被執行),引擎就會啟動 TurboFan 前臺(frontend)。TurboFan 前臺是 TurboFan 的一部分,他會負責合併處理統計資料並且構造一個這段程式碼的基礎機器端描述結構。TurboFan 前臺的產出之後就會傳送給在另一個執行緒上的 TurboFan 優化器(optimizer)用於後續優化。

pipeline-detail-v8

在優化器進行優化的期間,V8 會繼續使用 Ignition 執行位元組碼。當優化器完成了他的工作時我們就能得到可執行的機器碼,之後將使用機器碼繼續執行這段邏輯。

SpiderMonkey 引擎也同樣首先會在直譯器上執行位元組碼。不過他擁有一層 Baseline 層,所以 hot 的程式碼會被先傳送給 Baseline。Baseline 編譯器(compiler)會在主執行緒上優化生成 Baseline 程式碼,之後繼續執行邏輯。

pipeline-detail-spidermonkey

如果 Baseline 程式碼之後被多次執行,SpiderMonkey 最終會啟動 IonMonkey 前臺(frontend),並喚起他的優化器(optimizer)。這塊就和 V8 引擎很相似了。Baseline 程式碼依然會被執行一段時間,直到 IonMonkey 完成了優化工作。之後優化後的程式碼將會替代掉 Baseline 程式碼進行執行。

Chakra 的架構和 SpiderMonkey 非常相似。不同的是 Chakra 嘗試讓更多的內容並行執行以免阻塞主執行緒。所以 Chakra 不會在主執行緒上執行任何編譯器的元件,他選擇複製一份位元組碼和那些編譯器可能會需要用到的分析資料,然後將這些資料傳送給一個專門的編譯程式。

pipeline-detail-chakra

當程式碼生成完成之後,引擎將會執行這些 SimpleJIT(Chakra 中間一層的優化編譯器)程式碼來取代之前的位元組碼。FullJIT(Chakra 最上層的優化編譯器)的運作方式也與之相同。這種方法的好處是進行一次複製所產生的暫停時間會遠遠比執行一次整個編譯器或編譯器前臺短很多。不過這種方法的不足是 **copy heuristic(啟發式複製)**可能會造成一些實際優化過程中有用的資料的缺失。所以在一定程度上也可以看作是程式碼質量與暫停時間之間的權衡。

在 JavaScriptCore 中,所有的優化編譯器都是完全並行於 JS 執行的,不存在任何 copy 環節!主執行緒僅僅會觸發(trigger)一個編譯任務給另一個編譯執行緒。之後編譯器會使用一個複雜的鎖結構去主執行緒上訪問分析資料。

pipeline-detail-javascriptcore

這種方法的好處是他減少了程式碼優化在主執行緒上的開銷。不足是這種方法會引入複雜的多執行緒問題,並且在進行許多操作時也會產生執行緒鎖帶來的開銷。

我們上面討論了許多關於在使用直譯器讓程式碼更早開始執行和使用優化編譯器讓程式碼執行更高效之間的權衡。然而,還有另一種權衡存在——記憶體開銷!為了能夠更形象地理解這點,我們先看下下面這段程式碼。這段程式碼的內容很簡單,只是將兩個數相加。

function add(x, y) {
	return x + y;
}

add(1, 2);
複製程式碼

下面是我們使用 V8 的 Ignition 直譯器生成的 add 方法的位元組碼:

StackCheck
Ldar a1
Add a0, [0]
Return
複製程式碼

不用在意這段位元組碼實際是什麼意思,關鍵點是這就只有四行程式碼

當這段程式碼變得 hot 了,TurboFan 會生成下面這段高度優化後的機器碼:

leaq rcx,[rip+0x0]
movq rcx,[rcx-0x37]
testb [rcx+0xf],0x1
jnz CompileLazyDeoptimizedCode
push rbp
movq rbp,rsp
push rsi
push rdi
cmpq rsp,[r13+0xe88]
jna StackOverflow
movq rax,[rbp+0x18]
test al,0x1
jnz Deoptimize
movq rbx,[rbp+0x10]
testb rbx,0x1
jnz Deoptimize
movq rdx,rbx
shrq rdx, 32
movq rcx,rax
shrq rcx, 32
addl rdx,rcx
jo Deoptimize
shlq rdx, 32
movq rax,rdx
movq rsp,rbp
pop rbp
ret 0x18
複製程式碼

這可真是一長串程式碼,特別是如果我們將他和上面四行位元組碼做一下比較的話!通常來說,位元組碼的含義會傾向於比機器碼更復雜,特別是相對於優化後的機器碼來說。另一方面,位元組碼需要一個直譯器來執行他,而優化後的機器碼可以被處理器直接執行。

這也是 JS 引擎不會去“優化所有程式碼”的主要原因之一。我們在之前已經學到,生成優化機器碼需要花費很長的時間,在此基礎上,我們由上面的案例還可以發現優化後的程式碼將消耗更多記憶體

tradeoff-memory

總結: 不同的 JS 引擎之所以會有不同數量的優化層級,是因為需要在使用直譯器讓程式碼更早開始執行和使用優化編譯器讓程式碼執行更高效之間作出權衡。這是一個尺度抉擇問題,而新增更多的優化層級將使你可以在額外的複雜度等成本上作出更細粒度的抉擇。在此之上,又存在著優化層級與優化程式碼的記憶體開銷之間的權衡。這也是為什麼 JS 引擎只會嘗試去優化那些 hot 的方法。

優化原型屬性的訪問效能

我們的上一篇文章解釋了 JS 引擎是如何使用 Shapes 和 Inline Caches 優化物件屬性的載入的。總結一下就是,引擎會將物件的 Shapes 和物件的具體內容分開放置。

shape-2

Shapes 則帶來了名叫 Inline Caches(可簡寫為 ICs)的這種優化方法。兩者組合使用之後,可以加速程式碼中同一地方對物件屬性的重複訪問。

ic-4

類與基於原型的程式設計

現在我們知道了如何更快訪問物件屬性,我們再來看一個 JS 家族中剛加入不久的新人:類(Class)。下面是 JS 中定義類的大致語法。

class Bar {
	constructor(x) {
		this.x = x;
	}
	getX() {
		return this.x;
	}
}
複製程式碼

雖然這看起來像是 JS 中的一個新概念,但其實僅僅是基於原型程式設計的一個語法糖。原型大法依然是基業根深蒂固,萬古長青。

function Bar(x) {
	this.x = x;
}

Bar.prototype.getX = function getX() {
	return this.x;
};
複製程式碼

這裡我們賦值了名為 getX 的屬性給 Bar.prototype 物件。這和賦值給其他物件是一樣的,因為 JS 中原型也是物件!在類似 JS 這樣的基於原型的程式語言中,物件通過原型共享方法,具體欄位則是儲存在物件例項本身上。

那讓我們深入看一下當我們建立一個名為 fooBar 例項的時候,背後發生了哪些事情。

const foo = new Bar(true);
複製程式碼

這句程式碼生成的例項有一個只有 'x' 一個欄位的 Shape。foo 的原型是 Bar.prototype,其歸屬於類 Bar

class-shape-1

這個 Bar.prototype 有一個自己的 Shape,他有一個 'getX' 欄位,其值為功能只是返回 this.x 的方法 getXBar.prototype 的原型是 Object.prototype,他是 JS 語言基礎的一部分。Object.prototype 是整個原型樹的根,所以他的原型是 null

class-shape-2

如果你建立同一個類的另一個例項,如我們之前所說,兩個例項將共享同一個 Shape。兩個例項的原型也會指向同一個 Bar.prototype 物件。

訪問原型屬性

Ok,現在我們瞭解了當我們定義一個類、建立一個例項的時候背後發生了什麼。那麼當我們呼叫一個例項上的方法的時候,背後又發生了什麼呢?比如像下面的程式碼這樣:

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);
複製程式碼

第一步是載入這個方法,這個方法就只是原型上的一個屬性(他的值是一個函式)。 第二步是將當前例項作為 this 來呼叫這個函式。讓我們具體看下第一步,將 getX 方法從 foo 例項上讀取出來。

method-load

引擎先從 foo 例項開始,發現在 foo 的 Shape 上並沒有找到 'getX' 屬性,所以他需要順著原型鏈向上追溯。然後我們訪問到了 Bar.prototype,在他的原型 Shape 上,我們找到了 'getX' 屬性在偏移位0 上。我們訪問 Bar.prototype 在這個偏移位上的值,發現了 JSFunction getX,這正是我們想要的!

JS 的靈活性讓我們有辦法修改原型鏈,例如下面這段程式碼:

const foo = new Bar(true);
foo.getX();
// → true

Object.setPrototypeOf(foo, null);
foo.getX();
// → Uncaught TypeError: foo.getX is not a function
複製程式碼

在這個範例中,我們呼叫了 foo.getX() 兩次,但是每次的含義和結果都是完全不同的。這就是為什麼雖然原型在 JS 中也只是普通的物件,但是對 JS 引擎來說,加速原型屬性的訪問速度要比加速物件自己的屬性訪問速度更具挑戰性。

在那些 JS 程式中,載入原型屬性是一個非常常見的操作:每次你呼叫例項方法就會發生!

class Bar {
	constructor(x) { this.x = x; }
	getX() { return this.x; }
}

const foo = new Bar(true);
const x = foo.getX();
//        ^^^^^^^^^^
複製程式碼

之前我們已經討論過了引擎是如何通過使用 Shapes 和 Inline Caches 這兩種方法來優化普通物件對自身屬性的載入速度的。那我們如何優化對擁有類似 Shape 的物件的原型上的屬性的重複呼叫呢?我們在上面已經解釋過屬性的訪問是如何完成的。

prototype-load-checks-1

為了能加速在這種特定條件下的重複呼叫,我們首先需要確定下面三件事:

  1. foo 物件的 Shape 沒有 'getX' 屬性,並且不會改變。意思是物件 foo 未發生新增刪除一個屬性或者是改變其屬性內容這樣的變更。
  2. foo 的原型依然是初始的 Bar.prototype。意思是未通過 Object.setPrototypeOf() 或是對其 _proto_ 屬性賦值來改變 foo 的原型。
  3. Bar.prototype 的 Shape 擁有 'getX' 屬性,並且未發生變更。意思是 Bar.prototype 未發生新增刪除一個屬性或者是改變其屬性內容這樣的變更。

通常來說,這意味著我們需要先在例項本身上做一次檢查,然後再加上對原型鏈上直到我們找到這個屬性之前的每個原型做兩次檢查。也許你覺得 1+2N 次檢查(N 是中間涉及的原型的數量)感覺上並沒有什麼問題,但你需要知道範例的這個原型鏈其實是相對較淺的,引擎經常會面對比這長得多的原型鏈。打個比方,通用的 DOM 類就是一個例子。請看如下程式碼:

const anchor = document.createElement('a');
// → HTMLAnchorElement

const title = anchor.getAttribute('title');
複製程式碼

我們建立了一個 HTMLAnchorElement 然後呼叫了他的 getAttribute() 方法。這個簡單的 anchor 元素的原型鏈就已經引入了 6 個原型!大多數有意思的 DOM 操作方法並不是直接來自於 HTMLAnchorElement 原型的,而是在原型鏈中更上層的位置。

anchor-prototype-chain

你會在 Element.prototype 上找到 getAttribute() 方法。這意味著每次我們呼叫 anchor.getAttribute() 的時候,JS 引擎都需要...

  1. 檢查 'getAttribute' 並不存在於 anchor 物件上,
  2. 獲取到下一級的原型是 HTMLAnchorElement.prototype
  3. 檢查 'getAttribute' 不在這層原型上,
  4. 獲取到下一級的原型是 HTMLElement.prototype
  5. 檢查 'getAttribute' 也不再這層原型上,
  6. 最後獲取到下一級的原型是 Element.prototype
  7. 在他上面終於找到了 'getAttribute'

總共七步檢查!因為這一型別的程式碼在 web 程式設計中真的非常常見,引擎使用了一些小技巧去減少原型屬性訪問所必須的檢查次數。

讓我們回到之前的一個例子,我們訪問 foo'getX' 屬性時總共進行了三次檢查:

class Bar {
	constructor(x) { this.x = x; }
	getX() { return this.x; }
}

const foo = new Bar(true);
const $getX = foo.getX;
複製程式碼

在最終找到那個帶有我們需要的屬性的原型之前,每一個物件我們都需要進行 Shape 的屬性檢查。如果我們能通過將原型檢查收納進屬性檢查的方式減少檢查的次數,就能優化一部分效能。而這也大體上就是引擎正在使用的技巧:引擎會將原型的引用存放在 Shape 裡而不是例項本身上

prototype-load-checks-2

每一個 Shape 都會指向原型。這也意味著每當 foo 的原型發生改變,引擎將會為他替換一個新的 Shape。現在我們不管是確認屬性是否存在還是獲取原型的引用,都只需要檢查物件的 Shape 就可以了。

通過這一方法,我們可以加速原型屬性訪問,將所必須的檢查次數從 1+2N 次減少到 1+N 次。但是這開銷依然挺大的,畢竟這還是相對原型鏈深度線性遞增的。引擎實現了另外一些方法去儘可能得將檢查次數減少到一個固定數量,特別是針對那些之後會經常執行的屬性訪問操作。

有效性驗證單元(Validity cells)

V8 為了這一目標,對原型的 Shapes 做了特別的處理。每個原型都有一個獨一無二的 Shape,這個 Shape 不和其他物件共享(特別是不與其他原型共享),每一個原型 Shape 有一個特殊的 ValidityCell 與之關聯。

validitycell

每當這個 ValidityCell 關聯的原型發生了變更,或是更上層的原型發生了變更,他就會被標記無效。讓我們具體看下整個過程是怎麼樣的。

為了加速後續的原型屬性訪問,V8 建立了一個帶有四個欄位的 Inline Caches:

ic-validitycell

在第一次執行程式碼對 Inline Cache 做預熱的時候,V8 記錄下了原型中該屬性的位置偏移量、該屬性所屬的原型物件(該例子中就是 Bar.prototype)、當前例項的 Shape(該例子中就是 foo 的 Shape)以及當前例項的 Shape 最相近的那個原型當前的有效性驗證單元的引用(該例子中是 Bar.prototype 的有效性驗證單元)。

當下一次這個 Inline Cache 被命中生效時,引擎會首先檢查當前例項的 Shape 以及他的 ValidityCell。如果依然是標記有效的,引擎就可以直接跳過多餘的查詢,通過記錄的屬性位置偏移量(Offset)屬性所屬原型物件(Prototype)訪問到這個屬性。

validitycell-invalid

當原型發生了變更,則會導致一個新的 Shape 被分配到該原型,而原來的那個 ValidityCell 也會被標記為無效的。這樣下次執行時 Inline Cache 就無法起到作用了,從而造成一個低下的訪問效能。

讓我們再回到之前那個 DOM 元素的例子。如果我們對 Object.prototype 進行了修改,這不光光會使 Object.prototype 自己的 Inline Caches 被標記無效,還會使他下面的 EventTarget.prototype, Node.prototype, Element.prototype 等等直到 HTMLAnchorElement.prototype 為止的這些原型的 Inline Caches 都被標記無效。

prototype-chain-validitycells

由此可見,在執行時去修改 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.prototypeValidityCell。如果你後面做了類似修改 Object.prototype 這樣的操作,Object.prototype 在 JS 中是所有原型鏈的根,這樣操作會使我們的 ValidityCell 變無效,下次再命中現在這個 Inline Caches 時,他就已經沒有作用了。失去了 Inline Caches 的優化,我們只能回到低下的訪問效能了。

修改 Object.prototype 永遠都是一個不建議的做法,這會讓引擎在那個時間點之前建立的所有原型屬性訪問 Inline Caches 都失效。我們再來看看另一個關於不要做什麼的例子:

Object.prototype.foo = function() { /* … */ };

// Run critical code:
someObject.foo();
// End of critical code.

delete Object.prototype.foo;
複製程式碼

我們首先擴充套件了 Object.prototype,這會導致引擎在這之前建立的所有原型 Inline Caches 都失效。然後我們執行了一些呼叫了這個新的原型方法的程式碼。在這期間引擎將從頭開始查詢並且為所有的原型屬性訪問建立 Inline Caches。最後,我們進行清場,刪除了之前新增的那個原型方法。

清場聽起來是一個很好的做法,對吧?但是在這個場景下他只會讓情況變得更糟!刪除屬性的操作再次修改了 Object.prototype,因此所有剛才建立的 Inline Caches 又再一次失效了,引擎需要從查詢開始再做一遍。

**總結:**雖然原型就只是物件,但是 JS 引擎為了優化原型方法查詢效能對其有一些特殊的處理方式。請不要去動這些原型!如果你真的需要去改動原型,那麼請在其他程式碼執行之前完成改動,這樣你至少不會在程式碼執行的時候無效掉引擎所有的優化。

小結(Take-aways)

我們已經學習了 JS 引擎是如何儲存物件與類的,已經引擎是如何利用 Shapes, Inline Caches 和 ValidityCells 來優化原型上的操作的。基於這些知識,我們可以歸納出一條能幫助我們優化程式碼效能的 JS 程式設計實踐技巧:不要隨意改動原型(如果你真的真的需要,那麼至少在其他程式碼執行之前幹這個事)。

譯者記

這篇文章大體上可以分成兩塊。

第一部分向我們解釋了為什麼 JS 引擎不會優化所有的程式碼,以及為什麼每個 JS 引擎的設計會有一些不同。這些知識點在譯者的視角上有幾點比較有意思。

  1. V8 的直譯器是最高效的。
  2. 優化的一個點在如何應用併發優勢。
  3. 那些經常被執行的程式碼更加有機會被引擎優化,所以程式設計時要做好邏輯的設計規劃。

第二部分通過一些簡單情況下的引擎具體行為,向我們展示了引擎在對原型鏈訪問時是怎麼進行優化的。最重要的是這些優化都是基於某些場景假設下才成立的,所以如果你希望你的程式碼執行得更高效,請儘量遵守這些假設,保持良好的程式碼習慣。

Class 這種語法很好地包裝了原型鏈,語法還更簡潔易懂,推薦大家使用。

JS 作為動態語言很靈活,但是有時候這種靈活的代價是效能。如果你在意執行時效能請保持克制,不要隨意修改原型鏈內容。

全文完,謝謝觀眾老爺們。

相關文章