同一份 SPECint 2006,靜態翻成 AArch64 之後,.text 段膨脹到原生編譯版的 47.5 到 62.5 倍——按理說塞滿 i-cache 的怪物該慢得無可救藥;可它跑起來卻比 QEMU 的動態翻譯還快。一份大到離譜的二進位,為什麼反而贏了?
把 x86-64 靜態翻成 aarch64——Elevator 的程式碼膨脹之謎
把一個 x86-64 執行檔搬到 AArch64 上跑,業界的標準答案是動態二進位翻譯(dynamic binary translation)——QEMU、Rosetta 這一類,邊跑邊把遇到的 x86 指令即時翻成 ARM 指令、翻一塊執行一塊。Elevator 這篇論文走的是另一條路:完全靜態、事前一次翻完整支執行檔,不靠 debug 資訊、不要原始碼、不對程式佈局做任何假設,也不用任何啟發式的猜測。論文把自己定位成「第一個把整支 x86-64 執行檔靜態翻成 AArch64 的二進位翻譯器」。
奇怪的地方在後面。論文自己承認,翻出來的 .text「比原生編譯的 AArch64 .text 大上 47.5 到 62.5 倍」,跨整個 SPECint 2006 都是這個量級。直覺上,這種尺寸的程式碼根本不該能跑得好:指令快取放不下、分支預測器吃不消、記憶體頻寬被白白吃掉。可同一份評測裡,Elevator 相對原生只慢了 4.88 倍(-O2),而 QEMU 的 user-mode 慢了 7.24 倍——靜態翻譯不但沒被膨脹拖垮,還在十二個 benchmark 裡贏了七到八個。這篇就是要拆這個矛盾:忠實的逐指令靜態翻譯到底被迫吐出了什麼,把程式碼撐到五十倍;而撐成這樣之後,它又憑什麼還能跑贏動態翻譯。
47.5 到 62.5 倍——一個大到不該能跑的 .text
先把這個膨脹講具體。論文的數字是兩個層面:一個是靜態的程式碼尺寸,翻譯後的 .text 是原生 AArch64 的 47.5×–62.5×;另一個是動態的指令流,實際被執行的指令數,Elevator「把指令流脹大 7.12 倍(-O2)、7.05 倍(-O3)」。這兩個數字差了快一個數量級,本身就是一條線索——真正被執行的路徑只脹了 7 倍,而整個 .text 卻脹了 50 倍。多出來的那一大塊,是擺在那裡備而不用、平常根本踩不到的程式碼。下面這張表把論文裡的硬數字並排,方便你一眼看出哪些是尺寸、哪些是速度、哪些是時間成本。
點欄位標題排序 · 4 欄 × 7 列
| 量測項目 | -O2 | -O3 | 性質 |
|---|---|---|---|
| .text 相對原生 AArch64(下界) | 47.5 | 47.5 | 尺寸 |
| .text 相對原生 AArch64(上界) | 62.5 | 62.5 | 尺寸 |
| 實際執行指令流脹大倍數 | 7.12 | 7.05 | 指令 |
| Elevator 相對原生減速 | 4.88 | 4.79 | 速度 |
| QEMU user-mode 相對原生減速 | 7.24 | 7.69 | 速度 |
| Elevator 贏過 QEMU 的 benchmark 數(共 12) | 7 | 8 | 對比 |
| 翻完整套 SPECint 2006 耗時(秒) | 140 | 167 | 時間 |
把這張表讀完,問題其實分成了兩半。第一半是「五十倍的尺寸是從哪冒出來的」——這是一個關於忠實翻譯成本的問題:要在 AArch64 上一模一樣地重現 x86 的語意,每一條 x86 指令到底得補上多少額外的 ARM 指令。第二半是「脹成這樣為什麼還快」——這是一個關於靜態相對動態的問題。先解第一半。膨脹不是單一原因造成的,下面這幾個來源各自貢獻了一塊,逐一拆開。
每個位元組都可能是指令——superset CFG 撐出的死碼
第一個、也是尺寸層面最大的來源,跟「翻譯」這個動作本身沒太大關係,而是跟「不能對程式佈局做任何假設」這個前提有關。x86-64 是變長指令集:一條指令可能一個位元組、也可能十五個位元組,而且指令可以從 .text 的任意位元組偏移開始解碼。靜態翻譯器拿到一支執行檔,沒有 debug 資訊、也不能假設程式只會從「編譯器原本對齊的那些位置」進入,因為間接跳躍(indirect branch)、callback、各種執行期才決定的 dispatch,目標位址無法靜態分析。
Elevator 的對策很暴力但很誠實:它「同時把原始二進位裡每一個可執行位元組偏移,都當成(i)資料、以及(ii)一段從該偏移開始的潛在指令序列」,然後從每一個候選解碼結果建出一張 superset 控制流圖。換句話說,「每一個無法靜態分析的間接跳躍、callback 或執行期 dispatch 的潛在目標,在改寫後的二進位裡都有一個對應的落點」。這是它能保證正確性的方式——既然不知道執行期會跳到哪個位元組,那就保證每個位元組都翻好、都能當作合法的進入點。
代價直接寫在尺寸上。一段 N 個位元組的原始 .text,現在有將近 N 個重疊的候選解碼,每個都展開成一段 AArch64 序列。這些序列絕大多數永遠不會被執行——它們是為了「萬一間接跳躍真的跳到這裡」而存在的保險。這正好解釋了前面那個落差:.text 脹了五十倍,是因為靜態尺寸把所有候選落點都算進去;而實際執行只脹了 7 倍,是因為跑起來真正踩到的,只有其中那條真實控制流。論文也明說,Elevator「在 x86-64 .text 的每一個合法來源位元組偏移都吐出一段 AArch64 序列,而且翻譯後不做任何尺寸縮減」。膨脹的第一大塊,就是這片備而不用的死碼。
六個 flag 對上四個——把 EFLAGS 搬到只有 NZCV 的機器
第二個來源是 x86 語意裡最黏、最難搬的東西:條件旗標。x86 在大多數算術運算後,會「計算六個條件旗標(PF、AF、SF、ZF、CF、OF)」,而 AArch64 原生「只提供四個 NZCV 旗標」。少掉的兩個——奇偶旗標 PF 和輔助進位 AF——在 AArch64 上沒有硬體對應,得用額外的 ARM 指令算出來再存著。
Elevator 的翻譯模型是「tile」式的:每一條 x86 指令配上「對應其運算元暫存器的每一種具體組合」的一段預先編好的 AArch64 位元組序列。一條會更新旗標的 x86 算術指令,它的 tile 不只是做那個加法或減法,還得把六個旗標逐一算出來、寫進模擬的旗標狀態裡——因為翻譯器無法假設後面那條指令會不會去讀某個旗標,為了忠實,只能每次都全算。這就是膨脹的第二塊:一條 x86 的 add,在 ARM 上可能變成「做加法 + 算 PF + 算 AF + 算 SF/ZF/CF/OF」的一長串。
論文這裡有一個值得記下的優化,因為它反過來印證了旗標 tile 有多貴。Elevator「實作了一個有針對性的優化,把不必要的旗標計算 tile 消掉,這些 tile 相對其他 tile 來說相對昂貴」;判斷準則是「如果旗標在某個後支配(post-dominating)指令真正讀取它之前就被覆寫掉,那麼當前節點 tile 裡的旗標計算部分就能移除」。翻成白話:只有在能靜態證明「這次算出來的旗標,下次被讀之前一定先被另一條指令蓋掉」時,才敢把這段旗標計算刪掉。會特別去做這個優化,正說明旗標 tile 是膨脹的主要嫌疑犯之一——值得花力氣專門對付。
旗標只是「一條 x86 指令展開成多條 ARM 指令」的其中一個推手。把所有推手疊起來,論文量到的平均效果是指令流脹 7 倍。下面這個 demo 讓你親手把這些忠實性要求一項項加上去,看平均每條 x86 指令需要的 AArch64 指令數怎麼往 7× 爬——重點不是某個確切數字,而是「每多忠實一分,就多吐幾條指令」這個機制。
拖滑桿,逐項加上忠實翻譯的要求,看平均每條 x86 指令脹成幾條 AArch64 · 4 個階段
定址模式與暫存器——把 CISC 攤平成 RISC 要付的零錢
膨脹的第三、第四塊比較零散,但合起來不小,都源自 x86 是 CISC、AArch64 是 RISC 這個底層落差。x86 的複雜定址模式是第一筆:像 [base + index*scale + disp] 這種一條指令裡內建的位址計算,AArch64 的定址能力沒這麼花俏。論文寫得很直接——「x86 的複雜定址模式(例如 [base + index*scale + disp]),在來源運算元無法被直接表達時,會分解成 AArch64 上一小段位址計算序列」。本來 x86 用一條指令順手算完的位址,在 ARM 上得先用幾條指令把位址算出來,再去存取。每出現一次這種定址,就多吐幾條 ARM 指令。
暫存器是第二筆,但 Elevator 在這裡的選擇反而是克制的。它「在 x64 與 AArch64 暫存器之間採用一對一映射,用一個對應的 AArch64 暫存器去模擬每個 x64 暫存器的狀態」。x86-64 有 16 個通用暫存器,AArch64 有 31 個,看似綽綽有餘;但 Elevator 刻意遵守三條約束來挑這個映射——保持 volatility(caller/callee-saved 的屬性要對齊)、對齊參數位置、以及「最小性:在前兩條約束允許的範圍內,盡量少佔用 AArch64 的 callee-saved 暫存器」。把暫存器映射固定下來、不亂搶 scratch,是為了讓每段 tile 都能獨立翻譯、彼此不必協調——這是 tile 模型能成立的前提。代價是,當某段翻譯臨時需要一個工作暫存器來算位址或算旗標時,得自己搬進搬出,又是幾條指令。
把這四塊膨脹來源並排看會更清楚:它們不是同一個原因的不同說法,而是四個獨立的機制,各自往 .text 裡塞東西。下面這張圖把它們攤開,點任一塊看它具體吐了什麼、以及它撐大的是「靜態尺寸」還是「動態指令流」。
膨脹的四個來源——點一塊看它撐大的是尺寸還是指令流
點任一塊看它撐大的是靜態尺寸還是動態指令流。
① superset CFG
論文把每個可執行位元組偏移「同時當成資料與一段潛在指令序列」,為每個無法靜態分析的間接跳躍目標都留一個落點。這是 .text 脹到 47.5×–62.5× 的最大來源——大多數落點永遠不執行,所以指令流只脹 7×。撐大的主要是靜態尺寸。
② 旗標 tile
x86 大多數算術運算後算六個條件旗標(PF、AF、SF、ZF、CF、OF),AArch64 原生只有四個 NZCV。差的旗標得用額外 ARM 指令補算並存起來。論文專門優化掉「在被讀之前就會被覆寫」的旗標 tile,反證它的成本。撐大的是尺寸與指令流兩者。
③ 定址模式拆解
[base + index*scale + disp] 這類 x86 一條搞定的複合定址,當來源運算元無法直接表達時,會分解成 AArch64 上一小段位址計算序列。每出現一次就多吐幾條指令,主要撐大實際執行的指令流。
④ 暫存器搬運
Elevator 用 x64 與 AArch64 暫存器的一對一映射,遵守 volatility、參數位置、最小性三條約束,盡量少佔 callee-saved。固定映射是 tile 能獨立翻譯的前提,代價是臨時 scratch 需求得自己搬進搬出,撐大指令流。
四塊加起來,膨脹就不神秘了:尺寸的五十倍主要是 superset CFG 的死碼,而執行時真正會踩到的 7 倍,則是旗標、定址、暫存器這些逐指令成本累積出來的。順帶一提,這套靜態策略也劃出了它的邊界——論文坦言 Elevator「和所有完全靜態的二進位改寫器一樣,不支援自我修改或即時編譯(JIT)的程式碼」,也「還不支援使用例外處理的二進位,主要影響到 C++ 例外」。會自己改自己的程式碼,靜態翻譯在事前根本看不到那份未來的指令,這是這條路線的天花板,不是工程沒做完。而這兩個限制方向不同:自我修改與 JIT 是原理性的——程式要跑到一半才生成的指令,事前那一遍翻譯根本無從觸及;而例外處理的缺口論文用的字是「還不支援」(does not yet support),語氣留了餘地,更像工程進度而非死路。讀這類靜態方案時,分清「原理上做不到」和「目前還沒做」,比記住膨脹倍數更要緊——前者決定它永遠不能跑哪些程式,後者只是時間問題。
脹成這樣為什麼還快——靜態贏在哪一刀
現在回到開頭的矛盾。.text 脹了五十倍,可 Elevator 相對原生只慢 4.88×(-O2)、4.79×(-O3),而 QEMU user-mode 慢 7.24× 和 7.69×——「QEMU user-mode 平均更慢、而且變異大得多」。在十二個 benchmark 裡,「Elevator 在 -O2 有七個、-O3 有八個跑得比 QEMU 快」。這裡的「變異大得多」其實和「贏 7/12、8/12」是同一件事的兩種講法:幾何平均只告訴你整體輸贏,但 QEMU 變異大,意味著它在某些 benchmark 上跌得特別深,把平均拖下去;而 Elevator 的減速分布更集中、更可預測——對一個要拿去做生產遷移、必須給出最壞情況保證的人來說,可預測往往比平均快更值錢。下面這張圖把兩者的減速並排,以原生 AArch64(1×,越低越快)為基準。
贏的第一刀,是把翻譯成本整個從執行期移走了。動態翻譯器邊跑邊翻:第一次踩到一塊新程式碼,得先停下來把它翻成 ARM、(QEMU 的 TCG 還會)做即時編譯,這份翻譯與編譯的開銷算在執行時間裡。Elevator 是事前一次翻完——論文記下「翻完整套 SPECint 2006 在 -O2 花 140 秒、-O3 花 167 秒」,這筆錢在程式真正開跑之前就付清了,執行期完全不必再付。值得算一筆帳:140 秒是把整套十二個 benchmark 一次翻完的總額,分攤到每支執行檔不過十幾秒,而且這是一次性支出——翻好的二進位往後每跑一次都不必再翻。對一支會被反覆執行、或長時間常駐的程式來說,這筆事前成本被攤得幾乎為零;動態翻譯靠 code cache 攤掉重複翻譯的成本,但冷啟動、第一次命中、以及 cache 容量不夠時的重翻,都還是執行期的稅;靜態翻譯把這條稅一次繳清,跑起來就純粹是執行翻好的碼。
第二刀比較反直覺,正是回應開頭那個「五十倍怎麼還快」的疑問。.text 大,不等於熱路徑大。前面已經拆過:靜態尺寸的五十倍,絕大部分是 superset CFG 留下、永遠不執行的死碼;真正被執行的指令流只脹 7 倍。對 i-cache 來說,重要的從來不是 .text 總共多大,而是「實際在跑的那條路徑」落在多少快取行裡。那片死碼靜靜躺在記憶體裡,不會被取進 i-cache,也就不真的傷害到熱迴圈的局部性。合理的推測是:膨脹之所以沒把效能拖垮,正是因為膨脹的主體(superset 死碼)與真正影響速度的主體(熱路徑指令流)幾乎是兩回事——論文把這兩個數字(50× 對 7×)分開報,本身就暗示了這個切分。
第三刀是事前優化的空間。靜態翻譯有完整一支程式可以分析,能做動態翻譯在「邊跑邊翻、看不到全貌」時做不到的事——前面那個旗標 tile 優化就是例子:要靠後支配關係證明「這個旗標在被讀前一定先被覆寫」,得對控制流有全域視野,這正是靜態才負擔得起的分析。動態翻譯器為了不拖慢執行,能花在每塊程式碼上的分析時間有限;靜態翻譯把時間預算挪到了事前,於是同樣的忠實語意,可以翻得更省一點。三刀合起來,就是一個大到離譜、卻仍跑贏動態翻譯的二進位。
The lesson:看到「.text 膨脹五十倍卻沒變慢」別急著喊矛盾——先分清楚膨脹的是靜態尺寸還是熱路徑指令流。前者可以是備而不用的死碼,對 i-cache 無害;真正決定速度的,是那條被執行的路徑有多長,以及翻譯成本是事前付清還是執行期現付。