iT邦幫忙

2026 iThome 鐵人賽

DAY 3
1

昨天講 vectorized execution「一次處理一批」。今天問一個看起來很問號,但其實藏很深的問題:batch是多少?

一個開源的列式關聯資料庫管理系統 DuckDB 選 2048,DataFusion 選 8192(arrow-rs 的 Parquet reader 另外預設 1024,兩件事分開),不是 100、不是 100 萬,就這種奇怪的 2 的次方

為什麼 batch size 不能隨便選

先把記憶體階層(Memory Hierarchy)提出來講一下
memory hierarchy 是電腦架構中用來平衡速度、容量與成本的設計概念
https://ithelp.ithome.com.tw/upload/images/20260819/201835443hG36Pdyst.jpg
現代 CPU 讀一個值的成本不是常數,而是看資料住在哪一層:

大小級距 存取延遲(典型)
L1d cache 32–64 KB / core ~1 ns
L2 cache 256 KB–1 MB / core ~3–10 ns
L3 cache 幾 MB–幾十 MB / socket ~10–30 ns
DRAM GB 級 ~100 ns

L1d 掉到 DRAM 差兩個數量級,在迴圈裡跑 SIMD vmulps,reciprocal throughput 每 0.5 cycle 可以發一條(latency ~4 cycle 另算),如果每次都得從 DRAM 拉資料,CPU 大半時間在等記憶體,SIMD 那顆油門根本沒踩下去。

這就決定了 batch size 的上下界

太小:向量化破功

假如每批只處理 10 列,虛擬呼叫、Schema 查找這類 iterator 級固定開銷其實還是被攤掉九成(本來就是「一批一次」)
真正壞掉的是兩個地方:一是 10 列在 8-lane SIMD 上是「1 個滿向量 + 2 列尾巴」,尾巴得走純量或 masked 路徑;而載入指標、對齊、收尾這些 prologue / epilogue 的固定成本只有 10 列可攤,佔比暴增,把向量化的效益稀釋掉。二是每個 RecordBatch 都帶著每欄的 array 結構、buffer 指標與配置,一批 10 列的攤提比一批 8192 列高上 800 倍,這也是 DataFusion configs.mdbatch_size 挑毛病時真正的那條,它不是退回 Volcano 逐列,而是踩在向量化上但腳沒踩實。

太大:快取崩潰,慘變 DRAM 搬運工

當一個批次高達百萬列且包含多個欄位時,運算產生的中間結果會直接超出 L2 容量(capacity miss / cache thrashing,跟 cold miss 是兩回事)。後續的每個運算子都必須重新前往 DRAM 取資料,讓前面透過 SIMD 省下的時間,全被記憶體延遲吃回去。

倒 U 曲線與 8192 這個折衷

畫成圖大概是這樣:

throughput
   ▲
   │        ╭─────╮
   │       ╱       ╲
   │      ╱         ╲
   │     ╱           ╲___________
   │    ╱
   │___╱
   └────┬──────┬────────────┬─────▶ batch size
       ~100  ~8k          ~100k
       攤提不足 甜蜜點      掉出 L2
       固定成本 cache 剛好  DRAM 主導

甜蜜點大約在 batch size × row width × 中間 buffer 加總 ≈ 幾百 KB 到幾 MB 之間——正好落在 L2、最多一腳踩到 L3 前緣。(L1d 通常只有 32–64 KB / core,塞不下一整批的中間結果。)

以 8192 列、每列 ~10 個 float32 欄、加上一些中間 buffer 抓 ~5 倍空間:

8192 × 40 byte × 5 ≈ 1.6 MB

大約落在 L2 到 L3 交界,這就是 8192 的物理直覺,是 plan tree 沿路每個運算子都塞得下 L2 那個等級的折衷(表格那格「L2 = 256 KB–1 MB / core」,1.6 MB 已經一腳踩到 L3 前緣)。

注意:8192 是列數,不是位元組。一欄 int8 跟一欄 struct 差很多,這正是今天思考題會問的方向。

有了一批多大,還要問「一批的形狀」

8192 列尺寸有了,但實際資料不管來自 Parquet 檔、Kafka topic、還是 JOIN 中間結果,通常都不是剛好 8192 列

那要怎麼分批表達呢? 其實 Arrow 在這裡有兩種形狀
https://ithelp.ithome.com.tw/upload/images/20260819/20183544LkSPPC6Aj0.png
https://arrow.apache.org/docs/format/Intro.html

RecordBatch:一批連續記憶體

一個 Schema,加上 N 個等長的 Array。每一欄就是一塊連續記憶體

存取第 i 列先算位址、再取值:

addr  = base_ptr + (offset + i) * width
value = *addr

offset 是 Array 自帶的起始位移)變長型別多查一次 offsets buffer,也還是常數時間。

Table:一整份表,但每欄是一串 batch

同樣是一個 Schema 加 N 欄,但每一欄不再是單一 Array,而是一個 ChunkedArray ,裡面裝著若干個 Array。

關鍵在於:各欄的切法不必一致,三欄可以分別切成 2 塊、1 塊、3 塊,總長度仍然相同,但切點不對齊。

為什麼會需要 Table

兩個實務理由:

一、變長 array 有大小上限
32-bit offsets 給 String / Binary 的 data buffer 設了 2GB 的天花板(offset 指的是 byte 位置);List 的 32-bit offsets 則是 2³¹ 個元素 的天花板(offset 指的是子元素索引,跟 byte 無關)。要突破這一層有兩條路:LargeString / LargeBinary / LargeList 換 64-bit offsets,或走 Arrow 15+ 起的 StringView / BinaryView,以多 buffer 的形式繞開單一 buffer 的上限。

二、資料通常是分批到達的
讀 Parquet 一個 row group、收一批網路封包,要把它們合成單一連續 array,得再複製一次做 concat;ChunkedArray 讓你直接把新到的 Array 掛上去,zero copy

聽起來很划算吧,但代價很兇殘

代價:跨 chunk 邊界

  1. 隨機存取退化
    i 列在哪一塊、塊內偏移多少,得先查累積長度表再二分搜尋,O(1) 變成 O(log n)。

  2. 每個運算子要多寫一層
    對單一 array 做 filter 是一個緊湊迴圈,編譯器有機會自動向量化;對 ChunkedArray 就得外包一層走訪 chunk 的迴圈,而且每個 chunk 一進來都要重設指標、重新處理 Array.offset 造成的位元對齊,只要 Array 帶 offset(比如切片而來的 chunk),validity bitmap 就不再從 byte 邊界起頭。

  3. 兩欄一起算時最痛
    因為切點不對齊,計算 A + B 不能一塊對一塊地做,要先求出兩邊 chunk 邊界的聯集,把資料切成一段一段兩邊都對齊的區間,再逐段處理。

所以跨 chunk 邊界的成本不只是多一層迴圈,而是每一個運算子都要處理切點不對齊這件事。

規格管到哪裡?

RecordBatch 在 Arrow 規格裡,Table 不在

RecordBatch 會離開位址空間(跨語言、跨行程),所以 Arrow 在記憶體裡怎麼擺必須全體同意。Table 與 ChunkedArray 則是特定實作(Arrow C++、PyArrow、Arrow Go)自己的資料結構,規格一個字都沒寫——這是規格層與實作層的刻意分工,不是漏寫。Arrow C++ 官方文件自己就把界線畫得很清楚:Table 與 ChunkedArray 是「C++ 實作層」的概念,不屬於 Arrow 格式,所以沒辦法直接跨實作攜帶。

證據就在官方文件裡: Java 的 Table 既不是 ChunkedArray 的集合,也不是 RecordBatch 的集合,而是一組 FieldVector 的不可變快照——像凍結版的 VectorSchemaRoot,官方 doc 寫著「目前不支援 ChunkedArray 或任何形式的 row group」。同一個名字、三種完全不同的結構,而這不構成任何問題,因為它從來不需要跟別人對上。

實務上用哪個:

用 Table / ChunkedArray 用 RecordBatch
PyArrow、Arrow C++、Arrow Go arrow-rs / DataFusion / Comet 全程
資料整份留在記憶體反覆查詢 資料流過去就丟掉
需要增量 append 一次處理一批
單欄超過 2GB 又不想換 large / view 變體 每批控制在 cache 容納得下的大小
對應 pandas DataFrame 的心智模型 對應「資料流的一格」

兩者可以互轉,而且換外殼的兩個方向都免費Table::FromRecordBatches 把一串 RecordBatch 合成 Table、Table.to_batches() 再拆回去,都不需要複製底層的 array buffer。要付錢的是併成單一連續 Array(combine_chunks / concat)那才是實打實的拷貝,也是上一節花三點在講「跨 chunk 邊界很貴」的同一件事。

明天預告

明天要看一個看起來很反骨的設計——DataFusion 是徹底的欄式引擎,但排序時偷偷用列式。這個「臨時列式化」到底發生什麼事?跟今天的 batch size 有什麼延伸關係?

那就明天見~

Reference:
https://datafusion.apache.org/user-guide/cli/usage.html
https://github.com/apache/datafusion/blob/main/docs/source/user-guide/configs.md
https://arrow.apache.org/rust/parquet/arrow/arrow_reader/struct.ArrowReaderBuilder.html
https://arrow.apache.org/docs/format/Columnar.html
https://arrow.apache.org/java/current/table.html
https://arrow.apache.org/docs/cpp/tables.html


上一篇
Day 02 向量化執行跟 SIMD,是同一件事嗎?
下一篇
Day 04 DataFusion 是欄式引擎,為什麼排序時要轉成列式?
系列文
1+1+1>3 ~ Spark 與 DataFusion、Comet 效能煉金術 ~6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言