Skip to content

[VL] Rewrite selfjoin inequality to aggregate - #12756

Open
hhr293 wants to merge 2 commits into
apache:mainfrom
hhr293:rewrite-selfjoin-inequality-to-aggregate
Open

[VL] Rewrite selfjoin inequality to aggregate#12756
hhr293 wants to merge 2 commits into
apache:mainfrom
hhr293:rewrite-selfjoin-inequality-to-aggregate

Conversation

@hhr293

@hhr293 hhr293 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

What changes are proposed in this pull request?

Adds a Catalyst optimizer rule RewriteSelfJoinInequalityToAggregate for the Velox backend.

When the downstream context only cares about existence / membership semantics, the rule rewrites a class of inequality self-joins into GROUP BY + HAVING COUNT(DISTINCT ...) > 1.

Supported plan patterns

  • Pattern A' — InSubquery / Exists whose subquery contains a top-level inner self-join
  • Pattern A2 — nested: an outer InnerJoin with a self-join on one side; only the self-join subtree is replaced
  • Pattern A — a LeftSemi / LeftAnti already present in the input plan whose right child is an inner self-join

Benchmark Performance

On TPC-DS SF=300 (3 executors x 4 cores, Intel Xeon GNR), the observed
q95 result shows there is roughly up to ~150x reduction in the affected build-side rows/data volume and a ~69% reduction in q95 wall time.

The other 107 TPC-DS queries showed no material regression in the same test.

How was this patch tested?

Tests added in gluten

19 Scala tests in RewriteSelfJoinInequalityToAggregateSuite pass, covering:

  • Positive — EXISTS / IN with direct self-join (Pattern A')
  • Positive — nested InSubquery with outer InnerJoin on self-join child (Pattern A2)
  • Positive — correlated EXISTS / IN on a safely remappable equi-key
  • NULL / 3VL — rows with only-NULL or single-non-null inequality column excluded
  • Fail-closed — plain top-level InnerJoin, LeftOuter, IS DISTINCT FROM, IsNotNull on non-join column, multi-column inequality, equi/neq column overlap
  • Fail-closed — correlated predicate on the inequality column (s2.v) or non-key column (s2.w)
  • Fail-closed — Aggregate(first(v)), Window(ROW_NUMBER()), uncorrelated rand() filter inside subquery
  • Config gate — rule disabled when spark.gluten.sql.rewrite.selfJoinInequality=false

Local verification

A differential SQL harness was also used during development, comparing rewrite ON vs OFF over 50 targeted cases (NULL / 3VL, IN / NOT IN / EXISTS, multi-key joins, aliases, ExprId remap, unsupported predicates and data types, correlated subqueries, repeatability guards). All tests passed.

Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 4.7)

Copilot AI lite review requested due to automatic review settings August 12, 2026 09:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Adds a new Catalyst optimizer rule to rewrite inequality self-joins under existence-only semantics into a GROUP BY ... HAVING COUNT(DISTINCT ...) > 1 form to reduce intermediate row explosion and improve performance (e.g., TPC-DS q95).

Changes:

  • Introduces RewriteSelfJoinInequalityToAggregate optimizer rule and injects it into Velox rule API.
  • Adds an opt-in config flag spark.gluten.sql.rewrite.selfJoinInequality (default false).
  • Adds a dedicated Scala test suite covering positive, negative, config-gated, and NULL/3VL parity cases.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala Implements the rewrite rule for multiple existence-only patterns (A, A’, A2).
backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala Adds config plumbing + doc string for gating the rewrite.
backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxRuleApi.scala Registers the new optimizer rule in the Velox injector.
backends-velox/src/test/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregateSuite.scala Adds unit tests validating when the rule should/shouldn’t fire and semantic parity.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +602 to +613
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))
Comment on lines +217 to +223
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")
}
Comment on lines +101 to +104
if (!(rewritten eq plan)) {
logInfo("RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
"GROUP BY + HAVING COUNT(DISTINCT) > 1")
}
Comment on lines +161 to +175
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)
@github-actions github-actions Bot added the VELOX label Aug 12, 2026
@hhr293
hhr293 force-pushed the rewrite-selfjoin-inequality-to-aggregate branch from 1efcab9 to 55555d0 Compare August 12, 2026 10:48
Copilot AI review requested due to automatic review settings August 12, 2026 10:48
@github-actions github-actions Bot added the DOCS label Aug 12, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Suppressed comments (4)

backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:104

  • Using logInfo for optimizer-rule rewrites is likely too noisy in production (other rules in this module generally don’t log at INFO). Consider downgrading this message (and the other logInfo calls in this rule) to logDebug, or guarding with log.isDebugEnabled to avoid flooding driver logs when the rule fires frequently.
    if (!(rewritten eq plan)) {
      logInfo("RewriteSelfJoinInequalityToAggregate: rewrote self-join to " +
        "GROUP BY + HAVING COUNT(DISTINCT) > 1")
    }

backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:322

  • Same issue as above: building sjLeftEquiByName with .toMap can silently drop entries if equi-key names are duplicated (e.g., via upstream projection aliases), leading to incorrect remapping. Add a distinct-name guard (fail-closed) before constructing the map (or use ExprId-based mapping).
    val sjLeftEquiByName: Map[String, Attribute] = sjLeftEquiAttrs.map(a => a.name -> a).toMap

backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:510

  • nameToInnerLeft is keyed by column name and built via .toMap, which drops duplicates. If the inner equi-keys have duplicate names (possible when the self-join inputs are projections with repeated aliases), wrapper alias remapping can become ambiguous and incorrect. Add a distinct-name guard on innerLeftEquiAttrs before creating this map (or use ExprId-based mapping only).
    val nameToInnerLeft: Map[String, Attribute] =
      innerLeftEquiAttrs.map(a => a.name -> a).toMap

backends-velox/src/test/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregateSuite.scala:104

  • The test named "Pattern A': EXISTS subquery with bare self-join" isn’t actually exercising a self-join inside the subquery (it’s a correlated EXISTS over a single scan). That leaves the Exists + self-join path untested. Consider adjusting this test to use an EXISTS whose subquery body contains the self-join, so it actually covers the intended pattern.
  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")

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
hhr293 and others added 2 commits August 12, 2026 22:59
Adds a Catalyst optimizer rule that rewrites self-join with inequality
predicates (e.g. T JOIN T ON key=key AND col<>col) into GROUP BY key
HAVING COUNT(DISTINCT col) > 1, when consumed by an existence-only
operator (InSubquery, Exists, LeftSemi, LeftAnti).

The rewrite is valid because existence semantics only require the set of
distinct keys, not row multiplicity. The self-join produces O(N^2) rows
per equi-key group; the aggregate produces one row per group with the
same membership predicate.

Handles three structural patterns:
- Pattern A': InSubquery/Exists whose subquery top-level join is a
  direct self-join (primary path, fires before RewritePredicateSubquery)
- Pattern A2: InSubquery/Exists where the self-join is nested inside
  another InnerJoin within the subquery (only the self-join child is
  replaced; the outer join is preserved)
- Pattern A: LeftSemi/LeftAnti whose right child is an Inner self-join
  (matches input plans that already contain LeftSemi/LeftAnti such as explicit LEFT SEMI JOIN; this rule runs before RewriteSubquery via injectOptimizerRule and is not a fallback for A/A2)

Gated by spark.gluten.sql.rewrite.selfJoinInequality (opt-in, default
false) until the rewrite has been exercised more broadly across
workloads.

Tested on TPC-DS SF=300 (3 executors x 4 cores):
- q95 wall: 59.86s -> 18.64s (-69%)
- 108-query suite: no regression on the other 107 queries beyond the
  +/-2s noise band for a single run

Co-authored-by: Guo Wangyang <wangyang.guo@intel.com>
Co-authored-by: Lipeng Zhu <lipeng.zhu@intel.com>
Signed-off-by: hengrui hu <hengrui.hu@intel.com>
Adds RewriteSelfJoinInequalityToAggregateSuite covering:

Positive cases (rewrite fires):
- Pattern A': EXISTS whose subquery is a bare self-join
- Pattern A': InSubquery whose subquery is a bare self-join
- Pattern A2: self-join nested inside another InnerJoin

Semantic parity:
- NULL/3VL results identical to baseline (rule on vs off)

Negative cases (rewrite must NOT fire):
- Plain InnerJoin at top level (row multiplicity matters)
- IS DISTINCT FROM (NULL-safe inequality has different semantics)
- IsNotNull on a non-join column (would silently drop that filter)
- Multi-column inequality (not equivalent to count(distinct single_col) > 1)
- LeftOuter join (outside existence context)

Config gate:
- spark.gluten.sql.rewrite.selfJoinInequality=false disables the rule

Co-authored-by: Guo Wangyang <wangyang.guo@intel.com>
Co-authored-by: Lipeng Zhu <lipeng.zhu@intel.com>
Signed-off-by: hengrui hu <hengrui.hu@intel.com>
Copilot AI review requested due to automatic review settings August 13, 2026 06:02
@hhr293
hhr293 force-pushed the rewrite-selfjoin-inequality-to-aggregate branch from 55555d0 to 58b7d96 Compare August 13, 2026 06:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Suppressed comments (4)

backends-velox/src/test/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregateSuite.scala:95

  • This test name says it covers a “bare self-join”, but the SQL only scans T once and uses a correlated inequality (s.v <> ws1.v), so it doesn’t exercise the self-join rewrite path. Either rename the test to reflect what it actually covers, or change the SQL to include a self-join if the intent is to validate Pattern A' for EXISTS.
  test("Pattern A': EXISTS subquery with bare self-join produces equivalent results") {

backends-velox/src/main/scala/org/apache/gluten/extension/RewriteSelfJoinInequalityToAggregate.scala:300

  • Pattern A' currently requires a wrapping Project whose projectList entries are equi-key attributes/aliases (via canonicalizeWrapper). That means common EXISTS forms like EXISTS (SELECT 1 FROM t1 JOIN t2 ...) (Project of a literal) will be rejected here, even though the config/docs and scaladoc claim support for Exists. Consider either (a) relaxing the wrapper handling for EXISTS when the Project doesn’t reference join outputs, or (b) clarifying in docs/scaladoc which EXISTS shapes are actually supported (e.g., only after subquery rewrite into LeftSemi/LeftAnti).
    projectListOpt match {
      case None =>
        None
      case Some(pl) =>
        canonicalizeWrapper(pl, equiPairs, filtered).map {

docs/velox-configuration.md:87

  • This config description states the rewrite applies to Exists, but the current implementation’s Pattern A' path requires the subquery to project equi-key attributes (so a typical EXISTS (SELECT 1 ...) shape won’t match). Suggest clarifying the docs here to avoid over-promising (or update the rule to support the common EXISTS projection shape).
| spark.gluten.sql.rewrite.selfJoinInequality                                      | 🔄 Dynamic    | false             | When true, rewrite self-join with inequality predicate into GROUP BY + HAVING COUNT(DISTINCT) > 1 under existence semantics (InSubquery, Exists, LeftSemi, LeftAnti). Opt-in default (false) until the rewrite has been exercised more broadly across workloads.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |

backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala:861

  • The config docstring here lists Exists as a supported context. Given Pattern A' currently requires the subquery to project equi keys (so EXISTS (SELECT 1 ...) is typically rejected), it’d be better to either clarify the supported EXISTS shapes in this docstring or drop Exists until it’s fully supported.
      .doc("When true, rewrite self-join with inequality predicate into" +
        " GROUP BY + HAVING COUNT(DISTINCT) > 1 under existence semantics" +
        " (InSubquery, Exists, LeftSemi, LeftAnti). Opt-in default (false)" +
        " until the rewrite has been exercised more broadly across workloads.")

@hhr293 hhr293 changed the title Rewrite selfjoin inequality to aggregate [VL] Rewrite selfjoin inequality to aggregate Aug 13, 2026
@hhr293

hhr293 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants