Skip to content

Commit 267c9f7

Browse files
committed
[SQL] Optimize DeduplicateRelations for wide plans
1 parent c63bebe commit 267c9f7

5 files changed

Lines changed: 133 additions & 42 deletions

File tree

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/DeduplicateRelations.scala

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ import org.apache.spark.sql.internal.SQLConf
2929
object DeduplicateRelations extends Rule[LogicalPlan] {
3030
type ExprIdMap = mutable.HashMap[Class[_], mutable.HashSet[Long]]
3131

32+
/** Renews `right` against expression IDs collected from `left`. */
33+
private[sql] def deduplicateRight(left: LogicalPlan, right: LogicalPlan): LogicalPlan = {
34+
val existingRelations = mutable.HashMap.empty[Class[_], mutable.HashSet[Long]]
35+
renewDuplicatedRelations(existingRelations, left)
36+
renewDuplicatedRelations(existingRelations, right)._1
37+
}
38+
3239
override def apply(plan: LogicalPlan): LogicalPlan = {
3340
val newPlan = renewDuplicatedRelations(mutable.HashMap.empty, plan)._1
3441

@@ -67,24 +74,25 @@ object DeduplicateRelations extends Rule[LogicalPlan] {
6774
DeduplicateUnionChildOutput.deduplicateOutputPerChild(u)
6875
// Use projection-based de-duplication for Union to avoid breaking the checkpoint sharing
6976
// feature in streaming.
70-
val newChildren =
71-
unionWithChildOutputsDeduplicated.children.foldRight(Seq.empty[LogicalPlan]) {
72-
(head, tail) =>
73-
head +: tail.map {
74-
case child if head.outputSet.intersect(child.outputSet).isEmpty =>
75-
child
76-
case child =>
77-
val projectList = child.output.map { attr =>
78-
Alias(attr, attr.name)()
79-
}
80-
val project = Project(projectList, child)
81-
project.setTagValue(
82-
ResolverTag.PROJECT_FOR_EXPRESSION_ID_DEDUPLICATION,
83-
()
84-
)
85-
project
86-
}
77+
val seenExprIds = mutable.HashSet.empty[Long]
78+
val newChildren = unionWithChildOutputsDeduplicated.children.map { child =>
79+
val childOutput = child.output
80+
val hasConflictingExprId = childOutput.exists(attr => seenExprIds(attr.exprId.id))
81+
childOutput.foreach(attr => seenExprIds += attr.exprId.id)
82+
if (hasConflictingExprId) {
83+
val projectList = childOutput.map { attr =>
84+
Alias(attr, attr.name)()
85+
}
86+
val project = Project(projectList, child)
87+
project.setTagValue(
88+
ResolverTag.PROJECT_FOR_EXPRESSION_ID_DEDUPLICATION,
89+
()
90+
)
91+
project
92+
} else {
93+
child
8794
}
95+
}
8896
unionWithChildOutputsDeduplicated.copy(children = newChildren)
8997
case merge: MergeIntoTable
9098
if !merge.duplicateResolved && noMissingInput(merge.sourceTable) =>

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/InlineCTE.scala

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ import scala.collection.mutable
2222
import org.apache.spark.SparkException
2323
import org.apache.spark.sql.catalyst.analysis.DeduplicateRelations
2424
import org.apache.spark.sql.catalyst.expressions.{Alias, OuterReference, OuterScopeReference, SubqueryExpression}
25-
import org.apache.spark.sql.catalyst.plans.Inner
26-
import org.apache.spark.sql.catalyst.plans.logical.{CTERelationDef, CTERelationRef, Join, JoinHint, LogicalPlan, Project, Subquery, UnionLoop, WithCTE}
25+
import org.apache.spark.sql.catalyst.plans.logical.{CTERelationDef, CTERelationRef, LogicalPlan, Project, Subquery, UnionLoop, WithCTE}
2726
import org.apache.spark.sql.catalyst.rules.Rule
2827
import org.apache.spark.sql.catalyst.trees.TreePattern.{CTE, PLAN_EXPRESSION}
2928

@@ -278,15 +277,7 @@ case class InlineCTE(
278277
if (ref.outputSet == refInfo.cteDef.outputSet) {
279278
cteBody
280279
} else {
281-
val ctePlan = DeduplicateRelations(
282-
Join(
283-
cteBody,
284-
cteBody,
285-
Inner,
286-
None,
287-
JoinHint(None, None)
288-
)
289-
).children(1)
280+
val ctePlan = DeduplicateRelations.deduplicateRight(cteBody, cteBody)
290281
val projectList = ref.output.zip(ctePlan.output).map { case (tgtAttr, srcAttr) =>
291282
if (srcAttr.semanticEquals(tgtAttr)) {
292283
tgtAttr

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/PushDownJoinThroughUnion.scala

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717

1818
package org.apache.spark.sql.catalyst.optimizer
1919

20-
import org.apache.spark.SparkException
2120
import org.apache.spark.sql.catalyst.analysis.DeduplicateRelations
2221
import org.apache.spark.sql.catalyst.expressions._
2322
import org.apache.spark.sql.catalyst.plans._
@@ -141,17 +140,9 @@ case class PushDownJoinThroughUnion(override val conf: SQLConf)
141140
}
142141

143142
/**
144-
* Creates a copy of `plan` with fresh ExprIds on all output attributes,
145-
* using the same "fake self-join + DeduplicateRelations" pattern as InlineCTE.
143+
* Creates a copy of `plan` with fresh ExprIds on all output attributes.
146144
*/
147145
private def dedupRight(plan: LogicalPlan): LogicalPlan = {
148-
DeduplicateRelations(
149-
Join(plan, plan, Inner, None, JoinHint.NONE)
150-
) match {
151-
case Join(_, deduped, _, _, _) => deduped
152-
case other =>
153-
throw SparkException.internalError(
154-
s"Unexpected plan shape after DeduplicateRelations: ${other.getClass.getName}")
155-
}
146+
DeduplicateRelations.deduplicateRight(plan, plan)
156147
}
157148
}

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/ReplaceCTERefWithRepartition.scala

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import scala.collection.mutable
2222
import org.apache.spark.SparkException
2323
import org.apache.spark.sql.catalyst.analysis.DeduplicateRelations
2424
import org.apache.spark.sql.catalyst.expressions.{Alias, SubqueryExpression}
25-
import org.apache.spark.sql.catalyst.plans.Inner
2625
import org.apache.spark.sql.catalyst.plans.logical._
2726
import org.apache.spark.sql.catalyst.rules.Rule
2827
import org.apache.spark.sql.catalyst.trees.TreePattern.{CTE, PLAN_EXPRESSION}
@@ -74,8 +73,7 @@ object ReplaceCTERefWithRepartition extends Rule[LogicalPlan] {
7473
if (ref.outputSet == cteDefPlan.outputSet) {
7574
cteDefPlan
7675
} else {
77-
val ctePlan = DeduplicateRelations(
78-
Join(cteDefPlan, cteDefPlan, Inner, None, JoinHint(None, None))).children(1)
76+
val ctePlan = DeduplicateRelations.deduplicateRight(cteDefPlan, cteDefPlan)
7977
val projectList = ref.output.zip(ctePlan.output).map { case (tgtAttr, srcAttr) =>
8078
Alias(srcAttr, tgtAttr.name)(exprId = tgtAttr.exprId)
8179
}

sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/AnalysisSuite.scala

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
package org.apache.spark.sql.catalyst.analysis
1919

20+
import java.lang.management.ManagementFactory
2021
import java.util.{TimeZone, UUID}
2122

2223
import scala.jdk.CollectionConverters._
@@ -553,6 +554,108 @@ class AnalysisSuite extends AnalysisTest with Matchers {
553554
assertAnalysisSuccess(r2)
554555
}
555556

557+
test("DeduplicateRelations preserves union branch order and overlapping outputs") {
558+
case class TestLeaf(label: String, override val output: Seq[Attribute]) extends LeafNode
559+
560+
val a = AttributeReference("a", IntegerType)()
561+
val b = AttributeReference("b", IntegerType)()
562+
val c = AttributeReference("c", IntegerType)()
563+
val d = AttributeReference("d", IntegerType)()
564+
val first = TestLeaf("first", Seq(a, b))
565+
val second = TestLeaf("second", Seq(a, c))
566+
val third = TestLeaf("third", Seq(c, d))
567+
568+
val result = DeduplicateRelations(Union(Seq(first, second, third))).asInstanceOf[Union]
569+
570+
assert(result.children.head eq first)
571+
assert(result.children(1).asInstanceOf[Project].child eq second)
572+
assert(result.children(2).asInstanceOf[Project].child eq third)
573+
assert(result.children.tail.forall(_.getTagValue(
574+
resolver.ResolverTag.PROJECT_FOR_EXPRESSION_ID_DEDUPLICATION).contains(())))
575+
assert(result.children.flatMap(_.output).map(_.exprId).distinct.length == 6)
576+
}
577+
578+
test("DeduplicateRelations preserves streaming union children under tagged projections") {
579+
case class StreamingLeaf(override val output: Seq[Attribute]) extends LeafNode {
580+
override def isStreaming: Boolean = true
581+
}
582+
583+
val sharedOutput = Seq(AttributeReference("a", IntegerType)())
584+
val first = StreamingLeaf(sharedOutput)
585+
val second = StreamingLeaf(sharedOutput)
586+
587+
val result = DeduplicateRelations(Union(Seq(first, second))).asInstanceOf[Union]
588+
val project = result.children(1).asInstanceOf[Project]
589+
590+
assert(result.children.head eq first)
591+
assert(project.child eq second)
592+
assert(project.getTagValue(
593+
resolver.ResolverTag.PROJECT_FOR_EXPRESSION_ID_DEDUPLICATION).contains(()))
594+
}
595+
596+
test("DeduplicateRelations union work scales linearly with branch count") {
597+
case class TestLeaf(override val output: Seq[Attribute]) extends LeafNode
598+
599+
val bean = ManagementFactory.getThreadMXBean.asInstanceOf[com.sun.management.ThreadMXBean]
600+
if (!bean.isThreadAllocatedMemoryEnabled) {
601+
bean.setThreadAllocatedMemoryEnabled(true)
602+
}
603+
val threadId = Thread.currentThread().getId
604+
val sharedOutput = (0 until 26).map(i => AttributeReference(s"c$i", IntegerType)())
605+
606+
def allocatedBytes(branchCount: Int): Long = {
607+
val union = Union(Seq.fill(branchCount)(TestLeaf(sharedOutput)))
608+
val before = bean.getThreadAllocatedBytes(threadId)
609+
DeduplicateRelations(union)
610+
bean.getThreadAllocatedBytes(threadId) - before
611+
}
612+
613+
allocatedBytes(10)
614+
val small = Seq.fill(3)(allocatedBytes(100)).min
615+
val large = Seq.fill(3)(allocatedBytes(500)).min
616+
617+
assert(large <= small * 7,
618+
s"100 branches allocated $small bytes, while 500 branches allocated $large bytes")
619+
}
620+
621+
test("deduplicateRight matches fake self-join semantics with less scaling overhead") {
622+
def wideProject(width: Int): LogicalPlan = {
623+
val relation = LocalRelation(AttributeReference("a", IntegerType)())
624+
Project((0 until width).map(i => Alias(relation.output.head, s"c$i")()), relation)
625+
}
626+
627+
val semanticPlan = wideProject(10)
628+
val throughJoin = DeduplicateRelations(
629+
Join(semanticPlan, semanticPlan, Inner, None, JoinHint.NONE)).children(1)
630+
val direct = DeduplicateRelations.deduplicateRight(semanticPlan, semanticPlan)
631+
comparePlans(direct, throughJoin, checkAnalysis = false)
632+
633+
val bean = ManagementFactory.getThreadMXBean.asInstanceOf[com.sun.management.ThreadMXBean]
634+
if (!bean.isThreadAllocatedMemoryEnabled) {
635+
bean.setThreadAllocatedMemoryEnabled(true)
636+
}
637+
val threadId = Thread.currentThread().getId
638+
639+
def allocatedBytes(width: Int, useDirectPath: Boolean): Long = {
640+
val plan = wideProject(width)
641+
val before = bean.getThreadAllocatedBytes(threadId)
642+
if (useDirectPath) {
643+
DeduplicateRelations.deduplicateRight(plan, plan)
644+
} else {
645+
DeduplicateRelations(Join(plan, plan, Inner, None, JoinHint.NONE)).children(1)
646+
}
647+
bean.getThreadAllocatedBytes(threadId) - before
648+
}
649+
650+
allocatedBytes(10, useDirectPath = true)
651+
allocatedBytes(10, useDirectPath = false)
652+
val directLarge = Seq.fill(3)(allocatedBytes(500, useDirectPath = true)).min
653+
val throughJoinLarge = Seq.fill(3)(allocatedBytes(500, useDirectPath = false)).min
654+
655+
assert(directLarge * 3 < throughJoinLarge * 2,
656+
s"direct path allocated $directLarge bytes, fake self-join allocated $throughJoinLarge bytes")
657+
}
658+
556659
test("resolve as with an already existed alias") {
557660
checkAnalysis(
558661
Project(Seq(UnresolvedAttribute("tbl2.a")),

0 commit comments

Comments
 (0)