走向核心的探索 — V8引擎初探

Dominic_Ming發表於2017-12-28

前言

這段時間在寫編譯原理的課設,對於編譯器的實現算是入了個門,著就激起了我心中的一個本源問題,JavaScript的引擎到底是什麼樣子的,V8直接導致了Node.js時代,JavaScript能做的事情越來越多。作為一個出色的JavaScript引擎,他的模式值得我們思考和學習。

那麼V8引擎到底是怎麼工作的呢?

兩個編譯器的故事

V8會編譯所有JavaScript到原生程式碼,而在V8中,有兩個編譯器在執行著:一個執行比較快,輸出著一般的程式碼,另一個執行的沒有那麼快,但是盡力的輸出著優化過的程式碼。

第一個編譯器 — full-codegen 編譯器

輸出一般程式碼的那個編譯器在內部被稱為:full-codegen(全程式碼生成) 編譯器。它接受一個函式的抽象語法樹,遍歷語法樹,直接生產彙編程式碼。通過獲取被解析過的函式原始碼(抽象語法樹),帶有型別記錄快取的原生程式碼。它是一個很一般的編譯器,執行了一般編譯器從語法分析後直到程式碼生成的過程。

所有本地的變數沒有被存放在暫存器中,而是都放在了棧或者堆裡面。所有被巢狀函式引用的變數全部被存在了堆裡面,這個堆決定了在函式上下文中,哪些函式被定義了。編譯器會根據情況把這些值放進暫存器裡面,並執行具體工作。而對於存在棧裡面的變數,棧頂的幾個變數會暫時的在暫存器內快取。而更復雜的情況,則有實時處理程式來管理。這個編譯器會記錄語句執行的上下文,這樣就能直接跳到需要執行的塊,而不是把變數放進暫存器,測試這個量是不是0,然後產生分支(大概就是彙編裡面的TEST,JNZ的步驟吧)。類似於簡單的算數求值也會在這裡被優化進行。

這個編譯器使用了一個非常重要的技術來優化程式碼—— inline caching。編譯的時候有這樣的快取,直接可以用於賦值、一元運算、二元運算、函式呼叫、屬性獲取還有比較值。inline caching 還向另一個優化編譯器提供了型別源資料。而inline caching 在編譯的時候,快取了鍵和值的儲存,而一般的操作並不會觸發inline caching。

型別反饋

當V8引擎第一次看到一個函式的時候,他只直接建立語法樹,不做其他事情。直到第一次呼叫函式的時候,V8才第一次跑full-codegen 編譯。但這種有點偷懶的做法,在程式碼開始執行後有了變化。執行開始後,會觸發分析執行緒,這個執行緒負責看看程式碼跑的怎麼樣,那些函式是熱點函式。

這種偷懶,靜觀其變的做法,讓V8引擎可以跟蹤型別變化,記錄相應資料。當V8發現了熱點函式,覺得這個函式可以幫一把的時候,他就把型別反饋資料給編譯器。執行時的型別反饋資料會被記錄。

         Unknown
           |   \____________
           |                |
      Primitive       Non-primitive
           |   \_______     |
           |           |    |
        Number       String |
         /   \         |    |
    Double  Integer32  |   /
        |      |      /   /
        |     Smi    /   /
        |      |    / __/
        Uninitialized.
複製程式碼

每次看到新的值,就計算這個值的型別,然後和舊的值型別進行運算。最初的變數型別是Uninitialized(未初始化)。所以當看到一個整型的時候,如果他的大小在Smi (small integer) 範圍內的時候,會直接在型別反饋裡面推斷,它是一個Smi。但是當看到這個值變成了Double了,那麼做運算後,這個值的推斷直接變為Number。推斷每次的結果就是尋找了兩個值的最近共同parent。在內部做了型別預估,讓編譯器可以有目的的優化。

型別反饋資料和抽象語法樹是相互聯絡的,函式的熱度是由一個整型記錄的,相應的從full-codegen獲取熱點節點標記資訊,並把這些資訊送給編譯器做進一步優化。

到了這裡,這個過程開始變得有一些複雜了。這個過程裡面需要實現對於編譯器棧的向上向下相容。編譯器需要獲得運算元和結果的型別反饋,並且還要能準確的找到這個資料。然後你還要能夠把這些東西重新和抽象語法樹關聯起來,編譯器才能從語法樹有目的優化程式碼。

V8在這個過程上,通過把資料分析成TypeFeedbackOracle物件,並且把這個物件和特定的語法樹節點聯絡。最終,V8通過訪問語法樹的節點就會通過這個物件進行,這個物件也能夠優化編譯過程。

第二個編譯器 — crankshaft 編譯器

一旦V8確定了熱點函式,得到了型別反饋的資訊,他就會嘗試帶著這些資訊來執行優化編譯器。優化編譯器在市面上稱為crankshaft(軸心) 編譯器,雖然在原始碼上面並沒有這樣命名。實際上,crankshaft 編譯器在原始碼裡面是由四個過程組成的:帶有型別反饋的抽象語法樹->高階別中間程式碼->低階別中間程式碼->優化過的原生程式碼。

高階別中間程式碼是編譯器前端形成的程式碼,而低階別中間程式碼是後端使用的中間程式碼,通過前端後端雙重的優化,讓V8引擎對熱點函式有更好的處理。

總結

V8引擎採用惰性優化的方式來提高效能,通過針對執行時的熱點函式優化,快編譯和慢優化的結合,而且通過合理的型別推斷解決的JavaScript的型別問題。這只是對V8早期版本的一個概念分析,但是我已經開始接觸到了V8優化的魔法。

參考資料:

http://wingolog.org/archives/2011/07/05/v8-a-tale-of-two-compilers#ffc2b5d74c27fa60d75658244fee88e6fa783afb

https://github.com/v8/v8/tree/master/src

相關文章