sunchao commented on code in PR #4587:
URL: https://github.com/apache/datafusion-comet/pull/4587#discussion_r4042586357
##########
spark/src/main/scala/org/apache/spark/sql/comet/operators.scala:
##########
@@ -2428,6 +2428,8 @@ trait CometHashJoin {
case FullOuter => JoinType.FullOuter
case LeftSemi => JoinType.LeftSemi
case LeftAnti => JoinType.LeftAnti
+ case ExistenceJoin(_) if
CometConf.COMET_EXEC_EXISTENCE_JOIN_ENABLED.get(join.conf) =>
Review Comment:
[P2] Preserve first-match evaluation for residual predicates
Could we keep hash existence joins with residual predicates in Spark until
this evaluation difference is handled? Spark stops after the first qualifying
candidate, but DataFusion 55.1 evaluates the hash-join filter on the candidate
batch before reducing it to a marker. With equal right-hand keys, a successful
candidate can therefore be followed by a candidate that throws, although Spark
never evaluates it.
For example, I ran Spark 4.1.3 with a single-file left input `(id=1,
vals=[1.0], threshold=-0.0)` and right rows `(id=1, idx=0), (id=1, idx=1)`. An
`EXISTS` correlation on `r.id=l.id AND element_at(l.vals,r.idx)>l.threshold`
returns `true`: Spark visits index 1 first and stops. The native
candidate-batch evaluation reaches index 0, which Comet's `ListExtract` rejects
even with ANSI disabled.
There is also a wrong-marker case for the residual `r.value > l.value` with
equal integer keys, `r.value=+0.0`, and `l.value=-0.0`. Spark returns false,
while the pinned Arrow 59.3 comparison returns true. `normalizePlan` only
rewrites project/filter expressions, so it does not normalize this join
condition.
These checks used fresh Spark/Arrow probes and the pinned DataFusion source,
not an end-to-end run of this Comet head.
##########
spark/src/main/scala/org/apache/spark/sql/comet/operators.scala:
##########
@@ -2943,6 +2955,8 @@ object CometSortMergeJoinExec extends
CometOperatorSerde[SortMergeJoinExec] {
case FullOuter => JoinType.FullOuter
case LeftSemi => JoinType.LeftSemi
case LeftAnti => JoinType.LeftAnti
+ case ExistenceJoin(_) if
CometConf.COMET_EXEC_EXISTENCE_JOIN_ENABLED.get(join.conf) =>
Review Comment:
[P2] Keep existence SMJ behind fallback until output retention is bounded
Could we leave existence sort-merge joins in Spark until the pinned
DataFusion implementation yields completed output inside its spanning-key and
exhausted-inner loops? In DataFusion 55.1,
`BitwiseSortMergeJoinStream::process_unfiltered_match_loop` and `drain_outer`
repeatedly push outer batches into `BatchCoalescer`, but
`emit_completed_batches` only runs after those loops return. The reservation
tracks the inner key buffer, not that queued output.
Consequently, a large equal-key group or an empty right input can retain an
entire left partition outside the configured memory budget before returning its
first output batch. That can cause executor OOM even though the join reports
little or no reserved memory.
I checked this control flow in the exact 55.1 source matching `Cargo.lock`.
A separate cached 54.1 probe corroborated the pattern: all 102,400 outer rows
were consumed before the first 1,024-row result with a one-byte pool and zero
reported reservation. That cached execution is not a run of the current
dependency. A regression should verify incremental output and bounded retained
memory for both empty-right and skewed-key inputs.
##########
spark/src/main/scala/org/apache/spark/sql/comet/operators.scala:
##########
@@ -2428,6 +2428,8 @@ trait CometHashJoin {
case FullOuter => JoinType.FullOuter
case LeftSemi => JoinType.LeftSemi
case LeftAnti => JoinType.LeftAnti
+ case ExistenceJoin(_) if
CometConf.COMET_EXEC_EXISTENCE_JOIN_ENABLED.get(join.conf) =>
+ JoinType.Existence
Review Comment:
[P2] Check computed-key evaluation safety on both inputs
Could we add a recursive safety check for existence join keys, beyond
checking whether each expression can be serialized? Spark packs pairs of
integer keys and can skip the second expression when the first is NULL, whereas
the native join evaluates the key expressions independently over the batch.
With ANSI enabled, left rows `(id=1, prefix=NULL, txt='bad'), (id=2,
prefix=1, txt='1')`, and right row `(a=1,b=1)`, Spark 4.1.3 returns
`(1,false),(2,true)` for `EXISTS (... WHERE r.a=l.prefix AND r.b=CAST(l.txt AS
INT))`. Native key evaluation reaches the invalid cast on the first row.
Similarly, `LIMIT 1` can stop Spark after a valid probe row while a native key
batch still evaluates a later invalid row.
A computed integer key can also conceal a floating comparison: `CASE WHEN x
< 0.0 THEN 1 ELSE 2 END` yields 2 in Spark for `x=-0.0`, but the pinned Arrow
comparison selects 1. Normalizing the final floating join key does not help
because this key is an integer. The guard needs to inspect descendants on both
inputs and retain native execution for expressions safe under batch evaluation.
The Spark baseline and Arrow 59.3 comparison were executed locally. The
current native key path was source-checked; full Comet integration was not run.
##########
spark/src/test/scala/org/apache/spark/sql/benchmark/CometExistenceJoinBenchmark.scala:
##########
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.spark.sql.benchmark
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.SparkSession
+import org.apache.spark.sql.internal.SQLConf
+
+import org.apache.comet.{CometConf, CometSparkSessionExtensions}
+
+/**
+ * Benchmark to measure performance of Comet's ExistenceJoin support across
the three join
+ * physical operators (BHJ, SHJ, SMJ). To run this benchmark:
+ * {{{
+ * SPARK_GENERATE_BENCHMARK_FILES=1 make
benchmark-org.apache.spark.sql.benchmark.CometExistenceJoinBenchmark
+ * }}}
+ * Results will be written to
"spark/benchmarks/CometExistenceJoinBenchmark-**results.txt".
+ */
+object CometExistenceJoinBenchmark extends CometBenchmarkBase {
+
+ override def getSparkSession: SparkSession = {
+ val conf = new SparkConf()
+ .setAppName("CometExistenceJoinBenchmark")
+ .set("spark.master", "local[5]")
+ .setIfMissing("spark.driver.memory", "3g")
+ .setIfMissing("spark.executor.memory", "3g")
+ .set(
+ "spark.shuffle.manager",
+ "org.apache.spark.sql.comet.execution.shuffle.CometShuffleManager")
+
+ val sparkSession = SparkSession.builder
+ .config(conf)
+ .withExtensions(new CometSparkSessionExtensions)
+ .getOrCreate()
+
+ sparkSession.conf.set(CometConf.COMET_ENABLED.key, "false")
+ sparkSession.conf.set(CometConf.COMET_EXEC_ENABLED.key, "false")
+ sparkSession.conf.set(SQLConf.ANSI_ENABLED.key, "false")
+ sparkSession.conf.set("spark.sql.shuffle.partitions", "2")
+
+ sparkSession
+ }
+
+ override def runCometBenchmark(mainArgs: Array[String]): Unit = {
+ val probeRows = 1024 * 1024
+ val buildRows = 10000
+
+ withTempPath { dir =>
+ withTempTable("probe", "build") {
+ spark
+ .range(probeRows)
+ .selectExpr("id AS k", "CASE WHEN id % 3 = 0 THEN 'US' ELSE 'EU' END
AS region")
+ .write
+ .parquet(s"${dir.getAbsolutePath}/probe")
+ spark
+ .range(buildRows)
+ .selectExpr("id * 7 AS k")
+ .write
+ .parquet(s"${dir.getAbsolutePath}/build")
+
+
spark.read.parquet(s"${dir.getAbsolutePath}/probe").createOrReplaceTempView("probe")
+
spark.read.parquet(s"${dir.getAbsolutePath}/build").createOrReplaceTempView("build")
+
+ val query =
+ "SELECT count(*) FROM probe p " +
+ "WHERE p.region = 'US' OR EXISTS (SELECT 1 FROM build b WHERE b.k
= p.k)"
+
+ runBenchmark("ExistenceJoin - BroadcastHashJoin") {
+ runExpressionBenchmark(
+ "exists OR predicate (BHJ)",
+ probeRows,
+ query,
+ Map(
+ CometConf.COMET_EXEC_EXISTENCE_JOIN_ENABLED.key -> "true",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "10MB",
+ SQLConf.ADAPTIVE_AUTO_BROADCASTJOIN_THRESHOLD.key -> "10MB"))
+ }
+
+ runBenchmark("ExistenceJoin - ShuffledHashJoin") {
+ runExpressionBenchmark(
+ "exists OR predicate (SHJ)",
+ probeRows,
+ query,
+ Map(
+ CometConf.COMET_EXEC_EXISTENCE_JOIN_ENABLED.key -> "true",
+ SQLConf.PREFER_SORTMERGEJOIN.key -> "false",
Review Comment:
[P2] Apply benchmark strategy settings to the Spark baseline too
This is the same benchmark mismatch noted in the [existing
discussion](https://github.com/apache/datafusion-comet/pull/4587#issuecomment-5723010195),
anchored at the config map that causes it. `runExpressionBenchmark` treats
this map as `extraCometConfigs`, so the SHJ/SMJ planning settings apply only to
Comet. The Spark arm retains normal broadcast planning for the small build
table, and the reported SHJ/SMJ ratios compare different strategies between
engines.
Could we wrap each case in shared `withSQLConf` strategy settings, leaving
the Comet feature flag in `extraCometConfigs`, and assert the actual join class
for both arms? Subquery-local hints are also viable on Spark 4.1.3, as verified
in the SQL-fixture comment. This would make the benchmark labels and
performance comparisons reflect the strategy being measured.
##########
spark/src/test/resources/sql-tests/join/existence_join.sql:
##########
@@ -0,0 +1,167 @@
+-- Licensed to the Apache Software Foundation (ASF) under one
+-- or more contributor license agreements. See the NOTICE file
+-- distributed with this work for additional information
+-- regarding copyright ownership. The ASF licenses this file
+-- to you under the Apache License, Version 2.0 (the
+-- "License"); you may not use this file except in compliance
+-- with the License. You may obtain a copy of the License at
+--
+-- http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing,
+-- software distributed under the License is distributed on an
+-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+-- KIND, either express or implied. See the License for the
+-- specific language governing permissions and limitations
+-- under the License.
+
+-- Tests for ExistenceJoin: produced when EXISTS / NOT EXISTS is combined
+-- with another predicate via OR, preventing rewrite to LeftSemi / LeftAnti.
+-- Each query runs against the three physical join strategies (BHJ, SHJ,
+-- SMJ) via hints, so we exercise CometBroadcastHashJoinExec,
+-- CometHashJoinExec, and CometSortMergeJoinExec all carrying joinType =
+-- ExistenceJoin.
+
+-- Native ExistenceJoin support is experimental and disabled by default.
+-- Config: spark.comet.exec.existenceJoin.enabled=true
+
+-- ============================================================
+-- Setup: covers NULLs, duplicates, empty build side
+-- ============================================================
+
+statement
+CREATE TABLE ex_left(id int, k int, region string) USING parquet
+
+statement
+INSERT INTO ex_left VALUES
+ (1, 1, 'US'),
+ (2, 2, 'EU'),
+ (3, NULL, 'US'),
+ (4, 4, 'EU'),
+ (5, 5, 'EU')
+
+statement
+CREATE TABLE ex_right(id int, k int) USING parquet
+
+statement
+INSERT INTO ex_right VALUES (10, 1), (11, 2), (12, 2), (13, NULL)
+
+statement
+CREATE TABLE ex_right_no_nulls(id int, k int) USING parquet
+
+statement
+INSERT INTO ex_right_no_nulls VALUES (10, 1), (11, 5)
+
+statement
+CREATE TABLE ex_right_empty(id int, k int) USING parquet
+
+statement
+CREATE TABLE ex_right_dups(id int, k int) USING parquet
+
+statement
+INSERT INTO ex_right_dups VALUES (10, 1), (11, 1), (12, 1), (13, 2)
+
+-- ============================================================
+-- EXISTS with OR: BHJ build-right
+-- ============================================================
+
+query
+SELECT /*+ BROADCAST(ex_right) */ * FROM ex_left l
+WHERE l.region = 'US' OR EXISTS (SELECT 1 FROM ex_right r WHERE r.k = l.k)
+ORDER BY l.id
+
+-- ============================================================
+-- EXISTS with OR: SHJ build-right
+-- ============================================================
+
+query
+SELECT /*+ SHUFFLE_HASH(ex_right) */ * FROM ex_left l
Review Comment:
[P2] Place strategy hints in the subquery that owns the relation
Following up on the [fixture concern already
raised](https://github.com/apache/datafusion-comet/pull/4587#issuecomment-5723010195):
I confirmed on Spark 4.1.3 with AQE disabled that the outer
`SHUFFLE_HASH(ex_right)` and `MERGE(ex_right)` hints cannot resolve `ex_right`,
emit warnings, and both produce a broadcast hash join.
One correction to that discussion: putting the hint inside `EXISTS` does
work in this version. `EXISTS (SELECT /*+ SHUFFLE_HASH(r) */ 1 FROM ex_right r
WHERE r.k=l.k)` selects `ShuffledHashJoin`, and the corresponding `MERGE(r)`
selects `SortMergeJoin` in the fresh plan probes.
Could we move the hints into their subqueries and assert the selected
physical strategy, or use a configuration matrix that enforces the same
coverage? The current fixture does not establish null/duplicate/empty-input
coverage across the named strategies. The separate Scala tests do cover all
three operators for their simple unique, non-null integer case.
##########
native/core/src/execution/planner.rs:
##########
@@ -2699,6 +2699,7 @@ impl PhysicalPlanner {
Ok(JoinType::FullOuter) => DFJoinType::Full,
Ok(JoinType::LeftSemi) => DFJoinType::LeftSemi,
Ok(JoinType::LeftAnti) => DFJoinType::LeftAnti,
+ Ok(JoinType::Existence) => DFJoinType::LeftMark,
Review Comment:
[P2] Avoid enumerating every duplicate build match
For Spark's `BuildRight`, the later input swap turns this `LeftMark` into
`RightMark`. The pinned DataFusion 55.1 hash lookup enumerates every equal-key
candidate pair before the RightMark path reduces duplicate probe indices to
Boolean markers. With N identical build keys and M matching probe rows, that
means N*M candidate matches for M output rows, rather than Spark's existence
lookup stopping after a match.
Could we deduplicate the evaluated build-key tuples for residual-free
existence joins, or use a mark-join implementation that stops after the first
match? For example, 10,000 identical build keys and 1,000 matching probes imply
10 million candidates even though only 1,000 markers are needed. The new
benchmark's distinct build keys do not exercise this case.
I verified that Spark 4.1.3 preserves duplicate build rows in this plan
without inserting a distinct aggregate, and checked the duplicate-chain
traversal in the exact DataFusion 55.1 source. Any deduplication helper should
account for its memory and support bounded spilling. It must not discard
distinct right payloads while residual predicates can still use them. No
current-version performance timing is claimed here.
--
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]