-
Notifications
You must be signed in to change notification settings - Fork 29.4k
[SPARK-59080][SQL] Pick one ShuffleSpecCollection member for the SPJ pushdown and the re-shuffle #58527
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
[SPARK-59080][SQL] Pick one ShuffleSpecCollection member for the SPJ pushdown and the re-shuffle #58527
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,7 +17,6 @@ | |
|
|
||
| package org.apache.spark.sql.execution.exchange | ||
|
|
||
| import scala.annotation.tailrec | ||
| import scala.collection.immutable.BitSet | ||
| import scala.collection.mutable | ||
| import scala.collection.mutable.ArrayBuffer | ||
|
|
@@ -247,30 +246,50 @@ case class EnsureRequirements( | |
| } | ||
| } | ||
|
|
||
| // A `ShuffleSpecCollection` answers `isCompatibleWith` if *any* of its members does, so the | ||
| // collection alone does not say which member the sides agreed on. The projection pushed into | ||
| // a compatible child and the partitioning built for a re-shuffled child both have to come | ||
| // from one member, otherwise the sides end up grouped on different keys, or on a key set the | ||
| // child does not even have. Pick that member once, preferring the finest when several | ||
| // qualify. Only the branch that shuffles a child reads these, hence `lazy`. | ||
| lazy val matchedIndexes = bestSpecOpt.toSeq.flatMap { best => | ||
| childrenIndexes.filter(i => best.isCompatibleWith(specs(i))) | ||
| } | ||
| lazy val bestMemberOpt = bestSpecOpt.flatMap { best => | ||
| val matchedMembers = matchedIndexes.map(i => flattenSpec(specs(i))) | ||
| // No member serving every matched child means there is no layout to align them on, so they | ||
| // all take the ordinary shuffle. That needs three or more clustered children, since with | ||
| // two the member that reported the match serves both, and no operator has three today. | ||
| flattenSpec(best) | ||
| .filter(m => matchedMembers.forall(_.exists(m.isCompatibleWith))) | ||
| .maxByOption(_.numPartitions) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch, and I took it into #58531 rather than a separate ticket. Two things I measured that are worth passing back. Your reachability framing is slightly off, in a way that does not change your conclusion. The shape is narrower than it looks, too. Two members can only differ in coverage if the clustering is wider than the partitioning arity, since the collection requires its members to have matching arity. With that, the repro is: each side offering a member for a different clustering-key subset, in the opposite order, so taking each side's first member pairs two that do not agree. On the fix that pairs correctly and no shuffle is needed at all; without it I also checked the sibling |
||
| } | ||
|
|
||
| children = children.zip(requiredChildDistributions).zipWithIndex.map { | ||
| case ((child, _), idx) if areChildrenCompatible || | ||
| !childrenIndexes.contains(idx) => | ||
| child | ||
| case ((child, dist), idx) => | ||
| if (bestSpecOpt.isDefined && bestSpecOpt.get.isCompatibleWith(specs(idx))) { | ||
| // If the child's partitioning is a `PartitioningCollection`, its spec is a | ||
| // `ShuffleSpecCollection` whose `createPartitioning` delegates to the head spec, | ||
| // so unwrap to the head spec to stay aligned with the re-shuffled side below. | ||
| unwrapSpecCollection(bestSpecOpt.get) match { | ||
| if (bestMemberOpt.isDefined && matchedIndexes.contains(idx)) { | ||
| // The positions come from this child's own matching member, since they index into its | ||
| // own partition expressions -- the chosen best member only says which member of it the | ||
| // two sides agreed on. | ||
| val bestMember = bestMemberOpt.get | ||
| flattenSpec(specs(idx)).find(bestMember.isCompatibleWith) match { | ||
| // If `areChildrenCompatible` is false, we can still perform SPJ | ||
| // by shuffling the other side based on join keys (see the else case below). | ||
| // Hence we need to ensure that after this call, the outputPartitioning of the | ||
| // partitioned side's BatchScanExec is grouped by join keys to match, | ||
| // and we do that by pushing down the join keys | ||
| case KeyedShuffleSpec(_, _, Some(joinKeyPositions)) => | ||
| case Some(KeyedShuffleSpec(_, _, Some(joinKeyPositions))) => | ||
| withJoinKeyPositions(child, joinKeyPositions) | ||
| case _ => child | ||
| } | ||
| } else { | ||
| val newPartitioning = bestSpecOpt.map { bestSpec => | ||
| val newPartitioning = bestMemberOpt.map { bestMember => | ||
| // Use the best spec to create a new partitioning to re-shuffle this child | ||
| val clustering = dist.asInstanceOf[ClusteredDistribution].clustering | ||
| bestSpec.createPartitioning(clustering) | ||
| bestMember.createPartitioning(clustering) | ||
| }.getOrElse { | ||
| // No best spec available, so we create default partitioning from the required | ||
| // distribution | ||
|
|
@@ -779,12 +798,10 @@ case class EnsureRequirements( | |
| } | ||
| } | ||
|
|
||
| // Unwraps a `ShuffleSpecCollection` (possibly nested) to the spec that its | ||
| // `createPartitioning` delegates to, i.e. the head spec. | ||
| @tailrec | ||
| private def unwrapSpecCollection(spec: ShuffleSpec): ShuffleSpec = spec match { | ||
| case ShuffleSpecCollection(specs) => unwrapSpecCollection(specs.head) | ||
| case other => other | ||
| // Flattens a (possibly nested) `ShuffleSpecCollection` into its member specs. | ||
| private def flattenSpec(spec: ShuffleSpec): Seq[ShuffleSpec] = spec match { | ||
| case ShuffleSpecCollection(specs) => specs.flatMap(flattenSpec) | ||
| case other => Seq(other) | ||
| } | ||
|
|
||
| /** | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bestSpecOptis still picked withShuffleSpecCollection.numPartitions, which comes fromspecs.head. A collection like [coarse head with 2 partitions, fine member with 200] competes as 2 in themaxBy, loses to a sibling child's plain 100-partition spec, and the side holding the fine member gets re-shuffled onto 100 partitions when it could have stayed unshuffled as the best. The selection line predates this PR, and in this shape it behaves exactly as before (the losing side took the same full re-shuffle), so this is purely pre-existing. Not blocking; worth a follow-up comparing best candidates at member level at the call site (changingnumPartitionsitself would affect other readers).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed, and this is exactly what I had queued as the follow-up: #58531 (SPARK-59256). It takes the max over the flattened members at the call site, for the reason you give — changing
numPartitionsitself would affect other readers.Worth naming the other reader, since it turned out to want a different aggregation:
SinglePartitionShuffleSpec.isCompatibleWithreadsother.numPartitions == 1, reached fromValidateRequirements, and there the right answer isexists, notmax. Two consumers wanting two different aggregations is what convinced me the collection should not answernumPartitionsat all rather than answer it better, so that PR removes it along withcreatePartitioning.