Debian Code Search 打開 profile-guided optimization 那次,編譯器為了不讓分支跨過一個 32-byte 邊界而插入的 NOP,把預期中的效能提升,直接換成了 13% 的全面倒退。
最後一個 cgo 相依,用 Go SIMD 換掉了
Debian Code Search 靠一份反向索引,把整個 Debian 套件庫的原始碼字串對應到出現的檔案,索引能不能塞進磁碟、多快讀出來,七年來都仰賴 C 函式庫 powturbo/TurboPFor,透過 cgo 做整數壓縮與解壓縮。今年八月,作者把這條路徑重寫成純 Go,倚仗的是 Go 1.26 在今年二月引入的一個實驗性套件 simd/archsimd,第一次讓 AVX2、AVX512 這類指令可以直接寫進 Go 程式碼,不必再靠組合語言或 cgo 繞路。
TurboPFor 這類格式把資料切成固定大小的區塊,每個區塊各自決定一個 bit width,讓區塊內的值都能用這個寬度打包。少數幾個大到超出這個寬度的值,不會反過來拉高整個區塊的 bit width,而是被標記成例外,另外用一個獨立的 bit width 存起來,這樣多數值可以留在窄的寬度裡,不必為了少數離群值集體多付位元。scan() 只出現在 encoder 端,decoder 端不需要,因為解碼的時候 bit width 已經是區塊標頭裡寫死的資訊,直接照著這個位寬把值倒出來就好;真正貴的是編碼的時候要決定這個區塊該用哪個 bit width,而這個決定得先掃過所有候選寬度、算出每個寬度下有幾個例外,才能挑出總成本最低的一個。因為 bit width 是逐區塊決定的,同一個檔案裡,不同區塊可以各自用不同的寬度,取決於那一段資料的值分布;比起整個檔案套用同一個寬度,這樣才不會被少數幾個區塊的離群值拖累到全部。
PGO 先撞上的是 SKX102 對齊陷阱
作者想拆 cgo 是出於專案純度的偏好。他自己講得很直接:「I would prefer it if I did not have any C code in the project.」(我寧願專案裡完全沒有 C 程式碼)。真正促成重寫的,是 Go 終於有了不必繞道組合語言就能碰到 SIMD 的辦法。
第一步照例是打開 profile-guided optimization。理論上編譯器看過熱路徑的側寫資料,應該更敢做積極的 inline,效能只會往上走。結果 DCS 的 benchmark 反而全面倒退了 13%。追下去,原因出在一個和效能優化本身毫不相干的角落:Go 編譯器為了修一個叫 SKX102 的 Intel 勘誤(Go issue #35881),規定融合分支序列不能跨過或落在 32-byte 邊界上,遇到會踩線的情況就塞一條 NOP 進去對齊。這條規則疊在一個更早的機制上:2017 年的 mid-stack inlining 提案(proposal #19348)讓 Go 有時得放進 NOP 指令,好讓 inliner 能在上面附掛標記,方便之後的側寫工具定位。PGO 一打開,指令排列跟著變了,某些熱路徑就這樣撞進了對齊陷阱。後續幾個 commit 剛好改動了程式碼,這個倒楣的情境在接下來的優化系列裡就這樣被避開了——純粹是巧合。
這類問題有意思的地方在於,它不是演算法寫錯,也不是哪個迴圈沒寫好,而是編譯器為了相容特定世代 CPU 而做的底層調整,跟另一個最佳化(PGO 改變的指令排列)疊在一起才會現形,單獨看兩邊都沒問題。原文沒有解釋 32-byte 邊界為什麼會卡到效能,以下是照常理推想的機制:CPU 前端一次抓指令是照固定大小的視窗抓的;如果一組融合分支剛好跨在視窗邊界上,在有 SKX102 這個勘誤的處理器世代就可能不穩定,Go 編譯器的作法是預先算好每個分支的位置,只要會跨到邊界就墊一顆 NOP,讓它整個往後挪、不再跨界。PGO 打開之後,因為某些函式被更積極地 inline,程式碼在記憶體裡的位元組位置全部跟著變動。一個原本安全、離邊界還有餘裕的分支,可能因為前面多塞了幾行程式碼,就剛好撞上新的邊界,於是需要新的 NOP 墊上去。這個變動剛好卡進某條熱路徑,才會讓整體 benchmark 退步。
把配置搬出熱路徑
PGO 站穩之後,下一個要清的是配置熱點。decoder 原本在熱路徑裡用 make() 動態配置暫存陣列,每呼叫一次就要跟 allocator 打一次交道,這種成本平常不顯眼,量大了就是全面的拖累。作者把這個暫存陣列改成寫進 StreamDecoder 型別裡的固定欄位,一個 vals [256]uint32,搭配重新設計過的 API,讓呼叫端能重複使用同一個 decoder 實例,不必每次重新配置。這個改動把 debian-mix 這個混合真實工作負載的解碼速度,從 773 Mval/s 拉到 858 Mval/s,多了將近一成一。這一步完全沒碰 SIMD,也沒有動到任何演算法本身,純粹是把配置成本從迴圈內部搬出去,提升幅度遠不如後面幾輪,但也不必動到指令集。
這個 API 改動假設呼叫端會長時間跑在伺服器裡、重複處理大量查詢——建立一次 decoder、之後重複呼叫,是 DCS 這種常駐服務的常態用法。
用 generics 把 bit width 釘死成編譯期常數
TurboPFor 這類整數壓縮格式的核心是 bitpacking,把一組整數用剛好夠用的 bit 數打包,不多浪費一個位元。bit width 理論上可以是 1 到 32 之間任何一個數字,問題是,如果這個寬度只在執行期才確定,編譯器就沒辦法把移位量、遮罩這些運算寫死成常數,每一次都要多繞一層判斷。作者用 Go 的 generics 把 bit width 變成型別參數,對 1 到 32 每一個寬度各自生成一份特化程式碼。原文的說法是:「Because the bitWidth is now known at compile time, the Go compiler can generate close to the optimal machine code for each bit width」(因為 bitWidth 現在是編譯期常數,Go 編譯器可以為每個 bit width 生成接近最佳的機器碼)——迴圈能不能展開、分支能不能消掉,全看這個寬度是不是常數。
這個改動對「餘數區塊」最有感,也就是一個 block 裡塞不滿整個向量化寬度、留下來的那截尾巴。原文寫的是「Encoding remainder blocks is quite a bit faster (full blocks use the vertical layout anyway)」,完整區塊本來就走向量化的 vertical layout,能再省的空間有限,真正吃到特化紅利的是這些邊角料。
「vertical layout」實際上怎麼把多個值塞進一個 SIMD 暫存器,原文沒有進一步展開,但合理的推測是,它假設區塊裡的值數量剛好湊滿一整個向量寬度;一旦剩下不足一個向量寬度的尾巴,就沒有整份資料可以一次處理,只能退回逐值的 remainder 路徑,這也解釋了為什麼 generics 特化幾乎只對 remainder 有感。
| benchmark | 特化前(Mval/s) | 特化後(Mval/s) | 提升 |
|---|---|---|---|
| bitpacking-bw1 | 751.2 | 1120.5 | +49.15% |
| bitpacking-bw2 | 716.8 | 1176.0 | +64.07% |
| bitpacking-bw7 | 700.0 | 1078.5 | +54.08% |
| bitpacking-bw1-exc | 524.8 | 736.8 | +40.40% |
| bitpacking-bw7-exc | 566.7 | 787.7 | +38.99% |
| debian-mix | 559.5 | 783.8 | +40.09% |
上面表格裡的 exc,指的是後面會細講的帶 exceptions 路徑,先記得它也吃到特化的紅利就好。從表格可以看到,remainder block 的特化收益普遍落在 39% 到 64% 之間,純打包和帶 exceptions 的路徑都吃得到,不是只有某個特定 bit width 才特別有感。
拖曳調整 bit width,看 32 bit 能塞進幾個完整值 · 1 至 32 bit
32 bit 的區塊,用 5 bit 打包,可以塞進 6 個完整值,剩下 2 bit 進 remainder 路徑,得靠 generics 特化的餘數程式碼另外處理。
這個對應是離散的——bit width 只有 32 種可能,每一種都對應一組固定的位移量與遮罩,也就對應一份可以窮舉生成的特化程式碼。generics 在這裡等於把 32 種可能都個別編譯一次,執行期只要照 bit width 分派到對應的那一份。
AVX2 bitunpack 用兩個 256-bit 暫存器換掉一個迴圈
generics 特化能做的事有邊界,它讓編譯器少繞一層判斷,但運算本身還是一次處理一個值。真正的加速要等 Go 1.26 的 simd/archsimd 上場。這個實驗性套件要在建置時設定環境變數 GOEXPERIMENT=simd 才會啟用,搭配既有的 GOAMD64(決定編譯目標的微架構等級,分 v1 到 v4 四級),再用 archsimd.X86.AVX2() 在執行期偵測這台機器實際支援哪些指令集,偵測不到就退回純量版本。GOAMD64 這個環境變數本來就存在於 Go 工具鏈裡,用來決定編譯目標假設的最低微架構等級;archsimd 在這之上再加一層執行期偵測,兩者疊起來的效果,是同一份二進位檔案可以在新舊 CPU 上都跑,只是新 CPU 才吃得到 SIMD 的紅利。
GOAMD64 每往上一級,假設的最低微架構能力就更新一批,編譯器可以直接生成假設這些能力存在的機器碼,不必在每個呼叫點都插入相容性檢查。原文沒有說明在不滿足那個等級的舊機器上實際會發生什麼;比較保守的猜測是:程式碼用到的指令根本不存在於那顆 CPU 上,執行到時應該會觸發非法指令、讓程式中止,而不只是變慢。
拿 bitunpack(解包)來說,純量版本用一個逐一跑 8 次的 for range 迴圈處理 8 個值,位移、遮罩、邊界檢查全部逐值跑一輪;SIMD 版本一樣是處理 8 個值一批,卻完全不需要那個迴圈,用兩個 256-bit 暫存器同時扛住整批資料的位移與遮罩運算,8 個值的活一次做完。實測下來,SIMD 版本比純量版本快了大約 3 倍。這一段走的正是前面提到的向量化路徑,處理的是塞滿一整個 SIMD 寬度的完整區塊,不需要 generics 特化的 remainder 邏輯介入。
positional popcount:把逐值掃描改寫成逐欄計數
bitunpack 加速之後,瓶頸換了個位置。TurboPFor 這種帶 exceptions 的格式,encoder 端要先跑一輪 scan(),對每一個候選 bit width,數出有多少個值會變成例外,也就是超出這個寬度、得另外存起來的值。原文對這種區塊類型的定義是它會同時定出兩個 bit width——「one for values, the other bit width for encoding exceptions」(一個給正常值,一個給例外編碼用)。scan() 就是在替這兩個寬度找答案,而這是一個逐值、逐 bit width 的計數問題,即使套用了前面幾輪優化,「快」的版本平均每個值還是要花掉大約 12 條指令。SIMD 化了 bitunpack,卻繞不過這道逐值掃描。
作者的解法是把問題整個倒過來想。與其對每一個值逐一去問「你在這個 bit width 底下算不算例外」,不如把所有值攤開成一個 bit 矩陣,直接對每一欄,也就是每一個候選 bit width 的邊界,做一次橫向計數,這叫 positional population count,原文的講法是「Counting columns is called Positional Population Count」。第一步要先把每個輸入值變成它的 smear mask,原文形容是「imagine taking the first 1 bit and smearing it across the remaining positions」,把最低的那個 1 往其餘位置塗開;接著重新排列資料,讓同一批值裡同一個位置的內容集中到一起,方便逐欄計數。smear 之後,只要檢查某個 bit 位置是不是 1,就能知道這個值在那個 bit width 底下算不算例外,不必再對每一種 bit width 分別判斷一次。整套流程走完,原本要 12 條指令才能算完一個值的工作,壓到只剩 1.5 條,快了 8 倍。
原文沒有逐步拆解 GF2P8AFFINEQB 這一步的位元排列細節,但從整條 pipeline 的輸入輸出可以合理還原它要解決的問題:一批值攤開之後,每個值的第 k 個 bit 分散在不同的 byte、不同的偏移量上,硬要直接數所有值的第 k 個 bit 裡有幾個 1,得對每個值分別做位移跟遮罩,等於繞了一圈又回到逐值處理。VPERMB 先把同一個 byte 位置的內容集中到同一個 lane,GF2P8AFFINEQB 接著把位元位置本身重新排列,讓來自不同值、同一個 bit 位置的位元最後能落在同一個 byte 裡;排好之後,VPOPCNTB 才有辦法一次數完 64 個 byte,因為每個 byte 現在裝的是同一個位置、來自不同值的位元組合——這和轉置前一個 byte 只裝一個值的 8 個 bit,剛好相反。整套轉置換來的效果,是把「每個值各自的一排 bit」,重新切成「每個 bit 位置各自的一排值」——橫著數哪個位置有幾個 1,就直接等於數哪個 bit width 底下有幾個例外。這也是為什麼一次轉置能同時處理多個候選 bit width 的計數——byte 的數量早就對應著候選寬度的上限,VPOPCNTB 一次把整批位置都數完,不用再對每個寬度分別跑一次迴圈。
播放看四個階段如何把逐值掃描變成一次橫向計數 · 4 個階段
8 個示範值的低 8 位元;顏色只用來標示欄位,不代表真實資料
把每個值轉成 smear mask 後依位置重排、再做一次轉置,之後只要橫向數過去,掃描例外值就從每個值 12 條指令降到 1.5 條,快了 8 倍。
encoder 端最貴的一段掃描,因此從一個逐值迴圈,換成前面這套轉置加橫向計數的做法。這套轉置動用了三個實際的 x86 指令,各自負責哪一段可以點開下面看。
點開三個指令名稱看各自負責的段落 · 3 個名詞
-
VPERMB依 byte 重排資料,讓同一批值裡同一個 byte 位置的內容集中到同一個 lane,是轉置流程的第一步。 ——負責重排 byte。 -
GF2P8AFFINEQB原文形容它聽起來很嚇人,其實在位元操作上相當靈活,這裡用來把資料整理成方便逐欄計數的形狀。 ——負責位元層級整理。 -
VPOPCNTB一次數完 64 個 byte 裡所有的 1,取代逐值迴圈裡個別計算的開銷。 ——負責一次橫向計數。
把這五輪優化攤開來看,量級差得很明顯:
切換看五輪優化各自的實測數字 · 5 個階段
搬回 C 之後,Go 還是慢了 1.4 倍
故事如果在這裡打住,很容易寫成「Go 打敗了 C」的勝利敘事,但作者沒有這樣收尾。純量版本一開始的成績是 C 的 76%,原文寫的是「Go is at 76% of C」——沒有 SIMD 就只慢 24%。後面的優化甚至一度超車:原文說 SIMD 與 bit width 特化兩三個 commit 就足以「Beating C TurboPFor」,positional popcount 再拿下另一個 2 倍。等到把同一套 AVX512 kernel 和 positional popcount 技巧反向移植回 C TurboPFor,做真正對等的比較,作者測出來的結果是「Go benchmarks a little slower at ≈1.4x C.」,Go 版本還是慢了大約 1.4 倍。
差距不是玄學,三個原因都講得清楚。Go 強制邊界檢查,作者的態度很明確:「While it costs performance, bounds checking is great for safety, so I will not turn off bounds checking.」——這是主動的取捨。mid-stack inlining 留下的 NOP 標記,在指令吞吐量已經很吃緊的函式裡確實會拖慢執行,原文寫的是「For dispatch-bound functions, these extra NOPs can measurable slow down execution.」。再加上,Go 編譯器在每一次 POPCNT 前面都塞一條 XORL CX,CX,為的是修 Intel Sandy Bridge、Skylake 世代的一個假輸出依賴,這條指令在 AMD Zen 上其實用不到,但作者猜測,Go 是刻意不提供這種細緻的客製化,GOAMD64 只分 v1 到 v4 四個等級,粒度就是這麼粗。這個細節說明了架構泛用的實際代價:每次執行都多付一點點、在某些硬體上完全用不到的成本。
作者最後把話收得很收斂:「a powerful part of modern CPUs which allows speeding up the kind of computation that TurboPFor needs by an order of magnitude」(有能力把 TurboPFor 需要的這類運算加速一個數量級),重點是不用繞道 cgo 或組合語言就拿得到這種能力。純量版本的原生 Go encoder,作者說從動筆到寫完只花了幾天。優化到後段,解碼器的效能計數器顯示每個 cycle 能發出 7 條指令,在這台機器 8 IPC 的理論上限裡,已經算是把大部分等待都榨乾了。
如果只看結論,DCS 這條路徑給準備在自己專案裡用 Go SIMD 的人,還是留下幾個要先想清楚的代價:archsimd 目前是 experimental,API 隨時可能在後續版本改動;要吃到 SIMD 的紅利,實際上等於把部署目標釘在支援 AVX2 甚至 AVX512 的機器上,舊硬體只能走純量 fallback;就算全部條件都符合,跟針對同一批技巧手工調校過的 C 相比,仍有大約 1.4 倍的差距要接受。值得用在哪裡要先想清楚:對 DCS 這種吃索引解碼吞吐量的系統,不用 cgo 換來的維護單純,划算過那 1.4 倍。
算總帳:PGO 對齊陷阱、配置搬遷、generics 特化、AVX2 向量化、positional popcount,五輪優化疊起來,讓一個原本鎖在 C 函式庫裡的效能關鍵路徑,第一次能完全用 Go 寫、用 Go 測、用 Go 除錯,不必為了追平速度回頭寫組合語言或維護一份 cgo 綁定。