You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Experimental local execution mode that runs a whole query as one DataFusion graph #6499
When Spark runs with a local master, the driver and all executors share one JVM, but Comet still executes a query the distributed way: each Spark task builds and runs its own native plan, stages exchange data through Spark shuffle files, and each result partition is encoded by its task and decoded on the driver. In a single process these boundaries are pure overhead. Partial aggregates, join inputs and sort runs are written to and read back from shuffle files that DataFusion could exchange in memory, and the native plans of one stage cannot share operator state.
DataFusion can already execute a whole physical plan, including hash repartitioning, on its own Tokio runtime. For queries that Comet fully supports, the single-process case could hand the whole query to DataFusion and keep Spark only for parsing, analysis, optimization and result delivery.
Describe the potential solution
Add an experimental, opt-in local execution mode (an internal spark.comet.exec.local.enabled option, off by default):
Admit a query only as a whole, before ordinary Comet conversion: Spark 4.1, an in-process local master, AQE disabled, batch queries without subqueries, and a fully supported plan. Any other query keeps the existing Comet/Spark path, and there is no fallback once native output has started.
Plan the whole query as one DataFusion graph in the driver, reusing Comet's native Parquet scan and Spark-compatible expressions. Replace Spark exchanges with DataFusion repartitioning inside that graph, so the query has no Spark shuffle and runs as a single Spark result task. Each execution owns a fresh graph and a query-scoped memory pool with spilling.
Start with Parquet scan/filter/project, grouped and global COUNT/MIN/MAX, shuffled hash joins, and terminal global sort, Top-K and limit, and leave everything else to the existing path.
Keep Spark's cancellation, job groups and SQL metrics by still running the result task as a Spark job, while delivering collect/take results to the driver within the same JVM instead of encoding them in the task.
Additional context
Related to #1204, which avoids repeating native plan construction across tasks within an executor. This mode instead targets the single-process case, where the whole query can be one native graph.
Thanks @andygrove for the careful review on #6500. Replying here since the main questions are about direction.
Reusing the existing operator translation. Agreed. local.proto and the local planner re-describe aggregate, join, sort and limit and lower them a second time, so every newly admitted operator would need a second implementation without the Spark-compatibility handling that PhysicalPlanner::create_plan already has. I'd rather build the graph from the Operator trees that CometExecRule produces:
Admit a plan only when it is entirely native: native blocks connected by CometShuffleExchangeExec, with no JVM input.
Lower each block with PhysicalPlanner::create_plan, plan all file partitions of a native scan in the one graph, and replace each shuffle exchange with a RepartitionExec, or for range partitioning with a per-partition sort and a sort-preserving merge.
Admission then follows whatever Comet runs natively, and local.proto goes away.
One thing I'd like to confirm: within one graph both sides of a join go through the same RepartitionExec, so I don't think the local exchange needs Comet's Spark-compatible Murmur3 partitioning, unless some native operator depends on it.
AQE. Admission from the CometRule(session, queryStagePrep = true) instance looks like the right way to drop the session-wide requirement. Since InsertAdaptiveSparkPlan leaves exchange-free plans alone, those can be admitted with AQE enabled from the columnar rule, and plans with exchanges from the query stage prep rule. I'll prototype the latter to check that replacing the initial plan with a single leaf leaves AQE nothing to re-plan.
Splitting. Happy to split it along the lines you suggested:
The native/local lifecycle, JNI bridge, result delivery and range.
Admission of exchange-free native plans (scan/filter/project) from CometExecRule operator trees, without requiring AQE to be disabled.
Exchanges through RepartitionExec for aggregates and joins, admitted from the query stage prep rule, plus Top-K and limit.
The other review points (spill directories and size limit, native partition count, unordered limit determinism, cached plans, the Spark version gate and memory_management.md) would be addressed in the PR that introduces the affected code. I'll convert #6500 to a draft and keep it as a reference until the first split PR is up.
On the Murmur3 question: two RepartitionExecs with DataFusion's hash do co-partition with each other, so the hash only matters when one side of a join gets its partitioning from a leaf instead of an exchange. Bucketed tables are the common case. CometNativeScanExec reports the bucket HashPartitioning, and EnsureRequirements keeps the side that needs no exchange and shuffles only the other side to match it. If that exchange becomes a RepartitionExec with DataFusion's hash, partition i of the shuffled side no longer lines up with bucket i and the join returns wrong rows. Could admission decline plans where a join child's partitioning comes from a scan (bucketed HashPartitioning, or KeyGroupedPartitioning from a storage-partitioned join), or could the local exchange keep Comet's Spark-compatible hash? A join between a bucketed and a non-bucketed table on the bucket column would make a good test.
Lowering each block once with PhysicalPlanner::create_plan has a related catch. Every DataFusion partition shares one instance of each planned expression, and that instance carries the planner's partition index. So spark_partition_id(), monotonically_increasing_id(), the rand and randn seeds, Sample's seed and the partition index JvmScalarUdfExpr hands the dispatcher would all see partition 0. Could the deterministic gate and the nativeOnly protobuf walk in LocalParquetPlanner carry over to the operator-tree admission?
For the first PR, the range needs an overflow guard. The local range admits ranges whose arithmetic overflows in Spark. On this head, spark.range(Long.MinValue, Long.MaxValue, 1L << 62, 1) returns four rows with local execution and none without, because Spark's generated code overflows. spark.range(Long.MinValue, Long.MaxValue, 1, 1).take(3) returns three rows against none. Main now has a native RangeScan (#6553) whose mayOverflow check keeps these ranges on Spark. Could the first PR lower RangeScan through create_plan instead of adding a second range operator, and compare the range tests against Spark's answer rather than Scala ranges?
The session has a similar gap. query_context starts from SessionContext::new_with_config_rt, which registers DataFusion's default functions and none of Comet's overrides. That's harmless while nativeOnly keeps ScalarFunc out. Once admission follows CometExecRule trees, concat would resolve to DataFusion's version, which skips nulls, instead of SparkConcat. Could the local session come from the per-task setup (prepare_datafusion_session_context, or create_execution_context once #6695 lands)? That would also bring the spill directories and their size cap, the spark.comet.datafusion.* pass-through and the skip-partial-aggregation guard.
Last, which budget should bound a local query? Each one gets its own 256 MiB FairSpillPool that Spark never sees, so concurrent local queries add up with nothing bounding the total, and the spark.memory.offHeap.size users already set for Comet doesn't apply. The result task is a real Spark task. Could the pool come from its CometTaskMemoryManager, as in the per-task path, so spark.comet.exec.local.memoryLimit isn't needed?
Thanks, these are all real gaps. Taking them in order:
Murmur3 and leaf partitioning. You're right, I only considered both sides going through an exchange. Keeping Spark's hash inside RepartitionExec isn't possible, so admission will decline any plan with partitioning reported by a scan: a bucketed scan's HashPartitioning or the KeyGroupedPartitioning of a storage-partitioned join. That is broader than the mixed bucketed join, but it avoids relying on file groups lining up with buckets. I'll add the bucketed-to-non-bucketed join on the bucket column as a test that checks it is declined and matches Spark.
Partition index in shared expressions. Agreed. The deterministic gate and the nativeOnly protobuf walk will apply to every block of the operator tree, so partition-sensitive expressions, nondeterministic expressions and JVM UDF dispatch keep the plan on the ordinary path.
Range. The first PR will lower RangeScan through create_plan instead of adding a second range operator, so mayOverflow keeps the overflowing ranges on Spark. The range tests will compare against Spark's answer, including your two examples.
Session. The local session will come from create_execution_context now that #6695 is in, which brings Comet's function overrides, the spill directories and their size cap, the spark.comet.datafusion.* pass-through and the skip-partial-aggregation guard. That also covers the spill-directory point from the #6500 review.
Budget. Agreed, using the result task's CometTaskMemoryManager is better than a pool Spark never sees. A local query then draws on spark.memory.offHeap.size through Spark's per-task accounting like any other Comet task, concurrent local queries share that bound, and spark.comet.exec.local.memoryLimit goes away.
The first PR will start from current main with the lifecycle, JNI bridge and result delivery, plus RangeScan lowered through create_plan, the session from create_execution_context and the task memory manager.
Activity
viirya commentedon Oct 4, 2026
Thanks @andygrove for the careful review on #6500. Replying here since the main questions are about direction.
Reusing the existing operator translation. Agreed.
local.protoand the local planner re-describe aggregate, join, sort and limit and lower them a second time, so every newly admitted operator would need a second implementation without the Spark-compatibility handling thatPhysicalPlanner::create_planalready has. I'd rather build the graph from theOperatortrees thatCometExecRuleproduces:CometShuffleExchangeExec, with no JVM input.PhysicalPlanner::create_plan, plan all file partitions of a native scan in the one graph, and replace each shuffle exchange with aRepartitionExec, or for range partitioning with a per-partition sort and a sort-preserving merge.local.protogoes away.One thing I'd like to confirm: within one graph both sides of a join go through the same
RepartitionExec, so I don't think the local exchange needs Comet's Spark-compatible Murmur3 partitioning, unless some native operator depends on it.AQE. Admission from the
CometRule(session, queryStagePrep = true)instance looks like the right way to drop the session-wide requirement. SinceInsertAdaptiveSparkPlanleaves exchange-free plans alone, those can be admitted with AQE enabled from the columnar rule, and plans with exchanges from the query stage prep rule. I'll prototype the latter to check that replacing the initial plan with a single leaf leaves AQE nothing to re-plan.Splitting. Happy to split it along the lines you suggested:
native/locallifecycle, JNI bridge, result delivery andrange.CometExecRuleoperator trees, without requiring AQE to be disabled.RepartitionExecfor aggregates and joins, admitted from the query stage prep rule, plus Top-K and limit.The other review points (spill directories and size limit, native partition count, unordered limit determinism, cached plans, the Spark version gate and
memory_management.md) would be addressed in the PR that introduces the affected code. I'll convert #6500 to a draft and keep it as a reference until the first split PR is up.andygrove commentedon Oct 7, 2026
Thanks, the plan looks good to me.
On the Murmur3 question: two
RepartitionExecs with DataFusion's hash do co-partition with each other, so the hash only matters when one side of a join gets its partitioning from a leaf instead of an exchange. Bucketed tables are the common case.CometNativeScanExecreports the bucketHashPartitioning, andEnsureRequirementskeeps the side that needs no exchange and shuffles only the other side to match it. If that exchange becomes aRepartitionExecwith DataFusion's hash, partition i of the shuffled side no longer lines up with bucket i and the join returns wrong rows. Could admission decline plans where a join child's partitioning comes from a scan (bucketedHashPartitioning, orKeyGroupedPartitioningfrom a storage-partitioned join), or could the local exchange keep Comet's Spark-compatible hash? A join between a bucketed and a non-bucketed table on the bucket column would make a good test.Lowering each block once with
PhysicalPlanner::create_planhas a related catch. Every DataFusion partition shares one instance of each planned expression, and that instance carries the planner's partition index. Sospark_partition_id(),monotonically_increasing_id(), therandandrandnseeds,Sample's seed and the partition indexJvmScalarUdfExprhands the dispatcher would all see partition 0. Could thedeterministicgate and thenativeOnlyprotobuf walk inLocalParquetPlannercarry over to the operator-tree admission?For the first PR, the range needs an overflow guard. The local range admits ranges whose arithmetic overflows in Spark. On this head,
spark.range(Long.MinValue, Long.MaxValue, 1L << 62, 1)returns four rows with local execution and none without, because Spark's generated code overflows.spark.range(Long.MinValue, Long.MaxValue, 1, 1).take(3)returns three rows against none. Main now has a nativeRangeScan(#6553) whosemayOverflowcheck keeps these ranges on Spark. Could the first PR lowerRangeScanthroughcreate_planinstead of adding a second range operator, and compare the range tests against Spark's answer rather than Scala ranges?The session has a similar gap.
query_contextstarts fromSessionContext::new_with_config_rt, which registers DataFusion's default functions and none of Comet's overrides. That's harmless whilenativeOnlykeepsScalarFuncout. Once admission followsCometExecRuletrees,concatwould resolve to DataFusion's version, which skips nulls, instead ofSparkConcat. Could the local session come from the per-task setup (prepare_datafusion_session_context, orcreate_execution_contextonce #6695 lands)? That would also bring the spill directories and their size cap, thespark.comet.datafusion.*pass-through and the skip-partial-aggregation guard.Last, which budget should bound a local query? Each one gets its own 256 MiB
FairSpillPoolthat Spark never sees, so concurrent local queries add up with nothing bounding the total, and thespark.memory.offHeap.sizeusers already set for Comet doesn't apply. The result task is a real Spark task. Could the pool come from itsCometTaskMemoryManager, as in the per-task path, sospark.comet.exec.local.memoryLimitisn't needed?andygrove commentedon Oct 7, 2026
@viirya once this is working, it should be easy to have it run against Ballista too! cc @avantgardnerio
viirya commentedon Oct 12, 2026
Thanks, these are all real gaps. Taking them in order:
Murmur3 and leaf partitioning. You're right, I only considered both sides going through an exchange. Keeping Spark's hash inside
RepartitionExecisn't possible, so admission will decline any plan with partitioning reported by a scan: a bucketed scan'sHashPartitioningor theKeyGroupedPartitioningof a storage-partitioned join. That is broader than the mixed bucketed join, but it avoids relying on file groups lining up with buckets. I'll add the bucketed-to-non-bucketed join on the bucket column as a test that checks it is declined and matches Spark.Partition index in shared expressions. Agreed. The
deterministicgate and thenativeOnlyprotobuf walk will apply to every block of the operator tree, so partition-sensitive expressions, nondeterministic expressions and JVM UDF dispatch keep the plan on the ordinary path.Range. The first PR will lower
RangeScanthroughcreate_planinstead of adding a second range operator, somayOverflowkeeps the overflowing ranges on Spark. The range tests will compare against Spark's answer, including your two examples.Session. The local session will come from
create_execution_contextnow that #6695 is in, which brings Comet's function overrides, the spill directories and their size cap, thespark.comet.datafusion.*pass-through and the skip-partial-aggregation guard. That also covers the spill-directory point from the #6500 review.Budget. Agreed, using the result task's
CometTaskMemoryManageris better than a pool Spark never sees. A local query then draws onspark.memory.offHeap.sizethrough Spark's per-task accounting like any other Comet task, concurrent local queries share that bound, andspark.comet.exec.local.memoryLimitgoes away.The first PR will start from current main with the lifecycle, JNI bridge and result delivery, plus
RangeScanlowered throughcreate_plan, the session fromcreate_execution_contextand the task memory manager.