歷史上,快速的JIT編譯一直是一門黑魔法。要寫出快速的JIT編譯器,你需要懂得如何撰寫組合語言。舉例來說,現今沒有任何生產環境的資料庫擁有自己的JIT編譯器,它們要麼使用LLVM,要麼生成C/C++程式碼。這兩種方案都存在編譯時間過長的問題,限制了它們的應用範圍。如今,借助AI的幫助,直接針對組合語言撰寫快速編譯的JIT編譯器變得比以往更簡單。這也是新一代資料庫有機會超越舊有資料庫的領域之一。
在開發pgrust時,我原本以為實作JIT編譯器會非常困難,但最終發現有了AI的協助,這過程比預期簡單許多,這也是pgrust能夠如此快速的原因之一。pgrust的JIT編譯器能在約5微秒內完成編譯,這使我們能對每個SQL查詢進行JIT編譯,而不僅僅是部分查詢。本文將帶你了解如何打造自己的快速JIT編譯器,我們將以一個簡單的正則表達式引擎為例,展示JIT編譯的應用。
JIT編譯是指在執行時動態生成編譯後的程式碼,或稱「即時編譯」。若執行得當,JIT編譯能帶來2至5倍甚至更高的效能提升。JIT編譯的主要用途是在執行時獲得資訊,進而大幅改變程式行為,這在程式語言直譯器中特別常見,因為它們在執行時才接收到要執行的程式碼。JIT編譯器的應用範圍也超越程式語言,例如資料解析,有時你直到執行時才知道資料的結構,JIT能在這方面提供幫助。
首先,我們實作一個簡易的正則表達式引擎。為了簡化,我們只支援兩個功能:字串字面值和重複(即正則表達式中的*)。我們也跳過解析器,直接用已解析的Rust結構表示正則表達式。這表示我們能支援像是“b(an)*”這樣的字串,但不包含選擇、回顧等複雜功能。
程式碼方面,我們會有三種節點類型:字面字串節點、重複節點和串接節點(兩個節點的組合)。這樣的結構相當簡單。
撰寫這個正則表達式引擎的直譯器也很簡單,程式碼不到20行。接著,我們比較它的效能,與專門為該正則表達式手寫的程式碼相比較。以“b(an)*”為例,手寫程式碼雖有優化空間,但已足夠作為比較基準。
測試結果顯示,手寫版本比直譯器快10到20倍,顯示有很大提升空間。
接著,我們探討如何利用JIT編譯,打造一個效能可與手寫版本匹敵的通用正則表達式引擎。
JIT編譯分兩步驟:首先產生欲執行程式碼的組合語言,接著將組合語言封裝成可像一般函式般呼叫的函式。
產生組合語言時,我們採用一種稱為copy-and-patch的變體方法。概念是準備一系列組合語言範本(稱為「模板」),對應不同的JIT編譯操作。當要編譯某操作時,取出對應模板,根據操作細節做微調,就像填充模板一樣。將多個填充好的模板串接起來,即可在執行時組成效能接近手寫程式碼的程式。
我們的流程是:先看針對“b(an)*”生成的ARM64組合語言,將重複指令序列轉成可重用模板,撰寫發射器(emitter)從正則表達式AST填充並組合模板,最後將生成的指令複製到可執行記憶體,讓Rust能像呼叫一般函式般呼叫它們。
為了說明原理,我們從生成的程式碼開始,倒推回JIT編譯器本身。以“b(an)*”為例,設計決策如下:
程式狀態使用特定暫存器,輸入則包含字串指標與長度。
接著逐段解讀生成的組合語言,這是在macOS ARM64環境下。首先是序言,初始化堆疊指標。
接著檢查字元b,若非b則跳轉至回退邏輯,否則字串位置前進。
然後是重複(an)*部分,需要實作回溯機制。回溯時跳至迴圈結尾,需將迴圈後指令地址與字串位置存入堆疊。
接著執行重複主體,檢查字元a與n,若符合則回到迴圈頂端,字串位置更新。
迴圈結束後,回溯跳轉至此,檢查是否字串結尾,結尾則回傳成功1,否則執行失敗回退邏輯。
最後是回退邏輯,檢查堆疊是否空,空則回傳失敗0,否則從堆疊彈出回退地址與字串位置,跳轉回退地址。
看到這些編譯後程式碼,你應該能理解copy-and-patch編譯器的運作方式。這些指令區塊大致相同,只有細節差異。對每個區塊,我們撰寫函式生成對應程式碼,並以參數調整細節,例如比較字元。
序言相當簡單,僅初始化堆疊指標。
字元比較則插入欲比較字元與回退跳轉位置。
重複部分包含迴圈開始將資料推入堆疊及跳至迴圈結尾的指令。
匹配與失敗區塊也相當乾淨。
為完整起見,這裡是用來插入特定資料到指令的輔助函式。
這就是最困難的部分!對我而言,撰寫組合語言時AI幫助最大。我過去的組合語言經驗僅限於完成microcorruption CTF,從未真正自己寫過組合語言。若無AI,我很難確定需要哪些指令及如何修改才能達成目標。透過AI,我能告訴程式代理我想要的JIT編譯器大致架構,它能幫我處理許多細節。
完成編譯器後,我們需要將程式碼載入。這裡使用mmap分配一塊可讀、可寫、可執行的記憶體,將程式碼複製進去,並將該記憶體區塊轉成函式供Rust呼叫。
完成後,我們比較不同實作的效能:JIT版本與手寫版本幾乎不相上下,有時JIT較快,有時手寫較快。
近期有種迷思認為AI無法幫助,因為「寫程式碼從來不是難點」。我認為這在某些領域成立,但在其他領域,寫程式碼確實是難點。JIT編譯器就是一個很好的例子。對許多軟體而言,JIT編譯器能大幅提升速度,但過去因為實作困難而罕見。大型語言模型降低了門檻,讓撰寫JIT編譯器變得容易。這也是pgrust背後的理念。資料庫歷來是最難開發的軟體之一,限制了其發展。如今有了AI,我們可以更大膽地打造軟體。
感謝閱讀。