昨天講 vectorized execution「一次處理一批」。今天問一個看起來很問號,但其實藏很深的問題:batch是多少?
一個開源的列式關聯資料庫管理系統 DuckDB 選 2048,DataFusion 選 8192(arrow-rs 的 Parquet reader 另外預設 1024,兩件事分開),不是 100、不是 100 萬,就這種奇怪的 2 的次方
先把記憶體階層(Memory Hierarchy)提出來講一下
memory hierarchy 是電腦架構中用來平衡速度、容量與成本的設計概念
現代 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.md 給 batch_size 挑毛病時真正的那條,它不是退回 Volcano 逐列,而是踩在向量化上但腳沒踩實。
當一個批次高達百萬列且包含多個欄位時,運算產生的中間結果會直接超出 L2 容量(capacity miss / cache thrashing,跟 cold miss 是兩回事)。後續的每個運算子都必須重新前往 DRAM 取資料,讓前面透過 SIMD 省下的時間,全被記憶體延遲吃回去。
畫成圖大概是這樣:
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://arrow.apache.org/docs/format/Intro.html
一個 Schema,加上 N 個等長的 Array。每一欄就是一塊連續記憶體
存取第 i 列先算位址、再取值:
addr = base_ptr + (offset + i) * width
value = *addr
(offset 是 Array 自帶的起始位移)變長型別多查一次 offsets buffer,也還是常數時間。
同樣是一個 Schema 加 N 欄,但每一欄不再是單一 Array,而是一個 ChunkedArray ,裡面裝著若干個 Array。
關鍵在於:各欄的切法不必一致,三欄可以分別切成 2 塊、1 塊、3 塊,總長度仍然相同,但切點不對齊。
兩個實務理由:
一、變長 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
聽起來很划算吧,但代價很兇殘
隨機存取退化
第 i 列在哪一塊、塊內偏移多少,得先查累積長度表再二分搜尋,O(1) 變成 O(log n)。
每個運算子要多寫一層
對單一 array 做 filter 是一個緊湊迴圈,編譯器有機會自動向量化;對 ChunkedArray 就得外包一層走訪 chunk 的迴圈,而且每個 chunk 一進來都要重設指標、重新處理 Array.offset 造成的位元對齊,只要 Array 帶 offset(比如切片而來的 chunk),validity bitmap 就不再從 byte 邊界起頭。
兩欄一起算時最痛
因為切點不對齊,計算 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