cloud-fan commented on code in PR #58659:
URL: https://github.com/apache/spark/pull/58659#discussion_r4038851069


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -561,34 +415,286 @@ case class GroupPartitionsExec(
     val joinKeyStr = joinKeyPositions.map { p =>
       s"JoinKeyPositions: ${truncatedString(p, "[", ", ", "]", 
joinKeyMaxFields)}"
     }.iterator
-    val expectedStr = expectedPartitionKeys.map(ks => s"ExpectedPartitionKeys: 
${ks.size}")
+    val expectedStr = expectedKeyCount.map(n => s"ExpectedPartitionKeys: $n")
     val reducersStr = reducers.map { seq =>
       val names = seq.map(_.map(_.reducer.displayName()).getOrElse("identity"))
       s"Reducers: ${truncatedString(names, "[", ", ", "]", joinKeyMaxFields)}"
     }
     val distributeStr = Iterator(s"DistributePartitions: 
$distributePartitions")
-    // Rendered from the constructor field, as `DistributePartitions` above 
is. Not from
-    // `usesSortedMerge`, because that forces `grouping`, which can throw, and 
this method feeds
-    // `simpleString`, which `treeString` calls on error paths.
+    // Rendered from the constructor field, as `DistributePartitions` above 
is, and not from
+    // `usesSortedMerge`, which reads the child's ordering. This method feeds 
`simpleString`, which
+    // `treeString` calls on error paths, so it stays on what the node was 
built with.
     val sortedMergeStr = Iterator(s"SortedMerge: $enableSortedMerge")
     joinKeyStr ++ expectedStr ++ reducersStr ++ distributeStr ++ sortedMergeStr
   }
 }
 
 /**
- * What a [[GroupPartitionsExec]] computes once and reports from several 
members: which of the
- * child's partitions each of its own is built from, and the layout that 
describes them. Every
- * member is given the same `layout` instance, which is what
- * `PartitioningCollection.fromPartitionings` needs to return them untouched. 
The last two fields
- * count the alignment's effect on the reads of the child's splits (see 
`alignToExpectedKeys`), and
- * are 0 outside the alignment path.
+ * What a [[GroupPartitionsExec]] does to its child's partitions.
+ *
+ * @param partitions the index groups the node emits, each with the key it 
stands for
+ * @param layout what the node reports about those groups. Every member of a
+ *               [[PartitioningCollection]] is given this same instance, which 
is what
+ *               `PartitioningCollection.fromPartitionings` needs to return 
them untouched.
+ * @param isIdentity whether the grouping leaves the declared keys and every 
partition where they
+ *                   were. Nothing rewrote the keys, output partition i holds 
exactly input
+ *                   partition i, and there is one output per input. That is 
the only grouping that
+ *                   keeps a marked layout's undeclared rows at hash(key) % 
numPartitions, so a
+ *                   projection, a reduction or a dropped trailing key all 
fail it: each moves the
+ *                   claim into a different key space or changes the modulus. 
A reducer slot counts
+ *                   as key-changing even where a conforming self-reducer 
could not rewrite a
+ *                   reachable key value, so the give-up there loses at most 
an optimization.
+ * @param numPrunedPartitions the alignment's effect on the reads of the 
child's splits, 0 outside
+ *                            the alignment path. See `alignToExpectedKeys`.
+ * @param numReplicatedPartitionReads as above.
  */
-private case class PartitionGrouping(
+private[sql] case class PartitionGrouping(
     partitions: Seq[(InternalRowComparableWrapper, Seq[Int])],
     layout: KeyLayout,
-    keysRewritten: Boolean,
+    isIdentity: Boolean,
     numPrunedPartitions: Int,
-    numReplicatedPartitionReads: Int)
+    numReplicatedPartitionReads: Int) {
+
+  def isGrouped: Boolean = layout.isGrouped
+}
+
+private[sql] object GroupPartitionsExec {
+
+  /**
+   * Builds a node over `child`, deriving `grouping` and `plannedPartitioning` 
from the parameters.
+   *
+   * **Both are derived, and neither `copy` nor the generated `apply` 
re-derives them**, so a change
+   * to `child`, `joinKeyPositions`, `expectedPartitionKeys` (stored as 
`expectedKeyCount`),
+   * `reducers` or `distributePartitions` has to come back through here. 
`enableSortedMerge` is not
+   * an input to either, which is why `tryEnableSortedMerge` may `copy` it.
+   *
+   * Two other `copy` calls in this file are deliberate. 
`withNewChildInternal` carries both fields
+   * over a child rewrite, and a child that turns out to report something else 
is what
+   * `outputPartitioning` answers for, so the carried pair is never reported 
as if it still held.
+   * And `doCanonicalize` rewrites `reducers` without re-deriving, which holds 
because neither field

Review Comment:
   **Nit (P3):** This factory description does not match the values it builds. 
`plannedPartitioning` contains `AttributeReference` exprIds and is normalized 
during canonicalization. `computeGrouping` sorts when expected keys are absent; 
when they are present it distributes or replicates according to 
`distributePartitions`, and partial clustering can use either branch. Could we 
describe those actual conditions instead of the no-exprId and mode-split claims?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -327,8 +114,307 @@ case class EnsureRequirements(
           .getOrElse(SortExec(requiredOrdering, global = false, child = child))
       }
     }
+  }
+
+  /**
+   * What `child` needs to satisfy `distribution` on its own: nothing, a 
[[GroupPartitionsExec]], a
+   * broadcast, or a shuffle.
+   */
+  private def resolveChild(
+      child: SparkPlan,
+      distribution: Distribution,
+      shuffleOrigin: ShuffleOrigin): SparkPlan = {
+    // Ask what the child's partitioning still needs to satisfy the 
distribution
+    val (otherSatisfies, keyed) =
+      splitKeyedPartitionings(child.outputPartitioning, distribution)
+
+    // If a non-KeyedPartitioning already satisfies, no changes needed
+    if (otherSatisfies) {
+      child
+    } else {
+      keyed match {
+        case Some(resolution) =>
+          (distribution, resolution) match {
+            case (o: OrderedDistribution, _) =>
+              // OrderedDistribution requires grouped KeyedPartitioning with 
sorted keys
+              // according to the distribution's ordering.
+              val satisfyingKeyedPartitioning = resolution.fold(identity, _._1)
+              // The single-column invariant in 
KeyedPartitioning.supportsExpressions guarantees
+              // one attribute per partition expression.
+              val attrs = 
satisfyingKeyedPartitioning.expressions.flatMap(_.references)
+              val keyRowOrdering = RowOrdering.create(o.ordering, attrs)
+              val keyOrdering = keyRowOrdering.on((t: 
InternalRowComparableWrapper) => t.row)
+              val keys = satisfyingKeyedPartitioning.partitionKeys
+              // An empty zip is vacuously sorted, which is the answer for a 
single key.
+              if (keys.zip(keys.drop(1)).forall { case (k1, k2) => 
keyOrdering.lteq(k1, k2) }) {
+                child
+              } else {
+                // Use distributePartitions to spread splits across expected 
partitions
+                val sortedGroupedKeys = keys
+                  .groupBy(identity).view.mapValues(_.size)
+                  .toSeq.sortBy(_._1)(keyOrdering)
+                GroupPartitionsExec(child,
+                  expectedPartitionKeys = Some(sortedGroupedKeys),
+                  distributePartitions = true
+                )
+              }
+
+            // A KeyedPartitioning satisfies the distribution and a node would 
change nothing
+            case (_, scala.Left(_)) =>
+              child
+
+            // A KeyedPartitioning satisfies the distribution only after a 
GroupPartitionsExec:
+            // to coalesce duplicate partition keys, to project the partition 
keys down to the
+            // cluster keys, or both. The positions to project to come from 
whichever member
+            // of the child's partitioning leaves the most partitions.
+            case (_, scala.Right((_, positions))) =>
+              GroupPartitionsExec(child, joinKeyPositions = positions)
+          }
+
+        case None =>
+          // No partitioning satisfies - need broadcast or shuffle
+          val numPartitions = distribution.requiredNumPartitions
+            .getOrElse(conf.numShufflePartitions)
+          distribution match {
+            case BroadcastDistribution(mode) =>
+              BroadcastExchangeExec(mode, child)
+            case _: StatefulOpClusteredDistribution =>
+              ShuffleExchangeExec(
+                distribution.createPartitioning(numPartitions), child,
+                REQUIRED_BY_STATEFUL_OPERATOR)
+            case _ =>
+              ShuffleExchangeExec(
+                distribution.createPartitioning(numPartitions), child, 
shuffleOrigin)
+          }
+      }
+    }
+  }
+
+  /**
+   * Resolves each co-partitioned child on its own, which is what is left once 
nothing is arranged
+   * between them.
+   */
+  private def resolveEachChild(
+      children: Seq[SparkPlan],
+      coPartitioned: Seq[Option[ClusteredDistribution]],
+      shuffleOrigin: ShuffleOrigin): Seq[SparkPlan] = 
children.zip(coPartitioned).map {
+    case (child, required) => required.fold(child)(resolveChild(child, _, 
shuffleOrigin))
+  }
+
+  /**
+   * Decides the children an operator co-partitions, together, because neither 
side's answer stands
+   * on its own.
+   *
+   * Three shapes need nothing arranged between the children, and every one of 
them still resolves
+   * each child on its own: a child can owe its distribution a 
[[GroupPartitionsExec]] or a
+   * partition count whether or not it lines up with its siblings.
+   *   1. Every side is a single small partition, so they line up whatever 
they hold.
+   *   2. The sources report layouts a storage-partitioned join can align, 
which
+   *      `checkKeyGroupCompatible` decides and builds end to end.
+   *   3. Both sides pass shuffle partition ids through, on compatible specs.
+   *
+   * Failing all three, `shuffleToCoPartition` shuffles whichever children are 
not aligned onto one
+   * that is.
+   */
+  private def coPartitionChildren(
+      parent: Option[SparkPlan],
+      children: Seq[SparkPlan],
+      coPartitioned: Seq[Option[ClusteredDistribution]],
+      shuffleOrigin: ShuffleOrigin): Seq[SparkPlan] = {
+    // Every way of lining the children up as they are still needs this, and 
only the shuffle below
+    // reads more than it, hence `lazy`: the storage-partitioned join plans 
from the children as
+    // their sources report them, so when it succeeds nothing here is asked 
for.
+    lazy val resolved = resolveEachChild(children, coPartitioned, 
shuffleOrigin)
+
+    // Special case: if all sides of the join are single partition and it's 
physical size less than
+    // or equal spark.sql.maxSinglePartitionBytes.
+    val preferSinglePartition = children.zip(coPartitioned).forall {
+      case (child, Some(_)) =>
+        child.outputPartitioning == SinglePartition &&
+          child.logicalLink
+            .forall(_.stats.sizeInBytes <= 
conf.getConf(SQLConf.MAX_SINGLE_PARTITION_BYTES))
+      case _ => true
+    }
+
+    if (preferSinglePartition) {
+      // Nothing to arrange between them, but each still has to satisfy its 
own requirement. A
+      // `SinglePartition` child does unless the distribution asks for a 
partition count, which a
+      // stateful operator's does.
+      resolved
+    } else {
+      // The two checks below decide on a pair: the operator, and what each of 
its children has to
+      // satisfy. Spark doesn't support multi-way join at the moment, so a 
parent that
+      // co-partitions has exactly two children and both of them are in the 
decision.
+      val linedUp = (parent, coPartitioned) match {
+        case (Some(operator), Seq(Some(leftRequired), Some(rightRequired))) =>
+          // key group compatibility check
+          checkKeyGroupCompatible(
+            operator, children.head, leftRequired, children(1), rightRequired)
+            // If key group check fails, check ShufflePartitionIdPassThrough 
compatibility. That
+            // one reads the children once each has satisfied its distribution 
on its own.
+            .orElse(Option.when(checkShufflePartitionIdPassThroughCompatible(
+              resolved.head, leftRequired, resolved(1), 
rightRequired))(resolved))
+        case _ => None
+      }
+      linedUp.getOrElse(shuffleToCoPartition(resolved, coPartitioned))
+    }
+  }
+
+  /**
+   * The layout the co-partitioned children are lined up on, and the member 
each of them pairs with
+   * it. A child with no pair is one that has to be shuffled onto `member`.
+   */
+  private case class CoPartitionTarget(
+      member: LeafShuffleSpec,
+      pairedMembers: Seq[Option[LeafShuffleSpec]])
 
-    children
+  /**
+   * Lines the co-partitioned children up by shuffle, which is what is left 
once no way of taking
+   * them as they are has worked. One child's layout is picked and the others 
are shuffled onto it.
+   *
+   * The children arrive having each satisfied its distribution on its own.
+   */
+  private def shuffleToCoPartition(
+      children: Seq[SparkPlan],
+      coPartitioned: Seq[Option[ClusteredDistribution]]): Seq[SparkPlan] = {
+    // One spec per co-partitioned child, in child order, and nothing for a 
child that is not in
+    // the decision.
+    val specs = children.zip(coPartitioned).map { case (child, required) =>
+      required.map(child.outputPartitioning.createShuffleSpec)
+    }
+    val target = pickCoPartitionTarget(children, specs)
+    // Nothing pairs when no child can serve as the layout, and then each of 
them is shuffled below
+    // on its own required distribution.
+    val pairedMembers = 
target.map(_.pairedMembers).getOrElse(Seq.fill(children.length)(None))
+
+    children.lazyZip(coPartitioned).lazyZip(pairedMembers).map {
+      case (child, None, _) =>
+        child
+
+      // The positions come from this child's own paired member, since they 
index into its own
+      // partition expressions -- the layout picked only says which member of 
it the two sides
+      // agreed on.
+      //
+      // The storage-partitioned join above declined, but one can still be had 
by shuffling the
+      // other side onto this one's keys (see the last case below). So the 
partitioned side's scan
+      // has to end up grouped by those keys, which is what pushing the 
positions in does. They
+      // index into the child's own report, which already carries whatever 
projection
+      // `resolveChild` gave it, so `withJoinKeyPositions` composes the two 
rather than replacing.
+      case (child, Some(_), Some(KeyedShuffleSpec(_, _, 
Some(joinKeyPositions)))) =>
+        withJoinKeyPositions(child, joinKeyPositions)
+
+      // Paired as it stands, so there is nothing to shuffle and nothing to 
push in.
+      case (child, Some(_), Some(_)) =>
+        child
+
+      case (child, Some(required), None) =>
+        val newPartitioning = target.map { picked =>
+          // Use the picked layout to create a new partitioning to re-shuffle 
this child
+          picked.member.createPartitioning(required.clustering)
+        }.getOrElse {
+          // No layout was picked, so we create default partitioning from the 
required distribution
+          val numPartitions = required.requiredNumPartitions
+              .getOrElse(conf.numShufflePartitions)
+          required.createPartitioning(numPartitions)
+        }
+
+        child match {
+          case s: ShuffleExchangeExec =>
+            s.copy(outputPartitioning = newPartitioning)
+          case gpe: GroupPartitionsExec =>
+            // Strip every grouping this rule inserted (they can stack on a 
re-run): a
+            // replicating one repeats every row, so none of them may feed the 
shuffle.
+            ShuffleExchangeExec(newPartitioning, peelGroupPartitions(gpe))
+          case _ => ShuffleExchangeExec(newPartitioning, child)
+        }
+    }
+  }
+
+  /**
+   * Picks the layout to line the co-partitioned children up on. It is one of 
their own, so that at
+   * least the child offering it keeps its partitioning and only the others 
are shuffled. `None`
+   * when no child can serve, and then they all take a shuffle.
+   *
+   * Find out the shuffle spec that gives better parallelism. Currently this 
is done by

Review Comment:
   **Nit (P3):** Several contracts in this new explanatory block are broader 
than the code: target selection ranks avoiding an exchange before partition 
count; the co-partitioned dispatch is binary only because it handles concrete 
join cases; peeled groupings are from an earlier pass; only one tested shape 
needs `keepArrivedPairing`; grouped sources can still receive 
projection/alignment nodes; `satisfies` can read partition keys; and the 
no-node `AdmittedMember` interpretation does not hold generically for 
`OrderedDistribution`. Please narrow these statements to the implemented 
branches so later edits do not rely on false planner invariants.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -497,6 +350,7 @@ case class GroupPartitionsExec(
   override def supportsColumnar: Boolean = child.supportsColumnar && 
!usesSortedMerge
 
   override protected def doExecuteColumnar(): RDD[ColumnarBatch] = {
+    checkChildStillMatches()

Review Comment:
   **Nit (P3):** The stale-child check is duplicated in `doExecute` and 
`doExecuteColumnar`, but the new execution assertion calls only `execute()`. 
Removing this columnar call would leave the test green. Could the owning suite 
drive a genuinely columnar child through `executeColumnar()` and assert the 
same refusal, so either execution path losing the guard has a regression signal?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -834,41 +868,100 @@ case class KeyedPartitioning(
     KeyedPartitioning.reduceKeys(partitionKeys, keyDataTypes, reducers)
 
   override def satisfies0(required: Distribution): Boolean = {
-    nonGroupedSatisfies(required) || (isGrouped && keysSatisfy(required))
+    super.satisfies0(required) || (isGrouped && keysSatisfy(required))
   }
 
-  /** The first of the four questions the class doc lists. */
-  private def nonGroupedSatisfies(required: Distribution): Boolean = 
super.satisfies0(required)
+  /**
+   * The positions of the partition expressions that cover a cluster key of 
`required`, and so
+   * have to survive a projection. An expression covers one in two ways:
+   *
+   *  - one of its *references* is a cluster key, as a `bucket(4, a)` covers 
`a`;
+   *  - the expression *itself* is a cluster key, as a `years(ts)` under a 
clustering that names
+   *    `years(ts)` rather than `ts`.
+   *
+   * `EnsureRequirements.resolveKeyedPartitioning` and `keysSatisfy` both read 
this, so the
+   * predicate deciding whether a projection is needed and the one choosing 
the positions to
+   * project onto cannot drift apart.
+   *
+   * Not the same question as `KeyedShuffleSpec.keyPositions`, and the two 
must not be merged. That
+   * one answers, per expression, which cluster key the expression is a 
function *of*. That is
+   * what `KeyedShuffleSpec.createPartitioning` needs, since it rebuilds the 
expression over the
+   * other side's clustering with `te.copy(children = ... 
clustering(positionSet.head))`. Handing it
+   * the second form above would rebuild a `years(ts)` clustered on 
`years(ts)` as
+   * `years(years(ts))`.
+   * The two coincide exactly where every expression has a single reference 
and no expression is
+   * itself a cluster key. Where they diverge, `createShuffleSpec` refuses to 
project rather than
+   * projecting onto nothing.
+   */
+  def positionsCoveringClusterKeys(required: ClusteredDistribution): BitSet =
+    expressions.zipWithIndex.collect {
+      case (e, i) if required.isClusterKey(e) || 
e.references.exists(required.isClusterKey) => i
+    }.to(BitSet)
+
+  /**
+   * Whether every partition expression is a function of cluster keys alone, 
so that rows sharing
+   * a cluster key share a partition and nothing needs projecting.
+   *
+   * The sibling of `positionsCoveringClusterKeys`, and the two differ in one 
word: this quantifies
+   * over an expression's references with `forall`, that one with `exists`. 
Both are right for what
+   * they are asked. A `bucket(4, b, c)` over a clustering naming `b` alone 
*carries* a cluster
+   * key, so its position survives a projection, yet two rows sharing `b` land 
in different buckets
+   * when their `c` differs, so it is not a function of the cluster keys. 
Deliberately not
+   * `positionsCoveringClusterKeys(required).size == expressions.length`.
+   */
+  private def isFunctionOfClusterKeys(required: ClusteredDistribution): 
Boolean =
+    expressions.forall { e =>
+      required.isClusterKey(e) || e.references.forall(required.isClusterKey)
+    }
+
+  /**
+   * Whether a [[GroupPartitionsExec]] may project these keys down to the 
cluster keys at all. Only
+   * `spark.sql.sources.v2.bucketing.allowJoinKeysSubsetOfPartitionKeys` 
permits it, and a marked
+   * layout cannot survive it, since the projection coarsens the declared set 
the out-of-set routing
+   * speaks for (see the `@param`). Whole keys co-locate there and subsets do 
not, so a window or
+   * aggregate keyed on a strict subset of the partition columns must still 
shuffle. The
+   * single-reference requirement is what makes keeping a position sound at 
all. A kept expression
+   * is then a function of one cluster key, so coalescing on the projected 
keys cannot put rows that
+   * share a cluster key on different partitions.
+   *
+   * Read by `keysSatisfy` and `keysCanSatisfy`, which is the point. They ask 
different things once
+   * the projection is permitted, so the permission has to be one rule rather 
than two spellings.
+   */
+  private def mayProjectToClusterKeys(required: ClusteredDistribution): 
Boolean =
+    !required.requireAllClusterKeys && !mayContainUnknownPartitionKeys &&
+      SQLConf.get.v2BucketingAllowKeysSubsetOfPartitionKeys &&
+      expressions.forall(_.references.size == 1)
 
-  /** The second of the four questions the class doc lists. */
+  /**
+   * The strict question of the family the class doc lists, for a 
[[ClusteredDistribution]]. `true`

Review Comment:
   **Nit (P3):** These predicate and mapping claims need qualification. 
`keysSatisfy` does not inspect `isGrouped`, so an admitted layout can still 
need grouping; with clustering `[years(ts), ts]`, `years(ts)` is itself a 
cluster key while its `ts` reference maps to the second position. Also, 
Catalyst Scaladoc cannot resolve `[[GroupPartitionsExec]]`, which exists only 
in downstream sql/core. Please state the admitted cases precisely and use 
module-resolvable wording for that node.



-- 
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: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to