iT邦幫忙

2026 iThome 鐵人賽

DAY 11
1
Software Development

1+1+1>3 ~ Spark 與 DataFusion、Comet 效能煉金術 ~系列 第 11

Day 11 Parquet 晚物化(Late Materialization):先 WHERE 再解碼

  • 分享至 

  • xImage
  •  

嗨嗨~昨天 D10 走完 Iceberg 那條漏斗,catalog、metadata、manifest list、manifest 到 data file,一張表由幾百萬個 Parquet 組成,最後剪到剩下要打開的那幾個。收尾那句「已經決定打開這個 Parquet 了,頁面裡怎麼只解該解的列,是明天的晚物化」,就是今天要挖的這一截。

先看一句很短的 SQL:

SELECT s FROM t WHERE g = 5;

t 是 D8/D9 那份 500 萬列的 sortbench.parquetgid mod 8 的整數欄,s 是每列 32 byte 的 md5 字串,g = 5 平均只留 12.5%,也就是 62 萬列,其他 438 萬列不要。

兩條路可以走

  • 早物化,把 gs 兩欄整段解出來,套 filter 篩,把不要的 438 萬列 s 丟掉,解了才丟。
  • 晚物化,先只解 g,跑 filter 拿到「62 萬列在哪」的位圖,再回頭只解那 62 萬列的 s

兩條路的結果一模一樣,代價差 8 倍,差在有沒有把不會用到的 s 也解出來,而 s 是 32 byte 隨機字串、壓縮比只有 2 倍,解一列是純 memcpy 加 UTF-8 檢查,並不便宜。

今天要問四個問題:

  • 「物化」到底在物什麼,跟 D5 講的 batch 是同一件事嗎?
  • Parquet 上的晚物化怎麼做,漏斗最後這一截有幾步?
  • 兩個 predicate 誰先跑,順序由誰決定?
  • 什麼時候晚物化反而變慢?

先把「物化」講清楚

物化 = 把一個值變成記憶體裡真實存在、可以直接讀的一塊資料,來源可能是從磁碟解碼出來(本篇),也可能是運算子當場算出來(D5 的中間結果)。

Parquet 磁碟上不是明文的整數跟字串,而是 dictionary 編碼、bit-packing、RLE 之後再套 Snappy 壓縮的一坨 bytes,要讓 filter、TopK、hash join 這些運算子拿去比大小、做雜湊,得先解壓縮、解碼、還原成 Arrow 的 Int32Array / StringArray 塞進記憶體,這個「還原」的動作就是物化。

D5 講的是運算子中間結果(例如 hash join 的 build side)的物化,那是 CPU 在算的過程中順手產出一塊 Arrow buffer;今天講的是存取路徑上的物化,從磁碟 bytes 還原到記憶體 Array。兩件事共用「物化」這個詞,因為對下游運算子而言長得一樣,都是「可以直接讀的一塊 Arrow 資料」。

D9 說 Parquet「只讀該讀的位元組」有兩個條件,projection 對得到 column chunk、predicate 對得到 footer 的 min/max,今天要多加一條:物化的時機對得到 filter,前兩條決定讀哪些 bytes,這一條決定讀進來的 bytes 之中,要 decode 哪些列。

早物化 vs 晚物化:同一句 SQL,兩條路徑

拆開 SELECT s FROM t WHERE g = 5 的兩個 plan。

早物化 plan

  1. gs 兩欄,每欄 5 個 row group 全解碼,成 Int32ArrayStringArray
  2. g = 5,得到 selection vector v。
  3. 用 v 挑 s 裡符合的 62 萬列,丟出去。

s 有 500 萬列 × 32 byte = 160 MB 未壓縮,全部要物化到記憶體,然後丟掉 87.5%。

晚物化 plan

  1. 只讀 g 那一欄,解碼。
  2. g = 5,得到 selection vector v(62 萬個 true)。
  3. s 那一欄的原始 bytes,用 v 當導引,只解 v = true 的 62 萬列所在的位置

s 只解 62 萬列 × 32 byte = 20 MB,g 兩條 plan 都要全解,但 g 是 8 個相異值的 dictionary,全解也才 2.6 KB,可以忽略,差距全在 s,160 MB 對上 20 MB 就是 8 倍。

省的不是 I/O,該讀的 page 還是要 range GET 進來,省的是 decode CPU,D2 那條 vectorized loop 寫成的核心熱點一次少了 8 倍。這也是為什麼晚物化在 OLAP 場景幾乎是預設選項,query 的 selectivity 越低,省得越多。

Parquet 上的四步驟

真實 predicate 常常有兩個 conjunct,例如 WHERE A > 35 AND B = 'F',Arrow blog 那張圖畫的四步驟:

  1. 只讀 filter 欄的 page,從 Parquet 讀 AB 兩欄的相關頁,D9 的 footer min/max 跟 page index 已經先剪過一輪,A 那些整段 max < 35 的頁根本不會被讀進來,B 沒有 'F' 的頁也一樣。
  2. 解碼 A,跑第一個 predicate,解碼 A,套 A > 35,得到 selection vector v1。
  3. 解碼 B,跑第二個 predicate,跟 v1 交集,解碼 B(有些實作為了省事直接全解 B 再算 AND,更精細的做法只解 v1 為 true 的位置),套 B = 'F',跟 v1 交集得到 v2。
  4. 依 v2 只讀 payload 欄,這時候才回頭讀 SELECT 列表裡其他欄(例如 id, s),v2 對到 page 上,整頁沒 true 的頁跳過,有 true 的頁只 decode 該解的列

DataFusion 這條路走的是 datafusion/datasource-parquet/src/row_filter.rs,EXPLAIN ANALYZE 打開會看到 pushdown_rows_matched 這種指標,它印的就是 v2 剩多少列,比 row_groups_pruned_statistics 更細一級。

從 D10 那條漏斗看,這是第三階段,D10 剪 data file、D9 剪 row group、今天在 page 內剪列,三階段做的是同一件事,證明一定沒有就跳過,只是尺度越縮越小,同構重複。

順序由誰決定

思考題那句 A > 35 AND B = 'F',為什麼是先跑 A 還是先跑 B,答案兩層。

選擇性:越早剪掉越多列越好。假設 B = 'F' 只留 5%、A > 35 只留 50%,先跑 B = 'F' 讓後面只要對 5% 的位置跑 A > 35,反過來要對 50% 的位置跑 B = 'F',是十倍的差距。planner 拿 column stats 估選擇性,行話叫 selectivity estimation。

解碼成本:解碼便宜的先跑。int32 比 varchar 便宜,dictionary 編碼比 plain 便宜,有 page-level stats 可以整頁跳過的更便宜,DataFusion 的 RowFilter 會用 PhysicalExpr::analyze 收集這些成本。

兩件事會打架。A > 35 選擇性 50%、B = 'F' 選擇性 5%,照選擇性應該先 B,但如果 A 是 int32、B 是 varchar 沒有 dictionary、要跑整頁 UTF-8 檢查,解 B 每列的成本可能是 A 的十倍,這時候「花貴的成本剪 95%」跟「花便宜的成本剪 50% 再花貴的成本剪 90%」哪邊快,得看常數。

實務上 DataFusion 沒有 join 那種完整的 cost model,用啟發式,把 filter 表達式按估計選擇性 × 估計解碼成本排序,選擇性高、成本低的先跑,夠用,不完美,這也是為什麼手動改寫 predicate 順序有時候會被引擎重排掉、有時候會被保留,不是隨機,是啟發式的裁量邊界。

為什麼欄式引擎特別受惠

「只讀 filter 欄、不讀 payload 欄」對欄式引擎不是額外的功夫,是檔案格式天然支援。Parquet 每一欄的 column chunk 是分開放的,讀 g 那段 bytes 完全不需要碰 s 的位址空間,第一步「只讀 filter 欄的 page」在欄式檔上是免費的。

列式引擎做不到,一列所有欄擠在一起(row-oriented layout),讀 filter 欄那幾個 byte 就等於把整列 payload 的 byte 也讀進來了,I/O 已經付了,CPU 再跳過不要的欄也回不了本,晚物化在列式引擎上頂多能省 decode,省不了 I/O。

D1 講「Arrow / 欄式為什麼快」時著重在 CPU vectorized,這裡是同一件事在存取路徑上的表現,欄式讓 I/O 跟 decode 都能按 predicate 縮,跟 SIMD 是兩條互不干擾的加成,都受欄式布局所賜。

晚物化什麼時候反而慢

三個常見翻車情境,逆著上面的假設看就清楚了:

  1. 選擇性很低(predicate 幾乎不剪)WHERE g >= 0(全留)或 WHERE g < 100(留 99%),晚物化多跑一趟,先解 filter 欄、算 selection vector、再解 payload,省下的 decode 不多,卻付了一次 filter 開銷跟一次額外的頁定位。planner 偵測到選擇性太高就退回早物化。
  2. filter 欄比 payload 欄還貴WHERE md5(s) LIKE 'abc%',解 s、每列算 md5,再拿去挑一個 int 欄,filter 欄的成本已經蓋過整個 payload,晚物化白搭,這種 predicate 上做 pushdown 反而不如把 filter 拉到 payload 解完之後跑。
  3. selection vector 稀疏但分佈很散:62 萬列剪出來很集中好,如果 5% 平均散在每一頁,每個 page 都得打開、每 page 只解幾列,Snappy 解壓縮跟 dictionary 頁 lookup 這種整頁級的固定成本吃不消。

三種情境的共同點是,晚物化的獲益來自「不解該解的以外的列」,當 filter 欄本來就得全解、payload 本來就便宜、或 selection 稀疏到每頁都得碰,那個「不解」就撈不到本。這也是為什麼 DataFusion 沒把晚物化寫死,RowFilter 是一個 pushdown 選項,planner 判斷值得才會啟用。

那就明天見~

漏斗到這裡收尾
D10 剪枝 data file、D9 剪枝 row group、今天在 page 內只解該解的列,三層剪枝背後同一個公式:證明一定沒有就跳過

漏斗還有一個小尾巴要接,晚物化拿到的 selection vector 還不是最終要輸出的位圖,還要跟 delete file 交集,Iceberg 的 positional delete 講「這個 data file 的第 42、73、118 列被刪了」,equality delete 講「所有 id = 999 的列被刪了」,SELECT 真正吐出的 = predicate 剩下的 減 被刪掉的,檔案不能改的時候「這列被刪了」寫在哪、怎麼跟 selection vector 交集,是明天 D12 的題。

留一個沒有標準答案的問題:A > 35 AND B = 'F',如果 A > 35 選擇性 50%、B = 'F' 選擇性 5%,但 A 是 int32、B 是 varchar,兩件事哪個先做?提示,選擇性、解碼成本、有沒有 bloom filter / page index 可以幫忙判斷,答案不只一種。

那就明天見~


上一篇
Day 10 Iceberg 四層結構:讀一張表要打開幾個檔案
下一篇
Day 12 Iceberg 怎麼刪一列:從 delete file 到 deletion vector
系列文
1+1+1>3 ~ Spark 與 DataFusion、Comet 效能煉金術 ~21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言