SBCL: 組合語言碼實驗板
虛擬機器 (VM) 設計與低階機器碼優化之間的交集,往往揭示了靈活性與效能之間的緊張關係。在傳統的基於堆疊的 VM 中,推入 (push) 與彈出 (pop) 資料的開銷——通常涉及記憶體存取或複雜的暫存器重排——可能成為顯著的瓶頸。
在這項技術探索中,我們研究了一種利用 Steel Bank Common Lisp (SBCL) 組裝器來實現高效能基於堆疊的 VM 的方法。目標是透過將一組小的硬體暫存器視為旋轉堆疊 (rotating stack),並針對堆疊指標的每一種可能位置對機器碼進行特化 (specializing),來消除堆疊操作期間的資料移動。
旋轉堆疊概念
受 F18 與 x87 浮點運算單元的啟發,核心思想是將 VM 堆疊限制在少數固定數量的插槽 (slots) 中(例如 8 個)。與其在 push 或 pop 期間在暫存器或記憶體之間物理性地移動資料,不如讓 VM 維持一個指向當前堆疊頂端 (TOS) 的模數計數器 (modular counter)。
在軟體實作中,動態索引暫存器通常是不可能的或速度極慢。為了達成此目標,VM 採用了一種稱為原始操作特化 (primitive specialization) 的技術。對於每一個原始操作(例如 ADD 或 DUP),VM 會生成八種不同版本的機器碼——分別對應堆疊計數器的每一個可能值。
暫存器映射
為了在 x86_64 中實作此功能,使用了以下暫存器配置:
r8到r15: 8 個堆疊插槽。rsi: 機器碼原始操作的基底位址。rdi: 虛擬指令指標 (VIP)。rax,rbx,rcx,rdx: 暫存空間 (scratch registers)。rsp: 虛擬回傳堆疊指標。
使用 SBCL 實作 VM
SBCL 在此專案中充當「巨集組裝器 (macro-assembler)」。由於 SBCL 允許互動式地生成與檢查機器碼,因此可以編寫 Lisp 函數來根據虛擬堆疊指標的當前狀態發出組合語言。
NEXT 序列
在直接執行緒化 (direct-threaded) VM 中,每個原始操作都以一個 NEXT 序列結束,用以跳轉到下一條指令。為了支援旋轉堆疊,NEXT 序列必須分派 (dispatch) 到與新堆疊指標值相匹配的下一個原始操作版本。
實作中將每個 primop 的變體儲存在固定的間隔中(例如每 4288 位元組)。分派邏輯計算偏移量的方式如下:
variant_offset = 4288 * stack_counter
這使得 VM 可以直接跳轉到針對當前堆疊深度特化的程式碼,而無需在執行時進行條件檢查或複雜的查表。
建構指令集
基礎原始操作
簡單的操作如 SWAP、DUP 與 ADD 是透過發出針對目前映射到 TOS 與其下方元素的暫存器之特定 x86 指令來實作的。例如,一個 ADD 操作僅僅是將 (@ 1) 的暫存器與 (@ 0) 的暫存器相加,並增加虛擬堆疊指標。
控制流
為了使 VM 具備功能性,控制流原始操作是不可或缺的:
- JMP: 使用相對偏移量覆寫虛擬 IP。
- CALL/RET: 使用硬體堆疊 (
rsp) 來儲存虛擬 IP 的回傳位址。 - 條件判斷 (JZ/JNZ): 使用
CMOV(條件移動) 或重複的NEXT序列來根據 TOS 值更新虛擬 IP。
效能分析與優化
在此實作中最顯著的發現之一是「融合 (fusing)」運算子的影響。一個簡單的遞減計數器並在非零時跳轉的迴圈可以用幾種方式實作:
- 未特化的位元組碼 (Unspecialized Bytecode):
LIT$ ightarrow$SUB$ ightarrow$JNZ。這是最慢的方法(比原生碼慢 11-15 倍)。 - 融合運算子 (
DJN): 單一處理遞減與跳轉的原始操作。這顯著提升了效能。 - 優化後的融合運算子 (
DJN2): 透過將條件移動 (CMOV) 替換為可預測的硬體分支,並重複NEXT序列,該迴圈僅比原生組合語言慢約 6 倍。
比較摘要
| 實作方式 | 相對效能 (Cycles/Iter) |
|---|---|
| 原生組合語言 | 1x |
特化的 DJN2 |
~6x |
特化的 DJN |
~8x |
| 未特化的位元組碼 | 11-15x |
結論
只要堆疊大小保持在較小範圍內,基於虛擬堆疊指標的原始操作特化是一種減少執行緒化解釋器開銷的可行策略。雖然作者提到他們並非主要對堆疊語言感興趣,但此架構可作為一個高效能的執行時中間表示 (IR)。
此專案突顯了 SBCL 作為低階系統程式設計工具的力量。透過在高級 Lisp 抽象與原始機器碼生成之間建立橋樑,SBCL 允許快速迭代與「實驗」組合語言技術,而這些技術在 C 或傳統組合語言中會非常繁瑣。