iT邦幫忙

2026 iThome 鐵人賽

DAY 25
1

D24 講 dispatcher 的 canHandle 有一條:輸出型別要通過 isSupportedDataType,而這個集合的邊界就是「Arrow FFI 能跨的型別」

Arrow C Data Interface 是把列式資料在同一個process內,從一種語言或 runtime 傳遞到另一種的邊界。規格的 Non-goals 寫得很直白:不處理不同進程之間的資料共享,也不處理儲存持久化。這不是取捨,是物理限制,因為它傳的是裸指標,裸指標本來就跨不了位址空間。跨進程、跨機器那條路屬於 Arrow IPC format,兩者互補而不是替代。

Comet 的 JVM 側跟 Rust 側就靠這條contract傳 batch,過的是 JNI 這條線。要看清楚這條邊界,只需要盯住三件事:contract長什麼樣、零拷貝為什麼可能、記憶體是誰配置誰回收。

contract:兩個 struct,外加一層 stream

Arrow C Data Interface 的規格出乎意料的小,Structure definitions 那節就兩個 struct:

ArrowSchema 描述型別與欄位名,一次資料串流只需要傳一次,開頭給對方看,接下來的 batch 都照這份 schema 解。

ArrowArray 描述一個 array 的資料,裡面有一組 buffer 指標、lengthnull_countoffset,以及巢狀型別的 children 和字典型別的 dictionary。這是實際的資料載體。

offset 這個欄位等一下會回來咬人,先記著:它是這個 array 在物理 buffer 起點之後的邏輯位移。規格允許 producer 宣告自己只產出 offset 為 0 的 array,也允許 consumer 直接不支援非零 offset,只要各自把限制寫進文件。

兩個 struct 都有一個 release 函式指標欄位,這個欄位就是待會要講的所有權contract的支點。

還有第三個常被一起講的東西:ArrowArrayStream
嚴格說它不屬於 C Data Interface,而是定義在另一份規格 Arrow C Stream Interface,在官方文件裡是跟 C data interface 並列的獨立頁面。它把多個 ArrowArray 串成 pull-based iterator,提供 get_schemaget_nextget_last_error,以及自己的 release

get_last_error 這個欄位值得注意,它是 stream 跟兩個裸 struct 最大的差別:stream 有錯誤傳遞機制,ArrowSchemaArrowArray 沒有。要在跨語言邊界上傳「這批資料讀失敗了」,只靠 C Data Interface 是做不到的。

零拷貝為什麼可能

ArrowArray.buffers 的型別是 const void**,指向一組 buffer 的起始位址。跨語言傳 ArrowArray 時,傳的其實是這些指標與 metadata,不是 buffer 內容。

兩邊之所以能各自解讀同一塊 buffer,是因為 Arrow 有一份公開的 columnar 記憶體佈局規格。第 N 個 buffer 是什麼(validity bitmap、offset array、data array),依型別而定,兩邊照譜解讀就對得起來。JVM 側配置的 off-heap 記憶體,Rust 側直接讀,不用複製一份。

這件事的前提是兩邊都遵守 Arrow 記憶體佈局,這也是為什麼 Arrow 這個生態必須先有 D1 講過的 columnar spec,才會有 C Data Interface 這條contract,沒有前者,後者無法成立。

規格另外建議:匯出的資料,producer 跟 consumer 雙方都應該當成不可變的。因為沒有任何機制能強制這件事,一旦有人偷改,另一邊就會讀到不一致的狀態。跨語言邊界上的不可變是靠約定維持的,不是靠型別系統。

Release callback:所有權移轉的機制

零拷貝解決了資料怎麼跨語言,還留一個更難的問題:這塊記憶體算誰的、誰負責釋放?

Arrow C 用 release callback 這一招把問題結束掉。規則簡單但嚴格,而且分成三層來看比較好記。

struct 殼由消費方配置。 規格說 base structure 預期由消費方在 stack 或 heap 上配置,producer API 收的是一個指向消費方已配置結構的指標。這點等一下看 Comet 回傳路徑時會突然變得很具體。

struct 指到的資料由生產方配置與維護,release callback 也由生產方寫。 format 字串、metadata、buffer 指標陣列、children 陣列,全部是 producer 的東西。callback 裡放這塊記憶體要怎麼回收,例如呼叫 free、drop 對應的 Rust struct、通知 JVM off-heap allocator 等等。消費方唯一能影響資料生命週期的手段,就是呼叫 base structure 的 release

消費方用完呼叫 array->release(array),而且只能呼叫這一個。 規格明確禁止消費方去呼叫任何 children(含 dictionary)的 release callback,釋放 children 是 producer 的責任。Producer 寫的 callback 則必須遞迴走完所有 children 與 dictionary、釋放 struct 直接擁有的資料區(buffers 陣列跟 children 陣列本身),最後把 release 設成 NULL。呼叫過之後消費方也不准再碰這個 struct 或它底下的任何東西。

那個設 NULL 的動作就是防 double free 的標記:規格規定,看到 release 是 NULL 就代表這個 struct 已經被釋放了,消費方在解讀任何 struct 之前都應該先檢查這件事。

還有一條很容易誤解的:允許 move,不允許 copy。消費方可以把整個 ArrowArray 位元複製或逐成員淺複製到別的位置,但複製完必須立刻把來源標記為已釋放,而且是直接把 release 設成 NULL,不是呼叫 release。這樣任何時刻都只有一份存活的 struct,生命週期資訊才不會分岔。也因為要支援 move,struct 內的指標成員(含 private_data)不能指向 struct 自己內部,producer 也不能私下另存一份指向該 struct 的外部指標。

用一句話總結:release callback 是「這塊記憶體是我借你的,你用完喊一聲我來收」的協定。誰借誰收,一次講清楚,跨語言記憶體管理最痛的兩種錯(double free 跟 leak)就有邊界可循。

Comet 的兩個方向並不對稱

Comet 的 JVM ↔ Rust 有兩個方向,所有權規則一樣,但粒度不一樣。這件事在 0.17 之前不成立,是後來才長出來的不對稱。

JVM 送資料給 Rust(0.17 起): Spark 端把 Arrow-backed 的 ColumnarBatch 包成 RDD[ArrowArrayStream],一個 partition 一條 stream;native 側的 ScanExec 拿 stream 的 memoryAddress,用 from_raw 接上去,一顆一顆拉 batch。release callback 由 JVM 側寫,記憶體最終回到 JVM off-heap allocator。

0.17 之前這裡走的是 CometBatchIterator,一種逐 array 傳位址的自訂協定,PR #4572 才把它換成 C Stream Interface。

Rust 把結果送回 JVM: 這個方向到今天都還不走 stream,而是逐欄位的 C Data Interface。JVM 先替每一欄配置好 ArrowArrayArrowSchema 的 struct 殼,把兩個位址陣列一起傳進 JNI;Rust 端 prepare_output 對每個 column 呼叫 move_to_spark,內容是 FFI_ArrowArray::new(...) 之後直接把 struct 寫進 JVM 給的位址。一個 batch 有幾欄就寫幾組 struct。

看到沒有,這正是「struct 殼由消費方配置」那條規則的實作。JVM 是消費方,所以 JVM 出殼;Rust 是生產方,所以 Rust 填內容並寫 release callback,callback 做的事是 drop 掛在 private_data 上的 ArrayData,連帶把底層 Arc<Buffer> 的 refcount 減一。JVM 用完喊 release。

兩邊都不去 free 對方的記憶體,只呼叫對方留下來的 release。整條 JNI 邊界沒有跨語言的 GC 協調,也沒有共用的 allocator,靠的就是這條contract寫死誰負責。

零拷貝不等於零成本

Comet 0.17 有一條 release note:把 CometDecodedVector 的 validity buffer 位址快取起來。

翻進去看改動其實很小。isNullAt 原本每次呼叫都要走一次 Arrow Java 的 getValidityBuffer().memoryAddress();改完之後在建構子取一次存成 final 欄位,之後直接 Platform.getByte(null, validityBufferAddress + byteIndex)。而且還多做一層:validityByteCache 把剛讀到的那個 validity byte 存起來,同一個 byte 涵蓋的 8 個 rowId 只讀一次記憶體。

要說清楚的是,這條優化發生在 FFI import 之後的讀取路徑上,避掉的是 Arrow Java accessor 鏈,不是 C struct 的指標鏈。但它暴露的問題完全屬於這條邊界:FFI 給你的是一組指標與 metadata,它把「資料在哪裡」告訴你了,沒有把「怎麼便宜地找到它」一起給你。 零拷貝解決的是不用複製,不是不用找。找的成本要不要攤掉、攤得多乾淨,完全看兩邊 wrapper 的實作願不願意做這種快取。

更直白的反例在回傳路徑上。prepare_output 遇到 offset != 0 的 array 時,會先做一次 take 把它重排成 offset 為 0 的新 array 再匯出,原因是非零 offset 的 FFI 有 bug(issue #2051)。這是貨真價實的一次完整複製,發生在一條號稱零拷貝的邊界上,只是被歸在冷路徑而已。

所以「零拷貝」比較準確的說法是:規格保證的是不需要複製,不是保證沒有人複製。 規格允許 consumer 不支援非零 offset,實作就真的會在那裡退回複製。

結論

Arrow C Data Interface 用兩個 struct 加一條 release callback,把同一個進程內跨語言傳列式資料這件事講清楚:零拷貝靠 Arrow 記憶體佈局公開對齊,所有權靠 release callback 寫死誰配置誰回收,殼由消費方出、內容由生產方填。這條contract是 Comet 這種 native accelerator 能存在的必要基礎設施。至於零拷貝之後還剩多少成本,0.17 那條 validity buffer 快取,跟回傳路徑上那次 take,都是在還這筆帳。

寫在這裡的版本細節(0.17 換成 C Stream Interface、prepare_output 的逐欄位匯出)隨時可能被改掉。不會變的是底下那條規則:跨語言邊界上,記憶體的所有權必須被明確寫死在contract裡,否則不是 leak 就是 double free。C Data Interface 選擇把它寫成一個函式指標,這個選擇比任何一版實作都活得久。

那就明天見~

參考資料


上一篇
Day 24 Comet codegen dispatcher:getSupportLevel、canHandle 與修法時機
下一篇
Day 26 記憶體怎麼跨 runtime 對帳:unified pool 與那些對不準的地方
系列文
1+1+1>3 ~ Spark 與 DataFusion、Comet 效能煉金術 ~26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言