本文梳理了当前主流 JVM 查询引擎(Presto、Spark)集成 C++ 向量化执行引擎的三种架构模式,从通信机制、Fallback 策略、线程模型、语义兼容性等维度进行对比分析,帮助读者建立选型的思维框架。

为什么 JVM 引擎需要 Native 执行?

大规模数据处理领域的主流引擎——Presto、Spark、Flink——都运行在 JVM 上。JVM 提供了跨平台能力和丰富的生态,但在执行层面临几个根本性的性能限制:

内存布局不可控。JVM 的对象模型带来 header 开销和不可预测的内存布局,难以实现对 CPU cache 友好的 columnar 数据排列。手动管理 off-heap 内存虽然可以绕过这个问题,但增加了工程复杂度。

难以利用硬件指令。SIMD(如 AVX-256/512)是向量化执行的关键加速手段,但在 JVM 中使用 SIMD 需要深入了解 JIT 编译器内部细节,且效果不稳定。正如 Photon 论文所指出的,实现使用 SIMD 指令或 prefetch 数据的 operator,如果不深入了解 JVM 编译器内部实现几乎不可能做到 [1]

JIT 编译器的隐性限制。Spark 2.0 引入的 Whole Stage Code Generation 通过将多个 operator 融合成单个函数来消除虚函数调用开销。但 JVM JIT 编译器对方法大小存在内部限制,当 codegen 生成的方法过大时,JVM 优化会 bail out,造成难以诊断的性能悬崖 [1]

性能可预测性差。GC 停顿、JIT 编译的 warmup、以及 JVM 多层抽象使得性能分析和调优困难。相比之下,C++ native code 的内存管理和执行行为都在显式控制之下,性能更容易解释和优化 [1]

但 JVM 引擎的价值显然不在执行层。Presto 和 Spark 的核心竞争力是:成熟的分布式调度、经过大规模生产验证的 SQL 优化器(如 Spark 的 Catalyst)、庞大的生态系统和用户基础。完全抛弃 JVM 重写一个引擎的成本和风险都太高。

因此,核心问题变成了:如何在保留 JVM 引擎上层能力的同时,把数据密集的执行层下沉到 C++ native 代码? 不同的集成方式本质上是在选择 JVM 与 native 之间的边界画在哪里、以什么方式通信。


三种集成架构模式

模式 A:整体进程替换

代表项目:Prestissimo(Presto C++ Worker)

这是最"彻底"的方案。Presto 的分布式架构天然分为 Coordinator(负责 SQL 解析、优化、调度)和 Worker(负责实际数据处理)两个角色,它们之间通过 REST HTTP 通信。Prestissimo 的思路很直接——用 C++ 重写整个 Worker 进程。

graph TB
    subgraph "Java Coordinator"
        SQL[SQL 解析 / 优化 / 调度]
    end

    subgraph "C++ Worker(Prestissimo)"
        REST[REST API
Proxygen HTTP] Translate[Plan Fragment → Velox Plan] subgraph "Velox Runtime" D1[Driver 1] --> Pipeline1[Operator Pipeline] D2[Driver 2] --> Pipeline2[Operator Pipeline] D3[Driver N] --> PipelineN[Operator Pipeline] end end SQL -- "HTTP: Plan Fragment" --> REST REST --> Translate Translate --> D1 Translate --> D2 Translate --> D3 style SQL fill:#e8d5b7 style REST fill:#b7d5e8 style Translate fill:#b7d5e8 style D1 fill:#b7e8c4 style D2 fill:#b7e8c4 style D3 fill:#b7e8c4

图 1:模式 A 架构。Java Coordinator 通过 HTTP 发送 plan fragment 给 C++ Worker。Worker 内部完全由 Velox 接管,多个 Driver 并行执行。JVM-Native 边界在进程间。

Prestissimo 通过 Proxygen C++ HTTP 框架实现了与 Java Worker 完全相同的 REST endpoints。由于 Coordinator 和 Worker 之间的通信协议不变,C++ Worker 对 Coordinator 完全透明——这是一个真正的 drop-in replacement [2]

这种架构的一个重要特征是 Worker 节点上不需要 JVM。所有数据处理都在纯 C++ 环境下进行,Velox 可以完全掌控内存管理、线程调度和 CPU 指令的使用。Worker 接收到 Presto plan fragment 后,直接翻译为 Velox plan node tree,创建多个 Driver 并行执行 operator pipeline [3]

这里的"并行"不是指多个 Spark task 的并行,而是 Velox 原生的 morsel-driven parallelism——同一个 pipeline 内,多个 Driver 线程可以并行消费不同的数据分片(morsel),并通过 work stealing 实现动态负载均衡。这一调度能力是这种架构独有的优势。

值得注意的是,Prestissimo 方案之所以可行,很大程度上是因为 Presto 的 Coordinator-Worker 架构本身就通过 REST API 做了清晰的进程间隔离。如果原始引擎的组件间耦合更紧密(比如 Spark 的 driver-executor 之间不仅通过网络通信,还共享大量 JVM 层面的抽象),这种整体替换就不那么容易了。


模式 B:JNI In-Process 算子替换

代表项目:Databricks Photon、Apache Gluten + Velox/ClickHouse

与整体替换不同,这种模式选择在 Spark JVM 进程内部 引入 native 执行。Native library 被加载进 JVM 进程,通过 JNI(Java Native Interface)与 Spark 交互。Spark 的 Catalyst optimizer 正常工作不变,但在 physical plan 执行阶段,runtime 会判断哪些 operator 可以在 native 引擎中执行,不支持的 operator 则回退到 JVM。

graph TB
    subgraph "Spark JVM 进程"
        subgraph "JVM 侧"
            Catalyst[Catalyst Optimizer]
            PhysPlan[Physical Plan]
            Fallback[JVM Fallback
Row-based 执行] end subgraph "Native 侧(通过 JNI 加载)" NativeExec[Native Vectorized
Execution] OffHeap[(Off-Heap Memory)] end Catalyst --> PhysPlan PhysPlan -- "支持的 Operator" --> NativeExec PhysPlan -- "不支持的 Operator" --> Fallback NativeExec <--> |"JNI + 指针"| OffHeap Fallback <-.-> |"columnar ↔ row 转换"| NativeExec end style Catalyst fill:#e8d5b7 style PhysPlan fill:#e8d5b7 style Fallback fill:#e8d5b7 style NativeExec fill:#b7e8c4 style OffHeap fill:#b7e8c4

图 2:模式 B 架构。Native 引擎加载进 JVM 进程,通过 JNI 通信。支持的 operator 走 native 向量化执行,不支持的回退到 JVM。Fallback 边界处发生 columnar ↔ row 格式转换。

Photon 是这种模式的先行者。它作为 native library 被加载进 JVM 进程,Spark 和 Photon 之间通过 JNI 传递指向 off-heap memory 的数据指针。Photon 还与 Spark 的 memory manager 集成,在混合执行计划中协调 spilling [4]

但这种模式下的 native 引擎在 线程模型上受到 Spark 的约束。Spark 的 task 模型是 one task = one thread = one partition——Spark 自己决定并行度,通过启动大量 task 来处理不同的 partition。这意味着 native 引擎(无论是 Photon 还是 Velox)在每个 Spark task 内部以单线程运行,Velox 原生的 morsel-driven 并行调度能力在这种模式下基本无法发挥。

紧耦合 vs 松耦合:Photon 和 Gluten 的路线分歧

虽然 Photon 和 Gluten 在架构模式上相同,但它们在耦合策略上做了不同的选择。

graph LR
    subgraph "Photon(紧耦合)"
        CP1[Catalyst
Physical Plan] --> |直接映射| P[Photon
Operators] end subgraph "Gluten(松耦合)" CP2[Catalyst
Physical Plan] --> |翻译| S[Substrait IR] --> |翻译| V[Velox / ClickHouse] end style CP1 fill:#e8d5b7 style P fill:#d5b7e8 style CP2 fill:#e8d5b7 style S fill:#e8e8b7 style V fill:#b7e8c4

图 3:两种耦合策略对比。Photon 直接映射 Catalyst plan 到自有 operator;Gluten 通过 Substrait IR 解耦,支持可插拔后端。

Photon 走紧耦合路线。 Catalyst physical plan 直接映射到 Photon 自有的 operator 实现,无中间表示层。Photon 就是为 Databricks Runtime(DBR,Databricks 的 Spark fork)量身定做的,这让它可以做更深的优化——比如将 sort merge join 替换为 hash join [1] 。但代价是与 DBR 完全绑定,闭源,用户只能在 Databricks 平台上使用。

Gluten 走松耦合路线。 它在 Spark 和 native 引擎之间引入了 Substrait——一种跨语言的标准化查询计划表示——作为中间层。Spark physical plan 先翻译为 Substrait plan,再翻译为底层引擎(Velox 或 ClickHouse)的 native plan [5] 。这带来了 backend 可插拔的灵活性,也让 Gluten 成为一个开源的、多方参与的项目(Apache 顶级项目,Intel 主导,IBM、Meta、ByteDance、Microsoft 等参与贡献)。

Microsoft 的 Fabric 平台已经在用 Gluten + Velox 作为 Spark 的 native execution engine,并进入 public preview [6]

这两种策略的取舍可以用下表总结:

维度Photon(紧耦合)Gluten + Velox/CH(松耦合)
Plan 翻译路径Physical Plan → Photon OperatorsPhysical Plan → Substrait → Velox/CH
优化空间更大,可改写 plan 结构受 Substrait 表达能力限制
与 Spark 的关系与 DBR fork 深度绑定通过 IR 解耦,支持开源 Spark
Backend仅 Photon可插拔(Velox、ClickHouse)
开源性闭源Apache Incubating
核心取舍专用优化深度通用可组合性

这本质上是系统设计中一个经典的 trade-off:专用系统 vs 通用平台。Photon 可以看作是一个为 Spark 生态深度定制的 native 引擎;Gluten + Velox 则试图建立一个引擎无关的、可复用的加速层。


模式 C:外部进程 Offload

代表项目:Spruce / SparkCpp(Meta 内部)

这是一种工程上最轻量的方案。Meta 内部的 Spruce 项目利用 Spark 已有的 script transform 接口,将执行 offload 到 Spark executor 旁的独立 C++ 进程 [7]

graph LR
    subgraph "Spark JVM Executor"
        Task[Spark Task] --> |"序列化 Plan Fragment"| Pipe[stdin pipe]
    end

    subgraph "SparkCpp 进程"
        Pipe --> Deser[反序列化 +
翻译为 Velox Plan] Deser --> VeloxRT[Velox Runtime
多 Driver 并行] VeloxRT --> |"结果序列化"| PipeOut[stdout pipe] end PipeOut --> Task style Task fill:#e8d5b7 style Pipe fill:#e8e8b7 style Deser fill:#b7d5e8 style VeloxRT fill:#b7e8c4 style PipeOut fill:#e8e8b7

图 4:模式 C 架构。Spark executor 通过 pipe 将 plan fragment 序列化传给外部 C++ 进程,C++ 进程用 Velox 执行后返回结果。JVM-Native 边界在进程间,通过 pipe IPC 通信。

Spark 的 script transform 接口原本允许用户在 Spark 中执行任意外部程序。Spruce 利用这个已有接口,将 plan fragment 序列化后通过 pipe(stdin/stdout)传给外部的 SparkCpp 进程。SparkCpp 反序列化后翻译为 Velox plan 并执行 [7]

这种方式的优势在于:对 Spark 代码几乎零侵入、C++ 进程拥有独立的地址空间和线程模型、可以完全利用 Velox 的 morsel-driven 并行调度。但代价是 pipe 通信带来的序列化/反序列化开销,以及接口能力的上限——script transform 并非为高性能查询执行设计的。

需要说明的是,Spruce 是 Meta 的内部项目,公开信息主要来源于 Velox 的 VLDB 论文 [7] ,关于其生产规模和具体实现细节的信息有限。


多维度对比

三种模式的差异不仅体现在架构图上,更体现在一系列设计决策的 trade-off 中。

通信机制与数据传输开销

模式边界位置通信机制数据传输开销
A: 进程替换进程间REST HTTPWorker 间本来就有网络通信,C++ 替换的是 Worker 内部执行而非通信层,不增加额外开销
B: JNI 算子替换进程内JNI + off-heap pointer近似 zero-copy,开销最低
C: 外部进程进程间pipe (stdin/stdout)序列化/反序列化,有可测量开销

模式 B 在数据传输上有明确优势——JNI 调用本身的开销约为几十纳秒量级,且通过传递 off-heap memory 指针可以避免数据拷贝。但需要注意,这个优势建立在 native 侧和 JVM 侧共享同一进程地址空间的基础上,由此带来的内存管理复杂度是隐性成本。

Fallback 策略与粒度

Fallback 策略决定了在 native 引擎尚未完全覆盖所有 operator 时,系统如何优雅地处理不支持的操作。

模式 A 不提供 fallback。 整个 Worker 要么是 C++,要么是 Java,不存在混合执行。如果某个 query 使用了 C++ Worker 不支持的函数或 operator,query 会直接报错。这意味着模式 A 需要较高的 operator coverage 才能投入生产。Prestissimo 目前已经支持了 Presto SQL dialect 的大部分功能,Meta 和 IBM 已将其用于生产环境 [3]

模式 B 支持 operator 级别的 fallback。 同一个 query plan 中,支持的 operator 走 native 路径,不支持的回退到 JVM。这让用户可以在 operator coverage 尚不完整时就开始使用 native 加速——query 不会报错,只是部分 operator 没有被加速。

但 fallback 有其隐性代价:每次 native → JVM 或 JVM → native 的切换都涉及 columnar ↔ row 格式的转换。Native 引擎使用 columnar batch(Arrow 格式或类似格式),而 Spark JVM 的 operator 使用 InternalRow。这种格式转换不是免费的,尤其当 fallback 频繁发生时,转换开销可能显著侵蚀 native 执行带来的性能收益。

这也是为什么 Photon 和 Gluten 都在持续扩大 operator coverage——不只是为了功能完整性,更是为了减少 fallback 带来的格式转换开销。

模式 C 的 fallback 粒度介于两者之间——fragment 级别。 整个 plan fragment 要么在 C++ 进程执行,要么不走 offload。

线程模型与 Native 调度能力

这是一个容易被忽视但对性能影响深远的维度。

Velox 内部采用 Driver loop 执行模型:operator 被拍平成一个数组,由 Driver 在扁平循环中依次驱动——而不是 Volcano 模型的递归调用栈。这种设计配合 morsel-driven parallelism,可以实现多 Driver 线程并行消费数据、work stealing、动态负载均衡 [7]

但这些调度能力能否发挥,取决于集成模式:

模式 A 和 C(独立进程) 可以完全释放 Velox 的调度能力。C++ 进程拥有独立的线程池,一个 Velox Task 可以创建多个 Driver 线程并行处理 morsel。

模式 B(JNI in-process) 则受到 Spark task 模型的约束。Spark 的并行模型是 one task = one thread = one partition:Spark 自己决定并行度,通过启动大量 task 来处理不同 partition。Native 引擎(Photon 或 Velox)嵌在每个 Spark task 的线程里运行,在每个 task 内部以单线程执行。

这意味着:当某些 partition 存在数据倾斜时,模式 A/C 可以在 Velox 层面通过 work stealing 来缓解;模式 B 只能依赖 Spark 的 AQE(Adaptive Query Execution)来重新划分 partition,native 引擎自身的调度能力发挥不出来。

核心洞察:集成越深入 Spark runtime(JNI in-process),越受其 task 模型约束,native 引擎自身的调度优势越难释放;集成越独立(独立进程),越能发挥 native 调度能力,但集成成本和通信开销更高。

语义兼容性

语义兼容性是所有集成方案面临的共同挑战,但程度因场景而异。

模式 A(Presto 场景) 的兼容性问题最小。Velox 的函数库最初就是按 Presto 语义实现的——类型系统、函数行为、NULL 处理规则都与 Presto 保持一致。很多为 Prestissimo 实现的组件(如 Presto wire protocol、Presto 函数包)今天已经成为 Velox 核心的一部分 [7]

模式 B 和 C(Spark 场景) 的挑战更大。Spark 和 Presto 在很多看似相同的函数上存在微妙的语义差异。Velox 官方博客明确指出:LIKE 函数和 Spark 的 split 函数在使用列值(而非常量)作为参数时,可能悄悄产生错误的结果 [8] 。这类 subtle bugs 在日常使用中很容易被忽略,但在大规模数据处理中可能导致严重的正确性问题。

Photon 通过构建大量的交叉测试框架来验证语义一致性:单元测试验证每个 SQL expression 在 Photon 和 Spark 中行为一致,还有专门的 native code 测试框架对 column vectors 进行正确性校验 [1] 。这本身就是巨大且持续的工程投入。

内存管理

模式内存管理方式与 JVM GC 的关系Spilling 协调
A: 进程替换完全 native 管理(Velox memory arena)无关,Worker 无 JVMVelox 自主决定
B: JNI 算子替换native 侧使用 off-heap,与 Spark memory manager 集成共享进程,需协调Photon 深度集成;Gluten 通过 memory registration API 注册
C: 外部进程独立地址空间无关无法跨进程协调

模式 B 的内存管理最复杂。Photon 与 Spark memory manager 深度集成,共享 off-heap 内存池,在内存压力下协调 spilling [4] 。Gluten 的做法稍有不同——它通过调用 Spark 的 memory registration API,为每次 native 内存分配/释放动作在 Spark 侧注册,让 Spark 的统一内存管理机制也能感知和管控 native 内存使用 [5]


参考资料