D4 講排序時從欄式轉成列式,今天比的是兩套執行模型:向量化執行(DataFusion、Comet),對上 Spark 的 whole-stage codegen。
想像 filter → project → sum 是加工線上的三個站
做法 A:三個工人分站
第一站把整箱資料的不合格品挑掉,丟進一個中間箱
第二站再從中間箱拿出來,做欄位加減,成品丟到下一個中間箱
第三站再拿出來加總,每兩站之間都要一個中間箱。
做法 B:一個工人一氣呵成
一個工人拿一件東西,從第一步做到最後一步,中間不放下
做完這一件,才回去拿下一件
整條路上都在他手裡
差別不是哪個有用 SIMD、也不是哪個絕對比較快,差別只有一件事:上一個運算子算出來的中間結果,要不要先寫到一個中間箱、下一個運算子才拿得到
寫進中間箱這件事的術語叫物化(materialize),將抽象的查詢結果、計算過程或圖,實際轉換並以實體檔案、實體資料表的型態儲存在硬碟或記憶體中的技術
兩件事容易混成同一個詞,差在一次動多少列
沒有 WSCG 之前,JVM 上每一列都要爬過一串算子,每一層一次虛擬呼叫:
scan.next() → filter → project → agg
↑ 每一列都把這條鏈走完
WSCG 把這串寫進同一個 for 迴圈,判斷跟加減直接攤在迴圈裡,不再一層層 next():
for (int i = 0; i < n; i++) {
if (age[i] > 20) {
sum += salary[i];
}
}
注意 for 還是 i++,一次一列,但是向量化一次吃的是一批,中間結果放進 buffer,把呼叫鏈壓成一個迴圈,沒有變成一次處理一批
因為每個運算子典型上只認得一種輸入:RecordBatch
第一站算完,要變成第二站的輸入,中間就必須有一塊 buffer 把整批擺好。
三個運算子,兩次「寫進 buffer 再讀出來」。
因為它把三段程式碼合起來變成一個大迴圈:讀一列 → filter 判斷 → 通過就做 project → 結果加到 sum 的累計器上 → 再讀下一列
這一列的中間值走的是區域變數,JIT 理想狀況會把它們留在暫存器,不必先寫成一批 buffer。
a + b這種中間結果,沒有「先物化再給下一站」這一步,一列走完,才回頭拿下一列
三個理由:
當一批夠寬、SIMD 收益吃得掉那次多寫多讀的成本,A 就贏。
因為中間箱不是免費的:
當中間結果小、pipeline 短、資料還熱在 cache 裡,B 一路握在手裡就贏。
做法 B 聽起來很美,實際有兩個天花板:
HugeMethodLimit:預設 8000 bytecode,生成的 Java method 超過這個大小,JIT 預設不編譯,改跑解譯器。spark.sql.codegen.maxFields:預設 100,輸出欄位數(含巢狀欄)超過,Spark 就對那一段子樹關掉 WSCG。明天來做個小實驗,驗證 D2 向量化
Reference: