[SPARK-59080][SQL] Pick one ShuffleSpecCollection member for the SPJ pushdown and the re-shuffle - #58527
Conversation
…pushdown and the re-shuffle Under `spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys`, `KeyedPartitioning.createShuffleSpec` projects each member of a `PartitioningCollection` onto its own subset of the operation keys. The members of the resulting `ShuffleSpecCollection` can therefore end up with different partition counts. `EnsureRequirements` asked the collection itself for a shuffle template, and `ShuffleSpecCollection.createPartitioning` throws `expected all specs in the collection to have the same number of partitions`. The collection cannot answer that question. `isCompatibleWith` succeeds when *any* member matches, so the collection alone never said which member the two sides agreed on, and reading `specs.head` took whichever the alias cross-product enumerated first. `EnsureRequirements` now resolves that member once, preferring the finest when several qualify, and uses it to build the re-shuffled child's partitioning. The `require` stays as a guard on a method that no longer has a production caller. The `joinKeyPositions` pushed into a compatible child now come from that child's own matching member. They index into the child's own partition expressions, so the best spec's positions were only right when the best spec was that child's. No query reaches the wrong case today: a join is handled by `checkKeyGroupCompatible`, which already pushes each side's own positions, and a cogroup's grouping key is synthesized so neither side stays keyed.
sunchao
left a comment
There was a problem hiding this comment.
One nonblocking P3 finding; no P1/P2 defects found. Reviewed 6136919314809cd61473fb045d0521c202b41fff with five independent reviewers.
Finding
[P3] Correct the claim that keyed cogroups cannot reach this branch. The new comment overlooks Pandas/Arrow cogroups, which preserve named grouping expressions. The existing SPARK-58968 test in this suite already exercises two keyed cogroup children. Qualify the comment and the PR description’s claim that this correction is latent. The implementation itself improves this reachable path.
Validation
| Coverage | Result |
|---|---|
| Affected suites in CI | 202 passed |
| Local focused tests | 69 passed |
| Additional nested-layout and fallback probes | 6 passed |
| Reverted implementation | Reproduced all three claimed regression failures |
| Full-patch and last-commit whitespace checks | Passed |
Local validation used verified CI binaries and source overlays, not a clean local build. The PR head remained unchanged. Nothing was posted to GitHub.
| // 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) |
There was a problem hiding this comment.
createKeyedShuffleSpec still resolves a PartitioningCollection with collectFirst, taking the first satisfying member, while this PR makes the per-child branch resolve the finest member both sides agreed on. The default requireAllClusterKeysForCoPartition=true keeps partial-coverage members out of the candidates, so this only shows up with requireAllClusterKeysForCoPartition=false plus allowKeysSubsetOfPartitionKeys=true: when both sides report alias cross-products with a coarse member first, SPJ pairs the coarse members and joins on them, leaving the finer pairing unused; with coarse first on one side and fine first on the other, the picks fail to pair and the per-child branch takes over, grouping each side on its finest matching member, so what is skipped is the SPJ partition-value pushdown, not a shuffle. Results stay correct. If you align it, the choice cannot be an independent per-side finest (a side with only coarse members would then fail to pair); it has to pair like bestMemberOpt does, by finest member compatible with some member of the other side. Either fixing it here or a follow-up works for me.
There was a problem hiding this comment.
Good catch, and I took it into #58531 rather than a separate ticket. createKeyedShuffleSpecs now returns every member's spec and checkKeyGroupCompatible picks the pair, with your constraint respected: not an independent per-side finest, but the pair that agrees on the keys and offers the most parallelism.
Two things I measured that are worth passing back.
Your reachability framing is slightly off, in a way that does not change your conclusion. requireAllClusterKeysForCoPartition = true does not always keep a collection down to one candidate: I instrumented createKeyedShuffleSpec over KeyGroupedPartitioningSuite and EnsureRequirementsSuite and got 31 collection reaches, of which 2 had two qualifying members under the default. But in every one of those the qualifying members produced identical specs, so the pick was not observable. The observable case does need the relaxed conf, as you said — just not for the reason that the members are filtered out.
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 checkKeyGroupCompatible declines and each side is merely grouped on its own keys.
I also checked the sibling collectFirst at the top of the partially-clustered branch, which recovers the original KeyedPartitioning while the positions come from the picked member. That one is harmless and I am not touching it: projectKeys reads only partitionKeys and the types derived from them, and the collection invariant makes partitionKeys a shared reference, so any member gives the same answer.
| lazy val matchedIndexes = bestSpecOpt.toSeq.flatMap { best => | ||
| childrenIndexes.filter(i => best.isCompatibleWith(specs(i))) | ||
| } | ||
| lazy val bestMemberOpt = bestSpecOpt.flatMap { best => |
There was a problem hiding this comment.
bestSpecOpt is still picked with ShuffleSpecCollection.numPartitions, which comes from specs.head. A collection like [coarse head with 2 partitions, fine member with 200] competes as 2 in the maxBy, 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 (changing numPartitions itself would affect other readers).
There was a problem hiding this comment.
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 numPartitions itself would affect other readers.
Worth naming the other reader, since it turned out to want a different aggregation: SinglePartitionShuffleSpec.isCompatibleWith reads other.numPartitions == 1, reached from ValidateRequirements, and there the right answer is exists, not max. Two consumers wanting two different aggregations is what convinced me the collection should not answer numPartitions at all rather than answer it better, so that PR removes it along with createPartitioning.
… latent @sunchao pointed out that Pandas and Arrow cogroups group on real columns, unlike the Scala `CoGroupExec`, whose key comes from an `AppendColumns` that no `KeyedPartitioning` satisfies. Two keyed children do reach the per-child branch, so pushing each child's own `joinKeyPositions` is a reachable correctness fix rather than a latent one. The plan test is rebuilt on `FlatMapCoGroupsInPandasExec` instead of a synthetic parent. Both sides declare the same two key columns in the opposite order, so the cogroup key sits at position 1 on the left and at position 0 on the right, and both project onto the same key set. Without the production change the right side is handed the left side's positions and ends up grouped on its other partition column: `List(Some(List(1)), Some(List(1)))` where `List(Some(List(1)), Some(List(0)))` is right.
|
Thanks @sunchao, and you are right — I checked and it is worse than a wording problem. My "the cogroup key is synthesized" claim came from a measurement I did for a different follow-up, and that measurement only covered the Scala So the Pushed as f6a1442, and the description no longer calls that half latent. The only part that stays latent is the three-or-more clustered children case. |
What changes were proposed in this pull request?
EnsureRequirementsstops asking aShuffleSpecCollectionfor a single answer. It resolves the one member the matched children agreed on, preferring the finest when several qualify, and uses that member to build a re-shuffled child's partitioning.flattenSpecreplaces the head read. It recurses, becauseShuffledJoin.outputPartitioningbuildsPartitioningCollection.fromPartitionings(Seq(left, right))for an inner join, so a chain of same-key joins nests collections.joinKeyPositionspushed into a compatible child now come from that child's own matching member, because they index into that child's partition expressions.ShuffleSpecCollection.createPartitioningis untouched. Itsrequirestays as a guard, and a new unit test pins it.Why are the changes needed?
Under
spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled,KeyedPartitioning.createShuffleSpecprojects each member of aPartitioningCollectiononto its own join-key subset and drops the duplicate keys that projection creates. The members of the resultingShuffleSpecCollectioncan therefore end up with differentnumPartitions.EnsureRequirementsthen asks the collection for a shuffle template, andShuffleSpecCollection.createPartitioningrequires all members to agree:so planning fails outright. With
itemspartitioned by[identity(id), identity(arrive_time)], one row per split,purchasesunpartitioned, andv2BucketingShuffleEnabled=true,partiallyClusteredDistribution=false,allowKeysSubsetOfPartitionKeys=true:Selecting
arrive_timetwice under two aliases makes the alias cross-product produce members that cover different numbers of join keys, which is where the counts diverge.The collection cannot answer that question locally.
isCompatibleWithsucceeds when any member matches, so the collection alone never said which member the two sides agreed on, andcreatePartitioningfell back tospecs.head, whichever the alias cross-product enumerated first. Narrowing the collection to its finest members would satisfy therequire, but it would still be a guess: the right member is the one the other side matched, and that is only visible inEnsureRequirements.Does this PR introduce any user-facing change?
Yes. The query above failed to plan and now runs, producing one shuffle and the right rows.
The
joinKeyPositionshalf is user-facing too. I originally wrote here that it was latent, on the grounds that a cogroup's grouping key is synthesized so neither side stays keyed. @sunchao pointed out that this is only true of the ScalaCoGroupExec, whose key comes from anAppendColumnsthat noKeyedPartitioningsatisfies. A Pandas or Arrow cogroup groups on real columns, so two keyed children do reach the per-child branch, and this suite already had aFlatMapCoGroupsInPandasExectest with two of them.So: with two keyed children whose partition expressions are laid out differently, the second side is handed the first side's positions and ends up grouped on its other partition column. The plan test below reproduces it and fails without the fix, reporting
List(Some(List(1)), Some(List(1)))whereList(Some(List(1)), Some(List(0)))is right. I have not built an end-to-end query for it.The only part that stays latent is the three-or-more clustered children case, which no operator has.
How was this patch tested?
Four new tests. Each was measured against the same commit with only the
EnsureRequirementschange reverted.KeyGroupedPartitioningSuite: both sides of the join land on the same collection memberrequireaboveEnsureRequirementsSuite: the re-shuffled side lands on the member the keyed side was matched onrequireaboveEnsureRequirementsSuite: pushed-down positions index into the child's own partition expressions (a Pandas cogroup over two keyed children)ShuffleSpecSuite: a collection whose members cover different key subsets disagreesThe last one pins the guard rather than the fix. It asserts that the members disagree on purpose, that every one of them stays available for
isCompatibleWith, and that asking the collection for a single partitioning throws. That turns therequirefrom untested prose into a pinned contract, which matters now that the method has no production caller.Green:
ShuffleSpecSuite,EnsureRequirementsSuite,KeyGroupedPartitioningSuite, 202 tests in all.dev/lint-scalais clean.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code