《圖解 Google V8》編譯流水篇——學習筆記(二)

uccs發表於2023-02-14

這是《圖解 Google V8》第二篇/共三篇:編譯流水線

學習下來最大的收穫有兩點:

  1. V8 如何提升 JavaScript 執行速度

    • 早期快取機器碼,之後重構為快取位元組碼
  2. JavaScript 中訪問一個屬性時,V8 做了哪些最佳化

    • 隱藏類
    • 內聯快取

特別是第二點,讓我看到了使用 TypeScript 的好處,動態語言存在的問題,靜態語言都可以解決

09 | 執行時環境:執行 JavaScript 程式碼的基石

執行時環境包括:堆空間和棧空間、全域性執行上下文、全域性作用域、內建的內建函式、宿主環境提供的擴充套件函式和物件,還有訊息迴圈系統

宿主

瀏覽器為 V8 提供基礎的訊息迴圈系統、全域性變數、Web API

V8 的核心是實現 ECMAScript 標準,比如:ObjectFunctionString,還提供垃圾回收、協程等

構造資料儲存空間:堆空間和棧空間

Chrome 中,只要開啟一個渲染程式,渲染程式便會初始化 V8,同時初始化堆空間和棧空間。

棧是記憶體中連續的一塊空間,採用“先進後出”的策略。

在函式呼叫過程中,涉及到上下文相關的內容都會存放在棧上,比如原生型別、引用的物件的地址、函式的執行狀態、this 值等都會存在棧上

當一個函式執行結束,那麼該函式的執行上下文便會被銷燬掉。

堆空間是一種樹形的儲存結構,用來儲存物件型別的離散的資料,比如:函式、陣列,在瀏覽器中還有 windowdocument

全域性執行上下文和全域性作用域

執行上下文中主要包含三部分,變數環境、詞法環境和 this 關鍵字

全域性執行上下文在 V8 的生存週期內是不會被銷燬的,它會一直儲存在堆中

ES6 中,同一個全域性執行上下文中,都能存在多個作用域:

var x = 5;
{
  let y = 2;
  const z = 3;
}

構造事件迴圈系統

V8 需要一個主執行緒,用來執行 JavaScript 和執行垃圾回收等工作

V8 是寄生在宿主環境中的,V8 所執行的程式碼都是在宿主的主執行緒上執行的

如果主執行緒正在執行一個任務,這時候又來了一個新任務,把新任務放到訊息佇列中,等待當前任務執行結束後,再從訊息佇列中取出正在排列的任務,執行完這個任務之後,再重複這個過程

10 | 機器程式碼:二進位制機器碼究竟是如何被 CPU 執行的?

將組合語言轉換為機器語言的過程稱為“彙編”;反之,機器語言轉化為組合語言的過程稱為“反彙編”

在程式執行之前,需要將程式裝進記憶體中(記憶體中的每個儲存空間都有獨一無二的地址)

二進位制程式碼被裝載進記憶體後,CPU 便可以從記憶體中取出一條指令,然後分析該指令,最後執行該指令。

把取出指令、分析指令、執行指令這三個過程稱為一個 CPU 時鐘週期

CPU 中有一個 PC 暫存器,它儲存了將要執行的指令地址,到下一個時鐘週期時,CPU 便會根據 PC 暫存器中的地址,從記憶體中取出指令。

PC 暫存器中的指令取出來之後,系統要做兩件事:

  1. 將下一條指令的地址更新到 PC 暫存器中
  2. 分析該指令,識別出不同型別的指令,以及各種獲取運算元的方法

因為 CPU 訪問記憶體的速度很慢,所以需要通用暫存器,用來存放 CPU 中資料的(通用暫存器容量小,讀寫速度快,記憶體容量大,讀寫速度慢。)

通用暫存器通常用來存放資料或者記憶體中某塊資料的地址,我們把這個地址又稱為指標

  • ebp 暫存器通常是用來存放棧幀指標
  • esp 暫存器用來存放棧頂指標
  • PC 暫存器用來存放下一條要執行的指令

常用的指令型別:

  1. 載入指令:從記憶體中複製指定長度的內容到通用暫存器中,並覆蓋暫存器中原來的內容
  2. 儲存指令:和載入型別的指令相反,作用是將暫存器中的內容複製到記憶體中的某個位置,並覆蓋掉記憶體中的這個位置上原來的內容
  3. 更新指令:作用是複製兩個暫存器中的內容到 ALU
  4. 跳轉指令:從指令本身抽取出一個字,這個字是下一條要執行的指令地址,並將該字複製到 PC 暫存器中,並覆蓋掉 PC 暫存器中原來的值

11 | 堆和棧:函式呼叫是如何影響到記憶體佈局的?

函式有兩個主要的特性:

  1. 可以被呼叫
  2. 具有作用域機制

所以:

  • 函式呼叫者的生命週期比被呼叫者的長(後進),被呼叫者的生命週期先結束 (先出)
  • 從函式資源分配和回收角度來看,

    • 被呼叫函式的資源分配晚於呼叫函式 (後進),
    • 被呼叫函式資源的釋放先於呼叫函式 (先出)

棧的狀態從 add 中恢復到 main 函式的上次執行時的狀態,這個過程稱為恢復現場

function main() {
  add();
}
function add(num1, num2) {
  return num1 + num2;
}

怎麼恢復 main 函式的執行現場呢:

  1. esp 暫存器中儲存一個永遠指向當前棧頂的指標

    • 告訴你往哪個位置新增新元素
  2. ebp 暫存器,儲存當前函式的起始位置(也叫棧幀指標

    • 告訴 CPU 移動到這個地址

棧幀:每個棧幀對應著一個未執行完的函式,棧幀中儲存了該函式的返回地址和區域性變數。

12 | 延遲解析:V8 是如何實現閉包的?

在編譯階段,V8 不會對所有程式碼進行編譯,採用一種“惰性編譯”或者“惰性解析”,也就是說 V8 預設不會對函式內部的程式碼進行編譯,只有當函式被執行前,才會進行編譯。

閉包的問題指的是:由於子函式使用到了父函式的變數,導致父函式在執行完成以後,它內部被子函式引用的變數無法及時在記憶體中被釋放。

而閉包問題產生的根本原因是 JavaScript 中本身的特性:

  1. 可以在函式內部定義新的函式
  2. 內部函式可以訪問父函式的變數
  3. 函式是一等公民,所以函式可以作為返回值

既然由於 JavaScript 的這種特性就會出現閉包的問題,那麼就需要解決閉包問題,“預編譯“ 或者 “預解析” 就出現了

預編譯具體方案: 在編譯階段,V8 會對函式函式進行預解析

  1. 判斷函式內語法是否正確
  2. 子函式是否引用父函式中的變數,如果有的話,將這個變數複製一份到堆中,同時子函式本身也是一個物件,也會被放到堆中

    • 父函式執行完成後,記憶體會被釋放
    • 子函式在執行時,依然可以從堆記憶體中訪問複製過來的變數

13 | 位元組碼(一):V8 為什麼又重新引入位元組碼?

V8 中,位元組碼有兩個作用:

  1. 直譯器可以直接執行位元組碼
  2. 最佳化編譯器可以將位元組碼編譯為機器碼,然後再執行機器碼

早期的 V8

V8 團隊認為“先生成位元組碼再執行位元組碼”,會犧牲程式碼的執行速度,便直接將 JavaScript 程式碼編譯成機器碼

使用了兩個編譯器:

  1. 基線編譯器:將 JavaScript 程式碼編譯為沒有最佳化過的機器碼
  2. 最佳化編譯器:將一些熱點程式碼(執行頻繁的程式碼)最佳化為執行效率更高的機器碼

執行 JavaScript

  1. JavaScript 程式碼轉換為抽象語法樹(AST
  2. 基線編譯器將 AST 編譯為未最佳化過的機器碼,然後 V8 執行這些未最佳化過的機器程式碼
  3. 在執行未最佳化的機器程式碼時,將一些熱點程式碼最佳化為執行效率更高的機器程式碼,然後執行最佳化過的機器碼
  4. 如果最佳化過的機器碼不滿足當前程式碼的執行,V8 會進行反最佳化操作

問題

1. 機器碼快取

V8 執行一段 JavaScript 程式碼,編譯時間和執行時間差不多

如果再 JavaScript 沒有改變的情況下,每次都編譯這段程式碼,就會浪費 CPU 資源

所以 V8 引入機器碼快取:

  1. 將原始碼編譯成機器碼後,放在記憶體中(記憶體快取)
  2. 下次再執行這段程式碼,就先去記憶體中查詢是否存在這段程式碼的機器碼,有的話就執行這段機器碼
  3. 將編譯後的機器碼存入硬碟中,關閉瀏覽器後,下次重新開啟,可以直接用編譯好的機器碼

時間縮短了 20% ~ 40%

這是用空間換時間的策略,在移動端非常吃記憶體

2. 惰性編譯

V8 採用惰性編譯,只會編譯全域性執行上下文的程式碼

由於 ES6 之前,沒有塊級作用域,為了實現各模組之間的隔離,會採用立即執行函式

這會產生很多閉包,閉包模組中的程式碼不會被快取,所以只快取頂層程式碼是不完美的

所以 V8 就進行了大重構

現在的 V8

位元組碼 + 直譯器 + 編譯器

5K 的原始碼 JavaScript -> 40K 位元組碼 -> 10M 的機器碼

位元組碼的體積遠小於機器碼,瀏覽器就可以實現快取所有的位元組碼,而不僅僅是全域性執行上下文的位元組碼

優點:

  1. 降低了記憶體
  2. 提升程式碼啟動速度
  3. 降低了程式碼的複雜度

缺點:

  1. 執行效率下降

直譯器的作用是將原始碼轉換成位元組碼

V8 的直譯器是:lgnitionV8 的編譯器是:TurboFan

如何降低程式碼複雜度

機器碼在不同 CPU 中是不一樣的,直接將 AST 轉換成不同的機器碼,就需要基線編譯器和最佳化編譯器編寫大量適配各 CPU 的程式碼

先將 AST 轉換成位元組碼,再將位元組碼轉換成機器碼,由於位元組碼(消除了平臺的差異性)和 CPU 執行機器碼過程類似,將位元組碼轉換成機器碼就會容易很多

14 |位元組碼(二):直譯器是如何解釋執行位元組碼的?

生成位元組碼

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

生成 AST

[generating bytecode for function: add]
--- AST ---
FUNC at 12
  KIND 0
  LITERAL ID 1
  SUSPEND COUNT 0
  NAME "add"
  PARAMS
    VAR (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
    VAR (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
  DECLS
    VARIABLE (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
    VARIABLE (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
    VARIABLE (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
  BLOCK NOCOMPLETIONS at -1
    EXPRESSION STATEMENT at 31
      INIT at 31
        VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
        ADD at 32
          VAR PROXY parameter[0] (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
          VAR PROXY parameter[1] (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
  RETURN at 37
    VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"

AST 圖形化

將函式拆成了 4 部分

  1. 引數宣告(PARAMS):包括宣告中的所有引數,這裡是 xy,也可以使用 arguments
  2. 變數宣告節點(DECLS):出現了 3 個變數:xyz,你會發現 xy 的地址和 PARAMS 中是相同的,說明他們是同一塊資料
  3. 表示式節點:ADD 節點下有 VAR PROXY parameter[0]VAR PROXY parameter[1]
  4. RETURN 節點:指向了 z 的值,這裡是 local[0]

生成 AST 的同時,還生成了 add 函式的作用域

Global scope:
function add (x, y) { // (0x7f9ed7849468) (12, 47)
  // will be compiled
  // 1 stack slots
  // local vars:
  VAR y;  // (0x7f9ed7849790) parameter[1], never assigned
  VAR z;  // (0x7f9ed7849838) local[0], never assigned
  VAR x;  // (0x7f9ed78496e8) parameter[0], never assigned
}

在解析階段,普通變數預設值是 undefined,函式宣告指向實際的函式物件;執行階段,變數會指向棧和堆相應的資料

AST 作為輸入傳到自己位元組碼生成器中(BytecodeGenerator),它是 lgnition 的一部分,生成以函式為單位的位元組碼

[generated bytecode for function: add (0x079e0824fdc1 <SharedFunctionInfo add>)]
Parameter count 3
Register count 2
Frame size 16
         0x79e0824ff7a @    0 : a7                StackCheck
         0x79e0824ff7b @    1 : 25 02             Ldar a1
         0x79e0824ff7d @    3 : 34 03 00          Add a0, [0]
         0x79e0824ff80 @    6 : 26 fb             Star r0
         0x79e0824ff82 @    8 : 0c 02             LdaSmi [2]
         0x79e0824ff84 @   10 : 26 fa             Star r1
         0x79e0824ff86 @   12 : 25 fb             Ldar r0
         0x79e0824ff88 @   14 : ab                Return
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 0)

這裡 Parameter count 3 表示顯示的引數 xy,及隱式引數 this

最終的位元組碼

StackCheck
Ldar a1
Add a0, [0]
Star r0
LdaSmi [2]
Star r1
Ldar r0
Return

理解位元組碼

有兩種直譯器:

  • 基於棧(State-based

    • 使用棧來儲存函式引數、中間運算結果、變數
  • 基於暫存器(Register-based

    • 支援暫存器的指令操作,使用暫存器儲存引數、中間計算結果

基於棧的直譯器:Java 虛擬機器、.Net 虛擬機器,早期的 V8 虛擬機器;優點:在處理函式呼叫、解決遞迴問題和切換上下文時簡單快速

現在的 V8 採用了基於暫存器的設計

  • Ladr a1 指令:將 a1 暫存器中的值載入到累加器中
  • Star r0 指令:把累加器中的值儲存到 r0 暫存器中
  • Add a0, [0] 指令:

    • a0 暫存器載入值並與累加器中的值相加,再將結果放入累加器中
    • [0]:成為反饋向量槽(feedback vector slot),

      • 目的是了給最佳化編譯器(TurboFan)提供最佳化資訊,它是一個陣列,直譯器將解釋執行過程中的一些資料型別的分析資訊儲存在反饋向量槽中
  • LadSmi [2] 指令:將小整數(Smi2 載入到累加器中
  • Return 指令:結束當前函式的執行,並將控制權還給呼叫方,返回的值是累加器中的值
  • StackCheck 指令:檢查是棧是否到達溢位的上限

15 | 隱藏類:如何在記憶體中快速查詢物件屬性?

  • 為了提升物件屬性訪問速度,引入隱藏類
  • 為了加速運算引入內聯快取

為什麼靜態語言效率高

JavaScript 在執行時,物件的屬性可以被修改,所以 V8 在解析物件時,比如:解析 start.x 時,它不知道 start 中是否有 x,也不知道 x 相對於 start 的偏移量是多少,簡單說 V8 不知道 start 物件的具體行狀

所以當 JavaScript 查詢 start.x 時,過程非常慢

靜態語言,比如 C++ 在宣告物件之前需要定義該物件的結構(行狀),執行之前會被編譯,編譯的時候,行狀是固定的,也就是說在執行過程中,物件的行政是無法改變的

所以當 C++ 查詢 start.x 使,編譯器在編譯的時候,會直接將 x 相對於 start 物件的地址寫進彙編指令中,查詢時直接讀取 x 的地址,沒有查詢環節

隱藏類

V8 為了做到這點,做了兩個假設:

  1. 物件建立好了之後不會新增新的屬性
  2. 物件建立好了之後也不會刪除屬性

然後 V8 為每個物件建立一個隱藏類,記錄基礎的資訊

  1. 物件中所包含的所有屬性
  2. 每個屬性相對於物件的偏移量。

V8 中隱藏類有稱為 map,即每個物件都有一個 map 屬性,指向記憶體中的隱藏類

有了 map 之後,當訪問 start.x 時,V8 會先去 start.map 中查詢 x 相對 start 的偏移量,然後將 point 物件的地址加上偏移量就得到了 x 屬性的值在記憶體中的地址了

如果兩個物件行狀相同,V8 會為其複用同一個隱藏類:

  1. 減少隱藏類的建立次數,也間接加速了程式碼的執行速度
  2. 減少了隱藏類的儲存空間

兩個物件的形狀相同,要滿足:

  1. 相同的屬性名稱
  2. 相同的屬性順序
  3. 相同的屬性型別
  4. 相等的屬性個數

如果動態改變了物件的行狀,V8 就會重新構建新的隱藏類

參考資料:

  1. 利用 V8 深入理解 JavaScript 物件儲存策略

16 | 答疑:V8 是怎麼透過內聯快取來提升函式執行效率的?

function loadX(o) {
  return o.x;
}
var o = { x: 1, y: 3 };
var o1 = { x: 3, y: 6 };
for (var i = 0; i < 90000; i++) {
  loadX(o);
  loadX(o1);
}

V8 獲取 o.x 的流程:查詢物件 o 的隱藏類,再透過隱藏類查詢 x 屬性偏移量,然後根據偏移量獲取屬性值

這段程式碼裡 o.x 會被反覆執行,那麼查詢流程也會被反覆執行,那麼 V8 有沒有做這最佳化呢

內聯快取(Inline Cache,簡稱 IC

V8 在執行函式的過程中,會觀察函式中的一些呼叫點(CallSite)上的關鍵資料(中間資料),然後將它們快取起來,當下次再執行該函式時,V8 可以利用這些中間資料,節省再次獲取這些資料的過程

IC 會為每個函式維護一個反饋向量(FeedBack Vector),反饋向量記錄了函式在執行過程中的一些關鍵的中間資料

反饋向量是一個表結構,有很多項,每一項稱為一個插槽 (Slot)

function loadX(o) {
  o.y = 4;
  return o.x;
}

V8 執行這段函式時,它判斷 o.y = 4return o.x 是呼叫點 (CallSite),因為它們使用了物件和屬性,那麼 V8 會在 loadX 函式的反饋向量中為每個呼叫點分配一個插槽。

插槽中包括了:

  • 插槽的索引 (slot index)
  • 插槽的型別 (type)
  • 插槽的狀態 (state)
  • 隱藏類 (map) 的地址
  • 屬性的偏移量
function loadX(o) {
  return o.x;
}
loadX({ x: 1 });

// 位元組碼
StackCheck // 檢查是否溢位
LdaNamedProperty a0, [0], [0] // 取出引數 a0 的第一個屬性值,並將屬性值放到累加器中
Return // 返回累加器中的屬性

LdaNameProperty 有三個引數:

  • a0loadX 的第一引數
  • 第一個 [0] 表示取出物件 a0 的第一個屬性值
  • 第二個 [0] 和反饋向量有關,表示將 LdaNameProperty 操作的中間資料寫到反饋向量中,這裡 0 表示第一個插槽

  • map:快取了 o 的隱藏類的地址
  • offset:快取了屬性 x 的偏移量
  • type:快取了操作型別,這裡是 LOAD 型別。在反饋向量中,我們把這種透過 o.x 來訪問物件屬性值的操作稱為 LOAD 型別。
function foo() {}
function loadX(o) {
  o.y = 4;
  foo();
  return o.x;
}
loadX({ x: 1, y: 4 });

// 位元組碼
StackCheck
// 下面兩行是 o.y = 4,STORE 型別
LdaSmi [4]
StaNamedProperty a0, [0], [0]
// 下面三行是 呼叫 foo 函式,CALL
LdaGlobal [1], [2]
Star r0
CallUndefinedReceiver0 r0, [4]
// 下面一行是 o.x
LdaNamedProperty a0, [2], [6]
Return

多型和超態

function loadX(o) {
  return o.x;
}
// o 和 o1 行狀不同
var o = { x: 1, y: 3 };
var o1 = { x: 3, y: 6, z: 4 };
for (var i = 0; i < 90000; i++) {
  loadX(o);
  loadX(o1);
}
  • 第一次執行 loadX 時,V8o 的隱藏類記錄在反饋向量中,同時記錄 x 的偏移量
  • 第二次執行 loadXV8 先取出反饋向量中的隱藏類,和 o1 的隱藏類進行比較,不是同一個隱藏類,那麼就無法使用反饋向量中快取的偏移量了

  • 一個插槽只有 1 個隱藏類,稱為單態 (monomorphic)
  • 一個插槽有 2 ~ 4 個隱藏類,稱為為多型 (polymorphic)
  • 一個插槽中超過 4 個隱藏類,稱為超態 (magamorphic)。

如果一個插槽中存在多型或者超態時,執行效率是低於單態的(多了比較的過程)

參考資料:

  1. V8 中的多型內聯快取 PIC

《圖解 Google V8》學習筆記系列

  1. 《圖解 Google V8》設計思想篇——學習筆記(一)
  2. 《圖解 Google V8》事件迴圈和垃圾回收——學習筆記(三)

相關文章