andygrove opened a new issue, #7288:
URL: https://github.com/apache/datafusion-comet/issues/7288

   ### What is the problem the feature request solves?
   
   Comet accelerates Spark by running native operators inside Spark's JVM 
executors. #4796 proposed an additional, optional deployment mode with a fully 
native data plane: a Spark driver (a Spark Connect server, or a regular 
application's driver) stays the control plane, and queries run on native 
processes that reuse Comet's Spark-compatible operators and expressions. The 
draft PoC in #4800 showed this can work: TPC-H Q1's aggregate ran across a 
Ballista shuffle on a JVM-less executor and matched Spark row for row.
   
   This issue replaces #4796 with a concrete design and tracks the work. It 
builds on what the PoC learned and on what has changed since: Comet main and 
Ballista 55.0.0 are now both on DataFusion 55. The mode is called **Comet 
Native Executors**. It is purely additive; the existing accelerator is 
unchanged.
   
   ### Describe the potential solution
   
   #### Overview
   
   ```
    PySpark / Spark Connect client, or a Spark application
                       │
                       ▼
    ┌────────────────────────────────────────┐  CONTROL PLANE: Spark driver 
(JVM)
    │ Catalyst: analyze, optimize            │  • unchanged Spark front end
    │ Comet rules + serde → Comet protobuf   │  • strict-native check
    │ CometNativeExecutorsExec (one leaf)    │  • no Ballista, no 
datafusion-proto
    └──────────────────┬─────────────────────┘
                       │ Arrow Flight: GetFlightInfo(cmd = Comet query protobuf)
                       ▼
    ┌────────────────────────────────────────┐  DATA PLANE: native, no JVM
    │ comet-scheduler                        │  • Comet front end: validate 
blocks
    │   Comet Flight front end               │    with Comet's planner, then 
stage
    │   Ballista scheduler (library)         │    graph → Ballista physical plan
    ├────────────────────────────────────────┤
    │ comet-executor × N                     │  • Ballista executor (library) 
with
    │   Ballista executor (library)          │    Comet's planner, operators,
    │                                        │    expressions and scans
    └──────────────────┬─────────────────────┘
                       │ Arrow Flight: DoGet(ticket), one per result partition
                       ▼
               Spark driver → client
   ```
   
   A Comet Native Executor cluster is one `comet-scheduler` and N 
`comet-executor` processes, built on the Apache DataFusion Ballista crates 
(used as libraries from crates.io) with Comet's native crates linked in. 
Ballista provides stage scheduling, the shuffle between stages and task retry; 
Comet provides the Spark semantics.
   
   #### Key decisions
   
   1. **Comet protobuf is the only contract between the driver and the 
cluster.** The driver serializes a stage graph of Comet operator protos and 
submits it over Arrow Flight. It never links Ballista or datafusion-proto, so 
the main Comet build (JAR and libcomet) has no Ballista dependency: Comet can 
upgrade DataFusion without waiting for a Ballista release, and the cluster can 
upgrade Ballista without touching drivers. Results come back as Arrow IPC over 
Flight, which doesn't depend on either version.
   2. **The cluster is a separate build.** It gets its own Cargo workspace and 
lockfile, depending on Comet's native crates by path and on Ballista from 
crates.io, with its own CI job. It is built from Comet crates at some revision 
plus the Ballista release on that revision's DataFusion major. When Comet moves 
to a DataFusion major that Ballista hasn't released, the cluster build lags 
until Ballista catches up; a Ballista git dependency can't go into a source 
release.
   3. **Static linking first, with a seam narrow enough for FFI later.** Inside 
the executor, Comet's crates and Ballista share one DataFusion version. The 
fragment seam passes only proto bytes, a partition index, input streams and 
config, and returns an output stream and metrics. If we later want the 
executor's Comet library on a different DataFusion version than Ballista, that 
seam can move behind the Arrow C Stream interface (a spec-level stable ABI that 
Comet already uses with the JVM) without changing the driver, the proto or the 
scheduler. `datafusion-ffi` is an alternative, but its cross-version support is 
a soft promise: its README still recommends the same version on both sides.
   4. **Strict native, all or nothing.** A query either runs entirely on the 
native executors or fails with an error naming the unsupported node and its 
fallback reasons. There are no Spark executors to fall back to, and 
interleaving the two engines within one query is out of scope.
   5. **Upstream Ballista, no fork.** The Ballista enhancements this needs are 
filed and contributed upstream (list below).
   
   #### Driver side (JVM)
   
   - **Configs:** `spark.comet.nativeExecutors.enabled` (default `false`) and 
`spark.comet.nativeExecutors.scheduler.url`.
   - **Hook:** the last step of `CometRule` replaces the converted plan with a 
single row-based leaf, `CometNativeExecutorsExec`. It runs in the 
query-stage-prep instance when AQE applies and in the columnar instance 
otherwise. Comet's query-stage-prep rule already converts and serializes the 
whole plan, so this works with AQE on: AQE then finds no exchanges and runs one 
result stage. (The PoC overrode `executeCollect`, which Spark Connect never 
calls. Connect calls `executedPlan.execute()` and `sparkContext.submitJob`, so 
under Connect the PoC's offload silently never ran.)
   - **Stage graph:** walk the native blocks (`serializedPlanOpt`) separated by 
`CometShuffleExchangeExec` and `CometBroadcastExchangeExec`. Each block becomes 
a stage, and each exchange becomes a typed edge that reuses Comet's existing 
`Partitioning` proto (hash, single, range, round-robin) plus broadcast. Leaf 
stages carry per-partition scan data from `PlanDataInjector.findAllPlanData`, 
so a scan keeps its N partitions (the PoC collapsed each scan to one).
   - **Strict-native check:** built on `ExtendedExplainInfo`'s traversal with 
an allow-list of the node types the stage-graph builder can translate, plus a 
scan of each block's proto for expressions that call back into the JVM: 
`JvmScalarUdf` (which ScalaUDF codegen dispatch emits), `Subquery` unless its 
value has been inlined, and `ArrowPythonUdf`. Columnar (JVM) shuffle, 
row-to-columnar transitions and writes are rejected.
   - **Results:** `doExecute()` returns an RDD with one partition per result 
partition. Its tasks run on the driver (a Connect server runs `local[N]`), 
fetch Arrow IPC with Flight `DoGet`, and return rows. `executeCollect`, 
`executeTake` and `executeToIterator` are overridden for classic sessions and 
subqueries.
   - **Subqueries:** each scalar subquery runs as its own native-executor 
query, and its value is inlined into the main plan's proto as a literal before 
submission. DPP and runtime bloom filters are disabled in this mode at first; 
dropping them never changes results.
   - **Cancellation:** waiting on the remote job happens inside Spark tasks, so 
when a task is killed (Connect interrupts by job tag) the job is cancelled 
through Flight `CancelFlightInfo`. No driver thread blocks indefinitely.
   - **UI and metrics:** at first the SQL tab shows only the leaf. Later, post 
the inner plan the way `AdaptiveSparkPlanExec` does, and map the executors' 
per-operator metrics back by `plan_id`.
   
   #### Wire API
   
   A new proto file in `native/proto`, compiled for both Rust and the JVM:
   
   - `QueryRequest`: the Comet version and a fingerprint of the proto schema, a 
config map (the settings native execution reads), and the stages in topological 
order, root last.
   - `Stage`: the block's `Operator` proto, its output schema, its inputs in 
the order of the block's input leaves, and, for leaf stages, the per-partition 
injected plans.
   - `StageInput`: the producer stage, the edge's `Partitioning`, and whether 
it is a broadcast.
   
   Transport is Arrow Flight on the scheduler, following the Flight SQL 
pattern: `GetFlightInfo` submits a query and returns one endpoint per result 
partition, each with an executor location and an opaque ticket; `DoGet` fetches 
a partition from an executor; `PollFlightInfo` handles long-running queries; 
and `CancelFlightInfo` cancels. The driver uses `arrow-flight`, part of 
arrow-rs, from libcomet at Comet's own Arrow version, and hands batches to the 
JVM through the existing Arrow C Data path.
   
   **Versioning.** The boundary between driver and cluster becomes a 
cross-process API. Today the JAR and libcomet always come from the same build; 
with a separate cluster, versions can drift, and protobuf only protects the 
wire format: an older executor silently ignores a field it doesn't know. At 
first the scheduler requires an exact match of Comet version and proto 
fingerprint and rejects anything else with a clear error. If mixed versions 
matter later, we can adopt an additive-only proto policy.
   
   #### Scheduler: `comet-scheduler`
   
   Our own process: a Comet Flight front end that wraps Ballista's scheduler, 
used as a library (`create_scheduler` returns a `SchedulerServer` with 
`submit_physical_plan` and `cancel_job`). Executors register with the embedded 
Ballista scheduler as usual; drivers only talk to the Flight front end. For 
each query, the front end:
   
   1. checks the Comet version and proto fingerprint;
   2. runs Comet's native planner over each stage's block to validate it and 
derive its output schema, so a plan the executors can't build fails at 
submission with the planner's own error, not inside a task;
   3. turns the stage graph into a Ballista physical plan (table below) and 
submits it in-process;
   4. returns the result partitions' locations as Flight endpoints.
   
   Fragments still travel to the executors as Comet protos, because each 
executor rebuilds its block per partition from that partition's scan data and 
live input streams. Planning a leaf stage also builds its object stores, so 
validation runs in a mode that skips that, which keeps storage credentials off 
the scheduler. None of Ballista's client protocol (version header, job status, 
fetch tickets) leaks to the driver.
   
   How the stage graph maps onto Ballista 55's static planner:
   
   | Stage graph | Prototype | Later |
   |---|---|---|
   | Stage | `CometFragmentExec` (below) | |
   | Hash edge | `RepartitionExec(Hash(columns, n))` over the producer, so 
Ballista inserts its sort-based hash shuffle. This uses DataFusion's hash, not 
Spark's Murmur3. That is consistent within a query, which is all that joins, 
aggregates and windows need. Keys must be plain columns. | Spark-compatible 
partitioning, needed for bucketed tables and storage-partitioned joins |
   | Single edge | `CoalescePartitionsExec` over the producer | |
   | Broadcast edge | `CoalescePartitionsExec`, read whole by every consumer 
task. The fragment declares `SinglePartition` for that input, so Ballista 
doesn't slice it. | A Ballista broadcast input for extension nodes |
   | Range edge | Single partition, so the Comet sort runs in one task | A 
Ballista range shuffle for physical plans |
   | Round-robin edge | No stage boundary; the consumer runs on the producer's 
partitions | |
   
   Bucketed scans and storage-partitioned joins are disabled in this mode, 
because their unshuffled side is partitioned by Spark's Murmur3 hash.
   
   #### Executor: `comet-executor`
   
   Ballista's executor used as a library (`ExecutorProcessConfig` with Comet's 
codecs), with no JVM.
   
   - **`CometFragmentExec`** is a plain DataFusion `ExecutionPlan` in Comet's 
core crate, with no Ballista dependency. It holds a block's `Operator` proto 
and its declared output schema; its children are a partition descriptor (below) 
followed by one child per input leaf. `execute(p)` builds the block with 
Comet's `PhysicalPlanner` and session setup on a blocking thread (planning can 
block on Comet's runtime), feeds the block's input leaves directly from the 
child plans, and adapts output batches to the declared schema.
   - **Partition descriptor.** Ballista's scheduler slices each stage plan per 
task, but only for a fixed list of leaf types, and operators only see 
task-local partition indices. So each fragment gets a `MemorySourceConfig` 
child with one single-row batch per partition. The batch carries the global 
partition index, which seeds `rand`, `monotonically_increasing_id`, 
`spark_partition_id` and similar expressions, and, for leaf stages, the 
partition's injected plan. Ballista already slices and serializes that child. A 
Ballista hook that lets extension nodes slice themselves would make the 
descriptor unnecessary.
   - **Planner seam:** the planner's input leaves (`Scan`, `ShuffleScan`) take 
a native child plan instead of a JVM iterator. The PoC instead pumped a 
`ScanExec` with `block_on`, which blocked a Tokio worker on a network stream 
and no longer fits main.
   - **Memory:** Ballista's per-task `FairSpillPool` replaces Comet's 
Spark-backed pool, and spills go to the executor's work dir.
   
   What a JVM-free executor needs, from an audit of main:
   
   - Build without `hdfs-opendal`, which pulls in `hdfs-sys`, the only crate 
that links libjvm, and gate the macOS `hdrs` dependency behind it. On Linux 
that alone removes libjvm.
   - Make the JVM accessors (`JVMClasses::get`, `with_env`) checked instead of 
`get_unchecked`, so a JVM-only path that slips through fails cleanly instead of 
being undefined behavior.
   - Reject, or replace with native code, everything that calls back into the 
JVM: ScalaUDF codegen dispatch and other JVM UDFs; `Subquery` (inlined by the 
driver); Parquet encryption key unwrapping; the configurable S3 credential 
provider callback (the default chain and IRSA are already native); 
case-insensitive matching of non-ASCII field names; HDFS; and object-store 
writes, which go through a JVM output stream.
   
   #### Build and packaging
   
   - **Main workspace:** the wire proto; `CometFragmentExec` and the planner 
seam in core; and the Flight client in libcomet, behind a Cargo feature while 
this is experimental. No Ballista.
   - **New workspace** (`native/native-executors`, name open): the codecs, 
stage-graph assembly, the scheduler front end, and the `comet-scheduler` and 
`comet-executor` binaries.
   - Later: Docker images and deployment docs.
   
   #### Ballista enhancements we expect to need
   
   These will be filed upstream as they come up:
   
   - AQE for physical-plan submissions. Today only logical plans get it, and 
its planner and rules are private.
   - A way for extension nodes to slice themselves per task 
(`PartitionSliceable` is a hand-maintained whitelist) and to declare broadcast 
inputs.
   - Pluggable shuffle partitioning (Spark's Murmur3), or a pluggable shuffle 
writer and reader.
   - A range shuffle for physical plans; today only the AQE path produces one.
   - Expressions in Ballista's shuffle nodes are encoded with DataFusion's 
default codec, so hash and sort keys must be plain columns.
   
   #### Out of scope at first
   
   Writes (later: files produced on the executors with the commit on the 
driver, like the split Iceberg writer); Spark's AQE, which is lost in this mode 
until Ballista's AQE covers physical plans; fallback to Spark execution; Spark 
UI parity; and a highly available scheduler.
   
   #### Proposed sub-tasks
   
   - [ ] Prototype, as a draft PR: the proto, `CometFragmentExec` and the 
planner seam, the cluster workspace and binaries, the Flight client, and the 
driver hook. Simple queries: scan, filter, project, and an aggregate across a 
hash shuffle.
   - [ ] Spark Connect end to end; shuffled joins; sort (single partition); 
multi-partition scans at scale.
   - [ ] Inlined scalar subqueries; broadcast joins; DPP.
   - [ ] JVM-free executor hardening (checked JVM access, the up-front 
rejection list) and a CI job for the cluster workspace.
   - [ ] TPC-H, then TPC-DS: correctness and benchmarks.
   - [ ] The Ballista enhancements above.
   - [ ] Packaging and deployment docs.
   
   ### Additional context
   
   - Supersedes #4796. The PoC is #4800, which also led to 
apache/datafusion-ballista#1924 (physical-plan submission).
   - Ballista 55.0.0's dependency ranges match Comet main (DataFusion 55, 
arrow-flight 59.2, object_store 0.13, prost and tonic 0.14), so the cluster 
workspace resolves to a single DataFusion today.
   - The Comet protobuf and Flight front end also fit the "connect gateway" 
idea raised on #4796: a gateway could route a query to Spark, to Comet Native 
Executors or to DataFusion.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to