You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@gluten.apache.org by "hhr293 (via GitHub)" <gi...@apache.org> on 2026/08/12 09:17:03 UTC

[PR] Rewrite selfjoin inequality to aggregate [gluten]

hhr293 opened a new pull request, #12756:
URL: https://github.com/apache/gluten/pull/12756

    ## What changes are proposed in this pull request?
   
     Adds a Catalyst optimizer rule `RewriteSelfJoinInequalityToAggregate`
     that rewrites a self-join with an inequality predicate into an
     aggregate + HAVING, when the join output is consumed by an
     existence-only operator (`InSubquery` / `Exists` / `LeftSemi` /
     `LeftAnti`).
   
     Structural rewrite:
   
     ```
     SELECT ...
     WHERE key IN (
       SELECT t1.key FROM T t1 JOIN T t2
         ON t1.key = t2.key AND t1.col <> t2.col
     )
     -- becomes
     SELECT ...
     WHERE key IN (
       SELECT key FROM T
       GROUP BY key HAVING COUNT(DISTINCT col) > 1
     )
     ```
   
     **Motivation.** Under existence semantics only the set of qualifying
     keys matters, not row multiplicity. The self-join produces `O(N^2)`
     intermediate rows per equi-key group before being funneled into a
     `LeftSemi` join; the aggregate produces one row per group with the
     same membership predicate.
   
     On TPC-DS q95, the two `LeftSemi` build tables downstream of the
     inequality self-join hold **2.35 B / 3.08 B rows / 80 / 91 GiB** each
     (measured from spark-history). After the rewrite they shrink to
     **18 M / 22 M rows / 591 / 663 MiB** — a ~150x reduction in build-side
     data — and q95 wall drops from 59.86 s to 18.64 s.
     **Patterns matched.**
   
     - **Pattern A'** — `InSubquery` / `Exists` whose subquery top-level
       join is the self-join (primary path, fires before
       `RewritePredicateSubquery`).
     - **Pattern A2** — the self-join is nested inside another `InnerJoin`
       within the subquery. Only the self-join subtree is replaced; the
       outer join is preserved.
     - **Pattern A** — `LeftSemi` / `LeftAnti` whose right child is an
       Inner self-join (post-`RewritePredicateSubquery` fallback).
   
     **Conservative guards (fail-closed).**
   
     - Only accept `EqualTo(a, b)` and `Not(EqualTo(a, b))`; reject
       `EqualNullSafe` and `IS DISTINCT FROM`.
     - Single inequality column only. A disjunction such as
       `col1 <> col1 OR col2 <> col2` is not equivalent to
       `COUNT(DISTINCT single_col) > 1`.
     - Both self-join sides must expose the same attribute names.
     - `IsNotNull` predicates are only accepted on the equi / inequality
       columns (those are auto-inserted by `InferFiltersFromConstraints`);
       `IsNotNull` on any other column causes the rule to bail out so no
       user-visible filter is silently dropped.
     - For Pattern A2, the subquery's top-level `Project` must reference
       only equi-key attributes from the self-join side, otherwise the
       rewrite would leave unresolved references.
   
     **Config.** Gated by `spark.gluten.sql.rewrite.selfJoinInequality`,
     **opt-in default `false`**. The rewrite has been validated on TPC-DS
     q95 across three hardware platforms but non-q95 workloads have not
     been broadly exercised; keeping it opt-in until wider evidence
     accumulates prevents surprising behavior for existing users.
    ## How was this patch tested?
   
     **Unit tests** (`RewriteSelfJoinInequalityToAggregateSuite`, 10
     cases):
     - Positive: Pattern A' (EXISTS / InSubquery), Pattern A2 (nested).
     - Semantic parity: NULL / 3VL row results identical with rule on vs.
       off on a fixture that exercises NULL, distinct-only, and duplicate
       values.
     - Negative (rule must not fire): plain `InnerJoin` at top level,
       `IS DISTINCT FROM`, multi-column inequality, `IsNotNull` on a
       non-join column, `LeftOuter` join, and config disabled.
   
     **TPC-DS SF=300** (3 executors x 4 cores, Intel Xeon GNR-AP):
     - q95 wall: 59.86 s -> 18.64 s (-69%).
     - All other 107 queries stay within a `+/- 2 s` noise band for a
       single run; no regression attributable to the rule.
   


-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3931179227


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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)

Review Comment:
   Good catch. The fresh ExprId is intentional, but dropping the Alias metadata is not.
   
   I’ll make this path consistent with remapNamedExpressionAttributes: keep the newly generated ExprId, while preserving the original qualifier, explicitMetadata, and nonInheritableMetadataKeys.
   
   That keeps the plan-level metadata intact without changing the intended attribute identity semantics.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "LuciferYang (via GitHub)" <gi...@apache.org>.
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on PR #12756:
URL: https://github.com/apache/gluten/pull/12756#issuecomment-5439446824

   > I think we should commit this to Apache Spark too.
   
   Thank you for your suggestion. I will do it recently.


-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3871871854


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,674 @@
+/*
+ * 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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.LongType
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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)),

Review Comment:
   > The COUNT(DISTINCT neqCol) > 1 rewrite diverges from the original s1.neqCol <> s2.neqCol predicate for special float/double values:
   > 
   > * -0.0 vs +0.0: SQL -0.0 = 0.0 is true, so -0.0 <> 0.0 is false. The original self-join produces no row. But Spark's COUNT(DISTINCT) uses bit-level/Java Double.equals which keeps -0.0 != 0.0, so COUNT(DISTINCT {-0.0, 0.0}) = 2. The rewrite fires and the row becomes IN when it should be OUT.
   > * NaN: The reverse — two NaN rows satisfy NaN <> NaN in SQL (true), so the original matches; but Java NaN.equals(NaN) is true so COUNT(DISTINCT NaN) = 1. The rewrite doesn't fire when it should.
   
   Good catch. Floating-point comparison and DISTINCT semantics may differ across Spark versions/backends, especially for NaN and signed zero. To keep the rewrite fail-closed, I’ll reject Float/Double inequality columns for now and add regression tests for both cases.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "beliefer (via GitHub)" <gi...@apache.org>.
beliefer commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3871372304


##########
backends-velox/src/test/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregateSuite.scala:
##########
@@ -0,0 +1,548 @@
+/*
+ * 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.execution.WholeStageTransformerSuite
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.catalyst.expressions.Alias
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{IntegerType, StructField, StructType}
+
+/**
+ * Correctness tests for [[RewriteSelfJoinInequalityToAggregate]].
+ *
+ * Positive A' / A2 cases assert both result equivalence and that the rewrite actually fired.
+ *
+ * `assert(!ruleFired(plan))` on its own only proves the rewrite did not happen -- not that it was
+ * the guard under test that stopped it. A fixture whose two self-join sides are not structurally
+ * identical is rejected by `isSameBaseRelation` before any predicate is even parsed, and such a
+ * test passes while covering nothing. So six important rejection paths -- the predicate parser, the
+ * single-inequality requirement, output-position identity, the nondeterminism guard, the
+ * leaf-source allowlist (LogicalRDD vs Parquet), and the expression-type allowlist (`abs(v)` vs
+ * `v + 1`) -- are tested as single-variable pairs: the same fixture and the same query shape, one
+ * control query that must fire and one variant that changes only the feature under test and must
+ * not. A firing control does not pin the rejection to a particular line, but it does rule out an
+ * unrelated fixture mismatch as the reason its partner was rejected. The row-bag whitelist
+ * (Aggregate, Window) stays a plain negative: dropping the operator would change the query shape
+ * rather than one feature.
+ *
+ * Self-joined fixtures are real tables, not temp views over VALUES. Spark deduplicates a self-join
+ * over a [[org.apache.spark.sql.catalyst.analysis.MultiInstanceRelation]] via `newInstance()`,
+ * which refreshes one side's ExprIds without inserting a rename-only Project, so both sides stay
+ * structurally identical. A temp view over VALUES cannot, and Spark renames one side with a Project
+ * instead, which would make `isSameBaseRelation` false for every self-join below. `range()` needs
+ * no such treatment -- Range is a MultiInstanceRelation already.
+ */
+class RewriteSelfJoinInequalityToAggregateSuite extends WholeStageTransformerSuite {
+
+  override protected val resourcePath: String = "/tpch-data-parquet"
+  override protected val fileFormat: String = "parquet"
+
+  override protected def sparkConf: SparkConf = super.sparkConf
+    .set("spark.gluten.sql.rewrite.selfJoinInequality", "true")
+    .set(SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key, "-1")
+
+  /** Signature alias produced by the rewrite; presence => rule definitely fired. */
+  private val CountDistinctAlias = "_gluten_rw_selfjoin_cnt_distinct"
+
+  private def ruleFired(plan: LogicalPlan): Boolean =
+    plan.exists {
+      p =>
+        p.expressions.exists(_.exists {
+          case a: Alias if a.name == CountDistinctAlias => true
+          case _ => false
+        })
+    }
+
+  private def assertRuleFired(sql: String): Unit = {
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "true") {
+      val plan = spark.sql(sql).queryExecution.optimizedPlan
+      assert(ruleFired(plan), s"self-join inequality rewrite should fire:\n$plan")
+    }
+  }
+
+  private def assertRuleNotFired(sql: String): Unit = {
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "true") {
+      val plan = spark.sql(sql).queryExecution.optimizedPlan
+      assert(!ruleFired(plan), s"self-join inequality rewrite must not fire:\n$plan")
+    }
+  }
+
+  /**
+   * A real table, so that a self-join of it dedups into two structurally identical sides. See the
+   * class comment for why a temp view over VALUES cannot be used for a self-joined fixture.
+   */
+  private def createTable(name: String, schema: String, values: String): Unit = {
+    spark.sql(s"DROP TABLE IF EXISTS $name")
+    spark.sql(s"CREATE TABLE $name($schema) USING parquet")
+    spark.sql(s"INSERT INTO $name SELECT * FROM VALUES $values")
+  }
+
+  /** Run `sql` twice, first with rewrite ON then OFF, and return the two result row sets. */
+  private def runBoth(sql: String): (Set[Row], Set[Row]) = {
+    var on: Set[Row] = null
+    var off: Set[Row] = null
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "true") {
+      on = spark.sql(sql).collect().toSet
+    }
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "false") {
+      off = spark.sql(sql).collect().toSet
+    }
+    (on, off)
+  }
+
+  private def setupTable(): Unit = {
+    // k=1: distinct v={10,20}      -> matches (has 2 non-null distinct)
+    // k=2: distinct v={30}         -> no match (only 1)
+    // k=3: distinct v={40,50,60}   -> matches
+    // k=4: v={70, NULL}            -> no match (only 1 non-null)
+    // k=5: v={NULL, NULL}          -> no match (0 non-null)
+    // k=6: v={80, 90, NULL}        -> matches
+    createTable(
+      "T",
+      "k INT, v INT",
+      """  (1, 10), (1, 10), (1, 20),
+        |  (2, 30),
+        |  (3, 40), (3, 50), (3, 60),
+        |  (4, 70), (4, CAST(NULL AS INT)),
+        |  (5, CAST(NULL AS INT)), (5, CAST(NULL AS INT)),
+        |  (6, 80), (6, 90), (6, CAST(NULL AS INT))""".stripMargin
+    )
+  }
+
+  // ==================== Positive: rewrite fires and is semantically equivalent ===============
+
+  test("Pattern A': direct InSubquery self-join is rewritten") {
+    setupTable()
+    val sql =
+      """SELECT k FROM T outer_t WHERE k IN (
+        |  SELECT s1.k FROM T s1 JOIN T s2
+        |    ON s1.k = s2.k AND s1.v <> s2.v)""".stripMargin
+
+    assertRuleFired(sql)

Review Comment:
   By adding and optimizing SQL equivalent to the execution plan here, and then using comparePlans for comparison, the process becomes more intuitive



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3765744620


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,646 @@
+/*
+ * 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.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 (post-RewritePredicateSubquery fallback): LeftSemi/LeftAnti whose right child is an
+ * Inner self-join (possibly wrapped in Project). The outer semi-join condition is pure equi-key
+ * referencing a column from the self-join output. The self-join condition has equi + inequality on
+ * the same table. Rewrites the right child to GROUP BY + HAVING.
+ *
+ * Pattern A' (pre-RewritePredicateSubquery, primary path): InSubquery(_, ListQuery(sub, ...)) or
+ * Exists(sub, ...) whose sub is Project(Inner self-join with equi+neq). Rewrites sub to
+ * Project(equi_keys, Filter(count_distinct>1, Aggregate)). Fires in the Operator Optimization
+ * batches BEFORE Spark lifts them to LeftSemi/LeftAnti; the outer expression form guarantees
+ * existence semantics. ScalarSubquery is intentionally NOT matched.
+ *
+ * Pattern A2 (nested self-join): InSubquery/Exists whose subquery plan is Project(Join(Inner,
+ * other_table, self-join)) -- the self-join is a child of another InnerJoin, not the top-level join
+ * itself. Only the self-join child is replaced with Aggregate; the outer join is preserved. Safe
+ * because the outer join connects on the equi-key, and the subquery is still consumed as an
+ * existence set.
+ *
+ * Controlled by spark.gluten.sql.rewrite.selfJoinInequality (default false, opt-in until exercised
+ * more broadly across workloads).
+ */
+case class RewriteSelfJoinInequalityToAggregate(spark: SparkSession)
+  extends Rule[LogicalPlan]
+  with PredicateHelper
+  with Logging {
+
+  override def apply(plan: LogicalPlan): LogicalPlan = {
+    if (!VeloxConfig.get.enableRewriteSelfJoinInequality) {
+      logDebug("RewriteSelfJoinInequalityToAggregate: disabled via config, skipping")
+      return plan
+    }
+
+    val afterOps = plan.transformUp {
+      // Pattern A: LeftSemi/LeftAnti whose right child is an Inner self-join
+      // (possibly wrapped in Project).
+      case j: Join
+          if (j.joinType == LeftSemi || j.joinType == LeftAnti) &&
+            j.condition.isDefined &&
+            isInnerJoinShape(j.right) =>
+        tryRewriteSemiWithSelfJoinChild(j, j.left, j.right, j.joinType, j.condition.get, j.hint)
+          .getOrElse(j)
+
+      case other => other
+    }
+
+    // Pattern A': rewrite subquery plans embedded in InSubquery/Exists expressions.
+    // Fires before Spark's RewritePredicateSubquery (batch pos 26); once the subquery
+    // plan is rewritten to GROUP BY + HAVING, RewritePredicateSubquery lifts it to
+    // LeftSemi/LeftAnti in the normal way.
+    // Use type-based matching (`x: T`) and named-argument copy (`x.copy(plan = ...)`)
+    // instead of case-class unapply with a fixed parameter list. This keeps the code
+    // portable across Spark 3.3/3.4/3.5/4.x where the internal ListQuery/Exists
+    // case classes have added parameters over releases.
+    val rewritten = afterOps.transformAllExpressions {
+      case in @ InSubquery(_, lq: ListQuery) =>
+        rewriteSubqueryPlan(lq.plan) match {
+          case Some(newSub) => in.copy(query = lq.copy(plan = newSub))
+          case None => in
+        }
+      case ex: Exists =>
+        rewriteSubqueryPlan(ex.plan) match {
+          case Some(newSub) => ex.copy(plan = newSub)
+          case None => ex
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logInfo("RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+        "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+
+  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
+  }
+
+  /**
+   * Dispatches subquery plan rewriting: tries Pattern A' (direct self-join at top level) first,
+   * then Pattern A2 (self-join nested as a child of another InnerJoin).
+   *
+   * Only called on subquery plans of `InSubquery` / `Exists`, i.e. contexts that consume the output
+   * as a set of distinct keys. Cardinality of the intermediate is safe to change.
+   */
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(plan, projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(plan, projectListOpt, innerJoin)
+    }
+  }
+
+  private def rewriteDirectSelfJoin(
+      plan: LogicalPlan,
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+
+    // Use field accessors (portable across Spark versions) instead of
+    // case-class unapply with a fixed parameter list. The caller has already
+    // pattern-matched innerJoin as Join(_, _, Inner, Some(_), _).
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs = equiPairs.map(_._1).collect { case a: Attribute => a }
+    if (innerLeftEquiAttrs.size != equiPairs.size) return None
+
+    val innerLeftNeqAttr = neqPairs.head._1 match {
+      case a: Attribute => a
+      case _ => return None
+    }
+
+    val countDistinctExpr = AggregateExpression(
+      Count(Seq(innerLeftNeqAttr)),
+      mode = Complete,
+      isDistinct = true,
+      filter = None,
+      NamedExpression.newExprId)
+    val countAlias = Alias(countDistinctExpr, "_gluten_rw_selfjoin_cnt_distinct")()
+
+    val groupingExprs: Seq[Expression] = innerLeftEquiAttrs
+    val aggExprs: Seq[NamedExpression] =
+      innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]) :+ countAlias
+    val aggregate = Aggregate(groupingExprs, aggExprs, innerLeft)
+
+    val filterExpr = GreaterThan(countAlias.toAttribute, Literal(1L, LongType))
+    val filtered = Filter(filterExpr, aggregate)
+
+    val nameToInnerLeft: Map[String, Attribute] = innerLeftEquiAttrs.map(a => a.name -> a).toMap

Review Comment:
   `nameToInnerLeft` is built with `.toMap` keyed by attribute name. If equi-key attributes contain duplicate names (possible with projections/aliases), `.toMap` will silently drop earlier entries and the remapping below can bind the wrong attribute. Since this rule is intended to be fail-closed, add a guard that equi-key names are distinct before constructing the map (or switch to ExprId-based mapping).
   
   This issue also appears in the following locations of the same file:
   - line 322
   - line 509



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "LuciferYang (via GitHub)" <gi...@apache.org>.
LuciferYang commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3822503878


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

Review Comment:
   The `case ex: Exists` at line 100 is unreachable. An uncorrelated `Exists` is replaced by `IsNotNull(ScalarSubquery(Limit 1, Project(1, plan)))` by `RewriteNonCorrelatedExists` in `Batch("Finish Analysis")`, far ahead of the operator-optimization batch where injected rules live, so they never see it. A correlated `Exists` is blocked by the `children.isEmpty` guard on the same line. The branch is therefore dead code on 3.3 through 4.1, checked version by version, and the `Exists` mentioned in the config doc, in `docs/velox-configuration.md` and in the class comment never applies.
   
   The earlier comment about the literal projection describes the same symptom, but that layer is never reached. Real EXISTS support means matching the rewritten `ScalarSubquery(Limit 1, ...)` shape, and `Limit` is itself rejected by `isRowBagRepeatable`. If that is out of scope, drop the branch and remove `Exists` from the config and docs wording.



##########
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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Anything else in a projectList --
+   * e.g. computed expressions we don't own -- is passed through unchanged.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): NamedExpression = ne match {
+    case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+    case a: Attribute => a
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      if (newChild eq al.child) al
+      else Alias(newChild, al.name)(al.exprId, al.qualifier, al.explicitMetadata)
+    case other => other
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery / Exists)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition guarantees Seq[(Attribute, Attribute)] and distinct equi-key names.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map {
+          case (newWrapper, _) =>
+            logDebug(
+              s"Pattern A' - equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}]" +
+                s", neqCol=${innerLeftNeqAttr.name}" +
+                s", outCols=[${newWrapper.projectList.map(_.name).mkString(",")}]")
+            newWrapper
+        }
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A2 : self-join nested inside another InnerJoin in the subquery
+  // ============================================================================
+
+  private def rewriteNestedSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+    val outerCond = outerJoin.condition.get
+
+    val (selfJoinSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerJoin.right) match {
+        case Some(_) => (outerJoin.right, true)
+        case None =>
+          tryExtractSelfJoin(outerJoin.left) match {
+            case Some(_) => (outerJoin.left, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(p), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition guarantees Seq[(Attribute, Attribute)] and distinct equi-key names.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val sjLeftNeqAttr: Attribute = neqPairs.head._1
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val sjEquiExprIds: Set[ExprId] =
+      equiPairs.flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    // wrapper Project may reproject equi-keys under fresh alias exprIds; include those.
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt.toSeq.flatMap {
+      p =>
+        p.projectList.flatMap {
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) => Some(a.exprId)
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) => Some(al.exprId)
+          case _ => None
+        }
+    }.toSet
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+
+    // Outer join condition may reference only equi-key attrs from the self-join side.
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+
+    // Top-level subquery Project may reference only equi-key attrs from the self-join side.
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val refs = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        refs.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val filtered = buildAggregateHavingDistinctGt1(sjLeftEquiAttrs, sjLeftNeqAttr, sjLeft)
+
+    val (newSelfJoinSide, outputRemap): (LogicalPlan, Map[ExprId, Attribute]) =
+      selfJoinProjectOpt match {
+        case Some(wp) =>
+          canonicalizeWrapper(wp.projectList, equiPairs, filtered) match {
+            case Some((newWrapper, remap)) => (newWrapper, remap)
+            case None => return None
+          }
+        case None if projectListOpt.isEmpty =>
+          // Fail-closed: with neither a wrapper Project around the self-join nor a top-level
+          // subquery Project, the outer join currently exposes every self-join column, and
+          // replacing the self-join with `Project(equiKeys, filtered)` would shrink the outer
+          // join's right-hand output arity. RewritePredicateSubquery's positional zip
+          // (`values.zip(sub.output).map(EqualTo.tupled)`) would then bind semi predicates to
+          // the wrong attributes -- silently dropping components of a tuple IN/EXISTS. A
+          // top-level Project (`projectListOpt`) is what would let the arity be preserved
+          // by the top-level rewrite loop; without one, refuse to rewrite.
+          return None
+        case None =>
+          // No wrapper Project but there IS a top-level subquery Project: shrinking the outer
+          // join's self-join-side output is safe because the top-level Project is rewritten
+          // consistently via `outputRemap` below and the top-level rewrite loop ensures
+          // subquery output arity matches what the enclosing InSubquery/Exists expects.
+          // Outer references may point at sjRight equi-attributes; remap them to sjLeft
+          // (same names in a valid self-join).
+          val newP = Project(sjLeftEquiAttrs, filtered)
+          val remap: Map[ExprId, Attribute] =
+            equiPairs.map { case (l, r) => r.exprId -> l }.toMap
+          (newP, remap)
+      }
+
+    // Rewrite outer join condition to use new wrapper output attributes.
+    val newOuterCond = outerCond.transformUp {
+      case a: Attribute if outputRemap.contains(a.exprId) => outputRemap(a.exprId)
+    }
+
+    val newOuterJoin = if (selfJoinOnRight) {
+      outerJoin.copy(right = newSelfJoinSide, condition = Some(newOuterCond))
+    } else {
+      outerJoin.copy(left = newSelfJoinSide, condition = Some(newOuterCond))
+    }
+
+    // Rewrite top-level Project references.
+    val result = projectListOpt match {
+      case Some(pl) =>
+        val newPl = pl.map(ne => remapNamedExpressionAttributes(ne, outputRemap))
+        Project(newPl, newOuterJoin)
+      case None => newOuterJoin
+    }
+
+    logDebug(
+      s"Pattern A2 - equiKeys=[${sjLeftEquiAttrs.map(_.name).mkString(",")}]" +
+        s", neqCol=${sjLeftNeqAttr.name}")
+    Some(result)
+  }
+
+  private def tryExtractSelfJoin(plan: LogicalPlan): Option[Join] = {
+    val join = plan match {
+      case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => j
+      case j: Join if j.joinType == Inner && j.condition.isDefined => j
+      case _ => return None
+    }
+    if (!isSameBaseRelation(join.left, join.right)) return None
+    val parsed = parseSelfJoinCondition(join.condition.get, join.left, join.right)
+    if (parsed.isEmpty) return None
+    Some(join)
+  }
+
+  // ============================================================================
+  //  Pattern A : LeftSemi/LeftAnti whose right child is an Inner self-join
+  // ============================================================================
+
+  private def tryRewriteSemiWithSelfJoinChild(original: Join): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard on the entire right subtree. Same rationale as
+    // in [[rewriteSubqueryPlan]]: catches Rand/Limit/Sample/Offset hoisted between the semi
+    // join and the inner self-join by an earlier optimizer rule.
+    if (!isRepeatablePlan(original.right)) return None
+
+    val left = original.left
+    val right = original.right
+    val semiCondition = original.condition.get
+
+    val (innerJoin, wrapper): (Join, Option[Project]) = right match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => (j, Some(p))
+      case j: Join if j.joinType == Inner && j.condition.isDefined => (j, None)
+      case _ => return None
+    }
+
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+    if (!isSameBaseRelation(innerLeft, innerRight)) return None
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition guarantees Seq[(Attribute, Attribute)] and distinct equi-key names.
+    val (innerEquiPairs, innerNeqPairs) = parsed.get
+
+    // All semi predicates must be pure equi-join.
+    val semiPreds = splitConjunctivePredicates(semiCondition)
+    val leftOutputSet = left.outputSet
+    val rightOutputSet = right.outputSet
+    val semiEquiPairs = semiPreds.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if leftOutputSet.contains(l) && rightOutputSet.contains(r) =>
+        (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if leftOutputSet.contains(l) && rightOutputSet.contains(r) =>
+        (l, r)
+    }
+    if (semiEquiPairs.size != semiPreds.size) return None
+    if (semiEquiPairs.isEmpty) return None
+
+    val innerLeftEquiAttrs: Seq[Attribute] = innerEquiPairs.map(_._1)
+    val innerRightEquiAttrs: Seq[Attribute] = innerEquiPairs.map(_._2)
+    val innerEquiLeftAttrIds = innerLeftEquiAttrs.map(_.exprId).toSet
+    val innerEquiRightAttrIds = innerRightEquiAttrs.map(_.exprId).toSet
+    val innerEquiAllIds = innerEquiLeftAttrIds ++ innerEquiRightAttrIds
+    val innerNeqAttr: Attribute = innerNeqPairs.head._1
+
+    // Semi right-side keys must derive from inner equi-key attributes, possibly via wrapper alias.
+    val rightKeyIds = semiEquiPairs.map(_._2.exprId).toSet
+    val validSemiKeys = rightKeyIds.forall {
+      id =>
+        innerEquiAllIds.contains(id) || wrapper.exists {
+          p =>
+            p.projectList.exists {
+              case al @ Alias(a: Attribute, _) =>
+                al.exprId == id && innerEquiAllIds.contains(a.exprId)
+              case a: Attribute =>
+                a.exprId == id && innerEquiAllIds.contains(a.exprId)
+              case _ => false
+            }
+        }
+    }
+    if (!validSemiKeys) return None
+
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerNeqAttr, innerLeft)
+
+    // Replace the entire right subtree with a Project of just the equi keys.
+    val projectedKeys = Project(innerLeftEquiAttrs, filtered)
+
+    // Build an ExprId-keyed remap from every attribute reachable via the old right side to
+    // the corresponding sjLeft equi-attribute:
+    //   (a) direct innerLeft equi attr -> identity (by ExprId)
+    //   (b) direct innerRight equi attr -> paired sjLeft attr (looked up via equiPairs, NOT name)
+    //   (c) wrapper `Alias(equiAttr, name)` output -> paired sjLeft attr (via inner Attribute's
+    //       ExprId, NOT the alias name)
+    // Never use column name as identity: Catalyst allows same-name attributes with distinct
+    // ExprIds (e.g. `SELECT a AS k, b AS k`) and `.toMap` by name would silently drop one.
+    val exprIdToInnerLeft: Map[ExprId, Attribute] =
+      innerEquiPairs.flatMap {
+        case (l, r) => Seq(l.exprId -> l, r.exprId -> l)
+      }.toMap
+    val wrapperRemap: Map[ExprId, Attribute] = wrapper.map {
+      p =>
+        p.projectList.flatMap {
+          case al @ Alias(a: Attribute, _) if exprIdToInnerLeft.contains(a.exprId) =>
+            Some(al.exprId -> exprIdToInnerLeft(a.exprId))
+          case _ => None
+        }.toMap
+    }.getOrElse(Map.empty)
+    val oldToNewMap: Map[ExprId, Attribute] = exprIdToInnerLeft ++ wrapperRemap
+
+    // Fail-closed: refuse to rewrite if any attribute in the old right side is unresolvable.
+    val unresolved = semiCondition.collect {
+      case a: Attribute if rightOutputSet.contains(a) && !oldToNewMap.contains(a.exprId) => a
+    }
+    if (unresolved.nonEmpty) return None
+
+    val newSemiCondition = semiCondition.transformUp {
+      case a: Attribute if oldToNewMap.contains(a.exprId) && rightOutputSet.contains(a) =>
+        oldToNewMap(a.exprId)
+    }
+
+    logDebug(
+      s"Pattern A (${original.joinType}) - equiKeys=[" +
+        innerLeftEquiAttrs.map(_.name).mkString(",") +
+        s"], neqCol=${innerNeqAttr.name}")
+    Some(original.copy(right = projectedKeys, condition = Some(newSemiCondition)))
+  }
+
+  // ============================================================================
+  //  parseSelfJoinCondition + isSameBaseRelation
+  // ============================================================================
+
+  /**
+   * Parse a join condition into equi-pairs and inequality-pairs. Accepts only:
+   *   - `EqualTo(attr, attr)` where the two attrs come from opposite sides,
+   *   - `Not(EqualTo(attr, attr))` -- same side rule,
+   *   - `IsNotNull(attr)` where the attr is one of the join columns.
+   * Anything else in the condition disqualifies the whole rewrite (fail-closed).
+   */
+  private def parseSelfJoinCondition(
+      condition: Expression,
+      leftPlan: LogicalPlan,
+      rightPlan: LogicalPlan)
+      : Option[(Seq[(Attribute, Attribute)], Seq[(Attribute, Attribute)])] = {
+
+    val leftOutput = leftPlan.outputSet
+    val rightOutput = rightPlan.outputSet
+    val predicates = splitConjunctivePredicates(condition)
+
+    val equiPairs = predicates.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    val neqPairs = predicates.collect {
+      case Not(EqualTo(l: Attribute, r: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case Not(EqualTo(r: Attribute, l: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    // Only IsNotNull predicates on join columns are safe to drop -- they're redundant with
+    // the join semantics or auto-added by InferFiltersFromConstraints. IsNotNull on other
+    // columns changes semantics if we drop it; bail out.
+    val joinAttrIds: Set[ExprId] =
+      (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    val isNotNullOnJoinCols = predicates.count {
+      case IsNotNull(a: Attribute) if joinAttrIds.contains(a.exprId) => true
+      case _ => false
+    }
+
+    val totalMatched = equiPairs.size + neqPairs.size + isNotNullOnJoinCols
+    if (totalMatched != predicates.size) return None
+
+    if (equiPairs.isEmpty || neqPairs.isEmpty) return None
+
+    // Only rewrite single-inequality case. Multi-column inequality
+    //   (col_a<>col_a OR col_b<>col_b)
+    // is NOT equivalent to COUNT(DISTINCT single_col) > 1.
+    if (neqPairs.size != 1) return None
+
+    // COUNT(DISTINCT neqCol) requires a hashable/orderable data type. Use Spark's public
+    // `RowOrdering.isOrderable` (which delegates to `OrderUtils.isOrderable`) to reject
+    // UDTs / Maps / and any complex type whose order-or-hash isn't defined. `AtomicType`
+    // would be a simpler predicate, but `AtomicType` is `protected[sql]` and thus not
+    // usable from Gluten's package. Fail-closed here so a query that would otherwise
+    // run does not crash in Catalyst's CheckAnalysis after our rule fires.
+    if (!neqPairs.forall { case (l, _) => RowOrdering.isOrderable(l.dataType) }) return None
+
+    // Self-join invariant: equi and neq columns share names between the two sides.
+    val equiValid = equiPairs.forall { case (l, r) => l.name == r.name }

Review Comment:
   `isSameBaseRelation` proves "both sides are the same relation" with `left.canonicalized == right.canonicalized`, but `parseSelfJoinCondition` proves "the two ends are the same column" with `l.name == r.name`. Canonicalization erases names: `QueryPlan.doCanonicalize` rewrites every `Alias` to `Alias(normalizedChild, "")`. So two projections that differ only by swapped column aliases canonicalize equal, and the name check then wires the equi pair and the neq pair to different underlying columns.
   
   With the two sides' aliases swapped (`a AS k, b AS v` against `a AS v, b AS k`), `s1.k = s2.k` really compares `t1.a` with `t2.b`, so IN returns extra rows and LeftAnti drops rows it should keep.
   
   Make the column-identity check positional too, each side looked up by its own end's ExprId: require `leftPlan.output.indexWhere(_.exprId == l.exprId)` to be `>= 0` and to equal `rightPlan.output.indexWhere(_.exprId == r.exprId)`. Do not use `indexOf` here: `AttributeReference.equals` also compares name and qualifier, and the attributes in the condition still carry their `SubqueryAlias` qualifier, so both sides would return `-1` and the rule would stop firing altogether, q95 included. The equi-key dedup at 638-639 needs the same treatment; `semiEquiPairs` does not, since its two sides are different relations.



##########
backends-velox/src/test/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregateSuite.scala:
##########
@@ -0,0 +1,446 @@
+/*
+ * 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.execution.WholeStageTransformerSuite
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.catalyst.expressions.Alias
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+import org.apache.spark.sql.internal.SQLConf
+
+/**
+ * Correctness tests for [[RewriteSelfJoinInequalityToAggregate]].
+ *
+ * Assertions center on **result equivalence** between `rewrite=true` and `rewrite=false`. That is
+ * the direct check of the rule's semantic contract: the rewrite must not change what the query
+ * returns. Plan-shape assertions (e.g. "an Aggregate node exists") are avoided because downstream
+ * optimizer rules differ across Spark versions -- e.g. Spark 4.0's `RewritePredicateSubquery` and
+ * constant folding collapse LocalRelation-based EXISTS bodies so aggressively that our rule may
+ * never see the original self-join shape yet the final result is still correct.
+ *
+ * Where a plan-level signal is useful, we look for the alias name our rule injects
+ * (`_gluten_rw_selfjoin_cnt_distinct`) as a soft indicator that the rule fired. Its absence is not
+ * treated as a failure -- an equivalent result via a different path is still a pass.
+ */
+class RewriteSelfJoinInequalityToAggregateSuite extends WholeStageTransformerSuite {
+
+  override protected val resourcePath: String = "/tpch-data-parquet"
+  override protected val fileFormat: String = "parquet"
+
+  override protected def sparkConf: SparkConf = super.sparkConf
+    .set("spark.gluten.sql.rewrite.selfJoinInequality", "true")
+    .set(SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key, "-1")
+
+  /** Signature alias produced by the rewrite; presence => rule definitely fired. */
+  private val CountDistinctAlias = "_gluten_rw_selfjoin_cnt_distinct"
+
+  private def ruleFired(plan: LogicalPlan): Boolean =
+    plan.exists {
+      p =>
+        p.expressions.exists(_.exists {
+          case a: Alias if a.name == CountDistinctAlias => true
+          case _ => false
+        })
+    }
+
+  /** Run `sql` twice, first with rewrite ON then OFF, and return the two result row sets. */
+  private def runBoth(sql: String): (Set[Row], Set[Row]) = {
+    var on: Set[Row] = null
+    var off: Set[Row] = null
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "true") {
+      on = spark.sql(sql).collect().toSet
+    }
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "false") {
+      off = spark.sql(sql).collect().toSet
+    }
+    (on, off)
+  }
+
+  private def setupTable(): Unit = {
+    // k=1: distinct v={10,20}      -> matches (has 2 non-null distinct)
+    // k=2: distinct v={30}         -> no match (only 1)
+    // k=3: distinct v={40,50,60}   -> matches
+    // k=4: v={70, NULL}            -> no match (only 1 non-null)
+    // k=5: v={NULL, NULL}          -> no match (0 non-null)
+    // k=6: v={80, 90, NULL}        -> matches
+    spark.sql(
+      """CREATE OR REPLACE TEMP VIEW T AS SELECT * FROM VALUES
+        |  (1, 10), (1, 10), (1, 20),
+        |  (2, 30),
+        |  (3, 40), (3, 50), (3, 60),
+        |  (4, 70), (4, CAST(NULL AS INT)),
+        |  (5, CAST(NULL AS INT)), (5, CAST(NULL AS INT)),
+        |  (6, 80), (6, 90), (6, CAST(NULL AS INT))
+        |AS T(k, v)""".stripMargin)
+  }
+
+  // ==================== Positive: rewrite is semantically equivalent ====================
+
+  test("Pattern A': EXISTS subquery with bare self-join produces equivalent results") {
+    setupTable()
+    val sql =
+      """SELECT k FROM T ws1 WHERE EXISTS (
+        |  SELECT 1 FROM T s WHERE s.k = ws1.k AND s.v <> ws1.v)""".stripMargin
+    val (on, off) = runBoth(sql)
+    assert(on == off, s"rewrite ON $on != OFF $off")
+    // Ground truth: only k in {1,3,6} have >=2 non-null distinct v.
+    assert(on == Set(Row(1), Row(3), Row(6)), s"expected {1,3,6}, got $on")
+  }
+
+  test("Pattern A': InSubquery with bare self-join produces equivalent results") {
+    setupTable()
+    val sql =
+      """SELECT k FROM T ws1 WHERE k IN (
+        |  SELECT s1.k FROM T s1, T s2
+        |  WHERE s1.k = s2.k AND s1.v <> s2.v)""".stripMargin
+    val (on, off) = runBoth(sql)
+    assert(on == off, s"rewrite ON $on != OFF $off")
+    assert(on == Set(Row(1), Row(3), Row(6)))
+  }
+
+  test("Pattern A2: self-join nested inside outer InnerJoin produces equivalent results") {
+    setupTable()
+    // Only k in {1,3,6} qualify from the self-join side; the outer InnerJoin with D
+    // (values {1,3,6}) intersects, so the final answer is again {1,3,6}.
+    spark.sql(
+      """CREATE OR REPLACE TEMP VIEW D AS SELECT * FROM VALUES
+        |  (1), (3), (6) AS D(k)""".stripMargin)
+    val sql =
+      """SELECT k FROM T outer_t WHERE k IN (
+        |  SELECT d.k
+        |  FROM D d, (SELECT s1.k FROM T s1, T s2
+        |             WHERE s1.k = s2.k AND s1.v <> s2.v) sj
+        |  WHERE d.k = sj.k)""".stripMargin
+    val (on, off) = runBoth(sql)
+    assert(on == off, s"Pattern A2 rewrite ON $on != OFF $off")
+    assert(on == Set(Row(1), Row(3), Row(6)))
+  }
+
+  // ==================== Semantic parity on NULL / 3VL ====================
+
+  test("NULL / 3VL: rows with only-NULL or single-non-null inequality column are excluded") {
+    setupTable()
+    val sql =
+      """SELECT k FROM T ws1 WHERE EXISTS (
+        |  SELECT 1 FROM T s WHERE s.k = ws1.k AND s.v <> ws1.v)""".stripMargin
+    val (on, off) = runBoth(sql)
+    // k=4 (v={70,NULL}) fails: <> with NULL is UNKNOWN -> filtered by WHERE.
+    // k=5 (v={NULL,NULL}) fails: every <> is UNKNOWN.
+    assert(on == Set(Row(1), Row(3), Row(6)), s"expected {1,3,6}, got $on")
+    assert(off == on, s"NULL/3VL semantics diverge between rewrite ON and OFF: $on vs $off")
+  }
+
+  // ==================== Negative: rewrite must produce equivalent results (or bail) ==========
+
+  test("Plain InnerJoin at top level: results unchanged (rewrite must not touch it)") {
+    setupTable()
+    val sql =
+      """SELECT ws1.k FROM T ws1 JOIN T ws2
+        |ON ws1.k = ws2.k AND ws1.v <> ws2.v""".stripMargin
+    // Row-multiplicity matters here; using count() to catch any drop or dup.
+    var onCount: Long = -1L
+    var offCount: Long = -1L
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "true") {
+      onCount = spark.sql(sql).count()
+    }
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "false") {
+      offCount = spark.sql(sql).count()
+    }
+    assert(
+      onCount == offCount,
+      s"plain InnerJoin row-count differs: rewrite=$onCount vs baseline=$offCount")
+  }
+
+  test("IS DISTINCT FROM: NULL-safe inequality preserves original semantics") {
+    setupTable()
+    // IS DISTINCT FROM treats NULL as distinguishable (NULL IS DISTINCT FROM x = TRUE,
+    // NULL IS DISTINCT FROM NULL = FALSE). Our rewrite must NOT fold this into
+    // COUNT(DISTINCT), because COUNT(DISTINCT) ignores NULL.
+    val sql =
+      """SELECT k FROM T ws1 WHERE EXISTS (
+        |  SELECT 1 FROM T s WHERE s.k = ws1.k
+        |    AND (s.v IS DISTINCT FROM ws1.v))""".stripMargin
+    val (on, off) = runBoth(sql)
+    assert(on == off, s"IS DISTINCT FROM semantics diverge: ON=$on OFF=$off")
+    // Sanity check: k=4 has (70, NULL) -- pair (v=70, v=NULL) IS DISTINCT FROM => TRUE
+    // so k=4 must be included (unlike the plain-neq case above where it's excluded).
+    assert(on.contains(Row(4)), s"k=4 should be in IS DISTINCT FROM result: $on")
+    // And rule fire signal must be absent: this is a rejection path.
+    val plan = spark.sql(sql).queryExecution.optimizedPlan
+    assert(!ruleFired(plan), s"rule must not fire on IS DISTINCT FROM:\n$plan")

Review Comment:
   The negative tests are all written as correlated subqueries, but `apply` only handles `InSubquery`/`Exists` with `children.isEmpty`, and a correlated subquery's `outerAttrs`/`joinCond` are non-empty by the time an injected rule runs, so the expression comes back untouched. The `IS DISTINCT FROM`, `IsNotNull`-on-non-join-column, multi-column-inequality, `rand()` and equi/neq-overlap tests, together with lines 95, 137 and 284, are eight queries whose subquery holds a single table and no Join node at all, so even dropping the correlation would not reach the branch each test is named after.
   
   Only the IN test at line 106 and the A2 test at line 117 trigger the rewrite, and only lines 386 and 407 are effective negative tests: line 426 uses `rand(41)` on one side and `rand(42)` on the other, so the candidate-level `plan.deterministic` is what rejects it first, and `isSameBaseRelation` catches it too, so deleting the determinism check keeps the test green. Replace `apply` with the identity function and all 19 still pass, because none of them asserts that the rule fired.
   
   Please rewrite the negative tests as uncorrelated subqueries with the self-join inside and a constant `WHERE`, and add `assert(ruleFired(plan))` to the positive ones. `IsNotNull(equiKeys)` additionally needs a fixture with a NULL equi key plus a `NOT IN` query.



##########
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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Anything else in a projectList --
+   * e.g. computed expressions we don't own -- is passed through unchanged.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): NamedExpression = ne match {
+    case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+    case a: Attribute => a
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      if (newChild eq al.child) al
+      else Alias(newChild, al.name)(al.exprId, al.qualifier, al.explicitMetadata)
+    case other => other
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery / Exists)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition guarantees Seq[(Attribute, Attribute)] and distinct equi-key names.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map {
+          case (newWrapper, _) =>
+            logDebug(
+              s"Pattern A' - equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}]" +
+                s", neqCol=${innerLeftNeqAttr.name}" +
+                s", outCols=[${newWrapper.projectList.map(_.name).mkString(",")}]")
+            newWrapper
+        }
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A2 : self-join nested inside another InnerJoin in the subquery
+  // ============================================================================
+
+  private def rewriteNestedSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+    val outerCond = outerJoin.condition.get
+
+    val (selfJoinSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerJoin.right) match {
+        case Some(_) => (outerJoin.right, true)
+        case None =>
+          tryExtractSelfJoin(outerJoin.left) match {
+            case Some(_) => (outerJoin.left, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(p), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition guarantees Seq[(Attribute, Attribute)] and distinct equi-key names.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val sjLeftNeqAttr: Attribute = neqPairs.head._1
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val sjEquiExprIds: Set[ExprId] =
+      equiPairs.flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    // wrapper Project may reproject equi-keys under fresh alias exprIds; include those.
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt.toSeq.flatMap {
+      p =>
+        p.projectList.flatMap {
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) => Some(a.exprId)
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) => Some(al.exprId)
+          case _ => None
+        }
+    }.toSet
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+
+    // Outer join condition may reference only equi-key attrs from the self-join side.
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+
+    // Top-level subquery Project may reference only equi-key attrs from the self-join side.
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val refs = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        refs.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val filtered = buildAggregateHavingDistinctGt1(sjLeftEquiAttrs, sjLeftNeqAttr, sjLeft)
+
+    val (newSelfJoinSide, outputRemap): (LogicalPlan, Map[ExprId, Attribute]) =
+      selfJoinProjectOpt match {
+        case Some(wp) =>
+          canonicalizeWrapper(wp.projectList, equiPairs, filtered) match {
+            case Some((newWrapper, remap)) => (newWrapper, remap)
+            case None => return None
+          }
+        case None if projectListOpt.isEmpty =>
+          // Fail-closed: with neither a wrapper Project around the self-join nor a top-level
+          // subquery Project, the outer join currently exposes every self-join column, and
+          // replacing the self-join with `Project(equiKeys, filtered)` would shrink the outer
+          // join's right-hand output arity. RewritePredicateSubquery's positional zip
+          // (`values.zip(sub.output).map(EqualTo.tupled)`) would then bind semi predicates to
+          // the wrong attributes -- silently dropping components of a tuple IN/EXISTS. A
+          // top-level Project (`projectListOpt`) is what would let the arity be preserved
+          // by the top-level rewrite loop; without one, refuse to rewrite.
+          return None
+        case None =>
+          // No wrapper Project but there IS a top-level subquery Project: shrinking the outer
+          // join's self-join-side output is safe because the top-level Project is rewritten
+          // consistently via `outputRemap` below and the top-level rewrite loop ensures
+          // subquery output arity matches what the enclosing InSubquery/Exists expects.
+          // Outer references may point at sjRight equi-attributes; remap them to sjLeft
+          // (same names in a valid self-join).
+          val newP = Project(sjLeftEquiAttrs, filtered)
+          val remap: Map[ExprId, Attribute] =
+            equiPairs.map { case (l, r) => r.exprId -> l }.toMap
+          (newP, remap)
+      }
+
+    // Rewrite outer join condition to use new wrapper output attributes.
+    val newOuterCond = outerCond.transformUp {
+      case a: Attribute if outputRemap.contains(a.exprId) => outputRemap(a.exprId)
+    }
+
+    val newOuterJoin = if (selfJoinOnRight) {
+      outerJoin.copy(right = newSelfJoinSide, condition = Some(newOuterCond))
+    } else {
+      outerJoin.copy(left = newSelfJoinSide, condition = Some(newOuterCond))
+    }
+
+    // Rewrite top-level Project references.
+    val result = projectListOpt match {
+      case Some(pl) =>
+        val newPl = pl.map(ne => remapNamedExpressionAttributes(ne, outputRemap))
+        Project(newPl, newOuterJoin)
+      case None => newOuterJoin
+    }
+
+    logDebug(
+      s"Pattern A2 - equiKeys=[${sjLeftEquiAttrs.map(_.name).mkString(",")}]" +
+        s", neqCol=${sjLeftNeqAttr.name}")
+    Some(result)
+  }
+
+  private def tryExtractSelfJoin(plan: LogicalPlan): Option[Join] = {
+    val join = plan match {
+      case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => j
+      case j: Join if j.joinType == Inner && j.condition.isDefined => j
+      case _ => return None
+    }
+    if (!isSameBaseRelation(join.left, join.right)) return None
+    val parsed = parseSelfJoinCondition(join.condition.get, join.left, join.right)
+    if (parsed.isEmpty) return None
+    Some(join)
+  }
+
+  // ============================================================================
+  //  Pattern A : LeftSemi/LeftAnti whose right child is an Inner self-join
+  // ============================================================================
+
+  private def tryRewriteSemiWithSelfJoinChild(original: Join): Option[LogicalPlan] = {

Review Comment:
   `tryRewriteSemiWithSelfJoinChild` plus `validSemiKeys`, `wrapperRemap` and `newSemiCondition` is about 110 lines, and it is the only path in this PR that rewrites a join condition in the main plan rather than inside a subquery. The suite contains zero `LEFT SEMI`/`LEFT ANTI` queries, so none of it has ever run.
   
   The only way in is an explicitly written `LEFT SEMI JOIN`/`LEFT ANTI JOIN`: `semiEquiPairs` accepts only `EqualTo`, while `ReplaceIntersectWithSemiJoin`/`ReplaceExceptWithAntiJoin` emit `EqualNullSafe`, so `INTERSECT`/`EXCEPT DISTINCT` never get in, and the semi joins that IN/EXISTS turn into only appear in the `RewriteSubquery` batch, after this rule has run.
   
   Please add explicit semi/anti tests, including the aliased shape that goes through the wrapper Project. Alternatively ship A' and A2 only for now and drop Pattern A together with that part of the comment.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on PR #12756:
URL: https://github.com/apache/gluten/pull/12756#issuecomment-5357780813

   > @hhr293 As @jackylee-ch mentioned, if this is generally applicable to Apache Spark, feel free to submit it directly to Apache Spark, I can help review the patch there.
   
   Thanks a lot for the suggestion and for offering to help review it in Spark!
   
   I'd definitely like to upstream this optimization to Spark once the Gluten PR is settled. The review here has already helped me better understand the correctness boundaries and the Catalyst plan shapes that need to be handled carefully, so I'll incorporate these findings into a more general Spark-side version.
   
   Once I have the Spark patch ready, I'll ping you there. Really appreciate the guidance and the offer to review it!
   


-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3823018347


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

Review Comment:
   Thanks for digging into the optimizer ordering in detail. I agree with your analysis.
   
   Given that uncorrelated `Exists` has already been rewritten by `RewriteNonCorrelatedExists` before this injected rule runs, while correlated `Exists` is intentionally filtered by the `children.isEmpty` guard, the current `Exists` branch does not provide effective coverage here.
   
   For this PR, I'll remove the unreachable `Exists` branch and update the config description, class comment, and documentation accordingly.
   
   I'll leave real `EXISTS` support to a follow-up, where it can be redesigned around the actual post-`RewriteNonCorrelatedExists` `ScalarSubquery(Limit 1, ...)` shape with dedicated tests.
   



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3931149472


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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) {

Review Comment:
   I don’t think it should be gated on columnar HashAgg. I also measured the rewrite on vanilla Spark JVM without Gluten/Velox: on TPC-DS Q95 SF300. Just as pr(https://github.com/apache/spark/pull/58424)description show, enabling the rule reduced Q95 runtime by roughly 46% (about a 1.87x speedup)



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3931185612


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Any other NamedExpression shape we
+   * do not rewrite is left as-is ONLY if it does not reference a replaced self-join output;
+   * otherwise it would carry a stale ExprId, so returns None to fail the whole rewrite closed.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): Option[NamedExpression] = ne match {
+    case a: Attribute if remap.contains(a.exprId) => Some(remap(a.exprId))
+    case a: Attribute => Some(a)
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      Some(
+        if (newChild eq al.child) {
+          al
+        } else {
+          Alias(newChild, al.name)(
+            al.exprId,
+            al.qualifier,
+            al.explicitMetadata,
+            al.nonInheritableMetadataKeys)
+        })
+    case other if other.references.exists(a => remap.contains(a.exprId)) =>
+      // Fail-closed: a NamedExpression we do not rewrite (neither a bare Attribute nor an Alias)
+      // that still references a replaced self-join output would be left with a dangling ExprId.
+      // Refuse the rewrite rather than emit a plan with a stale reference.
+      None
+    case other => Some(other)
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    // Fail closed on an explicit join hint. This rule deletes the self-join outright; a user
+    // `/*+ BROADCAST(...) */` (or SHUFFLE_*/MERGE) is an explicit optimizer directive about THAT
+    // join, and an opt-in rewrite has no business silently discarding it. Not a correctness bug
+    // (hints do not change results), but the conservative maintainer choice. `JoinHint.NONE` exists
+    // across all Spark versions Gluten supports, so no shim is needed.
+    if (innerJoin.hint != JoinHint.NONE) return None
+
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map {
+          case (newWrapper, _) =>
+            logDebug(
+              s"Pattern A' - equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}]" +
+                s", neqCol=${innerLeftNeqAttr.name}" +
+                s", outCols=[${newWrapper.projectList.map(_.name).mkString(",")}]")
+            newWrapper
+        }
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A2 : self-join nested inside another InnerJoin in the subquery
+  // ============================================================================
+
+  private def rewriteNestedSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+    val outerCond = outerJoin.condition.get
+
+    val (selfJoinSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerJoin.right) match {
+        case Some(_) => (outerJoin.right, true)
+        case None =>
+          tryExtractSelfJoin(outerJoin.left) match {
+            case Some(_) => (outerJoin.left, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(p), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val sjLeftNeqAttr: Attribute = neqPairs.head._1
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val sjEquiExprIds: Set[ExprId] =
+      equiPairs.flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    // wrapper Project may reproject equi-keys under fresh alias exprIds; include those.
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt.toSeq.flatMap {
+      p =>
+        p.projectList.flatMap {
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) => Some(a.exprId)
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) => Some(al.exprId)
+          case _ => None
+        }
+    }.toSet
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+
+    // Outer join condition may reference only equi-key attrs from the self-join side.
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+
+    // Top-level subquery Project may reference only equi-key attrs from the self-join side.
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val refs = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        refs.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val filtered = buildAggregateHavingDistinctGt1(sjLeftEquiAttrs, sjLeftNeqAttr, sjLeft)
+
+    val (newSelfJoinSide, outputRemap): (LogicalPlan, Map[ExprId, Attribute]) =
+      selfJoinProjectOpt match {
+        case Some(wp) =>
+          canonicalizeWrapper(wp.projectList, equiPairs, filtered) match {
+            case Some((newWrapper, remap)) => (newWrapper, remap)
+            case None => return None
+          }
+        case None if projectListOpt.isEmpty =>
+          // Fail-closed: with neither a wrapper Project around the self-join nor a top-level
+          // subquery Project, the outer join currently exposes every self-join column, and
+          // replacing the self-join with `Project(equiKeys, filtered)` would shrink the outer
+          // join's right-hand output arity. RewritePredicateSubquery's positional zip
+          // (`values.zip(sub.output).map(EqualTo.tupled)`) would then bind semi predicates to
+          // the wrong attributes -- silently dropping components of a tuple IN. A
+          // top-level Project (`projectListOpt`) is what would let the arity be preserved
+          // by the top-level rewrite loop; without one, refuse to rewrite.
+          return None
+        case None =>
+          // No wrapper Project but there IS a top-level subquery Project: shrinking the outer
+          // join's self-join-side output is safe because the top-level Project is rewritten
+          // consistently via `outputRemap` below and the top-level rewrite loop ensures
+          // subquery output arity matches what the enclosing InSubquery expects.
+          // Outer references may point at sjRight equi-attributes; remap them to sjLeft
+          // (same output position in a valid self-join).
+          val newP = Project(sjLeftEquiAttrs, filtered)

Review Comment:
   That analysis makes sense. I’d prefer to keep this branch as a defensive fallback rather than remove it, but I’ll update the comment to make it clear.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3931157894


##########
backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala:
##########
@@ -918,6 +921,17 @@ object VeloxConfig extends ConfigRegistry {
       .booleanConf
       .createWithDefault(false)
 
+  val ENABLE_REWRITE_SELF_JOIN_INEQUALITY =
+    buildConf("spark.gluten.sql.rewrite.selfJoinInequality")

Review Comment:
   Yeah, this is deliberate.
   
   I’d like to keep spark.gluten.sql.rewrite.selfJoinInequality public but default-off, so users can explicitly opt in while the rule gets broader workload coverage. That is also why it is documented in velox-configuration.md.
   
   So I would prefer not to mark it .internal() at this stage.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3822940299


##########
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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Anything else in a projectList --
+   * e.g. computed expressions we don't own -- is passed through unchanged.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): NamedExpression = ne match {
+    case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+    case a: Attribute => a
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      if (newChild eq al.child) al
+      else Alias(newChild, al.name)(al.exprId, al.qualifier, al.explicitMetadata)
+    case other => other
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery / Exists)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition guarantees Seq[(Attribute, Attribute)] and distinct equi-key names.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map {
+          case (newWrapper, _) =>
+            logDebug(
+              s"Pattern A' - equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}]" +
+                s", neqCol=${innerLeftNeqAttr.name}" +
+                s", outCols=[${newWrapper.projectList.map(_.name).mkString(",")}]")
+            newWrapper
+        }
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A2 : self-join nested inside another InnerJoin in the subquery
+  // ============================================================================
+
+  private def rewriteNestedSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+    val outerCond = outerJoin.condition.get
+
+    val (selfJoinSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerJoin.right) match {
+        case Some(_) => (outerJoin.right, true)
+        case None =>
+          tryExtractSelfJoin(outerJoin.left) match {
+            case Some(_) => (outerJoin.left, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(p), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition guarantees Seq[(Attribute, Attribute)] and distinct equi-key names.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val sjLeftNeqAttr: Attribute = neqPairs.head._1
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val sjEquiExprIds: Set[ExprId] =
+      equiPairs.flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    // wrapper Project may reproject equi-keys under fresh alias exprIds; include those.
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt.toSeq.flatMap {
+      p =>
+        p.projectList.flatMap {
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) => Some(a.exprId)
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) => Some(al.exprId)
+          case _ => None
+        }
+    }.toSet
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+
+    // Outer join condition may reference only equi-key attrs from the self-join side.
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+
+    // Top-level subquery Project may reference only equi-key attrs from the self-join side.
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val refs = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        refs.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val filtered = buildAggregateHavingDistinctGt1(sjLeftEquiAttrs, sjLeftNeqAttr, sjLeft)
+
+    val (newSelfJoinSide, outputRemap): (LogicalPlan, Map[ExprId, Attribute]) =
+      selfJoinProjectOpt match {
+        case Some(wp) =>
+          canonicalizeWrapper(wp.projectList, equiPairs, filtered) match {
+            case Some((newWrapper, remap)) => (newWrapper, remap)
+            case None => return None
+          }
+        case None if projectListOpt.isEmpty =>
+          // Fail-closed: with neither a wrapper Project around the self-join nor a top-level
+          // subquery Project, the outer join currently exposes every self-join column, and
+          // replacing the self-join with `Project(equiKeys, filtered)` would shrink the outer
+          // join's right-hand output arity. RewritePredicateSubquery's positional zip
+          // (`values.zip(sub.output).map(EqualTo.tupled)`) would then bind semi predicates to
+          // the wrong attributes -- silently dropping components of a tuple IN/EXISTS. A
+          // top-level Project (`projectListOpt`) is what would let the arity be preserved
+          // by the top-level rewrite loop; without one, refuse to rewrite.
+          return None
+        case None =>
+          // No wrapper Project but there IS a top-level subquery Project: shrinking the outer
+          // join's self-join-side output is safe because the top-level Project is rewritten
+          // consistently via `outputRemap` below and the top-level rewrite loop ensures
+          // subquery output arity matches what the enclosing InSubquery/Exists expects.
+          // Outer references may point at sjRight equi-attributes; remap them to sjLeft
+          // (same names in a valid self-join).
+          val newP = Project(sjLeftEquiAttrs, filtered)
+          val remap: Map[ExprId, Attribute] =
+            equiPairs.map { case (l, r) => r.exprId -> l }.toMap
+          (newP, remap)
+      }
+
+    // Rewrite outer join condition to use new wrapper output attributes.
+    val newOuterCond = outerCond.transformUp {
+      case a: Attribute if outputRemap.contains(a.exprId) => outputRemap(a.exprId)
+    }
+
+    val newOuterJoin = if (selfJoinOnRight) {
+      outerJoin.copy(right = newSelfJoinSide, condition = Some(newOuterCond))
+    } else {
+      outerJoin.copy(left = newSelfJoinSide, condition = Some(newOuterCond))
+    }
+
+    // Rewrite top-level Project references.
+    val result = projectListOpt match {
+      case Some(pl) =>
+        val newPl = pl.map(ne => remapNamedExpressionAttributes(ne, outputRemap))
+        Project(newPl, newOuterJoin)
+      case None => newOuterJoin
+    }
+
+    logDebug(
+      s"Pattern A2 - equiKeys=[${sjLeftEquiAttrs.map(_.name).mkString(",")}]" +
+        s", neqCol=${sjLeftNeqAttr.name}")
+    Some(result)
+  }
+
+  private def tryExtractSelfJoin(plan: LogicalPlan): Option[Join] = {
+    val join = plan match {
+      case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => j
+      case j: Join if j.joinType == Inner && j.condition.isDefined => j
+      case _ => return None
+    }
+    if (!isSameBaseRelation(join.left, join.right)) return None
+    val parsed = parseSelfJoinCondition(join.condition.get, join.left, join.right)
+    if (parsed.isEmpty) return None
+    Some(join)
+  }
+
+  // ============================================================================
+  //  Pattern A : LeftSemi/LeftAnti whose right child is an Inner self-join
+  // ============================================================================
+
+  private def tryRewriteSemiWithSelfJoinChild(original: Join): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard on the entire right subtree. Same rationale as
+    // in [[rewriteSubqueryPlan]]: catches Rand/Limit/Sample/Offset hoisted between the semi
+    // join and the inner self-join by an earlier optimizer rule.
+    if (!isRepeatablePlan(original.right)) return None
+
+    val left = original.left
+    val right = original.right
+    val semiCondition = original.condition.get
+
+    val (innerJoin, wrapper): (Join, Option[Project]) = right match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => (j, Some(p))
+      case j: Join if j.joinType == Inner && j.condition.isDefined => (j, None)
+      case _ => return None
+    }
+
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+    if (!isSameBaseRelation(innerLeft, innerRight)) return None
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition guarantees Seq[(Attribute, Attribute)] and distinct equi-key names.
+    val (innerEquiPairs, innerNeqPairs) = parsed.get
+
+    // All semi predicates must be pure equi-join.
+    val semiPreds = splitConjunctivePredicates(semiCondition)
+    val leftOutputSet = left.outputSet
+    val rightOutputSet = right.outputSet
+    val semiEquiPairs = semiPreds.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if leftOutputSet.contains(l) && rightOutputSet.contains(r) =>
+        (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if leftOutputSet.contains(l) && rightOutputSet.contains(r) =>
+        (l, r)
+    }
+    if (semiEquiPairs.size != semiPreds.size) return None
+    if (semiEquiPairs.isEmpty) return None
+
+    val innerLeftEquiAttrs: Seq[Attribute] = innerEquiPairs.map(_._1)
+    val innerRightEquiAttrs: Seq[Attribute] = innerEquiPairs.map(_._2)
+    val innerEquiLeftAttrIds = innerLeftEquiAttrs.map(_.exprId).toSet
+    val innerEquiRightAttrIds = innerRightEquiAttrs.map(_.exprId).toSet
+    val innerEquiAllIds = innerEquiLeftAttrIds ++ innerEquiRightAttrIds
+    val innerNeqAttr: Attribute = innerNeqPairs.head._1
+
+    // Semi right-side keys must derive from inner equi-key attributes, possibly via wrapper alias.
+    val rightKeyIds = semiEquiPairs.map(_._2.exprId).toSet
+    val validSemiKeys = rightKeyIds.forall {
+      id =>
+        innerEquiAllIds.contains(id) || wrapper.exists {
+          p =>
+            p.projectList.exists {
+              case al @ Alias(a: Attribute, _) =>
+                al.exprId == id && innerEquiAllIds.contains(a.exprId)
+              case a: Attribute =>
+                a.exprId == id && innerEquiAllIds.contains(a.exprId)
+              case _ => false
+            }
+        }
+    }
+    if (!validSemiKeys) return None
+
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerNeqAttr, innerLeft)
+
+    // Replace the entire right subtree with a Project of just the equi keys.
+    val projectedKeys = Project(innerLeftEquiAttrs, filtered)
+
+    // Build an ExprId-keyed remap from every attribute reachable via the old right side to
+    // the corresponding sjLeft equi-attribute:
+    //   (a) direct innerLeft equi attr -> identity (by ExprId)
+    //   (b) direct innerRight equi attr -> paired sjLeft attr (looked up via equiPairs, NOT name)
+    //   (c) wrapper `Alias(equiAttr, name)` output -> paired sjLeft attr (via inner Attribute's
+    //       ExprId, NOT the alias name)
+    // Never use column name as identity: Catalyst allows same-name attributes with distinct
+    // ExprIds (e.g. `SELECT a AS k, b AS k`) and `.toMap` by name would silently drop one.
+    val exprIdToInnerLeft: Map[ExprId, Attribute] =
+      innerEquiPairs.flatMap {
+        case (l, r) => Seq(l.exprId -> l, r.exprId -> l)
+      }.toMap
+    val wrapperRemap: Map[ExprId, Attribute] = wrapper.map {
+      p =>
+        p.projectList.flatMap {
+          case al @ Alias(a: Attribute, _) if exprIdToInnerLeft.contains(a.exprId) =>
+            Some(al.exprId -> exprIdToInnerLeft(a.exprId))
+          case _ => None
+        }.toMap
+    }.getOrElse(Map.empty)
+    val oldToNewMap: Map[ExprId, Attribute] = exprIdToInnerLeft ++ wrapperRemap
+
+    // Fail-closed: refuse to rewrite if any attribute in the old right side is unresolvable.
+    val unresolved = semiCondition.collect {
+      case a: Attribute if rightOutputSet.contains(a) && !oldToNewMap.contains(a.exprId) => a
+    }
+    if (unresolved.nonEmpty) return None
+
+    val newSemiCondition = semiCondition.transformUp {
+      case a: Attribute if oldToNewMap.contains(a.exprId) && rightOutputSet.contains(a) =>
+        oldToNewMap(a.exprId)
+    }
+
+    logDebug(
+      s"Pattern A (${original.joinType}) - equiKeys=[" +
+        innerLeftEquiAttrs.map(_.name).mkString(",") +
+        s"], neqCol=${innerNeqAttr.name}")
+    Some(original.copy(right = projectedKeys, condition = Some(newSemiCondition)))
+  }
+
+  // ============================================================================
+  //  parseSelfJoinCondition + isSameBaseRelation
+  // ============================================================================
+
+  /**
+   * Parse a join condition into equi-pairs and inequality-pairs. Accepts only:
+   *   - `EqualTo(attr, attr)` where the two attrs come from opposite sides,
+   *   - `Not(EqualTo(attr, attr))` -- same side rule,
+   *   - `IsNotNull(attr)` where the attr is one of the join columns.
+   * Anything else in the condition disqualifies the whole rewrite (fail-closed).
+   */
+  private def parseSelfJoinCondition(
+      condition: Expression,
+      leftPlan: LogicalPlan,
+      rightPlan: LogicalPlan)
+      : Option[(Seq[(Attribute, Attribute)], Seq[(Attribute, Attribute)])] = {
+
+    val leftOutput = leftPlan.outputSet
+    val rightOutput = rightPlan.outputSet
+    val predicates = splitConjunctivePredicates(condition)
+
+    val equiPairs = predicates.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    val neqPairs = predicates.collect {
+      case Not(EqualTo(l: Attribute, r: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case Not(EqualTo(r: Attribute, l: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    // Only IsNotNull predicates on join columns are safe to drop -- they're redundant with
+    // the join semantics or auto-added by InferFiltersFromConstraints. IsNotNull on other
+    // columns changes semantics if we drop it; bail out.
+    val joinAttrIds: Set[ExprId] =
+      (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    val isNotNullOnJoinCols = predicates.count {
+      case IsNotNull(a: Attribute) if joinAttrIds.contains(a.exprId) => true
+      case _ => false
+    }
+
+    val totalMatched = equiPairs.size + neqPairs.size + isNotNullOnJoinCols
+    if (totalMatched != predicates.size) return None
+
+    if (equiPairs.isEmpty || neqPairs.isEmpty) return None
+
+    // Only rewrite single-inequality case. Multi-column inequality
+    //   (col_a<>col_a OR col_b<>col_b)
+    // is NOT equivalent to COUNT(DISTINCT single_col) > 1.
+    if (neqPairs.size != 1) return None
+
+    // COUNT(DISTINCT neqCol) requires a hashable/orderable data type. Use Spark's public
+    // `RowOrdering.isOrderable` (which delegates to `OrderUtils.isOrderable`) to reject
+    // UDTs / Maps / and any complex type whose order-or-hash isn't defined. `AtomicType`
+    // would be a simpler predicate, but `AtomicType` is `protected[sql]` and thus not
+    // usable from Gluten's package. Fail-closed here so a query that would otherwise
+    // run does not crash in Catalyst's CheckAnalysis after our rule fires.
+    if (!neqPairs.forall { case (l, _) => RowOrdering.isOrderable(l.dataType) }) return None
+
+    // Self-join invariant: equi and neq columns share names between the two sides.
+    val equiValid = equiPairs.forall { case (l, r) => l.name == r.name }

Review Comment:
   You are right. Matching the two sides by attribute name is not strong enough here, since aliases can produce matching names while referring to different underlying output positions.
   
   I'll resolve each predicate attribute against the corresponding child output by ExprId and require the two sides to refer to the same output ordinal. I'll also add a regression case with swapped aliases to make sure this cannot be rewritten incorrectly.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3822772554


##########
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:
   Thanks for the detailed review. I agree with this point.
   
   I'll add a separate commit to this PR to cover the EXISTS (SELECT 1 ...) case you mentioned, including an uncorrelated EXISTS regression test with an actual self-join, and make sure the rewrite is really exercised by the test.
   
   While working on this rule, I also noticed that the current matching is fairly conservative, so there are some other safe plan/projection shapes that are not covered yet. I'd prefer to keep this fix focused on the case raised here, and gradually broaden the supported patterns in follow-up PRs with dedicated regression coverage.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "LuciferYang (via GitHub)" <gi...@apache.org>.
LuciferYang commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3930589946


##########
backends-velox/src/test/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregateSuite.scala:
##########
@@ -0,0 +1,976 @@
+/*
+ * 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.execution.WholeStageTransformerSuite
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.catalyst.expressions.{Alias, GreaterThan, Literal}
+import org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, Count}
+import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, Filter, LogicalPlan}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{IntegerType, LongType, StructField, StructType}
+
+/**
+ * Correctness tests for [[RewriteSelfJoinInequalityToAggregate]].
+ *
+ * Positive A' / A2 cases assert both result equivalence and that the rewrite actually fired.
+ *
+ * `assert(!ruleFired(plan))` on its own only proves the rewrite did not happen -- not that it was
+ * the guard under test that stopped it. A fixture whose two self-join sides are not structurally
+ * identical is rejected by `isSameBaseRelation` before any predicate is even parsed, and such a
+ * test passes while covering nothing. So six important rejection paths -- the predicate parser, the
+ * single-inequality requirement, output-position identity, the nondeterminism guard, the
+ * leaf-source allowlist (LogicalRDD vs Parquet), and the expression-type allowlist (`abs(v)` vs
+ * `v + 1`) -- are tested as single-variable pairs: the same fixture and the same query shape, one
+ * control query that must fire and one variant that changes only the feature under test and must
+ * not. A firing control does not pin the rejection to a particular line, but it does rule out an
+ * unrelated fixture mismatch as the reason its partner was rejected. The row-bag whitelist
+ * (Aggregate, Window) stays a plain negative: dropping the operator would change the query shape
+ * rather than one feature.
+ *
+ * Self-joined fixtures are real tables, not temp views over VALUES. Spark deduplicates a self-join
+ * over a [[org.apache.spark.sql.catalyst.analysis.MultiInstanceRelation]] via `newInstance()`,
+ * which refreshes one side's ExprIds without inserting a rename-only Project, so both sides stay
+ * structurally identical. A temp view over VALUES cannot, and Spark renames one side with a Project
+ * instead, which would make `isSameBaseRelation` false for every self-join below. `range()` needs
+ * no such treatment -- Range is a MultiInstanceRelation already.
+ */
+class RewriteSelfJoinInequalityToAggregateSuite extends WholeStageTransformerSuite {
+
+  override protected val resourcePath: String = "/tpch-data-parquet"
+  override protected val fileFormat: String = "parquet"
+
+  override protected def sparkConf: SparkConf = super.sparkConf
+    .set("spark.gluten.sql.rewrite.selfJoinInequality", "true")

Review Comment:
   The SF=300 Q95 number in the description settles the benefit, and the first positive case at `:208` is already the Q95 shape, so neither of those is the gap.
   
   The gap is a different dimension: this is a Velox-backend rule, yet the suite has no `checkGlutenOperatorMatch` anywhere, so nothing says whether the rewritten aggregate is offloaded to Velox or falls back to vanilla. The suite does run in CI, so what is missing is the assertion, not the execution — one line on the existing `:208` case would do it.
   
   Separately, the only places the repo sets this config to true are this suite's own fixture and a few `withSQLConf` blocks in the same file. Neither `.github/workflows` nor `tools/gluten-it` mentions it, and Q95 runs in both of those with the config at `false`, so there is no standing evidence that the rule still fires on the real Q95 plan. Not worth touching the shared TPC-DS job over; just flagging it.



##########
backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxRuleApi.scala:
##########
@@ -58,6 +58,7 @@ object VeloxRuleApi {
     injector.injectOptimizerRule(CollapseGetJsonObjectExpressionRule.apply)
     injector.injectOptimizerRule(RewriteCastFromArray.apply)
     injector.injectOptimizerRule(RewriteUnboundedWindow.apply)
+    injector.injectOptimizerRule(RewriteSelfJoinInequalityToAggregate.apply)

Review Comment:
   "18 Scala tests in `RewriteSelfJoinInequalityToAggregateSuite` pass" does not match the code: the suite has 30 `test` blocks, with 17 `assertRuleFired` and 21 `assertRuleNotFired` call sites. Worth correcting, since the description is the only text that survives a squash merge and is what a later reader will trust when judging which guards are covered.
   
   Separately, "runs before `RewritePredicateSubquery`" is accurate but reads as a single pass. The rule lands in two fixed-point batches, each up to 100 iterations, and runs again inside every `OptimizeSubqueries` recursion level. What keeps that safe is `isRepeatablePlan` rejecting any plan that contains `Aggregate`, backed by the structure itself: A' no longer has a `Join` on top, and A2 keeps its outer InnerJoin but `tryExtractSelfJoin` then returns None on the rewritten side. Worth stating.



##########
backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala:
##########
@@ -918,6 +921,17 @@ object VeloxConfig extends ConfigRegistry {
       .booleanConf
       .createWithDefault(false)
 
+  val ENABLE_REWRITE_SELF_JOIN_INEQUALITY =
+    buildConf("spark.gluten.sql.rewrite.selfJoinInequality")

Review Comment:
   `spark.gluten.sql.rewrite.selfJoinInequality` does not call `.internal()`, while `spark.gluten.sql.rewrite.unboundedWindow` immediately above it does (though the third member of the family, `ENABLE_REWRITE_CAST_ARRAY_TO_STRING`, does not either). Its own doc says "until the rewrite has been exercised more broadly across workloads", which reads like an internal switch, yet the name is public and now documented in `docs/velox-configuration.md`.
   
   Either add `.internal()` to match the neighbour, or it is deliberately public so users can try it. Just checking which one it is.



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Any other NamedExpression shape we
+   * do not rewrite is left as-is ONLY if it does not reference a replaced self-join output;
+   * otherwise it would carry a stale ExprId, so returns None to fail the whole rewrite closed.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): Option[NamedExpression] = ne match {
+    case a: Attribute if remap.contains(a.exprId) => Some(remap(a.exprId))
+    case a: Attribute => Some(a)
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      Some(
+        if (newChild eq al.child) {
+          al
+        } else {
+          Alias(newChild, al.name)(
+            al.exprId,
+            al.qualifier,
+            al.explicitMetadata,
+            al.nonInheritableMetadataKeys)
+        })
+    case other if other.references.exists(a => remap.contains(a.exprId)) =>
+      // Fail-closed: a NamedExpression we do not rewrite (neither a bare Attribute nor an Alias)
+      // that still references a replaced self-join output would be left with a dangling ExprId.
+      // Refuse the rewrite rather than emit a plan with a stale reference.
+      None
+    case other => Some(other)
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    // Fail closed on an explicit join hint. This rule deletes the self-join outright; a user
+    // `/*+ BROADCAST(...) */` (or SHUFFLE_*/MERGE) is an explicit optimizer directive about THAT
+    // join, and an opt-in rewrite has no business silently discarding it. Not a correctness bug
+    // (hints do not change results), but the conservative maintainer choice. `JoinHint.NONE` exists
+    // across all Spark versions Gluten supports, so no shim is needed.
+    if (innerJoin.hint != JoinHint.NONE) return None
+
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map {
+          case (newWrapper, _) =>
+            logDebug(
+              s"Pattern A' - equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}]" +
+                s", neqCol=${innerLeftNeqAttr.name}" +
+                s", outCols=[${newWrapper.projectList.map(_.name).mkString(",")}]")
+            newWrapper
+        }
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A2 : self-join nested inside another InnerJoin in the subquery
+  // ============================================================================
+
+  private def rewriteNestedSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+    val outerCond = outerJoin.condition.get
+
+    val (selfJoinSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerJoin.right) match {
+        case Some(_) => (outerJoin.right, true)
+        case None =>
+          tryExtractSelfJoin(outerJoin.left) match {
+            case Some(_) => (outerJoin.left, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(p), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val sjLeftNeqAttr: Attribute = neqPairs.head._1
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val sjEquiExprIds: Set[ExprId] =
+      equiPairs.flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    // wrapper Project may reproject equi-keys under fresh alias exprIds; include those.
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt.toSeq.flatMap {
+      p =>
+        p.projectList.flatMap {
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) => Some(a.exprId)
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) => Some(al.exprId)
+          case _ => None
+        }
+    }.toSet
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+
+    // Outer join condition may reference only equi-key attrs from the self-join side.
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+
+    // Top-level subquery Project may reference only equi-key attrs from the self-join side.
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val refs = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        refs.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val filtered = buildAggregateHavingDistinctGt1(sjLeftEquiAttrs, sjLeftNeqAttr, sjLeft)
+
+    val (newSelfJoinSide, outputRemap): (LogicalPlan, Map[ExprId, Attribute]) =
+      selfJoinProjectOpt match {
+        case Some(wp) =>
+          canonicalizeWrapper(wp.projectList, equiPairs, filtered) match {
+            case Some((newWrapper, remap)) => (newWrapper, remap)
+            case None => return None
+          }
+        case None if projectListOpt.isEmpty =>
+          // Fail-closed: with neither a wrapper Project around the self-join nor a top-level
+          // subquery Project, the outer join currently exposes every self-join column, and
+          // replacing the self-join with `Project(equiKeys, filtered)` would shrink the outer
+          // join's right-hand output arity. RewritePredicateSubquery's positional zip
+          // (`values.zip(sub.output).map(EqualTo.tupled)`) would then bind semi predicates to
+          // the wrong attributes -- silently dropping components of a tuple IN. A
+          // top-level Project (`projectListOpt`) is what would let the arity be preserved
+          // by the top-level rewrite loop; without one, refuse to rewrite.
+          return None
+        case None =>
+          // No wrapper Project but there IS a top-level subquery Project: shrinking the outer
+          // join's self-join-side output is safe because the top-level Project is rewritten
+          // consistently via `outputRemap` below and the top-level rewrite loop ensures
+          // subquery output arity matches what the enclosing InSubquery expects.
+          // Outer references may point at sjRight equi-attributes; remap them to sjLeft
+          // (same output position in a valid self-join).
+          val newP = Project(sjLeftEquiAttrs, filtered)
+          val remap: Map[ExprId, Attribute] =
+            equiPairs.map { case (l, r) => r.exprId -> l }.toMap
+          (newP, remap)
+      }
+
+    // Rewrite outer join condition to use new wrapper output attributes.
+    val newOuterCond = outerCond.transformUp {
+      case a: Attribute if outputRemap.contains(a.exprId) => outputRemap(a.exprId)
+    }
+
+    val newOuterJoin = if (selfJoinOnRight) {
+      outerJoin.copy(right = newSelfJoinSide, condition = Some(newOuterCond))
+    } else {
+      outerJoin.copy(left = newSelfJoinSide, condition = Some(newOuterCond))
+    }
+
+    // Rewrite top-level Project references.
+    val result = projectListOpt match {
+      case Some(pl) =>
+        val remapped = pl.map(ne => remapNamedExpressionAttributes(ne, outputRemap))
+        if (remapped.exists(_.isEmpty)) return None
+        Project(remapped.flatten, newOuterJoin)
+      case None => newOuterJoin
+    }
+
+    logDebug(
+      s"Pattern A2 - equiKeys=[${sjLeftEquiAttrs.map(_.name).mkString(",")}]" +
+        s", neqCol=${sjLeftNeqAttr.name}")
+    Some(result)
+  }
+
+  private def tryExtractSelfJoin(plan: LogicalPlan): Option[Join] = {
+    val join = plan match {
+      case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => j
+      case j: Join if j.joinType == Inner && j.condition.isDefined => j
+      case _ => return None
+    }
+    // A hinted self-join is not an extraction candidate: fail closed so the same node the rewrite
+    // would delete is never even recognized here. See `rewriteDirectSelfJoin` for the rationale.
+    if (join.hint != JoinHint.NONE) return None
+    if (!isSameBaseRelation(join.left, join.right)) return None
+    val parsed = parseSelfJoinCondition(join.condition.get, join.left, join.right)
+    if (parsed.isEmpty) return None
+    Some(join)
+  }
+
+  // ============================================================================
+  //  parseSelfJoinCondition + isSameBaseRelation
+  // ============================================================================
+
+  private def outputOrdinal(plan: LogicalPlan, attr: Attribute): Int =
+    plan.output.indexWhere(_.exprId == attr.exprId)
+
+  private def sameOutputPosition(
+      leftPlan: LogicalPlan,
+      rightPlan: LogicalPlan,
+      leftAttr: Attribute,
+      rightAttr: Attribute): Boolean = {
+    val leftPos = outputOrdinal(leftPlan, leftAttr)
+    val rightPos = outputOrdinal(rightPlan, rightAttr)
+    leftPos >= 0 && rightPos >= 0 && leftPos == rightPos
+  }
+
+  /**
+   * Parse a join condition into equi-pairs and inequality-pairs. Accepts only:
+   *   - `EqualTo(attr, attr)` where the two attrs come from opposite sides,
+   *   - `Not(EqualTo(attr, attr))` -- same side rule,
+   *   - `IsNotNull(attr)` where the attr is one of the join columns.
+   * Anything else in the condition disqualifies the whole rewrite (fail-closed).
+   */
+  private def parseSelfJoinCondition(
+      condition: Expression,
+      leftPlan: LogicalPlan,
+      rightPlan: LogicalPlan)
+      : Option[(Seq[(Attribute, Attribute)], Seq[(Attribute, Attribute)])] = {
+
+    val leftOutput = leftPlan.outputSet
+    val rightOutput = rightPlan.outputSet
+    val predicates = splitConjunctivePredicates(condition)
+
+    val equiPairs = predicates.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    val neqPairs = predicates.collect {
+      case Not(EqualTo(l: Attribute, r: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case Not(EqualTo(r: Attribute, l: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    // Only IsNotNull predicates on join columns are safe to drop -- they're redundant with
+    // the join semantics or auto-added by InferFiltersFromConstraints. IsNotNull on other
+    // columns changes semantics if we drop it; bail out.
+    val joinAttrIds: Set[ExprId] =
+      (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    val isNotNullOnJoinCols = predicates.count {
+      case IsNotNull(a: Attribute) if joinAttrIds.contains(a.exprId) => true
+      case _ => false
+    }
+
+    val totalMatched = equiPairs.size + neqPairs.size + isNotNullOnJoinCols
+    if (totalMatched != predicates.size) return None
+
+    if (equiPairs.isEmpty || neqPairs.isEmpty) return None
+
+    // Only rewrite the single-inequality case. Multiple inequality conjuncts cannot be represented
+    // by COUNT(DISTINCT) over a single column.
+    if (neqPairs.size != 1) return None
+
+    // Canonicalization intentionally erases cosmetic Alias names, so name equality cannot prove
+    // that the two predicate ends refer to the same underlying column. Resolve each end by its own
+    // ExprId against its child output and require matching output ordinals instead.
+    val equiValid = equiPairs.forall {
+      case (l, r) => sameOutputPosition(leftPlan, rightPlan, l, r)
+    }
+    val neqValid = neqPairs.forall {
+      case (l, r) => sameOutputPosition(leftPlan, rightPlan, l, r)
+    }
+    if (!equiValid || !neqValid) return None
+
+    // Equi-key output positions must be distinct across pairs. Keep the same positional identity
+    // here so swapped or duplicate aliases cannot make two different underlying columns look equal.
+    val leftEquiOrdinals = equiPairs.map { case (l, _) => outputOrdinal(leftPlan, l) }
+    if (leftEquiOrdinals.exists(_ < 0)) return None
+    if (leftEquiOrdinals.distinct.size != leftEquiOrdinals.size) return None
+
+    // Defensive: reject when the neq column overlaps an equi-key column
+    // (e.g. `t1.k = t2.k AND t1.k <> t2.k`).
+    val neqLeftOrdinal = outputOrdinal(leftPlan, neqPairs.head._1)
+    if (neqLeftOrdinal < 0 || leftEquiOrdinals.contains(neqLeftOrdinal)) return None
+
+    // Datatype safety is checked LAST, only after correspondence is proven: these really are the
+    // paired equi-keys and neq column. The rewrite replaces the join predicates `=` / `<>` with
+    // GROUP BY / COUNT(DISTINCT), i.e. it swaps comparison equality for grouping/distinct equality,
+    // so every column moved into the aggregate -- both ends of every pair, not just the sjLeft side
+    // -- must be a type where the two equalities provably coincide (see
+    // [[isSafeComparisonGroupingType]]). Checking both ends (rather than trusting
+    // `left.canonicalized == right.canonicalized` to imply matching type/metadata) keeps this
+    // robust if a future metadata-aware type gate is added.
+    val comparisonAttrs = (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l, r) }
+    if (!comparisonAttrs.forall(a => isSafeComparisonGroupingType(a.dataType))) return None
+
+    Some((equiPairs, neqPairs))
+  }
+
+  /**
+   * True iff comparison equality (`=` / `<>`) and grouping/distinct equality provably coincide for
+   * `dataType`, so the key can be safely moved from a join predicate into GROUP BY /
+   * COUNT(DISTINCT).
+   *
+   * This is a POSITIVE allowlist, not "orderable minus a blacklist". The property we must prove is
+   * not that a type can be ordered, but that its `=` / `<>` semantics and its grouping/distinct
+   * semantics are identical. `RowOrdering.isOrderable` answers the former, not the latter, so it is
+   * not a sufficient proof here. Rather than depend on non-trivial, version-dependent equality
+   * contracts, we allow only the small set of types whose two equalities coincide unconditionally
+   * and which the target workload (TPC-DS Q95) actually needs:
+   *   - Float/Double: their equality around NaN and signed zero relies on normalization semantics
+   *     (`NormalizeFloatingNumbers` and friends) that we do not want this rule -- which spans
+   *     multiple Spark versions and native execution paths -- to depend on.
+   *   - String / CHAR / VARCHAR: non-binary collation semantics make the equivalence non-trivial
+   *     and version-dependent, and CHAR/VARCHAR may already appear as StringType-plus-metadata by
+   *     the optimizer, so `dataType` alone cannot even see the declared type. String is therefore
+   *     outside the initial allowlist.
+   * Everything else -- complex types (Array/Map/Struct), UDTs, and any future/unknown type -- fails
+   * closed for the same reason: we would rather miss the rewrite than depend on a contract we have
+   * not proven holds across every supported backend.
+   */
+  private def isSafeComparisonGroupingType(dataType: DataType): Boolean = dataType match {
+    case ByteType | ShortType | IntegerType | LongType => true
+    case _: DecimalType => true
+    case BooleanType => true
+    case DateType => true
+    case TimestampType => true
+    case BinaryType => true
+    case _ => false
+  }
+
+  /**
+   * True iff `plan` produces the same row bag on every evaluation.
+   *
+   * This is the primary safety guard for the rewrite, which folds two occurrences of the same
+   * subtree into one aggregate -- sound only when both occurrences produce identical row bags. We
+   * check it at TWO levels:
+   *   - candidate level: the enclosing subquery, before descending into the self-join. Catches
+   *     nondeterminism that has been hoisted OUT of the join by an earlier optimizer rule -- e.g. a
+   *     `Filter(rand(...))` moved to sit above the join rather than on each side. Without this,
+   *     `isSameBaseRelation(innerLeft, innerRight)` could pass (both sides look deterministic)
+   *     while the enclosing plan still contains `Rand`.
+   *   - relation level: [[isSameBaseRelation]] additionally requires the two sides to be
+   *     structurally identical.
+   *
+   * Attribute-level `plan.deterministic` alone is NOT sufficient. Catalyst's
+   * `Expression.deterministic` only checks explicit `Nondeterministic` annotation; several
+   * operators produce a runtime-nondeterministic row bag even though every expression they contain
+   * is `deterministic == true`:
+   *   - `Aggregate` with `First` / `Last` / `collect_list` / `min_by` / `max_by` (tie order),
+   *   - `Window` with `row_number()` / `rank()` over a non-total order,
+   *   - `Limit` / `LocalLimit` / `Sample` / `Offset` (row-bag operator-level nondeterminism),
+   *   - streaming sources.
+   *
+   * This rule collapses two evaluations of the same subtree into one aggregate; repeatability must
+   * be provable, not assumed. That is why both the operator check and the expression check below
+   * are WHITELISTS rather than blacklists -- unknown operators and unknown expression types default
+   * to reject.
+   *
+   * Expression support is allowlisted, not blacklisted. `plan.deterministic` relies on each
+   * expression's reported `deterministic` contract; that is necessary but insufficient for unknown
+   * expression types whose repeatability has not been established -- a builtin that carries hidden
+   * state yet reports `deterministic == true` would otherwise be trusted silently. For a rewrite
+   * that folds two evaluations of a subtree into one aggregate we prefer to miss an optimization
+   * than to misapply one, so new expression types are added to [[isRepeatableExpression]] only
+   * after their repeatability has been established. `plan.deterministic` is kept as a cheap
+   * fast-reject, but the expression allowlist is what actually proves repeatability.
+   *
+   * `plan.subqueriesAll.isEmpty` additionally fail-closes on any embedded expression subquery
+   * (scalar / IN / EXISTS). `plan.exists` in `isRowBagRepeatable` walks only the operator tree and
+   * does not descend into expression subqueries, and `plan.deterministic` does not prove a nested
+   * subquery is row-bag repeatable (e.g. an uncorrelated `LIMIT 1` without `ORDER BY`). Rejecting
+   * any embedded subquery keeps the repeatability proof confined to the operator whitelist below.
+   */
+  private def isRepeatablePlan(plan: LogicalPlan): Boolean = {
+    // The operator/source whitelist is checked before the expression whitelist so that a plan whose
+    // operator is itself unknown -- e.g. Aggregate (carries AggregateExpression) or Window (carries
+    // WindowExpression / SortOrder) -- is attributed to isRowBagRepeatable rather than being masked
+    // by the fact that those operators also carry non-allowlisted expressions.
+    plan.deterministic &&
+    !plan.isStreaming &&
+    plan.subqueriesAll.isEmpty &&
+    isRowBagRepeatable(plan) &&
+    hasRepeatableExpressions(plan)
+  }
+
+  /**
+   * Operator whitelist for `isRepeatablePlan`. A plan is row-bag repeatable only when every node is
+   * known to produce a repeatable output row bag from repeatable children. Unknown operators and
+   * unknown leaf sources fail closed.
+   *
+   * Kept intentionally narrow -- the target workload (Q95-shape self-join in a subquery) only needs
+   * a Parquet relation scan optionally wrapped in Project / Filter / SubqueryAlias plus the
+   * self-join itself. Range and LocalRelation are also trusted deterministic leaves. Adding an
+   * operator here requires proving:
+   *   - it does not reorder its input non-deterministically,
+   *   - it does not depend on shuffle-merge or tie-broken orderings,
+   *   - it produces the same output row bag on every evaluation.
+   *
+   * Arbitrary `LeafNode`s are intentionally not trusted. For example, `LogicalRDD` may wrap an
+   * arbitrary RDD lineage whose runtime behavior is invisible to Catalyst's `plan.deterministic`;
+   * `InMemoryRelation`, `DataSourceV2Relation` and custom leaves are likewise rejected until
+   * proven. Streaming sources reach here as `LeafNode`s but are already filtered upstream by
+   * `plan.isStreaming` in [[isRepeatablePlan]].
+   *
+   * `LogicalRelation` is trusted only when its underlying relation is a `HadoopFsRelation` whose
+   * `fileFormat` is EXACTLY `ParquetFileFormat` (`getClass == classOf[ParquetFileFormat]`, not
+   * `isInstanceOf`). `HadoopFsRelation.fileFormat` can be any `FileFormat`, including custom
+   * formats whose scan is not provably a repeatable row bag; `ParquetFileFormat` is also non-final,
+   * so a third-party subclass could override its scan. The target workload only needs stock
+   * Parquet, so every other `FileFormat` -- subclasses of `ParquetFileFormat` included -- and every
+   * non-`HadoopFsRelation` fail closed.
+   *
+   * This helper checks operators and leaf sources only; expression-type repeatability is a separate
+   * concern handled by [[hasRepeatableExpressions]], and both are joined in [[isRepeatablePlan]].
+   */
+  private def isRowBagRepeatable(plan: LogicalPlan): Boolean = !plan.exists {
+    // TreeNode exposes `exists` but not `forall`, so invert: a whitelisted operator maps to `false`
+    // ("does not break repeatability") and everything else to `true`; negating the whole `exists`
+    // then means "every operator is whitelisted".
+    case _: Project => false
+    case _: Filter => false
+    case _: SubqueryAlias => false
+    // Join is included because our target pattern IS a Join; both children get recursed into.
+    case _: Join => false
+    // Explicitly trusted leaves.
+    case _: Range => false
+    case _: LocalRelation => false
+    case relation: LogicalRelation =>
+      relation.relation match {
+        case h: HadoopFsRelation if h.fileFormat.getClass == classOf[ParquetFileFormat] => false

Review Comment:
   The only Velox-specific thing in this rule is reading its own config; the rest is relational algebra at the Catalyst level. It still lives in `backends-velox` at 734 lines, where the largest rule in that package is 191. Putting it there follows the existing convention, so this is not blocking, but was it deliberate? In `gluten-substrait` the ClickHouse backend would get the same rewrite.
   
   On the `:674` check, the docstring already gives the full reason — a custom `FileFormat`'s scan is not provably repeatable, `ParquetFileFormat` is not final, and the target workload only needs stock Parquet — so no objection there. One note only: stock ORC and CSV are `HadoopFsRelation` too, so widening this later is two more exact-class checks rather than new proof work.



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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

Review Comment:
   Two overreaches in the comment at `:96-98`. In "can flip IN/NOT IN outcomes", the `IN` half does not hold: a plain `IN` becomes a LeftSemi join whose condition is a conjunction of `EqualTo`, and `EqualTo(value, NULL)` is never TRUE, so a leaked NULL only fails to match that row. Only `NOT IN`, which goes through the null-aware anti join, is affected. And "spurious empty result" is exact for a single-column `NOT IN`; for a tuple `NOT IN` a NULL in one column satisfies only its own conjunct.
   
   This comment is the entire justification for the `IsNotNull(equiKeys)` filter, and the cost of the overreach is credibility rather than deletion: a reader who disproves the `IN` half tends to stop trusting the `NOT IN` half, which is the half the filter actually rests on. Naming only `NOT IN` and qualifying the tuple case in one clause would do it.



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Any other NamedExpression shape we
+   * do not rewrite is left as-is ONLY if it does not reference a replaced self-join output;
+   * otherwise it would carry a stale ExprId, so returns None to fail the whole rewrite closed.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): Option[NamedExpression] = ne match {
+    case a: Attribute if remap.contains(a.exprId) => Some(remap(a.exprId))
+    case a: Attribute => Some(a)
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      Some(
+        if (newChild eq al.child) {
+          al
+        } else {
+          Alias(newChild, al.name)(
+            al.exprId,
+            al.qualifier,
+            al.explicitMetadata,
+            al.nonInheritableMetadataKeys)
+        })
+    case other if other.references.exists(a => remap.contains(a.exprId)) =>
+      // Fail-closed: a NamedExpression we do not rewrite (neither a bare Attribute nor an Alias)
+      // that still references a replaced self-join output would be left with a dangling ExprId.
+      // Refuse the rewrite rather than emit a plan with a stale reference.
+      None
+    case other => Some(other)
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    // Fail closed on an explicit join hint. This rule deletes the self-join outright; a user
+    // `/*+ BROADCAST(...) */` (or SHUFFLE_*/MERGE) is an explicit optimizer directive about THAT
+    // join, and an opt-in rewrite has no business silently discarding it. Not a correctness bug
+    // (hints do not change results), but the conservative maintainer choice. `JoinHint.NONE` exists
+    // across all Spark versions Gluten supports, so no shim is needed.
+    if (innerJoin.hint != JoinHint.NONE) return None
+
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {

Review Comment:
   The suite's 21 must-not-fire assertions are thorough, but four guards have no test: A2's outer-join-condition reference check (`:353`), its top-level Project reference check (`:356-361`), and both bare-Join arity guards (`:282` and `:372`).
   
   By the rule's own comment at `:376-380`, the two arity guards are the ones whose failure mode is a silently wrong plan: `RewritePredicateSubquery` zips `values` with `sub.output` positionally, so a shrunken subquery output binds a tuple-IN component to the wrong attribute. Those two are the highest-consequence of the four.
   
   `RemoveNoopOperators` deletes the star Project because it has the same output as its child (the IN tuple's arity has to match separately for the query to analyze), so `(k, v, k, v) IN (SELECT * FROM T s1 JOIN T s2 ON ...)` reaches `:282`. Worth asserting in the new case that the optimized subquery really is a bare `Join`, or a future Spark that keeps the Project leaves the test green while covering nothing. The two reference checks are load-bearing only in the `:382` branch, which is unreachable with the default rule set, so testing them needs `ColumnPruning` excluded.



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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)

Review Comment:
   `:165` and `:169` rebuild the alias as `Alias(child, name)()`, dropping `qualifier`, `explicitMetadata` and `nonInheritableMetadataKeys`, while `remapNamedExpressionAttributes` at `:208-212` passes all four through for the same kind of rewrite. `Alias.metadata` only falls back to the child's metadata when `explicitMetadata` is None, so an alias carrying explicit metadata loses it.
   
   No wrong answer is reachable today, since the `IN` comparison never reads metadata: what gets dropped is a plan-level annotation, not a value. The issue is that the comment at `:161-164` explains only the ExprId half, so a reader cannot tell whether the metadata drop is deliberate. The issue is that two sibling rewrites in the same file differ with no stated reason. Either match `:208` or say so in the comment.



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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) {

Review Comment:
   `apply` only checks its own config. The sibling `HLLRewriteRule.scala:34` in the same package returns the plan untouched when `!enableColumnarHashAgg`, because the whole point of that rewrite is reaching the native implementation.
   
   This rule is not quite the same case: avoiding the self-join blowup is a win independent of offload, so copying that guard may not be right. What I am asking is whether it was measured — with `spark.gluten.sql.columnar.hashagg` off, is rewriting into a vanilla row-based `COUNT(DISTINCT)` still a win? If it was not, matching the HLL guard is the cheaper answer.



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Any other NamedExpression shape we
+   * do not rewrite is left as-is ONLY if it does not reference a replaced self-join output;
+   * otherwise it would carry a stale ExprId, so returns None to fail the whole rewrite closed.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): Option[NamedExpression] = ne match {
+    case a: Attribute if remap.contains(a.exprId) => Some(remap(a.exprId))
+    case a: Attribute => Some(a)
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      Some(
+        if (newChild eq al.child) {
+          al
+        } else {
+          Alias(newChild, al.name)(
+            al.exprId,
+            al.qualifier,
+            al.explicitMetadata,
+            al.nonInheritableMetadataKeys)
+        })
+    case other if other.references.exists(a => remap.contains(a.exprId)) =>
+      // Fail-closed: a NamedExpression we do not rewrite (neither a bare Attribute nor an Alias)
+      // that still references a replaced self-join output would be left with a dangling ExprId.
+      // Refuse the rewrite rather than emit a plan with a stale reference.
+      None
+    case other => Some(other)
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    // Fail closed on an explicit join hint. This rule deletes the self-join outright; a user
+    // `/*+ BROADCAST(...) */` (or SHUFFLE_*/MERGE) is an explicit optimizer directive about THAT
+    // join, and an opt-in rewrite has no business silently discarding it. Not a correctness bug
+    // (hints do not change results), but the conservative maintainer choice. `JoinHint.NONE` exists
+    // across all Spark versions Gluten supports, so no shim is needed.
+    if (innerJoin.hint != JoinHint.NONE) return None
+
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map {
+          case (newWrapper, _) =>
+            logDebug(
+              s"Pattern A' - equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}]" +
+                s", neqCol=${innerLeftNeqAttr.name}" +
+                s", outCols=[${newWrapper.projectList.map(_.name).mkString(",")}]")
+            newWrapper
+        }
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A2 : self-join nested inside another InnerJoin in the subquery
+  // ============================================================================
+
+  private def rewriteNestedSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+    val outerCond = outerJoin.condition.get
+
+    val (selfJoinSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerJoin.right) match {
+        case Some(_) => (outerJoin.right, true)
+        case None =>
+          tryExtractSelfJoin(outerJoin.left) match {
+            case Some(_) => (outerJoin.left, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(p), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val sjLeftNeqAttr: Attribute = neqPairs.head._1
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val sjEquiExprIds: Set[ExprId] =
+      equiPairs.flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    // wrapper Project may reproject equi-keys under fresh alias exprIds; include those.
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt.toSeq.flatMap {
+      p =>
+        p.projectList.flatMap {
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) => Some(a.exprId)
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) => Some(al.exprId)
+          case _ => None
+        }
+    }.toSet
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+
+    // Outer join condition may reference only equi-key attrs from the self-join side.
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+
+    // Top-level subquery Project may reference only equi-key attrs from the self-join side.
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val refs = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        refs.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val filtered = buildAggregateHavingDistinctGt1(sjLeftEquiAttrs, sjLeftNeqAttr, sjLeft)
+
+    val (newSelfJoinSide, outputRemap): (LogicalPlan, Map[ExprId, Attribute]) =
+      selfJoinProjectOpt match {
+        case Some(wp) =>
+          canonicalizeWrapper(wp.projectList, equiPairs, filtered) match {
+            case Some((newWrapper, remap)) => (newWrapper, remap)
+            case None => return None
+          }
+        case None if projectListOpt.isEmpty =>
+          // Fail-closed: with neither a wrapper Project around the self-join nor a top-level
+          // subquery Project, the outer join currently exposes every self-join column, and
+          // replacing the self-join with `Project(equiKeys, filtered)` would shrink the outer
+          // join's right-hand output arity. RewritePredicateSubquery's positional zip
+          // (`values.zip(sub.output).map(EqualTo.tupled)`) would then bind semi predicates to
+          // the wrong attributes -- silently dropping components of a tuple IN. A
+          // top-level Project (`projectListOpt`) is what would let the arity be preserved
+          // by the top-level rewrite loop; without one, refuse to rewrite.
+          return None
+        case None =>
+          // No wrapper Project but there IS a top-level subquery Project: shrinking the outer
+          // join's self-join-side output is safe because the top-level Project is rewritten
+          // consistently via `outputRemap` below and the top-level rewrite loop ensures
+          // subquery output arity matches what the enclosing InSubquery expects.
+          // Outer references may point at sjRight equi-attributes; remap them to sjLeft
+          // (same output position in a valid self-join).
+          val newP = Project(sjLeftEquiAttrs, filtered)

Review Comment:
   The branch at `:382-392` looks unreachable with the default rule set (excluding `ColumnPruning` via `spark.sql.optimizer.excludedRules` is the only way in): `ColumnPruning` wraps the self-join in a `Project` as soon as any of its four output columns is unused above it, and when all four are live, `s1.v` / `s2.v` trip the reference checks at `:353` or `:361` first.
   
   It is also the only place in the rule that deliberately shrinks a join child's output arity, and the comment at `:372` points at it as the safe alternative. Either fold it into that `return None` or note in the comment that it is currently unreachable, otherwise a reader concludes this shape is already handled.



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Any other NamedExpression shape we
+   * do not rewrite is left as-is ONLY if it does not reference a replaced self-join output;
+   * otherwise it would carry a stale ExprId, so returns None to fail the whole rewrite closed.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): Option[NamedExpression] = ne match {
+    case a: Attribute if remap.contains(a.exprId) => Some(remap(a.exprId))
+    case a: Attribute => Some(a)
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      Some(
+        if (newChild eq al.child) {
+          al
+        } else {
+          Alias(newChild, al.name)(
+            al.exprId,
+            al.qualifier,
+            al.explicitMetadata,
+            al.nonInheritableMetadataKeys)
+        })
+    case other if other.references.exists(a => remap.contains(a.exprId)) =>
+      // Fail-closed: a NamedExpression we do not rewrite (neither a bare Attribute nor an Alias)
+      // that still references a replaced self-join output would be left with a dangling ExprId.
+      // Refuse the rewrite rather than emit a plan with a stale reference.
+      None
+    case other => Some(other)
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    // Fail closed on an explicit join hint. This rule deletes the self-join outright; a user
+    // `/*+ BROADCAST(...) */` (or SHUFFLE_*/MERGE) is an explicit optimizer directive about THAT
+    // join, and an opt-in rewrite has no business silently discarding it. Not a correctness bug
+    // (hints do not change results), but the conservative maintainer choice. `JoinHint.NONE` exists
+    // across all Spark versions Gluten supports, so no shim is needed.
+    if (innerJoin.hint != JoinHint.NONE) return None
+
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map {
+          case (newWrapper, _) =>
+            logDebug(
+              s"Pattern A' - equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}]" +
+                s", neqCol=${innerLeftNeqAttr.name}" +
+                s", outCols=[${newWrapper.projectList.map(_.name).mkString(",")}]")
+            newWrapper
+        }
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A2 : self-join nested inside another InnerJoin in the subquery
+  // ============================================================================
+
+  private def rewriteNestedSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+    val outerCond = outerJoin.condition.get
+
+    val (selfJoinSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerJoin.right) match {
+        case Some(_) => (outerJoin.right, true)
+        case None =>
+          tryExtractSelfJoin(outerJoin.left) match {
+            case Some(_) => (outerJoin.left, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(p), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val sjLeftNeqAttr: Attribute = neqPairs.head._1
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val sjEquiExprIds: Set[ExprId] =
+      equiPairs.flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    // wrapper Project may reproject equi-keys under fresh alias exprIds; include those.
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt.toSeq.flatMap {
+      p =>
+        p.projectList.flatMap {
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) => Some(a.exprId)
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) => Some(al.exprId)
+          case _ => None
+        }
+    }.toSet
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+
+    // Outer join condition may reference only equi-key attrs from the self-join side.
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+
+    // Top-level subquery Project may reference only equi-key attrs from the self-join side.
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val refs = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        refs.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val filtered = buildAggregateHavingDistinctGt1(sjLeftEquiAttrs, sjLeftNeqAttr, sjLeft)
+
+    val (newSelfJoinSide, outputRemap): (LogicalPlan, Map[ExprId, Attribute]) =
+      selfJoinProjectOpt match {
+        case Some(wp) =>
+          canonicalizeWrapper(wp.projectList, equiPairs, filtered) match {
+            case Some((newWrapper, remap)) => (newWrapper, remap)
+            case None => return None
+          }
+        case None if projectListOpt.isEmpty =>
+          // Fail-closed: with neither a wrapper Project around the self-join nor a top-level
+          // subquery Project, the outer join currently exposes every self-join column, and
+          // replacing the self-join with `Project(equiKeys, filtered)` would shrink the outer
+          // join's right-hand output arity. RewritePredicateSubquery's positional zip
+          // (`values.zip(sub.output).map(EqualTo.tupled)`) would then bind semi predicates to
+          // the wrong attributes -- silently dropping components of a tuple IN. A
+          // top-level Project (`projectListOpt`) is what would let the arity be preserved
+          // by the top-level rewrite loop; without one, refuse to rewrite.
+          return None
+        case None =>
+          // No wrapper Project but there IS a top-level subquery Project: shrinking the outer
+          // join's self-join-side output is safe because the top-level Project is rewritten
+          // consistently via `outputRemap` below and the top-level rewrite loop ensures
+          // subquery output arity matches what the enclosing InSubquery expects.
+          // Outer references may point at sjRight equi-attributes; remap them to sjLeft
+          // (same output position in a valid self-join).
+          val newP = Project(sjLeftEquiAttrs, filtered)
+          val remap: Map[ExprId, Attribute] =
+            equiPairs.map { case (l, r) => r.exprId -> l }.toMap
+          (newP, remap)
+      }
+
+    // Rewrite outer join condition to use new wrapper output attributes.
+    val newOuterCond = outerCond.transformUp {
+      case a: Attribute if outputRemap.contains(a.exprId) => outputRemap(a.exprId)
+    }
+
+    val newOuterJoin = if (selfJoinOnRight) {
+      outerJoin.copy(right = newSelfJoinSide, condition = Some(newOuterCond))
+    } else {
+      outerJoin.copy(left = newSelfJoinSide, condition = Some(newOuterCond))
+    }
+
+    // Rewrite top-level Project references.
+    val result = projectListOpt match {
+      case Some(pl) =>
+        val remapped = pl.map(ne => remapNamedExpressionAttributes(ne, outputRemap))
+        if (remapped.exists(_.isEmpty)) return None
+        Project(remapped.flatten, newOuterJoin)
+      case None => newOuterJoin
+    }
+
+    logDebug(
+      s"Pattern A2 - equiKeys=[${sjLeftEquiAttrs.map(_.name).mkString(",")}]" +
+        s", neqCol=${sjLeftNeqAttr.name}")
+    Some(result)
+  }
+
+  private def tryExtractSelfJoin(plan: LogicalPlan): Option[Join] = {
+    val join = plan match {
+      case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => j
+      case j: Join if j.joinType == Inner && j.condition.isDefined => j
+      case _ => return None
+    }
+    // A hinted self-join is not an extraction candidate: fail closed so the same node the rewrite
+    // would delete is never even recognized here. See `rewriteDirectSelfJoin` for the rationale.
+    if (join.hint != JoinHint.NONE) return None
+    if (!isSameBaseRelation(join.left, join.right)) return None
+    val parsed = parseSelfJoinCondition(join.condition.get, join.left, join.right)
+    if (parsed.isEmpty) return None
+    Some(join)
+  }
+
+  // ============================================================================
+  //  parseSelfJoinCondition + isSameBaseRelation
+  // ============================================================================
+
+  private def outputOrdinal(plan: LogicalPlan, attr: Attribute): Int =
+    plan.output.indexWhere(_.exprId == attr.exprId)
+
+  private def sameOutputPosition(
+      leftPlan: LogicalPlan,
+      rightPlan: LogicalPlan,
+      leftAttr: Attribute,
+      rightAttr: Attribute): Boolean = {
+    val leftPos = outputOrdinal(leftPlan, leftAttr)
+    val rightPos = outputOrdinal(rightPlan, rightAttr)
+    leftPos >= 0 && rightPos >= 0 && leftPos == rightPos
+  }
+
+  /**
+   * Parse a join condition into equi-pairs and inequality-pairs. Accepts only:
+   *   - `EqualTo(attr, attr)` where the two attrs come from opposite sides,
+   *   - `Not(EqualTo(attr, attr))` -- same side rule,
+   *   - `IsNotNull(attr)` where the attr is one of the join columns.
+   * Anything else in the condition disqualifies the whole rewrite (fail-closed).
+   */
+  private def parseSelfJoinCondition(
+      condition: Expression,
+      leftPlan: LogicalPlan,
+      rightPlan: LogicalPlan)
+      : Option[(Seq[(Attribute, Attribute)], Seq[(Attribute, Attribute)])] = {
+
+    val leftOutput = leftPlan.outputSet
+    val rightOutput = rightPlan.outputSet
+    val predicates = splitConjunctivePredicates(condition)
+
+    val equiPairs = predicates.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    val neqPairs = predicates.collect {
+      case Not(EqualTo(l: Attribute, r: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case Not(EqualTo(r: Attribute, l: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    // Only IsNotNull predicates on join columns are safe to drop -- they're redundant with
+    // the join semantics or auto-added by InferFiltersFromConstraints. IsNotNull on other
+    // columns changes semantics if we drop it; bail out.
+    val joinAttrIds: Set[ExprId] =
+      (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    val isNotNullOnJoinCols = predicates.count {
+      case IsNotNull(a: Attribute) if joinAttrIds.contains(a.exprId) => true
+      case _ => false
+    }
+
+    val totalMatched = equiPairs.size + neqPairs.size + isNotNullOnJoinCols
+    if (totalMatched != predicates.size) return None
+
+    if (equiPairs.isEmpty || neqPairs.isEmpty) return None
+
+    // Only rewrite the single-inequality case. Multiple inequality conjuncts cannot be represented
+    // by COUNT(DISTINCT) over a single column.
+    if (neqPairs.size != 1) return None
+
+    // Canonicalization intentionally erases cosmetic Alias names, so name equality cannot prove
+    // that the two predicate ends refer to the same underlying column. Resolve each end by its own
+    // ExprId against its child output and require matching output ordinals instead.
+    val equiValid = equiPairs.forall {
+      case (l, r) => sameOutputPosition(leftPlan, rightPlan, l, r)
+    }
+    val neqValid = neqPairs.forall {
+      case (l, r) => sameOutputPosition(leftPlan, rightPlan, l, r)
+    }
+    if (!equiValid || !neqValid) return None
+
+    // Equi-key output positions must be distinct across pairs. Keep the same positional identity
+    // here so swapped or duplicate aliases cannot make two different underlying columns look equal.
+    val leftEquiOrdinals = equiPairs.map { case (l, _) => outputOrdinal(leftPlan, l) }
+    if (leftEquiOrdinals.exists(_ < 0)) return None
+    if (leftEquiOrdinals.distinct.size != leftEquiOrdinals.size) return None
+
+    // Defensive: reject when the neq column overlaps an equi-key column
+    // (e.g. `t1.k = t2.k AND t1.k <> t2.k`).
+    val neqLeftOrdinal = outputOrdinal(leftPlan, neqPairs.head._1)
+    if (neqLeftOrdinal < 0 || leftEquiOrdinals.contains(neqLeftOrdinal)) return None
+
+    // Datatype safety is checked LAST, only after correspondence is proven: these really are the
+    // paired equi-keys and neq column. The rewrite replaces the join predicates `=` / `<>` with
+    // GROUP BY / COUNT(DISTINCT), i.e. it swaps comparison equality for grouping/distinct equality,
+    // so every column moved into the aggregate -- both ends of every pair, not just the sjLeft side
+    // -- must be a type where the two equalities provably coincide (see
+    // [[isSafeComparisonGroupingType]]). Checking both ends (rather than trusting
+    // `left.canonicalized == right.canonicalized` to imply matching type/metadata) keeps this
+    // robust if a future metadata-aware type gate is added.
+    val comparisonAttrs = (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l, r) }
+    if (!comparisonAttrs.forall(a => isSafeComparisonGroupingType(a.dataType))) return None
+
+    Some((equiPairs, neqPairs))
+  }
+
+  /**
+   * True iff comparison equality (`=` / `<>`) and grouping/distinct equality provably coincide for
+   * `dataType`, so the key can be safely moved from a join predicate into GROUP BY /
+   * COUNT(DISTINCT).
+   *
+   * This is a POSITIVE allowlist, not "orderable minus a blacklist". The property we must prove is
+   * not that a type can be ordered, but that its `=` / `<>` semantics and its grouping/distinct
+   * semantics are identical. `RowOrdering.isOrderable` answers the former, not the latter, so it is
+   * not a sufficient proof here. Rather than depend on non-trivial, version-dependent equality
+   * contracts, we allow only the small set of types whose two equalities coincide unconditionally
+   * and which the target workload (TPC-DS Q95) actually needs:
+   *   - Float/Double: their equality around NaN and signed zero relies on normalization semantics
+   *     (`NormalizeFloatingNumbers` and friends) that we do not want this rule -- which spans
+   *     multiple Spark versions and native execution paths -- to depend on.
+   *   - String / CHAR / VARCHAR: non-binary collation semantics make the equivalence non-trivial
+   *     and version-dependent, and CHAR/VARCHAR may already appear as StringType-plus-metadata by
+   *     the optimizer, so `dataType` alone cannot even see the declared type. String is therefore
+   *     outside the initial allowlist.
+   * Everything else -- complex types (Array/Map/Struct), UDTs, and any future/unknown type -- fails
+   * closed for the same reason: we would rather miss the rewrite than depend on a contract we have
+   * not proven holds across every supported backend.
+   */
+  private def isSafeComparisonGroupingType(dataType: DataType): Boolean = dataType match {
+    case ByteType | ShortType | IntegerType | LongType => true
+    case _: DecimalType => true
+    case BooleanType => true
+    case DateType => true
+    case TimestampType => true
+    case BinaryType => true
+    case _ => false
+  }
+
+  /**
+   * True iff `plan` produces the same row bag on every evaluation.
+   *
+   * This is the primary safety guard for the rewrite, which folds two occurrences of the same
+   * subtree into one aggregate -- sound only when both occurrences produce identical row bags. We
+   * check it at TWO levels:
+   *   - candidate level: the enclosing subquery, before descending into the self-join. Catches
+   *     nondeterminism that has been hoisted OUT of the join by an earlier optimizer rule -- e.g. a
+   *     `Filter(rand(...))` moved to sit above the join rather than on each side. Without this,
+   *     `isSameBaseRelation(innerLeft, innerRight)` could pass (both sides look deterministic)
+   *     while the enclosing plan still contains `Rand`.
+   *   - relation level: [[isSameBaseRelation]] additionally requires the two sides to be
+   *     structurally identical.
+   *
+   * Attribute-level `plan.deterministic` alone is NOT sufficient. Catalyst's
+   * `Expression.deterministic` only checks explicit `Nondeterministic` annotation; several
+   * operators produce a runtime-nondeterministic row bag even though every expression they contain
+   * is `deterministic == true`:
+   *   - `Aggregate` with `First` / `Last` / `collect_list` / `min_by` / `max_by` (tie order),
+   *   - `Window` with `row_number()` / `rank()` over a non-total order,
+   *   - `Limit` / `LocalLimit` / `Sample` / `Offset` (row-bag operator-level nondeterminism),
+   *   - streaming sources.
+   *
+   * This rule collapses two evaluations of the same subtree into one aggregate; repeatability must
+   * be provable, not assumed. That is why both the operator check and the expression check below
+   * are WHITELISTS rather than blacklists -- unknown operators and unknown expression types default
+   * to reject.
+   *
+   * Expression support is allowlisted, not blacklisted. `plan.deterministic` relies on each
+   * expression's reported `deterministic` contract; that is necessary but insufficient for unknown
+   * expression types whose repeatability has not been established -- a builtin that carries hidden
+   * state yet reports `deterministic == true` would otherwise be trusted silently. For a rewrite
+   * that folds two evaluations of a subtree into one aggregate we prefer to miss an optimization
+   * than to misapply one, so new expression types are added to [[isRepeatableExpression]] only
+   * after their repeatability has been established. `plan.deterministic` is kept as a cheap
+   * fast-reject, but the expression allowlist is what actually proves repeatability.
+   *
+   * `plan.subqueriesAll.isEmpty` additionally fail-closes on any embedded expression subquery
+   * (scalar / IN / EXISTS). `plan.exists` in `isRowBagRepeatable` walks only the operator tree and
+   * does not descend into expression subqueries, and `plan.deterministic` does not prove a nested
+   * subquery is row-bag repeatable (e.g. an uncorrelated `LIMIT 1` without `ORDER BY`). Rejecting
+   * any embedded subquery keeps the repeatability proof confined to the operator whitelist below.
+   */
+  private def isRepeatablePlan(plan: LogicalPlan): Boolean = {
+    // The operator/source whitelist is checked before the expression whitelist so that a plan whose
+    // operator is itself unknown -- e.g. Aggregate (carries AggregateExpression) or Window (carries
+    // WindowExpression / SortOrder) -- is attributed to isRowBagRepeatable rather than being masked
+    // by the fact that those operators also carry non-allowlisted expressions.
+    plan.deterministic &&
+    !plan.isStreaming &&
+    plan.subqueriesAll.isEmpty &&
+    isRowBagRepeatable(plan) &&
+    hasRepeatableExpressions(plan)
+  }
+
+  /**
+   * Operator whitelist for `isRepeatablePlan`. A plan is row-bag repeatable only when every node is
+   * known to produce a repeatable output row bag from repeatable children. Unknown operators and
+   * unknown leaf sources fail closed.
+   *
+   * Kept intentionally narrow -- the target workload (Q95-shape self-join in a subquery) only needs
+   * a Parquet relation scan optionally wrapped in Project / Filter / SubqueryAlias plus the
+   * self-join itself. Range and LocalRelation are also trusted deterministic leaves. Adding an
+   * operator here requires proving:
+   *   - it does not reorder its input non-deterministically,
+   *   - it does not depend on shuffle-merge or tie-broken orderings,
+   *   - it produces the same output row bag on every evaluation.
+   *
+   * Arbitrary `LeafNode`s are intentionally not trusted. For example, `LogicalRDD` may wrap an
+   * arbitrary RDD lineage whose runtime behavior is invisible to Catalyst's `plan.deterministic`;
+   * `InMemoryRelation`, `DataSourceV2Relation` and custom leaves are likewise rejected until
+   * proven. Streaming sources reach here as `LeafNode`s but are already filtered upstream by
+   * `plan.isStreaming` in [[isRepeatablePlan]].
+   *
+   * `LogicalRelation` is trusted only when its underlying relation is a `HadoopFsRelation` whose
+   * `fileFormat` is EXACTLY `ParquetFileFormat` (`getClass == classOf[ParquetFileFormat]`, not
+   * `isInstanceOf`). `HadoopFsRelation.fileFormat` can be any `FileFormat`, including custom
+   * formats whose scan is not provably a repeatable row bag; `ParquetFileFormat` is also non-final,
+   * so a third-party subclass could override its scan. The target workload only needs stock
+   * Parquet, so every other `FileFormat` -- subclasses of `ParquetFileFormat` included -- and every
+   * non-`HadoopFsRelation` fail closed.
+   *
+   * This helper checks operators and leaf sources only; expression-type repeatability is a separate
+   * concern handled by [[hasRepeatableExpressions]], and both are joined in [[isRepeatablePlan]].
+   */
+  private def isRowBagRepeatable(plan: LogicalPlan): Boolean = !plan.exists {
+    // TreeNode exposes `exists` but not `forall`, so invert: a whitelisted operator maps to `false`
+    // ("does not break repeatability") and everything else to `true`; negating the whole `exists`
+    // then means "every operator is whitelisted".
+    case _: Project => false
+    case _: Filter => false
+    case _: SubqueryAlias => false
+    // Join is included because our target pattern IS a Join; both children get recursed into.
+    case _: Join => false
+    // Explicitly trusted leaves.
+    case _: Range => false
+    case _: LocalRelation => false
+    case relation: LogicalRelation =>
+      relation.relation match {
+        case h: HadoopFsRelation if h.fileFormat.getClass == classOf[ParquetFileFormat] => false
+        case _ => true
+      }
+    // Everything else -- Aggregate (First/Last), Window (row_number ties), Limit, Sample,
+    // Offset, Distinct, Union, Except, Intersect, Sort (may break ties nondeterministically),
+    // Expand, Generate, and opaque leaf sources -- fail-closed.
+    case _ => true
+  }
+
+  /**
+   * Expression-type check for [[isRepeatablePlan]], kept separate from the operator whitelist in
+   * [[isRowBagRepeatable]] so the two safety layers read independently. A plan passes only when
+   * every expression carried by every operator node is repeatable per [[isRepeatableExpression]]; a
+   * whitelisted operator holding an unknown expression -- e.g. `Project(Abs(v))` -- fails closed.
+   */
+  private def hasRepeatableExpressions(plan: LogicalPlan): Boolean = {
+    !plan.exists(node => node.expressions.exists(expr => !isRepeatableExpression(expr)))
+  }
+
+  /**
+   * Expression repeatability is allowlisted, consistently with the operator whitelist in
+   * [[isRowBagRepeatable]]. Only expression types whose result is determined entirely by repeatable
+   * children are accepted; unknown expression types fail closed. This rule folds two evaluations of
+   * the same subtree into one, so missing an optimization is preferable to assuming repeatability
+   * for an expression whose runtime behavior has not been proven.
+   *
+   * The whole tree is checked: an expression is repeatable only when its root type is on the
+   * allowlist AND all of its children are themselves repeatable, so e.g. `Add(v, Abs(w))` is
+   * rejected even though `Add` is allowlisted.
+   *
+   * The initial set covers what TPC-DS Q95 and the tests need: column / literal references, Alias,
+   * Cast, basic arithmetic, boolean connectives, the six comparisons plus null-safe equality, and
+   * null checks. Expressions such as `Abs` / `Coalesce` / `CaseWhen` are intentionally absent -- a
+   * self-join over them is simply not rewritten (a missed optimization, not a correctness bug)
+   * until each is added here after its repeatability has been established. Decimal wrappers such as
+   * `PromotePrecision` / `CheckOverflow` are likewise absent and may fail closed; that is
+   * acceptable and must not be worked around by trusting them just to make an arithmetic variant
+   * fire.
+   */
+  private def isRepeatableExpression(expr: Expression): Boolean = expr match {

Review Comment:
   The comment at `:708-711` says decimal's `PromotePrecision` / `CheckOverflow` are outside the allowlist and may fail closed. Since 3.4 (SPARK-39316) decimal arithmetic is wrapped in neither: the `PromotePrecision` class itself was deleted, and Gluten carries a passthrough stub in `shims/spark34` through `spark41` so the code compiles. So on 3.4/3.5 a decimal `Add` arrives as a plain `Add` and the allowlist accepts it, while on the `spark-3.3` profile the comment still holds.
   
   The outcome is still correct, through a different mechanism: `BinaryArithmetic.checkDecimalOverflow` normalizes the result to the declared (precision, scale) with ROUND_HALF_UP inside the arithmetic node, which is why the encoding comparison in `COUNT(DISTINCT)` agrees with the numeric comparison in `<>`. Only the stated reason is stale.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3931200830


##########
backends-velox/src/test/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregateSuite.scala:
##########
@@ -0,0 +1,976 @@
+/*
+ * 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.execution.WholeStageTransformerSuite
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.catalyst.expressions.{Alias, GreaterThan, Literal}
+import org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, Count}
+import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, Filter, LogicalPlan}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{IntegerType, LongType, StructField, StructType}
+
+/**
+ * Correctness tests for [[RewriteSelfJoinInequalityToAggregate]].
+ *
+ * Positive A' / A2 cases assert both result equivalence and that the rewrite actually fired.
+ *
+ * `assert(!ruleFired(plan))` on its own only proves the rewrite did not happen -- not that it was
+ * the guard under test that stopped it. A fixture whose two self-join sides are not structurally
+ * identical is rejected by `isSameBaseRelation` before any predicate is even parsed, and such a
+ * test passes while covering nothing. So six important rejection paths -- the predicate parser, the
+ * single-inequality requirement, output-position identity, the nondeterminism guard, the
+ * leaf-source allowlist (LogicalRDD vs Parquet), and the expression-type allowlist (`abs(v)` vs
+ * `v + 1`) -- are tested as single-variable pairs: the same fixture and the same query shape, one
+ * control query that must fire and one variant that changes only the feature under test and must
+ * not. A firing control does not pin the rejection to a particular line, but it does rule out an
+ * unrelated fixture mismatch as the reason its partner was rejected. The row-bag whitelist
+ * (Aggregate, Window) stays a plain negative: dropping the operator would change the query shape
+ * rather than one feature.
+ *
+ * Self-joined fixtures are real tables, not temp views over VALUES. Spark deduplicates a self-join
+ * over a [[org.apache.spark.sql.catalyst.analysis.MultiInstanceRelation]] via `newInstance()`,
+ * which refreshes one side's ExprIds without inserting a rename-only Project, so both sides stay
+ * structurally identical. A temp view over VALUES cannot, and Spark renames one side with a Project
+ * instead, which would make `isSameBaseRelation` false for every self-join below. `range()` needs
+ * no such treatment -- Range is a MultiInstanceRelation already.
+ */
+class RewriteSelfJoinInequalityToAggregateSuite extends WholeStageTransformerSuite {
+
+  override protected val resourcePath: String = "/tpch-data-parquet"
+  override protected val fileFormat: String = "parquet"
+
+  override protected def sparkConf: SparkConf = super.sparkConf
+    .set("spark.gluten.sql.rewrite.selfJoinInequality", "true")

Review Comment:
   Good point. I’ll add checkGlutenOperatorMatch to the existing core positive case so the test explicitly verifies that the rewritten aggregate is offloaded to Velox rather than falling back to vanilla Spark.
   
   I agree there is no need to change the shared TPC-DS CI job for this opt-in rule; the existing suite-level assertion should cover the missing backend-execution check.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3820274414


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

Review Comment:
   Thanks a lot for the review and the approval! Good question — let me spell out the query shapes for the other two patterns.
   
   q95 actually has **two** `IN` subqueries, and they hit different patterns. Both reference the same CTE, so I inlined it below — otherwise the two look identical:
   
   ```sql
   -- ws_wh = self-join:  web_sales a, web_sales b
   --                     where a.ws_order_number = b.ws_order_number
   --                       and a.ws_warehouse_sk <> b.ws_warehouse_sk
   
   -- Pattern A': the self-join IS the subquery
   and ws1.ws_order_number in (select ws_order_number from <ws_wh>)
   
   -- Pattern A2: the self-join is a child of another InnerJoin
   and ws1.ws_order_number in (select wr_order_number
                               from web_returns, <ws_wh>
                               where wr_order_number = ws_wh.ws_order_number)
   ```
   
   A' replaces the whole subquery plan, while A2 replaces only the self-join child and has to preserve the outer join plus its wrapping `Project`'s exprIds — which is why it needs a separate code path.
   
   Both subqueries become `LeftSemi` hash builds, and at SF=300 they are the two biggest build sides in the query:
   
   | | pattern | build input OFF | ON | share of hash-build cost |
   |---|---|---|---|---|
   | first `IN` | A' | 2.35 B rows / 80 GiB | 18 M rows | ~60% |
   | second `IN` | A2 | 3.08 B rows / 91 GiB | 22 M rows | ~40% |
   
   So A2 isn't a speculative generalization — dropping it would leave the larger of the two build sides untouched and give up roughly 40% of the gain. More generally, A2 is the shape you get whenever the "more than one distinct X" set is narrowed by another table before feeding the `IN`.
   
   **Pattern A** is just A' with the semi/anti join written explicitly rather than derived from a subquery:
   
   ```sql
   select ws1.ws_order_number from web_sales ws1
   left semi join (select ws_order_number from <ws_wh>) x
     on ws1.ws_order_number = x.ws_order_number
   ```
   
   Same build side, same reduction, same guards — the two are symmetric. TPC-DS and TPC-H don't match this one only because they write every existence check as `IN` / `EXISTS`, whereas hand-written and BI-generated SQL fairly often uses explicit `LEFT SEMI` / `LEFT ANTI JOIN`.
   
   Hope that clarifies it — happy to go into more detail on any of the three if useful.
   



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on PR #12756:
URL: https://github.com/apache/gluten/pull/12756#issuecomment-5438347501

   > Hi, @hhr293 . After discussing with @zhouyuan and @LuciferYang , I think it’s fine to continue with this change in Gluten, especially to better support older Spark versions. Please consider validating it with upstream Spark CI first, since optimizer changes usually get more comprehensive coverage there. Even if it’s not eventually merged upstream, confirming that it works correctly in the general Spark context should be sufficient for us to move forward in Gluten. Thanks for your great work again.
   
   Thanks for the guidance and for discussing this with @zhouyuan and @LuciferYang.
   
   I’ll prepare a corresponding Spark-side change and validate it with upstream Spark CI, then report the results back here.
   
   Thanks again.


-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3795558198


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,622 @@
+/*
+ * 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.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 (post-RewritePredicateSubquery fallback): LeftSemi/LeftAnti whose right child is an
+ * Inner self-join (possibly wrapped in Project). The outer semi-join condition is pure equi-key
+ * referencing a column from the self-join output. The self-join condition has equi + inequality on
+ * the same table. Rewrites the right child to GROUP BY + HAVING.
+ *
+ * Pattern A' (pre-RewritePredicateSubquery, primary path): InSubquery(_, ListQuery(sub, ...)) or
+ * Exists(sub, ...) whose sub is Project(Inner self-join with equi+neq). Rewrites sub to
+ * Project(equi_keys, Filter(count_distinct>1, Aggregate)). Fires in the Operator Optimization
+ * batches BEFORE Spark lifts them to LeftSemi/LeftAnti; the outer expression form guarantees
+ * existence semantics. ScalarSubquery is intentionally NOT matched.
+ *
+ * Pattern A2 (nested self-join): InSubquery/Exists whose subquery plan is Project(Join(Inner,
+ * other_table, self-join)) -- the self-join is a child of another InnerJoin, not the top-level join
+ * itself. Only the self-join child is replaced with Aggregate; the outer join is preserved. Safe
+ * because the outer join connects on the equi-key, and the subquery is still consumed as an
+ * existence set.
+ *
+ * Controlled by spark.gluten.sql.rewrite.selfJoinInequality (default false, opt-in until exercised
+ * more broadly across workloads).
+ */
+case class RewriteSelfJoinInequalityToAggregate(spark: SparkSession)
+  extends Rule[LogicalPlan]
+  with PredicateHelper
+  with Logging {
+
+  override def apply(plan: LogicalPlan): LogicalPlan = {
+    if (!VeloxConfig.get.enableRewriteSelfJoinInequality) {
+      logDebug("RewriteSelfJoinInequalityToAggregate: disabled via config, skipping")
+      return plan
+    }
+
+    val afterOps = plan.transformUp {
+      // Pattern A: LeftSemi/LeftAnti whose right child is an Inner self-join
+      // (possibly wrapped in Project).
+      case j: Join
+          if (j.joinType == LeftSemi || j.joinType == LeftAnti) &&
+            j.condition.isDefined &&
+            isInnerJoinShape(j.right) =>
+        tryRewriteSemiWithSelfJoinChild(j, j.left, j.right, j.joinType, j.condition.get, j.hint)
+          .getOrElse(j)
+
+      case other => other
+    }
+
+    // Pattern A': rewrite subquery plans embedded in InSubquery/Exists expressions.
+    // Fires before Spark's RewritePredicateSubquery (batch pos 26); once the subquery
+    // plan is rewritten to GROUP BY + HAVING, RewritePredicateSubquery lifts it to
+    // LeftSemi/LeftAnti in the normal way.
+    // Use type-based matching (`x: T`) and named-argument copy (`x.copy(plan = ...)`)
+    // instead of case-class unapply with a fixed parameter list. This keeps the code
+    // portable across Spark 3.3/3.4/3.5/4.x where the internal ListQuery/Exists
+    // case classes have added parameters over releases.
+    val rewritten = afterOps.transformAllExpressions {
+      case in @ InSubquery(_, lq: ListQuery) =>
+        rewriteSubqueryPlan(lq.plan) match {
+          case Some(newSub) => in.copy(query = lq.copy(plan = newSub))
+          case None => in
+        }
+      case ex: Exists =>
+        rewriteSubqueryPlan(ex.plan) match {
+          case Some(newSub) => ex.copy(plan = newSub)
+          case None => ex
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logInfo("RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+        "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+
+  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
+  }
+
+  /**
+   * Dispatches subquery plan rewriting: tries Pattern A' (direct self-join at top level) first,
+   * then Pattern A2 (self-join nested as a child of another InnerJoin).
+   *
+   * Only called on subquery plans of `InSubquery` / `Exists`, i.e. contexts that consume the output
+   * as a set of distinct keys. Cardinality of the intermediate is safe to change.
+   */
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(plan, projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(plan, projectListOpt, innerJoin)
+    }
+  }
+
+  private def rewriteDirectSelfJoin(
+      plan: LogicalPlan,
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+
+    // Use field accessors (portable across Spark versions) instead of
+    // case-class unapply with a fixed parameter list. The caller has already
+    // pattern-matched innerJoin as Join(_, _, Inner, Some(_), _).
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs = equiPairs.map(_._1).collect { case a: Attribute => a }
+    if (innerLeftEquiAttrs.size != equiPairs.size) return None
+
+    val innerLeftNeqAttr = neqPairs.head._1 match {
+      case a: Attribute => a
+      case _ => return None
+    }
+
+    val countDistinctExpr = AggregateExpression(
+      Count(Seq(innerLeftNeqAttr)),
+      mode = Complete,
+      isDistinct = true,
+      filter = None,
+      NamedExpression.newExprId)
+    val countAlias = Alias(countDistinctExpr, "_gluten_rw_selfjoin_cnt_distinct")()
+
+    val groupingExprs: Seq[Expression] = innerLeftEquiAttrs
+    val aggExprs: Seq[NamedExpression] =
+      innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]) :+ countAlias
+    val aggregate = Aggregate(groupingExprs, aggExprs, innerLeft)
+
+    val filterExpr = GreaterThan(countAlias.toAttribute, Literal(1L, LongType))
+    val filtered = Filter(filterExpr, aggregate)
+
+    val nameToInnerLeft: Map[String, Attribute] = innerLeftEquiAttrs.map(a => a.name -> a).toMap
+    val innerLeftEquiExprIds = innerLeftEquiAttrs.map(_.exprId).toSet
+    val innerRightEquiExprIds =
+      equiPairs.map(_._2).collect { case a: Attribute => a.exprId }.toSet
+
+    val projectListResolved: Option[Seq[NamedExpression]] = projectListOpt match {
+      case None =>
+        Some(innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]))
+      case Some(pl) =>
+        val remapped = pl.map {
+          case a: Attribute
+              if nameToInnerLeft.contains(a.name) &&
+                (innerLeftEquiExprIds.contains(a.exprId) ||
+                  innerRightEquiExprIds.contains(a.exprId)) =>
+            Some(Alias(nameToInnerLeft(a.name), a.name)(a.exprId).asInstanceOf[NamedExpression])
+          case al @ Alias(a: Attribute, _)
+              if nameToInnerLeft.contains(a.name) &&
+                (innerLeftEquiExprIds.contains(a.exprId) ||
+                  innerRightEquiExprIds.contains(a.exprId)) =>
+            Some(Alias(nameToInnerLeft(a.name), al.name)(al.exprId).asInstanceOf[NamedExpression])
+          case _ =>
+            None
+        }
+        if (remapped.exists(_.isEmpty)) None
+        else Some(remapped.flatten)
+    }
+
+    projectListResolved.map {
+      pl =>
+        logInfo(
+          s"RewriteSelfJoinInequalityToAggregate: Pattern A' - rewrote subquery Project(Inner " +
+            s"self-join) to Project + Filter(count_distinct>1) + Aggregate. " +
+            s"equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}], " +
+            s"neqCol=${innerLeftNeqAttr.name}, outCols=[${pl.map(_.name).mkString(",")}]")
+        Project(pl, filtered)
+    }
+  }
+
+  /**
+   * Pattern A2: the top-level InnerJoin is NOT a self-join, but one of its children IS a self-join
+   * (possibly wrapped in Project). Example: `Join(Inner, web_returns, Project(self-join))`.
+   *
+   * We replace the self-join child with Aggregate + Filter, preserving the outer join. This is safe
+   * because:
+   *   1. The outer join condition connects the other table to the self-join's equi-key.
+   *   2. Replacing the self-join with GROUP BY + HAVING preserves the set of distinct keys (only
+   *      row-count multiplicity changes), and the other table joins on that key.
+   *   3. The entire subquery is consumed by InSubquery/Exists (existence semantics), so the final
+   *      output is still just a set of distinct keys.
+   */
+  private def rewriteNestedSelfJoin(
+      plan: LogicalPlan,
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+
+    // Use field accessors instead of case-class unapply (portable across Spark
+    // versions). Caller has already pattern-matched outerJoin as
+    // Join(_, _, Inner, Some(_), _).
+    val outerLeft = outerJoin.left
+    val outerRight = outerJoin.right
+    val outerCond = outerJoin.condition.get
+    val outerHint = outerJoin.hint
+
+    val (selfJoinSide, otherSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerRight) match {
+        case Some(_) => (outerRight, outerLeft, true)
+        case None =>
+          tryExtractSelfJoin(outerLeft) match {
+            case Some(_) => (outerLeft, outerRight, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs = equiPairs.map(_._1).collect { case a: Attribute => a }
+    if (sjLeftEquiAttrs.size != equiPairs.size) return None
+
+    val sjLeftNeqAttr = neqPairs.head._1 match {
+      case a: Attribute => a
+      case _ => return None
+    }
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    val sjEquiExprIds = equiPairs.flatMap {
+      case (l: Attribute, r: Attribute) => Seq(l.exprId, r.exprId)
+      case _ => Seq.empty
+    }.toSet
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt match {
+      case Some(pl) =>
+        pl.flatMap {
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) =>
+            Some(al.exprId)
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) =>
+            Some(a.exprId)
+          case _ => None
+        }.toSet
+      case None => Set.empty
+    }
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+
+    // The rewrite replaces the self-join subtree with Project(equiKeys, Filter(Aggregate)),
+    // so any reference to non-equi self-join attributes in the subquery's top-level
+    // projectList would become unresolved. Bail out unless the top-level projectList
+    // depends only on equi-key attributes from the self-join side.
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val projRefsFromSelfJoin = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        projRefsFromSelfJoin.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val countDistinctExpr = AggregateExpression(
+      Count(Seq(sjLeftNeqAttr)),
+      mode = Complete,
+      isDistinct = true,
+      filter = None,
+      NamedExpression.newExprId)
+    val countAlias = Alias(countDistinctExpr, "_gluten_rw_selfjoin_cnt_distinct")()
+
+    val groupingExprs: Seq[Expression] = sjLeftEquiAttrs
+    val aggExprs: Seq[NamedExpression] =
+      sjLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]) :+ countAlias
+    val aggregate = Aggregate(groupingExprs, aggExprs, sjLeft)
+
+    val filterExpr = GreaterThan(countAlias.toAttribute, Literal(1L, LongType))
+    val filtered = Filter(filterExpr, aggregate)
+
+    val sjLeftEquiByName: Map[String, Attribute] = sjLeftEquiAttrs.map(a => a.name -> a).toMap
+    val sjLeftEquiExprIds = sjLeftEquiAttrs.map(_.exprId).toSet
+    val sjRightEquiExprIds = equiPairs.map(_._2).collect { case a: Attribute => a.exprId }.toSet
+
+    val newSelfJoinSide: LogicalPlan = selfJoinProjectOpt match {
+      case Some(pl) =>
+        // Use flatMap + size check instead of `return None` inside the map lambda,
+        // which triggers Scala's nonlocal return (unsafe / lint-flagged).
+        val remapped: Seq[NamedExpression] = pl.flatMap {
+          case a: Attribute
+              if sjLeftEquiByName.contains(a.name) &&
+                (sjLeftEquiExprIds.contains(a.exprId) ||
+                  sjRightEquiExprIds.contains(a.exprId)) =>
+            Some(Alias(sjLeftEquiByName(a.name), a.name)(a.exprId): NamedExpression)
+          case al @ Alias(a: Attribute, _)
+              if sjLeftEquiByName.contains(a.name) &&
+                (sjLeftEquiExprIds.contains(a.exprId) ||
+                  sjRightEquiExprIds.contains(a.exprId)) =>
+            Some(Alias(sjLeftEquiByName(a.name), al.name)(al.exprId): NamedExpression)
+          case _ => None
+        }
+        if (remapped.size != pl.size) return None
+        Project(remapped, filtered)
+      case None =>
+        Project(sjLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]), filtered)
+    }
+
+    val newOuterJoin = if (selfJoinOnRight) {
+      outerJoin.copy(right = newSelfJoinSide)
+    } else {
+      outerJoin.copy(left = newSelfJoinSide)
+    }
+
+    val result = projectListOpt match {
+      case Some(pl) => Project(pl, newOuterJoin)
+      case None => newOuterJoin
+    }
+
+    logInfo(
+      s"RewriteSelfJoinInequalityToAggregate: Pattern A2 - rewrote nested self-join inside " +
+        s"subquery InnerJoin. Self-join replaced with GROUP BY HAVING COUNT(DISTINCT) > 1. " +
+        s"equiKeys=[${sjLeftEquiAttrs.map(_.name).mkString(",")}], " +
+        s"neqCol=${sjLeftNeqAttr.name}")
+
+    Some(result)
+  }
+
+  private def tryExtractSelfJoin(plan: LogicalPlan): Option[Join] = {
+    val join = plan match {
+      case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => j
+      case j: Join if j.joinType == Inner && j.condition.isDefined => j
+      case _ => return None
+    }
+    val l = join.left
+    val r = join.right
+    val cond = join.condition.get
+    if (!isSameBaseRelation(l, r)) return None
+    val parsed = parseSelfJoinCondition(cond, l, r)
+    if (parsed.isEmpty) return None
+    Some(join)
+  }
+
+  /**
+   * Pattern A: The LeftSemi/LeftAnti right child is itself an Inner self-join (with or without a
+   * wrapping Project). The semi-join condition is pure equi. We replace the right child with GROUP
+   * BY + HAVING COUNT(DISTINCT) > 1.
+   *
+   * Matches q95 structure: Join(LeftSemi, left = outer query (filtered web_sales + dims), right =
+   * Join(Inner, ws_L, ws_R, equi(order_number) AND neq(warehouse_sk)), condition =
+   * outer.order_number = inner.order_number)
+   */
+  private def tryRewriteSemiWithSelfJoinChild(
+      original: Join,
+      left: LogicalPlan,
+      right: LogicalPlan,
+      joinType: JoinType,
+      semiCondition: Expression,
+      hint: JoinHint): Option[LogicalPlan] = {
+
+    // Unwrap the right child: may be Project(Join(Inner,...)) or bare Join(Inner,...)
+    val (innerJoin, wrapper) = right match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (j, Some(p))
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (j, None)
+      case _ => return None
+    }
+
+    // Use field accessors (portable across Spark versions) instead of
+    // case-class unapply with a fixed parameter list. The caller has already
+    // pattern-matched innerJoin as Join(_, _, Inner, Some(_), _).
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    // Both sides of inner join must scan the same base relation
+    if (!isSameBaseRelation(innerLeft, innerRight)) return None
+
+    // Parse inner join condition: must be equi + neq only
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    val (innerEquiPairs, innerNeqPairs) = parsed.get
+
+    // The semi-join condition must reference an output attribute from the inner join
+    // that corresponds to an equi-key. This is how the outer query connects to the CTE.
+    val semiPreds = splitConjunctivePredicates(semiCondition)
+    val rightOutputSet = right.outputSet
+
+    // All semi predicates must be pure equi-join (no inequality in semi condition)
+    val semiEquiPairs = semiPreds.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if left.outputSet.contains(l) && rightOutputSet.contains(r) => (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if left.outputSet.contains(l) && rightOutputSet.contains(r) => (l, r)
+    }
+    if (semiEquiPairs.size != semiPreds.size) return None
+    if (semiEquiPairs.isEmpty) return None
+
+    // The semi-join right-side keys must be derivable from the inner join's equi-keys
+    val innerEquiLeftAttrIds =
+      innerEquiPairs.map(_._1).collect { case a: Attribute => a.exprId }.toSet
+    val innerEquiRightAttrIds =
+      innerEquiPairs.map(_._2).collect { case a: Attribute => a.exprId }.toSet
+    val innerEquiAllIds = innerEquiLeftAttrIds ++ innerEquiRightAttrIds
+
+    // Check that all semi-join right keys reference inner equi-key attributes
+    val semiRightKeys = semiEquiPairs.map(_._2)
+    // After ColumnPruning, the right side might output only the equi-key columns
+    // The semi right keys must be from the inner join's equi-key set
+    val rightKeyIds = semiRightKeys.map(_.exprId).toSet
+    val validSemiKeys = rightKeyIds.forall {
+      id =>
+        innerEquiAllIds.contains(id) || {
+          // Semi-key may be exposed via a wrapper Project. For an Alias,
+          // the alias's *output* ExprId must match the caller's `id`, AND
+          // the underlying attribute must be an inner equi-key. Using the
+          // alias's input ExprId here compared with `id` compares two
+          // unrelated ids (alias always mints a new output id).
+          wrapper.exists {
+            case Project(pl, _) =>
+              pl.exists {
+                case al @ Alias(a: Attribute, _) =>
+                  al.exprId == id && innerEquiAllIds.contains(a.exprId)
+                case a: Attribute =>
+                  a.exprId == id && innerEquiAllIds.contains(a.exprId)
+                case _ => false
+              }
+            case _ => false
+          }
+        }
+    }
+    if (!validSemiKeys) return None
+
+    // Build replacement: GROUP BY equi_keys HAVING COUNT(DISTINCT neq_col) > 1
+    val innerLeftEquiAttrs = innerEquiPairs.map(_._1).collect { case a: Attribute => a }
+    val innerNeqAttr = innerNeqPairs.head._1 match {
+      case a: Attribute => a
+      case _ => return None
+    }
+
+    val countDistinctExpr = AggregateExpression(
+      Count(Seq(innerNeqAttr)),
+      mode = Complete,
+      isDistinct = true,
+      filter = None,
+      NamedExpression.newExprId)
+    val countAlias = Alias(countDistinctExpr, "_gluten_rw_selfjoin_cnt_distinct")()
+
+    val groupingExprs: Seq[Expression] = innerLeftEquiAttrs
+    val aggExprs: Seq[NamedExpression] =
+      innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]) :+ countAlias
+    val aggregate = Aggregate(groupingExprs, aggExprs, innerLeft)
+
+    val filterExpr = GreaterThan(countAlias.toAttribute, Literal(1L, LongType))
+    val filtered = Filter(filterExpr, aggregate)
+
+    // Project only the equi-key columns (to match the original right-side output schema)
+    val projectedKeys = Project(
+      innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]),
+      filtered)
+
+    // Rebuild semi condition: map any exprId reachable from the old right side to
+    // the corresponding innerLeft equi attr. Cover three cases:
+    //  (1) direct innerRight equi attr
+    //  (2) innerLeft equi attr (identity)
+    //  (3) wrapper Project's output attr backed by Alias(innerEquiAttr, _) - new exprId
+    val innerRightEquiAttrs = innerEquiPairs.map(_._2).collect { case a: Attribute => a }
+    val nameToInnerLeft: Map[String, Attribute] =
+      innerLeftEquiAttrs.map(a => a.name -> a).toMap
+    val wrapperRemap: Map[ExprId, Attribute] = wrapper match {
+      case Some(Project(pl, _)) =>
+        pl.flatMap {
+          case al @ Alias(a: Attribute, _)
+              if (innerEquiLeftAttrIds.contains(a.exprId) ||
+                innerEquiRightAttrIds.contains(a.exprId)) &&
+                nameToInnerLeft.contains(a.name) =>
+            Some(al.exprId -> nameToInnerLeft(a.name))
+          case _ => None
+        }.toMap
+      case _ => Map.empty
+    }
+    val oldToNewMap: Map[ExprId, Attribute] =
+      innerRightEquiAttrs.map(_.exprId).zip(innerLeftEquiAttrs).toMap ++
+        innerLeftEquiAttrs.map(a => a.exprId -> a).toMap ++
+        wrapperRemap
+
+    // Safety: every attr in semiCondition that came from the old right output must
+    // resolve to something in oldToNewMap. If not, refuse to rewrite.
+    val unresolved = semiCondition.collect {
+      case a: Attribute if rightOutputSet.contains(a) && !oldToNewMap.contains(a.exprId) => a
+    }
+    if (unresolved.nonEmpty) return None
+
+    val newSemiCondition = semiCondition.transformUp {
+      case a: Attribute if oldToNewMap.contains(a.exprId) && rightOutputSet.contains(a) =>
+        oldToNewMap(a.exprId)
+    }
+
+    val newJoin = original.copy(
+      right = projectedKeys,
+      condition = Some(newSemiCondition))
+
+    logInfo(
+      s"RewriteSelfJoinInequalityToAggregate: Pattern A - rewrote $joinType with Inner " +
+        s"self-join child to $joinType + GROUP BY HAVING COUNT(DISTINCT) > 1. " +
+        s"equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}], " +
+        s"neqCol=${innerNeqAttr.name}")
+
+    Some(newJoin)
+  }
+
+  /**
+   * Parse a join condition into equi-pairs and inequality-pairs. Accepts only: EqualTo(attr, attr)
+   * and Not(EqualTo(attr, attr)). Ignores IsNotNull predicates (added by
+   * InferFiltersFromConstraints). Returns None if there are unrecognized predicates beyond equi +
+   * neq + IsNotNull.
+   */
+  private def parseSelfJoinCondition(
+      condition: Expression,
+      left: LogicalPlan,
+      right: LogicalPlan): Option[(Seq[(Attribute, Attribute)], Seq[(Attribute, Attribute)])] = {
+
+    val leftOutput = left.outputSet
+    val rightOutput = right.outputSet
+    val predicates = splitConjunctivePredicates(condition)
+
+    val equiPairs = predicates.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    val neqPairs = predicates.collect {
+      case Not(EqualTo(l: Attribute, r: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case Not(EqualTo(r: Attribute, l: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    // Only IsNotNull predicates on equi/neq attributes are safe to drop
+    // (they're redundant with the join semantics or auto-added by
+    // InferFiltersFromConstraints). IsNotNull on other columns would be
+    // silently dropped by the rewrite and change query semantics -- bail out.
+    val joinAttrIds: Set[org.apache.spark.sql.catalyst.expressions.ExprId] =
+      (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+
+    val isNotNullOnJoinCols = predicates.count {
+      case IsNotNull(a: Attribute) if joinAttrIds.contains(a.exprId) => true
+      case _ => false
+    }
+
+    // Accept only equi + neq + IsNotNull-on-join-cols; reject anything else.
+    val totalMatched = equiPairs.size + neqPairs.size + isNotNullOnJoinCols
+    if (totalMatched != predicates.size) return None
+
+    if (equiPairs.isEmpty || neqPairs.isEmpty) return None
+
+    // Only rewrite single-inequality case: COUNT(DISTINCT single_col) > 1 semantics
+    // is strictly wider than "exists row with ALL of multiple != predicates".
+    if (neqPairs.size != 1) return None
+
+    // Self-join invariant: equi and neq must be on same-named columns from both sides.
+    val equiValid = equiPairs.forall { case (l, r) => l.name == r.name }
+    val neqValid = neqPairs.forall { case (l, r) => l.name == r.name }
+    if (!equiValid || !neqValid) return None
+
+    Some((equiPairs, neqPairs))

Review Comment:
   Thanks for flagging this. The claimed failure mode is actually not a correctness issue. If the inequality column is also a grouping/equi-key, e.g. t1.k = t2.k AND t1.k <> t2.k, the original join is unsatisfiable. The rewritten form is also empty: within each GROUP BY k group, COUNT(DISTINCT k) is at most 1, so HAVING COUNT(DISTINCT k) > 1 can never pass.
   
   That said, the rule already rejects this shape explicitly. The guard is intentionally fail-closed: it keeps the supported rewrite shape limited to cases where the inequality column is distinct from the equi-key columns, rather than relying on the degenerate-case equivalence above.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3818037377


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,622 @@
+/*
+ * 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.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 (post-RewritePredicateSubquery fallback): LeftSemi/LeftAnti whose right child is an
+ * Inner self-join (possibly wrapped in Project). The outer semi-join condition is pure equi-key
+ * referencing a column from the self-join output. The self-join condition has equi + inequality on
+ * the same table. Rewrites the right child to GROUP BY + HAVING.
+ *
+ * Pattern A' (pre-RewritePredicateSubquery, primary path): InSubquery(_, ListQuery(sub, ...)) or
+ * Exists(sub, ...) whose sub is Project(Inner self-join with equi+neq). Rewrites sub to
+ * Project(equi_keys, Filter(count_distinct>1, Aggregate)). Fires in the Operator Optimization
+ * batches BEFORE Spark lifts them to LeftSemi/LeftAnti; the outer expression form guarantees
+ * existence semantics. ScalarSubquery is intentionally NOT matched.
+ *
+ * Pattern A2 (nested self-join): InSubquery/Exists whose subquery plan is Project(Join(Inner,
+ * other_table, self-join)) -- the self-join is a child of another InnerJoin, not the top-level join
+ * itself. Only the self-join child is replaced with Aggregate; the outer join is preserved. Safe
+ * because the outer join connects on the equi-key, and the subquery is still consumed as an
+ * existence set.
+ *
+ * Controlled by spark.gluten.sql.rewrite.selfJoinInequality (default false, opt-in until exercised
+ * more broadly across workloads).
+ */
+case class RewriteSelfJoinInequalityToAggregate(spark: SparkSession)
+  extends Rule[LogicalPlan]
+  with PredicateHelper
+  with Logging {
+
+  override def apply(plan: LogicalPlan): LogicalPlan = {
+    if (!VeloxConfig.get.enableRewriteSelfJoinInequality) {
+      logDebug("RewriteSelfJoinInequalityToAggregate: disabled via config, skipping")
+      return plan
+    }
+
+    val afterOps = plan.transformUp {
+      // Pattern A: LeftSemi/LeftAnti whose right child is an Inner self-join
+      // (possibly wrapped in Project).
+      case j: Join
+          if (j.joinType == LeftSemi || j.joinType == LeftAnti) &&
+            j.condition.isDefined &&
+            isInnerJoinShape(j.right) =>
+        tryRewriteSemiWithSelfJoinChild(j, j.left, j.right, j.joinType, j.condition.get, j.hint)
+          .getOrElse(j)
+
+      case other => other
+    }
+
+    // Pattern A': rewrite subquery plans embedded in InSubquery/Exists expressions.
+    // Fires before Spark's RewritePredicateSubquery (batch pos 26); once the subquery
+    // plan is rewritten to GROUP BY + HAVING, RewritePredicateSubquery lifts it to
+    // LeftSemi/LeftAnti in the normal way.
+    // Use type-based matching (`x: T`) and named-argument copy (`x.copy(plan = ...)`)
+    // instead of case-class unapply with a fixed parameter list. This keeps the code
+    // portable across Spark 3.3/3.4/3.5/4.x where the internal ListQuery/Exists
+    // case classes have added parameters over releases.
+    val rewritten = afterOps.transformAllExpressions {
+      case in @ InSubquery(_, lq: ListQuery) =>
+        rewriteSubqueryPlan(lq.plan) match {
+          case Some(newSub) => in.copy(query = lq.copy(plan = newSub))
+          case None => in
+        }
+      case ex: Exists =>
+        rewriteSubqueryPlan(ex.plan) match {
+          case Some(newSub) => ex.copy(plan = newSub)
+          case None => ex
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logInfo("RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+        "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+
+  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
+  }
+
+  /**
+   * Dispatches subquery plan rewriting: tries Pattern A' (direct self-join at top level) first,
+   * then Pattern A2 (self-join nested as a child of another InnerJoin).
+   *
+   * Only called on subquery plans of `InSubquery` / `Exists`, i.e. contexts that consume the output
+   * as a set of distinct keys. Cardinality of the intermediate is safe to change.
+   */
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(plan, projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(plan, projectListOpt, innerJoin)
+    }
+  }
+
+  private def rewriteDirectSelfJoin(
+      plan: LogicalPlan,
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+
+    // Use field accessors (portable across Spark versions) instead of
+    // case-class unapply with a fixed parameter list. The caller has already
+    // pattern-matched innerJoin as Join(_, _, Inner, Some(_), _).
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs = equiPairs.map(_._1).collect { case a: Attribute => a }
+    if (innerLeftEquiAttrs.size != equiPairs.size) return None
+
+    val innerLeftNeqAttr = neqPairs.head._1 match {
+      case a: Attribute => a
+      case _ => return None
+    }
+
+    val countDistinctExpr = AggregateExpression(
+      Count(Seq(innerLeftNeqAttr)),
+      mode = Complete,
+      isDistinct = true,
+      filter = None,
+      NamedExpression.newExprId)
+    val countAlias = Alias(countDistinctExpr, "_gluten_rw_selfjoin_cnt_distinct")()
+
+    val groupingExprs: Seq[Expression] = innerLeftEquiAttrs
+    val aggExprs: Seq[NamedExpression] =
+      innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]) :+ countAlias
+    val aggregate = Aggregate(groupingExprs, aggExprs, innerLeft)
+
+    val filterExpr = GreaterThan(countAlias.toAttribute, Literal(1L, LongType))
+    val filtered = Filter(filterExpr, aggregate)
+
+    val nameToInnerLeft: Map[String, Attribute] = innerLeftEquiAttrs.map(a => a.name -> a).toMap
+    val innerLeftEquiExprIds = innerLeftEquiAttrs.map(_.exprId).toSet
+    val innerRightEquiExprIds =
+      equiPairs.map(_._2).collect { case a: Attribute => a.exprId }.toSet
+
+    val projectListResolved: Option[Seq[NamedExpression]] = projectListOpt match {
+      case None =>
+        Some(innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]))
+      case Some(pl) =>
+        val remapped = pl.map {
+          case a: Attribute
+              if nameToInnerLeft.contains(a.name) &&
+                (innerLeftEquiExprIds.contains(a.exprId) ||
+                  innerRightEquiExprIds.contains(a.exprId)) =>
+            Some(Alias(nameToInnerLeft(a.name), a.name)(a.exprId).asInstanceOf[NamedExpression])
+          case al @ Alias(a: Attribute, _)
+              if nameToInnerLeft.contains(a.name) &&
+                (innerLeftEquiExprIds.contains(a.exprId) ||
+                  innerRightEquiExprIds.contains(a.exprId)) =>
+            Some(Alias(nameToInnerLeft(a.name), al.name)(al.exprId).asInstanceOf[NamedExpression])
+          case _ =>
+            None
+        }
+        if (remapped.exists(_.isEmpty)) None
+        else Some(remapped.flatten)
+    }
+
+    projectListResolved.map {
+      pl =>
+        logInfo(
+          s"RewriteSelfJoinInequalityToAggregate: Pattern A' - rewrote subquery Project(Inner " +
+            s"self-join) to Project + Filter(count_distinct>1) + Aggregate. " +
+            s"equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}], " +
+            s"neqCol=${innerLeftNeqAttr.name}, outCols=[${pl.map(_.name).mkString(",")}]")
+        Project(pl, filtered)
+    }
+  }
+
+  /**
+   * Pattern A2: the top-level InnerJoin is NOT a self-join, but one of its children IS a self-join
+   * (possibly wrapped in Project). Example: `Join(Inner, web_returns, Project(self-join))`.
+   *
+   * We replace the self-join child with Aggregate + Filter, preserving the outer join. This is safe
+   * because:
+   *   1. The outer join condition connects the other table to the self-join's equi-key.
+   *   2. Replacing the self-join with GROUP BY + HAVING preserves the set of distinct keys (only
+   *      row-count multiplicity changes), and the other table joins on that key.
+   *   3. The entire subquery is consumed by InSubquery/Exists (existence semantics), so the final
+   *      output is still just a set of distinct keys.
+   */
+  private def rewriteNestedSelfJoin(
+      plan: LogicalPlan,
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+
+    // Use field accessors instead of case-class unapply (portable across Spark
+    // versions). Caller has already pattern-matched outerJoin as
+    // Join(_, _, Inner, Some(_), _).
+    val outerLeft = outerJoin.left
+    val outerRight = outerJoin.right
+    val outerCond = outerJoin.condition.get
+    val outerHint = outerJoin.hint
+
+    val (selfJoinSide, otherSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerRight) match {
+        case Some(_) => (outerRight, outerLeft, true)
+        case None =>
+          tryExtractSelfJoin(outerLeft) match {
+            case Some(_) => (outerLeft, outerRight, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs = equiPairs.map(_._1).collect { case a: Attribute => a }
+    if (sjLeftEquiAttrs.size != equiPairs.size) return None
+
+    val sjLeftNeqAttr = neqPairs.head._1 match {
+      case a: Attribute => a
+      case _ => return None
+    }
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    val sjEquiExprIds = equiPairs.flatMap {
+      case (l: Attribute, r: Attribute) => Seq(l.exprId, r.exprId)
+      case _ => Seq.empty
+    }.toSet
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt match {
+      case Some(pl) =>
+        pl.flatMap {
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) =>
+            Some(al.exprId)
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) =>
+            Some(a.exprId)
+          case _ => None
+        }.toSet
+      case None => Set.empty
+    }
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+
+    // The rewrite replaces the self-join subtree with Project(equiKeys, Filter(Aggregate)),
+    // so any reference to non-equi self-join attributes in the subquery's top-level
+    // projectList would become unresolved. Bail out unless the top-level projectList
+    // depends only on equi-key attributes from the self-join side.
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val projRefsFromSelfJoin = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        projRefsFromSelfJoin.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val countDistinctExpr = AggregateExpression(
+      Count(Seq(sjLeftNeqAttr)),
+      mode = Complete,
+      isDistinct = true,
+      filter = None,
+      NamedExpression.newExprId)
+    val countAlias = Alias(countDistinctExpr, "_gluten_rw_selfjoin_cnt_distinct")()
+
+    val groupingExprs: Seq[Expression] = sjLeftEquiAttrs
+    val aggExprs: Seq[NamedExpression] =
+      sjLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]) :+ countAlias
+    val aggregate = Aggregate(groupingExprs, aggExprs, sjLeft)
+
+    val filterExpr = GreaterThan(countAlias.toAttribute, Literal(1L, LongType))
+    val filtered = Filter(filterExpr, aggregate)
+
+    val sjLeftEquiByName: Map[String, Attribute] = sjLeftEquiAttrs.map(a => a.name -> a).toMap
+    val sjLeftEquiExprIds = sjLeftEquiAttrs.map(_.exprId).toSet
+    val sjRightEquiExprIds = equiPairs.map(_._2).collect { case a: Attribute => a.exprId }.toSet
+
+    val newSelfJoinSide: LogicalPlan = selfJoinProjectOpt match {
+      case Some(pl) =>
+        // Use flatMap + size check instead of `return None` inside the map lambda,
+        // which triggers Scala's nonlocal return (unsafe / lint-flagged).
+        val remapped: Seq[NamedExpression] = pl.flatMap {
+          case a: Attribute
+              if sjLeftEquiByName.contains(a.name) &&
+                (sjLeftEquiExprIds.contains(a.exprId) ||
+                  sjRightEquiExprIds.contains(a.exprId)) =>
+            Some(Alias(sjLeftEquiByName(a.name), a.name)(a.exprId): NamedExpression)
+          case al @ Alias(a: Attribute, _)
+              if sjLeftEquiByName.contains(a.name) &&
+                (sjLeftEquiExprIds.contains(a.exprId) ||
+                  sjRightEquiExprIds.contains(a.exprId)) =>
+            Some(Alias(sjLeftEquiByName(a.name), al.name)(al.exprId): NamedExpression)
+          case _ => None
+        }
+        if (remapped.size != pl.size) return None
+        Project(remapped, filtered)
+      case None =>
+        Project(sjLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]), filtered)
+    }
+
+    val newOuterJoin = if (selfJoinOnRight) {
+      outerJoin.copy(right = newSelfJoinSide)
+    } else {
+      outerJoin.copy(left = newSelfJoinSide)
+    }
+
+    val result = projectListOpt match {
+      case Some(pl) => Project(pl, newOuterJoin)
+      case None => newOuterJoin
+    }
+
+    logInfo(
+      s"RewriteSelfJoinInequalityToAggregate: Pattern A2 - rewrote nested self-join inside " +
+        s"subquery InnerJoin. Self-join replaced with GROUP BY HAVING COUNT(DISTINCT) > 1. " +
+        s"equiKeys=[${sjLeftEquiAttrs.map(_.name).mkString(",")}], " +
+        s"neqCol=${sjLeftNeqAttr.name}")
+
+    Some(result)
+  }
+
+  private def tryExtractSelfJoin(plan: LogicalPlan): Option[Join] = {
+    val join = plan match {
+      case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => j
+      case j: Join if j.joinType == Inner && j.condition.isDefined => j
+      case _ => return None
+    }
+    val l = join.left
+    val r = join.right
+    val cond = join.condition.get
+    if (!isSameBaseRelation(l, r)) return None
+    val parsed = parseSelfJoinCondition(cond, l, r)
+    if (parsed.isEmpty) return None
+    Some(join)
+  }
+
+  /**
+   * Pattern A: The LeftSemi/LeftAnti right child is itself an Inner self-join (with or without a
+   * wrapping Project). The semi-join condition is pure equi. We replace the right child with GROUP
+   * BY + HAVING COUNT(DISTINCT) > 1.
+   *
+   * Matches q95 structure: Join(LeftSemi, left = outer query (filtered web_sales + dims), right =
+   * Join(Inner, ws_L, ws_R, equi(order_number) AND neq(warehouse_sk)), condition =
+   * outer.order_number = inner.order_number)
+   */
+  private def tryRewriteSemiWithSelfJoinChild(
+      original: Join,
+      left: LogicalPlan,
+      right: LogicalPlan,
+      joinType: JoinType,
+      semiCondition: Expression,
+      hint: JoinHint): Option[LogicalPlan] = {
+
+    // Unwrap the right child: may be Project(Join(Inner,...)) or bare Join(Inner,...)
+    val (innerJoin, wrapper) = right match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (j, Some(p))
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (j, None)
+      case _ => return None
+    }
+
+    // Use field accessors (portable across Spark versions) instead of
+    // case-class unapply with a fixed parameter list. The caller has already
+    // pattern-matched innerJoin as Join(_, _, Inner, Some(_), _).
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    // Both sides of inner join must scan the same base relation
+    if (!isSameBaseRelation(innerLeft, innerRight)) return None
+
+    // Parse inner join condition: must be equi + neq only
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    val (innerEquiPairs, innerNeqPairs) = parsed.get
+
+    // The semi-join condition must reference an output attribute from the inner join
+    // that corresponds to an equi-key. This is how the outer query connects to the CTE.
+    val semiPreds = splitConjunctivePredicates(semiCondition)
+    val rightOutputSet = right.outputSet
+
+    // All semi predicates must be pure equi-join (no inequality in semi condition)
+    val semiEquiPairs = semiPreds.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if left.outputSet.contains(l) && rightOutputSet.contains(r) => (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if left.outputSet.contains(l) && rightOutputSet.contains(r) => (l, r)
+    }
+    if (semiEquiPairs.size != semiPreds.size) return None
+    if (semiEquiPairs.isEmpty) return None
+
+    // The semi-join right-side keys must be derivable from the inner join's equi-keys
+    val innerEquiLeftAttrIds =
+      innerEquiPairs.map(_._1).collect { case a: Attribute => a.exprId }.toSet
+    val innerEquiRightAttrIds =
+      innerEquiPairs.map(_._2).collect { case a: Attribute => a.exprId }.toSet
+    val innerEquiAllIds = innerEquiLeftAttrIds ++ innerEquiRightAttrIds
+
+    // Check that all semi-join right keys reference inner equi-key attributes
+    val semiRightKeys = semiEquiPairs.map(_._2)
+    // After ColumnPruning, the right side might output only the equi-key columns
+    // The semi right keys must be from the inner join's equi-key set
+    val rightKeyIds = semiRightKeys.map(_.exprId).toSet
+    val validSemiKeys = rightKeyIds.forall {
+      id =>
+        innerEquiAllIds.contains(id) || {
+          // Semi-key may be exposed via a wrapper Project. For an Alias,
+          // the alias's *output* ExprId must match the caller's `id`, AND
+          // the underlying attribute must be an inner equi-key. Using the
+          // alias's input ExprId here compared with `id` compares two
+          // unrelated ids (alias always mints a new output id).
+          wrapper.exists {
+            case Project(pl, _) =>
+              pl.exists {
+                case al @ Alias(a: Attribute, _) =>
+                  al.exprId == id && innerEquiAllIds.contains(a.exprId)
+                case a: Attribute =>
+                  a.exprId == id && innerEquiAllIds.contains(a.exprId)
+                case _ => false
+              }
+            case _ => false
+          }
+        }
+    }
+    if (!validSemiKeys) return None
+
+    // Build replacement: GROUP BY equi_keys HAVING COUNT(DISTINCT neq_col) > 1
+    val innerLeftEquiAttrs = innerEquiPairs.map(_._1).collect { case a: Attribute => a }
+    val innerNeqAttr = innerNeqPairs.head._1 match {
+      case a: Attribute => a
+      case _ => return None
+    }
+
+    val countDistinctExpr = AggregateExpression(
+      Count(Seq(innerNeqAttr)),
+      mode = Complete,
+      isDistinct = true,
+      filter = None,
+      NamedExpression.newExprId)
+    val countAlias = Alias(countDistinctExpr, "_gluten_rw_selfjoin_cnt_distinct")()
+
+    val groupingExprs: Seq[Expression] = innerLeftEquiAttrs
+    val aggExprs: Seq[NamedExpression] =
+      innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]) :+ countAlias
+    val aggregate = Aggregate(groupingExprs, aggExprs, innerLeft)
+
+    val filterExpr = GreaterThan(countAlias.toAttribute, Literal(1L, LongType))
+    val filtered = Filter(filterExpr, aggregate)
+
+    // Project only the equi-key columns (to match the original right-side output schema)
+    val projectedKeys = Project(
+      innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]),
+      filtered)
+
+    // Rebuild semi condition: map any exprId reachable from the old right side to
+    // the corresponding innerLeft equi attr. Cover three cases:
+    //  (1) direct innerRight equi attr
+    //  (2) innerLeft equi attr (identity)
+    //  (3) wrapper Project's output attr backed by Alias(innerEquiAttr, _) - new exprId
+    val innerRightEquiAttrs = innerEquiPairs.map(_._2).collect { case a: Attribute => a }
+    val nameToInnerLeft: Map[String, Attribute] =
+      innerLeftEquiAttrs.map(a => a.name -> a).toMap
+    val wrapperRemap: Map[ExprId, Attribute] = wrapper match {
+      case Some(Project(pl, _)) =>
+        pl.flatMap {
+          case al @ Alias(a: Attribute, _)
+              if (innerEquiLeftAttrIds.contains(a.exprId) ||
+                innerEquiRightAttrIds.contains(a.exprId)) &&
+                nameToInnerLeft.contains(a.name) =>
+            Some(al.exprId -> nameToInnerLeft(a.name))
+          case _ => None
+        }.toMap
+      case _ => Map.empty
+    }
+    val oldToNewMap: Map[ExprId, Attribute] =
+      innerRightEquiAttrs.map(_.exprId).zip(innerLeftEquiAttrs).toMap ++
+        innerLeftEquiAttrs.map(a => a.exprId -> a).toMap ++
+        wrapperRemap
+
+    // Safety: every attr in semiCondition that came from the old right output must
+    // resolve to something in oldToNewMap. If not, refuse to rewrite.
+    val unresolved = semiCondition.collect {
+      case a: Attribute if rightOutputSet.contains(a) && !oldToNewMap.contains(a.exprId) => a
+    }
+    if (unresolved.nonEmpty) return None
+
+    val newSemiCondition = semiCondition.transformUp {
+      case a: Attribute if oldToNewMap.contains(a.exprId) && rightOutputSet.contains(a) =>
+        oldToNewMap(a.exprId)
+    }
+
+    val newJoin = original.copy(
+      right = projectedKeys,
+      condition = Some(newSemiCondition))
+
+    logInfo(
+      s"RewriteSelfJoinInequalityToAggregate: Pattern A - rewrote $joinType with Inner " +
+        s"self-join child to $joinType + GROUP BY HAVING COUNT(DISTINCT) > 1. " +
+        s"equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}], " +
+        s"neqCol=${innerNeqAttr.name}")
+
+    Some(newJoin)
+  }
+
+  /**
+   * Parse a join condition into equi-pairs and inequality-pairs. Accepts only: EqualTo(attr, attr)
+   * and Not(EqualTo(attr, attr)). Ignores IsNotNull predicates (added by
+   * InferFiltersFromConstraints). Returns None if there are unrecognized predicates beyond equi +
+   * neq + IsNotNull.
+   */
+  private def parseSelfJoinCondition(
+      condition: Expression,
+      left: LogicalPlan,
+      right: LogicalPlan): Option[(Seq[(Attribute, Attribute)], Seq[(Attribute, Attribute)])] = {
+
+    val leftOutput = left.outputSet
+    val rightOutput = right.outputSet
+    val predicates = splitConjunctivePredicates(condition)
+
+    val equiPairs = predicates.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    val neqPairs = predicates.collect {
+      case Not(EqualTo(l: Attribute, r: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case Not(EqualTo(r: Attribute, l: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    // Only IsNotNull predicates on equi/neq attributes are safe to drop
+    // (they're redundant with the join semantics or auto-added by
+    // InferFiltersFromConstraints). IsNotNull on other columns would be
+    // silently dropped by the rewrite and change query semantics -- bail out.
+    val joinAttrIds: Set[org.apache.spark.sql.catalyst.expressions.ExprId] =
+      (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+
+    val isNotNullOnJoinCols = predicates.count {
+      case IsNotNull(a: Attribute) if joinAttrIds.contains(a.exprId) => true
+      case _ => false
+    }
+
+    // Accept only equi + neq + IsNotNull-on-join-cols; reject anything else.
+    val totalMatched = equiPairs.size + neqPairs.size + isNotNullOnJoinCols
+    if (totalMatched != predicates.size) return None
+
+    if (equiPairs.isEmpty || neqPairs.isEmpty) return None
+
+    // Only rewrite single-inequality case: COUNT(DISTINCT single_col) > 1 semantics
+    // is strictly wider than "exists row with ALL of multiple != predicates".
+    if (neqPairs.size != 1) return None
+
+    // Self-join invariant: equi and neq must be on same-named columns from both sides.
+    val equiValid = equiPairs.forall { case (l, r) => l.name == r.name }
+    val neqValid = neqPairs.forall { case (l, r) => l.name == r.name }
+    if (!equiValid || !neqValid) return None
+
+    Some((equiPairs, neqPairs))

Review Comment:
   > @hhr293 could you please check if this review comment makes sense? it's marked as high priority
   
   Hi, @zhouyuan I’ve addressed the feedback from your last review. Could you take another look when you get a chance? Thanks!



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3823009133


##########
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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Anything else in a projectList --
+   * e.g. computed expressions we don't own -- is passed through unchanged.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): NamedExpression = ne match {
+    case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+    case a: Attribute => a
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      if (newChild eq al.child) al
+      else Alias(newChild, al.name)(al.exprId, al.qualifier, al.explicitMetadata)
+    case other => other
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery / Exists)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition guarantees Seq[(Attribute, Attribute)] and distinct equi-key names.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map {
+          case (newWrapper, _) =>
+            logDebug(
+              s"Pattern A' - equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}]" +
+                s", neqCol=${innerLeftNeqAttr.name}" +
+                s", outCols=[${newWrapper.projectList.map(_.name).mkString(",")}]")
+            newWrapper
+        }
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A2 : self-join nested inside another InnerJoin in the subquery
+  // ============================================================================
+
+  private def rewriteNestedSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+    val outerCond = outerJoin.condition.get
+
+    val (selfJoinSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerJoin.right) match {
+        case Some(_) => (outerJoin.right, true)
+        case None =>
+          tryExtractSelfJoin(outerJoin.left) match {
+            case Some(_) => (outerJoin.left, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(p), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition guarantees Seq[(Attribute, Attribute)] and distinct equi-key names.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val sjLeftNeqAttr: Attribute = neqPairs.head._1
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val sjEquiExprIds: Set[ExprId] =
+      equiPairs.flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    // wrapper Project may reproject equi-keys under fresh alias exprIds; include those.
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt.toSeq.flatMap {
+      p =>
+        p.projectList.flatMap {
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) => Some(a.exprId)
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) => Some(al.exprId)
+          case _ => None
+        }
+    }.toSet
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+
+    // Outer join condition may reference only equi-key attrs from the self-join side.
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+
+    // Top-level subquery Project may reference only equi-key attrs from the self-join side.
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val refs = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        refs.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val filtered = buildAggregateHavingDistinctGt1(sjLeftEquiAttrs, sjLeftNeqAttr, sjLeft)
+
+    val (newSelfJoinSide, outputRemap): (LogicalPlan, Map[ExprId, Attribute]) =
+      selfJoinProjectOpt match {
+        case Some(wp) =>
+          canonicalizeWrapper(wp.projectList, equiPairs, filtered) match {
+            case Some((newWrapper, remap)) => (newWrapper, remap)
+            case None => return None
+          }
+        case None if projectListOpt.isEmpty =>
+          // Fail-closed: with neither a wrapper Project around the self-join nor a top-level
+          // subquery Project, the outer join currently exposes every self-join column, and
+          // replacing the self-join with `Project(equiKeys, filtered)` would shrink the outer
+          // join's right-hand output arity. RewritePredicateSubquery's positional zip
+          // (`values.zip(sub.output).map(EqualTo.tupled)`) would then bind semi predicates to
+          // the wrong attributes -- silently dropping components of a tuple IN/EXISTS. A
+          // top-level Project (`projectListOpt`) is what would let the arity be preserved
+          // by the top-level rewrite loop; without one, refuse to rewrite.
+          return None
+        case None =>
+          // No wrapper Project but there IS a top-level subquery Project: shrinking the outer
+          // join's self-join-side output is safe because the top-level Project is rewritten
+          // consistently via `outputRemap` below and the top-level rewrite loop ensures
+          // subquery output arity matches what the enclosing InSubquery/Exists expects.
+          // Outer references may point at sjRight equi-attributes; remap them to sjLeft
+          // (same names in a valid self-join).
+          val newP = Project(sjLeftEquiAttrs, filtered)
+          val remap: Map[ExprId, Attribute] =
+            equiPairs.map { case (l, r) => r.exprId -> l }.toMap
+          (newP, remap)
+      }
+
+    // Rewrite outer join condition to use new wrapper output attributes.
+    val newOuterCond = outerCond.transformUp {
+      case a: Attribute if outputRemap.contains(a.exprId) => outputRemap(a.exprId)
+    }
+
+    val newOuterJoin = if (selfJoinOnRight) {
+      outerJoin.copy(right = newSelfJoinSide, condition = Some(newOuterCond))
+    } else {
+      outerJoin.copy(left = newSelfJoinSide, condition = Some(newOuterCond))
+    }
+
+    // Rewrite top-level Project references.
+    val result = projectListOpt match {
+      case Some(pl) =>
+        val newPl = pl.map(ne => remapNamedExpressionAttributes(ne, outputRemap))
+        Project(newPl, newOuterJoin)
+      case None => newOuterJoin
+    }
+
+    logDebug(
+      s"Pattern A2 - equiKeys=[${sjLeftEquiAttrs.map(_.name).mkString(",")}]" +
+        s", neqCol=${sjLeftNeqAttr.name}")
+    Some(result)
+  }
+
+  private def tryExtractSelfJoin(plan: LogicalPlan): Option[Join] = {
+    val join = plan match {
+      case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => j
+      case j: Join if j.joinType == Inner && j.condition.isDefined => j
+      case _ => return None
+    }
+    if (!isSameBaseRelation(join.left, join.right)) return None
+    val parsed = parseSelfJoinCondition(join.condition.get, join.left, join.right)
+    if (parsed.isEmpty) return None
+    Some(join)
+  }
+
+  // ============================================================================
+  //  Pattern A : LeftSemi/LeftAnti whose right child is an Inner self-join
+  // ============================================================================
+
+  private def tryRewriteSemiWithSelfJoinChild(original: Join): Option[LogicalPlan] = {

Review Comment:
   Thanks, this makes sense.
   
   When I originally designed the rule, I included Pattern A intentionally to cover cases where a LEFT SEMI / LEFT ANTI join is already present in the input plan. So this path is reachable for explicitly written semi/anti joins, although, as you pointed out, it is not exercised by TPC-DS Q95.
   
   Given that Pattern A introduces a separate main-plan rewrite path and this PR has already grown fairly large, I agree that the cleaner option is to ship A' and A2 only for now and remove Pattern A together with its related remapping logic and comments.
   
   This is mainly to keep the scope of this PR focused on the two Q95 shapes rather than because of a correctness issue with Pattern A itself. I plan to revisit and redesign Pattern A in a follow-up PR with dedicated coverage.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on PR #12756:
URL: https://github.com/apache/gluten/pull/12756#issuecomment-5311525804

   > Thanks for the PR! It looks like this change introduces a dedicated optimizer rule. Have you considered contributing this optimization upstream to the Spark community as well? It seems fairly generic and potentially useful beyond Gluten.
   
   Thanks for the suggestion. The rewrite itself is generic, but the performance issue was identified and validated on Gluten/Velox. We kept it in Gluten first to limit the rollout scope while validating correctness and profitability. If the rule proves broadly applicable, upstreaming it to Spark would be the natural next step.


-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "LuciferYang (via GitHub)" <gi...@apache.org>.
LuciferYang commented on PR #12756:
URL: https://github.com/apache/gluten/pull/12756#issuecomment-5312149457

   @hhr293 As @jackylee-ch  mentioned, if this is generally applicable to Apache Spark, feel free to submit it directly to Apache Spark, I can help review the patch there.


-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3871892639


##########
backends-velox/src/test/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregateSuite.scala:
##########
@@ -0,0 +1,548 @@
+/*
+ * 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.execution.WholeStageTransformerSuite
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.catalyst.expressions.Alias
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{IntegerType, StructField, StructType}
+
+/**
+ * Correctness tests for [[RewriteSelfJoinInequalityToAggregate]].
+ *
+ * Positive A' / A2 cases assert both result equivalence and that the rewrite actually fired.
+ *
+ * `assert(!ruleFired(plan))` on its own only proves the rewrite did not happen -- not that it was
+ * the guard under test that stopped it. A fixture whose two self-join sides are not structurally
+ * identical is rejected by `isSameBaseRelation` before any predicate is even parsed, and such a
+ * test passes while covering nothing. So six important rejection paths -- the predicate parser, the
+ * single-inequality requirement, output-position identity, the nondeterminism guard, the
+ * leaf-source allowlist (LogicalRDD vs Parquet), and the expression-type allowlist (`abs(v)` vs
+ * `v + 1`) -- are tested as single-variable pairs: the same fixture and the same query shape, one
+ * control query that must fire and one variant that changes only the feature under test and must
+ * not. A firing control does not pin the rejection to a particular line, but it does rule out an
+ * unrelated fixture mismatch as the reason its partner was rejected. The row-bag whitelist
+ * (Aggregate, Window) stays a plain negative: dropping the operator would change the query shape
+ * rather than one feature.
+ *
+ * Self-joined fixtures are real tables, not temp views over VALUES. Spark deduplicates a self-join
+ * over a [[org.apache.spark.sql.catalyst.analysis.MultiInstanceRelation]] via `newInstance()`,
+ * which refreshes one side's ExprIds without inserting a rename-only Project, so both sides stay
+ * structurally identical. A temp view over VALUES cannot, and Spark renames one side with a Project
+ * instead, which would make `isSameBaseRelation` false for every self-join below. `range()` needs
+ * no such treatment -- Range is a MultiInstanceRelation already.
+ */
+class RewriteSelfJoinInequalityToAggregateSuite extends WholeStageTransformerSuite {
+
+  override protected val resourcePath: String = "/tpch-data-parquet"
+  override protected val fileFormat: String = "parquet"
+
+  override protected def sparkConf: SparkConf = super.sparkConf
+    .set("spark.gluten.sql.rewrite.selfJoinInequality", "true")
+    .set(SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key, "-1")
+
+  /** Signature alias produced by the rewrite; presence => rule definitely fired. */
+  private val CountDistinctAlias = "_gluten_rw_selfjoin_cnt_distinct"
+
+  private def ruleFired(plan: LogicalPlan): Boolean =
+    plan.exists {
+      p =>
+        p.expressions.exists(_.exists {
+          case a: Alias if a.name == CountDistinctAlias => true
+          case _ => false
+        })
+    }
+
+  private def assertRuleFired(sql: String): Unit = {
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "true") {
+      val plan = spark.sql(sql).queryExecution.optimizedPlan
+      assert(ruleFired(plan), s"self-join inequality rewrite should fire:\n$plan")
+    }
+  }
+
+  private def assertRuleNotFired(sql: String): Unit = {
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "true") {
+      val plan = spark.sql(sql).queryExecution.optimizedPlan
+      assert(!ruleFired(plan), s"self-join inequality rewrite must not fire:\n$plan")
+    }
+  }
+
+  /**
+   * A real table, so that a self-join of it dedups into two structurally identical sides. See the
+   * class comment for why a temp view over VALUES cannot be used for a self-joined fixture.
+   */
+  private def createTable(name: String, schema: String, values: String): Unit = {
+    spark.sql(s"DROP TABLE IF EXISTS $name")
+    spark.sql(s"CREATE TABLE $name($schema) USING parquet")
+    spark.sql(s"INSERT INTO $name SELECT * FROM VALUES $values")
+  }
+
+  /** Run `sql` twice, first with rewrite ON then OFF, and return the two result row sets. */
+  private def runBoth(sql: String): (Set[Row], Set[Row]) = {
+    var on: Set[Row] = null
+    var off: Set[Row] = null
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "true") {
+      on = spark.sql(sql).collect().toSet
+    }
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "false") {
+      off = spark.sql(sql).collect().toSet
+    }
+    (on, off)
+  }
+
+  private def setupTable(): Unit = {
+    // k=1: distinct v={10,20}      -> matches (has 2 non-null distinct)
+    // k=2: distinct v={30}         -> no match (only 1)
+    // k=3: distinct v={40,50,60}   -> matches
+    // k=4: v={70, NULL}            -> no match (only 1 non-null)
+    // k=5: v={NULL, NULL}          -> no match (0 non-null)
+    // k=6: v={80, 90, NULL}        -> matches
+    createTable(
+      "T",
+      "k INT, v INT",
+      """  (1, 10), (1, 10), (1, 20),
+        |  (2, 30),
+        |  (3, 40), (3, 50), (3, 60),
+        |  (4, 70), (4, CAST(NULL AS INT)),
+        |  (5, CAST(NULL AS INT)), (5, CAST(NULL AS INT)),
+        |  (6, 80), (6, 90), (6, CAST(NULL AS INT))""".stripMargin
+    )
+  }
+
+  // ==================== Positive: rewrite fires and is semantically equivalent ===============
+
+  test("Pattern A': direct InSubquery self-join is rewritten") {
+    setupTable()
+    val sql =
+      """SELECT k FROM T outer_t WHERE k IN (
+        |  SELECT s1.k FROM T s1 JOIN T s2
+        |    ON s1.k = s2.k AND s1.v <> s2.v)""".stripMargin
+
+    assertRuleFired(sql)

Review Comment:
   > By adding and optimizing SQL equivalent to the execution plan here, and then using comparePlans for comparison, the process becomes more intuitive
   
   Thanks, this makes sense. I’ll add an equivalent aggregate SQL for the positive rewrite cases and compare the optimized plans with comparePlans, while keeping the result-parity checks as well.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3956102411


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,511 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites supported uncorrelated IN-subquery inequality self-joins into
+ * `GROUP BY + HAVING COUNT(DISTINCT) > 1`, avoiding the self-join cross-product.
+ *
+ * Supports a direct self-join (Pattern A') and a self-join nested under an outer inner join
+ * (Pattern A2, where only the self-join child becomes an Aggregate). Unsupported and correlated
+ * shapes fail closed.
+ *
+ * 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) {
+      return plan
+    }
+
+    // Fail closed on correlated subqueries: `lq.children` holds the outer references this rule
+    // does not remap.
+    val rewritten = plan.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
+        }
+    }
+    rewritten
+  }
+
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * Build `GROUP BY equiKeys HAVING COUNT(DISTINCT neqCol) > 1`, filtering NULL equi keys first to
+   * preserve the equi-join's NULL semantics: `=` never matches a NULL key, but GROUP BY would fold
+   * all NULL keys into one group that can leak NULL into a `NOT IN`. COUNT(DISTINCT) ignores NULL,
+   * so the neq column needs no filter.
+   */
+  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)()
+    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)
+  }
+
+  /**
+   * Rebuild the wrapper Project so every equi-key reference points at the sjLeft attribute with a
+   * fresh output ExprId, returning `oldOutputExprId -> newOutputAttr` for downstream references
+   * (outer join condition, top-level Project). Lookup is by ExprId (Catalyst attribute identity),
+   * not name. Fails closed when an entry is neither an equi-key Attribute nor `Alias(equi-key, _)`.
+   */
+  private def canonicalizeWrapper(
+      projectList: Seq[NamedExpression],
+      equiPairs: Seq[(Attribute, Attribute)],
+      newChild: LogicalPlan): Option[(Project, Map[ExprId, 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) =>
+        Some(
+          Alias(exprIdToLeft(a.exprId), a.name)(
+            qualifier = a.qualifier,
+            explicitMetadata = Some(a.metadata)): NamedExpression)
+      case al @ Alias(a: Attribute, _) if exprIdToLeft.contains(a.exprId) =>
+        Some(
+          Alias(exprIdToLeft(a.exprId), al.name)(
+            qualifier = al.qualifier,
+            explicitMetadata = al.explicitMetadata,
+            nonInheritableMetadataKeys = al.nonInheritableMetadataKeys): NamedExpression)
+      case _ => None
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key references inside a NamedExpression per `remap`, preserving Attribute/Alias
+   * shape. Any other expression still referencing a replaced output returns None (fail-closed) to
+   * avoid a dangling ExprId.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): Option[NamedExpression] = ne match {
+    case a: Attribute if remap.contains(a.exprId) => Some(remap(a.exprId))
+    case a: Attribute => Some(a)
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      Some(
+        if (newChild eq al.child) {
+          al
+        } else {
+          Alias(newChild, al.name)(
+            al.exprId,
+            al.qualifier,
+            al.explicitMetadata,
+            al.nonInheritableMetadataKeys)
+        })
+    case other if other.references.exists(a => remap.contains(a.exprId)) =>
+      None
+    case other => Some(other)
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level guard: reject if any node in the whole subquery is non-repeatable, catching
+    // nondeterminism hoisted above the self-join that the per-side `isSameBaseRelation` misses.
+    if (!isRepeatablePlan(plan)) return None
+

Review Comment:
   `rewriteSubqueryPlan` already performs a whole-subquery `isRepeatablePlan(plan)` check, but `isSameBaseRelation` then re-traverses subtrees with `isRepeatablePlan(left/right)` (and may be called multiple times via `tryExtractSelfJoin` / nested checks). Consider removing the redundant repeatability checks from `isSameBaseRelation` (or memoizing repeatability results) to reduce optimizer overhead on large plans.



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,511 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites supported uncorrelated IN-subquery inequality self-joins into
+ * `GROUP BY + HAVING COUNT(DISTINCT) > 1`, avoiding the self-join cross-product.
+ *
+ * Supports a direct self-join (Pattern A') and a self-join nested under an outer inner join
+ * (Pattern A2, where only the self-join child becomes an Aggregate). Unsupported and correlated
+ * shapes fail closed.
+ *
+ * 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) {
+      return plan
+    }
+
+    // Fail closed on correlated subqueries: `lq.children` holds the outer references this rule
+    // does not remap.
+    val rewritten = plan.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
+        }
+    }

Review Comment:
   `lq.children.isEmpty` is a non-obvious proxy for “uncorrelated subquery” and couples the rule to how `ListQuery.children` is constructed in a given Spark version. Prefer checking the dedicated correlation/outer-reference fields/API on `ListQuery`/`SubqueryExpression` (e.g., `outerAttrs` if available) so the intent is explicit and less brittle across Spark versions.



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,511 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites supported uncorrelated IN-subquery inequality self-joins into
+ * `GROUP BY + HAVING COUNT(DISTINCT) > 1`, avoiding the self-join cross-product.
+ *
+ * Supports a direct self-join (Pattern A') and a self-join nested under an outer inner join
+ * (Pattern A2, where only the self-join child becomes an Aggregate). Unsupported and correlated
+ * shapes fail closed.
+ *
+ * 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) {
+      return plan
+    }
+
+    // Fail closed on correlated subqueries: `lq.children` holds the outer references this rule
+    // does not remap.
+    val rewritten = plan.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
+        }
+    }
+    rewritten
+  }
+
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * Build `GROUP BY equiKeys HAVING COUNT(DISTINCT neqCol) > 1`, filtering NULL equi keys first to
+   * preserve the equi-join's NULL semantics: `=` never matches a NULL key, but GROUP BY would fold
+   * all NULL keys into one group that can leak NULL into a `NOT IN`. COUNT(DISTINCT) ignores NULL,
+   * so the neq column needs no filter.
+   */
+  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)()
+    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)
+  }
+
+  /**
+   * Rebuild the wrapper Project so every equi-key reference points at the sjLeft attribute with a
+   * fresh output ExprId, returning `oldOutputExprId -> newOutputAttr` for downstream references
+   * (outer join condition, top-level Project). Lookup is by ExprId (Catalyst attribute identity),
+   * not name. Fails closed when an entry is neither an equi-key Attribute nor `Alias(equi-key, _)`.
+   */
+  private def canonicalizeWrapper(
+      projectList: Seq[NamedExpression],
+      equiPairs: Seq[(Attribute, Attribute)],
+      newChild: LogicalPlan): Option[(Project, Map[ExprId, 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) =>
+        Some(
+          Alias(exprIdToLeft(a.exprId), a.name)(
+            qualifier = a.qualifier,
+            explicitMetadata = Some(a.metadata)): NamedExpression)
+      case al @ Alias(a: Attribute, _) if exprIdToLeft.contains(a.exprId) =>
+        Some(
+          Alias(exprIdToLeft(a.exprId), al.name)(
+            qualifier = al.qualifier,
+            explicitMetadata = al.explicitMetadata,
+            nonInheritableMetadataKeys = al.nonInheritableMetadataKeys): NamedExpression)
+      case _ => None
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key references inside a NamedExpression per `remap`, preserving Attribute/Alias
+   * shape. Any other expression still referencing a replaced output returns None (fail-closed) to
+   * avoid a dangling ExprId.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): Option[NamedExpression] = ne match {
+    case a: Attribute if remap.contains(a.exprId) => Some(remap(a.exprId))
+    case a: Attribute => Some(a)
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      Some(
+        if (newChild eq al.child) {
+          al
+        } else {
+          Alias(newChild, al.name)(
+            al.exprId,
+            al.qualifier,
+            al.explicitMetadata,
+            al.nonInheritableMetadataKeys)
+        })
+    case other if other.references.exists(a => remap.contains(a.exprId)) =>
+      None
+    case other => Some(other)
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level guard: reject if any node in the whole subquery is non-repeatable, catching
+    // nondeterminism hoisted above the self-join that the per-side `isSameBaseRelation` misses.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    // Fail closed on an explicit join hint: it is a directive about the join this rule deletes.
+    if (innerJoin.hint != JoinHint.NONE) return None
+
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail closed on a bare-Join subquery: with no wrapper Project, replacing the self-join output
+    // with `Project(equiKeys, filtered)` shrinks arity and RewritePredicateSubquery's positional
+    // `values.zip(sub.output)` would misbind semi predicates. Q95 subqueries always have a Project.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map { case (newWrapper, _) => newWrapper }
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A2 : self-join nested inside another InnerJoin in the subquery
+  // ============================================================================
+
+  private def rewriteNestedSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+    val outerCond = outerJoin.condition.get
+
+    val (selfJoinSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerJoin.right) match {
+        case Some(_) => (outerJoin.right, true)
+        case None =>
+          tryExtractSelfJoin(outerJoin.left) match {
+            case Some(_) => (outerJoin.left, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(p), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val sjLeftNeqAttr: Attribute = neqPairs.head._1
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val sjEquiExprIds: Set[ExprId] =
+      equiPairs.flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    // A wrapper Project may reproject equi-keys under fresh alias exprIds; include those.
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt.toSeq.flatMap {
+      p =>
+        p.projectList.flatMap {
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) => Some(a.exprId)
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) => Some(al.exprId)
+          case _ => None
+        }
+    }.toSet
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+
+    // The outer join condition and any top-level Project may reference only equi-key attrs from the
+    // self-join side (the neq column does not survive the rewrite).
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val refs = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        refs.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val filtered = buildAggregateHavingDistinctGt1(sjLeftEquiAttrs, sjLeftNeqAttr, sjLeft)
+
+    val (newSelfJoinSide, outputRemap): (LogicalPlan, Map[ExprId, Attribute]) =
+      selfJoinProjectOpt match {
+        case Some(wp) =>
+          canonicalizeWrapper(wp.projectList, equiPairs, filtered) match {
+            case Some((newWrapper, remap)) => (newWrapper, remap)
+            case None => return None
+          }
+        case None if projectListOpt.isEmpty =>
+          // Fail closed: with no wrapper and no top-level Project, `Project(equiKeys, filtered)`
+          // shrinks the outer join's arity and RewritePredicateSubquery's positional zip misbinds.
+          return None
+        case None =>
+          // Defensive path for a rule set without ColumnPruning (unreachable by default): the
+          // top-level Project preserves arity via `outputRemap`, remapping sjRight equi-refs to
+          // sjLeft (same output position in a valid self-join).
+          val newP = Project(sjLeftEquiAttrs, filtered)
+          val remap: Map[ExprId, Attribute] =
+            equiPairs.map { case (l, r) => r.exprId -> l }.toMap
+          (newP, remap)
+      }
+
+    val newOuterCond = outerCond.transformUp {
+      case a: Attribute if outputRemap.contains(a.exprId) => outputRemap(a.exprId)
+    }
+
+    val newOuterJoin = if (selfJoinOnRight) {
+      outerJoin.copy(right = newSelfJoinSide, condition = Some(newOuterCond))
+    } else {
+      outerJoin.copy(left = newSelfJoinSide, condition = Some(newOuterCond))
+    }
+
+    val result = projectListOpt match {
+      case Some(pl) =>
+        val remapped = pl.map(ne => remapNamedExpressionAttributes(ne, outputRemap))
+        if (remapped.exists(_.isEmpty)) return None
+        Project(remapped.flatten, newOuterJoin)
+      case None => newOuterJoin
+    }
+    Some(result)
+  }
+
+  private def tryExtractSelfJoin(plan: LogicalPlan): Option[Join] = {
+    val join = plan match {
+      case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => j
+      case j: Join if j.joinType == Inner && j.condition.isDefined => j
+      case _ => return None
+    }
+    // A hinted self-join is not an extraction candidate; see `rewriteDirectSelfJoin`.
+    if (join.hint != JoinHint.NONE) return None
+    if (!isSameBaseRelation(join.left, join.right)) return None
+    if (parseSelfJoinCondition(join.condition.get, join.left, join.right).isEmpty) return None
+    Some(join)
+  }
+
+  // ============================================================================
+  //  parseSelfJoinCondition + isSameBaseRelation
+  // ============================================================================
+
+  private def outputOrdinal(plan: LogicalPlan, attr: Attribute): Int =
+    plan.output.indexWhere(_.exprId == attr.exprId)
+
+  private def sameOutputPosition(
+      leftPlan: LogicalPlan,
+      rightPlan: LogicalPlan,
+      leftAttr: Attribute,
+      rightAttr: Attribute): Boolean = {
+    val leftPos = outputOrdinal(leftPlan, leftAttr)
+    val rightPos = outputOrdinal(rightPlan, rightAttr)
+    leftPos >= 0 && rightPos >= 0 && leftPos == rightPos
+  }
+
+  /**
+   * Parse a join condition into equi-pairs and inequality-pairs. Accepts only `EqualTo(attr, attr)`
+   * and `Not(EqualTo(attr, attr))` across opposite sides, and `IsNotNull(attr)` on a join column;
+   * anything else fails the whole rewrite closed.
+   */
+  private def parseSelfJoinCondition(
+      condition: Expression,
+      leftPlan: LogicalPlan,
+      rightPlan: LogicalPlan)
+      : Option[(Seq[(Attribute, Attribute)], Seq[(Attribute, Attribute)])] = {
+
+    val leftOutput = leftPlan.outputSet
+    val rightOutput = rightPlan.outputSet
+    val predicates = splitConjunctivePredicates(condition)
+
+    val equiPairs = predicates.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    val neqPairs = predicates.collect {
+      case Not(EqualTo(l: Attribute, r: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case Not(EqualTo(r: Attribute, l: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    // Only IsNotNull on a join column is safe to drop -- redundant with the join or auto-added by
+    // InferFiltersFromConstraints. IsNotNull on any other column changes semantics; bail out.
+    val joinAttrIds: Set[ExprId] =
+      (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    val isNotNullOnJoinCols = predicates.count {
+      case IsNotNull(a: Attribute) if joinAttrIds.contains(a.exprId) => true
+      case _ => false
+    }
+
+    val totalMatched = equiPairs.size + neqPairs.size + isNotNullOnJoinCols
+    if (totalMatched != predicates.size) return None
+    if (equiPairs.isEmpty || neqPairs.isEmpty) return None
+
+    // A single inequality only: COUNT(DISTINCT) over one column cannot represent multiple neqs.
+    if (neqPairs.size != 1) return None
+
+    // Resolve each predicate end by ExprId and require matching output ordinals, not name equality
+    // (canonicalization erases cosmetic Alias names).
+    val equiValid =
+      equiPairs.forall { case (l, r) => sameOutputPosition(leftPlan, rightPlan, l, r) }
+    val neqValid = neqPairs.forall { case (l, r) => sameOutputPosition(leftPlan, rightPlan, l, r) }
+    if (!equiValid || !neqValid) return None
+
+    // Equi-key output positions must be distinct, so swapped/duplicate aliases cannot collide.
+    val leftEquiOrdinals = equiPairs.map { case (l, _) => outputOrdinal(leftPlan, l) }
+    if (leftEquiOrdinals.exists(_ < 0)) return None
+    if (leftEquiOrdinals.distinct.size != leftEquiOrdinals.size) return None
+
+    // Reject when the neq column overlaps an equi-key column (e.g. `t1.k = t2.k AND t1.k <> t2.k`).
+    val neqLeftOrdinal = outputOrdinal(leftPlan, neqPairs.head._1)
+    if (neqLeftOrdinal < 0 || leftEquiOrdinals.contains(neqLeftOrdinal)) return None
+
+    // Datatype safety, checked last: the rewrite swaps comparison equality (`=`/`<>`) for
+    // grouping/distinct equality, so every column moved into the aggregate must be a type where the
+    // two coincide.
+    val comparisonAttrs = (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l, r) }
+    if (!comparisonAttrs.forall(a => isSafeComparisonGroupingType(a.dataType))) return None
+
+    Some((equiPairs, neqPairs))
+  }
+
+  /**
+   * Positive allowlist of types where comparison equality (`=`/`<>`) and grouping/distinct equality
+   * provably coincide, so a key can move from a join predicate into GROUP BY / COUNT(DISTINCT).
+   * Float/Double (NaN, signed zero) and String/CHAR/VARCHAR (collation-dependent) are excluded;
+   * complex types, UDTs and unknown types fail closed.
+   */
+  private def isSafeComparisonGroupingType(dataType: DataType): Boolean = dataType match {
+    case ByteType | ShortType | IntegerType | LongType => true
+    case _: DecimalType => true
+    case BooleanType => true
+    case DateType => true
+    case TimestampType => true
+    case BinaryType => true
+    case _ => false
+  }
+
+  /**
+   * Primary safety guard: the rewrite folds two occurrences of one subtree into a single aggregate,
+   * so a plan qualifies only when its operators, leaves and expressions are all allowlisted as
+   * repeatable. `plan.deterministic` alone is insufficient -- Aggregate(First), Window row_number
+   * over a non-total order and Limit/Sample are row-bag nondeterministic yet report deterministic.
+   * Embedded expression subqueries also fail closed.
+   */
+  private def isRepeatablePlan(plan: LogicalPlan): Boolean = {
+    plan.deterministic &&
+    !plan.isStreaming &&
+    plan.subqueriesAll.isEmpty &&
+    isRowBagRepeatable(plan) &&
+    hasRepeatableExpressions(plan)
+  }
+
+  /** Operator/leaf allowlist for repeatable row bags; everything unknown fails closed. */
+  private def isRowBagRepeatable(plan: LogicalPlan): Boolean = !plan.exists {
+    case _: Project => false
+    case _: Filter => false
+    case _: SubqueryAlias => false
+    case _: Join => false
+    case _: Range => false
+    case _: LocalRelation => false
+    case relation: LogicalRelation =>
+      // Trust a Parquet scan only: exact `ParquetFileFormat` (getClass, not isInstanceOf, since it
+      // is non-final); any other FileFormat is not provably repeatable.
+      relation.relation match {
+        case h: HadoopFsRelation if h.fileFormat.getClass == classOf[ParquetFileFormat] => false
+        case _ => true
+      }
+    case _ => true
+  }
+
+  private def hasRepeatableExpressions(plan: LogicalPlan): Boolean = {
+    !plan.exists(node => node.expressions.exists(expr => !isRepeatableExpression(expr)))
+  }
+
+  /**
+   * Expression allowlist: repeatable only when the root type is allowlisted and all children are,
+   * so `Add(v, Abs(w))` is rejected. Unknown types fail closed (a missed optimization, not a bug).
+   *
+   * Spark 3.3 decimal arithmetic carries `PromotePrecision` / `CheckOverflow`, absent here, so it
+   * fails closed; Spark 3.4+ removed those wrappers (SPARK-39316) and a decimal `Add` is accepted.
+   */
+  private def isRepeatableExpression(expr: Expression): Boolean = expr match {
+    case _: Attribute | _: Literal =>
+      true
+    case _: Alias | _: Cast | _: Add | _: Subtract | _: Multiply | _: Divide | _: Remainder |
+        _: And | _: Or | _: Not | _: EqualTo | _: EqualNullSafe | _: LessThan |
+        _: LessThanOrEqual | _: GreaterThan | _: GreaterThanOrEqual | _: IsNull | _: IsNotNull =>
+      expr.children.forall(isRepeatableExpression)
+    case _ =>
+      false
+  }
+
+  /** True iff `left`/`right` are the same plan modulo canonicalization AND each is repeatable. */
+  private def isSameBaseRelation(left: LogicalPlan, right: LogicalPlan): Boolean = {
+    left.canonicalized == right.canonicalized &&
+    isRepeatablePlan(left) && isRepeatablePlan(right)

Review Comment:
   `rewriteSubqueryPlan` already performs a whole-subquery `isRepeatablePlan(plan)` check, but `isSameBaseRelation` then re-traverses subtrees with `isRepeatablePlan(left/right)` (and may be called multiple times via `tryExtractSelfJoin` / nested checks). Consider removing the redundant repeatability checks from `isSameBaseRelation` (or memoizing repeatability results) to reduce optimizer overhead on large plans.



##########
docs/velox-configuration.md:
##########
@@ -92,6 +92,7 @@ nav_order: 16
 | spark.gluten.sql.columnar.backend.velox.valueStream.dynamicFilter.enabled        | 🔄 Dynamic    | false             | Whether to apply dynamic filters pushed down from hash probe in the ValueStream (shuffle reader) operator to filter rows before they reach the hash join.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 
                 |
 | spark.gluten.sql.enable.enhancedFeatures                                         | 🔄 Dynamic    | true              | Enable some features including iceberg native write and other features.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   
                 |
 | spark.gluten.sql.rewrite.castArrayToString                                       | 🔄 Dynamic    | true              | When true, rewrite `cast(array as String)` to `concat('[', array_join(array, ', ', null), ']')` to allow offloading to Velox.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             
                 |
+| spark.gluten.sql.rewrite.selfJoinInequality                                      | 🔄 Dynamic    | false             | When true, rewrite supported uncorrelated InSubquery self-joins with inequality predicate into GROUP BY + HAVING COUNT(DISTINCT) > 1. Currently targets Parquet-backed direct and nested self-join shapes exercised by TPC-DS Q95. Opt-in default (false) until the rewrite has been exercised more broadly across workloads.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             
                 |

Review Comment:
   The description is a bit imprecise: `COUNT(DISTINCT) > 1` is missing the distinct expression (it’s really `COUNT(DISTINCT <neqCol>) > 1`), and the rewrite includes a `GROUP BY <equiKeys>`. Tweaking the wording (and/or adding backticks) would make it clearer what shape is produced and help users reason about why the optimization is conservative.



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,511 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites supported uncorrelated IN-subquery inequality self-joins into
+ * `GROUP BY + HAVING COUNT(DISTINCT) > 1`, avoiding the self-join cross-product.
+ *
+ * Supports a direct self-join (Pattern A') and a self-join nested under an outer inner join
+ * (Pattern A2, where only the self-join child becomes an Aggregate). Unsupported and correlated
+ * shapes fail closed.
+ *
+ * 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) {
+      return plan
+    }
+
+    // Fail closed on correlated subqueries: `lq.children` holds the outer references this rule
+    // does not remap.
+    val rewritten = plan.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
+        }
+    }
+    rewritten
+  }
+
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * Build `GROUP BY equiKeys HAVING COUNT(DISTINCT neqCol) > 1`, filtering NULL equi keys first to
+   * preserve the equi-join's NULL semantics: `=` never matches a NULL key, but GROUP BY would fold
+   * all NULL keys into one group that can leak NULL into a `NOT IN`. COUNT(DISTINCT) ignores NULL,
+   * so the neq column needs no filter.
+   */
+  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)()
+    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)
+  }
+
+  /**
+   * Rebuild the wrapper Project so every equi-key reference points at the sjLeft attribute with a
+   * fresh output ExprId, returning `oldOutputExprId -> newOutputAttr` for downstream references
+   * (outer join condition, top-level Project). Lookup is by ExprId (Catalyst attribute identity),
+   * not name. Fails closed when an entry is neither an equi-key Attribute nor `Alias(equi-key, _)`.
+   */
+  private def canonicalizeWrapper(
+      projectList: Seq[NamedExpression],
+      equiPairs: Seq[(Attribute, Attribute)],
+      newChild: LogicalPlan): Option[(Project, Map[ExprId, 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) =>
+        Some(
+          Alias(exprIdToLeft(a.exprId), a.name)(
+            qualifier = a.qualifier,
+            explicitMetadata = Some(a.metadata)): NamedExpression)
+      case al @ Alias(a: Attribute, _) if exprIdToLeft.contains(a.exprId) =>
+        Some(
+          Alias(exprIdToLeft(a.exprId), al.name)(
+            qualifier = al.qualifier,
+            explicitMetadata = al.explicitMetadata,
+            nonInheritableMetadataKeys = al.nonInheritableMetadataKeys): NamedExpression)
+      case _ => None
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key references inside a NamedExpression per `remap`, preserving Attribute/Alias
+   * shape. Any other expression still referencing a replaced output returns None (fail-closed) to
+   * avoid a dangling ExprId.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): Option[NamedExpression] = ne match {
+    case a: Attribute if remap.contains(a.exprId) => Some(remap(a.exprId))
+    case a: Attribute => Some(a)
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      Some(
+        if (newChild eq al.child) {
+          al
+        } else {
+          Alias(newChild, al.name)(
+            al.exprId,
+            al.qualifier,
+            al.explicitMetadata,
+            al.nonInheritableMetadataKeys)
+        })
+    case other if other.references.exists(a => remap.contains(a.exprId)) =>
+      None
+    case other => Some(other)
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level guard: reject if any node in the whole subquery is non-repeatable, catching
+    // nondeterminism hoisted above the self-join that the per-side `isSameBaseRelation` misses.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    // Fail closed on an explicit join hint: it is a directive about the join this rule deletes.
+    if (innerJoin.hint != JoinHint.NONE) return None
+
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail closed on a bare-Join subquery: with no wrapper Project, replacing the self-join output
+    // with `Project(equiKeys, filtered)` shrinks arity and RewritePredicateSubquery's positional
+    // `values.zip(sub.output)` would misbind semi predicates. Q95 subqueries always have a Project.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map { case (newWrapper, _) => newWrapper }
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A2 : self-join nested inside another InnerJoin in the subquery
+  // ============================================================================
+
+  private def rewriteNestedSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+    val outerCond = outerJoin.condition.get
+
+    val (selfJoinSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerJoin.right) match {
+        case Some(_) => (outerJoin.right, true)
+        case None =>
+          tryExtractSelfJoin(outerJoin.left) match {
+            case Some(_) => (outerJoin.left, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(p), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val sjLeftNeqAttr: Attribute = neqPairs.head._1
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val sjEquiExprIds: Set[ExprId] =
+      equiPairs.flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    // A wrapper Project may reproject equi-keys under fresh alias exprIds; include those.
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt.toSeq.flatMap {
+      p =>
+        p.projectList.flatMap {
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) => Some(a.exprId)
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) => Some(al.exprId)
+          case _ => None
+        }
+    }.toSet
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+
+    // The outer join condition and any top-level Project may reference only equi-key attrs from the
+    // self-join side (the neq column does not survive the rewrite).
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val refs = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        refs.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val filtered = buildAggregateHavingDistinctGt1(sjLeftEquiAttrs, sjLeftNeqAttr, sjLeft)
+
+    val (newSelfJoinSide, outputRemap): (LogicalPlan, Map[ExprId, Attribute]) =
+      selfJoinProjectOpt match {
+        case Some(wp) =>
+          canonicalizeWrapper(wp.projectList, equiPairs, filtered) match {
+            case Some((newWrapper, remap)) => (newWrapper, remap)
+            case None => return None
+          }
+        case None if projectListOpt.isEmpty =>
+          // Fail closed: with no wrapper and no top-level Project, `Project(equiKeys, filtered)`
+          // shrinks the outer join's arity and RewritePredicateSubquery's positional zip misbinds.
+          return None
+        case None =>
+          // Defensive path for a rule set without ColumnPruning (unreachable by default): the
+          // top-level Project preserves arity via `outputRemap`, remapping sjRight equi-refs to
+          // sjLeft (same output position in a valid self-join).
+          val newP = Project(sjLeftEquiAttrs, filtered)
+          val remap: Map[ExprId, Attribute] =
+            equiPairs.map { case (l, r) => r.exprId -> l }.toMap
+          (newP, remap)
+      }
+
+    val newOuterCond = outerCond.transformUp {
+      case a: Attribute if outputRemap.contains(a.exprId) => outputRemap(a.exprId)
+    }
+
+    val newOuterJoin = if (selfJoinOnRight) {
+      outerJoin.copy(right = newSelfJoinSide, condition = Some(newOuterCond))
+    } else {
+      outerJoin.copy(left = newSelfJoinSide, condition = Some(newOuterCond))
+    }
+
+    val result = projectListOpt match {
+      case Some(pl) =>
+        val remapped = pl.map(ne => remapNamedExpressionAttributes(ne, outputRemap))
+        if (remapped.exists(_.isEmpty)) return None
+        Project(remapped.flatten, newOuterJoin)
+      case None => newOuterJoin
+    }
+    Some(result)
+  }
+
+  private def tryExtractSelfJoin(plan: LogicalPlan): Option[Join] = {
+    val join = plan match {
+      case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => j
+      case j: Join if j.joinType == Inner && j.condition.isDefined => j
+      case _ => return None
+    }
+    // A hinted self-join is not an extraction candidate; see `rewriteDirectSelfJoin`.
+    if (join.hint != JoinHint.NONE) return None
+    if (!isSameBaseRelation(join.left, join.right)) return None
+    if (parseSelfJoinCondition(join.condition.get, join.left, join.right).isEmpty) return None
+    Some(join)
+  }
+
+  // ============================================================================
+  //  parseSelfJoinCondition + isSameBaseRelation
+  // ============================================================================
+
+  private def outputOrdinal(plan: LogicalPlan, attr: Attribute): Int =
+    plan.output.indexWhere(_.exprId == attr.exprId)
+
+  private def sameOutputPosition(
+      leftPlan: LogicalPlan,
+      rightPlan: LogicalPlan,
+      leftAttr: Attribute,
+      rightAttr: Attribute): Boolean = {
+    val leftPos = outputOrdinal(leftPlan, leftAttr)
+    val rightPos = outputOrdinal(rightPlan, rightAttr)
+    leftPos >= 0 && rightPos >= 0 && leftPos == rightPos
+  }
+
+  /**
+   * Parse a join condition into equi-pairs and inequality-pairs. Accepts only `EqualTo(attr, attr)`
+   * and `Not(EqualTo(attr, attr))` across opposite sides, and `IsNotNull(attr)` on a join column;
+   * anything else fails the whole rewrite closed.
+   */
+  private def parseSelfJoinCondition(
+      condition: Expression,
+      leftPlan: LogicalPlan,
+      rightPlan: LogicalPlan)
+      : Option[(Seq[(Attribute, Attribute)], Seq[(Attribute, Attribute)])] = {
+
+    val leftOutput = leftPlan.outputSet
+    val rightOutput = rightPlan.outputSet
+    val predicates = splitConjunctivePredicates(condition)
+
+    val equiPairs = predicates.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    val neqPairs = predicates.collect {
+      case Not(EqualTo(l: Attribute, r: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case Not(EqualTo(r: Attribute, l: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    // Only IsNotNull on a join column is safe to drop -- redundant with the join or auto-added by
+    // InferFiltersFromConstraints. IsNotNull on any other column changes semantics; bail out.
+    val joinAttrIds: Set[ExprId] =
+      (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    val isNotNullOnJoinCols = predicates.count {
+      case IsNotNull(a: Attribute) if joinAttrIds.contains(a.exprId) => true
+      case _ => false
+    }
+
+    val totalMatched = equiPairs.size + neqPairs.size + isNotNullOnJoinCols
+    if (totalMatched != predicates.size) return None
+    if (equiPairs.isEmpty || neqPairs.isEmpty) return None
+
+    // A single inequality only: COUNT(DISTINCT) over one column cannot represent multiple neqs.
+    if (neqPairs.size != 1) return None
+
+    // Resolve each predicate end by ExprId and require matching output ordinals, not name equality
+    // (canonicalization erases cosmetic Alias names).
+    val equiValid =
+      equiPairs.forall { case (l, r) => sameOutputPosition(leftPlan, rightPlan, l, r) }
+    val neqValid = neqPairs.forall { case (l, r) => sameOutputPosition(leftPlan, rightPlan, l, r) }
+    if (!equiValid || !neqValid) return None
+
+    // Equi-key output positions must be distinct, so swapped/duplicate aliases cannot collide.
+    val leftEquiOrdinals = equiPairs.map { case (l, _) => outputOrdinal(leftPlan, l) }
+    if (leftEquiOrdinals.exists(_ < 0)) return None
+    if (leftEquiOrdinals.distinct.size != leftEquiOrdinals.size) return None
+
+    // Reject when the neq column overlaps an equi-key column (e.g. `t1.k = t2.k AND t1.k <> t2.k`).
+    val neqLeftOrdinal = outputOrdinal(leftPlan, neqPairs.head._1)
+    if (neqLeftOrdinal < 0 || leftEquiOrdinals.contains(neqLeftOrdinal)) return None
+
+    // Datatype safety, checked last: the rewrite swaps comparison equality (`=`/`<>`) for
+    // grouping/distinct equality, so every column moved into the aggregate must be a type where the
+    // two coincide.
+    val comparisonAttrs = (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l, r) }
+    if (!comparisonAttrs.forall(a => isSafeComparisonGroupingType(a.dataType))) return None
+
+    Some((equiPairs, neqPairs))
+  }
+
+  /**
+   * Positive allowlist of types where comparison equality (`=`/`<>`) and grouping/distinct equality
+   * provably coincide, so a key can move from a join predicate into GROUP BY / COUNT(DISTINCT).
+   * Float/Double (NaN, signed zero) and String/CHAR/VARCHAR (collation-dependent) are excluded;
+   * complex types, UDTs and unknown types fail closed.
+   */
+  private def isSafeComparisonGroupingType(dataType: DataType): Boolean = dataType match {
+    case ByteType | ShortType | IntegerType | LongType => true
+    case _: DecimalType => true
+    case BooleanType => true
+    case DateType => true
+    case TimestampType => true
+    case BinaryType => true
+    case _ => false
+  }
+
+  /**
+   * Primary safety guard: the rewrite folds two occurrences of one subtree into a single aggregate,
+   * so a plan qualifies only when its operators, leaves and expressions are all allowlisted as
+   * repeatable. `plan.deterministic` alone is insufficient -- Aggregate(First), Window row_number
+   * over a non-total order and Limit/Sample are row-bag nondeterministic yet report deterministic.
+   * Embedded expression subqueries also fail closed.
+   */
+  private def isRepeatablePlan(plan: LogicalPlan): Boolean = {
+    plan.deterministic &&
+    !plan.isStreaming &&
+    plan.subqueriesAll.isEmpty &&
+    isRowBagRepeatable(plan) &&
+    hasRepeatableExpressions(plan)
+  }
+
+  /** Operator/leaf allowlist for repeatable row bags; everything unknown fails closed. */
+  private def isRowBagRepeatable(plan: LogicalPlan): Boolean = !plan.exists {
+    case _: Project => false
+    case _: Filter => false
+    case _: SubqueryAlias => false
+    case _: Join => false
+    case _: Range => false
+    case _: LocalRelation => false
+    case relation: LogicalRelation =>
+      // Trust a Parquet scan only: exact `ParquetFileFormat` (getClass, not isInstanceOf, since it
+      // is non-final); any other FileFormat is not provably repeatable.
+      relation.relation match {
+        case h: HadoopFsRelation if h.fileFormat.getClass == classOf[ParquetFileFormat] => false
+        case _ => true
+      }
+    case _ => true

Review Comment:
   The `!plan.exists { ... }` with inverted boolean meanings (returning `false` for allowlisted nodes and `true` for disallowed) is easy to misread and easy to break when extending the allowlist. Refactor to a clearer predicate (e.g., a `plan.exists(isDisallowedNode)` helper, or a direct `plan.forall(isAllowedNode)` style) so future edits don’t accidentally flip semantics.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3931175919


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Any other NamedExpression shape we
+   * do not rewrite is left as-is ONLY if it does not reference a replaced self-join output;
+   * otherwise it would carry a stale ExprId, so returns None to fail the whole rewrite closed.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): Option[NamedExpression] = ne match {
+    case a: Attribute if remap.contains(a.exprId) => Some(remap(a.exprId))
+    case a: Attribute => Some(a)
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      Some(
+        if (newChild eq al.child) {
+          al
+        } else {
+          Alias(newChild, al.name)(
+            al.exprId,
+            al.qualifier,
+            al.explicitMetadata,
+            al.nonInheritableMetadataKeys)
+        })
+    case other if other.references.exists(a => remap.contains(a.exprId)) =>
+      // Fail-closed: a NamedExpression we do not rewrite (neither a bare Attribute nor an Alias)
+      // that still references a replaced self-join output would be left with a dangling ExprId.
+      // Refuse the rewrite rather than emit a plan with a stale reference.
+      None
+    case other => Some(other)
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    // Fail closed on an explicit join hint. This rule deletes the self-join outright; a user
+    // `/*+ BROADCAST(...) */` (or SHUFFLE_*/MERGE) is an explicit optimizer directive about THAT
+    // join, and an opt-in rewrite has no business silently discarding it. Not a correctness bug
+    // (hints do not change results), but the conservative maintainer choice. `JoinHint.NONE` exists
+    // across all Spark versions Gluten supports, so no shim is needed.
+    if (innerJoin.hint != JoinHint.NONE) return None
+
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map {
+          case (newWrapper, _) =>
+            logDebug(
+              s"Pattern A' - equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}]" +
+                s", neqCol=${innerLeftNeqAttr.name}" +
+                s", outCols=[${newWrapper.projectList.map(_.name).mkString(",")}]")
+            newWrapper
+        }
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A2 : self-join nested inside another InnerJoin in the subquery
+  // ============================================================================
+
+  private def rewriteNestedSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+    val outerCond = outerJoin.condition.get
+
+    val (selfJoinSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerJoin.right) match {
+        case Some(_) => (outerJoin.right, true)
+        case None =>
+          tryExtractSelfJoin(outerJoin.left) match {
+            case Some(_) => (outerJoin.left, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(p), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val sjLeftNeqAttr: Attribute = neqPairs.head._1
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val sjEquiExprIds: Set[ExprId] =
+      equiPairs.flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    // wrapper Project may reproject equi-keys under fresh alias exprIds; include those.
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt.toSeq.flatMap {
+      p =>
+        p.projectList.flatMap {
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) => Some(a.exprId)
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) => Some(al.exprId)
+          case _ => None
+        }
+    }.toSet
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+
+    // Outer join condition may reference only equi-key attrs from the self-join side.
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+
+    // Top-level subquery Project may reference only equi-key attrs from the self-join side.
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val refs = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        refs.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val filtered = buildAggregateHavingDistinctGt1(sjLeftEquiAttrs, sjLeftNeqAttr, sjLeft)
+
+    val (newSelfJoinSide, outputRemap): (LogicalPlan, Map[ExprId, Attribute]) =
+      selfJoinProjectOpt match {
+        case Some(wp) =>
+          canonicalizeWrapper(wp.projectList, equiPairs, filtered) match {
+            case Some((newWrapper, remap)) => (newWrapper, remap)
+            case None => return None
+          }
+        case None if projectListOpt.isEmpty =>
+          // Fail-closed: with neither a wrapper Project around the self-join nor a top-level
+          // subquery Project, the outer join currently exposes every self-join column, and
+          // replacing the self-join with `Project(equiKeys, filtered)` would shrink the outer
+          // join's right-hand output arity. RewritePredicateSubquery's positional zip
+          // (`values.zip(sub.output).map(EqualTo.tupled)`) would then bind semi predicates to
+          // the wrong attributes -- silently dropping components of a tuple IN. A
+          // top-level Project (`projectListOpt`) is what would let the arity be preserved
+          // by the top-level rewrite loop; without one, refuse to rewrite.
+          return None
+        case None =>
+          // No wrapper Project but there IS a top-level subquery Project: shrinking the outer
+          // join's self-join-side output is safe because the top-level Project is rewritten
+          // consistently via `outputRemap` below and the top-level rewrite loop ensures
+          // subquery output arity matches what the enclosing InSubquery expects.
+          // Outer references may point at sjRight equi-attributes; remap them to sjLeft
+          // (same output position in a valid self-join).
+          val newP = Project(sjLeftEquiAttrs, filtered)
+          val remap: Map[ExprId, Attribute] =
+            equiPairs.map { case (l, r) => r.exprId -> l }.toMap
+          (newP, remap)
+      }
+
+    // Rewrite outer join condition to use new wrapper output attributes.
+    val newOuterCond = outerCond.transformUp {
+      case a: Attribute if outputRemap.contains(a.exprId) => outputRemap(a.exprId)
+    }
+
+    val newOuterJoin = if (selfJoinOnRight) {
+      outerJoin.copy(right = newSelfJoinSide, condition = Some(newOuterCond))
+    } else {
+      outerJoin.copy(left = newSelfJoinSide, condition = Some(newOuterCond))
+    }
+
+    // Rewrite top-level Project references.
+    val result = projectListOpt match {
+      case Some(pl) =>
+        val remapped = pl.map(ne => remapNamedExpressionAttributes(ne, outputRemap))
+        if (remapped.exists(_.isEmpty)) return None
+        Project(remapped.flatten, newOuterJoin)
+      case None => newOuterJoin
+    }
+
+    logDebug(
+      s"Pattern A2 - equiKeys=[${sjLeftEquiAttrs.map(_.name).mkString(",")}]" +
+        s", neqCol=${sjLeftNeqAttr.name}")
+    Some(result)
+  }
+
+  private def tryExtractSelfJoin(plan: LogicalPlan): Option[Join] = {
+    val join = plan match {
+      case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => j
+      case j: Join if j.joinType == Inner && j.condition.isDefined => j
+      case _ => return None
+    }
+    // A hinted self-join is not an extraction candidate: fail closed so the same node the rewrite
+    // would delete is never even recognized here. See `rewriteDirectSelfJoin` for the rationale.
+    if (join.hint != JoinHint.NONE) return None
+    if (!isSameBaseRelation(join.left, join.right)) return None
+    val parsed = parseSelfJoinCondition(join.condition.get, join.left, join.right)
+    if (parsed.isEmpty) return None
+    Some(join)
+  }
+
+  // ============================================================================
+  //  parseSelfJoinCondition + isSameBaseRelation
+  // ============================================================================
+
+  private def outputOrdinal(plan: LogicalPlan, attr: Attribute): Int =
+    plan.output.indexWhere(_.exprId == attr.exprId)
+
+  private def sameOutputPosition(
+      leftPlan: LogicalPlan,
+      rightPlan: LogicalPlan,
+      leftAttr: Attribute,
+      rightAttr: Attribute): Boolean = {
+    val leftPos = outputOrdinal(leftPlan, leftAttr)
+    val rightPos = outputOrdinal(rightPlan, rightAttr)
+    leftPos >= 0 && rightPos >= 0 && leftPos == rightPos
+  }
+
+  /**
+   * Parse a join condition into equi-pairs and inequality-pairs. Accepts only:
+   *   - `EqualTo(attr, attr)` where the two attrs come from opposite sides,
+   *   - `Not(EqualTo(attr, attr))` -- same side rule,
+   *   - `IsNotNull(attr)` where the attr is one of the join columns.
+   * Anything else in the condition disqualifies the whole rewrite (fail-closed).
+   */
+  private def parseSelfJoinCondition(
+      condition: Expression,
+      leftPlan: LogicalPlan,
+      rightPlan: LogicalPlan)
+      : Option[(Seq[(Attribute, Attribute)], Seq[(Attribute, Attribute)])] = {
+
+    val leftOutput = leftPlan.outputSet
+    val rightOutput = rightPlan.outputSet
+    val predicates = splitConjunctivePredicates(condition)
+
+    val equiPairs = predicates.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    val neqPairs = predicates.collect {
+      case Not(EqualTo(l: Attribute, r: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case Not(EqualTo(r: Attribute, l: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    // Only IsNotNull predicates on join columns are safe to drop -- they're redundant with
+    // the join semantics or auto-added by InferFiltersFromConstraints. IsNotNull on other
+    // columns changes semantics if we drop it; bail out.
+    val joinAttrIds: Set[ExprId] =
+      (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    val isNotNullOnJoinCols = predicates.count {
+      case IsNotNull(a: Attribute) if joinAttrIds.contains(a.exprId) => true
+      case _ => false
+    }
+
+    val totalMatched = equiPairs.size + neqPairs.size + isNotNullOnJoinCols
+    if (totalMatched != predicates.size) return None
+
+    if (equiPairs.isEmpty || neqPairs.isEmpty) return None
+
+    // Only rewrite the single-inequality case. Multiple inequality conjuncts cannot be represented
+    // by COUNT(DISTINCT) over a single column.
+    if (neqPairs.size != 1) return None
+
+    // Canonicalization intentionally erases cosmetic Alias names, so name equality cannot prove
+    // that the two predicate ends refer to the same underlying column. Resolve each end by its own
+    // ExprId against its child output and require matching output ordinals instead.
+    val equiValid = equiPairs.forall {
+      case (l, r) => sameOutputPosition(leftPlan, rightPlan, l, r)
+    }
+    val neqValid = neqPairs.forall {
+      case (l, r) => sameOutputPosition(leftPlan, rightPlan, l, r)
+    }
+    if (!equiValid || !neqValid) return None
+
+    // Equi-key output positions must be distinct across pairs. Keep the same positional identity
+    // here so swapped or duplicate aliases cannot make two different underlying columns look equal.
+    val leftEquiOrdinals = equiPairs.map { case (l, _) => outputOrdinal(leftPlan, l) }
+    if (leftEquiOrdinals.exists(_ < 0)) return None
+    if (leftEquiOrdinals.distinct.size != leftEquiOrdinals.size) return None
+
+    // Defensive: reject when the neq column overlaps an equi-key column
+    // (e.g. `t1.k = t2.k AND t1.k <> t2.k`).
+    val neqLeftOrdinal = outputOrdinal(leftPlan, neqPairs.head._1)
+    if (neqLeftOrdinal < 0 || leftEquiOrdinals.contains(neqLeftOrdinal)) return None
+
+    // Datatype safety is checked LAST, only after correspondence is proven: these really are the
+    // paired equi-keys and neq column. The rewrite replaces the join predicates `=` / `<>` with
+    // GROUP BY / COUNT(DISTINCT), i.e. it swaps comparison equality for grouping/distinct equality,
+    // so every column moved into the aggregate -- both ends of every pair, not just the sjLeft side
+    // -- must be a type where the two equalities provably coincide (see
+    // [[isSafeComparisonGroupingType]]). Checking both ends (rather than trusting
+    // `left.canonicalized == right.canonicalized` to imply matching type/metadata) keeps this
+    // robust if a future metadata-aware type gate is added.
+    val comparisonAttrs = (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l, r) }
+    if (!comparisonAttrs.forall(a => isSafeComparisonGroupingType(a.dataType))) return None
+
+    Some((equiPairs, neqPairs))
+  }
+
+  /**
+   * True iff comparison equality (`=` / `<>`) and grouping/distinct equality provably coincide for
+   * `dataType`, so the key can be safely moved from a join predicate into GROUP BY /
+   * COUNT(DISTINCT).
+   *
+   * This is a POSITIVE allowlist, not "orderable minus a blacklist". The property we must prove is
+   * not that a type can be ordered, but that its `=` / `<>` semantics and its grouping/distinct
+   * semantics are identical. `RowOrdering.isOrderable` answers the former, not the latter, so it is
+   * not a sufficient proof here. Rather than depend on non-trivial, version-dependent equality
+   * contracts, we allow only the small set of types whose two equalities coincide unconditionally
+   * and which the target workload (TPC-DS Q95) actually needs:
+   *   - Float/Double: their equality around NaN and signed zero relies on normalization semantics
+   *     (`NormalizeFloatingNumbers` and friends) that we do not want this rule -- which spans
+   *     multiple Spark versions and native execution paths -- to depend on.
+   *   - String / CHAR / VARCHAR: non-binary collation semantics make the equivalence non-trivial
+   *     and version-dependent, and CHAR/VARCHAR may already appear as StringType-plus-metadata by
+   *     the optimizer, so `dataType` alone cannot even see the declared type. String is therefore
+   *     outside the initial allowlist.
+   * Everything else -- complex types (Array/Map/Struct), UDTs, and any future/unknown type -- fails
+   * closed for the same reason: we would rather miss the rewrite than depend on a contract we have
+   * not proven holds across every supported backend.
+   */
+  private def isSafeComparisonGroupingType(dataType: DataType): Boolean = dataType match {
+    case ByteType | ShortType | IntegerType | LongType => true
+    case _: DecimalType => true
+    case BooleanType => true
+    case DateType => true
+    case TimestampType => true
+    case BinaryType => true
+    case _ => false
+  }
+
+  /**
+   * True iff `plan` produces the same row bag on every evaluation.
+   *
+   * This is the primary safety guard for the rewrite, which folds two occurrences of the same
+   * subtree into one aggregate -- sound only when both occurrences produce identical row bags. We
+   * check it at TWO levels:
+   *   - candidate level: the enclosing subquery, before descending into the self-join. Catches
+   *     nondeterminism that has been hoisted OUT of the join by an earlier optimizer rule -- e.g. a
+   *     `Filter(rand(...))` moved to sit above the join rather than on each side. Without this,
+   *     `isSameBaseRelation(innerLeft, innerRight)` could pass (both sides look deterministic)
+   *     while the enclosing plan still contains `Rand`.
+   *   - relation level: [[isSameBaseRelation]] additionally requires the two sides to be
+   *     structurally identical.
+   *
+   * Attribute-level `plan.deterministic` alone is NOT sufficient. Catalyst's
+   * `Expression.deterministic` only checks explicit `Nondeterministic` annotation; several
+   * operators produce a runtime-nondeterministic row bag even though every expression they contain
+   * is `deterministic == true`:
+   *   - `Aggregate` with `First` / `Last` / `collect_list` / `min_by` / `max_by` (tie order),
+   *   - `Window` with `row_number()` / `rank()` over a non-total order,
+   *   - `Limit` / `LocalLimit` / `Sample` / `Offset` (row-bag operator-level nondeterminism),
+   *   - streaming sources.
+   *
+   * This rule collapses two evaluations of the same subtree into one aggregate; repeatability must
+   * be provable, not assumed. That is why both the operator check and the expression check below
+   * are WHITELISTS rather than blacklists -- unknown operators and unknown expression types default
+   * to reject.
+   *
+   * Expression support is allowlisted, not blacklisted. `plan.deterministic` relies on each
+   * expression's reported `deterministic` contract; that is necessary but insufficient for unknown
+   * expression types whose repeatability has not been established -- a builtin that carries hidden
+   * state yet reports `deterministic == true` would otherwise be trusted silently. For a rewrite
+   * that folds two evaluations of a subtree into one aggregate we prefer to miss an optimization
+   * than to misapply one, so new expression types are added to [[isRepeatableExpression]] only
+   * after their repeatability has been established. `plan.deterministic` is kept as a cheap
+   * fast-reject, but the expression allowlist is what actually proves repeatability.
+   *
+   * `plan.subqueriesAll.isEmpty` additionally fail-closes on any embedded expression subquery
+   * (scalar / IN / EXISTS). `plan.exists` in `isRowBagRepeatable` walks only the operator tree and
+   * does not descend into expression subqueries, and `plan.deterministic` does not prove a nested
+   * subquery is row-bag repeatable (e.g. an uncorrelated `LIMIT 1` without `ORDER BY`). Rejecting
+   * any embedded subquery keeps the repeatability proof confined to the operator whitelist below.
+   */
+  private def isRepeatablePlan(plan: LogicalPlan): Boolean = {
+    // The operator/source whitelist is checked before the expression whitelist so that a plan whose
+    // operator is itself unknown -- e.g. Aggregate (carries AggregateExpression) or Window (carries
+    // WindowExpression / SortOrder) -- is attributed to isRowBagRepeatable rather than being masked
+    // by the fact that those operators also carry non-allowlisted expressions.
+    plan.deterministic &&
+    !plan.isStreaming &&
+    plan.subqueriesAll.isEmpty &&
+    isRowBagRepeatable(plan) &&
+    hasRepeatableExpressions(plan)
+  }
+
+  /**
+   * Operator whitelist for `isRepeatablePlan`. A plan is row-bag repeatable only when every node is
+   * known to produce a repeatable output row bag from repeatable children. Unknown operators and
+   * unknown leaf sources fail closed.
+   *
+   * Kept intentionally narrow -- the target workload (Q95-shape self-join in a subquery) only needs
+   * a Parquet relation scan optionally wrapped in Project / Filter / SubqueryAlias plus the
+   * self-join itself. Range and LocalRelation are also trusted deterministic leaves. Adding an
+   * operator here requires proving:
+   *   - it does not reorder its input non-deterministically,
+   *   - it does not depend on shuffle-merge or tie-broken orderings,
+   *   - it produces the same output row bag on every evaluation.
+   *
+   * Arbitrary `LeafNode`s are intentionally not trusted. For example, `LogicalRDD` may wrap an
+   * arbitrary RDD lineage whose runtime behavior is invisible to Catalyst's `plan.deterministic`;
+   * `InMemoryRelation`, `DataSourceV2Relation` and custom leaves are likewise rejected until
+   * proven. Streaming sources reach here as `LeafNode`s but are already filtered upstream by
+   * `plan.isStreaming` in [[isRepeatablePlan]].
+   *
+   * `LogicalRelation` is trusted only when its underlying relation is a `HadoopFsRelation` whose
+   * `fileFormat` is EXACTLY `ParquetFileFormat` (`getClass == classOf[ParquetFileFormat]`, not
+   * `isInstanceOf`). `HadoopFsRelation.fileFormat` can be any `FileFormat`, including custom
+   * formats whose scan is not provably a repeatable row bag; `ParquetFileFormat` is also non-final,
+   * so a third-party subclass could override its scan. The target workload only needs stock
+   * Parquet, so every other `FileFormat` -- subclasses of `ParquetFileFormat` included -- and every
+   * non-`HadoopFsRelation` fail closed.
+   *
+   * This helper checks operators and leaf sources only; expression-type repeatability is a separate
+   * concern handled by [[hasRepeatableExpressions]], and both are joined in [[isRepeatablePlan]].
+   */
+  private def isRowBagRepeatable(plan: LogicalPlan): Boolean = !plan.exists {
+    // TreeNode exposes `exists` but not `forall`, so invert: a whitelisted operator maps to `false`
+    // ("does not break repeatability") and everything else to `true`; negating the whole `exists`
+    // then means "every operator is whitelisted".
+    case _: Project => false
+    case _: Filter => false
+    case _: SubqueryAlias => false
+    // Join is included because our target pattern IS a Join; both children get recursed into.
+    case _: Join => false
+    // Explicitly trusted leaves.
+    case _: Range => false
+    case _: LocalRelation => false
+    case relation: LogicalRelation =>
+      relation.relation match {
+        case h: HadoopFsRelation if h.fileFormat.getClass == classOf[ParquetFileFormat] => false

Review Comment:
   Yes, this is deliberate for this PR. The rule is currently validated only with the Velox backend, so I prefer to keep the Gluten-side integration scoped to Velox rather than expose it to other backends without validation. The generic Catalyst version is already proposed upstream in apache/spark#58424.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3871264179


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,674 @@
+/*
+ * 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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.LongType
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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)

Review Comment:
   When rebuilding wrapper projections, the new `Alias(...)()` drops qualifier and explicit metadata from the original `Attribute`/`Alias` outputs. Even if semantics are unchanged, losing column metadata can affect downstream consumers (e.g., metadata-driven features, column comments, or analyzer/debugging output). Consider preserving `qualifier` and `explicitMetadata` from the original `Alias`, and preserving attribute metadata where applicable.



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,674 @@
+/*
+ * 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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.LongType
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Anything else in a projectList --
+   * e.g. computed expressions we don't own -- is passed through unchanged.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): NamedExpression = ne match {
+    case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+    case a: Attribute => a
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      if (newChild eq al.child) al
+      else Alias(newChild, al.name)(al.exprId, al.qualifier, al.explicitMetadata)
+    case other => other
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map {
+          case (newWrapper, _) =>
+            logDebug(
+              s"Pattern A' - equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}]" +
+                s", neqCol=${innerLeftNeqAttr.name}" +
+                s", outCols=[${newWrapper.projectList.map(_.name).mkString(",")}]")
+            newWrapper
+        }
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A2 : self-join nested inside another InnerJoin in the subquery
+  // ============================================================================
+
+  private def rewriteNestedSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+    val outerCond = outerJoin.condition.get
+
+    val (selfJoinSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerJoin.right) match {
+        case Some(_) => (outerJoin.right, true)
+        case None =>
+          tryExtractSelfJoin(outerJoin.left) match {
+            case Some(_) => (outerJoin.left, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(p), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None

Review Comment:
   `tryExtractSelfJoin` does non-trivial work (base-relation check + condition parsing), but its result is discarded here and the same subtree is later re-matched and re-parsed again. Consider changing `tryExtractSelfJoin` to return the extracted wrapper/join (and ideally the parsed `(equiPairs, neqPairs)`) and thread that through, so the nested-path only canonicalizes/parses once.



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,674 @@
+/*
+ * 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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.LongType
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Anything else in a projectList --
+   * e.g. computed expressions we don't own -- is passed through unchanged.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): NamedExpression = ne match {
+    case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+    case a: Attribute => a
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      if (newChild eq al.child) al
+      else Alias(newChild, al.name)(al.exprId, al.qualifier, al.explicitMetadata)
+    case other => other
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map {
+          case (newWrapper, _) =>
+            logDebug(
+              s"Pattern A' - equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}]" +
+                s", neqCol=${innerLeftNeqAttr.name}" +
+                s", outCols=[${newWrapper.projectList.map(_.name).mkString(",")}]")
+            newWrapper

Review Comment:
   Using `isEmpty` + `get` is safe here but makes control flow more brittle and verbose. Consider pattern matching on the `Option` (or using `fold`/`map`) to keep the success path scoped without `get`.



##########
docs/velox-configuration.md:
##########
@@ -92,6 +92,7 @@ nav_order: 16
 | spark.gluten.sql.columnar.backend.velox.valueStream.dynamicFilter.enabled        | 🔄 Dynamic    | false             | Whether to apply dynamic filters pushed down from hash probe in the ValueStream (shuffle reader) operator to filter rows before they reach the hash join.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 
                 |
 | spark.gluten.sql.enable.enhancedFeatures                                         | 🔄 Dynamic    | true              | Enable some features including iceberg native write and other features.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   
                 |
 | spark.gluten.sql.rewrite.castArrayToString                                       | 🔄 Dynamic    | true              | When true, rewrite `cast(array as String)` to `concat('[', array_join(array, ', ', null), ']')` to allow offloading to Velox.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             
                 |
+| spark.gluten.sql.rewrite.selfJoinInequality                                      | 🔄 Dynamic    | false             | When true, rewrite supported uncorrelated InSubquery self-joins with inequality predicate into GROUP BY + HAVING COUNT(DISTINCT) > 1. Currently targets Parquet-backed direct and nested self-join shapes exercised by TPC-DS Q95. Opt-in default (false) until the rewrite has been exercised more broadly across workloads.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             
                 |

Review Comment:
   This is user-facing documentation, but it uses the Catalyst internal name `InSubquery`. Consider rephrasing to SQL terminology (e.g., “uncorrelated `IN (subquery)` self-joins”) and formatting SQL fragments with backticks for readability/consistency in the docs table.



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,674 @@
+/*
+ * 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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.LongType
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Anything else in a projectList --
+   * e.g. computed expressions we don't own -- is passed through unchanged.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): NamedExpression = ne match {
+    case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+    case a: Attribute => a
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      if (newChild eq al.child) al
+      else Alias(newChild, al.name)(al.exprId, al.qualifier, al.explicitMetadata)
+    case other => other
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map {
+          case (newWrapper, _) =>
+            logDebug(
+              s"Pattern A' - equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}]" +
+                s", neqCol=${innerLeftNeqAttr.name}" +
+                s", outCols=[${newWrapper.projectList.map(_.name).mkString(",")}]")
+            newWrapper
+        }
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A2 : self-join nested inside another InnerJoin in the subquery
+  // ============================================================================
+
+  private def rewriteNestedSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+    val outerCond = outerJoin.condition.get
+
+    val (selfJoinSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerJoin.right) match {
+        case Some(_) => (outerJoin.right, true)
+        case None =>
+          tryExtractSelfJoin(outerJoin.left) match {
+            case Some(_) => (outerJoin.left, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(p), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val sjLeftNeqAttr: Attribute = neqPairs.head._1
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val sjEquiExprIds: Set[ExprId] =
+      equiPairs.flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    // wrapper Project may reproject equi-keys under fresh alias exprIds; include those.
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt.toSeq.flatMap {
+      p =>
+        p.projectList.flatMap {
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) => Some(a.exprId)
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) => Some(al.exprId)
+          case _ => None
+        }
+    }.toSet
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+
+    // Outer join condition may reference only equi-key attrs from the self-join side.
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+
+    // Top-level subquery Project may reference only equi-key attrs from the self-join side.
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val refs = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        refs.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val filtered = buildAggregateHavingDistinctGt1(sjLeftEquiAttrs, sjLeftNeqAttr, sjLeft)
+
+    val (newSelfJoinSide, outputRemap): (LogicalPlan, Map[ExprId, Attribute]) =
+      selfJoinProjectOpt match {
+        case Some(wp) =>
+          canonicalizeWrapper(wp.projectList, equiPairs, filtered) match {
+            case Some((newWrapper, remap)) => (newWrapper, remap)
+            case None => return None
+          }
+        case None if projectListOpt.isEmpty =>
+          // Fail-closed: with neither a wrapper Project around the self-join nor a top-level
+          // subquery Project, the outer join currently exposes every self-join column, and
+          // replacing the self-join with `Project(equiKeys, filtered)` would shrink the outer
+          // join's right-hand output arity. RewritePredicateSubquery's positional zip
+          // (`values.zip(sub.output).map(EqualTo.tupled)`) would then bind semi predicates to
+          // the wrong attributes -- silently dropping components of a tuple IN. A
+          // top-level Project (`projectListOpt`) is what would let the arity be preserved
+          // by the top-level rewrite loop; without one, refuse to rewrite.
+          return None
+        case None =>
+          // No wrapper Project but there IS a top-level subquery Project: shrinking the outer
+          // join's self-join-side output is safe because the top-level Project is rewritten
+          // consistently via `outputRemap` below and the top-level rewrite loop ensures
+          // subquery output arity matches what the enclosing InSubquery expects.
+          // Outer references may point at sjRight equi-attributes; remap them to sjLeft
+          // (same output position in a valid self-join).
+          val newP = Project(sjLeftEquiAttrs, filtered)
+          val remap: Map[ExprId, Attribute] =
+            equiPairs.map { case (l, r) => r.exprId -> l }.toMap
+          (newP, remap)
+      }
+
+    // Rewrite outer join condition to use new wrapper output attributes.
+    val newOuterCond = outerCond.transformUp {
+      case a: Attribute if outputRemap.contains(a.exprId) => outputRemap(a.exprId)
+    }
+
+    val newOuterJoin = if (selfJoinOnRight) {
+      outerJoin.copy(right = newSelfJoinSide, condition = Some(newOuterCond))
+    } else {
+      outerJoin.copy(left = newSelfJoinSide, condition = Some(newOuterCond))
+    }
+
+    // Rewrite top-level Project references.
+    val result = projectListOpt match {
+      case Some(pl) =>
+        val newPl = pl.map(ne => remapNamedExpressionAttributes(ne, outputRemap))
+        Project(newPl, newOuterJoin)
+      case None => newOuterJoin
+    }
+
+    logDebug(
+      s"Pattern A2 - equiKeys=[${sjLeftEquiAttrs.map(_.name).mkString(",")}]" +
+        s", neqCol=${sjLeftNeqAttr.name}")
+    Some(result)
+  }
+
+  private def tryExtractSelfJoin(plan: LogicalPlan): Option[Join] = {
+    val join = plan match {
+      case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => j
+      case j: Join if j.joinType == Inner && j.condition.isDefined => j
+      case _ => return None
+    }
+    if (!isSameBaseRelation(join.left, join.right)) return None
+    val parsed = parseSelfJoinCondition(join.condition.get, join.left, join.right)
+    if (parsed.isEmpty) return None
+    Some(join)
+  }
+
+  // ============================================================================
+  //  parseSelfJoinCondition + isSameBaseRelation
+  // ============================================================================
+
+  private def outputOrdinal(plan: LogicalPlan, attr: Attribute): Int =
+    plan.output.indexWhere(_.exprId == attr.exprId)
+
+  private def sameOutputPosition(
+      leftPlan: LogicalPlan,
+      rightPlan: LogicalPlan,
+      leftAttr: Attribute,
+      rightAttr: Attribute): Boolean = {
+    val leftPos = outputOrdinal(leftPlan, leftAttr)
+    val rightPos = outputOrdinal(rightPlan, rightAttr)
+    leftPos >= 0 && rightPos >= 0 && leftPos == rightPos
+  }

Review Comment:
   `outputOrdinal` is linear in `plan.output` and is called repeatedly during condition parsing/validation. Precomputing `Map[ExprId, Int]` for `leftPlan.output` and `rightPlan.output` (once per candidate) would avoid repeated scans and keep the check O(1) per predicate.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "jackylee-ch (via GitHub)" <gi...@apache.org>.
jackylee-ch commented on PR #12756:
URL: https://github.com/apache/gluten/pull/12756#issuecomment-5438281786

   Hi, @hhr293 . After discussing with @zhouyuan and @LuciferYang , I think it’s fine to continue with this change in Gluten, especially to better support older Spark versions.
   Please consider validating it with upstream Spark CI first, since optimizer changes usually get more comprehensive coverage there. Even if it’s not eventually merged upstream, confirming that it works correctly in the general Spark context should be sufficient for us to move forward in Gluten.
   Thanks for your great work again.


-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "beliefer (via GitHub)" <gi...@apache.org>.
beliefer commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3871397200


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,674 @@
+/*
+ * 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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.LongType
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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)),

Review Comment:
   The COUNT(DISTINCT neqCol) > 1 rewrite diverges from the original s1.neqCol <> s2.neqCol predicate for special float/double values:
   
   - -0.0 vs +0.0: SQL -0.0 = 0.0 is true, so -0.0 <> 0.0 is false → the original self-join produces no row. But Spark's COUNT(DISTINCT) uses bit-level/Java Double.equals which keeps -0.0 != 0.0, so COUNT(DISTINCT {-0.0, 0.0}) = 2 → the rewrite fires and the row becomes IN when it should be OUT.
   - NaN: The reverse — two NaN rows satisfy NaN <> NaN in SQL (true), so the original matches; but Java NaN.equals(NaN) is true so COUNT(DISTINCT NaN) = 1 → the rewrite doesn't fire when it should.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "FelixYBW (via GitHub)" <gi...@apache.org>.
FelixYBW commented on PR #12756:
URL: https://github.com/apache/gluten/pull/12756#issuecomment-5447092871

   > I’ll prepare a corresponding Spark-side change and validate it with upstream Spark CI, then report the results back here.
   
   You may submit a PR to upstream Spark, and the CIs there will validate it. Make sure to refer to the upstream PR link here once you submit. 
   
   Since most Gluten customers have their own Spark Rule optimizations, we prefer to upstream them rather than include them in Gluten, except for rules that only benefit the native engine.


-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on PR #12756:
URL: https://github.com/apache/gluten/pull/12756#issuecomment-5277851977

   The CI job failed while downloading Maven from Maven Central with:
   HTTP 429 Too Many Requests so it could not download apache-maven-3.9.16-bin.tar.gz, which looks like an external dependency download / CI infrastructure issue and not related to this patch.


-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3931197990


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Any other NamedExpression shape we
+   * do not rewrite is left as-is ONLY if it does not reference a replaced self-join output;
+   * otherwise it would carry a stale ExprId, so returns None to fail the whole rewrite closed.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): Option[NamedExpression] = ne match {
+    case a: Attribute if remap.contains(a.exprId) => Some(remap(a.exprId))
+    case a: Attribute => Some(a)
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      Some(
+        if (newChild eq al.child) {
+          al
+        } else {
+          Alias(newChild, al.name)(
+            al.exprId,
+            al.qualifier,
+            al.explicitMetadata,
+            al.nonInheritableMetadataKeys)
+        })
+    case other if other.references.exists(a => remap.contains(a.exprId)) =>
+      // Fail-closed: a NamedExpression we do not rewrite (neither a bare Attribute nor an Alias)
+      // that still references a replaced self-join output would be left with a dangling ExprId.
+      // Refuse the rewrite rather than emit a plan with a stale reference.
+      None
+    case other => Some(other)
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    // Fail closed on an explicit join hint. This rule deletes the self-join outright; a user
+    // `/*+ BROADCAST(...) */` (or SHUFFLE_*/MERGE) is an explicit optimizer directive about THAT
+    // join, and an opt-in rewrite has no business silently discarding it. Not a correctness bug
+    // (hints do not change results), but the conservative maintainer choice. `JoinHint.NONE` exists
+    // across all Spark versions Gluten supports, so no shim is needed.
+    if (innerJoin.hint != JoinHint.NONE) return None
+
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {

Review Comment:
   Thanks, this distinction makes sense.
   
   I’ll add focused coverage for the two bare-Join arity guards, since those are the guards with the highest correctness consequence if the subquery output were accidentally shrunk before RewritePredicateSubquery performs positional binding.
   
   For the two A2 reference guards, I’d prefer not to exclude ColumnPruning just to manufacture a normally unreachable optimizer state. I’ll keep those fail-closed checks as defensive guards rather than adding tests that depend on a non-default optimizer configuration.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3931152137


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Any other NamedExpression shape we
+   * do not rewrite is left as-is ONLY if it does not reference a replaced self-join output;
+   * otherwise it would carry a stale ExprId, so returns None to fail the whole rewrite closed.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): Option[NamedExpression] = ne match {
+    case a: Attribute if remap.contains(a.exprId) => Some(remap(a.exprId))
+    case a: Attribute => Some(a)
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      Some(
+        if (newChild eq al.child) {
+          al
+        } else {
+          Alias(newChild, al.name)(
+            al.exprId,
+            al.qualifier,
+            al.explicitMetadata,
+            al.nonInheritableMetadataKeys)
+        })
+    case other if other.references.exists(a => remap.contains(a.exprId)) =>
+      // Fail-closed: a NamedExpression we do not rewrite (neither a bare Attribute nor an Alias)
+      // that still references a replaced self-join output would be left with a dangling ExprId.
+      // Refuse the rewrite rather than emit a plan with a stale reference.
+      None
+    case other => Some(other)
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    // Fail closed on an explicit join hint. This rule deletes the self-join outright; a user
+    // `/*+ BROADCAST(...) */` (or SHUFFLE_*/MERGE) is an explicit optimizer directive about THAT
+    // join, and an opt-in rewrite has no business silently discarding it. Not a correctness bug
+    // (hints do not change results), but the conservative maintainer choice. `JoinHint.NONE` exists
+    // across all Spark versions Gluten supports, so no shim is needed.
+    if (innerJoin.hint != JoinHint.NONE) return None
+
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map {
+          case (newWrapper, _) =>
+            logDebug(
+              s"Pattern A' - equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}]" +
+                s", neqCol=${innerLeftNeqAttr.name}" +
+                s", outCols=[${newWrapper.projectList.map(_.name).mkString(",")}]")
+            newWrapper
+        }
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A2 : self-join nested inside another InnerJoin in the subquery
+  // ============================================================================
+
+  private def rewriteNestedSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+    val outerCond = outerJoin.condition.get
+
+    val (selfJoinSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerJoin.right) match {
+        case Some(_) => (outerJoin.right, true)
+        case None =>
+          tryExtractSelfJoin(outerJoin.left) match {
+            case Some(_) => (outerJoin.left, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(p), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val sjLeftNeqAttr: Attribute = neqPairs.head._1
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val sjEquiExprIds: Set[ExprId] =
+      equiPairs.flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    // wrapper Project may reproject equi-keys under fresh alias exprIds; include those.
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt.toSeq.flatMap {
+      p =>
+        p.projectList.flatMap {
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) => Some(a.exprId)
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) => Some(al.exprId)
+          case _ => None
+        }
+    }.toSet
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+
+    // Outer join condition may reference only equi-key attrs from the self-join side.
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+
+    // Top-level subquery Project may reference only equi-key attrs from the self-join side.
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val refs = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        refs.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val filtered = buildAggregateHavingDistinctGt1(sjLeftEquiAttrs, sjLeftNeqAttr, sjLeft)
+
+    val (newSelfJoinSide, outputRemap): (LogicalPlan, Map[ExprId, Attribute]) =
+      selfJoinProjectOpt match {
+        case Some(wp) =>
+          canonicalizeWrapper(wp.projectList, equiPairs, filtered) match {
+            case Some((newWrapper, remap)) => (newWrapper, remap)
+            case None => return None
+          }
+        case None if projectListOpt.isEmpty =>
+          // Fail-closed: with neither a wrapper Project around the self-join nor a top-level
+          // subquery Project, the outer join currently exposes every self-join column, and
+          // replacing the self-join with `Project(equiKeys, filtered)` would shrink the outer
+          // join's right-hand output arity. RewritePredicateSubquery's positional zip
+          // (`values.zip(sub.output).map(EqualTo.tupled)`) would then bind semi predicates to
+          // the wrong attributes -- silently dropping components of a tuple IN. A
+          // top-level Project (`projectListOpt`) is what would let the arity be preserved
+          // by the top-level rewrite loop; without one, refuse to rewrite.
+          return None
+        case None =>
+          // No wrapper Project but there IS a top-level subquery Project: shrinking the outer
+          // join's self-join-side output is safe because the top-level Project is rewritten
+          // consistently via `outputRemap` below and the top-level rewrite loop ensures
+          // subquery output arity matches what the enclosing InSubquery expects.
+          // Outer references may point at sjRight equi-attributes; remap them to sjLeft
+          // (same output position in a valid self-join).
+          val newP = Project(sjLeftEquiAttrs, filtered)
+          val remap: Map[ExprId, Attribute] =
+            equiPairs.map { case (l, r) => r.exprId -> l }.toMap
+          (newP, remap)
+      }
+
+    // Rewrite outer join condition to use new wrapper output attributes.
+    val newOuterCond = outerCond.transformUp {
+      case a: Attribute if outputRemap.contains(a.exprId) => outputRemap(a.exprId)
+    }
+
+    val newOuterJoin = if (selfJoinOnRight) {
+      outerJoin.copy(right = newSelfJoinSide, condition = Some(newOuterCond))
+    } else {
+      outerJoin.copy(left = newSelfJoinSide, condition = Some(newOuterCond))
+    }
+
+    // Rewrite top-level Project references.
+    val result = projectListOpt match {
+      case Some(pl) =>
+        val remapped = pl.map(ne => remapNamedExpressionAttributes(ne, outputRemap))
+        if (remapped.exists(_.isEmpty)) return None
+        Project(remapped.flatten, newOuterJoin)
+      case None => newOuterJoin
+    }
+
+    logDebug(
+      s"Pattern A2 - equiKeys=[${sjLeftEquiAttrs.map(_.name).mkString(",")}]" +
+        s", neqCol=${sjLeftNeqAttr.name}")
+    Some(result)
+  }
+
+  private def tryExtractSelfJoin(plan: LogicalPlan): Option[Join] = {
+    val join = plan match {
+      case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => j
+      case j: Join if j.joinType == Inner && j.condition.isDefined => j
+      case _ => return None
+    }
+    // A hinted self-join is not an extraction candidate: fail closed so the same node the rewrite
+    // would delete is never even recognized here. See `rewriteDirectSelfJoin` for the rationale.
+    if (join.hint != JoinHint.NONE) return None
+    if (!isSameBaseRelation(join.left, join.right)) return None
+    val parsed = parseSelfJoinCondition(join.condition.get, join.left, join.right)
+    if (parsed.isEmpty) return None
+    Some(join)
+  }
+
+  // ============================================================================
+  //  parseSelfJoinCondition + isSameBaseRelation
+  // ============================================================================
+
+  private def outputOrdinal(plan: LogicalPlan, attr: Attribute): Int =
+    plan.output.indexWhere(_.exprId == attr.exprId)
+
+  private def sameOutputPosition(
+      leftPlan: LogicalPlan,
+      rightPlan: LogicalPlan,
+      leftAttr: Attribute,
+      rightAttr: Attribute): Boolean = {
+    val leftPos = outputOrdinal(leftPlan, leftAttr)
+    val rightPos = outputOrdinal(rightPlan, rightAttr)
+    leftPos >= 0 && rightPos >= 0 && leftPos == rightPos
+  }
+
+  /**
+   * Parse a join condition into equi-pairs and inequality-pairs. Accepts only:
+   *   - `EqualTo(attr, attr)` where the two attrs come from opposite sides,
+   *   - `Not(EqualTo(attr, attr))` -- same side rule,
+   *   - `IsNotNull(attr)` where the attr is one of the join columns.
+   * Anything else in the condition disqualifies the whole rewrite (fail-closed).
+   */
+  private def parseSelfJoinCondition(
+      condition: Expression,
+      leftPlan: LogicalPlan,
+      rightPlan: LogicalPlan)
+      : Option[(Seq[(Attribute, Attribute)], Seq[(Attribute, Attribute)])] = {
+
+    val leftOutput = leftPlan.outputSet
+    val rightOutput = rightPlan.outputSet
+    val predicates = splitConjunctivePredicates(condition)
+
+    val equiPairs = predicates.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    val neqPairs = predicates.collect {
+      case Not(EqualTo(l: Attribute, r: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case Not(EqualTo(r: Attribute, l: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    // Only IsNotNull predicates on join columns are safe to drop -- they're redundant with
+    // the join semantics or auto-added by InferFiltersFromConstraints. IsNotNull on other
+    // columns changes semantics if we drop it; bail out.
+    val joinAttrIds: Set[ExprId] =
+      (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    val isNotNullOnJoinCols = predicates.count {
+      case IsNotNull(a: Attribute) if joinAttrIds.contains(a.exprId) => true
+      case _ => false
+    }
+
+    val totalMatched = equiPairs.size + neqPairs.size + isNotNullOnJoinCols
+    if (totalMatched != predicates.size) return None
+
+    if (equiPairs.isEmpty || neqPairs.isEmpty) return None
+
+    // Only rewrite the single-inequality case. Multiple inequality conjuncts cannot be represented
+    // by COUNT(DISTINCT) over a single column.
+    if (neqPairs.size != 1) return None
+
+    // Canonicalization intentionally erases cosmetic Alias names, so name equality cannot prove
+    // that the two predicate ends refer to the same underlying column. Resolve each end by its own
+    // ExprId against its child output and require matching output ordinals instead.
+    val equiValid = equiPairs.forall {
+      case (l, r) => sameOutputPosition(leftPlan, rightPlan, l, r)
+    }
+    val neqValid = neqPairs.forall {
+      case (l, r) => sameOutputPosition(leftPlan, rightPlan, l, r)
+    }
+    if (!equiValid || !neqValid) return None
+
+    // Equi-key output positions must be distinct across pairs. Keep the same positional identity
+    // here so swapped or duplicate aliases cannot make two different underlying columns look equal.
+    val leftEquiOrdinals = equiPairs.map { case (l, _) => outputOrdinal(leftPlan, l) }
+    if (leftEquiOrdinals.exists(_ < 0)) return None
+    if (leftEquiOrdinals.distinct.size != leftEquiOrdinals.size) return None
+
+    // Defensive: reject when the neq column overlaps an equi-key column
+    // (e.g. `t1.k = t2.k AND t1.k <> t2.k`).
+    val neqLeftOrdinal = outputOrdinal(leftPlan, neqPairs.head._1)
+    if (neqLeftOrdinal < 0 || leftEquiOrdinals.contains(neqLeftOrdinal)) return None
+
+    // Datatype safety is checked LAST, only after correspondence is proven: these really are the
+    // paired equi-keys and neq column. The rewrite replaces the join predicates `=` / `<>` with
+    // GROUP BY / COUNT(DISTINCT), i.e. it swaps comparison equality for grouping/distinct equality,
+    // so every column moved into the aggregate -- both ends of every pair, not just the sjLeft side
+    // -- must be a type where the two equalities provably coincide (see
+    // [[isSafeComparisonGroupingType]]). Checking both ends (rather than trusting
+    // `left.canonicalized == right.canonicalized` to imply matching type/metadata) keeps this
+    // robust if a future metadata-aware type gate is added.
+    val comparisonAttrs = (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l, r) }
+    if (!comparisonAttrs.forall(a => isSafeComparisonGroupingType(a.dataType))) return None
+
+    Some((equiPairs, neqPairs))
+  }
+
+  /**
+   * True iff comparison equality (`=` / `<>`) and grouping/distinct equality provably coincide for
+   * `dataType`, so the key can be safely moved from a join predicate into GROUP BY /
+   * COUNT(DISTINCT).
+   *
+   * This is a POSITIVE allowlist, not "orderable minus a blacklist". The property we must prove is
+   * not that a type can be ordered, but that its `=` / `<>` semantics and its grouping/distinct
+   * semantics are identical. `RowOrdering.isOrderable` answers the former, not the latter, so it is
+   * not a sufficient proof here. Rather than depend on non-trivial, version-dependent equality
+   * contracts, we allow only the small set of types whose two equalities coincide unconditionally
+   * and which the target workload (TPC-DS Q95) actually needs:
+   *   - Float/Double: their equality around NaN and signed zero relies on normalization semantics
+   *     (`NormalizeFloatingNumbers` and friends) that we do not want this rule -- which spans
+   *     multiple Spark versions and native execution paths -- to depend on.
+   *   - String / CHAR / VARCHAR: non-binary collation semantics make the equivalence non-trivial
+   *     and version-dependent, and CHAR/VARCHAR may already appear as StringType-plus-metadata by
+   *     the optimizer, so `dataType` alone cannot even see the declared type. String is therefore
+   *     outside the initial allowlist.
+   * Everything else -- complex types (Array/Map/Struct), UDTs, and any future/unknown type -- fails
+   * closed for the same reason: we would rather miss the rewrite than depend on a contract we have
+   * not proven holds across every supported backend.
+   */
+  private def isSafeComparisonGroupingType(dataType: DataType): Boolean = dataType match {
+    case ByteType | ShortType | IntegerType | LongType => true
+    case _: DecimalType => true
+    case BooleanType => true
+    case DateType => true
+    case TimestampType => true
+    case BinaryType => true
+    case _ => false
+  }
+
+  /**
+   * True iff `plan` produces the same row bag on every evaluation.
+   *
+   * This is the primary safety guard for the rewrite, which folds two occurrences of the same
+   * subtree into one aggregate -- sound only when both occurrences produce identical row bags. We
+   * check it at TWO levels:
+   *   - candidate level: the enclosing subquery, before descending into the self-join. Catches
+   *     nondeterminism that has been hoisted OUT of the join by an earlier optimizer rule -- e.g. a
+   *     `Filter(rand(...))` moved to sit above the join rather than on each side. Without this,
+   *     `isSameBaseRelation(innerLeft, innerRight)` could pass (both sides look deterministic)
+   *     while the enclosing plan still contains `Rand`.
+   *   - relation level: [[isSameBaseRelation]] additionally requires the two sides to be
+   *     structurally identical.
+   *
+   * Attribute-level `plan.deterministic` alone is NOT sufficient. Catalyst's
+   * `Expression.deterministic` only checks explicit `Nondeterministic` annotation; several
+   * operators produce a runtime-nondeterministic row bag even though every expression they contain
+   * is `deterministic == true`:
+   *   - `Aggregate` with `First` / `Last` / `collect_list` / `min_by` / `max_by` (tie order),
+   *   - `Window` with `row_number()` / `rank()` over a non-total order,
+   *   - `Limit` / `LocalLimit` / `Sample` / `Offset` (row-bag operator-level nondeterminism),
+   *   - streaming sources.
+   *
+   * This rule collapses two evaluations of the same subtree into one aggregate; repeatability must
+   * be provable, not assumed. That is why both the operator check and the expression check below
+   * are WHITELISTS rather than blacklists -- unknown operators and unknown expression types default
+   * to reject.
+   *
+   * Expression support is allowlisted, not blacklisted. `plan.deterministic` relies on each
+   * expression's reported `deterministic` contract; that is necessary but insufficient for unknown
+   * expression types whose repeatability has not been established -- a builtin that carries hidden
+   * state yet reports `deterministic == true` would otherwise be trusted silently. For a rewrite
+   * that folds two evaluations of a subtree into one aggregate we prefer to miss an optimization
+   * than to misapply one, so new expression types are added to [[isRepeatableExpression]] only
+   * after their repeatability has been established. `plan.deterministic` is kept as a cheap
+   * fast-reject, but the expression allowlist is what actually proves repeatability.
+   *
+   * `plan.subqueriesAll.isEmpty` additionally fail-closes on any embedded expression subquery
+   * (scalar / IN / EXISTS). `plan.exists` in `isRowBagRepeatable` walks only the operator tree and
+   * does not descend into expression subqueries, and `plan.deterministic` does not prove a nested
+   * subquery is row-bag repeatable (e.g. an uncorrelated `LIMIT 1` without `ORDER BY`). Rejecting
+   * any embedded subquery keeps the repeatability proof confined to the operator whitelist below.
+   */
+  private def isRepeatablePlan(plan: LogicalPlan): Boolean = {
+    // The operator/source whitelist is checked before the expression whitelist so that a plan whose
+    // operator is itself unknown -- e.g. Aggregate (carries AggregateExpression) or Window (carries
+    // WindowExpression / SortOrder) -- is attributed to isRowBagRepeatable rather than being masked
+    // by the fact that those operators also carry non-allowlisted expressions.
+    plan.deterministic &&
+    !plan.isStreaming &&
+    plan.subqueriesAll.isEmpty &&
+    isRowBagRepeatable(plan) &&
+    hasRepeatableExpressions(plan)
+  }
+
+  /**
+   * Operator whitelist for `isRepeatablePlan`. A plan is row-bag repeatable only when every node is
+   * known to produce a repeatable output row bag from repeatable children. Unknown operators and
+   * unknown leaf sources fail closed.
+   *
+   * Kept intentionally narrow -- the target workload (Q95-shape self-join in a subquery) only needs
+   * a Parquet relation scan optionally wrapped in Project / Filter / SubqueryAlias plus the
+   * self-join itself. Range and LocalRelation are also trusted deterministic leaves. Adding an
+   * operator here requires proving:
+   *   - it does not reorder its input non-deterministically,
+   *   - it does not depend on shuffle-merge or tie-broken orderings,
+   *   - it produces the same output row bag on every evaluation.
+   *
+   * Arbitrary `LeafNode`s are intentionally not trusted. For example, `LogicalRDD` may wrap an
+   * arbitrary RDD lineage whose runtime behavior is invisible to Catalyst's `plan.deterministic`;
+   * `InMemoryRelation`, `DataSourceV2Relation` and custom leaves are likewise rejected until
+   * proven. Streaming sources reach here as `LeafNode`s but are already filtered upstream by
+   * `plan.isStreaming` in [[isRepeatablePlan]].
+   *
+   * `LogicalRelation` is trusted only when its underlying relation is a `HadoopFsRelation` whose
+   * `fileFormat` is EXACTLY `ParquetFileFormat` (`getClass == classOf[ParquetFileFormat]`, not
+   * `isInstanceOf`). `HadoopFsRelation.fileFormat` can be any `FileFormat`, including custom
+   * formats whose scan is not provably a repeatable row bag; `ParquetFileFormat` is also non-final,
+   * so a third-party subclass could override its scan. The target workload only needs stock
+   * Parquet, so every other `FileFormat` -- subclasses of `ParquetFileFormat` included -- and every
+   * non-`HadoopFsRelation` fail closed.
+   *
+   * This helper checks operators and leaf sources only; expression-type repeatability is a separate
+   * concern handled by [[hasRepeatableExpressions]], and both are joined in [[isRepeatablePlan]].
+   */
+  private def isRowBagRepeatable(plan: LogicalPlan): Boolean = !plan.exists {
+    // TreeNode exposes `exists` but not `forall`, so invert: a whitelisted operator maps to `false`
+    // ("does not break repeatability") and everything else to `true`; negating the whole `exists`
+    // then means "every operator is whitelisted".
+    case _: Project => false
+    case _: Filter => false
+    case _: SubqueryAlias => false
+    // Join is included because our target pattern IS a Join; both children get recursed into.
+    case _: Join => false
+    // Explicitly trusted leaves.
+    case _: Range => false
+    case _: LocalRelation => false
+    case relation: LogicalRelation =>
+      relation.relation match {
+        case h: HadoopFsRelation if h.fileFormat.getClass == classOf[ParquetFileFormat] => false
+        case _ => true
+      }
+    // Everything else -- Aggregate (First/Last), Window (row_number ties), Limit, Sample,
+    // Offset, Distinct, Union, Except, Intersect, Sort (may break ties nondeterministically),
+    // Expand, Generate, and opaque leaf sources -- fail-closed.
+    case _ => true
+  }
+
+  /**
+   * Expression-type check for [[isRepeatablePlan]], kept separate from the operator whitelist in
+   * [[isRowBagRepeatable]] so the two safety layers read independently. A plan passes only when
+   * every expression carried by every operator node is repeatable per [[isRepeatableExpression]]; a
+   * whitelisted operator holding an unknown expression -- e.g. `Project(Abs(v))` -- fails closed.
+   */
+  private def hasRepeatableExpressions(plan: LogicalPlan): Boolean = {
+    !plan.exists(node => node.expressions.exists(expr => !isRepeatableExpression(expr)))
+  }
+
+  /**
+   * Expression repeatability is allowlisted, consistently with the operator whitelist in
+   * [[isRowBagRepeatable]]. Only expression types whose result is determined entirely by repeatable
+   * children are accepted; unknown expression types fail closed. This rule folds two evaluations of
+   * the same subtree into one, so missing an optimization is preferable to assuming repeatability
+   * for an expression whose runtime behavior has not been proven.
+   *
+   * The whole tree is checked: an expression is repeatable only when its root type is on the
+   * allowlist AND all of its children are themselves repeatable, so e.g. `Add(v, Abs(w))` is
+   * rejected even though `Add` is allowlisted.
+   *
+   * The initial set covers what TPC-DS Q95 and the tests need: column / literal references, Alias,
+   * Cast, basic arithmetic, boolean connectives, the six comparisons plus null-safe equality, and
+   * null checks. Expressions such as `Abs` / `Coalesce` / `CaseWhen` are intentionally absent -- a
+   * self-join over them is simply not rewritten (a missed optimization, not a correctness bug)
+   * until each is added here after its repeatability has been established. Decimal wrappers such as
+   * `PromotePrecision` / `CheckOverflow` are likewise absent and may fail closed; that is
+   * acceptable and must not be worked around by trusting them just to make an arithmetic variant
+   * fire.
+   */
+  private def isRepeatableExpression(expr: Expression): Boolean = expr match {

Review Comment:
   I’ll update the Decimal comment to distinguish Spark 3.3 from 3.4+; no code change needed. Thank you for your suggestion.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "zhouyuan (via GitHub)" <gi...@apache.org>.
zhouyuan commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3819906703


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

Review Comment:
   since we already address the main issue on q95, could you please clarify what are the typical quereis for the other two patterns?



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "github-actions[bot] (via GitHub)" <gi...@apache.org>.
github-actions[bot] commented on PR #12756:
URL: https://github.com/apache/gluten/pull/12756#issuecomment-6512821534

   This PR is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 10 days.


-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "zhouyuan (via GitHub)" <gi...@apache.org>.
zhouyuan commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3795418483


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,622 @@
+/*
+ * 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.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 (post-RewritePredicateSubquery fallback): LeftSemi/LeftAnti whose right child is an
+ * Inner self-join (possibly wrapped in Project). The outer semi-join condition is pure equi-key
+ * referencing a column from the self-join output. The self-join condition has equi + inequality on
+ * the same table. Rewrites the right child to GROUP BY + HAVING.
+ *
+ * Pattern A' (pre-RewritePredicateSubquery, primary path): InSubquery(_, ListQuery(sub, ...)) or
+ * Exists(sub, ...) whose sub is Project(Inner self-join with equi+neq). Rewrites sub to
+ * Project(equi_keys, Filter(count_distinct>1, Aggregate)). Fires in the Operator Optimization
+ * batches BEFORE Spark lifts them to LeftSemi/LeftAnti; the outer expression form guarantees
+ * existence semantics. ScalarSubquery is intentionally NOT matched.
+ *
+ * Pattern A2 (nested self-join): InSubquery/Exists whose subquery plan is Project(Join(Inner,
+ * other_table, self-join)) -- the self-join is a child of another InnerJoin, not the top-level join
+ * itself. Only the self-join child is replaced with Aggregate; the outer join is preserved. Safe
+ * because the outer join connects on the equi-key, and the subquery is still consumed as an
+ * existence set.
+ *
+ * Controlled by spark.gluten.sql.rewrite.selfJoinInequality (default false, opt-in until exercised
+ * more broadly across workloads).
+ */
+case class RewriteSelfJoinInequalityToAggregate(spark: SparkSession)
+  extends Rule[LogicalPlan]
+  with PredicateHelper
+  with Logging {
+
+  override def apply(plan: LogicalPlan): LogicalPlan = {
+    if (!VeloxConfig.get.enableRewriteSelfJoinInequality) {
+      logDebug("RewriteSelfJoinInequalityToAggregate: disabled via config, skipping")
+      return plan
+    }
+
+    val afterOps = plan.transformUp {
+      // Pattern A: LeftSemi/LeftAnti whose right child is an Inner self-join
+      // (possibly wrapped in Project).
+      case j: Join
+          if (j.joinType == LeftSemi || j.joinType == LeftAnti) &&
+            j.condition.isDefined &&
+            isInnerJoinShape(j.right) =>
+        tryRewriteSemiWithSelfJoinChild(j, j.left, j.right, j.joinType, j.condition.get, j.hint)
+          .getOrElse(j)
+
+      case other => other
+    }
+
+    // Pattern A': rewrite subquery plans embedded in InSubquery/Exists expressions.
+    // Fires before Spark's RewritePredicateSubquery (batch pos 26); once the subquery
+    // plan is rewritten to GROUP BY + HAVING, RewritePredicateSubquery lifts it to
+    // LeftSemi/LeftAnti in the normal way.
+    // Use type-based matching (`x: T`) and named-argument copy (`x.copy(plan = ...)`)
+    // instead of case-class unapply with a fixed parameter list. This keeps the code
+    // portable across Spark 3.3/3.4/3.5/4.x where the internal ListQuery/Exists
+    // case classes have added parameters over releases.
+    val rewritten = afterOps.transformAllExpressions {
+      case in @ InSubquery(_, lq: ListQuery) =>
+        rewriteSubqueryPlan(lq.plan) match {
+          case Some(newSub) => in.copy(query = lq.copy(plan = newSub))
+          case None => in
+        }
+      case ex: Exists =>
+        rewriteSubqueryPlan(ex.plan) match {
+          case Some(newSub) => ex.copy(plan = newSub)
+          case None => ex
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logInfo("RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+        "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+
+  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
+  }
+
+  /**
+   * Dispatches subquery plan rewriting: tries Pattern A' (direct self-join at top level) first,
+   * then Pattern A2 (self-join nested as a child of another InnerJoin).
+   *
+   * Only called on subquery plans of `InSubquery` / `Exists`, i.e. contexts that consume the output
+   * as a set of distinct keys. Cardinality of the intermediate is safe to change.
+   */
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(plan, projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(plan, projectListOpt, innerJoin)
+    }
+  }
+
+  private def rewriteDirectSelfJoin(
+      plan: LogicalPlan,
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+
+    // Use field accessors (portable across Spark versions) instead of
+    // case-class unapply with a fixed parameter list. The caller has already
+    // pattern-matched innerJoin as Join(_, _, Inner, Some(_), _).
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs = equiPairs.map(_._1).collect { case a: Attribute => a }
+    if (innerLeftEquiAttrs.size != equiPairs.size) return None
+
+    val innerLeftNeqAttr = neqPairs.head._1 match {
+      case a: Attribute => a
+      case _ => return None
+    }
+
+    val countDistinctExpr = AggregateExpression(
+      Count(Seq(innerLeftNeqAttr)),
+      mode = Complete,
+      isDistinct = true,
+      filter = None,
+      NamedExpression.newExprId)
+    val countAlias = Alias(countDistinctExpr, "_gluten_rw_selfjoin_cnt_distinct")()
+
+    val groupingExprs: Seq[Expression] = innerLeftEquiAttrs
+    val aggExprs: Seq[NamedExpression] =
+      innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]) :+ countAlias
+    val aggregate = Aggregate(groupingExprs, aggExprs, innerLeft)
+
+    val filterExpr = GreaterThan(countAlias.toAttribute, Literal(1L, LongType))
+    val filtered = Filter(filterExpr, aggregate)
+
+    val nameToInnerLeft: Map[String, Attribute] = innerLeftEquiAttrs.map(a => a.name -> a).toMap
+    val innerLeftEquiExprIds = innerLeftEquiAttrs.map(_.exprId).toSet
+    val innerRightEquiExprIds =
+      equiPairs.map(_._2).collect { case a: Attribute => a.exprId }.toSet
+
+    val projectListResolved: Option[Seq[NamedExpression]] = projectListOpt match {
+      case None =>
+        Some(innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]))
+      case Some(pl) =>
+        val remapped = pl.map {
+          case a: Attribute
+              if nameToInnerLeft.contains(a.name) &&
+                (innerLeftEquiExprIds.contains(a.exprId) ||
+                  innerRightEquiExprIds.contains(a.exprId)) =>
+            Some(Alias(nameToInnerLeft(a.name), a.name)(a.exprId).asInstanceOf[NamedExpression])
+          case al @ Alias(a: Attribute, _)
+              if nameToInnerLeft.contains(a.name) &&
+                (innerLeftEquiExprIds.contains(a.exprId) ||
+                  innerRightEquiExprIds.contains(a.exprId)) =>
+            Some(Alias(nameToInnerLeft(a.name), al.name)(al.exprId).asInstanceOf[NamedExpression])
+          case _ =>
+            None
+        }
+        if (remapped.exists(_.isEmpty)) None
+        else Some(remapped.flatten)
+    }
+
+    projectListResolved.map {
+      pl =>
+        logInfo(
+          s"RewriteSelfJoinInequalityToAggregate: Pattern A' - rewrote subquery Project(Inner " +
+            s"self-join) to Project + Filter(count_distinct>1) + Aggregate. " +
+            s"equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}], " +
+            s"neqCol=${innerLeftNeqAttr.name}, outCols=[${pl.map(_.name).mkString(",")}]")
+        Project(pl, filtered)
+    }
+  }
+
+  /**
+   * Pattern A2: the top-level InnerJoin is NOT a self-join, but one of its children IS a self-join
+   * (possibly wrapped in Project). Example: `Join(Inner, web_returns, Project(self-join))`.
+   *
+   * We replace the self-join child with Aggregate + Filter, preserving the outer join. This is safe
+   * because:
+   *   1. The outer join condition connects the other table to the self-join's equi-key.
+   *   2. Replacing the self-join with GROUP BY + HAVING preserves the set of distinct keys (only
+   *      row-count multiplicity changes), and the other table joins on that key.
+   *   3. The entire subquery is consumed by InSubquery/Exists (existence semantics), so the final
+   *      output is still just a set of distinct keys.
+   */
+  private def rewriteNestedSelfJoin(
+      plan: LogicalPlan,
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+
+    // Use field accessors instead of case-class unapply (portable across Spark
+    // versions). Caller has already pattern-matched outerJoin as
+    // Join(_, _, Inner, Some(_), _).
+    val outerLeft = outerJoin.left
+    val outerRight = outerJoin.right
+    val outerCond = outerJoin.condition.get
+    val outerHint = outerJoin.hint
+
+    val (selfJoinSide, otherSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerRight) match {
+        case Some(_) => (outerRight, outerLeft, true)
+        case None =>
+          tryExtractSelfJoin(outerLeft) match {
+            case Some(_) => (outerLeft, outerRight, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs = equiPairs.map(_._1).collect { case a: Attribute => a }
+    if (sjLeftEquiAttrs.size != equiPairs.size) return None
+
+    val sjLeftNeqAttr = neqPairs.head._1 match {
+      case a: Attribute => a
+      case _ => return None
+    }
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    val sjEquiExprIds = equiPairs.flatMap {
+      case (l: Attribute, r: Attribute) => Seq(l.exprId, r.exprId)
+      case _ => Seq.empty
+    }.toSet
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt match {
+      case Some(pl) =>
+        pl.flatMap {
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) =>
+            Some(al.exprId)
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) =>
+            Some(a.exprId)
+          case _ => None
+        }.toSet
+      case None => Set.empty
+    }
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+
+    // The rewrite replaces the self-join subtree with Project(equiKeys, Filter(Aggregate)),
+    // so any reference to non-equi self-join attributes in the subquery's top-level
+    // projectList would become unresolved. Bail out unless the top-level projectList
+    // depends only on equi-key attributes from the self-join side.
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val projRefsFromSelfJoin = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        projRefsFromSelfJoin.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val countDistinctExpr = AggregateExpression(
+      Count(Seq(sjLeftNeqAttr)),
+      mode = Complete,
+      isDistinct = true,
+      filter = None,
+      NamedExpression.newExprId)
+    val countAlias = Alias(countDistinctExpr, "_gluten_rw_selfjoin_cnt_distinct")()
+
+    val groupingExprs: Seq[Expression] = sjLeftEquiAttrs
+    val aggExprs: Seq[NamedExpression] =
+      sjLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]) :+ countAlias
+    val aggregate = Aggregate(groupingExprs, aggExprs, sjLeft)
+
+    val filterExpr = GreaterThan(countAlias.toAttribute, Literal(1L, LongType))
+    val filtered = Filter(filterExpr, aggregate)
+
+    val sjLeftEquiByName: Map[String, Attribute] = sjLeftEquiAttrs.map(a => a.name -> a).toMap
+    val sjLeftEquiExprIds = sjLeftEquiAttrs.map(_.exprId).toSet
+    val sjRightEquiExprIds = equiPairs.map(_._2).collect { case a: Attribute => a.exprId }.toSet
+
+    val newSelfJoinSide: LogicalPlan = selfJoinProjectOpt match {
+      case Some(pl) =>
+        // Use flatMap + size check instead of `return None` inside the map lambda,
+        // which triggers Scala's nonlocal return (unsafe / lint-flagged).
+        val remapped: Seq[NamedExpression] = pl.flatMap {
+          case a: Attribute
+              if sjLeftEquiByName.contains(a.name) &&
+                (sjLeftEquiExprIds.contains(a.exprId) ||
+                  sjRightEquiExprIds.contains(a.exprId)) =>
+            Some(Alias(sjLeftEquiByName(a.name), a.name)(a.exprId): NamedExpression)
+          case al @ Alias(a: Attribute, _)
+              if sjLeftEquiByName.contains(a.name) &&
+                (sjLeftEquiExprIds.contains(a.exprId) ||
+                  sjRightEquiExprIds.contains(a.exprId)) =>
+            Some(Alias(sjLeftEquiByName(a.name), al.name)(al.exprId): NamedExpression)
+          case _ => None
+        }
+        if (remapped.size != pl.size) return None
+        Project(remapped, filtered)
+      case None =>
+        Project(sjLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]), filtered)
+    }
+
+    val newOuterJoin = if (selfJoinOnRight) {
+      outerJoin.copy(right = newSelfJoinSide)
+    } else {
+      outerJoin.copy(left = newSelfJoinSide)
+    }
+
+    val result = projectListOpt match {
+      case Some(pl) => Project(pl, newOuterJoin)
+      case None => newOuterJoin
+    }
+
+    logInfo(
+      s"RewriteSelfJoinInequalityToAggregate: Pattern A2 - rewrote nested self-join inside " +
+        s"subquery InnerJoin. Self-join replaced with GROUP BY HAVING COUNT(DISTINCT) > 1. " +
+        s"equiKeys=[${sjLeftEquiAttrs.map(_.name).mkString(",")}], " +
+        s"neqCol=${sjLeftNeqAttr.name}")
+
+    Some(result)
+  }
+
+  private def tryExtractSelfJoin(plan: LogicalPlan): Option[Join] = {
+    val join = plan match {
+      case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => j
+      case j: Join if j.joinType == Inner && j.condition.isDefined => j
+      case _ => return None
+    }
+    val l = join.left
+    val r = join.right
+    val cond = join.condition.get
+    if (!isSameBaseRelation(l, r)) return None
+    val parsed = parseSelfJoinCondition(cond, l, r)
+    if (parsed.isEmpty) return None
+    Some(join)
+  }
+
+  /**
+   * Pattern A: The LeftSemi/LeftAnti right child is itself an Inner self-join (with or without a
+   * wrapping Project). The semi-join condition is pure equi. We replace the right child with GROUP
+   * BY + HAVING COUNT(DISTINCT) > 1.
+   *
+   * Matches q95 structure: Join(LeftSemi, left = outer query (filtered web_sales + dims), right =
+   * Join(Inner, ws_L, ws_R, equi(order_number) AND neq(warehouse_sk)), condition =
+   * outer.order_number = inner.order_number)
+   */
+  private def tryRewriteSemiWithSelfJoinChild(
+      original: Join,
+      left: LogicalPlan,
+      right: LogicalPlan,
+      joinType: JoinType,
+      semiCondition: Expression,
+      hint: JoinHint): Option[LogicalPlan] = {
+
+    // Unwrap the right child: may be Project(Join(Inner,...)) or bare Join(Inner,...)
+    val (innerJoin, wrapper) = right match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (j, Some(p))
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (j, None)
+      case _ => return None
+    }
+
+    // Use field accessors (portable across Spark versions) instead of
+    // case-class unapply with a fixed parameter list. The caller has already
+    // pattern-matched innerJoin as Join(_, _, Inner, Some(_), _).
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    // Both sides of inner join must scan the same base relation
+    if (!isSameBaseRelation(innerLeft, innerRight)) return None
+
+    // Parse inner join condition: must be equi + neq only
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    val (innerEquiPairs, innerNeqPairs) = parsed.get
+
+    // The semi-join condition must reference an output attribute from the inner join
+    // that corresponds to an equi-key. This is how the outer query connects to the CTE.
+    val semiPreds = splitConjunctivePredicates(semiCondition)
+    val rightOutputSet = right.outputSet
+
+    // All semi predicates must be pure equi-join (no inequality in semi condition)
+    val semiEquiPairs = semiPreds.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if left.outputSet.contains(l) && rightOutputSet.contains(r) => (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if left.outputSet.contains(l) && rightOutputSet.contains(r) => (l, r)
+    }
+    if (semiEquiPairs.size != semiPreds.size) return None
+    if (semiEquiPairs.isEmpty) return None
+
+    // The semi-join right-side keys must be derivable from the inner join's equi-keys
+    val innerEquiLeftAttrIds =
+      innerEquiPairs.map(_._1).collect { case a: Attribute => a.exprId }.toSet
+    val innerEquiRightAttrIds =
+      innerEquiPairs.map(_._2).collect { case a: Attribute => a.exprId }.toSet
+    val innerEquiAllIds = innerEquiLeftAttrIds ++ innerEquiRightAttrIds
+
+    // Check that all semi-join right keys reference inner equi-key attributes
+    val semiRightKeys = semiEquiPairs.map(_._2)
+    // After ColumnPruning, the right side might output only the equi-key columns
+    // The semi right keys must be from the inner join's equi-key set
+    val rightKeyIds = semiRightKeys.map(_.exprId).toSet
+    val validSemiKeys = rightKeyIds.forall {
+      id =>
+        innerEquiAllIds.contains(id) || {
+          // Semi-key may be exposed via a wrapper Project. For an Alias,
+          // the alias's *output* ExprId must match the caller's `id`, AND
+          // the underlying attribute must be an inner equi-key. Using the
+          // alias's input ExprId here compared with `id` compares two
+          // unrelated ids (alias always mints a new output id).
+          wrapper.exists {
+            case Project(pl, _) =>
+              pl.exists {
+                case al @ Alias(a: Attribute, _) =>
+                  al.exprId == id && innerEquiAllIds.contains(a.exprId)
+                case a: Attribute =>
+                  a.exprId == id && innerEquiAllIds.contains(a.exprId)
+                case _ => false
+              }
+            case _ => false
+          }
+        }
+    }
+    if (!validSemiKeys) return None
+
+    // Build replacement: GROUP BY equi_keys HAVING COUNT(DISTINCT neq_col) > 1
+    val innerLeftEquiAttrs = innerEquiPairs.map(_._1).collect { case a: Attribute => a }
+    val innerNeqAttr = innerNeqPairs.head._1 match {
+      case a: Attribute => a
+      case _ => return None
+    }
+
+    val countDistinctExpr = AggregateExpression(
+      Count(Seq(innerNeqAttr)),
+      mode = Complete,
+      isDistinct = true,
+      filter = None,
+      NamedExpression.newExprId)
+    val countAlias = Alias(countDistinctExpr, "_gluten_rw_selfjoin_cnt_distinct")()
+
+    val groupingExprs: Seq[Expression] = innerLeftEquiAttrs
+    val aggExprs: Seq[NamedExpression] =
+      innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]) :+ countAlias
+    val aggregate = Aggregate(groupingExprs, aggExprs, innerLeft)
+
+    val filterExpr = GreaterThan(countAlias.toAttribute, Literal(1L, LongType))
+    val filtered = Filter(filterExpr, aggregate)
+
+    // Project only the equi-key columns (to match the original right-side output schema)
+    val projectedKeys = Project(
+      innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]),
+      filtered)
+
+    // Rebuild semi condition: map any exprId reachable from the old right side to
+    // the corresponding innerLeft equi attr. Cover three cases:
+    //  (1) direct innerRight equi attr
+    //  (2) innerLeft equi attr (identity)
+    //  (3) wrapper Project's output attr backed by Alias(innerEquiAttr, _) - new exprId
+    val innerRightEquiAttrs = innerEquiPairs.map(_._2).collect { case a: Attribute => a }
+    val nameToInnerLeft: Map[String, Attribute] =
+      innerLeftEquiAttrs.map(a => a.name -> a).toMap
+    val wrapperRemap: Map[ExprId, Attribute] = wrapper match {
+      case Some(Project(pl, _)) =>
+        pl.flatMap {
+          case al @ Alias(a: Attribute, _)
+              if (innerEquiLeftAttrIds.contains(a.exprId) ||
+                innerEquiRightAttrIds.contains(a.exprId)) &&
+                nameToInnerLeft.contains(a.name) =>
+            Some(al.exprId -> nameToInnerLeft(a.name))
+          case _ => None
+        }.toMap
+      case _ => Map.empty
+    }
+    val oldToNewMap: Map[ExprId, Attribute] =
+      innerRightEquiAttrs.map(_.exprId).zip(innerLeftEquiAttrs).toMap ++
+        innerLeftEquiAttrs.map(a => a.exprId -> a).toMap ++
+        wrapperRemap
+
+    // Safety: every attr in semiCondition that came from the old right output must
+    // resolve to something in oldToNewMap. If not, refuse to rewrite.
+    val unresolved = semiCondition.collect {
+      case a: Attribute if rightOutputSet.contains(a) && !oldToNewMap.contains(a.exprId) => a
+    }
+    if (unresolved.nonEmpty) return None
+
+    val newSemiCondition = semiCondition.transformUp {
+      case a: Attribute if oldToNewMap.contains(a.exprId) && rightOutputSet.contains(a) =>
+        oldToNewMap(a.exprId)
+    }
+
+    val newJoin = original.copy(
+      right = projectedKeys,
+      condition = Some(newSemiCondition))
+
+    logInfo(
+      s"RewriteSelfJoinInequalityToAggregate: Pattern A - rewrote $joinType with Inner " +
+        s"self-join child to $joinType + GROUP BY HAVING COUNT(DISTINCT) > 1. " +
+        s"equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}], " +
+        s"neqCol=${innerNeqAttr.name}")
+
+    Some(newJoin)
+  }
+
+  /**
+   * Parse a join condition into equi-pairs and inequality-pairs. Accepts only: EqualTo(attr, attr)
+   * and Not(EqualTo(attr, attr)). Ignores IsNotNull predicates (added by
+   * InferFiltersFromConstraints). Returns None if there are unrecognized predicates beyond equi +
+   * neq + IsNotNull.
+   */
+  private def parseSelfJoinCondition(
+      condition: Expression,
+      left: LogicalPlan,
+      right: LogicalPlan): Option[(Seq[(Attribute, Attribute)], Seq[(Attribute, Attribute)])] = {
+
+    val leftOutput = left.outputSet
+    val rightOutput = right.outputSet
+    val predicates = splitConjunctivePredicates(condition)
+
+    val equiPairs = predicates.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    val neqPairs = predicates.collect {
+      case Not(EqualTo(l: Attribute, r: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case Not(EqualTo(r: Attribute, l: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    // Only IsNotNull predicates on equi/neq attributes are safe to drop
+    // (they're redundant with the join semantics or auto-added by
+    // InferFiltersFromConstraints). IsNotNull on other columns would be
+    // silently dropped by the rewrite and change query semantics -- bail out.
+    val joinAttrIds: Set[org.apache.spark.sql.catalyst.expressions.ExprId] =
+      (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+
+    val isNotNullOnJoinCols = predicates.count {
+      case IsNotNull(a: Attribute) if joinAttrIds.contains(a.exprId) => true
+      case _ => false
+    }
+
+    // Accept only equi + neq + IsNotNull-on-join-cols; reject anything else.
+    val totalMatched = equiPairs.size + neqPairs.size + isNotNullOnJoinCols
+    if (totalMatched != predicates.size) return None
+
+    if (equiPairs.isEmpty || neqPairs.isEmpty) return None
+
+    // Only rewrite single-inequality case: COUNT(DISTINCT single_col) > 1 semantics
+    // is strictly wider than "exists row with ALL of multiple != predicates".
+    if (neqPairs.size != 1) return None
+
+    // Self-join invariant: equi and neq must be on same-named columns from both sides.
+    val equiValid = equiPairs.forall { case (l, r) => l.name == r.name }
+    val neqValid = neqPairs.forall { case (l, r) => l.name == r.name }
+    if (!equiValid || !neqValid) return None
+
+    Some((equiPairs, neqPairs))

Review Comment:
   @hhr293 could you please check if this review comment makes sense?  it's marked as high priority 



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "zhouyuan (via GitHub)" <gi...@apache.org>.
zhouyuan commented on PR #12756:
URL: https://github.com/apache/gluten/pull/12756#issuecomment-5438208245

   Cc: @wangyum @beliefer


-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on PR #12756:
URL: https://github.com/apache/gluten/pull/12756#issuecomment-5504652874

   Hi @FelixYBW @LuciferYang @beliefer @zhouyuan , I’ve completed the follow-up validation and review changes. Could you please take another look?
   
   Submitted the Spark-side draft PR: apache/spark#58424. The rule remains default-off, and the upstream validation is complete.
   
   I also validated the rule with default-on and regenerated the Q95 golden plans. The related CI run is here:
   https://github.com/hhr293/spark/actions/runs/33462355553
   
   One of the current CI failures is infrastructure-related rather than test-related: Maven failed to download apache-maven-3.9.16-bin.tar.gz from Maven Central and exited before running the tests.
   
   Addressed the comparePlans suggestion in 9b6ed299 by comparing the rewritten plans against equivalent hand-written aggregate SQL.
   
   Thanks for the reviews.


-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3931190189


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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

Review Comment:
   Agreed. I’ll narrow the NULL comment to NOT IN semantics; the implementation stays unchanged.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "beliefer (via GitHub)" <gi...@apache.org>.
beliefer commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3871397200


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,674 @@
+/*
+ * 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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.LongType
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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)),

Review Comment:
   The COUNT(DISTINCT neqCol) > 1 rewrite diverges from the original s1.neqCol <> s2.neqCol predicate for special float/double values:
   
   - -0.0 vs +0.0: SQL -0.0 = 0.0 is true, so -0.0 <> 0.0 is false. The original self-join produces no row. But Spark's COUNT(DISTINCT) uses bit-level/Java Double.equals which keeps -0.0 != 0.0, so COUNT(DISTINCT {-0.0, 0.0}) = 2. The rewrite fires and the row becomes IN when it should be OUT.
   - NaN: The reverse — two NaN rows satisfy NaN <> NaN in SQL (true), so the original matches; but Java NaN.equals(NaN) is true so COUNT(DISTINCT NaN) = 1. The rewrite doesn't fire when it should.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3765217929


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,622 @@
+/*
+ * 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.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 (post-RewritePredicateSubquery fallback): LeftSemi/LeftAnti whose right child is an
+ * Inner self-join (possibly wrapped in Project). The outer semi-join condition is pure equi-key
+ * referencing a column from the self-join output. The self-join condition has equi + inequality on
+ * the same table. Rewrites the right child to GROUP BY + HAVING.
+ *
+ * Pattern A' (pre-RewritePredicateSubquery, primary path): InSubquery(_, ListQuery(sub, ...)) or
+ * Exists(sub, ...) whose sub is Project(Inner self-join with equi+neq). Rewrites sub to
+ * Project(equi_keys, Filter(count_distinct>1, Aggregate)). Fires in the Operator Optimization
+ * batches BEFORE Spark lifts them to LeftSemi/LeftAnti; the outer expression form guarantees
+ * existence semantics. ScalarSubquery is intentionally NOT matched.
+ *
+ * Pattern A2 (nested self-join): InSubquery/Exists whose subquery plan is Project(Join(Inner,
+ * other_table, self-join)) -- the self-join is a child of another InnerJoin, not the top-level join
+ * itself. Only the self-join child is replaced with Aggregate; the outer join is preserved. Safe
+ * because the outer join connects on the equi-key, and the subquery is still consumed as an
+ * existence set.
+ *
+ * Controlled by spark.gluten.sql.rewrite.selfJoinInequality (default false, opt-in until exercised
+ * more broadly across workloads).
+ */
+case class RewriteSelfJoinInequalityToAggregate(spark: SparkSession)
+  extends Rule[LogicalPlan]
+  with PredicateHelper
+  with Logging {
+
+  override def apply(plan: LogicalPlan): LogicalPlan = {
+    if (!VeloxConfig.get.enableRewriteSelfJoinInequality) {
+      logDebug("RewriteSelfJoinInequalityToAggregate: disabled via config, skipping")
+      return plan
+    }
+
+    val afterOps = plan.transformUp {
+      // Pattern A: LeftSemi/LeftAnti whose right child is an Inner self-join
+      // (possibly wrapped in Project).
+      case j: Join
+          if (j.joinType == LeftSemi || j.joinType == LeftAnti) &&
+            j.condition.isDefined &&
+            isInnerJoinShape(j.right) =>
+        tryRewriteSemiWithSelfJoinChild(j, j.left, j.right, j.joinType, j.condition.get, j.hint)
+          .getOrElse(j)
+
+      case other => other
+    }
+
+    // Pattern A': rewrite subquery plans embedded in InSubquery/Exists expressions.
+    // Fires before Spark's RewritePredicateSubquery (batch pos 26); once the subquery
+    // plan is rewritten to GROUP BY + HAVING, RewritePredicateSubquery lifts it to
+    // LeftSemi/LeftAnti in the normal way.
+    // Use type-based matching (`x: T`) and named-argument copy (`x.copy(plan = ...)`)
+    // instead of case-class unapply with a fixed parameter list. This keeps the code
+    // portable across Spark 3.3/3.4/3.5/4.x where the internal ListQuery/Exists
+    // case classes have added parameters over releases.
+    val rewritten = afterOps.transformAllExpressions {
+      case in @ InSubquery(_, lq: ListQuery) =>
+        rewriteSubqueryPlan(lq.plan) match {
+          case Some(newSub) => in.copy(query = lq.copy(plan = newSub))
+          case None => in
+        }
+      case ex: Exists =>
+        rewriteSubqueryPlan(ex.plan) match {
+          case Some(newSub) => ex.copy(plan = newSub)
+          case None => ex
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logInfo("RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+        "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+
+  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
+  }
+
+  /**
+   * Dispatches subquery plan rewriting: tries Pattern A' (direct self-join at top level) first,
+   * then Pattern A2 (self-join nested as a child of another InnerJoin).
+   *
+   * Only called on subquery plans of `InSubquery` / `Exists`, i.e. contexts that consume the output
+   * as a set of distinct keys. Cardinality of the intermediate is safe to change.
+   */
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(plan, projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(plan, projectListOpt, innerJoin)
+    }
+  }
+
+  private def rewriteDirectSelfJoin(
+      plan: LogicalPlan,
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+
+    // Use field accessors (portable across Spark versions) instead of
+    // case-class unapply with a fixed parameter list. The caller has already
+    // pattern-matched innerJoin as Join(_, _, Inner, Some(_), _).
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs = equiPairs.map(_._1).collect { case a: Attribute => a }
+    if (innerLeftEquiAttrs.size != equiPairs.size) return None
+
+    val innerLeftNeqAttr = neqPairs.head._1 match {
+      case a: Attribute => a
+      case _ => return None
+    }
+
+    val countDistinctExpr = AggregateExpression(
+      Count(Seq(innerLeftNeqAttr)),
+      mode = Complete,
+      isDistinct = true,
+      filter = None,
+      NamedExpression.newExprId)
+    val countAlias = Alias(countDistinctExpr, "_gluten_rw_selfjoin_cnt_distinct")()
+
+    val groupingExprs: Seq[Expression] = innerLeftEquiAttrs
+    val aggExprs: Seq[NamedExpression] =
+      innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]) :+ countAlias
+    val aggregate = Aggregate(groupingExprs, aggExprs, innerLeft)
+
+    val filterExpr = GreaterThan(countAlias.toAttribute, Literal(1L, LongType))
+    val filtered = Filter(filterExpr, aggregate)
+
+    val nameToInnerLeft: Map[String, Attribute] = innerLeftEquiAttrs.map(a => a.name -> a).toMap
+    val innerLeftEquiExprIds = innerLeftEquiAttrs.map(_.exprId).toSet
+    val innerRightEquiExprIds =
+      equiPairs.map(_._2).collect { case a: Attribute => a.exprId }.toSet
+
+    val projectListResolved: Option[Seq[NamedExpression]] = projectListOpt match {
+      case None =>
+        Some(innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]))
+      case Some(pl) =>
+        val remapped = pl.map {
+          case a: Attribute
+              if nameToInnerLeft.contains(a.name) &&
+                (innerLeftEquiExprIds.contains(a.exprId) ||
+                  innerRightEquiExprIds.contains(a.exprId)) =>
+            Some(Alias(nameToInnerLeft(a.name), a.name)(a.exprId).asInstanceOf[NamedExpression])
+          case al @ Alias(a: Attribute, _)
+              if nameToInnerLeft.contains(a.name) &&
+                (innerLeftEquiExprIds.contains(a.exprId) ||
+                  innerRightEquiExprIds.contains(a.exprId)) =>
+            Some(Alias(nameToInnerLeft(a.name), al.name)(al.exprId).asInstanceOf[NamedExpression])
+          case _ =>
+            None
+        }
+        if (remapped.exists(_.isEmpty)) None
+        else Some(remapped.flatten)
+    }
+
+    projectListResolved.map {
+      pl =>
+        logInfo(
+          s"RewriteSelfJoinInequalityToAggregate: Pattern A' - rewrote subquery Project(Inner " +
+            s"self-join) to Project + Filter(count_distinct>1) + Aggregate. " +
+            s"equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}], " +
+            s"neqCol=${innerLeftNeqAttr.name}, outCols=[${pl.map(_.name).mkString(",")}]")
+        Project(pl, filtered)
+    }
+  }
+
+  /**
+   * Pattern A2: the top-level InnerJoin is NOT a self-join, but one of its children IS a self-join
+   * (possibly wrapped in Project). Example: `Join(Inner, web_returns, Project(self-join))`.
+   *
+   * We replace the self-join child with Aggregate + Filter, preserving the outer join. This is safe
+   * because:
+   *   1. The outer join condition connects the other table to the self-join's equi-key.
+   *   2. Replacing the self-join with GROUP BY + HAVING preserves the set of distinct keys (only
+   *      row-count multiplicity changes), and the other table joins on that key.
+   *   3. The entire subquery is consumed by InSubquery/Exists (existence semantics), so the final
+   *      output is still just a set of distinct keys.
+   */
+  private def rewriteNestedSelfJoin(
+      plan: LogicalPlan,
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+
+    // Use field accessors instead of case-class unapply (portable across Spark
+    // versions). Caller has already pattern-matched outerJoin as
+    // Join(_, _, Inner, Some(_), _).
+    val outerLeft = outerJoin.left
+    val outerRight = outerJoin.right
+    val outerCond = outerJoin.condition.get
+    val outerHint = outerJoin.hint
+
+    val (selfJoinSide, otherSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerRight) match {
+        case Some(_) => (outerRight, outerLeft, true)
+        case None =>
+          tryExtractSelfJoin(outerLeft) match {
+            case Some(_) => (outerLeft, outerRight, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs = equiPairs.map(_._1).collect { case a: Attribute => a }
+    if (sjLeftEquiAttrs.size != equiPairs.size) return None
+
+    val sjLeftNeqAttr = neqPairs.head._1 match {
+      case a: Attribute => a
+      case _ => return None
+    }
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    val sjEquiExprIds = equiPairs.flatMap {
+      case (l: Attribute, r: Attribute) => Seq(l.exprId, r.exprId)
+      case _ => Seq.empty
+    }.toSet
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt match {
+      case Some(pl) =>
+        pl.flatMap {
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) =>
+            Some(al.exprId)
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) =>
+            Some(a.exprId)
+          case _ => None
+        }.toSet
+      case None => Set.empty
+    }
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+
+    // The rewrite replaces the self-join subtree with Project(equiKeys, Filter(Aggregate)),
+    // so any reference to non-equi self-join attributes in the subquery's top-level
+    // projectList would become unresolved. Bail out unless the top-level projectList
+    // depends only on equi-key attributes from the self-join side.
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val projRefsFromSelfJoin = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        projRefsFromSelfJoin.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val countDistinctExpr = AggregateExpression(
+      Count(Seq(sjLeftNeqAttr)),
+      mode = Complete,
+      isDistinct = true,
+      filter = None,
+      NamedExpression.newExprId)
+    val countAlias = Alias(countDistinctExpr, "_gluten_rw_selfjoin_cnt_distinct")()
+
+    val groupingExprs: Seq[Expression] = sjLeftEquiAttrs
+    val aggExprs: Seq[NamedExpression] =
+      sjLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]) :+ countAlias
+    val aggregate = Aggregate(groupingExprs, aggExprs, sjLeft)
+
+    val filterExpr = GreaterThan(countAlias.toAttribute, Literal(1L, LongType))
+    val filtered = Filter(filterExpr, aggregate)
+
+    val sjLeftEquiByName: Map[String, Attribute] = sjLeftEquiAttrs.map(a => a.name -> a).toMap
+    val sjLeftEquiExprIds = sjLeftEquiAttrs.map(_.exprId).toSet
+    val sjRightEquiExprIds = equiPairs.map(_._2).collect { case a: Attribute => a.exprId }.toSet
+
+    val newSelfJoinSide: LogicalPlan = selfJoinProjectOpt match {
+      case Some(pl) =>
+        // Use flatMap + size check instead of `return None` inside the map lambda,
+        // which triggers Scala's nonlocal return (unsafe / lint-flagged).
+        val remapped: Seq[NamedExpression] = pl.flatMap {
+          case a: Attribute
+              if sjLeftEquiByName.contains(a.name) &&
+                (sjLeftEquiExprIds.contains(a.exprId) ||
+                  sjRightEquiExprIds.contains(a.exprId)) =>
+            Some(Alias(sjLeftEquiByName(a.name), a.name)(a.exprId): NamedExpression)
+          case al @ Alias(a: Attribute, _)
+              if sjLeftEquiByName.contains(a.name) &&
+                (sjLeftEquiExprIds.contains(a.exprId) ||
+                  sjRightEquiExprIds.contains(a.exprId)) =>
+            Some(Alias(sjLeftEquiByName(a.name), al.name)(al.exprId): NamedExpression)
+          case _ => None
+        }
+        if (remapped.size != pl.size) return None
+        Project(remapped, filtered)
+      case None =>
+        Project(sjLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]), filtered)
+    }
+
+    val newOuterJoin = if (selfJoinOnRight) {
+      outerJoin.copy(right = newSelfJoinSide)
+    } else {
+      outerJoin.copy(left = newSelfJoinSide)
+    }
+
+    val result = projectListOpt match {
+      case Some(pl) => Project(pl, newOuterJoin)
+      case None => newOuterJoin
+    }
+
+    logInfo(
+      s"RewriteSelfJoinInequalityToAggregate: Pattern A2 - rewrote nested self-join inside " +
+        s"subquery InnerJoin. Self-join replaced with GROUP BY HAVING COUNT(DISTINCT) > 1. " +
+        s"equiKeys=[${sjLeftEquiAttrs.map(_.name).mkString(",")}], " +
+        s"neqCol=${sjLeftNeqAttr.name}")
+
+    Some(result)
+  }
+
+  private def tryExtractSelfJoin(plan: LogicalPlan): Option[Join] = {
+    val join = plan match {
+      case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => j
+      case j: Join if j.joinType == Inner && j.condition.isDefined => j
+      case _ => return None
+    }
+    val l = join.left
+    val r = join.right
+    val cond = join.condition.get
+    if (!isSameBaseRelation(l, r)) return None
+    val parsed = parseSelfJoinCondition(cond, l, r)
+    if (parsed.isEmpty) return None
+    Some(join)
+  }
+
+  /**
+   * Pattern A: The LeftSemi/LeftAnti right child is itself an Inner self-join (with or without a
+   * wrapping Project). The semi-join condition is pure equi. We replace the right child with GROUP
+   * BY + HAVING COUNT(DISTINCT) > 1.
+   *
+   * Matches q95 structure: Join(LeftSemi, left = outer query (filtered web_sales + dims), right =
+   * Join(Inner, ws_L, ws_R, equi(order_number) AND neq(warehouse_sk)), condition =
+   * outer.order_number = inner.order_number)
+   */
+  private def tryRewriteSemiWithSelfJoinChild(
+      original: Join,
+      left: LogicalPlan,
+      right: LogicalPlan,
+      joinType: JoinType,
+      semiCondition: Expression,
+      hint: JoinHint): Option[LogicalPlan] = {
+
+    // Unwrap the right child: may be Project(Join(Inner,...)) or bare Join(Inner,...)
+    val (innerJoin, wrapper) = right match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (j, Some(p))
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (j, None)
+      case _ => return None
+    }
+
+    // Use field accessors (portable across Spark versions) instead of
+    // case-class unapply with a fixed parameter list. The caller has already
+    // pattern-matched innerJoin as Join(_, _, Inner, Some(_), _).
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    // Both sides of inner join must scan the same base relation
+    if (!isSameBaseRelation(innerLeft, innerRight)) return None
+
+    // Parse inner join condition: must be equi + neq only
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    val (innerEquiPairs, innerNeqPairs) = parsed.get
+
+    // The semi-join condition must reference an output attribute from the inner join
+    // that corresponds to an equi-key. This is how the outer query connects to the CTE.
+    val semiPreds = splitConjunctivePredicates(semiCondition)
+    val rightOutputSet = right.outputSet
+
+    // All semi predicates must be pure equi-join (no inequality in semi condition)
+    val semiEquiPairs = semiPreds.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if left.outputSet.contains(l) && rightOutputSet.contains(r) => (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if left.outputSet.contains(l) && rightOutputSet.contains(r) => (l, r)
+    }
+    if (semiEquiPairs.size != semiPreds.size) return None
+    if (semiEquiPairs.isEmpty) return None
+
+    // The semi-join right-side keys must be derivable from the inner join's equi-keys
+    val innerEquiLeftAttrIds =
+      innerEquiPairs.map(_._1).collect { case a: Attribute => a.exprId }.toSet
+    val innerEquiRightAttrIds =
+      innerEquiPairs.map(_._2).collect { case a: Attribute => a.exprId }.toSet
+    val innerEquiAllIds = innerEquiLeftAttrIds ++ innerEquiRightAttrIds
+
+    // Check that all semi-join right keys reference inner equi-key attributes
+    val semiRightKeys = semiEquiPairs.map(_._2)
+    // After ColumnPruning, the right side might output only the equi-key columns
+    // The semi right keys must be from the inner join's equi-key set
+    val rightKeyIds = semiRightKeys.map(_.exprId).toSet
+    val validSemiKeys = rightKeyIds.forall {
+      id =>
+        innerEquiAllIds.contains(id) || {
+          // Semi-key may be exposed via a wrapper Project. For an Alias,
+          // the alias's *output* ExprId must match the caller's `id`, AND
+          // the underlying attribute must be an inner equi-key. Using the
+          // alias's input ExprId here compared with `id` compares two
+          // unrelated ids (alias always mints a new output id).
+          wrapper.exists {
+            case Project(pl, _) =>
+              pl.exists {
+                case al @ Alias(a: Attribute, _) =>
+                  al.exprId == id && innerEquiAllIds.contains(a.exprId)
+                case a: Attribute =>
+                  a.exprId == id && innerEquiAllIds.contains(a.exprId)
+                case _ => false
+              }
+            case _ => false
+          }
+        }
+    }
+    if (!validSemiKeys) return None
+
+    // Build replacement: GROUP BY equi_keys HAVING COUNT(DISTINCT neq_col) > 1
+    val innerLeftEquiAttrs = innerEquiPairs.map(_._1).collect { case a: Attribute => a }
+    val innerNeqAttr = innerNeqPairs.head._1 match {
+      case a: Attribute => a
+      case _ => return None
+    }
+
+    val countDistinctExpr = AggregateExpression(
+      Count(Seq(innerNeqAttr)),
+      mode = Complete,
+      isDistinct = true,
+      filter = None,
+      NamedExpression.newExprId)
+    val countAlias = Alias(countDistinctExpr, "_gluten_rw_selfjoin_cnt_distinct")()
+
+    val groupingExprs: Seq[Expression] = innerLeftEquiAttrs
+    val aggExprs: Seq[NamedExpression] =
+      innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]) :+ countAlias
+    val aggregate = Aggregate(groupingExprs, aggExprs, innerLeft)
+
+    val filterExpr = GreaterThan(countAlias.toAttribute, Literal(1L, LongType))
+    val filtered = Filter(filterExpr, aggregate)
+
+    // Project only the equi-key columns (to match the original right-side output schema)
+    val projectedKeys = Project(
+      innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]),
+      filtered)
+
+    // Rebuild semi condition: map any exprId reachable from the old right side to
+    // the corresponding innerLeft equi attr. Cover three cases:
+    //  (1) direct innerRight equi attr
+    //  (2) innerLeft equi attr (identity)
+    //  (3) wrapper Project's output attr backed by Alias(innerEquiAttr, _) - new exprId
+    val innerRightEquiAttrs = innerEquiPairs.map(_._2).collect { case a: Attribute => a }
+    val nameToInnerLeft: Map[String, Attribute] =
+      innerLeftEquiAttrs.map(a => a.name -> a).toMap
+    val wrapperRemap: Map[ExprId, Attribute] = wrapper match {
+      case Some(Project(pl, _)) =>
+        pl.flatMap {
+          case al @ Alias(a: Attribute, _)
+              if (innerEquiLeftAttrIds.contains(a.exprId) ||
+                innerEquiRightAttrIds.contains(a.exprId)) &&
+                nameToInnerLeft.contains(a.name) =>
+            Some(al.exprId -> nameToInnerLeft(a.name))
+          case _ => None
+        }.toMap
+      case _ => Map.empty
+    }
+    val oldToNewMap: Map[ExprId, Attribute] =
+      innerRightEquiAttrs.map(_.exprId).zip(innerLeftEquiAttrs).toMap ++
+        innerLeftEquiAttrs.map(a => a.exprId -> a).toMap ++
+        wrapperRemap
+
+    // Safety: every attr in semiCondition that came from the old right output must
+    // resolve to something in oldToNewMap. If not, refuse to rewrite.
+    val unresolved = semiCondition.collect {
+      case a: Attribute if rightOutputSet.contains(a) && !oldToNewMap.contains(a.exprId) => a
+    }
+    if (unresolved.nonEmpty) return None
+
+    val newSemiCondition = semiCondition.transformUp {
+      case a: Attribute if oldToNewMap.contains(a.exprId) && rightOutputSet.contains(a) =>
+        oldToNewMap(a.exprId)
+    }
+
+    val newJoin = original.copy(
+      right = projectedKeys,
+      condition = Some(newSemiCondition))
+
+    logInfo(
+      s"RewriteSelfJoinInequalityToAggregate: Pattern A - rewrote $joinType with Inner " +
+        s"self-join child to $joinType + GROUP BY HAVING COUNT(DISTINCT) > 1. " +
+        s"equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}], " +
+        s"neqCol=${innerNeqAttr.name}")
+
+    Some(newJoin)
+  }
+
+  /**
+   * Parse a join condition into equi-pairs and inequality-pairs. Accepts only: EqualTo(attr, attr)
+   * and Not(EqualTo(attr, attr)). Ignores IsNotNull predicates (added by
+   * InferFiltersFromConstraints). Returns None if there are unrecognized predicates beyond equi +
+   * neq + IsNotNull.
+   */
+  private def parseSelfJoinCondition(
+      condition: Expression,
+      left: LogicalPlan,
+      right: LogicalPlan): Option[(Seq[(Attribute, Attribute)], Seq[(Attribute, Attribute)])] = {
+
+    val leftOutput = left.outputSet
+    val rightOutput = right.outputSet
+    val predicates = splitConjunctivePredicates(condition)
+
+    val equiPairs = predicates.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    val neqPairs = predicates.collect {
+      case Not(EqualTo(l: Attribute, r: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case Not(EqualTo(r: Attribute, l: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    // Only IsNotNull predicates on equi/neq attributes are safe to drop
+    // (they're redundant with the join semantics or auto-added by
+    // InferFiltersFromConstraints). IsNotNull on other columns would be
+    // silently dropped by the rewrite and change query semantics -- bail out.
+    val joinAttrIds: Set[org.apache.spark.sql.catalyst.expressions.ExprId] =
+      (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+
+    val isNotNullOnJoinCols = predicates.count {
+      case IsNotNull(a: Attribute) if joinAttrIds.contains(a.exprId) => true
+      case _ => false
+    }
+
+    // Accept only equi + neq + IsNotNull-on-join-cols; reject anything else.
+    val totalMatched = equiPairs.size + neqPairs.size + isNotNullOnJoinCols
+    if (totalMatched != predicates.size) return None
+
+    if (equiPairs.isEmpty || neqPairs.isEmpty) return None
+
+    // Only rewrite single-inequality case: COUNT(DISTINCT single_col) > 1 semantics
+    // is strictly wider than "exists row with ALL of multiple != predicates".
+    if (neqPairs.size != 1) return None
+
+    // Self-join invariant: equi and neq must be on same-named columns from both sides.
+    val equiValid = equiPairs.forall { case (l, r) => l.name == r.name }
+    val neqValid = neqPairs.forall { case (l, r) => l.name == r.name }
+    if (!equiValid || !neqValid) return None
+
+    Some((equiPairs, neqPairs))

Review Comment:
   The rewrite can become incorrect if the inequality column is also part of the equi-key set (e.g., `t1.k = t2.k AND t1.k <> t2.k`), where the original join is unsatisfiable but `COUNT(DISTINCT k) > 1` may still pass. Add an explicit guard to reject cases where the neq attribute name/ExprId overlaps any equi-key attribute (on either side) before allowing the rewrite.



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,622 @@
+/*
+ * 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.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 (post-RewritePredicateSubquery fallback): LeftSemi/LeftAnti whose right child is an
+ * Inner self-join (possibly wrapped in Project). The outer semi-join condition is pure equi-key
+ * referencing a column from the self-join output. The self-join condition has equi + inequality on
+ * the same table. Rewrites the right child to GROUP BY + HAVING.
+ *
+ * Pattern A' (pre-RewritePredicateSubquery, primary path): InSubquery(_, ListQuery(sub, ...)) or
+ * Exists(sub, ...) whose sub is Project(Inner self-join with equi+neq). Rewrites sub to
+ * Project(equi_keys, Filter(count_distinct>1, Aggregate)). Fires in the Operator Optimization
+ * batches BEFORE Spark lifts them to LeftSemi/LeftAnti; the outer expression form guarantees
+ * existence semantics. ScalarSubquery is intentionally NOT matched.
+ *
+ * Pattern A2 (nested self-join): InSubquery/Exists whose subquery plan is Project(Join(Inner,
+ * other_table, self-join)) -- the self-join is a child of another InnerJoin, not the top-level join
+ * itself. Only the self-join child is replaced with Aggregate; the outer join is preserved. Safe
+ * because the outer join connects on the equi-key, and the subquery is still consumed as an
+ * existence set.
+ *
+ * Controlled by spark.gluten.sql.rewrite.selfJoinInequality (default false, opt-in until exercised
+ * more broadly across workloads).
+ */
+case class RewriteSelfJoinInequalityToAggregate(spark: SparkSession)
+  extends Rule[LogicalPlan]
+  with PredicateHelper
+  with Logging {
+
+  override def apply(plan: LogicalPlan): LogicalPlan = {
+    if (!VeloxConfig.get.enableRewriteSelfJoinInequality) {
+      logDebug("RewriteSelfJoinInequalityToAggregate: disabled via config, skipping")
+      return plan
+    }
+
+    val afterOps = plan.transformUp {
+      // Pattern A: LeftSemi/LeftAnti whose right child is an Inner self-join
+      // (possibly wrapped in Project).
+      case j: Join
+          if (j.joinType == LeftSemi || j.joinType == LeftAnti) &&
+            j.condition.isDefined &&
+            isInnerJoinShape(j.right) =>
+        tryRewriteSemiWithSelfJoinChild(j, j.left, j.right, j.joinType, j.condition.get, j.hint)
+          .getOrElse(j)
+
+      case other => other
+    }
+
+    // Pattern A': rewrite subquery plans embedded in InSubquery/Exists expressions.
+    // Fires before Spark's RewritePredicateSubquery (batch pos 26); once the subquery
+    // plan is rewritten to GROUP BY + HAVING, RewritePredicateSubquery lifts it to
+    // LeftSemi/LeftAnti in the normal way.
+    // Use type-based matching (`x: T`) and named-argument copy (`x.copy(plan = ...)`)
+    // instead of case-class unapply with a fixed parameter list. This keeps the code
+    // portable across Spark 3.3/3.4/3.5/4.x where the internal ListQuery/Exists
+    // case classes have added parameters over releases.
+    val rewritten = afterOps.transformAllExpressions {
+      case in @ InSubquery(_, lq: ListQuery) =>
+        rewriteSubqueryPlan(lq.plan) match {
+          case Some(newSub) => in.copy(query = lq.copy(plan = newSub))
+          case None => in
+        }
+      case ex: Exists =>
+        rewriteSubqueryPlan(ex.plan) match {
+          case Some(newSub) => ex.copy(plan = newSub)
+          case None => ex
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logInfo("RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+        "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }

Review Comment:
   Using `logInfo` for optimizer rewrites can be noisy in production (rules run per-query and can fire multiple times per plan/tree). Consider downgrading this to `logDebug` (and similarly for the per-pattern `logInfo` calls) or gating the info log behind an explicit debug/trace config so normal workloads don’t spam driver logs.



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,622 @@
+/*
+ * 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.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 (post-RewritePredicateSubquery fallback): LeftSemi/LeftAnti whose right child is an
+ * Inner self-join (possibly wrapped in Project). The outer semi-join condition is pure equi-key
+ * referencing a column from the self-join output. The self-join condition has equi + inequality on
+ * the same table. Rewrites the right child to GROUP BY + HAVING.
+ *
+ * Pattern A' (pre-RewritePredicateSubquery, primary path): InSubquery(_, ListQuery(sub, ...)) or
+ * Exists(sub, ...) whose sub is Project(Inner self-join with equi+neq). Rewrites sub to
+ * Project(equi_keys, Filter(count_distinct>1, Aggregate)). Fires in the Operator Optimization
+ * batches BEFORE Spark lifts them to LeftSemi/LeftAnti; the outer expression form guarantees
+ * existence semantics. ScalarSubquery is intentionally NOT matched.
+ *
+ * Pattern A2 (nested self-join): InSubquery/Exists whose subquery plan is Project(Join(Inner,
+ * other_table, self-join)) -- the self-join is a child of another InnerJoin, not the top-level join
+ * itself. Only the self-join child is replaced with Aggregate; the outer join is preserved. Safe
+ * because the outer join connects on the equi-key, and the subquery is still consumed as an
+ * existence set.
+ *
+ * Controlled by spark.gluten.sql.rewrite.selfJoinInequality (default false, opt-in until exercised
+ * more broadly across workloads).
+ */
+case class RewriteSelfJoinInequalityToAggregate(spark: SparkSession)
+  extends Rule[LogicalPlan]
+  with PredicateHelper
+  with Logging {
+
+  override def apply(plan: LogicalPlan): LogicalPlan = {
+    if (!VeloxConfig.get.enableRewriteSelfJoinInequality) {
+      logDebug("RewriteSelfJoinInequalityToAggregate: disabled via config, skipping")
+      return plan
+    }
+
+    val afterOps = plan.transformUp {
+      // Pattern A: LeftSemi/LeftAnti whose right child is an Inner self-join
+      // (possibly wrapped in Project).
+      case j: Join
+          if (j.joinType == LeftSemi || j.joinType == LeftAnti) &&
+            j.condition.isDefined &&
+            isInnerJoinShape(j.right) =>
+        tryRewriteSemiWithSelfJoinChild(j, j.left, j.right, j.joinType, j.condition.get, j.hint)
+          .getOrElse(j)
+
+      case other => other
+    }
+
+    // Pattern A': rewrite subquery plans embedded in InSubquery/Exists expressions.
+    // Fires before Spark's RewritePredicateSubquery (batch pos 26); once the subquery
+    // plan is rewritten to GROUP BY + HAVING, RewritePredicateSubquery lifts it to
+    // LeftSemi/LeftAnti in the normal way.
+    // Use type-based matching (`x: T`) and named-argument copy (`x.copy(plan = ...)`)
+    // instead of case-class unapply with a fixed parameter list. This keeps the code
+    // portable across Spark 3.3/3.4/3.5/4.x where the internal ListQuery/Exists
+    // case classes have added parameters over releases.
+    val rewritten = afterOps.transformAllExpressions {
+      case in @ InSubquery(_, lq: ListQuery) =>
+        rewriteSubqueryPlan(lq.plan) match {
+          case Some(newSub) => in.copy(query = lq.copy(plan = newSub))
+          case None => in
+        }
+      case ex: Exists =>
+        rewriteSubqueryPlan(ex.plan) match {
+          case Some(newSub) => ex.copy(plan = newSub)
+          case None => ex
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logInfo("RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+        "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+
+  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
+  }
+
+  /**
+   * Dispatches subquery plan rewriting: tries Pattern A' (direct self-join at top level) first,
+   * then Pattern A2 (self-join nested as a child of another InnerJoin).
+   *
+   * Only called on subquery plans of `InSubquery` / `Exists`, i.e. contexts that consume the output
+   * as a set of distinct keys. Cardinality of the intermediate is safe to change.
+   */
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(plan, projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(plan, projectListOpt, innerJoin)
+    }
+  }
+
+  private def rewriteDirectSelfJoin(
+      plan: LogicalPlan,
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+
+    // Use field accessors (portable across Spark versions) instead of
+    // case-class unapply with a fixed parameter list. The caller has already
+    // pattern-matched innerJoin as Join(_, _, Inner, Some(_), _).
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs = equiPairs.map(_._1).collect { case a: Attribute => a }
+    if (innerLeftEquiAttrs.size != equiPairs.size) return None
+
+    val innerLeftNeqAttr = neqPairs.head._1 match {
+      case a: Attribute => a
+      case _ => return None
+    }
+
+    val countDistinctExpr = AggregateExpression(
+      Count(Seq(innerLeftNeqAttr)),
+      mode = Complete,
+      isDistinct = true,
+      filter = None,
+      NamedExpression.newExprId)
+    val countAlias = Alias(countDistinctExpr, "_gluten_rw_selfjoin_cnt_distinct")()
+
+    val groupingExprs: Seq[Expression] = innerLeftEquiAttrs
+    val aggExprs: Seq[NamedExpression] =
+      innerLeftEquiAttrs.map(_.asInstanceOf[NamedExpression]) :+ countAlias
+    val aggregate = Aggregate(groupingExprs, aggExprs, innerLeft)
+
+    val filterExpr = GreaterThan(countAlias.toAttribute, Literal(1L, LongType))
+    val filtered = Filter(filterExpr, aggregate)

Review Comment:
   The `AggregateExpression(Count DISTINCT) + Filter(GreaterThan(..., 1))` construction is duplicated in multiple paths (direct self-join, nested self-join, and semi/anti-join rewrite). Extracting a small helper (e.g., buildAggregateHavingDistinctGt1(groupKeys, neqAttr, childPlan)) would reduce repetition and the risk of future drift across patterns.



##########
backends-velox/src/test/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregateSuite.scala:
##########
@@ -0,0 +1,225 @@
+/*
+ * 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.execution.WholeStageTransformerSuite
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.catalyst.plans.{Inner, LeftOuter}
+import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, Join, LogicalPlan}
+import org.apache.spark.sql.internal.SQLConf
+
+class RewriteSelfJoinInequalityToAggregateSuite extends WholeStageTransformerSuite {
+
+  override protected val resourcePath: String = "/tpch-data-parquet"
+  override protected val fileFormat: String = "parquet"
+
+  override protected def sparkConf: SparkConf = super.sparkConf
+    .set("spark.gluten.sql.rewrite.selfJoinInequality", "true")
+    .set(SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key, "-1")
+
+  private def hasAggregate(plan: LogicalPlan): Boolean =
+    plan.exists(_.isInstanceOf[Aggregate])
+  private def hasInnerJoin(plan: LogicalPlan): Boolean =
+    plan.exists {
+      case j: Join if j.joinType == Inner => true
+      case _ => false
+    }
+
+  private def setupTable(): Unit = {
+    // k=1: distinct v={10,20}      -> matches (has 2 non-null distinct)
+    // k=2: distinct v={30}         -> no match (only 1)
+    // k=3: distinct v={40,50,60}   -> matches
+    // k=4: v={70, NULL}            -> no match (only 1 non-null)
+    // k=5: v={NULL, NULL}          -> no match (0 non-null)
+    // k=6: v={80, 90, NULL}        -> matches
+    spark.sql(
+      """CREATE OR REPLACE TEMP VIEW T AS SELECT * FROM VALUES
+        |  (1, 10), (1, 10), (1, 20),
+        |  (2, 30),
+        |  (3, 40), (3, 50), (3, 60),
+        |  (4, 70), (4, CAST(NULL AS INT)),
+        |  (5, CAST(NULL AS INT)), (5, CAST(NULL AS INT)),
+        |  (6, 80), (6, 90), (6, CAST(NULL AS INT))
+        |AS T(k, v)""".stripMargin)
+  }
+
+  // ==================== Positive: rewrite fires ====================
+
+  test("Pattern A': EXISTS whose subquery is a bare self-join -> rewrite fires") {
+    setupTable()
+    val sql =
+      """SELECT k FROM T ws1 WHERE EXISTS (
+        |  SELECT 1 FROM T s WHERE s.k = ws1.k AND s.v <> ws1.v)""".stripMargin
+    val plan = spark.sql(sql).queryExecution.optimizedPlan
+    assert(hasAggregate(plan), s"expected Aggregate in plan:\n$plan")
+  }
+
+  test("Pattern A': InSubquery whose subquery is a bare self-join -> rewrite fires") {
+    setupTable()
+    val sql =
+      """SELECT k FROM T ws1 WHERE k IN (
+        |  SELECT s1.k FROM T s1, T s2
+        |  WHERE s1.k = s2.k AND s1.v <> s2.v)""".stripMargin
+    val plan = spark.sql(sql).queryExecution.optimizedPlan
+    assert(hasAggregate(plan))
+  }
+
+  test("Pattern A2: self-join nested inside outer InnerJoin -> rewrite fires") {
+    setupTable()
+    // A separate small dimension table D. The subquery inner-joins D against
+    // the self-join on the equi-key (k). Only the self-join subtree should be
+    // replaced by Aggregate; the outer InnerJoin with D is preserved.
+    spark.sql(
+      """CREATE OR REPLACE TEMP VIEW D AS SELECT * FROM VALUES
+        |  (1), (3), (6) AS D(k)""".stripMargin)
+    val sql =
+      """SELECT k FROM T outer_t WHERE k IN (
+        |  SELECT d.k
+        |  FROM D d, (SELECT s1.k FROM T s1, T s2
+        |             WHERE s1.k = s2.k AND s1.v <> s2.v) sj
+        |  WHERE d.k = sj.k)""".stripMargin
+    val plan = spark.sql(sql).queryExecution.optimizedPlan
+    assert(
+      hasAggregate(plan),
+      s"Pattern A2 should introduce Aggregate to replace nested self-join:\n$plan")
+  }
+
+  // ==================== Semantic parity ====================
+
+  test("Semantic parity: NULL/3VL results match baseline (rule on vs off)") {
+    setupTable()
+    val sql =
+      """SELECT k FROM T ws1 WHERE EXISTS (
+        |  SELECT 1 FROM T s WHERE s.k = ws1.k AND s.v <> ws1.v)""".stripMargin
+    var on: Set[Int] = null
+    var off: Set[Int] = null
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "true") {
+      on = spark.sql(sql).collect().map(_.getInt(0)).toSet
+    }
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "false") {
+      off = spark.sql(sql).collect().map(_.getInt(0)).toSet
+    }
+    assert(on == off, s"rewrite $on != baseline $off")
+    // Ground truth: only k=1, k=3, k=6 have >=2 non-null distinct v.
+    // k=4 (v={70,NULL}) fails: <> with NULL is UNKNOWN -> filtered.
+    // k=5 (v={NULL,NULL}) fails: all UNKNOWN.
+    assert(on == Set(1, 3, 6), s"expected {1,3,6}, got $on")
+  }
+
+  // ==================== Negative: rewrite must NOT fire ====================
+
+  test("No fire: plain InnerJoin at top level (multiplicity matters)") {
+    setupTable()
+    val sql =
+      """SELECT ws1.k FROM T ws1 JOIN T ws2
+        |ON ws1.k = ws2.k AND ws1.v <> ws2.v""".stripMargin
+    val plan = spark.sql(sql).queryExecution.optimizedPlan
+    assert(
+      hasInnerJoin(plan),
+      s"plain InnerJoin should NOT be rewritten (row multiplicity matters):\n$plan")
+    val onCount = spark.sql(sql).count()
+    var offCount: Long = 0L
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "false") {
+      offCount = spark.sql(sql).count()
+    }
+    assert(
+      onCount == offCount,
+      s"row count differs: rewrite=$onCount vs baseline=$offCount")
+  }
+
+  test("No fire: IS DISTINCT FROM (NULL-safe inequality has different semantics)") {
+    setupTable()
+    // IS DISTINCT FROM treats NULL as a distinct value (NULL IS DISTINCT FROM NULL = FALSE,
+    // NULL IS DISTINCT FROM x = TRUE). Rewrite must refuse: count(distinct) ignores NULL
+    // so the semantics differ.
+    val sql =
+      """SELECT k FROM T ws1 WHERE EXISTS (
+        |  SELECT 1 FROM T s WHERE s.k = ws1.k
+        |    AND (s.v IS DISTINCT FROM ws1.v))""".stripMargin
+    val plan = spark.sql(sql).queryExecution.optimizedPlan
+    // Expect the self-join InnerJoin to be preserved (rewrite refuses to fire)
+    // because the neq predicate is EqualNullSafe (wrapped in Not), not EqualTo.
+    assert(
+      hasInnerJoin(plan),
+      s"IS DISTINCT FROM should NOT be rewritten (NULL semantics differ):\n$plan")
+  }
+
+  test("No fire: IsNotNull on a non-join column") {
+    // Join condition contains IsNotNull on a column that is not part of equi/neq pairs.
+    // The rewrite would silently drop that filter, so it must bail out.
+    spark.sql(
+      """CREATE OR REPLACE TEMP VIEW T3 AS SELECT * FROM VALUES
+        |  (1, 10, 100), (1, 20, 200), (2, 30, CAST(NULL AS INT))
+        |AS T3(k, v, w)""".stripMargin)
+    // Craft a self-join whose condition explicitly references IsNotNull(w) --
+    // w is neither the equi key (k) nor the inequality column (v).
+    val sql =
+      """SELECT k FROM T3 outer_t WHERE EXISTS (
+        |  SELECT 1 FROM T3 s
+        |  WHERE s.k = outer_t.k AND s.v <> outer_t.v AND s.w IS NOT NULL)""".stripMargin
+    val plan = spark.sql(sql).queryExecution.optimizedPlan
+    // Rewrite must not fire: dropping IsNotNull(w) would keep NULL-w rows that the
+    // original query filters out. Original self-join InnerJoin should be preserved.
+    assert(
+      hasInnerJoin(plan),
+      s"IsNotNull on non-join column should NOT be rewritten:\n$plan")
+  }
+
+  test("No fire: multi-column inequality (v<>v AND w<>w)") {
+    spark.sql(
+      """CREATE OR REPLACE TEMP VIEW T2 AS SELECT * FROM VALUES
+        |  (1, 10, 100), (1, 20, 200), (2, 30, 300)
+        |AS T2(k, v, w)""".stripMargin)
+    val sql =
+      """SELECT k FROM T2 ws1 WHERE EXISTS (
+        |  SELECT 1 FROM T2 s WHERE s.k = ws1.k
+        |    AND s.v <> ws1.v AND s.w <> ws1.w)""".stripMargin
+    val plan = spark.sql(sql).queryExecution.optimizedPlan
+    assert(
+      hasInnerJoin(plan),
+      s"multi-column inequality should NOT be rewritten:\n$plan")
+  }
+
+  test("No fire: LeftOuter join is outside existence context") {
+    setupTable()
+    val sql =
+      """SELECT ws1.k FROM T ws1 LEFT OUTER JOIN T ws2
+        |ON ws1.k = ws2.k AND ws1.v <> ws2.v""".stripMargin
+    val plan = spark.sql(sql).queryExecution.optimizedPlan
+    assert(
+      plan.exists {
+        case Join(_, _, LeftOuter, _, _) => true
+        case _ => false
+      },
+      s"LeftOuter should be preserved:\n$plan")
+  }
+
+  test("Config gate: rule disabled by spark.gluten.sql.rewrite.selfJoinInequality=false") {
+    setupTable()
+    val sql =
+      """SELECT k FROM T ws1 WHERE EXISTS (
+        |  SELECT 1 FROM T s WHERE s.k = ws1.k AND s.v <> ws1.v)""".stripMargin
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "false") {
+      val plan = spark.sql(sql).queryExecution.optimizedPlan
+      // Original plan should still contain the self-join; no Aggregate rewrite.
+      assert(
+        hasInnerJoin(plan) || !hasAggregate(plan),
+        s"config off should not fire rewrite:\n$plan")
+    }

Review Comment:
   This assertion is too weak: `hasInnerJoin(plan) || !hasAggregate(plan)` can pass even if the rewrite incorrectly introduced an Aggregate as long as some InnerJoin still exists in the optimized plan. Make this stricter by asserting both that the expected InnerJoin is present and that no Aggregate rewrite was introduced for this query (e.g., `hasInnerJoin(plan) && !hasAggregate(plan)`).



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on PR #12756:
URL: https://github.com/apache/gluten/pull/12756#issuecomment-5584678684

   Hi @LuciferYang @beliefer @zhouyuan, latest commit covers the remaining points, CI now green:
     - Preserve qualifier / explicitMetadata / nonInheritableMetadataKeys when rebuilding wrapper aliases (per the Alias-metadata comment).
     - Add provable bare-Join arity-guard coverage for both A' and A2.
     - Verify the rewritten aggregate is actually offloaded to Velox.
     - Trim rule and test comments.
     Please take another look, thanks!


-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "jackylee-ch (via GitHub)" <gi...@apache.org>.
jackylee-ch commented on PR #12756:
URL: https://github.com/apache/gluten/pull/12756#issuecomment-5311399663

   Thanks for the PR! It looks like this change introduces a dedicated optimizer rule. Have you considered contributing this optimization upstream to the Spark community as well? It seems fairly generic and potentially useful beyond Gluten.


-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3823030189


##########
backends-velox/src/test/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregateSuite.scala:
##########
@@ -0,0 +1,446 @@
+/*
+ * 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.execution.WholeStageTransformerSuite
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.catalyst.expressions.Alias
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+import org.apache.spark.sql.internal.SQLConf
+
+/**
+ * Correctness tests for [[RewriteSelfJoinInequalityToAggregate]].
+ *
+ * Assertions center on **result equivalence** between `rewrite=true` and `rewrite=false`. That is
+ * the direct check of the rule's semantic contract: the rewrite must not change what the query
+ * returns. Plan-shape assertions (e.g. "an Aggregate node exists") are avoided because downstream
+ * optimizer rules differ across Spark versions -- e.g. Spark 4.0's `RewritePredicateSubquery` and
+ * constant folding collapse LocalRelation-based EXISTS bodies so aggressively that our rule may
+ * never see the original self-join shape yet the final result is still correct.
+ *
+ * Where a plan-level signal is useful, we look for the alias name our rule injects
+ * (`_gluten_rw_selfjoin_cnt_distinct`) as a soft indicator that the rule fired. Its absence is not
+ * treated as a failure -- an equivalent result via a different path is still a pass.
+ */
+class RewriteSelfJoinInequalityToAggregateSuite extends WholeStageTransformerSuite {
+
+  override protected val resourcePath: String = "/tpch-data-parquet"
+  override protected val fileFormat: String = "parquet"
+
+  override protected def sparkConf: SparkConf = super.sparkConf
+    .set("spark.gluten.sql.rewrite.selfJoinInequality", "true")
+    .set(SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key, "-1")
+
+  /** Signature alias produced by the rewrite; presence => rule definitely fired. */
+  private val CountDistinctAlias = "_gluten_rw_selfjoin_cnt_distinct"
+
+  private def ruleFired(plan: LogicalPlan): Boolean =
+    plan.exists {
+      p =>
+        p.expressions.exists(_.exists {
+          case a: Alias if a.name == CountDistinctAlias => true
+          case _ => false
+        })
+    }
+
+  /** Run `sql` twice, first with rewrite ON then OFF, and return the two result row sets. */
+  private def runBoth(sql: String): (Set[Row], Set[Row]) = {
+    var on: Set[Row] = null
+    var off: Set[Row] = null
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "true") {
+      on = spark.sql(sql).collect().toSet
+    }
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "false") {
+      off = spark.sql(sql).collect().toSet
+    }
+    (on, off)
+  }
+
+  private def setupTable(): Unit = {
+    // k=1: distinct v={10,20}      -> matches (has 2 non-null distinct)
+    // k=2: distinct v={30}         -> no match (only 1)
+    // k=3: distinct v={40,50,60}   -> matches
+    // k=4: v={70, NULL}            -> no match (only 1 non-null)
+    // k=5: v={NULL, NULL}          -> no match (0 non-null)
+    // k=6: v={80, 90, NULL}        -> matches
+    spark.sql(
+      """CREATE OR REPLACE TEMP VIEW T AS SELECT * FROM VALUES
+        |  (1, 10), (1, 10), (1, 20),
+        |  (2, 30),
+        |  (3, 40), (3, 50), (3, 60),
+        |  (4, 70), (4, CAST(NULL AS INT)),
+        |  (5, CAST(NULL AS INT)), (5, CAST(NULL AS INT)),
+        |  (6, 80), (6, 90), (6, CAST(NULL AS INT))
+        |AS T(k, v)""".stripMargin)
+  }
+
+  // ==================== Positive: rewrite is semantically equivalent ====================
+
+  test("Pattern A': EXISTS subquery with bare self-join produces equivalent results") {
+    setupTable()
+    val sql =
+      """SELECT k FROM T ws1 WHERE EXISTS (
+        |  SELECT 1 FROM T s WHERE s.k = ws1.k AND s.v <> ws1.v)""".stripMargin
+    val (on, off) = runBoth(sql)
+    assert(on == off, s"rewrite ON $on != OFF $off")
+    // Ground truth: only k in {1,3,6} have >=2 non-null distinct v.
+    assert(on == Set(Row(1), Row(3), Row(6)), s"expected {1,3,6}, got $on")
+  }
+
+  test("Pattern A': InSubquery with bare self-join produces equivalent results") {
+    setupTable()
+    val sql =
+      """SELECT k FROM T ws1 WHERE k IN (
+        |  SELECT s1.k FROM T s1, T s2
+        |  WHERE s1.k = s2.k AND s1.v <> s2.v)""".stripMargin
+    val (on, off) = runBoth(sql)
+    assert(on == off, s"rewrite ON $on != OFF $off")
+    assert(on == Set(Row(1), Row(3), Row(6)))
+  }
+
+  test("Pattern A2: self-join nested inside outer InnerJoin produces equivalent results") {
+    setupTable()
+    // Only k in {1,3,6} qualify from the self-join side; the outer InnerJoin with D
+    // (values {1,3,6}) intersects, so the final answer is again {1,3,6}.
+    spark.sql(
+      """CREATE OR REPLACE TEMP VIEW D AS SELECT * FROM VALUES
+        |  (1), (3), (6) AS D(k)""".stripMargin)
+    val sql =
+      """SELECT k FROM T outer_t WHERE k IN (
+        |  SELECT d.k
+        |  FROM D d, (SELECT s1.k FROM T s1, T s2
+        |             WHERE s1.k = s2.k AND s1.v <> s2.v) sj
+        |  WHERE d.k = sj.k)""".stripMargin
+    val (on, off) = runBoth(sql)
+    assert(on == off, s"Pattern A2 rewrite ON $on != OFF $off")
+    assert(on == Set(Row(1), Row(3), Row(6)))
+  }
+
+  // ==================== Semantic parity on NULL / 3VL ====================
+
+  test("NULL / 3VL: rows with only-NULL or single-non-null inequality column are excluded") {
+    setupTable()
+    val sql =
+      """SELECT k FROM T ws1 WHERE EXISTS (
+        |  SELECT 1 FROM T s WHERE s.k = ws1.k AND s.v <> ws1.v)""".stripMargin
+    val (on, off) = runBoth(sql)
+    // k=4 (v={70,NULL}) fails: <> with NULL is UNKNOWN -> filtered by WHERE.
+    // k=5 (v={NULL,NULL}) fails: every <> is UNKNOWN.
+    assert(on == Set(Row(1), Row(3), Row(6)), s"expected {1,3,6}, got $on")
+    assert(off == on, s"NULL/3VL semantics diverge between rewrite ON and OFF: $on vs $off")
+  }
+
+  // ==================== Negative: rewrite must produce equivalent results (or bail) ==========
+
+  test("Plain InnerJoin at top level: results unchanged (rewrite must not touch it)") {
+    setupTable()
+    val sql =
+      """SELECT ws1.k FROM T ws1 JOIN T ws2
+        |ON ws1.k = ws2.k AND ws1.v <> ws2.v""".stripMargin
+    // Row-multiplicity matters here; using count() to catch any drop or dup.
+    var onCount: Long = -1L
+    var offCount: Long = -1L
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "true") {
+      onCount = spark.sql(sql).count()
+    }
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "false") {
+      offCount = spark.sql(sql).count()
+    }
+    assert(
+      onCount == offCount,
+      s"plain InnerJoin row-count differs: rewrite=$onCount vs baseline=$offCount")
+  }
+
+  test("IS DISTINCT FROM: NULL-safe inequality preserves original semantics") {
+    setupTable()
+    // IS DISTINCT FROM treats NULL as distinguishable (NULL IS DISTINCT FROM x = TRUE,
+    // NULL IS DISTINCT FROM NULL = FALSE). Our rewrite must NOT fold this into
+    // COUNT(DISTINCT), because COUNT(DISTINCT) ignores NULL.
+    val sql =
+      """SELECT k FROM T ws1 WHERE EXISTS (
+        |  SELECT 1 FROM T s WHERE s.k = ws1.k
+        |    AND (s.v IS DISTINCT FROM ws1.v))""".stripMargin
+    val (on, off) = runBoth(sql)
+    assert(on == off, s"IS DISTINCT FROM semantics diverge: ON=$on OFF=$off")
+    // Sanity check: k=4 has (70, NULL) -- pair (v=70, v=NULL) IS DISTINCT FROM => TRUE
+    // so k=4 must be included (unlike the plain-neq case above where it's excluded).
+    assert(on.contains(Row(4)), s"k=4 should be in IS DISTINCT FROM result: $on")
+    // And rule fire signal must be absent: this is a rejection path.
+    val plan = spark.sql(sql).queryExecution.optimizedPlan
+    assert(!ruleFired(plan), s"rule must not fire on IS DISTINCT FROM:\n$plan")

Review Comment:
   I'll rework the negative cases so they are uncorrelated and contain an actual self-join, ensuring each test reaches the guard it is intended to validate. I'll also add assert(ruleFired(plan)) to the positive A'/A2 cases, and add a NULL equi-key + NOT IN regression for the IsNotNull(equiKeys) protection.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3820274414


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

Review Comment:
   Thanks a lot for the review and the approval! Good question — let me spell out the query shapes for the other two patterns.
   
   q95 actually has **two** `IN` subqueries, and they hit different patterns. Both reference the same CTE, so I inlined it below — otherwise the two look identical:
   
   ```sql
   -- ws_wh = self-join:  web_sales a, web_sales b
   --                     where a.ws_order_number = b.ws_order_number
   --                       and a.ws_warehouse_sk <> b.ws_warehouse_sk
   
   -- Pattern A': the self-join IS the subquery
   and ws1.ws_order_number in (select ws_order_number from <ws_wh>)
   
   -- Pattern A2: the self-join is a child of another InnerJoin
   and ws1.ws_order_number in (select wr_order_number
                               from web_returns, <ws_wh>
                               where wr_order_number = ws_wh.ws_order_number)
   ```
   
   A' replaces the whole subquery plan, while A2 replaces only the self-join child and has to preserve the outer join plus its wrapping `Project`'s exprIds — which is why it needs a separate code path.
   
   Both subqueries become `LeftSemi` hash builds, and at SF=300 they are the two biggest build sides in the query:
   
   | | pattern | build input OFF | ON | share of hash-build cost |
   |---|---|---|---|---|
   | first `IN` | A' | 2.35 B rows / 80 GiB | 18 M rows | ~60% |
   | second `IN` | A2 | 3.08 B rows / 91 GiB | 22 M rows | ~40% |
   
   So A2 isn't a speculative generalization — dropping it would leave the larger of the two build sides untouched and give up roughly 40% of the gain. More generally, A2 is the shape you get whenever the "more than one distinct X" set is narrowed by another table before feeding the `IN`.
   
   **Pattern A** is just A' with the semi/anti join written explicitly rather than derived from a subquery:
   
   ```sql
   select ws1.ws_order_number from web_sales ws1
   left semi join (select ws_order_number from <ws_wh>) x
     on ws1.ws_order_number = x.ws_order_number
   ```
   
   Same build side, same reduction, same guards — the two are symmetric. TPC-DS and TPC-H don't match this one only because they write every existence check as `IN` / `EXISTS`, whereas hand-written and BI-generated SQL fairly often uses explicit `LEFT SEMI` / `LEFT ANTI JOIN`.
   
   Hope that clarifies it — happy to go into more detail on any of the three if useful.
   



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3820277363


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

Review Comment:
   > since we already address the main issue on q95, could you please clarify what are the typical quereis for the other two patterns?
   
   Thanks a lot for the review and the approval! Good question — let me spell out the query shapes for the other two patterns.
   
   q95 actually has **two** `IN` subqueries, and they hit different patterns. Both reference the same CTE, so I inlined it below — otherwise the two look identical:
   
   ```sql
   -- ws_wh = self-join:  web_sales a, web_sales b
   --                     where a.ws_order_number = b.ws_order_number
   --                       and a.ws_warehouse_sk <> b.ws_warehouse_sk
   
   -- Pattern A': the self-join IS the subquery
   and ws1.ws_order_number in (select ws_order_number from <ws_wh>)
   
   -- Pattern A2: the self-join is a child of another InnerJoin
   and ws1.ws_order_number in (select wr_order_number
                               from web_returns, <ws_wh>
                               where wr_order_number = ws_wh.ws_order_number)
   ```
   
   A' replaces the whole subquery plan, while A2 replaces only the self-join child and has to preserve the outer join plus its wrapping `Project`'s exprIds — which is why it needs a separate code path.
   
   Both subqueries become `LeftSemi` hash builds, and at SF=300 they are the two biggest build sides in the query:
   
   | | pattern | build input OFF | ON | share of hash-build cost |
   |---|---|---|---|---|
   | first `IN` | A' | 2.35 B rows / 80 GiB | 18 M rows | ~60% |
   | second `IN` | A2 | 3.08 B rows / 91 GiB | 22 M rows | ~40% |
   
   So A2 isn't a speculative generalization — dropping it would leave the larger of the two build sides untouched and give up roughly 40% of the gain. More generally, A2 is the shape you get whenever the "more than one distinct X" set is narrowed by another table before feeding the `IN`.
   
   **Pattern A** is just A' with the semi/anti join written explicitly rather than derived from a subquery:
   
   ```sql
   select ws1.ws_order_number from web_sales ws1
   left semi join (select ws_order_number from <ws_wh>) x
     on ws1.ws_order_number = x.ws_order_number
   ```
   
   Same build side, same reduction, same guards — the two are symmetric. TPC-DS and TPC-H don't match this one only because they write every existence check as `IN` / `EXISTS`, whereas hand-written and BI-generated SQL fairly often uses explicit `LEFT SEMI` / `LEFT ANTI JOIN`.
   
   Hope that clarifies it — happy to go into more detail on any of the three if useful.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3931194757


##########
backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxRuleApi.scala:
##########
@@ -58,6 +58,7 @@ object VeloxRuleApi {
     injector.injectOptimizerRule(CollapseGetJsonObjectExpressionRule.apply)
     injector.injectOptimizerRule(RewriteCastFromArray.apply)
     injector.injectOptimizerRule(RewriteUnboundedWindow.apply)
+    injector.injectOptimizerRule(RewriteSelfJoinInequalityToAggregate.apply)

Review Comment:
   Thank you for pointing that. I’ll update the PR description to 30 tests and clarify that repeated optimizer application is safe because the rewritten shape no longer matches.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3901740398


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,727 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Any other NamedExpression shape we
+   * do not rewrite is left as-is ONLY if it does not reference a replaced self-join output;
+   * otherwise it would carry a stale ExprId, so returns None to fail the whole rewrite closed.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): Option[NamedExpression] = ne match {
+    case a: Attribute if remap.contains(a.exprId) => Some(remap(a.exprId))
+    case a: Attribute => Some(a)
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      Some(
+        if (newChild eq al.child) al
+        else Alias(newChild, al.name)(al.exprId, al.qualifier, al.explicitMetadata))

Review Comment:
   When rebuilding an Alias after remapping attributes, the new Alias should preserve `nonInheritableMetadataKeys` as well (and in Spark versions where Alias’ constructor includes this parameter, omitting it can break compilation or drop metadata). This file already preserves all 4 Alias fields in other code paths (e.g., RewriteUnboundedWindow).



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3901980939


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Any other NamedExpression shape we
+   * do not rewrite is left as-is ONLY if it does not reference a replaced self-join output;
+   * otherwise it would carry a stale ExprId, so returns None to fail the whole rewrite closed.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): Option[NamedExpression] = ne match {
+    case a: Attribute if remap.contains(a.exprId) => Some(remap(a.exprId))
+    case a: Attribute => Some(a)
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      Some(
+        if (newChild eq al.child) {
+          al
+        } else {
+          Alias(newChild, al.name)(
+            al.exprId,
+            al.qualifier,
+            al.explicitMetadata,
+            al.nonInheritableMetadataKeys)
+        })
+    case other if other.references.exists(a => remap.contains(a.exprId)) =>
+      // Fail-closed: a NamedExpression we do not rewrite (neither a bare Attribute nor an Alias)
+      // that still references a replaced self-join output would be left with a dangling ExprId.
+      // Refuse the rewrite rather than emit a plan with a stale reference.
+      None
+    case other => Some(other)
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }

Review Comment:
   `rewriteSubqueryPlan` already rejects non-repeatable candidates via `isRepeatablePlan(plan)`, but `isSameBaseRelation` then re-walks both self-join sides with `isRepeatablePlan(left/right)` (and this happens multiple times on the nested path as well). This is a concrete extra traversal cost in the optimizer. Consider splitting `isSameBaseRelation` into (a) structural equality (`canonicalized`) and (b) repeatability, and in the subquery rewrite path only pay the repeatability walk once (or memoize `isRepeatablePlan` per subtree for the duration of `rewriteSubqueryPlan`).



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Any other NamedExpression shape we
+   * do not rewrite is left as-is ONLY if it does not reference a replaced self-join output;
+   * otherwise it would carry a stale ExprId, so returns None to fail the whole rewrite closed.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): Option[NamedExpression] = ne match {
+    case a: Attribute if remap.contains(a.exprId) => Some(remap(a.exprId))
+    case a: Attribute => Some(a)
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      Some(
+        if (newChild eq al.child) {
+          al
+        } else {
+          Alias(newChild, al.name)(
+            al.exprId,
+            al.qualifier,
+            al.explicitMetadata,
+            al.nonInheritableMetadataKeys)
+        })
+    case other if other.references.exists(a => remap.contains(a.exprId)) =>
+      // Fail-closed: a NamedExpression we do not rewrite (neither a bare Attribute nor an Alias)
+      // that still references a replaced self-join output would be left with a dangling ExprId.
+      // Refuse the rewrite rather than emit a plan with a stale reference.
+      None
+    case other => Some(other)
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    // Fail closed on an explicit join hint. This rule deletes the self-join outright; a user
+    // `/*+ BROADCAST(...) */` (or SHUFFLE_*/MERGE) is an explicit optimizer directive about THAT
+    // join, and an opt-in rewrite has no business silently discarding it. Not a correctness bug
+    // (hints do not change results), but the conservative maintainer choice. `JoinHint.NONE` exists
+    // across all Spark versions Gluten supports, so no shim is needed.
+    if (innerJoin.hint != JoinHint.NONE) return None
+
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map {
+          case (newWrapper, _) =>
+            logDebug(
+              s"Pattern A' - equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}]" +
+                s", neqCol=${innerLeftNeqAttr.name}" +
+                s", outCols=[${newWrapper.projectList.map(_.name).mkString(",")}]")
+            newWrapper
+        }
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A2 : self-join nested inside another InnerJoin in the subquery
+  // ============================================================================
+
+  private def rewriteNestedSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+    val outerCond = outerJoin.condition.get
+
+    val (selfJoinSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerJoin.right) match {
+        case Some(_) => (outerJoin.right, true)
+        case None =>
+          tryExtractSelfJoin(outerJoin.left) match {
+            case Some(_) => (outerJoin.left, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(p), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val sjLeftNeqAttr: Attribute = neqPairs.head._1
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val sjEquiExprIds: Set[ExprId] =
+      equiPairs.flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    // wrapper Project may reproject equi-keys under fresh alias exprIds; include those.
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt.toSeq.flatMap {
+      p =>
+        p.projectList.flatMap {
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) => Some(a.exprId)
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) => Some(al.exprId)
+          case _ => None
+        }
+    }.toSet
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+
+    // Outer join condition may reference only equi-key attrs from the self-join side.
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+
+    // Top-level subquery Project may reference only equi-key attrs from the self-join side.
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val refs = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        refs.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val filtered = buildAggregateHavingDistinctGt1(sjLeftEquiAttrs, sjLeftNeqAttr, sjLeft)
+
+    val (newSelfJoinSide, outputRemap): (LogicalPlan, Map[ExprId, Attribute]) =
+      selfJoinProjectOpt match {
+        case Some(wp) =>
+          canonicalizeWrapper(wp.projectList, equiPairs, filtered) match {
+            case Some((newWrapper, remap)) => (newWrapper, remap)
+            case None => return None
+          }
+        case None if projectListOpt.isEmpty =>
+          // Fail-closed: with neither a wrapper Project around the self-join nor a top-level
+          // subquery Project, the outer join currently exposes every self-join column, and
+          // replacing the self-join with `Project(equiKeys, filtered)` would shrink the outer
+          // join's right-hand output arity. RewritePredicateSubquery's positional zip
+          // (`values.zip(sub.output).map(EqualTo.tupled)`) would then bind semi predicates to
+          // the wrong attributes -- silently dropping components of a tuple IN. A
+          // top-level Project (`projectListOpt`) is what would let the arity be preserved
+          // by the top-level rewrite loop; without one, refuse to rewrite.
+          return None
+        case None =>
+          // No wrapper Project but there IS a top-level subquery Project: shrinking the outer
+          // join's self-join-side output is safe because the top-level Project is rewritten
+          // consistently via `outputRemap` below and the top-level rewrite loop ensures
+          // subquery output arity matches what the enclosing InSubquery expects.
+          // Outer references may point at sjRight equi-attributes; remap them to sjLeft
+          // (same output position in a valid self-join).
+          val newP = Project(sjLeftEquiAttrs, filtered)
+          val remap: Map[ExprId, Attribute] =
+            equiPairs.map { case (l, r) => r.exprId -> l }.toMap
+          (newP, remap)
+      }
+
+    // Rewrite outer join condition to use new wrapper output attributes.
+    val newOuterCond = outerCond.transformUp {
+      case a: Attribute if outputRemap.contains(a.exprId) => outputRemap(a.exprId)
+    }
+
+    val newOuterJoin = if (selfJoinOnRight) {
+      outerJoin.copy(right = newSelfJoinSide, condition = Some(newOuterCond))
+    } else {
+      outerJoin.copy(left = newSelfJoinSide, condition = Some(newOuterCond))
+    }
+
+    // Rewrite top-level Project references.
+    val result = projectListOpt match {
+      case Some(pl) =>
+        val remapped = pl.map(ne => remapNamedExpressionAttributes(ne, outputRemap))
+        if (remapped.exists(_.isEmpty)) return None
+        Project(remapped.flatten, newOuterJoin)
+      case None => newOuterJoin
+    }
+
+    logDebug(
+      s"Pattern A2 - equiKeys=[${sjLeftEquiAttrs.map(_.name).mkString(",")}]" +
+        s", neqCol=${sjLeftNeqAttr.name}")
+    Some(result)
+  }
+
+  private def tryExtractSelfJoin(plan: LogicalPlan): Option[Join] = {
+    val join = plan match {
+      case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => j
+      case j: Join if j.joinType == Inner && j.condition.isDefined => j
+      case _ => return None
+    }
+    // A hinted self-join is not an extraction candidate: fail closed so the same node the rewrite
+    // would delete is never even recognized here. See `rewriteDirectSelfJoin` for the rationale.
+    if (join.hint != JoinHint.NONE) return None
+    if (!isSameBaseRelation(join.left, join.right)) return None
+    val parsed = parseSelfJoinCondition(join.condition.get, join.left, join.right)
+    if (parsed.isEmpty) return None
+    Some(join)
+  }
+
+  // ============================================================================
+  //  parseSelfJoinCondition + isSameBaseRelation
+  // ============================================================================
+
+  private def outputOrdinal(plan: LogicalPlan, attr: Attribute): Int =
+    plan.output.indexWhere(_.exprId == attr.exprId)
+
+  private def sameOutputPosition(
+      leftPlan: LogicalPlan,
+      rightPlan: LogicalPlan,
+      leftAttr: Attribute,
+      rightAttr: Attribute): Boolean = {
+    val leftPos = outputOrdinal(leftPlan, leftAttr)
+    val rightPos = outputOrdinal(rightPlan, rightAttr)
+    leftPos >= 0 && rightPos >= 0 && leftPos == rightPos
+  }
+
+  /**
+   * Parse a join condition into equi-pairs and inequality-pairs. Accepts only:
+   *   - `EqualTo(attr, attr)` where the two attrs come from opposite sides,
+   *   - `Not(EqualTo(attr, attr))` -- same side rule,
+   *   - `IsNotNull(attr)` where the attr is one of the join columns.
+   * Anything else in the condition disqualifies the whole rewrite (fail-closed).
+   */
+  private def parseSelfJoinCondition(
+      condition: Expression,
+      leftPlan: LogicalPlan,
+      rightPlan: LogicalPlan)
+      : Option[(Seq[(Attribute, Attribute)], Seq[(Attribute, Attribute)])] = {
+
+    val leftOutput = leftPlan.outputSet
+    val rightOutput = rightPlan.outputSet
+    val predicates = splitConjunctivePredicates(condition)
+
+    val equiPairs = predicates.collect {
+      case EqualTo(l: Attribute, r: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case EqualTo(r: Attribute, l: Attribute)
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    val neqPairs = predicates.collect {
+      case Not(EqualTo(l: Attribute, r: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+      case Not(EqualTo(r: Attribute, l: Attribute))
+          if leftOutput.contains(l) && rightOutput.contains(r) =>
+        (l, r)
+    }
+
+    // Only IsNotNull predicates on join columns are safe to drop -- they're redundant with
+    // the join semantics or auto-added by InferFiltersFromConstraints. IsNotNull on other
+    // columns changes semantics if we drop it; bail out.
+    val joinAttrIds: Set[ExprId] =
+      (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    val isNotNullOnJoinCols = predicates.count {
+      case IsNotNull(a: Attribute) if joinAttrIds.contains(a.exprId) => true
+      case _ => false
+    }
+
+    val totalMatched = equiPairs.size + neqPairs.size + isNotNullOnJoinCols
+    if (totalMatched != predicates.size) return None
+
+    if (equiPairs.isEmpty || neqPairs.isEmpty) return None
+
+    // Only rewrite the single-inequality case. Multiple inequality conjuncts cannot be represented
+    // by COUNT(DISTINCT) over a single column.
+    if (neqPairs.size != 1) return None
+
+    // Canonicalization intentionally erases cosmetic Alias names, so name equality cannot prove
+    // that the two predicate ends refer to the same underlying column. Resolve each end by its own
+    // ExprId against its child output and require matching output ordinals instead.
+    val equiValid = equiPairs.forall {
+      case (l, r) => sameOutputPosition(leftPlan, rightPlan, l, r)
+    }
+    val neqValid = neqPairs.forall {
+      case (l, r) => sameOutputPosition(leftPlan, rightPlan, l, r)
+    }
+    if (!equiValid || !neqValid) return None
+
+    // Equi-key output positions must be distinct across pairs. Keep the same positional identity
+    // here so swapped or duplicate aliases cannot make two different underlying columns look equal.
+    val leftEquiOrdinals = equiPairs.map { case (l, _) => outputOrdinal(leftPlan, l) }
+    if (leftEquiOrdinals.exists(_ < 0)) return None
+    if (leftEquiOrdinals.distinct.size != leftEquiOrdinals.size) return None
+
+    // Defensive: reject when the neq column overlaps an equi-key column
+    // (e.g. `t1.k = t2.k AND t1.k <> t2.k`).
+    val neqLeftOrdinal = outputOrdinal(leftPlan, neqPairs.head._1)
+    if (neqLeftOrdinal < 0 || leftEquiOrdinals.contains(neqLeftOrdinal)) return None
+
+    // Datatype safety is checked LAST, only after correspondence is proven: these really are the
+    // paired equi-keys and neq column. The rewrite replaces the join predicates `=` / `<>` with
+    // GROUP BY / COUNT(DISTINCT), i.e. it swaps comparison equality for grouping/distinct equality,
+    // so every column moved into the aggregate -- both ends of every pair, not just the sjLeft side
+    // -- must be a type where the two equalities provably coincide (see
+    // [[isSafeComparisonGroupingType]]). Checking both ends (rather than trusting
+    // `left.canonicalized == right.canonicalized` to imply matching type/metadata) keeps this
+    // robust if a future metadata-aware type gate is added.
+    val comparisonAttrs = (equiPairs ++ neqPairs).flatMap { case (l, r) => Seq(l, r) }
+    if (!comparisonAttrs.forall(a => isSafeComparisonGroupingType(a.dataType))) return None
+
+    Some((equiPairs, neqPairs))
+  }
+
+  /**
+   * True iff comparison equality (`=` / `<>`) and grouping/distinct equality provably coincide for
+   * `dataType`, so the key can be safely moved from a join predicate into GROUP BY /
+   * COUNT(DISTINCT).
+   *
+   * This is a POSITIVE allowlist, not "orderable minus a blacklist". The property we must prove is
+   * not that a type can be ordered, but that its `=` / `<>` semantics and its grouping/distinct
+   * semantics are identical. `RowOrdering.isOrderable` answers the former, not the latter, so it is
+   * not a sufficient proof here. Rather than depend on non-trivial, version-dependent equality
+   * contracts, we allow only the small set of types whose two equalities coincide unconditionally
+   * and which the target workload (TPC-DS Q95) actually needs:
+   *   - Float/Double: their equality around NaN and signed zero relies on normalization semantics
+   *     (`NormalizeFloatingNumbers` and friends) that we do not want this rule -- which spans
+   *     multiple Spark versions and native execution paths -- to depend on.
+   *   - String / CHAR / VARCHAR: non-binary collation semantics make the equivalence non-trivial
+   *     and version-dependent, and CHAR/VARCHAR may already appear as StringType-plus-metadata by
+   *     the optimizer, so `dataType` alone cannot even see the declared type. String is therefore
+   *     outside the initial allowlist.
+   * Everything else -- complex types (Array/Map/Struct), UDTs, and any future/unknown type -- fails
+   * closed for the same reason: we would rather miss the rewrite than depend on a contract we have
+   * not proven holds across every supported backend.
+   */
+  private def isSafeComparisonGroupingType(dataType: DataType): Boolean = dataType match {
+    case ByteType | ShortType | IntegerType | LongType => true
+    case _: DecimalType => true
+    case BooleanType => true
+    case DateType => true
+    case TimestampType => true
+    case BinaryType => true
+    case _ => false
+  }
+
+  /**
+   * True iff `plan` produces the same row bag on every evaluation.
+   *
+   * This is the primary safety guard for the rewrite, which folds two occurrences of the same
+   * subtree into one aggregate -- sound only when both occurrences produce identical row bags. We
+   * check it at TWO levels:
+   *   - candidate level: the enclosing subquery, before descending into the self-join. Catches
+   *     nondeterminism that has been hoisted OUT of the join by an earlier optimizer rule -- e.g. a
+   *     `Filter(rand(...))` moved to sit above the join rather than on each side. Without this,
+   *     `isSameBaseRelation(innerLeft, innerRight)` could pass (both sides look deterministic)
+   *     while the enclosing plan still contains `Rand`.
+   *   - relation level: [[isSameBaseRelation]] additionally requires the two sides to be
+   *     structurally identical.
+   *
+   * Attribute-level `plan.deterministic` alone is NOT sufficient. Catalyst's
+   * `Expression.deterministic` only checks explicit `Nondeterministic` annotation; several
+   * operators produce a runtime-nondeterministic row bag even though every expression they contain
+   * is `deterministic == true`:
+   *   - `Aggregate` with `First` / `Last` / `collect_list` / `min_by` / `max_by` (tie order),
+   *   - `Window` with `row_number()` / `rank()` over a non-total order,
+   *   - `Limit` / `LocalLimit` / `Sample` / `Offset` (row-bag operator-level nondeterminism),
+   *   - streaming sources.
+   *
+   * This rule collapses two evaluations of the same subtree into one aggregate; repeatability must
+   * be provable, not assumed. That is why both the operator check and the expression check below
+   * are WHITELISTS rather than blacklists -- unknown operators and unknown expression types default
+   * to reject.
+   *
+   * Expression support is allowlisted, not blacklisted. `plan.deterministic` relies on each
+   * expression's reported `deterministic` contract; that is necessary but insufficient for unknown
+   * expression types whose repeatability has not been established -- a builtin that carries hidden
+   * state yet reports `deterministic == true` would otherwise be trusted silently. For a rewrite
+   * that folds two evaluations of a subtree into one aggregate we prefer to miss an optimization
+   * than to misapply one, so new expression types are added to [[isRepeatableExpression]] only
+   * after their repeatability has been established. `plan.deterministic` is kept as a cheap
+   * fast-reject, but the expression allowlist is what actually proves repeatability.
+   *
+   * `plan.subqueriesAll.isEmpty` additionally fail-closes on any embedded expression subquery
+   * (scalar / IN / EXISTS). `plan.exists` in `isRowBagRepeatable` walks only the operator tree and
+   * does not descend into expression subqueries, and `plan.deterministic` does not prove a nested
+   * subquery is row-bag repeatable (e.g. an uncorrelated `LIMIT 1` without `ORDER BY`). Rejecting
+   * any embedded subquery keeps the repeatability proof confined to the operator whitelist below.
+   */
+  private def isRepeatablePlan(plan: LogicalPlan): Boolean = {
+    // The operator/source whitelist is checked before the expression whitelist so that a plan whose
+    // operator is itself unknown -- e.g. Aggregate (carries AggregateExpression) or Window (carries
+    // WindowExpression / SortOrder) -- is attributed to isRowBagRepeatable rather than being masked
+    // by the fact that those operators also carry non-allowlisted expressions.
+    plan.deterministic &&
+    !plan.isStreaming &&
+    plan.subqueriesAll.isEmpty &&
+    isRowBagRepeatable(plan) &&
+    hasRepeatableExpressions(plan)
+  }
+
+  /**
+   * Operator whitelist for `isRepeatablePlan`. A plan is row-bag repeatable only when every node is
+   * known to produce a repeatable output row bag from repeatable children. Unknown operators and
+   * unknown leaf sources fail closed.
+   *
+   * Kept intentionally narrow -- the target workload (Q95-shape self-join in a subquery) only needs
+   * a Parquet relation scan optionally wrapped in Project / Filter / SubqueryAlias plus the
+   * self-join itself. Range and LocalRelation are also trusted deterministic leaves. Adding an
+   * operator here requires proving:
+   *   - it does not reorder its input non-deterministically,
+   *   - it does not depend on shuffle-merge or tie-broken orderings,
+   *   - it produces the same output row bag on every evaluation.
+   *
+   * Arbitrary `LeafNode`s are intentionally not trusted. For example, `LogicalRDD` may wrap an
+   * arbitrary RDD lineage whose runtime behavior is invisible to Catalyst's `plan.deterministic`;
+   * `InMemoryRelation`, `DataSourceV2Relation` and custom leaves are likewise rejected until
+   * proven. Streaming sources reach here as `LeafNode`s but are already filtered upstream by
+   * `plan.isStreaming` in [[isRepeatablePlan]].
+   *
+   * `LogicalRelation` is trusted only when its underlying relation is a `HadoopFsRelation` whose
+   * `fileFormat` is EXACTLY `ParquetFileFormat` (`getClass == classOf[ParquetFileFormat]`, not
+   * `isInstanceOf`). `HadoopFsRelation.fileFormat` can be any `FileFormat`, including custom
+   * formats whose scan is not provably a repeatable row bag; `ParquetFileFormat` is also non-final,
+   * so a third-party subclass could override its scan. The target workload only needs stock
+   * Parquet, so every other `FileFormat` -- subclasses of `ParquetFileFormat` included -- and every
+   * non-`HadoopFsRelation` fail closed.
+   *
+   * This helper checks operators and leaf sources only; expression-type repeatability is a separate
+   * concern handled by [[hasRepeatableExpressions]], and both are joined in [[isRepeatablePlan]].
+   */
+  private def isRowBagRepeatable(plan: LogicalPlan): Boolean = !plan.exists {
+    // TreeNode exposes `exists` but not `forall`, so invert: a whitelisted operator maps to `false`
+    // ("does not break repeatability") and everything else to `true`; negating the whole `exists`
+    // then means "every operator is whitelisted".
+    case _: Project => false
+    case _: Filter => false
+    case _: SubqueryAlias => false
+    // Join is included because our target pattern IS a Join; both children get recursed into.
+    case _: Join => false
+    // Explicitly trusted leaves.
+    case _: Range => false
+    case _: LocalRelation => false
+    case relation: LogicalRelation =>
+      relation.relation match {
+        case h: HadoopFsRelation if h.fileFormat.getClass == classOf[ParquetFileFormat] => false
+        case _ => true
+      }
+    // Everything else -- Aggregate (First/Last), Window (row_number ties), Limit, Sample,
+    // Offset, Distinct, Union, Except, Intersect, Sort (may break ties nondeterministically),
+    // Expand, Generate, and opaque leaf sources -- fail-closed.
+    case _ => true
+  }
+
+  /**
+   * Expression-type check for [[isRepeatablePlan]], kept separate from the operator whitelist in
+   * [[isRowBagRepeatable]] so the two safety layers read independently. A plan passes only when
+   * every expression carried by every operator node is repeatable per [[isRepeatableExpression]]; a
+   * whitelisted operator holding an unknown expression -- e.g. `Project(Abs(v))` -- fails closed.
+   */
+  private def hasRepeatableExpressions(plan: LogicalPlan): Boolean = {
+    !plan.exists(node => node.expressions.exists(expr => !isRepeatableExpression(expr)))
+  }
+
+  /**
+   * Expression repeatability is allowlisted, consistently with the operator whitelist in
+   * [[isRowBagRepeatable]]. Only expression types whose result is determined entirely by repeatable
+   * children are accepted; unknown expression types fail closed. This rule folds two evaluations of
+   * the same subtree into one, so missing an optimization is preferable to assuming repeatability
+   * for an expression whose runtime behavior has not been proven.
+   *
+   * The whole tree is checked: an expression is repeatable only when its root type is on the
+   * allowlist AND all of its children are themselves repeatable, so e.g. `Add(v, Abs(w))` is
+   * rejected even though `Add` is allowlisted.
+   *
+   * The initial set covers what TPC-DS Q95 and the tests need: column / literal references, Alias,
+   * Cast, basic arithmetic, boolean connectives, the six comparisons plus null-safe equality, and
+   * null checks. Expressions such as `Abs` / `Coalesce` / `CaseWhen` are intentionally absent -- a
+   * self-join over them is simply not rewritten (a missed optimization, not a correctness bug)
+   * until each is added here after its repeatability has been established. Decimal wrappers such as
+   * `PromotePrecision` / `CheckOverflow` are likewise absent and may fail closed; that is
+   * acceptable and must not be worked around by trusting them just to make an arithmetic variant
+   * fire.
+   */
+  private def isRepeatableExpression(expr: Expression): Boolean = expr match {
+    case _: Attribute | _: Literal =>
+      true
+    case _: Alias | _: Cast | _: Add | _: Subtract | _: Multiply | _: Divide | _: Remainder |
+        _: And | _: Or | _: Not | _: EqualTo | _: EqualNullSafe | _: LessThan |
+        _: LessThanOrEqual | _: GreaterThan | _: GreaterThanOrEqual | _: IsNull | _: IsNotNull =>
+      expr.children.forall(isRepeatableExpression)
+    case _ =>
+      false
+  }
+
+  /**
+   * True iff `left` and `right` are the same plan modulo canonicalization AND each side is a
+   * repeatable plan. See [[isRepeatablePlan]] for the repeatability contract.
+   */
+  private def isSameBaseRelation(left: LogicalPlan, right: LogicalPlan): Boolean = {
+    left.canonicalized == right.canonicalized &&
+    isRepeatablePlan(left) && isRepeatablePlan(right)

Review Comment:
   `rewriteSubqueryPlan` already rejects non-repeatable candidates via `isRepeatablePlan(plan)`, but `isSameBaseRelation` then re-walks both self-join sides with `isRepeatablePlan(left/right)` (and this happens multiple times on the nested path as well). This is a concrete extra traversal cost in the optimizer. Consider splitting `isSameBaseRelation` into (a) structural equality (`canonicalized`) and (b) repeatability, and in the subquery rewrite path only pay the repeatability walk once (or memoize `isRepeatablePlan` per subtree for the duration of `rewriteSubqueryPlan`).



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Any other NamedExpression shape we
+   * do not rewrite is left as-is ONLY if it does not reference a replaced self-join output;
+   * otherwise it would carry a stale ExprId, so returns None to fail the whole rewrite closed.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): Option[NamedExpression] = ne match {
+    case a: Attribute if remap.contains(a.exprId) => Some(remap(a.exprId))
+    case a: Attribute => Some(a)
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      Some(
+        if (newChild eq al.child) {
+          al
+        } else {
+          Alias(newChild, al.name)(
+            al.exprId,
+            al.qualifier,
+            al.explicitMetadata,
+            al.nonInheritableMetadataKeys)
+        })
+    case other if other.references.exists(a => remap.contains(a.exprId)) =>
+      // Fail-closed: a NamedExpression we do not rewrite (neither a bare Attribute nor an Alias)
+      // that still references a replaced self-join output would be left with a dangling ExprId.
+      // Refuse the rewrite rather than emit a plan with a stale reference.
+      None
+    case other => Some(other)
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    // Fail closed on an explicit join hint. This rule deletes the self-join outright; a user
+    // `/*+ BROADCAST(...) */` (or SHUFFLE_*/MERGE) is an explicit optimizer directive about THAT
+    // join, and an opt-in rewrite has no business silently discarding it. Not a correctness bug
+    // (hints do not change results), but the conservative maintainer choice. `JoinHint.NONE` exists
+    // across all Spark versions Gluten supports, so no shim is needed.
+    if (innerJoin.hint != JoinHint.NONE) return None
+
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map {
+          case (newWrapper, _) =>
+            logDebug(
+              s"Pattern A' - equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}]" +
+                s", neqCol=${innerLeftNeqAttr.name}" +
+                s", outCols=[${newWrapper.projectList.map(_.name).mkString(",")}]")
+            newWrapper
+        }
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A2 : self-join nested inside another InnerJoin in the subquery
+  // ============================================================================
+
+  private def rewriteNestedSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+    val outerCond = outerJoin.condition.get
+
+    val (selfJoinSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerJoin.right) match {
+        case Some(_) => (outerJoin.right, true)
+        case None =>
+          tryExtractSelfJoin(outerJoin.left) match {
+            case Some(_) => (outerJoin.left, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(p), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+

Review Comment:
   `rewriteNestedSelfJoin` uses `tryExtractSelfJoin` only to decide which side contains the self-join, but then re-extracts the join again (and later re-parses the self-join condition). This duplicates work and makes the control flow harder to follow. A tangible simplification is to have `tryExtractSelfJoin` return the extracted shape needed downstream (e.g., `(projectOpt, join, parsedPairs)`), and reuse that result rather than matching/parsing a second time.



##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,734 @@
+/*
+ * 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.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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, DataType, DateType, DecimalType, IntegerType, LongType, ShortType, TimestampType}
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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.
+   * [[parseSelfJoinCondition]] has already verified that each pair refers to the same output
+   * position on the two structurally identical self-join sides. 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
+    }
+    if (mapped.exists(_.isEmpty)) {
+      None
+    } else {
+      val newProjectList = mapped.flatten
+      val newWrapper = Project(newProjectList, newChild)
+      val newOutput = newWrapper.output
+      val remap: Map[ExprId, Attribute] =
+        oldOutput.zip(newOutput).map { case (o, n) => o.exprId -> n }.toMap
+      Some((newWrapper, remap))
+    }
+  }
+
+  /**
+   * Replace equi-key attribute references inside a NamedExpression according to `remap`, while
+   * preserving the NamedExpression shape.
+   *
+   * `Expression.transformUp` returns `Expression`, not `NamedExpression`. We avoid a blanket
+   * `asInstanceOf[NamedExpression]` by handling the two shapes that can appear in a Project's
+   * `projectList` explicitly: a bare Attribute (whose top-level may itself be replaced) and an
+   * Alias (which stays an Alias while its child is transformed). Any other NamedExpression shape we
+   * do not rewrite is left as-is ONLY if it does not reference a replaced self-join output;
+   * otherwise it would carry a stale ExprId, so returns None to fail the whole rewrite closed.
+   */
+  private def remapNamedExpressionAttributes(
+      ne: NamedExpression,
+      remap: Map[ExprId, Attribute]): Option[NamedExpression] = ne match {
+    case a: Attribute if remap.contains(a.exprId) => Some(remap(a.exprId))
+    case a: Attribute => Some(a)
+    case al: Alias =>
+      val newChild = al.child.transformUp {
+        case a: Attribute if remap.contains(a.exprId) => remap(a.exprId)
+      }
+      Some(
+        if (newChild eq al.child) {
+          al
+        } else {
+          Alias(newChild, al.name)(
+            al.exprId,
+            al.qualifier,
+            al.explicitMetadata,
+            al.nonInheritableMetadataKeys)
+        })
+    case other if other.references.exists(a => remap.contains(a.exprId)) =>
+      // Fail-closed: a NamedExpression we do not rewrite (neither a bare Attribute nor an Alias)
+      // that still references a replaced self-join output would be left with a dangling ExprId.
+      // Refuse the rewrite rather than emit a plan with a stale reference.
+      None
+    case other => Some(other)
+  }
+
+  // ============================================================================
+  //  Pattern A' / A2 dispatch (subquery plans of InSubquery)
+  // ============================================================================
+
+  private def rewriteSubqueryPlan(plan: LogicalPlan): Option[LogicalPlan] = {
+    // Candidate-level nondeterminism guard: reject if ANY node in the whole subquery plan
+    // is non-repeatable (Rand, LIMIT-without-ORDER-BY, Sample, Offset, streaming). This
+    // catches nondeterminism that has been hoisted above the self-join by an earlier
+    // optimizer rule -- the per-side `isSameBaseRelation` check alone would miss it because
+    // both innerLeft/innerRight can look deterministic after such a hoist.
+    if (!isRepeatablePlan(plan)) return None
+
+    val (projectListOpt, innerJoin): (Option[Seq[NamedExpression]], Join) = plan match {
+      case Project(pl, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(pl), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    if (isSameBaseRelation(innerJoin.left, innerJoin.right)) {
+      rewriteDirectSelfJoin(projectListOpt, innerJoin)
+    } else {
+      rewriteNestedSelfJoin(projectListOpt, innerJoin)
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A' : direct self-join at subquery top level
+  // ============================================================================
+
+  private def rewriteDirectSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      innerJoin: Join): Option[LogicalPlan] = {
+    // Fail closed on an explicit join hint. This rule deletes the self-join outright; a user
+    // `/*+ BROADCAST(...) */` (or SHUFFLE_*/MERGE) is an explicit optimizer directive about THAT
+    // join, and an opt-in rewrite has no business silently discarding it. Not a correctness bug
+    // (hints do not change results), but the conservative maintainer choice. `JoinHint.NONE` exists
+    // across all Spark versions Gluten supports, so no shim is needed.
+    if (innerJoin.hint != JoinHint.NONE) return None
+
+    val innerLeft = innerJoin.left
+    val innerRight = innerJoin.right
+    val innerCond = innerJoin.condition.get
+
+    val parsed = parseSelfJoinCondition(innerCond, innerLeft, innerRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val innerLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val innerLeftNeqAttr: Attribute = neqPairs.head._1
+    val filtered = buildAggregateHavingDistinctGt1(innerLeftEquiAttrs, innerLeftNeqAttr, innerLeft)
+
+    // Fail-closed on bare-Join subqueries: without a wrapping Project the subquery output
+    // is the full self-join output (both sides' columns). Replacing that with
+    // `Project(equiKeys, filtered)` shrinks the output; if the enclosing InSubquery
+    // referenced a non-equi column by position, `values.zip(sub.output).map(EqualTo.tupled)`
+    // inside RewritePredicateSubquery would build an incorrect semi condition. Q95's
+    // subqueries all have an explicit Project wrapper, so this branch does not affect it.
+    projectListOpt match {
+      case None =>
+        None
+      case Some(pl) =>
+        canonicalizeWrapper(pl, equiPairs, filtered).map {
+          case (newWrapper, _) =>
+            logDebug(
+              s"Pattern A' - equiKeys=[${innerLeftEquiAttrs.map(_.name).mkString(",")}]" +
+                s", neqCol=${innerLeftNeqAttr.name}" +
+                s", outCols=[${newWrapper.projectList.map(_.name).mkString(",")}]")
+            newWrapper
+        }
+    }
+  }
+
+  // ============================================================================
+  //  Pattern A2 : self-join nested inside another InnerJoin in the subquery
+  // ============================================================================
+
+  private def rewriteNestedSelfJoin(
+      projectListOpt: Option[Seq[NamedExpression]],
+      outerJoin: Join): Option[LogicalPlan] = {
+    val outerCond = outerJoin.condition.get
+
+    val (selfJoinSide, selfJoinOnRight) =
+      tryExtractSelfJoin(outerJoin.right) match {
+        case Some(_) => (outerJoin.right, true)
+        case None =>
+          tryExtractSelfJoin(outerJoin.left) match {
+            case Some(_) => (outerJoin.left, false)
+            case None => return None
+          }
+      }
+
+    val (selfJoinProjectOpt, selfJoin) = selfJoinSide match {
+      case p @ Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined =>
+        (Some(p), j)
+      case j: Join if j.joinType == Inner && j.condition.isDefined =>
+        (None, j)
+      case _ => return None
+    }
+
+    val sjLeft = selfJoin.left
+    val sjRight = selfJoin.right
+    val sjCond = selfJoin.condition.get
+    if (!isSameBaseRelation(sjLeft, sjRight)) return None
+
+    val parsed = parseSelfJoinCondition(sjCond, sjLeft, sjRight)
+    if (parsed.isEmpty) return None
+    // parseSelfJoinCondition has validated column correspondence and equi-key uniqueness.
+    val (equiPairs, neqPairs) = parsed.get
+
+    val sjLeftEquiAttrs: Seq[Attribute] = equiPairs.map(_._1)
+    val sjLeftNeqAttr: Attribute = neqPairs.head._1
+
+    val selfJoinOutputSet = selfJoinSide.outputSet
+    val sjEquiExprIds: Set[ExprId] =
+      equiPairs.flatMap { case (l, r) => Seq(l.exprId, r.exprId) }.toSet
+    // wrapper Project may reproject equi-keys under fresh alias exprIds; include those.
+    val wrapperEquiExprIds: Set[ExprId] = selfJoinProjectOpt.toSeq.flatMap {
+      p =>
+        p.projectList.flatMap {
+          case a: Attribute if sjEquiExprIds.contains(a.exprId) => Some(a.exprId)
+          case al @ Alias(a: Attribute, _) if sjEquiExprIds.contains(a.exprId) => Some(al.exprId)
+          case _ => None
+        }
+    }.toSet
+    val allEquiExprIds = sjEquiExprIds ++ wrapperEquiExprIds
+
+    // Outer join condition may reference only equi-key attrs from the self-join side.
+    val outerCondRefs = outerCond.references.filter(selfJoinOutputSet.contains)
+    if (!outerCondRefs.forall(a => allEquiExprIds.contains(a.exprId))) return None
+
+    // Top-level subquery Project may reference only equi-key attrs from the self-join side.
+    val projectOk = projectListOpt.forall {
+      pl =>
+        val refs = pl.flatMap(_.references).filter(selfJoinOutputSet.contains)
+        refs.forall(a => allEquiExprIds.contains(a.exprId))
+    }
+    if (!projectOk) return None
+
+    val filtered = buildAggregateHavingDistinctGt1(sjLeftEquiAttrs, sjLeftNeqAttr, sjLeft)
+
+    val (newSelfJoinSide, outputRemap): (LogicalPlan, Map[ExprId, Attribute]) =
+      selfJoinProjectOpt match {
+        case Some(wp) =>
+          canonicalizeWrapper(wp.projectList, equiPairs, filtered) match {
+            case Some((newWrapper, remap)) => (newWrapper, remap)
+            case None => return None
+          }
+        case None if projectListOpt.isEmpty =>
+          // Fail-closed: with neither a wrapper Project around the self-join nor a top-level
+          // subquery Project, the outer join currently exposes every self-join column, and
+          // replacing the self-join with `Project(equiKeys, filtered)` would shrink the outer
+          // join's right-hand output arity. RewritePredicateSubquery's positional zip
+          // (`values.zip(sub.output).map(EqualTo.tupled)`) would then bind semi predicates to
+          // the wrong attributes -- silently dropping components of a tuple IN. A
+          // top-level Project (`projectListOpt`) is what would let the arity be preserved
+          // by the top-level rewrite loop; without one, refuse to rewrite.
+          return None
+        case None =>
+          // No wrapper Project but there IS a top-level subquery Project: shrinking the outer
+          // join's self-join-side output is safe because the top-level Project is rewritten
+          // consistently via `outputRemap` below and the top-level rewrite loop ensures
+          // subquery output arity matches what the enclosing InSubquery expects.
+          // Outer references may point at sjRight equi-attributes; remap them to sjLeft
+          // (same output position in a valid self-join).
+          val newP = Project(sjLeftEquiAttrs, filtered)
+          val remap: Map[ExprId, Attribute] =
+            equiPairs.map { case (l, r) => r.exprId -> l }.toMap
+          (newP, remap)
+      }
+
+    // Rewrite outer join condition to use new wrapper output attributes.
+    val newOuterCond = outerCond.transformUp {
+      case a: Attribute if outputRemap.contains(a.exprId) => outputRemap(a.exprId)
+    }
+
+    val newOuterJoin = if (selfJoinOnRight) {
+      outerJoin.copy(right = newSelfJoinSide, condition = Some(newOuterCond))
+    } else {
+      outerJoin.copy(left = newSelfJoinSide, condition = Some(newOuterCond))
+    }
+
+    // Rewrite top-level Project references.
+    val result = projectListOpt match {
+      case Some(pl) =>
+        val remapped = pl.map(ne => remapNamedExpressionAttributes(ne, outputRemap))
+        if (remapped.exists(_.isEmpty)) return None
+        Project(remapped.flatten, newOuterJoin)
+      case None => newOuterJoin
+    }
+
+    logDebug(
+      s"Pattern A2 - equiKeys=[${sjLeftEquiAttrs.map(_.name).mkString(",")}]" +
+        s", neqCol=${sjLeftNeqAttr.name}")
+    Some(result)
+  }
+
+  private def tryExtractSelfJoin(plan: LogicalPlan): Option[Join] = {
+    val join = plan match {
+      case Project(_, j: Join) if j.joinType == Inner && j.condition.isDefined => j
+      case j: Join if j.joinType == Inner && j.condition.isDefined => j
+      case _ => return None
+    }
+    // A hinted self-join is not an extraction candidate: fail closed so the same node the rewrite
+    // would delete is never even recognized here. See `rewriteDirectSelfJoin` for the rationale.
+    if (join.hint != JoinHint.NONE) return None
+    if (!isSameBaseRelation(join.left, join.right)) return None
+    val parsed = parseSelfJoinCondition(join.condition.get, join.left, join.right)
+    if (parsed.isEmpty) return None
+    Some(join)
+  }

Review Comment:
   `rewriteNestedSelfJoin` uses `tryExtractSelfJoin` only to decide which side contains the self-join, but then re-extracts the join again (and later re-parses the self-join condition). This duplicates work and makes the control flow harder to follow. A tangible simplification is to have `tryExtractSelfJoin` return the extracted shape needed downstream (e.g., `(projectOpt, join, parsedPairs)`), and reuse that result rather than matching/parsing a second time.



##########
backends-velox/src/test/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregateSuite.scala:
##########
@@ -0,0 +1,929 @@
+/*
+ * 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.execution.WholeStageTransformerSuite
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.catalyst.expressions.{Alias, GreaterThan, Literal}
+import org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, Count}
+import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, Filter, LogicalPlan}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{IntegerType, LongType, StructField, StructType}
+
+/**
+ * Correctness tests for [[RewriteSelfJoinInequalityToAggregate]].
+ *
+ * Positive A' / A2 cases assert both result equivalence and that the rewrite actually fired.
+ *
+ * `assert(!ruleFired(plan))` on its own only proves the rewrite did not happen -- not that it was
+ * the guard under test that stopped it. A fixture whose two self-join sides are not structurally
+ * identical is rejected by `isSameBaseRelation` before any predicate is even parsed, and such a
+ * test passes while covering nothing. So six important rejection paths -- the predicate parser, the
+ * single-inequality requirement, output-position identity, the nondeterminism guard, the
+ * leaf-source allowlist (LogicalRDD vs Parquet), and the expression-type allowlist (`abs(v)` vs
+ * `v + 1`) -- are tested as single-variable pairs: the same fixture and the same query shape, one
+ * control query that must fire and one variant that changes only the feature under test and must
+ * not. A firing control does not pin the rejection to a particular line, but it does rule out an
+ * unrelated fixture mismatch as the reason its partner was rejected. The row-bag whitelist
+ * (Aggregate, Window) stays a plain negative: dropping the operator would change the query shape
+ * rather than one feature.
+ *
+ * Self-joined fixtures are real tables, not temp views over VALUES. Spark deduplicates a self-join
+ * over a [[org.apache.spark.sql.catalyst.analysis.MultiInstanceRelation]] via `newInstance()`,
+ * which refreshes one side's ExprIds without inserting a rename-only Project, so both sides stay
+ * structurally identical. A temp view over VALUES cannot, and Spark renames one side with a Project
+ * instead, which would make `isSameBaseRelation` false for every self-join below. `range()` needs
+ * no such treatment -- Range is a MultiInstanceRelation already.
+ */
+class RewriteSelfJoinInequalityToAggregateSuite extends WholeStageTransformerSuite {
+
+  override protected val resourcePath: String = "/tpch-data-parquet"
+  override protected val fileFormat: String = "parquet"
+
+  override protected def sparkConf: SparkConf = super.sparkConf
+    .set("spark.gluten.sql.rewrite.selfJoinInequality", "true")
+    .set(SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key, "-1")
+
+  /** Signature alias produced by the rewrite; presence => rule definitely fired. */
+  private val CountDistinctAlias = "_gluten_rw_selfjoin_cnt_distinct"
+
+  private def ruleFired(plan: LogicalPlan): Boolean =
+    plan.exists {
+      p =>
+        p.expressions.exists(_.exists {
+          case a: Alias if a.name == CountDistinctAlias => true
+          case _ => false
+        })
+    }
+
+  private def assertRuleFired(sql: String): Unit = {
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "true") {
+      val plan = spark.sql(sql).queryExecution.optimizedPlan
+      assert(ruleFired(plan), s"self-join inequality rewrite should fire:\n$plan")
+    }
+  }
+
+  private def assertRuleNotFired(sql: String): Unit = {
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "true") {
+      val plan = spark.sql(sql).queryExecution.optimizedPlan
+      assert(!ruleFired(plan), s"self-join inequality rewrite must not fire:\n$plan")
+    }
+  }
+
+  /** Optimized plan with the rewrite enabled. */
+  private def onOptimizedPlan(sql: String): LogicalPlan = {
+    var plan: LogicalPlan = null
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "true") {
+      plan = spark.sql(sql).queryExecution.optimizedPlan
+    }
+    plan
+  }

Review Comment:
   This helper relies on a mutable var initialized to `null`. Since `withSQLConf` returns the block result, this can be simplified to return the optimized plan directly (no mutable state / null sentinel), which reduces the chance of accidental NPEs if the method is modified later.



##########
backends-velox/src/test/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregateSuite.scala:
##########
@@ -0,0 +1,929 @@
+/*
+ * 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.execution.WholeStageTransformerSuite
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.catalyst.expressions.{Alias, GreaterThan, Literal}
+import org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, Count}
+import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, Filter, LogicalPlan}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{IntegerType, LongType, StructField, StructType}
+
+/**
+ * Correctness tests for [[RewriteSelfJoinInequalityToAggregate]].
+ *
+ * Positive A' / A2 cases assert both result equivalence and that the rewrite actually fired.
+ *
+ * `assert(!ruleFired(plan))` on its own only proves the rewrite did not happen -- not that it was
+ * the guard under test that stopped it. A fixture whose two self-join sides are not structurally
+ * identical is rejected by `isSameBaseRelation` before any predicate is even parsed, and such a
+ * test passes while covering nothing. So six important rejection paths -- the predicate parser, the
+ * single-inequality requirement, output-position identity, the nondeterminism guard, the
+ * leaf-source allowlist (LogicalRDD vs Parquet), and the expression-type allowlist (`abs(v)` vs
+ * `v + 1`) -- are tested as single-variable pairs: the same fixture and the same query shape, one
+ * control query that must fire and one variant that changes only the feature under test and must
+ * not. A firing control does not pin the rejection to a particular line, but it does rule out an
+ * unrelated fixture mismatch as the reason its partner was rejected. The row-bag whitelist
+ * (Aggregate, Window) stays a plain negative: dropping the operator would change the query shape
+ * rather than one feature.
+ *
+ * Self-joined fixtures are real tables, not temp views over VALUES. Spark deduplicates a self-join
+ * over a [[org.apache.spark.sql.catalyst.analysis.MultiInstanceRelation]] via `newInstance()`,
+ * which refreshes one side's ExprIds without inserting a rename-only Project, so both sides stay
+ * structurally identical. A temp view over VALUES cannot, and Spark renames one side with a Project
+ * instead, which would make `isSameBaseRelation` false for every self-join below. `range()` needs
+ * no such treatment -- Range is a MultiInstanceRelation already.
+ */
+class RewriteSelfJoinInequalityToAggregateSuite extends WholeStageTransformerSuite {
+
+  override protected val resourcePath: String = "/tpch-data-parquet"
+  override protected val fileFormat: String = "parquet"
+
+  override protected def sparkConf: SparkConf = super.sparkConf
+    .set("spark.gluten.sql.rewrite.selfJoinInequality", "true")
+    .set(SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key, "-1")
+
+  /** Signature alias produced by the rewrite; presence => rule definitely fired. */
+  private val CountDistinctAlias = "_gluten_rw_selfjoin_cnt_distinct"
+
+  private def ruleFired(plan: LogicalPlan): Boolean =
+    plan.exists {
+      p =>
+        p.expressions.exists(_.exists {
+          case a: Alias if a.name == CountDistinctAlias => true
+          case _ => false
+        })
+    }
+
+  private def assertRuleFired(sql: String): Unit = {
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "true") {
+      val plan = spark.sql(sql).queryExecution.optimizedPlan
+      assert(ruleFired(plan), s"self-join inequality rewrite should fire:\n$plan")
+    }
+  }
+
+  private def assertRuleNotFired(sql: String): Unit = {
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "true") {
+      val plan = spark.sql(sql).queryExecution.optimizedPlan
+      assert(!ruleFired(plan), s"self-join inequality rewrite must not fire:\n$plan")
+    }
+  }
+
+  /** Optimized plan with the rewrite enabled. */
+  private def onOptimizedPlan(sql: String): LogicalPlan = {
+    var plan: LogicalPlan = null
+    withSQLConf("spark.gluten.sql.rewrite.selfJoinInequality" -> "true") {
+      plan = spark.sql(sql).queryExecution.optimizedPlan
+    }
+    plan
+  }
+
+  /**
+   * Assert the rewrite's structural contract without pinning the full optimized plan. Exact
+   * optimized-plan equality is brittle across the Spark versions Gluten supports, because
+   * optimizer-derived filters, alias names, and Project collapsing differ between versions. We
+   * verify only the invariant the rewrite owns: a `HAVING count > 1` [[Filter]] sitting directly
+   * over a COUNT(DISTINCT) [[Aggregate]]. We deliberately do not assert the plan has no join: the
+   * rewrite may legitimately leave other joins in place, including Pattern A2's preserved outer
+   * join, and `assertRuleFired` already proves the self-join inequality was the thing rewritten.
+   */
+  private def assertAggregateRewriteShape(plan: LogicalPlan): Unit =
+    assert(
+      hasAggregateRewriteShape(plan),
+      s"expected a HAVING count > 1 Filter directly over a COUNT(DISTINCT) Aggregate:\n$plan")
+
+  /** True iff a `HAVING count > 1` Filter sits directly over a COUNT(DISTINCT) Aggregate. */
+  private def hasAggregateRewriteShape(plan: LogicalPlan): Boolean = plan.exists {
+    case Filter(cond, agg: Aggregate) =>
+      val hasDistinctCount = agg.aggregateExpressions.exists(_.exists {
+        case ae: AggregateExpression => ae.isDistinct && ae.aggregateFunction.isInstanceOf[Count]
+        case _ => false
+      })
+      val hasGt1 = cond.exists {
+        case GreaterThan(_, Literal(v: Long, LongType)) => v == 1L
+        case _ => false
+      }
+      hasDistinctCount && hasGt1
+    case _ => false
+  }
+
+  /**
+   * A real table, so that a self-join of it dedups into two structurally identical sides. See the
+   * class comment for why a temp view over VALUES cannot be used for a self-joined fixture.
+   */
+  private def createTable(name: String, schema: String, values: String): Unit = {
+    spark.sql(s"DROP TABLE IF EXISTS $name")
+    spark.sql(s"CREATE TABLE $name($schema) USING parquet")
+    spark.sql(s"INSERT INTO $name SELECT * FROM VALUES $values")
+  }

Review Comment:
   The suite creates/drops real catalog tables with fixed names (e.g., `T`, `TM`, `TLeft`, etc.) but doesn’t scope cleanup to each test (beyond `DROP TABLE IF EXISTS` at creation time). If the build runs tests in parallel or shares the same metastore/warehouse across suites, this can cause cross-suite interference or intermittent failures. Consider using per-test unique table names (e.g., suffix with a UUID/test name) and/or `withTable(...)`-style guards to ensure cleanup even on test failures.



##########
docs/velox-configuration.md:
##########
@@ -84,6 +84,7 @@ nav_order: 16
 | spark.gluten.sql.columnar.backend.velox.valueStream.dynamicFilter.enabled        | 🔄 Dynamic    | false             | Whether to apply dynamic filters pushed down from hash probe in the ValueStream (shuffle reader) operator to filter rows before they reach the hash join.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 
                 |
 | spark.gluten.sql.enable.enhancedFeatures                                         | 🔄 Dynamic    | true              | Enable some features including iceberg native write and other features.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   
                 |
 | spark.gluten.sql.rewrite.castArrayToString                                       | 🔄 Dynamic    | true              | When true, rewrite `cast(array as String)` to `concat('[', array_join(array, ', ', null), ']')` to allow offloading to Velox.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             
                 |
+| spark.gluten.sql.rewrite.selfJoinInequality                                      | 🔄 Dynamic    | false             | When true, rewrite supported uncorrelated InSubquery self-joins with inequality predicate into GROUP BY + HAVING COUNT(DISTINCT) > 1. Currently targets Parquet-backed direct and nested self-join shapes exercised by TPC-DS Q95. Opt-in default (false) until the rewrite has been exercised more broadly across workloads.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             
                 |

Review Comment:
   The description includes SQL/logical concepts (`InSubquery`, `GROUP BY`, `HAVING`, `COUNT(DISTINCT) > 1`) that are easier to read (and less ambiguous) when formatted as inline code in Markdown. Updating the doc string to wrap those tokens in backticks would improve readability and consistency with nearby entries.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "beliefer (via GitHub)" <gi...@apache.org>.
beliefer commented on code in PR #12756:
URL: https://github.com/apache/gluten/pull/12756#discussion_r3871397200


##########
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:
##########
@@ -0,0 +1,674 @@
+/*
+ * 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.execution.datasources.{HadoopFsRelation, LogicalRelation}
+import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
+import org.apache.spark.sql.types.LongType
+
+/**
+ * Rewrites self-join with inequality into GROUP BY + HAVING COUNT(DISTINCT) > 1.
+ *
+ * Targets the two uncorrelated InSubquery shapes exercised by TPC-DS Q95:
+ *
+ *   - Pattern A': the subquery top-level InnerJoin is a direct self-join.
+ *   - Pattern A2: the subquery contains an outer InnerJoin with a self-join child; only the
+ *     self-join child is replaced with Aggregate and the outer join is preserved.
+ *
+ * Both patterns require an existence-only membership context so row-count multiplicity from the
+ * original self-join cross-product does not affect semantics. Correlated InSubquery expressions are
+ * intentionally fail-closed because the ExprId remapping performed here does not rewrite correlated
+ * predicates.
+ *
+ * Both patterns 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' / A2: rewrite uncorrelated InSubquery plans.
+    // Correlated subqueries carry outer references / correlated join conditions in
+    // `SubqueryExpression.children`; fail closed because this rule does not remap them.
+    val rewritten = plan.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
+        }
+    }
+    if (!(rewritten eq plan)) {
+      logDebug(
+        "RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
+          "GROUP BY + HAVING COUNT(DISTINCT) > 1")
+    }
+    rewritten
+  }
+  // ============================================================================
+  //  Shared helpers
+  // ============================================================================
+
+  /**
+   * 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 IN/NOT 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)),

Review Comment:
   The COUNT(DISTINCT neqCol) > 1 rewrite diverges from the original s1.neqCol <> s2.neqCol predicate for special float/double values:
   
   - -0.0 vs +0.0: SQL -0.0 = 0.0 is true, so -0.0 <> 0.0 is false. The original self-join produces no row. But Spark's COUNT(DISTINCT) uses bit-level/Java Double.equals which keeps -0.0 != 0.0, so COUNT(DISTINCT {-0.0, 0.0}) = 2. The rewrite fires and the row becomes IN when it should be OUT.
   - NaN: The reverse — two NaN rows satisfy NaN <> NaN in SQL (true), so the original matches; but Java NaN.equals(NaN) is true so COUNT(DISTINCT NaN) = 1 → the rewrite doesn't fire when it should.



-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org


Re: [PR] [VL] Rewrite selfjoin inequality to aggregate [gluten]

Posted by "hhr293 (via GitHub)" <gi...@apache.org>.
hhr293 commented on PR #12756:
URL: https://github.com/apache/gluten/pull/12756#issuecomment-5311143336

   @zhouyuan @FelixYBW  Hi, could you please help review this PR? Thanks a lot.


-- 
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: commits-unsubscribe@gluten.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@gluten.apache.org
For additional commands, e-mail: commits-help@gluten.apache.org