Uh oh!
There was an error while loading. Please reload this page.
[SPARK-59123][SQL] Avoid per-key intermediate collections in KeyedPartitioning.projectKeys and reduceKeys - #58421
Conversation
peter-toth
commented
Aug 31, 2026
…titioning.projectKeys and reduceKeys
`KeyedPartitioning.projectKeys` and `KeyedPartitioning.reduceKeys` walk every partition key of a storage-partitioned join, and each one built several throwaway collections to end up with one array. Both now hoist what does not depend on the key and fill a single `Array[Any]` with an indexed loop.
`projectKeys` materialised an intermediate `Seq` per key and copied it into an array, destructuring a `Tuple2` per position:
val projectedKey = positionsWithTypes.map {
case (position, dataType) => key.row.get(position, dataType)
}.toArray[Any]
`reduceKeys` did the same four times over. `key.row.toSeq(dataTypes)` allocated an array and an `ArraySeq` wrapper, `zip(reducers)` a sequence of tuples, `map` a third sequence, and `toArray` the array that was wanted in the first place:
val keyValues = key.row.toSeq(dataTypes)
val reducedKey = keyValues.zip(reducers).map {
case (v, Some(KeyReducer(reducer: Reducer[Any, Any], _))) => reducer.reduce(v)
case (v, _) => v
}.toArray
Which positions have a reducer is now settled once, outside the key loop, so the erased `Some(KeyReducer(reducer: Reducer[Any, Any], _))` type test runs once per position instead of once per key value, and it gives the reduced data types with it. Unwrapping the `KeyReducer` moves out of the loop with it.
A key list is as long as the number of splits the scan reported, so tens of thousands is ordinary, and anything allocated per key is allocated that many times. `projectKeys` runs over all of them on every `EnsureRequirements` and `ValidateRequirements` pass whenever `spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled` is on, not only on the reducer path.
Measured on a `KeyedPartitioning` with 50k keys of 12 positions, six `IntegerType` and six `StringType`, projecting two of each, with a reducer on one position, 20 evaluations after a warm-up:
| | before | after |
|---|---|---|
| `projectKeys` | 105-111 ms | 35-39 ms |
| `reduceKeys` | 227-252 ms | 66-95 ms |
One thing I tried and dropped: hoisting the type dispatch out of the loop with `InternalRow.getAccessor`, the way `BoundReference` does. It measured slower, 63-79 ms for `projectKeys`, because these rows are `GenericInternalRow`s, whose `get(ordinal, dataType)` ignores the requested type and reads the array directly. The accessor only adds a closure call and a null-check wrapper on top of that. `PhysicalDataType.apply` per value is on the `UnsafeRow` path, which partition keys are not.
No.
No new test: the two bodies are rewritten, not changed in behaviour, and both are on the path of the existing storage-partitioned-join tests. 286 tests green across `KeyGroupedPartitioningSuite`, `KeyGroupedPartitioningCatalystRuntimeFilterSuite`, `EnsureRequirementsSuite`, `ValidateRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `PlannerSuite`, `ShuffleSpecSuite` and `DistributionSuite`. `dev/lint-scala` clean.
Generated-by: Claude Code87e93d8 to
9ceb613Compare
dongjoon-hyun
left a comment
There was a problem hiding this comment.
LGTM. Both rewrites look behavior-preserving to me:
projectKeys: the same reads, just without the intermediateSeqand the per-position tuple.reduceKeys:key.row.toSeq(dataTypes)is exactly the same indexedget(i, dataTypes(i))loop, and theReducer[Any, Any]test is erased, so settling it per position is sound.- The
nullsentinel inreducerArrayis safe:TransformExpression.reducerswraps the connector's reducer inOption(res), so aKeyReducernever carries a null reducer. - The new loop bounds on
dataTypes.lengthwhile indexingreducerArray, where the oldziptruncated to the shorter of the two. Both call sites keep them equal --EnsureRequirementstakes the reducers fromreducersBothWaysover the specs' (already projected) expressions, afterareKeysCompatiblechecked equal arity, andGroupPartitionsExecprojects the data types with the samejoinKeyPositions-- so the difference is not reachable today.
Three minor comments:
The PR description quotes stale code: it shows
case (v, Some(reducer: Reducer[Any, Any])) => reducer.reduce(v), but master hascase (v, Some(KeyReducer(reducer: Reducer[Any, Any], _))). Same for theresultType()snippet. Worth refreshing before merge.reduceKeysdrops theassert(numFields == fieldTypes.length)thatInternalRow.toSeq(fieldTypes)carried. No impact outside-ea, but it is an unmentioned side effect of not going throughtoSeq; oneassertoutside the key loop would restore it for free.In
projectKeys, the comment "whatever is allocated per key is allocated tens of thousands of times" sits abovepositionArray/typeArray, which are hoisted out of the key loop and are not per-key allocations. It reads as the motivation for the whole rewrite rather than for those two lines -- moving it abovekeys.map, or rewording it to say what the hoist buys, would be clearer.
Agreed that no new test is needed here.
…d the hoist comment Keeps the arity check that `InternalRow.toSeq(dataTypes)` carried, as one `assert` before the key loop rather than one per key, and asserts the reducer array's length with it, since the loop indexes both arrays by the same bound where the old `zip` would have truncated. Rewords the `projectKeys` hoist comment to say what the hoist buys, instead of stating the motivation for the whole rewrite above two lines that are not per-key allocations. Generated-by: Claude Code
peter-toth
commented
Sep 1, 2026
Thanks @dongjoon-hyun. All three taken, in f2f417e.
On the reachability: agreed, and thanks for tracing both call sites rather than taking the equality on trust. The The description's suite total moved from 286 to 305 with the rebase, since |
…titioning.projectKeys and reduceKeys
### What changes were proposed in this pull request?
`KeyedPartitioning.projectKeys` and `KeyedPartitioning.reduceKeys` walk every partition key of a storage-partitioned join, and each one built several throwaway collections to end up with one array. Both now hoist what does not depend on the key and fill a single `Array[Any]` with an indexed loop.
`projectKeys` materialised an intermediate `Seq` per key and copied it into an array, destructuring a `Tuple2` per position:
val projectedKey = positionsWithTypes.map {
case (position, dataType) => key.row.get(position, dataType)
}.toArray[Any]
`reduceKeys` did the same four times over. `key.row.toSeq(dataTypes)` allocated an array and an `ArraySeq` wrapper, `zip(reducers)` a sequence of tuples, `map` a third sequence, and `toArray` the array that was wanted in the first place:
val keyValues = key.row.toSeq(dataTypes)
val reducedKey = keyValues.zip(reducers).map {
case (v, Some(KeyReducer(reducer: Reducer[Any, Any], _))) => reducer.reduce(v)
case (v, _) => v
}.toArray
Which positions have a reducer is now settled once, outside the key loop, so the erased `Some(KeyReducer(reducer: Reducer[Any, Any], _))` type test runs once per position instead of once per key value, and it gives the reduced data types with it. Unwrapping the `KeyReducer` moves out of the loop with it.
`reduceKeys` keeps the arity check that `InternalRow.toSeq(dataTypes)` carried, as one `assert` before the loop rather than one per key. It asserts the reducer array's length with it, because the loop indexes both arrays by the same bound where the old `zip` would have truncated to the shorter. Both call sites keep the two equal today, so this pins that rather than changing anything.
### Why are the changes needed?
A key list is as long as the number of splits the scan reported, so tens of thousands is ordinary, and anything allocated per key is allocated that many times. `projectKeys` runs over all of them on every `EnsureRequirements` and `ValidateRequirements` pass whenever `spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled` is on, not only on the reducer path.
Measured on a `KeyedPartitioning` with 50k keys of 12 positions, six `IntegerType` and six `StringType`, projecting two of each, with a reducer on one position, 20 evaluations after a warm-up:
| | before | after |
|---|---|---|
| `projectKeys` | 105-111 ms | 35-39 ms |
| `reduceKeys` | 227-252 ms | 66-95 ms |
One thing I tried and dropped: hoisting the type dispatch out of the loop with `InternalRow.getAccessor`, the way `BoundReference` does. It measured slower, 63-79 ms for `projectKeys`, because these rows are `GenericInternalRow`s, whose `get(ordinal, dataType)` ignores the requested type and reads the array directly. The accessor only adds a closure call and a null-check wrapper on top of that. `PhysicalDataType.apply` per value is on the `UnsafeRow` path, which partition keys are not.
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
No new test: the two bodies are rewritten, not changed in behaviour, and both are on the path of the existing storage-partitioned-join tests. 305 tests green across `KeyGroupedPartitioningSuite`, `KeyGroupedPartitioningCatalystRuntimeFilterSuite`, `EnsureRequirementsSuite`, `ValidateRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `PlannerSuite`, `ShuffleSpecSuite` and `DistributionSuite`. `dev/lint-scala` clean.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code
Closes#58421 from peter-toth/SPARK-59123-keyed-partitioning-key-loops.
Authored-by: Peter Toth <peter.toth@gmail.com>
Signed-off-by: Peter Toth <peter.toth@gmail.com>
(cherry picked from commit 9e0bea0)
Signed-off-by: Peter Toth <peter.toth@gmail.com>peter-toth
commented
Sep 2, 2026
peter-toth
commented
Sep 2, 2026
Thank you @dongjoon-hyun and @ulysses-you for the review. |
What changes were proposed in this pull request?
KeyedPartitioning.projectKeysandKeyedPartitioning.reduceKeyswalk every partition key of a storage-partitioned join, and each one built several throwaway collections to end up with one array. Both now hoist what does not depend on the key and fill a singleArray[Any]with an indexed loop.projectKeysmaterialised an intermediateSeqper key and copied it into an array, destructuring aTuple2per position:reduceKeysdid the same four times over.key.row.toSeq(dataTypes)allocated an array and anArraySeqwrapper,zip(reducers)a sequence of tuples,mapa third sequence, andtoArraythe array that was wanted in the first place:Which positions have a reducer is now settled once, outside the key loop, so the erased
Some(KeyReducer(reducer: Reducer[Any, Any], _))type test runs once per position instead of once per key value, and it gives the reduced data types with it. Unwrapping theKeyReducermoves out of the loop with it.reduceKeyskeeps the arity check thatInternalRow.toSeq(dataTypes)carried, as oneassertbefore the loop rather than one per key. It asserts the reducer array's length with it, because the loop indexes both arrays by the same bound where the oldzipwould have truncated to the shorter. Both call sites keep the two equal today, so this pins that rather than changing anything.Why are the changes needed?
A key list is as long as the number of splits the scan reported, so tens of thousands is ordinary, and anything allocated per key is allocated that many times.
projectKeysruns over all of them on everyEnsureRequirementsandValidateRequirementspass wheneverspark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabledis on, not only on the reducer path.Measured on a
KeyedPartitioningwith 50k keys of 12 positions, sixIntegerTypeand sixStringType, projecting two of each, with a reducer on one position, 20 evaluations after a warm-up:projectKeysreduceKeysOne thing I tried and dropped: hoisting the type dispatch out of the loop with
InternalRow.getAccessor, the wayBoundReferencedoes. It measured slower, 63-79 ms forprojectKeys, because these rows areGenericInternalRows, whoseget(ordinal, dataType)ignores the requested type and reads the array directly. The accessor only adds a closure call and a null-check wrapper on top of that.PhysicalDataType.applyper value is on theUnsafeRowpath, which partition keys are not.Does this PR introduce any user-facing change?
No.
How was this patch tested?
No new test: the two bodies are rewritten, not changed in behaviour, and both are on the path of the existing storage-partitioned-join tests. 305 tests green across
KeyGroupedPartitioningSuite,KeyGroupedPartitioningCatalystRuntimeFilterSuite,EnsureRequirementsSuite,ValidateRequirementsSuite,ProjectedOrderingAndPartitioningSuite,PlannerSuite,ShuffleSpecSuiteandDistributionSuite.dev/lint-scalaclean.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code