cloud-fan commented on code in PR #59535:
URL: https://github.com/apache/spark/pull/59535#discussion_r4324836901
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -1040,6 +1036,36 @@ case class UnionExec(children: Seq[SparkPlan]) extends
SparkPlan with CodegenSup
}
}
+ /**
+ * Whether the legs' markers let their keys be merged. A marked leg may hold
rows of a key it
+ * does not declare, and `KeyGroupedPartitioner` routes those into the
partition of some declared
+ * key. Two conditions keep every key's rows in one group, which is what a
clustering reads:
+ *
+ * - a marked leg declares every key the other legs declare, so its rows
of such a key do not
+ * land in a partition the merge names for another key;
+ * - marked legs declare the same keys in the same order, and when more
than one is marked,
+ * each still routes by the hash. A routed leg is grouped, so its keys
are unique and the
+ * list length is the modulus the hash is taken over: the position in
that list is the name a
+ * key no leg declares reaches, and equal lists agree on both. A leg
whose routing is lost
+ * names such a key by an earlier layout's order, which its list does
not show. One marked
+ * leg needs no agreement, since its undeclared rows reach one group
either way.
+ *
+ * The merged layout cannot claim that a partition holds only the key it
names, which
+ * [[KeyedPartitioning.concat]] records.
+ */
+ private def canMergeMarkedKeys(kps: Seq[KeyedPartitioning]): Boolean = {
+ val marked = kps.filter(_.mayContainUnknownPartitionKeys)
+ if (marked.isEmpty) {
+ true
+ } else {
+ val declared = marked.head.partitionKeys
+ marked.tail.forall(_.partitionKeys == declared) &&
+ (marked.size == 1 || marked.forall(_.unknownKeysRouted)) &&
+ kps.filterNot(_.mayContainUnknownPartitionKeys)
+ .forall(_.partitionKeys.forall(declared.contains))
Review Comment:
**Non-blocking (P2):** `declared.contains` scans the marked leg's
`partitionKeys` linearly, once for every key of every unmarked leg. That is U x
D `InternalRowComparableWrapper.equals` calls, where U is the unmarked keys
(one per split when a leg is ungrouped) and D the declared keys.
`outputPartitioning` re-runs `rawPartitioning` on every call for a non-plain
union. It is deliberately not cached, because AQE changes child partition
counts, so EnsureRequirements, ValidateRequirements, stamping and AQE
re-planning each pay the cost again. With D = 1e3 and U = 1e4 that is about 1e7
comparisons per call. Before this PR, a marked leg failed the gate before any
key comparison. `KeyedShuffleSpec.areKeysCompatible` answers the same subset
question with `partitionKeys.toSet`. Building `declared.toSet` once, only when
an unmarked leg exists, keeps this linear and gives the same answer. The
ordered `==` check across marked legs should stay as it is. Only driver
planning time on the opt-in
one-side-shuffle path is affected, not results.
**Verification:**
- **Behavior:** The union merge gate keeps its existing outcomes: a covered
unmarked leg merges, an uncovered key, differing order or multiplicity, two
lost legs, and a routed/lost pair are refused, and a single lost marked leg and
a grouped concatenation still merge.
- **Inspection:** The containment check probes a hashed collection of the
marked leg's declared keys and no longer scans the declared key list once per
unmarked key.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1182,25 +1216,49 @@ object KeyedPartitioning {
KeyLayout(comparablePartitionKeys, factory.dataTypes, isGrouped,
isCollapsed = false))
}
+ /**
+ * The unknown-keys state a layout built from several members carries: the
marked members have to
+ * agree, since one layout cannot carry two descriptions of where an
undeclared row sits, and a
+ * disagreement leaves `ROUTING_LOST`. An unmarked member takes no part.
Asked of the members'
+ * states, so a new origin joins without this changing.
+ */
+ def combine(states: Seq[Option[UnknownKeysOrigin]]):
Option[UnknownKeysOrigin] =
+ states.flatten.distinct match {
+ case Seq() => None
+ case Seq(one) => Some(one)
+ case _ => Some(ROUTING_LOST)
+ }
+
/**
* Concatenates partitionings that agree on their expressions, which is what
a `UnionExec` does to
* its children's partitions. The result reports one key per output
partition, so its keys are the
* children's keys in child order.
*
* Keys repeating across children is not a collapse. Only a child's own
collapse carries over,
* since such a key still stands for several finer-grained ones in the
concatenation.
+ *
+ * One marked child marks the result, and the concatenation reports the
weakest routing of its
+ * legs': ungrouped, it routes an undeclared row per contributing leg rather
than by a hash over
+ * its own count.
*/
def concat(kps: Seq[KeyedPartitioning]): KeyedPartitioning = {
val concatenatedKeys = kps.flatMap(_.partitionKeys)
val ungroupingOrigin =
PartitioningCollection.concatUngrouping(kps.map(_.ungroupingOrigin))
+ // A child that has duplicates of its own puts them in the concatenation
too, which answers
+ // this without walking the keys.
+ val grouped = kps.forall(_.isGrouped) &&
+ concatenatedKeys.distinct.length == concatenatedKeys.length
+ // Only a grouped concatenation, where one marked leg holds every
partition, keeps the routing.
+ val routing = combine(kps.map(_.layout.unknownKeysOrigin)) match {
+ case Some(HASH_ROUTING) if !grouped => Some(ROUTING_LOST)
Review Comment:
**Nit (P3):** This keeps `HASH_ROUTING` whenever the concatenation is
grouped. The comment above states the condition that actually preserves the
modulus: one marked leg holds every partition. The two coincide only because
`UnionExec.canMergeMarkedKeys` runs first and forces every unmarked key into
the marked declared list, so a grouped result there means every other leg is
empty. Without that gate, `concat` over a routed [1, 2, 3] leg and an unmarked
[4, 5] leg is grouped. It reports `HASH_ROUTING` over 5 partitions while the
undeclared rows sit at `hash % 3`. `canCreatePartitioning` would then accept it
as a template and lay a sibling's undeclared rows out by `hash % 5`, losing
matches. No current caller reaches this. Deriving the state from the stated
condition (at most one leg contributes partitions, and that leg is routed)
costs one count and keeps every outcome `UnionExec` sees today.
**Verification:**
- **Regression:** A direct concatenation of a hash-routed marked layout over
keys [1, 2, 3] with an unmarked layout over keys [4, 5] yields a grouped,
marked layout whose routing is lost, so it cannot serve as a shuffle template.
- **Behavior:** A grouped concatenation in which the routed marked leg holds
every partition still reports hash routing, and the existing union merge tests
keep their outcomes.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/EnsureRequirementsSuite.scala:
##########
@@ -2809,7 +2809,7 @@ class EnsureRequirementsSuite extends SharedSparkSession {
marked <- Seq(false, true)
} yield {
val kp = KeyedPartitioning(exprs, keys)
- val p = if (marked)
kp.withLayout(_.copy(mayContainUnknownPartitionKeys = true)) else kp
+ val p = if (marked) kp.withLayout(_.copy(unknownKeysOrigin =
Some(HASH_ROUTING))) else kp
Review Comment:
**Nit (P3):** Before this PR, every reachable marked layout was grouped and
hash-routed, so this generator covered the whole marked state space. The PR
adds two more kinds of join input: `ROUTING_LOST` layouts, and ungrouped marked
layouts such as a merged union reports. The generator was only migrated
mechanically, so the sweep never feeds EnsureRequirements either kind. The
SPARK-60277 join test pairs identical {1, 2, 3} key sets, and the
GroupPartitionsExec and ShuffleSpec unit tests check each refusal predicate on
its own. Suppose a later change let a lost-routing side act as a template, or
pair index by index under a non-identity expected-key alignment. No test would
fail. Two additions would close the gap under the existing
`ValidateRequirements` oracle: `Some(ROUTING_LOST)` and an ungrouped marked key
set in the shapes here, and one end-to-end join, with AQE off and on, over a
merged marked union against a side with a different key set.
**Verification:**
- **Regression:** A planner change that pairs index by index with, or lays a
sibling out on, a marked side whose routing is lost fails the generated pairing
sweep.
- **Behavior:** A storage-partitioned join over a merged marked union whose
other side declares a different key set falls back to a valid plan and returns
the expected rows under both AQE settings.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -937,9 +937,10 @@ case class EnsureRequirements(
// The pairing is only worth committing to if both children still declare
the same aligned key
// sequence once the grouping has been pushed into them. A
`GroupPartitionsExec` gives up its
- // keyed claim when it turns out to regroup a layout that pins undeclared
rows to
- // `hash(key) % numPartitions` (see
`KeyLayout.mayContainUnknownPartitionKeys`), and only the
- // node knows the permutation it performs, so that answer arrives after
the pairing was chosen.
+ // keyed claim when the grouping re-labels the keys or replicates them
into expected slots,
Review Comment:
**Nit (P3):** This rewritten sentence matches the new rule. The same block's
last paragraph (955-958), however, still says the `groupIfNeeded` node 'gives
up the claim for the same reason the pushed one does'. That node has no key
rewrite and no expected keys, so it now keeps a marked claim as `ROUTING_LOST`,
and the SPARK-60277 join test depends on that. Other unchanged comments still
state the old rule:
- `createShuffleSpec` (partitioning.scala 1150-1152) justifies its marked
early return by a give-up that no longer happens for the sort-only half.
- `areKeysCompatible` (2316-2317) says a reducing GPE gives the claim up
because it 'is not an identity regrouping'. The cause is now `keysRewritten`.
- The EnsureRequirementsSuite pairing sweep says the failing marked shapes
are those 'where a `GroupPartitionsExec` gives up the keyed claim' while
regrouping (2781-2782), and that 'regrouping one moves them and the node stops
claiming the layout' (2807-2808).
- KeyGroupedPartitioningSuite 9840-9843 still names `keyedMarkerOf`.
A maintainer who trusts these comments could relax the `createShuffleSpec`
early return or the routed checks. That would re-admit a sorted marked layout
whose undeclared rows no longer sit at `hash(key) % numPartitions`.
See **Shared repair plan 1** in the review body.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -447,6 +447,21 @@ case object REPLICATED_FOR_JOIN extends UngroupingOrigin
// A spread an operator's ordering requirement is read through, which pairs
with nothing.
case object SPLIT_FOR_ORDERING extends UngroupingOrigin
+/**
+ * Why a layout may hold rows whose key it does not declare, and what became
of the hash routing
+ * that placed them. `KeyGroupedPartitioner` routes such a row by
+ * `nonNegativeMod(key.hashCode, numPartitions)`, so a marked layout
co-locates whole keys, never a
+ * subset of their columns. Anything that regrouped, sorted or projected the
keys moves the rows off
+ * that routing, and only the co-location survives.
+ */
+sealed trait UnknownKeysOrigin
+
+// An undeclared row sits at `nonNegativeMod(key.hashCode, numPartitions)`;
the relation holds.
+case object HASH_ROUTING extends UnknownKeysOrigin
+
+// A regrouping, a sort or a projection moved the rows off that relation; only
co-location remains.
Review Comment:
**Nit (P3):** No projection produces `ROUTING_LOST`.
`KeyedPartitioning.project` asserts the layout is unmarked (847-848),
`AliasAwareOutputExpression` turns away a narrowing projection of a marked
layout, and a GroupPartitionsExec with `keysRewritten` reports
`UnknownPartitioning`. After a projection, no co-location claim survives at
all. The carriers that do set `ROUTING_LOST` are a non-identity regroup,
`toGrouped`, an ungrouped `concat`, and `combine` over disagreeing members, and
the docs do not mention the last two. Two nearby passages have the same problem.
- Carry rule (1) (506-512) says a union 'expands' the declared set over a
marked leg, which the same rule says must drop the claim. In fact
`canMergeMarkedKeys` never adds a key beyond the marked leg's declaration.
- The comment on the `project` assert (843-846) blames distinct keys
collapsing, but a regroup survives that. The real break is an undeclared row
under another declared key's label projecting to a different value. For
example, project keys [(1, a), (2, b)] to [1, 2] with an undeclared (1, z)
routed into the (2, b) partition. Projected key 1 is then split across two
labels without any collapse.
A maintainer reads these docs before adding a carrier, so the producer list
should match the code.
See **Shared repair plan 1** in the review body.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -817,21 +827,25 @@ private[sql] object GroupPartitionsExec {
case _ => false
}
// A `copy` of the child's layout rather than a fresh one, so the marker
and anything the
- // layout grows later are carried without this having to name them. The
origin is the one
+ // layout grows later are carried without this having to name them, except
for the routing:
+ // only an identity regrouping leaves an undeclared row where it was. The
origin is the one
Review Comment:
**Nit (P3):** The marker and the routing are one field, `unknownKeysOrigin`,
and lines 837-838 and 846 name it explicitly, so it is not carried 'without
this having to name' it. 'The origin is the one field not carried' was true
while `ungroupingOrigin` was the only origin. Now this code decides two origin
fields, so the sentence is false and 'the origin' could mean either one. The
comment would match the code if it said that both are set explicitly:
`unknownKeysOrigin`, which only an identity regrouping keeps, and
`ungroupingOrigin`.
See **Shared repair plan 1** in the review body.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -727,7 +758,7 @@ case class KeyedPartitioning(
*/
override protected def stringArgs: Iterator[Any] =
Iterator(expressions, partitionKeys, isGrouped, isCollapsed,
- mayContainUnknownPartitionKeys) ++ ungroupingOrigin
+ unknownKeysOrigin) ++ ungroupingOrigin
Review Comment:
**Nit (P3):** `flatArguments` (Expression.scala 363-366) flattens only
`Iterable`, and in Scala 2.13 `Option` is only `IterableOnce`, so this slot
renders as `Some(HASH_ROUTING)`, `Some(ROUTING_LOST)` or `None`. Every keyed
exchange from `createPartitioning` now ends in `Some(HASH_ROUTING)`, and every
unmarked SPJ partitioning ends in `None`. The `++ ungroupingOrigin` beside it
flattens to a bare name or nothing, so the two optional layout fields print
differently on the same line. The two case-object families have distinct names,
so flattening this one the same way keeps the trailing slots unambiguous:
```suggestion
Iterator(expressions, partitionKeys, isGrouped, isCollapsed) ++
unknownKeysOrigin ++
ungroupingOrigin
```
**Verification:**
- **Behavior:** Rendering a hash-routed, a routing-lost, and an unmarked
KeyedPartitioning to a string shows the bare state name for the first two and
no routing token for the third.
- **Compatibility:** No plan-stability or explain golden output that renders
KeyedPartitioning changes unexpectedly.
--
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]