-
Notifications
You must be signed in to change notification settings - Fork 303
Liquid-clustering boundary sampling #15925
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: main
Are you sure you want to change the base?
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 |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| /* | ||
| * Copyright (c) 2026, NVIDIA CORPORATION. | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| package org.apache.spark.sql.rapids.execution | ||
|
|
||
| import scala.annotation.tailrec | ||
|
|
||
| import com.nvidia.spark.rapids.{GpuCoalesceBatches, GpuExec, GpuFilterExec, GpuProjectExec} | ||
| import com.nvidia.spark.rapids.shims.ShimUnaryExecNode | ||
|
|
||
| import org.apache.spark.rdd.RDD | ||
| import org.apache.spark.sql.catalyst.InternalRow | ||
| import org.apache.spark.sql.catalyst.expressions.{ | ||
| Attribute, Expression, ExprId, NamedExpression, SortOrder} | ||
| import org.apache.spark.sql.execution.SparkPlan | ||
| import org.apache.spark.sql.rapids.GpuFileSourceScanExec | ||
| import org.apache.spark.sql.types.StructType | ||
| import org.apache.spark.sql.vectorized.ColumnarBatch | ||
|
|
||
| /** | ||
| * An auxiliary physical plan used to collect range-partition boundaries. | ||
| * | ||
| * The range exchange exposes this node as a subquery so Spark includes the narrow scan and its | ||
| * normal GPU metrics in the SQL physical plan. The exchange executes it once while constructing | ||
| * its range partitioner; the full-width exchange child remains the source of shuffled rows. | ||
| */ | ||
| private[rapids] case class GpuRangeBoundaryExec(child: SparkPlan) | ||
| extends ShimUnaryExecNode with GpuExec { | ||
| override def output: Seq[Attribute] = child.output | ||
| override def nodeName: String = "GpuRangeBoundaryCollect" | ||
|
|
||
| override protected def doExecute(): RDD[InternalRow] = | ||
| throw new IllegalStateException(s"Row-based execution should not occur for $this") | ||
|
|
||
| override protected def internalDoExecuteColumnar(): RDD[ColumnarBatch] = | ||
| child.executeColumnar() | ||
| } | ||
|
|
||
| /** | ||
| * Builds a key-only physical plan for range-boundary collection when it is safe to do so. | ||
| * Unsupported plans fall back to sampling the full exchange input. | ||
| */ | ||
| private[rapids] object GpuRangeBoundaryPlan { | ||
| private def referencedExprIds(expressions: Seq[Expression]): Set[ExprId] = | ||
| expressions.flatMap(_.references).map(_.exprId).toSet | ||
|
|
||
| def build(plan: SparkPlan, ordering: Seq[SortOrder]): Option[GpuRangeBoundaryExec] = { | ||
| val required = referencedExprIds(ordering) | ||
| if (required.isEmpty) { | ||
| None | ||
| } else { | ||
| prune(plan, required).flatMap { pruned => | ||
| val selected = pruned.output.filter(attr => required.contains(attr.exprId)) | ||
| if (selected.map(_.exprId).toSet != required) { | ||
| None | ||
| } else { | ||
| val keyOnly = if (selected.length == pruned.output.length) { | ||
| pruned | ||
| } else { | ||
| GpuProjectExec(selected.toList, pruned) | ||
| } | ||
| Some(GpuRangeBoundaryExec(keyOnly)) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private def selectProjectExpressions( | ||
| projectList: List[NamedExpression], | ||
| localOutputIds: Set[ExprId], | ||
| required: Set[ExprId]): Option[List[NamedExpression]] = { | ||
| val projectOutputIds = projectList.map(_.exprId).toSet | ||
| if (!required.subsetOf(projectOutputIds)) { | ||
| None | ||
| } else { | ||
| @tailrec | ||
| def dependencyClosure(needed: Set[ExprId]): List[NamedExpression] = { | ||
| val selected = projectList.filter(ne => needed.contains(ne.exprId)) | ||
| val localDependencies = referencedExprIds(selected).intersect(localOutputIds) | ||
| val expanded = needed ++ localDependencies | ||
| if (expanded == needed) selected else dependencyClosure(expanded) | ||
| } | ||
|
|
||
| val selected = dependencyClosure(required) | ||
| // Boundary collection and shuffle input are separate executions of the source plan. | ||
| // Nondeterministic expressions can produce different keys if the executions use different | ||
| // batch boundaries, even when their seeds and partition IDs match. | ||
| if (selected.forall(_.deterministic)) Some(selected) else None | ||
| } | ||
| } | ||
|
|
||
| private def prune(plan: SparkPlan, required: Set[ExprId]): Option[SparkPlan] = plan match { | ||
| case project: GpuProjectExec => | ||
| val childOutputIds = project.child.output.map(_.exprId).toSet | ||
| val projectOutputIds = project.projectList.map(_.exprId).toSet | ||
| val localOutputIds = projectOutputIds -- childOutputIds | ||
| selectProjectExpressions(project.projectList, localOutputIds, required).flatMap { selected => | ||
| // Keep project-local dependencies here instead of requesting aliases from the child scan. | ||
| val childRequired = referencedExprIds(selected) -- localOutputIds | ||
| prune(project.child, childRequired).map { child => | ||
| project.copy(projectList = selected.toList, child = child) | ||
| } | ||
| } | ||
|
|
||
| case filter: GpuFilterExec if filter.condition.deterministic => | ||
| val childRequired = required ++ referencedExprIds(Seq(filter.condition)) | ||
| prune(filter.child, childRequired).map { child => | ||
| filter.withNewChildren(Seq(child)) | ||
| } | ||
|
|
||
| case coalesce: GpuCoalesceBatches => | ||
| prune(coalesce.child, required).map { child => | ||
| coalesce.withNewChildren(Seq(child)) | ||
| } | ||
|
|
||
| case scan: GpuFileSourceScanExec => | ||
| pruneFileScan(scan, required) | ||
|
|
||
| case _ => | ||
| None | ||
| } | ||
|
|
||
| private def pruneFileScan( | ||
| scan: GpuFileSourceScanExec, | ||
| required: Set[ExprId]): Option[GpuFileSourceScanExec] = { | ||
| if (!required.subsetOf(scan.output.map(_.exprId).toSet)) { | ||
| return None | ||
| } | ||
|
|
||
| val dataColumnCount = scan.requiredSchema.length | ||
| val dataPairs = scan.output.take(dataColumnCount).zip(scan.requiredSchema.fields) | ||
| val partitionPairs = scan.output.drop(dataColumnCount).zip(scan.readPartitionSchema.fields) | ||
| val selectedData = dataPairs.filter { case (attr, _) => required.contains(attr.exprId) } | ||
| val selectedPartitions = | ||
| partitionPairs.filter { case (attr, _) => required.contains(attr.exprId) } | ||
|
|
||
| val selectedOutput = selectedData.map(_._1) ++ selectedPartitions.map(_._1) | ||
| if (selectedOutput.map(_.exprId).toSet != required) { | ||
| None | ||
| } else { | ||
| val originalPartitionOutput = scan.originalOutput.drop(dataColumnCount) | ||
| Some(scan.copy( | ||
| originalOutput = selectedData.map(_._1) ++ originalPartitionOutput, | ||
| requiredSchema = StructType(selectedData.map(_._2)), | ||
| requiredPartitionSchema = Some(StructType(selectedPartitions.map(_._2))))(scan.rapidsConf)) | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -31,10 +31,10 @@ import org.apache.spark.rapids.shims.GpuShuffleExchangeExec | |||||
| import org.apache.spark.rdd.RDD | ||||||
| import org.apache.spark.serializer.Serializer | ||||||
| import org.apache.spark.sql.catalyst.InternalRow | ||||||
| import org.apache.spark.sql.catalyst.expressions.{Ascending, Attribute, SortOrder} | ||||||
| import org.apache.spark.sql.catalyst.expressions.{Ascending, Attribute, Expression, SortOrder} | ||||||
| import org.apache.spark.sql.catalyst.plans.physical.RoundRobinPartitioning | ||||||
| import org.apache.spark.sql.catalyst.trees.TreeNodeTag | ||||||
| import org.apache.spark.sql.execution.SparkPlan | ||||||
| import org.apache.spark.sql.execution.{ExecSubqueryExpression, SparkPlan} | ||||||
| import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanExec | ||||||
| import org.apache.spark.sql.execution.exchange.{Exchange, ShuffleExchangeExec} | ||||||
| import org.apache.spark.sql.execution.metric._ | ||||||
|
|
@@ -243,6 +243,27 @@ abstract class GpuShuffleExchangeExecBase( | |||||
|
|
||||||
| @transient lazy val inputBatchRDD: RDD[ColumnarBatch] = child.executeColumnar() | ||||||
|
|
||||||
| @transient private lazy val rangeBoundaryPlan: Option[GpuRangeBoundaryExec] = | ||||||
| gpuOutputPartitioning match { | ||||||
| case range: GpuRangePartitioning => | ||||||
| GpuRangeBoundaryPlan.build(child, range.gpuOrdering) | ||||||
| case _ => | ||||||
| None | ||||||
| } | ||||||
|
|
||||||
| private def expressionSubqueries(expression: Expression): Seq[SparkPlan] = { | ||||||
| val nested = expression.children.flatMap(expressionSubqueries) | ||||||
| expression match { | ||||||
| case subquery: ExecSubqueryExpression => nested :+ subquery.plan | ||||||
| case _ => nested | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| // Boundary collection is an auxiliary query of the exchange. Exposing it through Spark's | ||||||
| // subquery mechanism makes its physical operators and native metrics part of the SQL plan. | ||||||
| override lazy val subqueries: Seq[SparkPlan] = | ||||||
|
Collaborator
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. Nit, non-blocking:
Suggested change
|
||||||
| expressions.flatMap(expressionSubqueries) ++ rangeBoundaryPlan.toSeq | ||||||
|
|
||||||
| /** | ||||||
| * Returns the GPU partitioning used to build the shuffle dependency. Distributions whose | ||||||
| * physical partition count depends on the input RDD can override this hook. | ||||||
|
|
@@ -275,7 +296,8 @@ abstract class GpuShuffleExchangeExecBase( | |||||
| additionalMetrics, | ||||||
| opTimeNewShuffleWrite, | ||||||
| descendantOpTimeMetrics, | ||||||
| enableOpTimeTrackingRdd) | ||||||
| enableOpTimeTrackingRdd, | ||||||
| rangeBoundaryPlan) | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
|
|
@@ -405,7 +427,8 @@ object GpuShuffleExchangeExecBase { | |||||
| additionalMetrics: Map[String, GpuMetric], | ||||||
| opTimeNewShuffleWrite: Option[GpuMetric] = None, | ||||||
| descendantOpTimeMetrics: Seq[GpuMetric] = Seq.empty, | ||||||
| enableOpTimeTrackingRdd: Boolean = true) | ||||||
| enableOpTimeTrackingRdd: Boolean = true, | ||||||
| rangeBoundaryPlan: Option[GpuRangeBoundaryExec] = None) | ||||||
| : ShuffleDependency[Int, ColumnarBatch, ColumnarBatch] = { | ||||||
| val isRoundRobin = newPartitioning match { | ||||||
| case _: GpuRoundRobinPartitioning => true | ||||||
|
|
@@ -434,7 +457,7 @@ object GpuShuffleExchangeExecBase { | |||||
| rdd | ||||||
| } | ||||||
| val partitioner: GpuExpression = getPartitioner(newRdd, outputAttributes, | ||||||
| newPartitioning, metrics) | ||||||
| newPartitioning, metrics, rangeBoundaryPlan) | ||||||
| // Inject debugging subMetrics, such as D2HTime before SliceOnCpu | ||||||
| // The injected metrics will be serialized as the members of GpuPartitioning | ||||||
| partitioner match { | ||||||
|
|
@@ -549,16 +572,32 @@ object GpuShuffleExchangeExecBase { | |||||
| rdd: RDD[ColumnarBatch], | ||||||
| outputAttributes: Seq[Attribute], | ||||||
| newPartitioning: GpuPartitioning, | ||||||
| metrics: Map[String, GpuMetric]): GpuExpression with GpuPartitioning = { | ||||||
| metrics: Map[String, GpuMetric], | ||||||
| rangeBoundaryPlan: Option[GpuRangeBoundaryExec]): GpuExpression with GpuPartitioning = { | ||||||
| newPartitioning match { | ||||||
| case h: GpuHashPartitioning => | ||||||
| GpuBindReferences.bindReference(h, outputAttributes, metrics) | ||||||
| case r: GpuRangePartitioning => | ||||||
| val sorter = new GpuSorter(r.gpuOrdering, outputAttributes, metrics) | ||||||
| val bounds = GpuRangePartitioner.createRangeBounds(r.numPartitions, sorter, | ||||||
| rdd, SQLConf.get.rangeExchangeSampleSizePerPartition) | ||||||
| val (boundaryRdd, boundarySorter, boundaryProjection) = rangeBoundaryPlan match { | ||||||
| case Some(plan) => | ||||||
| val projectList = plan.output.map { boundaryAttr => | ||||||
| val ordinal = outputAttributes.indexWhere(_.exprId == boundaryAttr.exprId) | ||||||
| require(ordinal >= 0, | ||||||
| s"Range boundary attribute $boundaryAttr is missing from the shuffle input") | ||||||
| GpuBoundReference(ordinal, boundaryAttr.dataType, boundaryAttr.nullable)( | ||||||
| boundaryAttr.exprId, boundaryAttr.name) | ||||||
| } | ||||||
| (plan.executeColumnar(), | ||||||
| new GpuSorter(r.gpuOrdering, plan.output, metrics), Some(projectList)) | ||||||
| case None => | ||||||
| (rdd, sorter, None) | ||||||
| } | ||||||
| val bounds = GpuRangePartitioner.createRangeBounds(r.numPartitions, boundarySorter, | ||||||
| boundaryRdd, SQLConf.get.rangeExchangeSampleSizePerPartition) | ||||||
| // No need to bind arguments for the GpuRangePartitioner. The Sorter has already done it | ||||||
| new GpuRangePartitioner(bounds, sorter) | ||||||
| new GpuRangePartitioner(bounds, sorter, | ||||||
| boundaryProjection.map(_ => boundarySorter), boundaryProjection) | ||||||
| case GpuSinglePartitioning => | ||||||
| GpuSinglePartitioning | ||||||
| case rrp: GpuRoundRobinPartitioning => | ||||||
|
|
||||||
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.
Non-blocking: unconditional for every GPU range exchange the pruner accepts, with no off switch, so a field regression needs a patched release to mitigate.
Suggest an internal boolean defaulting to true, shaped like
spark.rapids.sql.shuffledHashJoin.optimizeShuffle. It also lets a test select between the two paths, which the suite comment needs.