本文討論如何優化我為娛樂創建的動態語言Zef的極簡AST遍歷直譯器,使其效能能與Lua、QuickJS和CPython等相媲美。
大多數關於語言實現加速的文章,著重於已有穩定基礎後的工作,如撰寫JIT編譯器或微調垃圾回收器。我之前寫過許多關於成熟JS執行環境的瘋狂優化,但本文不同,聚焦於從零開始,尚未接近JIT階段且GC不是主要瓶頸的情況。
本文介紹的技巧易於理解,無需SSA、GC、位元碼或機器碼,卻能帶來高達16倍(若包含未完成的Yolo-C++移植則達67倍)的速度提升,讓我微小的直譯器進入QuickJS、CPython和Lua的效能範圍。
我使用名為ScriptBench1的基準套件評估進展,該套件包含多個經典語言基準的Zef版本,並參考了JavaScript、Python和Lua的現有移植版本。所有實驗在Ubuntu 22.04.5、Intel Core Ultra 5 135U、32GB記憶體環境下進行,Lua 5.4.7使用GCC 11.4.0編譯,QuickJS-ng 0.14.0為官方二進位檔,CPython 3.10為Ubuntu預設版本。每項實驗取30次隨機交錯執行的平均值。
本文大部分比較我用Fil-C++編譯的直譯器與他人用Yolo-C編譯的直譯器。
文章先介紹原始AST遍歷且大量使用雜湊表的Zef直譯器,接著依序說明我在優化過程中採用的多項技巧,最終達成16.6倍速度提升。
原始Zef直譯器幾乎未考慮效能,僅有兩個效能意識的設計決策,但也做了許多從效能工程角度看來錯誤的權宜之計。這些不佳選擇卻讓我用極少程式碼實作出相當複雜的語言,最大模組是解析器,其餘部分簡潔明瞭。
該直譯器效能遠落後CPython 3.10約35倍、Lua 5.4.7約80倍、QuickJS-ng 0.14.0約23倍。接下來看看透過多項優化能提升多少效能!
第一項優化是讓解析器為每個運算子生成不同AST節點,而非使用帶有運算子名稱的DotCall節點。原本解析a + b會生成DotCall(a, "add")並以b為參數,導致每次數學運算都需字串查找運算子方法名稱,執行緩慢。
優化後,解析器產生Binary<>和Unary<>節點,透過模板與lambda技巧,這些節點為每個運算子分別覆寫Node::evaluate,直接呼叫對應的Value快速路徑。於是a + b會呼叫Binary<add>::evaluate,再呼叫Value::add。
此改動帶來17.5%速度提升。此時Zef比CPython慢30倍,比Lua慢67倍,比QuickJS慢19倍。
第二項優化針對運算子的讀-改-寫(RMW)形式,原本仍使用字串分派。解析器改為為每種RMW情況生成不同節點,透過makeRMW虛擬呼叫替換LValue節點,並用SPECIALIZE_NEW_RMW宏創建模板專用形式。RMW分派使用列舉型別,最後由Value::callRMW<>模板函式處理實際呼叫。
此改動帶來3.7%速度提升。此時Zef比CPython慢29倍,比Lua慢65倍,比QuickJS慢18.5倍,較起點快1.22倍。
第三項優化是Value快速路徑避免使用isInt()虛擬呼叫,改為只考慮int32與double,將IntObject處理放入IntObject本身,避免每次方法分派都呼叫isInt()。
此改動帶來1%速度提升。此時Zef比CPython慢29倍,比Lua慢65倍,比QuickJS慢18倍,較起點快1.23倍。
第四項優化是將原本廣泛使用的std::string改為指向hash-consed Symbol物件的指標,避免大量字串雜湊與比較。
此改動帶來18%速度提升。此時Zef比CPython慢24倍,比Lua慢54倍,比QuickJS慢15倍,較起點快1.46倍。
第五項優化引入valueinlines.h標頭,允許重要函式內聯,提升效能。
此改動帶來2.8%速度提升。此時Zef比CPython慢24倍,比Lua慢53倍,比QuickJS慢15倍,較起點快1.5倍。
第六項優化是大幅重構Object、ClassObject和Context,使物件分配更便宜,存取避免雜湊表查找。引入Storage概念,根據Context預先計算偏移量分配儲存空間。此技術是現代高效動態語言實現的基礎,透過內聯快取記憶先前類型與偏移,避免重複查找。
此改動帶來4.55倍速度提升。此時Zef比CPython慢5.2倍,比Lua慢11.7倍,比QuickJS慢3.3倍,較起點快6.8倍。
第七項優化改進函式參數傳遞,避免不必要的std::vector與optional分配,改用Arguments類型直接分配呼叫者所需參數。
此改動帶來1.33倍速度提升。此時Zef比CPython慢3.9倍,比Lua慢8.8倍,比QuickJS慢2.5倍,較起點快9.05倍。
第八項優化針對私有實例欄位預設為私有的特性,專門優化getter函式,避免每次呼叫都重新評估AST。
此改動帶來5.6%速度提升。此時Zef比CPython慢3.7倍,比Lua慢8.3倍,比QuickJS慢2.4倍,較起點快9.55倍。
第九項優化同理專門優化setter函式,透過模式匹配推斷setter。
此改動帶來3.4%速度提升。此時Zef比CPython慢3.6倍,比Lua慢8倍,比QuickJS慢2.3倍,較起點快9.87倍。
第十項優化是內聯重要函式,帶來3.2%速度提升。此時Zef比CPython慢3.5倍,比Lua慢7.8倍,比QuickJS慢2.2倍,較起點快10.2倍。
第十一項優化引入全局雜湊表,根據接收者類別與符號直接查找呼叫函式,避免多層繼承結構中多次雜湊查找。
此改動帶來15%速度提升。此時Zef比CPython慢3倍,比Lua慢6.8倍,比QuickJS慢1.9倍,較起點快11.8倍。
第十二項優化避免Fil-C++中std::optional因聯合體特性導致的堆分配,減少記憶體分配。
此改動帶來1.7%速度提升。此時Zef比CPython慢3倍,比Lua慢6.65倍,比QuickJS慢1.9倍,較起點快12倍。
第十三項優化引入ZeroArguments、OneArgument、TwoArguments等專用參數類型,避免不必要的Arguments物件分配。
此改動帶來3.8%速度提升。此時Zef比CPython慢2.9倍,比Lua慢6.4倍,比QuickJS慢1.8倍,較起點快12.4倍。
第十四項優化將Value慢路徑方法改為靜態函式,避免Fil-C++中因堆分配Value導致的效能損失。
此改動帶來10%速度提升。此時Zef比CPython慢2.6倍,比Lua慢5.8倍,比QuickJS慢1.65倍,較起點快13.6倍。
第十五項優化移除重複程式碼,雖未帶來效能提升,但使程式碼更簡潔。
第十六項優化讓Dot節點專門化處理value.sqrt等非運算子方法呼叫,提升效能。
此改動帶來1.6%速度提升。此時Zef比CPython慢2.6倍,比Lua慢5.75倍,比QuickJS慢1.6倍,較起點快13.8倍。
第十七項優化類似前項,專門化toString方法,並減少整數轉字串時的分配。
此改動帶來2.7%速度提升。此時Zef比CPython慢2.5倍,比Lua慢5.6倍,比QuickJS慢1.6倍,較起點快14.2倍。
第十八項優化專門化ArrayLiteral節點,避免每次評估常數陣列時重複遞迴AST。
此改動帶來8.1%速度提升。此時Zef比CPython慢2.3倍,比Lua慢5.2倍,比QuickJS慢1.5倍,較起點快15.35倍。
第十九項優化避免呼叫callOperator慢路徑時傳參考,改為傳值。
此改動帶來6.5%速度提升。此時Zef比CPython慢2.2倍,比Lua慢4.9倍,比QuickJS慢1.4倍,較起點快16.3倍。
第二十項優化關閉RTTI及libc++硬化,因Fil-C++不需要這些功能。
此改動帶來1.8%速度提升。此時Zef比CPython慢2.1倍,比Lua慢4.8倍,比QuickJS慢1.35倍,較起點快16.6倍。
最後一項優化預設關閉assertions,改用條件編譯的ASSERT宏,雖未帶來效能提升。
我嘗試用Yolo-C++編譯,獲得約4倍速度提升,但目前實作不完整且不穩定。若加入真正的GC,速度提升可能更大。
此時Zef比CPython快1.9倍,比Lua慢1.2倍,比QuickJS快3倍,整體較起點快67倍。
以下為所有基準測試的執行時間及幾何平均數據。