注:本文轉自liuyanghejerry的V8 之旅:FULL COMPILER
在過去的五年中,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)的時間通路這些程式員未做任何結構定義工作(類似于類定義)的對象。