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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
package org.apache.spark.sql.catalyst.optimizer

import org.apache.spark.SparkException
import org.apache.spark.sql.catalyst.expressions.{Alias, And, ArrayDistinct, ArrayExcept, ArrayIntersect, ArraysOverlap, ArrayTransform, ArrayUnion, CaseWhen, Coalesce, CreateArray, CreateMap, CreateNamedStruct, EqualTo, ExpectsInputTypes, Expression, GetStructField, If, IsNull, KnownFloatingPointNormalized, LambdaFunction, Literal, NamedLambdaVariable, TransformValues, UnaryExpression}
import org.apache.spark.sql.catalyst.expressions.{Alias, And, ArrayDistinct, ArrayExcept, ArrayIntersect, ArraysOverlap, ArrayTransform, ArrayUnion, CaseWhen, Coalesce, CreateArray, CreateMap, CreateNamedStruct, EqualTo, ExpectsInputTypes, Expression, GetStructField, If, IsNull, KnownFloatingPointNormalized, LambdaFunction, Literal, NamedLambdaVariable, Or, TransformValues, UnaryExpression}
import org.apache.spark.sql.catalyst.expressions.codegen.{CodegenContext, ExprCode}
import org.apache.spark.sql.catalyst.planning.ExtractEquiJoinKeys
import org.apache.spark.sql.catalyst.planning.{ExtractEquiJoinKeys, ExtractSingleColumnNullAwareAntiJoin}
import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, Window}
import org.apache.spark.sql.catalyst.rules.Rule
import org.apache.spark.sql.catalyst.trees.TreePattern._
Expand Down Expand Up @@ -94,6 +94,12 @@ object NormalizeFloatingNumbers extends Rule[LogicalPlan] {
} ++ condition
j.copy(condition = Some(newConditions.reduce(And)))

// The specialized NAAJ is a hash join, but its OR condition is not an equi-join shape.
case j @ ExtractSingleColumnNullAwareAntiJoin(leftKeys, rightKeys)
if leftKeys.exists(needNormalize) =>
val equality = EqualTo(normalize(leftKeys.head), normalize(rightKeys.head))
j.copy(condition = Some(Or(equality, IsNull(equality))))

// TODO: ideally Aggregate should also be handled here, but its grouping expressions are
// mixed in its aggregate expressions. It's unreliable to change the grouping expressions
// here. For now we normalize grouping expressions during planning. See Case 2 in the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
package org.apache.spark.sql.catalyst.optimizer

import org.apache.spark.sql.catalyst.expressions._
import org.apache.spark.sql.catalyst.planning.ExtractSingleColumnNullAwareAntiJoin
import org.apache.spark.sql.catalyst.plans._
import org.apache.spark.sql.catalyst.plans.logical._
import org.apache.spark.sql.catalyst.rules.Rule
Expand Down Expand Up @@ -61,11 +60,12 @@ object PushDownLeftSemiAntiJoin extends Rule[LogicalPlan]
}
}

// LeftSemi/LeftAnti over Aggregate
// LeftSemi/LeftAnti over Aggregate, only push down if join can be planned as broadcast join.
case join @ Join(agg: Aggregate, rightOp, LeftSemiOrAnti(_), joinCond, _)
if agg.aggregateExpressions.forall(_.deterministic) && agg.groupingExpressions.nonEmpty &&
!agg.aggregateExpressions.exists(ScalarSubquery.hasCorrelatedScalarSubquery) &&
canPushThroughCondition(agg.children, joinCond, rightOp) =>
canPushThroughCondition(agg.children, joinCond, rightOp) &&
canPlanAsBroadcastHashJoin(join, conf) =>
val aliasMap = getAliasMap(agg)
val canPushDownPredicate = (predicate: Expression) => {
val replaced = replaceAlias(predicate, aliasMap)
Expand All @@ -75,20 +75,7 @@ object PushDownLeftSemiAntiJoin extends Rule[LogicalPlan]
val makeJoinCondition = (predicates: Seq[Expression]) => {
replaceAlias(predicates.reduce(And), aliasMap)
}
val canPushDownJoin = if (ExtractSingleColumnNullAwareAntiJoin.extract(join).isDefined) {
val originalIsBroadcastHash =
NullAwareAntiJoinPlanning.decide(join, conf) == NullAwareAntiJoinPlanning.BroadcastHash
(pushedJoin: Join) => originalIsBroadcastHash &&
NullAwareAntiJoinPlanning.decide(pushedJoin, conf) ==
NullAwareAntiJoinPlanning.BroadcastHash
} else {
(_: Join) => canPlanAsBroadcastHashJoin(join, conf)
}
pushDownJoin(
join,
canPushDownPredicate,
makeJoinCondition,
canPushDownJoin)
pushDownJoin(join, canPushDownPredicate, makeJoinCondition)

// LeftSemi/LeftAnti over Window
case join @ Join(w: Window, rightOp, LeftSemiOrAnti(_), _, _)
Expand Down Expand Up @@ -146,17 +133,11 @@ object PushDownLeftSemiAntiJoin extends Rule[LogicalPlan]
private def pushDownJoin(
join: Join,
canPushDownPredicate: Expression => Boolean,
makeJoinCondition: Seq[Expression] => Expression,
canPushDownJoin: Join => Boolean = _ => true): LogicalPlan = {
makeJoinCondition: Seq[Expression] => Expression): LogicalPlan = {
assert(join.left.children.length == 1)

if (join.condition.isEmpty) {
val pushedJoin = join.copy(left = join.left.children.head)
if (canPushDownJoin(pushedJoin)) {
join.left.withNewChildren(Seq(pushedJoin))
} else {
join
}
join.left.withNewChildren(Seq(join.copy(left = join.left.children.head)))
} else {
val (pushDown, stayUp) = splitConjunctivePredicates(join.condition.get)
.partition(canPushDownPredicate)
Expand All @@ -170,25 +151,19 @@ object PushDownLeftSemiAntiJoin extends Rule[LogicalPlan]
if (pushDown.isEmpty || referRightSideCols) {
join
} else {
val pushedJoin = join.copy(
left = join.left.children.head, condition = Some(makeJoinCondition(pushDown)))
if (!canPushDownJoin(pushedJoin)) {
join
val newPlan = join.left.withNewChildren(Seq(join.copy(
left = join.left.children.head, condition = Some(makeJoinCondition(pushDown)))))
// If there is no more filter to stay up, return the new plan that has join pushed down.
if (stayUp.isEmpty) {
newPlan
} else {
val newPlan = join.left.withNewChildren(Seq(pushedJoin))
// If no predicates remain above the join, return the plan with the join pushed down.
if (stayUp.isEmpty) {
newPlan
} else {
join.joinType match {
// For a left semi join, the non-pushable part of the condition is kept as a Filter
// above the join.
case LeftSemi => Filter(stayUp.reduce(And), newPlan)
// In the case of a left anti join, the join is pushed down only when the entire join
// condition is eligible to be pushed down to preserve the semantics of the left anti
// join.
case _ => join
}
join.joinType match {
// In case of Left semi join, the part of the join condition which does not refer to
// to attributes of the grandchild are kept as a Filter above.
case LeftSemi => Filter(stayUp.reduce(And), newPlan)
// In case of left-anti join, the join is pushed down only when the entire join
// condition is eligible to be pushed down to preserve the semantics of left-anti join.
case _ => join
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -353,66 +353,6 @@ trait JoinSelectionHelper extends Logging {
}
}

def getBroadcastNestedLoopJoinDesiredBuildSide(join: Join): BuildSide = {
if (join.joinType.isInstanceOf[InnerLike] || join.joinType == FullOuter) {
getSmallerSide(join.left, join.right)
} else {
// For perf reasons, BroadcastNestedLoopJoinExec prefers to broadcast the left side for a
// right join and the right side for a left join. If one side is much smaller, revisiting
// that preference may be worthwhile.
if (canBuildBroadcastLeft(join.joinType)) BuildLeft else BuildRight
}
}

def getBroadcastNestedLoopJoinBuildSide(
join: Join,
hintOnly: Boolean,
conf: SQLConf): Option[BuildSide] = {
lazy val buildLeft = if (hintOnly) {
hintToBroadcastLeft(join.hint)
} else {
canBroadcastBySize(join.left, conf) &&
!hintToNotBroadcastAndReplicateLeft(join.hint)
}
lazy val buildRight = if (hintOnly) {
hintToBroadcastRight(join.hint)
} else {
canBroadcastBySize(join.right, conf) &&
!hintToNotBroadcastAndReplicateRight(join.hint)
}

if (join.joinType.isInstanceOf[InnerLike] || join.joinType == FullOuter) {
if (buildLeft && buildRight) {
Some(getBroadcastNestedLoopJoinDesiredBuildSide(join))
} else if (buildLeft) {
Some(BuildLeft)
} else if (buildRight) {
Some(BuildRight)
} else {
None
}
} else {
getBroadcastNestedLoopJoinDesiredBuildSide(join) match {
case BuildLeft =>
if (buildLeft) Some(BuildLeft) else if (buildRight) Some(BuildRight) else None
case BuildRight =>
if (buildRight) Some(BuildRight) else if (buildLeft) Some(BuildLeft) else None
}
}
}

def getBroadcastNestedLoopJoinBuildSide(join: Join, conf: SQLConf): BuildSide = {
val hintedBuildSide = if (join.hint.isEmpty) {
None
} else {
getBroadcastNestedLoopJoinBuildSide(join, hintOnly = true, conf)
}
hintedBuildSide
.orElse(getBroadcastNestedLoopJoinBuildSide(join, hintOnly = false, conf))
.orElse(getBroadcastNestedLoopJoinBuildSide(join.hint, join.joinType))
.getOrElse(getBroadcastNestedLoopJoinDesiredBuildSide(join))
}

def getSmallerSide(left: LogicalPlan, right: LogicalPlan): BuildSide = {
if (right.stats.sizeInBytes <= left.stats.sizeInBytes) BuildRight else BuildLeft
}
Expand Down Expand Up @@ -498,9 +438,12 @@ trait JoinSelectionHelper extends Logging {
getBroadcastBuildSide(join, hintOnly = true, conf).orElse {
if (noShufflePlannedBefore) getBroadcastBuildSide(join, hintOnly = false, conf) else None
}
case j if ExtractSingleColumnNullAwareAntiJoin.extract(j).isDefined =>
if (NullAwareAntiJoinPlanning.decide(j, conf) ==
NullAwareAntiJoinPlanning.BroadcastHash) {
// `JoinSelection` always builds from the right for this shape. A negative threshold preserves
// the original unbounded NAAJ behavior, while zero disables the broadcast hash optimization.
case j @ ExtractSingleColumnNullAwareAntiJoin(_, _) =>
val threshold = conf.nullAwareAntiJoinBroadcastThreshold
val rightSize = j.right.stats.sizeInBytes
if (threshold < 0 || (threshold > 0 && rightSize >= 0 && rightSize <= threshold)) {
Some(BuildRight)
} else {
None
Expand Down Expand Up @@ -628,19 +571,3 @@ trait JoinSelectionHelper extends Logging {
conf.getConfString("spark.sql.join.forceApplyShuffledHashJoin", "false") == "true"
}
}

private[sql] object NullAwareAntiJoinPlanning extends JoinSelectionHelper {
sealed trait Decision
case object BroadcastHash extends Decision
case object BroadcastNestedLoop extends Decision

def decide(join: Join, conf: SQLConf): Decision = {
if (conf.optimizeNullAwareAntiJoin &&
canBroadcastBySize(join.right, conf) &&
getBroadcastNestedLoopJoinBuildSide(join, conf) == BuildRight) {
BroadcastHash
} else {
BroadcastNestedLoop
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -404,11 +404,12 @@ object ExtractSingleColumnNullAwareAntiJoin extends JoinSelectionHelper with Pre
* But if it's a single column case O(M*N) calculation could be optimized into O(M)
* using hash lookup instead of loop lookup.
*/
private[sql] def extract(join: Join): Option[ReturnType] = join match {
def unapply(join: Join): Option[ReturnType] = join match {
case Join(left, right, LeftAnti,
Some(Or(e @ EqualTo(leftAttr: Expression, rightAttr: Expression),
IsNull(e2 @ EqualTo(_, _)))), _)
if e.semanticEquals(e2) =>
if SQLConf.get.optimizeNullAwareAntiJoin &&
e.semanticEquals(e2) =>
if (canEvaluate(leftAttr, left) && canEvaluate(rightAttr, right)) {
Some(Seq(leftAttr), Seq(rightAttr))
} else if (canEvaluate(leftAttr, right) && canEvaluate(rightAttr, left)) {
Expand All @@ -418,10 +419,6 @@ object ExtractSingleColumnNullAwareAntiJoin extends JoinSelectionHelper with Pre
}
case _ => None
}

def unapply(join: Join): Option[ReturnType] = {
if (SQLConf.get.optimizeNullAwareAntiJoin) extract(join) else None
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7366,15 +7366,34 @@ object SQLConf {
val OPTIMIZE_NULL_AWARE_ANTI_JOIN =
buildConf("spark.sql.optimizeNullAwareAntiJoin")
.internal()
.doc("When true, NULL-aware anti join execution will be planed into " +
.doc("When true, NULL-aware anti join execution can be planned as " +
"BroadcastHashJoinExec with flag isNullAwareAntiJoin enabled, " +
"optimized from O(M*N) calculation into O(M) calculation " +
"using Hash lookup instead of Looping lookup. " +
"Only support for singleColumn NAAJ for now.")
"Only support for singleColumn NAAJ for now. The optimization is also controlled by " +
"spark.sql.optimizeNullAwareAntiJoin.broadcastThreshold.")
.version("3.1.0")
.booleanConf
.createWithDefault(true)

val NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD =
buildConf("spark.sql.optimizeNullAwareAntiJoin.broadcastThreshold")

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.

Negative meaning unlimited inverts the broadcast-threshold family: autoBroadcastJoinThreshold and its adaptive twin both document -1 as the way to disable broadcasting, and canBroadcastBySize (joins.scala:370) refuses any negative size. An operator whose driver is dying on a NAAJ broadcast will set -1 and get the size limit removed instead. bytesConf strips the sign, so -2g lands in the same bucket.

createOptional avoids the collision: unset means no limit, a set value is a real limit, and disabling stays with spark.sql.optimizeNullAwareAntiJoin. One reader to update, plus the -2 assertion in JoinSelectionHelperSuite. If the current semantics stay, the doc should say they read the opposite way from autoBroadcastJoinThreshold.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for raising this. I prefer to keep the current -1 semantics. This threshold is not intended to follow spark.sql.autoBroadcastJoinThreshold: disabling the NAAJ optimization is already controlled by spark.sql.optimizeNullAwareAntiJoin=false, while this threshold needs an unbounded default to restore the original behavior. Long.MaxValue is not sufficient because sizeInBytes is a BigInt; the threshold < 0 branch is genuinely unbounded and covers estimates above Long.MaxValue. createOptional would encode the same unbounded state as None, but would not improve correctness here. The config is internal and its documentation explicitly calls out the negative-value semantics, so I prefer to keep -1.

.internal()
.doc("Configures the maximum estimated size in bytes of the right side of a " +

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.

The doc only says the fallback "may still use a broadcast nested loop join", which reads as if lowering this config can stop a large right side from being broadcast. With no broadcast hint there is nothing else to pick for this shape: the condition is non-equi so no SMJ/SHJ is available, canBuildBroadcastLeft(LeftAnti) is false, and when neither side fits, the final fallback branch in createJoinWithoutHint broadcasts the right side anyway as an Array[InternalRow] rather than a compact HashedRelation, plus O(M*N) matching. With a 5GB left and right and the default 10MB autoBroadcastJoinThreshold, setting it to 100m costs more memory, not less.

The PR description explains this; the config doc, which is usually all an operator sees via SET, does not. Worth a clause in the doc too: canPlanAsBroadcastHashJoin is the pushdown guard in PushDownLeftSemiAntiJoin (:68) too, so this threshold also decides whether a NAAJ gets pushed below an Aggregate. Non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated the config doc in 0b7c77b. It now explains that the fallback may broadcast the right side using a nested-loop representation that consumes more memory and runs in O(M * N) time, and that the threshold also controls aggregate pushdown.

"single-column null-aware anti join for which Spark uses the broadcast hash join " +
"optimization. This configuration takes effect only when " +
"spark.sql.optimizeNullAwareAntiJoin is enabled. A negative value allows the " +
"optimization regardless of the estimated size, while zero disables it. If the " +
"estimated size exceeds a positive value, Spark falls back to regular join planning. " +
"The fallback may still broadcast the right side with a nested-loop representation " +
"that uses more memory and runs in O(M * N) time. Join hints do not override this " +
"configuration when the broadcast hash optimization is selected. This configuration " +
"also controls whether a null-aware anti join can be pushed below an aggregate.")
.version("4.2.1")
.withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
.bytesConf(ByteUnit.BYTE)
.createWithDefault(-1)

val LEGACY_DUPLICATE_BETWEEN_INPUT =
buildConf("spark.sql.legacy.duplicateBetweenInput")
.internal()
Expand Down Expand Up @@ -9743,6 +9762,9 @@ class SQLConf extends Serializable with Logging with SqlApiConf {
def optimizeNullAwareAntiJoin: Boolean =
getConf(SQLConf.OPTIMIZE_NULL_AWARE_ANTI_JOIN)

def nullAwareAntiJoinBroadcastThreshold: Long =
getConf(SQLConf.NULL_AWARE_ANTI_JOIN_BROADCAST_THRESHOLD)

def legacyDuplicateBetweenInput: Boolean =
getConf(SQLConf.LEGACY_DUPLICATE_BETWEEN_INPUT)

Expand Down
Loading