V8 之旅:FULL COMPILER

jopen 9年前發布 | 19K 次閱讀 V8 JavaScript開發

原文出處: Jay Conrod   譯文出處:liuyanghejerry(@不編程不舒服斯基)   


在過去的五年中,JavaScript的性能有了極大的提升,這主要歸功于JavaScript虛擬機的執行機制由解釋演變為了JIT。現在,JavaScript成為了HTML5的中堅力量,推動著新一波Web技術的發展。JavaScript引擎中,V8是最早使用原生代碼的引擎之一。V8現已成為了Google Chrome、Android瀏覽器、WebOS及Node.js這樣的其他項目中不可分割的重要組件。

本文來自Jay Conrod的A tour of V8: full compiler,其中的術語、代碼請以原文為準。

一年多前,我(指的是原作者)進入了我們公司的一個負責V8在我們ARM產品上優化的團隊。從那時算起,由于軟硬件性能的提升,我已親眼見到SunSpider性能翻倍,V8性能測試提升近50%。

V8是一個非常有趣的項目,然而它的文檔卻非常分散。在接下來的幾篇文章中,我將在較高的層面上對其做一個概述,希望對其他同樣對VM或編譯器內部原理感興趣的朋友們能有所幫助。

全局架構

V8將所有JavaScript代碼編譯為原生代碼執行,其中沒有任何的解釋器以及字節碼參與。編譯以函數為單位,一次編譯一個(這與FireFox VM原有的TraceMonkey引擎相反,TraceMonkey為追蹤式編譯,并不以函數為單位)。通常,函數在初次調用之前是不會被編譯的,因此如果你引用了一個大型的腳本庫,VM并不會花大量的時間去編譯那些根本沒用到的部分。

V8實際上有兩個不同的JavaScript編譯器。我個人喜歡將其看作一個簡單編譯器及一個輔助編譯器譯注,這里看起來沒有一個正經的,但實際上兩個詞匯描述的方面不同。前者指的是機制簡單的編譯器,后者指的是使用頻度低的編譯器。)。Full Compiler(對應簡單編譯器)是一個不含優化的編譯器,其工作就是盡快生成原生代碼,以保持頁面始終快速運轉。Crankshaft(對應輔助編譯器)則是一個帶有優化能力的編譯器。V8會將任何初次遇到的代碼使用FC編譯,之后再使用內置的性能分析器挑選頻度高的函數,使用Crankshaft優化。由于V8基本上是單線程的(截至3.14版),任何一個編譯器運行時,都會打斷腳本的執行。在V8未來的版本中,Crankshaft(或者至少其中一部分)將會在一個單獨的線程中運行,與JavaScript的執行并發,以便進行更多昂貴的優化。

為何沒有字節碼?

大多數VM都有一個字節碼解釋器,但V8卻沒有。你可能好奇為何原本應當先編譯為字節碼再執行的過程,被FC替換掉了。原因是,編譯為原生代碼并不會比編譯為字節碼耗去太多。考慮如下兩個過程:

字節碼編譯:

  • 語法分析(解析)
  • 作用域分析
  • 將語法樹轉換為字節碼

原生代碼編譯:

  • 語法分析(解析)
  • 作用域分析
  • 將語法樹轉換為原生代碼

在上述兩個過程中,我們都需要解析源碼以及生成抽象語法樹(AST),我們都需要進行作用域分析,以便得出每個符號所代表的是局部變量,上下文變量(閉包相關)或全局屬性。唯獨轉換的過程是不同的。你可以在這一步做一些非常細致的工作,但你也同時希望編譯器越快越好,甚至很想來個“直譯”:語法樹的每個節點都轉化為一串相應的字節碼或原生代碼指令(譯注,匯編指令)。

現在思考一下你會如何去做一個字節碼解釋器。一個樸素的實現可能就是一個循環,其中會不斷獲取字節碼,然后進入一個大的switch語句,逐一執行其事先準備好的指令。有一些途徑對這個過程進行改進,但最終還是會落到相近的結構上。

如果我們此時不是去生成字節碼、使用解釋器的那個循環,而是直接觸發相應的原生代碼呢?無需如果,V8的FC就是這樣做的。這樣做便不再需要解釋器,并且大大簡化了未優化代碼與優化代碼之間的切換。

一般來說,字節碼發揮用武之地的最佳時機,是編譯器有充分的準備時間的時候。但這并不是瀏覽器中所能允許的,因此FC對于V8來說更加應景。

內聯緩存:加速未優化代碼

如果你看過ECMAScript標準,你會發現其中有很多操作異常復雜。以+操作符來說,如果操作數都為數字,則它演繹為加法;如果其中有一個操作數是字符串,則它演繹為字符串拼接;如果操作數不是數字也不是字符串,其將經過某些復雜的(可能是用戶定義的)過程,轉化為原語(譯注,原語指的是JavaScript中的數字、字符串、布爾、undefined以及null),最終再演繹為數字加法或字符串拼接。僅僅是查看腳本源碼,我們無從得知哪種操作最終應當執行。屬性的讀取(比如:o.x)是另一個潛在復雜操作的例子。只通過源碼,你將無從得知你要的是讀取一個對象自己的屬性(對象本身所具有的屬性),還是原型對象的屬性(來自于原型鏈上原型的屬性),還是一個getter方法,亦或是瀏覽器的某些自定義回調。這個屬性還可能根本不存在。如果你要在FC編譯的代碼中處理所有這些情況,即使一個簡單的操作也會引發上百條指令。

內聯緩存(Inline caches, ICs)提供了一個優雅的方案來解決這個問題。內聯緩存大致就是一個包含多種可能的實現(通常運行時生成)來處理某個操作的函數(譯注:拗口,我的理解是,這個函數提供了多個處理問題的方案,這些方案的性能由優至次,一個不行就退化到另一個,直至最終最低效率的方法)。我之前曾寫過函數的多態內聯緩存的文章。V8使用IC處理了大量的操作:FC使用IC來實現讀取、存儲、函數調用、二元運算符、一元運算符、比較運算符以及ToBoolean隱操作符。

IC的實現稱為Stub。Stub在使用層面上像函數:調用、返回。但它不必初始化一個調用棧來完成調用約定。Stub常常在運行時動態生成,但在通常情況下都可被緩存,并被多個IC重用。Stub一般會含有已優化的代碼,來處理某個IC之前所碰到的特定類型的操作。一旦Stub碰到了優化代碼無法解決的操作,它會調用C++運行時代碼來進行處理。運行時代碼處理了這個操作之后,會生成一個新的Stub,包含解決這個操作的方案(當然也包括之前的其他方案)。對原有Stub的調用隨即變為了新Stub的調用,腳本的執行也將繼續進行,變得和Stub正常的調用流程一樣。

我們來看一段簡單的例子,讀取屬性:

function f(o) {
  return o.x;
}

當FC初次生成代碼時,它會使用一個IC來演繹這個讀取。IC以uninitialized狀態(初態)初始,調用一個不包含任何優化代碼的簡易的Stub。下面是FC生成的調用stub的代碼:

;; FC調用
ldr   r0, [fp, #+8]     ; 從棧中讀取參數”o“
ldr   r2, [pc, #+84]    ; 從固定的位置讀取”x“
ldr   ip, [pc, #+84]    ; 從固定位置載入uninitialized態的stub
blx   ip                ; 調用stub
...
dd    0xabcdef01        ; 上面拿到的stub地址
                        ; 當stub出現處理不了的操作時,這里的stub會被換成新的stub

(如果你不熟悉ARM匯編的話,抱歉。希望注釋能讓代碼的意圖清晰)
這是處于uninitialized態的stub:

;; uninitialized stub
ldr   ip,  [pc, #8]   ; 讀取C++運行時的函數來處理
bx    ip              ; 尾調;譯注:尾遞歸優化技術
...

當stub第一次被調用時,stub注定無法處理它所面對的操作,運行時代碼會替stub來解決。在V8中,最常見的存儲屬性的方法就是將其放在對象中一個固定偏移量的地方,我們以此為例。每個對象都有一個指向Map的指針,也即一個描述對象布局的一個不變結構。負責讀取對象自身屬性的stub會將對象的布局圖與已知的Map(也就是運行時所生成的Map)相比較,來快速確定對象是否在相應的位置存放著該屬性。這個Map的檢查使我們能夠避開一次麻煩的Hash表查詢。

;; monomorphic態的對象自身屬性讀取stub
tst   r0,   #1          ; 檢驗目標是否是一個對象;譯注:見代碼末詳細譯注
beq   miss              ; 不是就說明處理不了
ldr   r1,   [r0, #-1]   ; 讀取對象的Map
ldr   ip,   [pc, #+24]  ; 讀取已知的Map
cmp   r1,   ip          ; 它們相同否?
bne   miss              ; 不同說明處理不了
ldr   r0,   [r0, #+11]  ; 讀取屬性
bx    lr                ; 返回
miss:
ldr   ip,   [pc, #+8]   ; 調用C++運行時來解決
bx    ip                ; 尾調
...

譯注:V8中對32bits長的值做了進一步分類,其中最低位作為區分,如果為0則表示該值為31bits長的整數;如果為1則表示該值為30bits長的指針。由于V8中的對象以4Bytes為單位對齊,指針的最低2位恰好空閑。

只要該表達式只負責讀取對象自身的屬性,則讀取可以無附加地快速完成。由于IC只處理了一種情況,它處于monomorphic態(單態)。如果在后續的運行中,這個IC又遇到了無法處理的情況,則更加常見的megamorphic態(復態)stub會被生成。

待續…

如上所述,FC圓滿地完成了它快速生成優質代碼的任務。由于IC易于擴展的特點,FC生成的代碼也非常通用,這使得FC非常簡單;而IC則使代碼非常靈活,能夠處理任何情況。

在接下來的文章中,我們將看到V8內部如何表達JavaScript對象,來做到在大多數場景下以O(1)的時間訪問這些程序員未做任何結構定義工作(類似于類定義)的對象。

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!