在過去的五年中,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正常的呼叫流程一樣。
我們來看一段簡單的例子,讀取屬性:
1 2 3 |
function f(o) { return o.x; } |
當FC初次生成程式碼時,它會使用一個IC來演繹這個讀取。IC以uninitialized狀態(初態)初始,呼叫一個不包含任何優化程式碼的簡易的Stub。下面是FC生成的呼叫stub的程式碼:
1 2 3 4 5 6 7 8 |
;; 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:
1 2 3 4 |
;; uninitialized stub ldr ip, [pc, #8] ; 讀取C++執行時的函式來處理 bx ip ; 尾調;譯註:尾遞迴優化技術 ... |
當stub第一次被呼叫時,stub註定無法處理它所面對的操作,執行時程式碼會替stub來解決。在V8中,最常見的儲存屬性的方法就是將其放在物件中一個固定偏移量的地方,我們以此為例。每個物件都有一個指向Map的指標,也即一個描述物件佈局的一個不變結構。負責讀取物件自身屬性的stub會將物件的佈局圖與已知的Map(也就是執行時所生成的Map)相比較,來快速確定物件是否在相應的位置存放著該屬性。這個Map的檢查使我們能夠避開一次麻煩的Hash表查詢。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
;; 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)的時間訪問這些程式設計師未做任何結構定義工作(類似於類定義)的物件。