LuciferYang commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3821016426


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,722 @@
+/*
+ * 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.gluten.extension
+
+import org.apache.gluten.config.VeloxConfig
+
+import org.apache.spark.internal.Logging
+import org.apache.spark.sql.SparkSession
+import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.expressions.RowOrdering
+import org.apache.spark.sql.catalyst.expressions.aggregate._
+import org.apache.spark.sql.catalyst.plans._
+import org.apache.spark.sql.catalyst.plans.logical._
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.types.LongType
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 
1.
+ *
+ * Targets three patterns; all require an existence-only context 
(LeftSemi/LeftAnti join, or
+ * InSubquery/Exists expression) so that row-count multiplicity from the 
self-join cross-product
+ * does not affect semantics.
+ *
+ *   - Pattern A' (InSubquery/Exists primary): InSubquery/Exists whose 
subquery top-level join is a
+ *     direct self-join. The primary path for TPC-DS Q95.
+ *   - Pattern A2 (nested): InSubquery/Exists whose subquery contains an outer 
InnerJoin that has a
+ *     self-join child. Only the self-join child is replaced with Aggregate; 
the outer join is
+ *     preserved.
+ *   - Pattern A (LeftSemi/LeftAnti): LeftSemi/LeftAnti whose right child is 
an Inner self-join
+ *     (possibly wrapped in Project). Matches semi/anti joins that already 
exist in the input --
+ *     e.g. from an explicit `LEFT SEMI JOIN` clause. Note: this rule is 
injected via
+ *     `injectOptimizerRule`, which places it in the operator-optimization 
batch that runs BEFORE
+ *     `RewritePredicateSubquery`; A is NOT a post-subquery-rewrite fallback 
for A'.
+ *
+ * Correlated subqueries (outer references / joinCond in ListQuery/Exists) are 
fail-closed at the
+ * entry expression, since our ExprId canonicalization does not remap those 
predicates.
+ *
+ * All three share:
+ *   - [[buildAggregateHavingDistinctGt1]] to construct `Filter(cnt > 1, 
Aggregate)`
+ *   - [[canonicalizeWrapper]] to rebuild a wrapping Project so every equi-key 
reference points to
+ *     the sjLeft-side attribute, with **fresh exprIds** (Spark's SPARK-21835 
style -- no reuse of
+ *     original exprIds), returning an old->new attribute remap for downstream 
rewrite.
+ *
+ * Controlled by `spark.gluten.sql.rewrite.selfJoinInequality` (default false, 
opt-in).
+ */
+case class RewriteSelfJoinInequalityToAggregate(spark: SparkSession)
+  extends Rule[LogicalPlan]
+  with PredicateHelper
+  with Logging {
+
+  private val CountDistinctAliasName = "_gluten_rw_selfjoin_cnt_distinct"
+
+  override def apply(plan: LogicalPlan): LogicalPlan = {
+    if (!VeloxConfig.get.enableRewriteSelfJoinInequality) {
+      logDebug("RewriteSelfJoinInequalityToAggregate: disabled via config, 
skipping")
+      return plan
+    }
+
+    // Pattern A: rewrite LeftSemi/LeftAnti whose right child is an Inner 
self-join.
+    val afterOps = plan.transformUp {
+      case j: Join
+          if (j.joinType == LeftSemi || j.joinType == LeftAnti) &&
+            j.condition.isDefined &&
+            isInnerJoinShape(j.right) =>
+        tryRewriteSemiWithSelfJoinChild(j).getOrElse(j)
+      case other => other
+    }
+
+    // Pattern A' / A2: rewrite subquery plans embedded in InSubquery/Exists.
+    // Type-based matching (`x: T`) + named-argument copy keeps this portable 
across
+    // Spark 3.3/3.4/3.5/4.x where ListQuery/Exists case-class arity has 
drifted.
+    //
+    // Correlated subquery fail-closed: `SubqueryExpression.children.nonEmpty` 
iff the
+    // subquery has outer references / correlated join conditions. These 
predicates
+    // reference attributes INSIDE the subquery plan by ExprId; our 
canonicalizeWrapper
+    // rewrites those ExprIds without remapping the correlated predicates, 
which would
+    // leave dangling references after `RewritePredicateSubquery` folds them 
back into
+    // the semi-join condition. Target workload (TPC-DS Q95) is uncorrelated, 
so bail
+    // on any correlated candidate rather than growing the remap surface.
+    val rewritten = afterOps.transformAllExpressions {
+      case in @ InSubquery(_, lq: ListQuery) if lq.children.isEmpty =>
+        rewriteSubqueryPlan(lq.plan) match {
+          case Some(newSub) => in.copy(query = lq.copy(plan = newSub))
+          case None => in
+        }
+      case ex: Exists if ex.children.isEmpty =>
+        rewriteSubqueryPlan(ex.plan) match {
+          case Some(newSub) => ex.copy(plan = newSub)
+          case None => ex
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+
+  // 
============================================================================
+  //  Shared helpers
+  // 
============================================================================
+
+  private def isInnerJoinShape(plan: LogicalPlan): Boolean = plan match {
+    case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined 
=> true
+    case j: Join if j.joinType == Inner && j.condition.isDefined => true
+    case _ => false
+  }
+
+  /**
+   * Build `Filter(cnt > 1, Aggregate(equiKeys, [equiKeys, cnt_alias], 
Filter(IsNotNull(equiKeys),
+   * child)))`. Returns the Filter node whose output is `equiKeys ++ 
[count_alias_attr]`.
+   *
+   * The extra `IsNotNull(equiKeys)` filter is essential to preserve the 
original equi-join's NULL
+   * semantics. Under SQL 3VL, `left.k = right.k` never matches when either 
side is NULL, so the
+   * original self-join drops rows with NULL equi-keys. Aggregate, in 
contrast, groups NULL keys
+   * together into a single "NULL group" -- if that group has >= 2 distinct 
non-null neq values,
+   * COUNT(DISTINCT) > 1 fires and injects NULL into the subquery output. That 
leaked NULL then
+   * turns `NOT IN` into a spurious empty result (Spark's null-aware anti-join 
uses
+   * `Or(equi, IsNull(equi))` which any NULL sub-row satisfies) and can flip 
EXISTS/IN outcomes. The
+   * neq column needs no such filter: `COUNT(DISTINCT col)` already ignores 
NULL.
+   */
+  private def buildAggregateHavingDistinctGt1(
+      equiKeys: Seq[Attribute],
+      neqCol: Attribute,
+      child: LogicalPlan): LogicalPlan = {
+    val countExpr = AggregateExpression(
+      Count(Seq(neqCol)),
+      mode = Complete,
+      isDistinct = true,
+      filter = None,
+      NamedExpression.newExprId)
+    val countAlias = Alias(countExpr, CountDistinctAliasName)()
+    // Seq[Attribute] is a Seq[NamedExpression] via covariance; no cast needed.
+    val aggExprs: Seq[NamedExpression] = equiKeys :+ countAlias
+    val nonNullChild = equiKeys
+      .map(a => IsNotNull(a): Expression)
+      .reduceOption(And)
+      .map(Filter(_, child))
+      .getOrElse(child)
+    val agg = Aggregate(equiKeys, aggExprs, nonNullChild)
+    Filter(GreaterThan(countAlias.toAttribute, Literal(1L, LongType)), agg)
+  }
+
+  /**
+   * Canonicalize a Project so every equi-key reference points at the 
sjLeft-side attribute (both
+   * sides of a valid self-join share names, so this substitution is 
semantically safe). Uses
+   * **fresh exprIds** (no reuse of original wrapper output exprIds) -- the 
same technique Spark's
+   * own `dedupSubqueryOnSelfJoin` uses when it needs to change subquery 
output.
+   *
+   * Returns the rebuilt Project and a map `oldWrapperOutputExprId -> 
newWrapperOutputAttr`, so
+   * downstream references (outer join condition, top-level Project) can be 
updated consistently.
+   *
+   * `equiPairs` provides the definitive ExprId-based lookup: `equiPair (l, 
r)` binds
+   * `l.exprId -> l` (identity) and `r.exprId -> l` (sjRight -> sjLeft). 
Attribute identity in
+   * Catalyst is ExprId, not name; two columns can share a name with distinct 
ExprIds. Name-based
+   * lookup would silently drop such entries via `.toMap`.
+   *
+   * Fails (returns None) when a projectList entry is neither an equi-key 
Attribute (by ExprId) nor
+   * `Alias(equi-key Attribute, _)`. Fail-closed.
+   */
+  private def canonicalizeWrapper(
+      projectList: Seq[NamedExpression],
+      equiPairs: Seq[(Attribute, Attribute)],
+      newChild: LogicalPlan): Option[(Project, Map[ExprId, Attribute])] = {
+    // ExprId-based canonical map: any equi-key attribute (either side) -> 
sjLeft attribute.
+    val exprIdToLeft: Map[ExprId, Attribute] =
+      equiPairs.flatMap { case (l, r) => Seq(l.exprId -> l, r.exprId -> l) 
}.toMap
+    val oldOutput: Seq[Attribute] = projectList.map(_.toAttribute)
+    val mapped: Seq[Option[NamedExpression]] = projectList.map {
+      case a: Attribute if exprIdToLeft.contains(a.exprId) =>
+        // Wrap every rewritten output slot in a fresh Alias.
+        //
+        // When a wrapper reprojects BOTH sides of the same equi pair (e.g.
+        // `SELECT s1.k, s2.k FROM T s1 JOIN T s2 ON s1.k = s2.k AND s1.v <> 
s2.v`),
+        // both entries collapse to the same sjLeft Attribute after the 
self-join is
+        // rewritten. Duplicate output ExprIds are not illegal in Spark 
(`SELECT a, a`
+        // is a valid Project), but fresh Aliases give each output slot an 
independent
+        // identity, which keeps the `oldOutput -> newOutput` remap 1-to-1 and 
lets
+        // downstream references (outer join condition, top-level Project) be 
updated
+        // unambiguously via ExprId.
+        //
+        // The fresh ExprId is on the Alias ITSELF; the referenced child keeps 
its
+        // original ExprId. Spark's logical-plan integrity checks reject 
reusing a
+        // referenced ExprId as the Alias's own ExprId, not duplication across 
slots.
+        Some(Alias(exprIdToLeft(a.exprId), a.name)(): NamedExpression)
+      case al @ Alias(a: Attribute, _) if exprIdToLeft.contains(a.exprId) =>
+        // Fresh exprId; do NOT reuse `al.exprId`. Reusing another 
expression's exprId
+        // is the pattern that Spark 3.3 flags via structural-integrity checks.
+        Some(Alias(exprIdToLeft(a.exprId), al.name)(): NamedExpression)
+      case _ => None

Review Comment:
   The common `EXISTS (SELECT 1 FROM t1 JOIN t2 ...)` shape is analyzed as 
`Project(Literal(1), Join(...))`, but `canonicalizeWrapper` falls through to 
`case _ => None` for that projection, so the A' rewrite is skipped. The config 
and PR description advertise `Exists` support, yet this form still materializes 
the self-join's O(N²) build side; the current EXISTS test is a single-table 
correlated subquery and does not exercise it. Please preserve the EXISTS output 
shape while allowing literal projections, and add an uncorrelated EXISTS 
regression test that contains the self-join.



-- 
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