V8 引擎:基於類型推測的性能優化原理

介紹

本文的會介紹一些關於 V8 內基於推測的優化的技術,以此來告訴大家,爲什麼需要 TypeScript。

我們將以一段函數的執行未展開,從函數執行的角度來看看,一段代碼如何被執行,優化,再最後,你會了解,爲什麼 TypeScript 更好。

看完本文後,你不需要記住文章中出現的繁雜的指令和代碼,只需要在你的腦海中存在一個印象,避免寫出糟糕的代碼,以及,儘量使用 TypeScript。

如何執行代碼?

作爲介紹的第一部分,我們會用一段簡短的篇幅帶大家看看,你的代碼如何被執行

當然,如果用簡單的流程圖表示,你可以把上面的過程理解爲這樣一個線性的執行過程,當然可能並不嚴謹,稍後我們會繼續介紹。

下面讓我們從一段具體的代碼來看一下這個過程。

一段簡單的代碼

function add(x, y) {
    return x + y;
} 
console.log(add(1, 2))

如果你在 chrome 的 DevTools console 中運行這段代碼,你可以看到預期的輸出值 3。

根據上面的流程圖,這段代碼被執行的第一步,是被解析器解析爲 AST,這一步我們用 d8 shell 的 Debug 版本中使用 –print-ast 命令來查看 V8 內部生成的 AST。

$ out/Debug/d8 --print-ast add.js
…-
-- AST ---
FUNC at 12
. KIND 0
. SUSPEND COUNT 0
. NAME "add"
. PARAMS
. . VAR (0x7fbd5e818210) (mode = VAR) "x"
. . VAR (0x7fbd5e818240) (mode = VAR) "y"
. RETURN at 23
. . ADD at 32
. . . VAR PROXY parameter[0] (0x7fbd5e818210) (mode = VAR) "x"
. . . VAR PROXY parameter[1] (0x7fbd5e818240) (mode = VAR) "y

很多人可能或多或少接觸過 AST 的概念,這裏不多贅述,只是用一張簡單的圖表示下上面的過程。

最開始,函數字面量 add 被解析爲樹形表示,其中一個子樹用於參數聲明,另外一個子樹用於實際的的函數體。在解析階段,不可能知道程序中名稱和變量的綁定關係,這主要是因爲 “有趣的變量聲明提升規則” 以及 JavaScript 中的 eval,此外還有其他原因。

一旦我們構建完成了 AST,它便包含了從中生成可執行字節碼的所有必要信息。AST 隨後被傳遞給 BytecodeGenerator ,BytecodeGenerator 是屬於 Ignition 的一部分,它以函數爲單位生成字節碼(其他引擎並不一定以函數爲單位生成的)。你也可以在 d8 中使用命令–print-bytecode 來查看 V8 生成的字節碼(或者用 node 端)

$ out/Debug/d8 --print-bytecode add.js
…[
generated bytecode for function: add]
Parameter count 3
Frame size 0
12 E> 0x37738712a02a @ 0 : 94 StackCheck
23 S> 0x37738712a02b @ 1 : 1d 02 Ldar a1
32 E> 0x37738712a02d @ 3 : 29 03 00 Add a0, [0]
36 S> 0x37738712a030 @ 6 : 98 Return
Constant pool (size = 0)
Handler Table (size = 16)

上面過程中爲函數 add 生成了一個新的字節碼對象,它接受三個參數,一個內部的 this 引用,以及兩個顯式形參 x 和 y。該函數不需要任何的局部變量(所以棧幀大小爲 0),並且包含下面這四個字節碼指令組成的序列

StackCheck
Ldar a1
Add a0, [0]
Return

爲了解釋這段字節碼,我們首先需要從較高的層面來認知解釋器如何工作。V8 的解釋器是基於寄存器架構 (register machine) 的(相對的是基於棧架構,也是早期 V8 版本中使用的 FullCodegen 編譯器)。Ignition 會把指令序列都保存在解釋器自身的(虛擬)寄存器中,這些寄存器部分被映射到實際 CPU 的寄存器中,而另外一部分會用實際機器的棧內存來模擬。

有兩個特殊寄存器 a0 和 a1 對應着函數在機器棧(即內存棧)上的形式參數(在函數 add 這個例子中,有兩個形參)。形參是在源代碼中聲名的參數, 它可能與在運行時傳遞給函數的實際參數數量不同。每個字節碼指令執行得到的最終值通常被保存在一個稱作累加器(accumulator)的特殊寄存器中。堆棧指針(stack pointer )指向當前的棧幀或者說激活記錄,程序計數器( program counter)指向指令字節碼中當前正在執行的指令。下面我們看看這個例子中每條字節碼指令都做了什麼。

爲什麼這條指令是這樣的?以一條 JS 語句爲例

var dest = src1 + src2 // op dest, src1,src2

 var dest += src; //op dest, src

 +src; // op src

分別表示三地址指令,二地址指令,一地址指令,我在後面分別標註了轉換後的機器指令。三地址和二地址指令都指定了運算後儲存結果的位置。

但在一地址指令中,沒有指定目標源。實際上,它會被默認存在一個累加器”(accumulator)的專用寄存器,保存計算結果。

其中 Add 運算符的 [0] 操作數指向一個**「反饋向量槽( feedback vector slot)」**,它是解釋器用來儲存有關函數執行期間看到的值的分析信息。在後面講解 TurboFan 如何優化函數的時候會再次回到這。

當最終生成了上面這段字節碼後,會被送入的 VM ,一般會由解釋器進行執行,這種執行方式是最原始也是效率最低的。我們可以在下一部分了解到,這種原始的執行會經歷什麼。

關於字節碼的解釋,這裏不會做過多的贅述,如果你感興趣,可以擴展閱讀 「Understanding V8’s Bytecode」 (https://medium.com/dailyjs/understanding-v8s-bytecode-317d46c94775) 一文,這篇字節碼對 V8 的字節碼的工作原理提供了一些深入的瞭解。

爲什麼需要優化

現在,我相信你已經對 V8 如何執行一段代碼有了一個簡單的認識。在正式進入我們的主題之前,還需要解釋一個很關鍵的問題,爲什麼我們需要優化。爲了回答這個問題,我們需要先看下下規範

關於規範的更多瞭解,可以去這裏查找 https://tc39.es/ecma262/#sec-toprimitive

我們再以看最常見的 ToPrimitive 爲例,需要經過非常繁瑣的求值過程,而這些過程都是爲了解決操作類型的動態性

在 JavaScript 中的 “+” 運算符已經是一個相當複雜的操作了,在最終執行一個數值相加之前必須進行大量的檢查

而如果引擎要想讓這些步驟是能夠在幾個機器指令內完成以達到峯值性能(與 C++ 相媲美),這裏有一個關鍵能力—- 推測優化,通過假設可能的輸入。例如,當我們知道表達式 x+y 中,x 和 y 都是數字,那麼我們就不需要處理任何一個是字符串或者其他更糟糕的情況—- 操作數是任意類型的 JavaScript 對象,也就不需要對所有參數調用一遍 ToPrimitive 了。

換句話說,如果我們能夠確定 x,y 都是數字類型,我們自然就很容易對這個函數執行進行優化,消除冗餘的 IR 指令。

「而從執行的角度來說,動態類型性能瓶頸很大程度是因爲它的動態的類型系統,與靜態類型的語言相比,JavaScript 程序需要額外的操作來處理類型的動態性,所以執行效率比較低。」

那麼如何確認 x,y 都是數字,我們又如何優化呢?

基於推測的優化

因爲 JavaScript 動態語言的特性,我們通常直到運行時才知道值的確切類型,僅僅觀察源代碼,往往不可能知道某個操作的可能輸入值。所以這就是爲什麼我們需要推測,根據之前運行收集到的值的反饋,然後假設將來總會看到類似的值。這種方法聽起來可能作用相當有限,但它已被證明適用於 JavaScript 這樣的動態語言。

你可能會聯想到 CPU 的分支預測能力,如果是這樣,那麼恭喜你,你並沒有想錯。

我們再回到這段代碼

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

你可能已經想到了,作爲一個動態類型的語言,推測的第一步,就是要收集到足夠多的信息,來預測 add 在今後的執行中會遇到的類型。

所以,首先向你介紹反饋向量(Feedback Vector),它是我們執行預測最核心的成員之一:負責儲存我們收集到的信息。

反饋向量(Feedback Vector)

當一段代碼被初次執行時,它所執行的往往是解釋器產生的字節碼。當這段字節碼每次的執行後,都會會產生一些反饋信息,這些反饋信息會被儲存在**「反饋向量」**(過去叫類型反饋向量) 中,這個特殊的數據結構會被鏈接在閉包上。如果從對象結構的角度來看,反饋向量和其他相關的內容會是這樣。

其中 SharedFunctionInfo,它包含了函數的一般信息,比如源位置,字節碼,嚴格或一般模式。除此之外,還有一個指向上下文的指針,其中包含自由變量的值以及對全局對象的訪問。

關於自由變量和約束變量的概念, 閉包 (計算機科學)

反饋向量的大致結構如下,slot 是一個槽,表示向量表裏面的一項,包含了操作類型和傳入的值類型,

比如,第二個是一個 BinaryOp 槽,二元操作符類似 “+,-” 等能夠記錄迄今爲止看到的輸入和輸出的反饋。先不用糾結它的含義,後面我們會具體介紹。

如果你想查看你的函數所對應的反饋向量,可以在你的代碼中加上專門的內部函數 %DebugPrint  ,並且在 d8 中加上命令 –allow-natives-syntax 來檢查特定閉包的反饋向量的內容。

源代碼:

function add(x, y) {
return x + y;
} 
console.log(add(1, 2));
%DebugPrint(add);

在 d8 使用這個命令 –allow-natives-syntax 運行,我們看到 :

$ out/Debug/d8 --allow-natives-syntax add.js
DebugPrint: 0xb5101ea9d89: [Function] in OldSpace
- feedback vector: 0xb5101eaa091: [FeedbackVector] in OldSpace
- length: 1
SharedFunctionInfo: 0xb5101ea99c9 <SharedFunctionInfo add>
Optimized Code: 0
Invocation Count: 1
Profiler Ticks: 0
Slot #0 BinaryOp BinaryOp:SignedSmall

我們看到調用次數(Invocation Count)是 1,因爲我們只調用了一次函數 add。此時還沒有優化代碼(根據 Optimized Code 的值爲 0)。反饋向量的長度爲 1,說明裏面只有一個槽,就是我們上面說到的二元操作符槽(BinaryOp Slot),當前反饋爲 SignedSmall。

這個反饋 SignedSmall 代表什麼?這表明指令 Add 只看到了 SignedSmall 類型的輸入,並且直到現在也只產生了 SignedSmall 類型的輸出。

但是什麼是 SignedSmall 類型?JavaScript 裏面並不存在這種類型。實際上,SignedSmall 來自己 V8 中的一種優化策略,它表示在程序中經常使用的小的有符號整數(V8 將高位的 32 位表示整數,低位的全部置 0 來表示 SignedSmall),這種類型能夠獲得特殊處理(其他 JavaScript 引擎也有類似的優化策略)。

「值的表示」

V8 通常使用一種叫做指針標記(Pointer Tagging)的技術來表示值,應用這種技術,V8 在每個值裏面都設置一個標識。我們處理的大部分值都分配在 JavaScript 堆上,並且由垃圾回收器(GC)來管理。但是對某些值來說,總是將它們分配在內存裏開銷會很大。尤其是對於小整數,它們通常會用在數組索引和一些臨時計算結果。

在 V8 中存在兩種指針標識類型:分別是是 Smi(即 Small Integer 的縮寫)和堆對象( HeapObject,就是 JavaScript 的引用類型),其中堆對象是分配在內存的堆中,圖中的地址即指向堆中的某塊地方。

我們用最低有效位來區分堆對象(標誌是 1)和小整數(標誌是 0)。對於 64 位結構上的 Smi,至少有 32 位有效位(低半部)是一直被置爲 0。另外 32 位,也就是 Word 的上半部,是被用來儲存 32 位有符號小整數的值。


僅僅是一次的執行,還不足以讓引擎這麼快下定決心,相信 add 函數隨後的執行都是 Smi 類型。那麼我們先來看看,如果在隨後的執行中,我們傳入不一樣的類型會怎麼樣。

反饋向量的變化

反饋類型 SignedSmall 是指所有能用小整數表示的值。對於 add 操作而言,這意味着目前爲止它只能看到輸入類型爲 Smi,並且所產生的輸出值也都是 Smi(也就是說,所有的值都沒有超過 32 位整數的範圍)。下面我們來看看,當我們調用 add 的時候傳入一個不是 Smi 的值會發生什麼。

function add(x, y) {
return x + y;
}
console.log(add(1, 2));
console.log(add(1.1, 2.2));
//調用100ci
%DebugPrint(add);

在 d8 加入命令 –allow-natives-syntax ,然後看到下面結果

$ out/Debug/d8 --allow-natives-syntax add.js
DebugPrint: 0xb5101ea9d89: [Function] in OldSpace
…
- feedback vector: 0x3fd6ea9ef9: [FeedbackVector] in OldSpace
- length: 1
SharedFunctionInfo: 0x3fd6ea9989 <SharedFunctionInfo add>
Optimized Code: 0
Invocation Count: 2
Profiler Ticks: 0
Slot #0 BinaryOp BinaryOp:Number

首先,我們看到調用次數現在是 2,因爲運行了兩次函數 add。然後發現 BinaryOp 槽的值現在變成了 Number,這表明對於這個加法已經有傳入了任意類型的數值(即非整數)。此外,這有一個反饋向量的狀態遷移圖,大致如下所示:

反饋狀態從 None 開始,這表明目前還沒有看到任何輸入,所以什麼都不知道。狀態 Any 表明我們看到了不兼容的(比如 number 和 string)輸入和輸出的組合。狀態 Any 意味着 Add(字節碼中的)是多態。相比之下,其餘的狀態表明 Add 都是單態(monomorphic),因爲看到的輸入和產生的都是相同類型的值。下面是圖中名詞解釋:

需要注意一點,反饋只能在這個圖中前進(從 NoneAny),不能回退。如果真的那樣做,那麼我們就會有陷入去優化循環的風險。那樣情況下,優化編譯器發現輸入值與之前得到反饋內容不同,比如之前解釋器生成的反饋是 Number,但現在輸入值出現了 String,這時候已經生成的反饋和優化代碼就會失效,並回退到解釋器生成的字節碼版本。當下一次函數再次變熱(hot,多次運行),我們將再次優化它,如果允許回退,這時候優化編譯器會再次生成相同的代碼,這意味着會再次回到 Number 的情況。如果這樣無限制的回退去優化,再優化,編譯器將會忙於優化和去優化,而不是高速運行 JavaScript 代碼。

優化管道(The Optimization Pipeline)

現在我們知道了解釋器 Ignition 是如何爲函數 add 收集反饋,下面來看看優化編譯器如何利用反饋生成最小的代碼,因爲_越小的機器指令代碼塊,意味着更快的速度_。爲了觀察,我將使用一個特殊的內部函數 OptimizeFunctionOnNextCall()在特定的時間點觸發 V8 對函數的優化。我們經常使用這些內部函數以非常特定的方式對引擎進行測試。

function add(x, y) {
return x + y;
} 
add(1, 2); // Warm up with SignedSmall feedback.
%OptimizeFunctionOnNextCall(add);
add(1, 2); // Optimize and run generated code

在這裏,給函數 add 傳遞兩個整數型值來明確 call site “x + y” 的反饋會被預熱爲小整數(表示_這個 call site 全部傳遞的都是小整數,對於優化引擎來說將來得到的輸入也會是小整數_),並且結果也是屬於小整數範圍。然後我們告訴 V8 應該在下次調用函數 add 的時候去優化它(用 TurboFan ),最終再次調用 add,觸發優化編譯器運行生成機器碼。

TurboFan 拿到之前爲函數 add 生成的字節碼,並從函數 add 的反饋向量表裏提取出相關的反饋。優化編譯器將這些信息轉換成一個圖表示,再將這個圖表示傳遞給前端,優化以及後端的各個階段(見上圖)。在本文不會詳細展開這部分內容,這是另一個系列的內容了。我們要了解的是最終生成的機器碼,並看看優化推測是如何工作的。你可以在 d8 中加上命令 –print-opt-code 來查看由 TurboFan 生成的優化代碼。

這是由 TurboFan 在 x64 架構上生成的機器碼,這裏省略了一些無關緊要的技術細節(,下面就來看看這些代碼做了什麼。

# Prologue
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+0xdb0]
jna StackCheck

第一段代碼檢查對象是否仍然有效(對象的形狀是否符合之前生成機器碼的那個),或者某些條件是否發生了改變,這就需要丟棄這個優化代碼。這部分具體內容可以參考 Juliana Franco 的 “Internship on Laziness“。一旦我們知道這段代碼仍然有效,就會建立一個棧幀並且檢查堆棧上是否有足夠的空間來執行代碼。

# Check x is a small integer
movq rax,[rbp+0x18]
test al,0x1
jnz Deoptimize
# Check y is a small integer
movq rbx,[rbp+0x10]
testb rbx,0x1
jnz Deoptimize
# Convert y from Smi to Word32
movq rdx,rbx
shrq rdx, 32
# Convert x from Smi to Word32
movq rcx,rax
shrq rcx, 32

然後從函數主體開始。我們從棧中讀取參數 x 和 y 的值(相對於幀指針 rbp,比如 rbp+1 這樣的地址,請參考棧幀概念),然後檢查兩個參數是否都是 Smi 類型(因爲根據 “+” 得到的反饋,兩個輸入總是 Smi)。這一步是通過測試最低有效位來完成。一旦確定了參數都是 Smi,我們需要將它轉換成 32 位表示,這是通過將值右移 32 位來完成的。如果 x 或 y 不是 Smi,則會立即終止執行優化代碼,接着負責去優化的模塊就會恢復到之前解釋器生成的函數 add 的代碼(即字節碼)。

# Add x and y (incl. overflow check)
addl rdx,rcx
jo Deoptimize
# Convert result to Smi
shlq rdx, 32
movq rax,rdx
# Epilogue
movq rsp,rbp
pop rbp
ret 0x18

然後我們繼續執行對輸入值的整數加法,這時需要明確地測試溢出,因爲加法的結果可能超出 32 位整數的範圍,在這種情況下就要返回到解釋器版本,並在隨後將 add 的反饋類型提升爲 Number(之前說過,反饋類型的改變只能前進)。

最後我們通過將帶符號的 32 位值向上移動 32 位,將結果轉換回 Smi 表示,並將結果返回存到累加器 rax 。

我們現在可以看到生成的代碼是高度優化的,並且適用於專門的反饋。它完全不去處理其他數字,字符串,大整數或任意 JavaScript 對象,只關注目前爲止我們所看到的那種類型的值。這是使許多 JavaScript 應用程序達到最佳性能的關鍵因素。

爲什麼需要 TypeScript

在上面的介紹中,我們竭力避免了對 JavaScript 對象的訪問,如果有對象加入,這將會變成一個很複雜的話題。但爲了更好的展開這個話題,我們還是需要提一下,關於對象的優化是 V8 中極其重要的一部分。例如,以下面這個對象爲例

var o = {
    x: ''
}

var o1 = {
    x: ''
    y
}

//o1. o2

對於像 o.x 這樣的屬性訪問,若 o 始終具有相同的形狀(形狀同結構,即相同的屬性以及屬性是相同的順序,例如 o 的結構一直是 {x:v},其中 v 的類型是 String),我們會把如何獲得 o.x 的過程信息緩存起來,構造成一個隱藏類( Hidden Class)。在隨後執行相同的字節碼時,不需要再次搜索對象 o 中 x 的位置。這種底層實現被稱爲內聯緩存– inline cache (IC)。

你可以在 Vyacheslav Egoro 寫的這篇文章 “What’s up with monomorphism?” 中瞭解更多關於 ICs 和屬性訪問的細節。

總而言之,你現在應該瞭解到,作爲一門弱類型的語言,從最早的 SELF 和 smalltalk 語言開始,研究者就在不斷去優化這種弱類型語言的執行效率。

「從執行的角度來說,動態類型性能瓶頸很大程度是因爲它的動態的類型系統,與靜態類型的語言相比, JavaScript 程序需要額外的操作來處理類型的動態性,所以執行效率比較低。」

說了這麼多,最關鍵的一點

「確定你的代碼將要看到的類型很重要」

再加上另外一句話:

「作爲動態語言,你的程序可能在 90% 的時間裏,都在處理和代碼邏輯無關的事情。即:確認你的代碼是什麼形狀」

從傳統的 JavaScript 角度來說,

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

你無法很好的保證 add 函數將要看到的類型,哪怕你確實想要這麼做。但在一個大型的系統中,維護每一個函數和對象的形狀,極其困難。

你可能在前 99 次都保證了 add 看到的都是 Smi 類型,但是在第 100 次,add 看到了一個 String,而在這之前,優化編輯器,即 TurboFan,已經大膽的推測了你的函數只會看到 Smi,那麼這時候

Ops!

優化編輯器將不得不認爲自己做了個錯誤的預測,它會立即把之前的優化丟掉。從字節碼開始重新執行。

而如果你的代碼一直陷入優化 <-> 去優化的怪圈,那麼程序執行將會變慢,慢到還不如不優化。

大多數的瀏覽器都做了限制,當優化 / 去優化循環發生的時候會嘗試跳出這種循環。比如,如果 JIT 做了 10 次以上的優化並且又丟棄的操作,那麼就不繼續嘗試去優化這段代碼。

所以,到這裏你應該明白了,有兩點準則:

  1. 「確保你的代碼是什麼形狀很重要」

但比第一條更重要的是:

  1. 「確保你的代碼固定在某個形狀上」

而編寫 TypeScript ,從工程和語言的層面上幫助你解決了這兩個準則,你可以暢快的使用 TypeScript,而無需擔心你是否不小心違背了上面兩條準則。

推薦閱讀

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/YHD28SvIMTyJOTcj24wyuw