昨天 D22 講的是 Comet 決定不接手某個 operator 時的診斷子系統,理由怎麼被記錄下來,今天講另一半:Comet 決定接手之後,這個 operator 要怎麼翻成 Rust 看得懂
這是 D15 六接點的第二個,protobuf IR。看起來是純技術題,其實藏了一個很大的選擇。Gluten 走 Substrait,一個社群主導的跨引擎標準。Comet 自己刻一份 protobuf。表面上像重複造輪子,實際上兩邊在解不同的問題。
Substrait 是一份跨引擎的關聯代數規格,用 protobuf 定義。目標是任何引擎都能吃、都能吐。
舉個例子。SELECT a + b FROM t WHERE c > 10 這個查詢,翻成 Substrait 大概是一棵這樣的樹:
ProjectRel (a + b)
+- FilterRel (c > 10)
+- ReadRel (t)
ProjectRel、FilterRel、ReadRel 都是規格裡定義好的 message,+ 跟 > 對到標準函式庫裡的 add 跟 gt。理論上你在 Ibis 產出這棵樹,DuckDB、Velox、DataFusion 誰都能接手去跑。
Gluten 是 Substrait 最大的實用案例。Spark plan 攔截下來後轉成 Substrait,交給 Velox 或 ClickHouse 執行。理論上抽掉 Velox 改插 ClickHouse,Spark 到 Substrait 的那段轉換不用重寫。
Comet 選了完全相反的路。native/proto/src/proto/ 底下四個檔案(operator.proto、expr.proto、types.proto、partitioning.proto),每個 Spark operator、每個表達式都有對應的 message。JVM 側把 physical plan 翻成這些 message,過 JNI 送到 Rust 側,DataFusion 再 build 成 ExecutionPlan。
這份 protobuf 沒有任何跨引擎的野心。訊息定義貼著 Spark 語義寫,需要什麼欄位就開什麼欄位,不會為了「將來也許 Velox 也吃」而做通用抽象。
| 面向 | Substrait | Comet protobuf |
|---|---|---|
| 目標 | 跨引擎的關聯代數標準 | Spark 到 DataFusion 的傳送格式 |
| 覆蓋範圍 | 全引擎共通子集 | Spark 語義照抄 |
| 演進節奏 | 社群討論、標準委員會 | Comet 自己決定 |
| 抽換 native 後端 | 轉換層不用重寫 | 綁死 DataFusion |
| 使用者 | Velox、Gluten、DuckDB、Ibis 等 | 只有 Comet |
一句話:Substrait 是通用 IR,Comet protobuf 是專用管道。
通用性不是免費,而且代價比想像中重。Gluten 不是「用 Substrait 加幾個 extension」,是直接 fork 了 Substrait 的 proto 檔,base 版本鎖在 v0.23.0,然後自己往裡面加東西。看幾個具體的:
Spark 有的 operator,Substrait 沒有,就自己加一個 Rel。 Spark 的 Expand(GROUPING SETS / CUBE 會用到)在 Substrait 裡沒有對應,Gluten 加了 ExpandRel。同樣被加進去的還有 WindowRel、GenerateRel、TopNRel、WriteRel。
語義對不上,就改上游的定義。 Substrait 原本只有一個 JOIN_TYPE_SEMI,但 Spark 分左右,所以 Gluten 把它拆成 JOIN_TYPE_LEFT_SEMI 跟 JOIN_TYPE_RIGHT_SEMI。型別那邊也加了一個 Substrait 沒有的 Nothing。
Gluten 自己的文件寫得很清楚:能推回上游的就推,推不動的才考慮 AdvancedExtension。所以 extension 是退路,不是主要手段。
fork 之後跟不上上游,會撞車。 這是最好的例子。Gluten 早期把 bucket_spec 接在 WriteRel 的 field 7 上,因為那時候 7 是空的。上游 v0.98.0 後來把 field 7 指派給 common,號碼直接撞上。Gluten 只好把 bucket_spec 搬到 1000,並且立下新規矩:自己加的欄位一律從 1000 起跳,避開上游會用到的區間。
這件事很說明問題。你 fork 了一份標準,你就同時失去了標準的好處,又背上了追上游的義務。
好處當然還是有。Substrait 的生態互通不是假的,Gluten 押的是「Spark 之外也有需求,這個投資會攤下去」。只是帳單比廣告上寫的長。
Comet 要橋接的兩端從一開始就寫死了:上游只有 Spark,下游只有 DataFusion。沒有「將來把 DataFusion 抽掉改插 Velox」的故事,也沒有「Ibis 也能吐 Comet plan」的野心。
在這個範圍內,Substrait 的通用性沒有回報。要付的代價全都要付,收到的好處用不到。
反過來,自訂 protobuf 在這個範圍內幾乎零阻力。Spark 加一個新運算子,Comet 加一個 message;Spark 改一個語義行為,Comet 改對應 message,不用等社群開會,也不用擔心撞到別人的 field 號碼。目前 Comet 有二十幾個 operator serde、幾百個表達式全部照 Spark 語義刻進去,這件事在 Substrait 上只會更慢。
如果只能記一件事,就記這個:這份 IR 要服務幾種引擎。
Substrait 押通用,Gluten 收到多引擎可攜性,付 fork 與對齊的稅。Comet 押貼身,收到簡潔與速度,付「這份 IR 沒有別人能用」的封閉性。
這不是誰對誰錯,是對「這份 IR 要服務幾種引擎」的判斷不同。
Substrait 是通用 IR,Comet protobuf 是專用管道。差別不在技術優劣,在這份 IR 要服務幾種引擎。Gluten 押跨引擎攤成本,代價是 fork 一份標準之後還要追上游;Comet 押 Spark 專用求速度,代價是這份 IR 只有自己能用。
明天 D24 談 Arrow C Data Interface。protobuf 傳的是 plan,資料還沒動。當 Rust 算完,Arrow batch 要怎麼跨語言傳回 JVM 而不複製?這是六接點的第三個。
那就明天見~
bucket_spec 撞號始末): https://github.com/apache/gluten/blob/main/docs/developers/SubstraitModifications.md
native/proto/src/proto/: https://github.com/apache/datafusion-comet/tree/main/native/proto/src/proto
CometExecRule.scala(operator serde 註冊表): https://github.com/apache/datafusion-comet/blob/main/spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala
datafusion/substrait/(producer 與 consumer): https://github.com/apache/datafusion/tree/main/datafusion/substrait