Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions native/core/src/execution/jni_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ fn op_name(op: &OpStruct) -> &'static str {
OpStruct::CsvScan(_) => "CsvScan",
OpStruct::ShuffleScan(_) => "ShuffleScan",
OpStruct::BroadcastNestedLoopJoin(_) => "BroadcastNestedLoopJoin",
OpStruct::CrossJoin(_) => "CrossJoin",
OpStruct::Sample(_) => "Sample",
OpStruct::MergeRows(_) => "MergeRows",
OpStruct::RangeScan(_) => "RangeScan",
Expand Down
50 changes: 47 additions & 3 deletions native/core/src/execution/planner/join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
// specific language governing permissions and limitations
// under the License.

//! Builders for the native join operators (hash join, sort-merge join, and broadcast nested loop
//! join), and the join-key and join-filter planning they share.
//! Builders for the native join operators (hash join, sort-merge join, broadcast nested loop
//! join, and cross join), and the join-key and join-filter planning they share.

use std::{collections::HashMap, sync::Arc};

Expand All @@ -37,7 +37,8 @@ use datafusion::{
physical_plan::{
filter::FilterExec,
joins::{
utils::JoinFilter, HashJoinExec, NestedLoopJoinExec, PartitionMode, SortMergeJoinExec,
utils::JoinFilter, CrossJoinExec, HashJoinExec, NestedLoopJoinExec, PartitionMode,
SortMergeJoinExec,
},
projection::ProjectionExec,
ChildrenPropertiesMode, ExecutionPlan, ExecutionPlanProperties, ReplaceChildrenOptions,
Expand Down Expand Up @@ -344,6 +345,49 @@ impl OperatorBuilder for BroadcastNestedLoopJoinBuilder {
}
}

/// Builder for `CrossJoin`: Spark's CartesianProductExec, delegated to DataFusion's CrossJoinExec.
pub struct CrossJoinBuilder;

impl OperatorBuilder for CrossJoinBuilder {
fn build(
&self,
spark_plan: &Operator,
inputs: &mut Vec<Arc<Global<JObject<'static>>>>,
partition_count: usize,
planner: &PhysicalPlanner,
) -> PlanCreationResult {
let children = &spark_plan.children;

// A pure cross product has no join keys and no condition; the Inner join type here only
// satisfies the shared helper and is unused by CrossJoinExec. CrossJoinExec emits left
// columns followed by right columns, matching Spark's output order.
let (join_params, scans, shuffle_scans) = planner.parse_join_parameters(
inputs,
children,
&[],
&[],
JoinType::Inner as i32,
&None,
partition_count,
)?;

let left = Arc::clone(&join_params.left.native_plan);
let right = Arc::clone(&join_params.right.native_plan);

let cross_join = Arc::new(CrossJoinExec::new(left, right));

Ok((
scans,
shuffle_scans,
Arc::new(SparkPlan::new(
spark_plan.plan_id,
cross_join,
vec![join_params.left, join_params.right],
)),
))
}
}

impl PhysicalPlanner {
/// Keep Spark metric identities for the small set of nodes whose children
/// runtime-filter placement can replace. Stop at native/Spark tree boundaries.
Expand Down
8 changes: 7 additions & 1 deletion native/core/src/execution/planner/operator_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ pub enum OperatorType {
MergeRows,
Explode,
BroadcastNestedLoopJoin,
CrossJoin,
WindowGroupLimit,
}

Expand Down Expand Up @@ -151,7 +152,9 @@ impl OperatorRegistry {

/// Register join operators
fn register_join_operators(&mut self) {
use super::join::{BroadcastNestedLoopJoinBuilder, HashJoinBuilder, SortMergeJoinBuilder};
use super::join::{
BroadcastNestedLoopJoinBuilder, CrossJoinBuilder, HashJoinBuilder, SortMergeJoinBuilder,
};

self.builders
.insert(OperatorType::HashJoin, Box::new(HashJoinBuilder));
Expand All @@ -161,6 +164,8 @@ impl OperatorRegistry {
OperatorType::BroadcastNestedLoopJoin,
Box::new(BroadcastNestedLoopJoinBuilder),
);
self.builders
.insert(OperatorType::CrossJoin, Box::new(CrossJoinBuilder));
}

/// Register window operators
Expand Down Expand Up @@ -236,6 +241,7 @@ fn get_operator_type(spark_operator: &Operator) -> Option<OperatorType> {
OpStruct::CsvScan(_) => Some(OperatorType::CsvScan),
OpStruct::ShuffleScan(_) => Some(OperatorType::ShuffleScan),
OpStruct::BroadcastNestedLoopJoin(_) => Some(OperatorType::BroadcastNestedLoopJoin),
OpStruct::CrossJoin(_) => Some(OperatorType::CrossJoin),
OpStruct::RangeScan(_) => None,
OpStruct::Sample(_) => None, // Not yet in OperatorType enum
OpStruct::MergeRows(_) => Some(OperatorType::MergeRows),
Expand Down
7 changes: 7 additions & 0 deletions native/proto/src/proto/operator.proto
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ message Operator {
RangeScan range_scan = 121;
MergeRows merge_rows = 122;
ArrowPythonUdf arrow_python_udf = 123;
CrossJoin cross_join = 124;
// Extension point for optional, out-of-tree contrib scans (Delta, Lance, ...). The concrete
// scan message (e.g. `DeltaScan`) is packed into this envelope on the JVM side and dispatched
// by `type_url` on the native side. Using a single permanent field -- rather than a new oneof
Expand Down Expand Up @@ -1156,6 +1157,12 @@ message BroadcastNestedLoopJoin {
optional spark.spark_expression.Expr condition = 3;
}

// Spark's CartesianProductExec, delegated to DataFusion's CrossJoinExec. A pure cross product has
// no join keys and no condition, so the message carries nothing beyond the two child operators
// (Spark folds any cross-join predicate into a separate FilterExec above this node).
message CrossJoin {
}

enum JoinType {
Inner = 0;
LeftOuter = 1;
Expand Down
9 changes: 9 additions & 0 deletions spark/src/main/scala/org/apache/comet/CometConf.scala
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,15 @@ object CometConf extends ShimCometConf {
createExecEnabledConfig("broadcastNestedLoopJoin", defaultValue = true)
val COMET_EXEC_SORT_MERGE_JOIN_ENABLED: ConfigEntry[Boolean] =
createExecEnabledConfig("sortMergeJoin", defaultValue = true)
val COMET_EXEC_CARTESIAN_PRODUCT_ENABLED: ConfigEntry[Boolean] =
createExecEnabledConfig(
"cartesianProduct",
defaultValue = false,
notes = Some(
"Opt-in: DataFusion's CrossJoinExec buffers the entire left input in memory and " +
"cannot spill, so a large left input can exhaust memory where Spark's spillable " +
"CartesianProduct succeeds. Only single-partition, condition-free cross joins are " +
"eligible; everything else falls back to Spark"))
val COMET_EXEC_EXISTENCE_JOIN_ENABLED: ConfigEntry[Boolean] =
conf("spark.comet.exec.existenceJoin.enabled")
.category(CATEGORY_ENABLE_EXEC)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import org.apache.spark.sql.execution.datasources.v2.json.JsonScan
import org.apache.spark.sql.execution.datasources.v2.parquet.ParquetScan
import org.apache.spark.sql.execution.datasources.v2.text.TextScan
import org.apache.spark.sql.execution.exchange.{BroadcastExchangeExec, BroadcastExchangeLike, Exchange, ReusedExchangeExec, ShuffleExchangeExec, ShuffleExchangeLike}
import org.apache.spark.sql.execution.joins.{BroadcastHashJoinExec, BroadcastNestedLoopJoinExec, ShuffledHashJoinExec, SortMergeJoinExec}
import org.apache.spark.sql.execution.joins.{BroadcastHashJoinExec, BroadcastNestedLoopJoinExec, CartesianProductExec, ShuffledHashJoinExec, SortMergeJoinExec}
import org.apache.spark.sql.execution.window.WindowExec
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types._
Expand Down Expand Up @@ -120,6 +120,7 @@ object CometExecRule {
classOf[BroadcastNestedLoopJoinExec] -> CometBroadcastNestedLoopJoinExec,
classOf[ShuffledHashJoinExec] -> CometHashJoinExec,
classOf[SortMergeJoinExec] -> CometSortMergeJoinExec,
classOf[CartesianProductExec] -> CometCartesianProductExec,
classOf[SortExec] -> CometSortExec,
classOf[LocalTableScanExec] -> CometLocalTableScanExec,
classOf[RangeExec] -> CometRangeExec,
Expand Down
77 changes: 76 additions & 1 deletion spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import org.apache.spark.sql.execution.aggregate.{BaseAggregateExec, HashAggregat
import org.apache.spark.sql.execution.columnar.InMemoryTableScanExec
import org.apache.spark.sql.execution.datasources.v2.DataSourceV2ScanExecBase
import org.apache.spark.sql.execution.exchange.ReusedExchangeExec
import org.apache.spark.sql.execution.joins.{BroadcastHashJoinExec, BroadcastNestedLoopJoinExec, HashJoin, ShuffledHashJoinExec, SortMergeJoinExec}
import org.apache.spark.sql.execution.joins.{BroadcastHashJoinExec, BroadcastNestedLoopJoinExec, CartesianProductExec, HashJoin, ShuffledHashJoinExec, SortMergeJoinExec}
import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics}
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types.{ArrayType, BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, DoubleType, FloatType, IntegerType, LongType, MapType, ShortType, StringType, StructField, StructType, TimestampNTZType, TimestampType}
Expand Down Expand Up @@ -3273,6 +3273,81 @@ object CometBroadcastNestedLoopJoinExec extends CometOperatorSerde[BroadcastNest
}
}

case class CometCartesianProductExec(
override val nativeOp: Operator,
override val originalPlan: SparkPlan,
override val output: Seq[Attribute],
override val left: SparkPlan,
override val right: SparkPlan,
override val serializedPlanOpt: SerializedPlan)
extends CometBinaryExec {

// outputPartitioning is inherited from the base exec (originalPlan.outputPartitioning), which
// matches Spark's CartesianProductExec (UnknownPartitioning), so downstream exchange planning
// is unchanged.

override def withNewChildrenInternal(newLeft: SparkPlan, newRight: SparkPlan): SparkPlan =
this.copy(left = newLeft, right = newRight)

override def stringArgs: Iterator[Any] = Iterator(left, right)

override def equals(obj: Any): Boolean = {
obj match {
case other: CometCartesianProductExec =>
this.output == other.output &&
this.left == other.left &&
this.right == other.right &&
this.serializedPlanOpt == other.serializedPlanOpt
case _ =>
false
}
}

override def hashCode(): Int = Objects.hashCode(output, left, right)

override lazy val metrics: Map[String, SQLMetric] =
CometMetricNode.joinMetrics(sparkContext)
}

object CometCartesianProductExec extends CometOperatorSerde[CartesianProductExec] {

override def enabledConfig: Option[ConfigEntry[Boolean]] =
Some(CometConf.COMET_EXEC_CARTESIAN_PRODUCT_ENABLED)

override def getSupportLevel(op: CartesianProductExec): SupportLevel = {
// Spark folds a cross-join predicate into a separate FilterExec above the product, so the
// common CartesianProductExec has no condition. DataFusion's CrossJoinExec has no post-product
// filter, so fall back to Spark when a condition is attached.
if (op.condition.isDefined) {
Unsupported(Some("CartesianProduct with a join condition is not supported natively"))
} else if (op.left.outputPartitioning.numPartitions != 1 ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell, no plain file scan passes this check, since non-bucketed scans report UnknownPartitioning(0). Inputs qualify only after coalesce(1), repartition(1), a single-slice range or a SinglePartition exchange, which is what every benchmark case forces. Under AQE this rule runs before shuffle partitions are coalesced, so a shuffle that later shrinks to one partition is probably still rejected, though I have not run that. Could the config note and the operators.md entry say this, so users know when enabling it helps? Separately, for the large-left, small-right case, DataFusion 55.2 CrossJoinExec::build_batch emits one batch per left row, sized by the right batch, so 262144 x 64 produces 262144 batches of 64 rows. That probably explains why that case gains 3.1X against 18.6X for the square one. I read this from the source and did not measure it.

op.right.outputPartitioning.numPartitions != 1) {
// Comet runs a native plan per Spark partition and cannot inject an exchange here
// (EnsureRequirements has already run). A cross product is cross-partition, so it is only
// correct natively when both inputs are a single partition; otherwise fall back to Spark's
// CartesianProductExec, which crosses every left x right partition pair via CartesianRDD.
Unsupported(
Some(
"Native CartesianProduct requires single-partition inputs (a multi-partition " +
"cross product cannot be expressed per-partition)"))
} else {
Compatible(None)
}
}

override def convert(
op: CartesianProductExec,
builder: Operator.Builder,
childOp: Operator*): Option[Operator] = {
val joinBuilder = OperatorOuterClass.CrossJoin.newBuilder()
Some(builder.setCrossJoin(joinBuilder).build())
}

override def createExec(nativeOp: Operator, op: CartesianProductExec): CometNativeExec = {
CometCartesianProductExec(nativeOp, op, op.output, op.left, op.right, SerializedPlan(None))
}
}

object CometBroadcastHashJoinExec extends CometOperatorSerde[HashJoin] with CometHashJoin {

override def enabledConfig: Option[ConfigEntry[Boolean]] =
Expand Down
56 changes: 55 additions & 1 deletion spark/src/test/scala/org/apache/comet/exec/CometJoinSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import org.apache.spark.sql.catalyst.expressions.{And, Ascending, AttributeRefer
import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight, BuildSide}
import org.apache.spark.sql.catalyst.plans.{Inner, LeftOuter, LeftSemi}
import org.apache.spark.sql.catalyst.plans.logical.Join
import org.apache.spark.sql.comet.{CometBroadcastExchangeExec, CometBroadcastHashJoinExec, CometBroadcastNestedLoopJoinExec, CometFilterExec, CometHashJoin, CometHashJoinExec, CometNativeScanExec, CometProjectExec, CometRangeExec, CometSampleExec, CometSortExec, CometSortMergeJoinExec, CometSparkToColumnarExec, CometUnionExec, CometWindowExec}
import org.apache.spark.sql.comet.{CometBroadcastExchangeExec, CometBroadcastHashJoinExec, CometBroadcastNestedLoopJoinExec, CometCartesianProductExec, CometFilterExec, CometHashJoin, CometHashJoinExec, CometNativeScanExec, CometProjectExec, CometRangeExec, CometSampleExec, CometSortExec, CometSortMergeJoinExec, CometSparkToColumnarExec, CometUnionExec, CometWindowExec}
import org.apache.spark.sql.execution.{ColumnarToRowTransition, InputAdapter, LocalTableScanExec, SortExec, SparkPlan, SQLExecution, WholeStageCodegenExec}
import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanExec, AQEShuffleReadExec, QueryStageExec}
import org.apache.spark.sql.execution.aggregate.SortAggregateExec
Expand Down Expand Up @@ -154,6 +154,60 @@ class CometJoinSuite extends CometTestBase {
}
}

test("cross join (CartesianProduct) runs natively") {
withSQLConf(
CometConf.COMET_EXEC_CARTESIAN_PRODUCT_ENABLED.key -> "true",
// Disable broadcast so Spark plans a CartesianProductExec rather than a
// BroadcastNestedLoopJoinExec for the keyless join.
SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
SQLConf.ADAPTIVE_AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
withParquetTable(Seq((0, 10), (1, 11), (2, 12)), "l") {
withParquetTable(Seq((0, 100), (1, 101)), "r") {
checkSparkAnswerAndOperator(
sql(
"SELECT * FROM (SELECT /*+ COALESCE(1) */ * FROM l) l " +
"CROSS JOIN (SELECT /*+ COALESCE(1) */ * FROM r) r"),
Seq(classOf[CometCartesianProductExec]))
}
}
}
}

test("cross join with a join condition falls back to Spark") {
withSQLConf(
CometConf.COMET_EXEC_CARTESIAN_PRODUCT_ENABLED.key -> "true",
SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
SQLConf.ADAPTIVE_AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
withParquetTable(Seq((0, 10), (1, 11), (2, 12)), "l") {
withParquetTable(Seq((0, 100), (1, 101)), "r") {
// A non-equi inner join plans a CartesianProductExec that carries the predicate; Comet
// has no native post-product filter, so results must still match Spark via fallback.
checkSparkAnswer(sql("SELECT * FROM l JOIN r ON l._2 > r._2"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test cannot fail on the condition guard. Both sides are plain parquet scans, which report UnknownPartitioning(0) (CometNativeScanExec.outputPartitioning and FileSourceScanExec for non-bucketed scans), so the single-partition check rejects the product even if the op.condition.isDefined branch were removed. Could both sides use /*+ COALESCE(1) */ as in the native test, so the condition is the only reason left? A sql-tests/join/cartesian_product.sql fixture with -- Config: spark.comet.exec.cartesianProduct.enabled=true and both broadcast thresholds at -1 could then hold this as query expect_fallback(CartesianProduct with a join condition is not supported natively), next to the native case and the multi-partition, NULL, empty-side and over-8192-row cases andygrove listed. The default-off check needs the plan and can stay in Scala. I have not run the fixture.

}
}
}
}

test("cross join stays on Spark by default (native path is opt-in)") {
// Native CrossJoinExec can't spill, so it's opt-in; by default the cross join stays on Spark.
withSQLConf(
SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
SQLConf.ADAPTIVE_AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
withParquetTable(Seq((0, 10), (1, 11), (2, 12)), "l") {
withParquetTable(Seq((0, 100), (1, 101)), "r") {
val df = sql(
"SELECT * FROM (SELECT /*+ COALESCE(1) */ * FROM l) l " +
"CROSS JOIN (SELECT /*+ COALESCE(1) */ * FROM r) r")
checkSparkAnswer(df)
assert(
!df.queryExecution.executedPlan.exists(_.isInstanceOf[CometCartesianProductExec]),
"cross join must stay on Spark when cartesianProduct.enabled is false")
}
}
}
}

test("join - self join") {
val df1 = testData.select(testData("key")).as("df1")
val df2 = testData.select(testData("key")).as("df2")
Expand Down
Loading
Loading