This is an automated email from the ASF dual-hosted git repository.

dongjoon-hyun pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new f521d01350d4 [SPARK-57851][SQL] Shuffle-free single-task execution for 
small queries
f521d01350d4 is described below

commit f521d01350d4cee793aec98e2975b81f87c575ba
Author: Liang-Chi Hsieh <[email protected]>
AuthorDate: Thu Jul 9 13:08:28 2026 -0700

    [SPARK-57851][SQL] Shuffle-free single-task execution for small queries
    
    ### What changes were proposed in this pull request?
    
    This adds a conservative optimizer rule `MarkSingleTaskExecution` that 
marks small single-partition scans, optionally with a shuffle-inducing operator 
on top (sort, aggregate, distinct, window, limit/offset, expand) or an 
in-memory `LocalRelation`, as candidates for single-task execution. Such a scan 
reports a `SinglePartition` output partitioning, allowing `EnsureRequirements` 
to elide the shuffle that would otherwise be inserted before the operator on 
top.
    
    Details:
    - The rule runs as the last optimizer batch (so it sees the final plan 
shape) and marks eligible `LogicalRelation`/`LocalRelation` nodes with a 
`TreeNodeTag`.
    - `FileSourceStrategy`/`SparkStrategies` propagate the mark to 
`FileSourceScanExec`/`LocalTableScanExec`.
    - `FileSourceScanExec` additionally gates on file count and size thresholds 
using the generic `ScanFileListing`, reports `SinglePartition`, and coalesces 
its input RDD to a single partition as a correctness backstop when the estimate 
does not match the runtime partition count.
    - `LocalTableScanExec` reads its data in a single partition and reports 
`SinglePartition`.
    - `ExpandExec` forwards `SinglePartition` from its child, since Expand only 
replicates rows within a partition and never moves rows across partitions.
    
    The feature is controlled by new internal configs under 
`spark.sql.optimizer.singleTaskExecution.*` and is disabled by default. Join is 
intentionally left out for now and can be added as a follow-up; union is 
already covered by the existing `spark.sql.unionOutputPartitioning`.
    
    This is part of the SPIP umbrella 
[SPARK-56978](https://issues.apache.org/jira/browse/SPARK-56978) (Faster 
queries in local laptop mode), covering the shuffle-free local execution for 
small queries category.
    
    ### Why are the changes needed?
    
    For small, low-latency queries the fixed cost of a shuffle (scheduling, 
serialization, network) dominates the total runtime. When the input is already 
a single small partition, the shuffle inserted before a sort/aggregate/window 
is unnecessary and can be removed to reduce latency, without affecting 
correctness.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. The optimization is behind internal configs 
(`spark.sql.optimizer.singleTaskExecution.*`) and is disabled by default.
    
    ### How was this patch tested?
    
    New `MarkSingleTaskExecutionSuite` (14 tests) covering:
    - the marking decision for the supported plan shapes;
    - `SinglePartition` output with no shuffle in the final physical plan;
    - empty-scan correctness (a global aggregation over an empty scan still 
returns a single row);
    - disabled-flag negatives (master flag and per-operator sub-flags);
    - ineligibility of unsupported shapes (join) and subquery expressions;
    - the leaf-node parallelism override disabling the local-relation case.
    
    `SQLConfSuite` passes as a config-wiring regression check.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Yes, using Claude Code.
    
    Closes #56928 from viirya/single-node-execution.
    
    Authored-by: Liang-Chi Hsieh <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 .../expressions/aggregate/V2Aggregator.scala       |   3 +
 .../spark/sql/catalyst/trees/TreePatterns.scala    |   1 +
 .../org/apache/spark/sql/internal/SQLConf.scala    | 106 ++++++++
 .../execution/SparkConnectPlanExecution.scala      |   2 +-
 .../spark/sql/execution/DataSourceScanExec.scala   |  62 ++++-
 .../apache/spark/sql/execution/ExpandExec.scala    |  31 ++-
 .../spark/sql/execution/LocalTableScanExec.scala   |  31 ++-
 .../spark/sql/execution/SparkOptimizer.scala       |   7 +-
 .../spark/sql/execution/SparkStrategies.scala      |  10 +-
 .../aggregate/TypedAggregateExpression.scala       |   5 +
 .../spark/sql/execution/aggregate/udaf.scala       |   5 +
 .../execution/datasources/FileSourceStrategy.scala |   4 +-
 .../datasources/MarkSingleTaskExecution.scala      | 177 ++++++++++++
 .../org/apache/spark/sql/DataFrameJoinSuite.scala  |   2 +-
 .../scala/org/apache/spark/sql/SubquerySuite.scala |   2 +-
 .../datasources/MarkSingleTaskExecutionSuite.scala | 301 +++++++++++++++++++++
 .../scala/org/apache/spark/sql/hive/hiveUDFs.scala |   3 +
 17 files changed, 729 insertions(+), 23 deletions(-)

diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/aggregate/V2Aggregator.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/aggregate/V2Aggregator.scala
index 49ba2ec8b904..e92e61245201 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/aggregate/V2Aggregator.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/aggregate/V2Aggregator.scala
@@ -19,6 +19,7 @@ package org.apache.spark.sql.catalyst.expressions.aggregate
 
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions.{Expression, 
ImplicitCastInputTypes, UnsafeProjection}
+import org.apache.spark.sql.catalyst.trees.TreePattern.{TreePattern, 
USER_DEFINED_AGGREGATION}
 import org.apache.spark.sql.connector.catalog.functions.{AggregateFunction => 
V2AggregateFunction}
 import org.apache.spark.sql.types.{AbstractDataType, DataType}
 import org.apache.spark.util.ArrayImplicits._
@@ -31,6 +32,8 @@ case class V2Aggregator[BUF <: java.io.Serializable, OUT](
     inputAggBufferOffset: Int = 0)
   extends TypedImperativeAggregate[BUF] with ImplicitCastInputTypes {
 
+  final override val nodePatterns: Seq[TreePattern] = 
Seq(USER_DEFINED_AGGREGATION)
+
   private[this] lazy val inputProjection = UnsafeProjection.create(children)
 
   override def nullable: Boolean = aggrFunc.isResultNullable
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
index 94b4666a88a8..dfb815414dd3 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
@@ -107,6 +107,7 @@ object TreePattern extends Enumeration  {
   val TIME_WINDOW: Value = Value
   val TIME_ZONE_AWARE_EXPRESSION: Value = Value
   val TRUE_OR_FALSE_LITERAL: Value = Value
+  val USER_DEFINED_AGGREGATION: Value = Value
   val VARIANT_GET: Value = Value
   val WINDOW_EXPRESSION: Value = Value
   val WINDOW_TIME: Value = Value
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index ca31519a7069..3315cdc72332 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -7407,6 +7407,112 @@ object SQLConf {
       .booleanConf
       .createWithDefault(true)
 
+  val SINGLE_TASK_EXECUTION_ENABLED =
+    buildConf("spark.sql.optimizer.singleTaskExecution.enabled")
+      .doc("When true, eligible query fragments that read a small 
single-partition scan can run " +
+        "in a single task, skipping the shuffle that would otherwise be 
inserted before an " +
+        "operator such as a sort or aggregation. This avoids the scheduling 
overhead of an " +
+        "unnecessary shuffle for small, low-latency queries.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .booleanConf
+      .createWithDefault(false)
+
+  val SINGLE_TASK_EXECUTION_AGGREGATION =
+    buildConf("spark.sql.optimizer.singleTaskExecution.aggregation")
+      .internal()
+      .doc("When true, and 'spark.sql.optimizer.singleTaskExecution.enabled' 
is also true, " +
+        "enable the single-task optimization for query plans with aggregation 
operators.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .fallbackConf(SINGLE_TASK_EXECUTION_ENABLED)
+
+  val SINGLE_TASK_EXECUTION_EXPAND =
+    buildConf("spark.sql.optimizer.singleTaskExecution.expand")
+      .internal()
+      .doc("When true, and 'spark.sql.optimizer.singleTaskExecution.enabled' 
is also true, " +
+        "enable the single-task optimization for query plans with expand 
operators.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .fallbackConf(SINGLE_TASK_EXECUTION_ENABLED)
+
+  val SINGLE_TASK_EXECUTION_LIMIT_OFFSET =
+    buildConf("spark.sql.optimizer.singleTaskExecution.limitOffset")
+      .internal()
+      .doc("When true, and 'spark.sql.optimizer.singleTaskExecution.enabled' 
is also true, " +
+        "enable the single-task optimization for query plans with limit or 
offset operators.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .fallbackConf(SINGLE_TASK_EXECUTION_ENABLED)
+
+  val SINGLE_TASK_EXECUTION_SORT =
+    buildConf("spark.sql.optimizer.singleTaskExecution.sort")
+      .internal()
+      .doc("When true, and 'spark.sql.optimizer.singleTaskExecution.enabled' 
is also true, " +
+        "enable the single-task optimization for query plans with sort 
operators.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .fallbackConf(SINGLE_TASK_EXECUTION_ENABLED)
+
+  val SINGLE_TASK_EXECUTION_WINDOW =
+    buildConf("spark.sql.optimizer.singleTaskExecution.window")
+      .internal()
+      .doc("When true, and 'spark.sql.optimizer.singleTaskExecution.enabled' 
is also true, " +
+        "enable the single-task optimization for query plans with window 
operators.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .fallbackConf(SINGLE_TASK_EXECUTION_ENABLED)
+
+  val SINGLE_TASK_EXECUTION_MAX_NUM_FILES =
+    buildConf("spark.sql.optimizer.singleTaskExecution.maxNumFiles")
+      .internal()
+      .doc("The maximum number of files that a file scan may have for the 
single-task " +
+        "optimization to apply to it.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .intConf
+      .createWithDefault(1)
+
+  val SINGLE_TASK_EXECUTION_MIN_NUM_FILES =
+    buildConf("spark.sql.optimizer.singleTaskExecution.minNumFiles")
+      .internal()
+      .doc("The minimum number of files that a file scan may have for the 
single-task " +
+        "optimization to apply to it.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .intConf
+      .createWithDefault(1)
+
+  val SINGLE_TASK_EXECUTION_MIN_NUM_BYTES =
+    buildConf("spark.sql.optimizer.singleTaskExecution.minNumBytes")
+      .internal()
+      .doc("The minimum total size in bytes that a file scan may have for the 
single-task " +
+        "optimization to apply to it.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .longConf
+      .createWithDefault(1)
+
+  val SINGLE_TASK_EXECUTION_LOCAL_TABLE_SCAN_MIN_ROWS =
+    buildConf("spark.sql.optimizer.singleTaskExecution.localTableScan.minRows")
+      .internal()
+      .doc("The minimum number of rows that a local in-memory relation may 
have for the " +
+        "single-task optimization to apply to it.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .intConf
+      .createWithDefault(1)
+
+  val SINGLE_TASK_EXECUTION_LOCAL_TABLE_SCAN_THRESHOLD =
+    
buildConf("spark.sql.optimizer.singleTaskExecution.localTableScan.threshold")
+      .internal()
+      .doc("The maximum number of rows that a local in-memory relation may 
have for the " +
+        "single-task optimization to apply to it.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .intConf
+      .createWithDefault(1000)
+
   val LEGACY_PARSE_QUERY_WITHOUT_EOF = 
buildConf("spark.sql.legacy.parseQueryWithoutEof")
     .internal()
     .doc(
diff --git 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/execution/SparkConnectPlanExecution.scala
 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/execution/SparkConnectPlanExecution.scala
index 5fdfd5d1ccd1..0c4ca9357e84 100644
--- 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/execution/SparkConnectPlanExecution.scala
+++ 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/execution/SparkConnectPlanExecution.scala
@@ -213,7 +213,7 @@ private[execution] class 
SparkConnectPlanExecution(executeHolder: ExecuteHolder)
       }
     }
     dataframe.queryExecution.executedPlan match {
-      case LocalTableScanExec(_, rows, _) =>
+      case LocalTableScanExec(_, rows, _, _) =>
         executePlan.eventsManager.postFinished(Some(rows.length))
         var offset = 0L
         converter(rows.iterator).foreach { case (bytes, count) =>
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/DataSourceScanExec.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/DataSourceScanExec.scala
index be7013188f2f..a727ccf56506 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/DataSourceScanExec.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/DataSourceScanExec.scala
@@ -28,7 +28,7 @@ import org.apache.spark.sql.catalyst.{FileSourceOptions, 
InternalRow, TableIdent
 import org.apache.spark.sql.catalyst.catalog.BucketSpec
 import org.apache.spark.sql.catalyst.expressions._
 import org.apache.spark.sql.catalyst.plans.QueryPlan
-import org.apache.spark.sql.catalyst.plans.physical.{HashPartitioning, 
Partitioning, UnknownPartitioning}
+import org.apache.spark.sql.catalyst.plans.physical.{HashPartitioning, 
Partitioning, SinglePartition, UnknownPartitioning}
 import org.apache.spark.sql.catalyst.util.{truncatedString, CaseInsensitiveMap}
 import org.apache.spark.sql.connector.read.streaming.SparkDataStream
 import org.apache.spark.sql.errors.QueryExecutionErrors
@@ -320,6 +320,34 @@ trait FileSourceScanLike extends DataSourceScanExec with 
SessionStateHelper {
   def requiredSchema: StructType
   // Identifier for the table in the metastore.
   def tableIdentifier: Option[TableIdentifier]
+  // When true, the `MarkSingleTaskExecution` optimizer rule has marked this 
scan's plan shape as a
+  // candidate for single-task execution. The scan is only actually executed 
in a single task when
+  // it additionally passes the file count and size thresholds (see 
`useSingleTaskExecution`).
+  def markedForSingleTaskExecution: Boolean
+
+  /**
+   * Whether this file scan should run in a single task, reporting a 
`SinglePartition` output
+   * partitioning so that a following shuffle can be elided. This is true when 
the plan shape was
+   * marked eligible by the optimizer and the statically-selected files fall 
within the configured
+   * count and size bounds. Bucketed scans are excluded: they report a 
`HashPartitioning` over the
+   * bucket columns, which coalescing to a single partition would invalidate. 
It relies on
+   * `selectedPartitions`, so it must not be evaluated before the scan's file 
listing is available.
+   */
+  lazy val useSingleTaskExecution: Boolean = {
+    if (!markedForSingleTaskExecution || bucketedScan) {
+      false
+    } else {
+      val sqlConf = getSqlConf(relation.sparkSession)
+      val minNumFiles = 
sqlConf.getConf(SQLConf.SINGLE_TASK_EXECUTION_MIN_NUM_FILES)
+      val maxNumFiles = 
sqlConf.getConf(SQLConf.SINGLE_TASK_EXECUTION_MAX_NUM_FILES)
+      val minNumBytes = 
sqlConf.getConf(SQLConf.SINGLE_TASK_EXECUTION_MIN_NUM_BYTES)
+      val maxPartitionBytes = 
sqlConf.getConf(SQLConf.FILES_MAX_PARTITION_BYTES)
+      val numFiles = selectedPartitions.totalNumberOfFiles
+      val numBytes = selectedPartitions.totalFileSize
+      numFiles >= minNumFiles && numFiles <= maxNumFiles &&
+        numBytes >= minNumBytes && numBytes <= maxPartitionBytes
+    }
+  }
 
 
   lazy val fileConstantMetadataColumns: Seq[AttributeReference] = 
output.collect {
@@ -478,6 +506,8 @@ trait FileSourceScanLike extends DataSourceScanExec with 
SessionStateHelper {
         Nil
       }
       (partitioning, sortOrder)
+    } else if (useSingleTaskExecution) {
+      (SinglePartition, Nil)
     } else {
       (UnknownPartitioning(0), Nil)
     }
@@ -696,7 +726,8 @@ case class FileSourceScanExec(
     override val optionalNumCoalescedBuckets: Option[Int],
     override val dataFilters: Seq[Expression],
     override val tableIdentifier: Option[TableIdentifier],
-    override val disableBucketedScan: Boolean = false)
+    override val disableBucketedScan: Boolean = false,
+    override val markedForSingleTaskExecution: Boolean = false)
   extends FileSourceScanLike {
 
   // Note that some vals referring the file-based relation are lazy 
intentionally
@@ -744,10 +775,28 @@ case class FileSourceScanExec(
     inputRDD :: Nil
   }
 
+  /**
+   * The input RDD, coalesced to a single partition when this scan runs in 
single-task mode. This
+   * enforces the `SinglePartition` output partitioning reported by 
`outputPartitioning`, which is
+   * estimated from the statically-selected files and may not correspond 
exactly to the number of
+   * partitions the input RDD produces after dynamic pruning. Coalescing here 
keeps the query
+   * correct in either case.
+   */
+  private[spark] lazy val maybeCoalesceInputRDD: RDD[InternalRow] = {
+    if (useSingleTaskExecution && inputRDD.getNumPartitions > 1) {
+      inputRDD.coalesce(1)
+    } else if (useSingleTaskExecution && inputRDD.getNumPartitions == 0) {
+      // All files were pruned away; produce a single empty partition to match 
`SinglePartition`.
+      sparkContext.parallelize[InternalRow](Nil, 1)
+    } else {
+      inputRDD
+    }
+  }
+
   protected override def doExecute(): RDD[InternalRow] = {
     val numOutputRows = longMetric("numOutputRows")
     if (needsUnsafeRowConversion) {
-      inputRDD.mapPartitionsWithIndexInternal { (index, iter) =>
+      maybeCoalesceInputRDD.mapPartitionsWithIndexInternal { (index, iter) =>
         val toUnsafe = UnsafeProjection.create(schema)
         toUnsafe.initialize(index)
         iter.map { row =>
@@ -756,7 +805,7 @@ case class FileSourceScanExec(
         }
       }
     } else {
-      inputRDD.mapPartitionsInternal { iter =>
+      maybeCoalesceInputRDD.mapPartitionsInternal { iter =>
         iter.map { row =>
           numOutputRows += 1
           row
@@ -768,7 +817,7 @@ case class FileSourceScanExec(
   protected override def doExecuteColumnar(): RDD[ColumnarBatch] = {
     val numOutputRows = longMetric("numOutputRows")
     val scanTime = longMetric("scanTime")
-    inputRDD.asInstanceOf[RDD[ColumnarBatch]].mapPartitionsInternal { batches 
=>
+    
maybeCoalesceInputRDD.asInstanceOf[RDD[ColumnarBatch]].mapPartitionsInternal { 
batches =>
       new Iterator[ColumnarBatch] {
 
         override def hasNext: Boolean = {
@@ -921,7 +970,8 @@ case class FileSourceScanExec(
       optionalNumCoalescedBuckets,
       QueryPlan.normalizePredicates(dataFilters, output),
       None,
-      disableBucketedScan)
+      disableBucketedScan,
+      markedForSingleTaskExecution)
   }
 
   override def getStream: Option[SparkDataStream] = stream
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/ExpandExec.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/ExpandExec.scala
index 254772f73208..ba1238564348 100644
--- a/sql/core/src/main/scala/org/apache/spark/sql/execution/ExpandExec.scala
+++ b/sql/core/src/main/scala/org/apache/spark/sql/execution/ExpandExec.scala
@@ -21,7 +21,7 @@ import org.apache.spark.rdd.RDD
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions._
 import org.apache.spark.sql.catalyst.expressions.codegen._
-import org.apache.spark.sql.catalyst.plans.physical.{Partitioning, 
UnknownPartitioning}
+import org.apache.spark.sql.catalyst.plans.physical.{Partitioning, 
SinglePartition, UnknownPartitioning}
 import org.apache.spark.sql.execution.metric.SQLMetrics
 import org.apache.spark.sql.internal.SQLConf
 
@@ -36,15 +36,38 @@ import org.apache.spark.sql.internal.SQLConf
 case class ExpandExec(
     projections: Seq[Seq[Expression]],
     output: Seq[Attribute],
-    child: SparkPlan)
+    child: SparkPlan,
+    // When true, this Expand is part of a plan marked for single-task 
execution by the
+    // `MarkSingleTaskExecution` optimizer rule, and forwards the child's 
`SinglePartition`
+    // output partitioning (see `outputPartitioning`).
+    useSingleTask: Boolean = false)
   extends UnaryExecNode with CodegenSupport {
 
   override lazy val metrics = Map(
     "numOutputRows" -> SQLMetrics.createMetric(sparkContext, "number of output 
rows"))
 
   // The GroupExpressions can output data with arbitrary partitioning, so set 
it
-  // as UNKNOWN partitioning
-  override def outputPartitioning: Partitioning = UnknownPartitioning(0)
+  // as UNKNOWN partitioning. Expand only replicates rows within a partition 
and never moves rows
+  // across partitions, so when this Expand is part of a plan marked for 
single-task execution
+  // and the child produces a single partition, we can forward the 
`SinglePartition` property to
+  // avoid an unneeded shuffle.
+  override def outputPartitioning: Partitioning = {
+    if (useSingleTask && child.outputPartitioning == SinglePartition) {
+      SinglePartition
+    } else {
+      UnknownPartitioning(0)
+    }
+  }
+
+  // Show `useSingleTask` in the string representation only when it is set, so 
that plans not
+  // using single-task execution (the default) keep their existing explain 
output.
+  override protected def stringArgs: Iterator[Any] = {
+    if (useSingleTask) {
+      super.stringArgs
+    } else {
+      Iterator(projections, output, child)
+    }
+  }
 
   @transient
   override lazy val references: AttributeSet =
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/LocalTableScanExec.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/LocalTableScanExec.scala
index 2d5dbf819959..21c9a43710fa 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/LocalTableScanExec.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/LocalTableScanExec.scala
@@ -20,6 +20,7 @@ package org.apache.spark.sql.execution
 import org.apache.spark.rdd.RDD
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions.{Attribute, UnsafeProjection}
+import org.apache.spark.sql.catalyst.plans.physical.{Partitioning, 
SinglePartition, UnknownPartitioning}
 import org.apache.spark.sql.connector.read.streaming.SparkDataStream
 import org.apache.spark.sql.execution.metric.SQLMetrics
 import org.apache.spark.util.ArrayImplicits._
@@ -34,7 +35,10 @@ import org.apache.spark.util.ArrayImplicits._
 case class LocalTableScanExec(
     output: Seq[Attribute],
     @transient rows: Seq[InternalRow],
-    @transient stream: Option[SparkDataStream])
+    @transient stream: Option[SparkDataStream],
+    // When true, the relation is scanned in a single partition, so this node 
reports a
+    // `SinglePartition` output partitioning. Set by the 
`MarkSingleTaskExecution` optimizer rule.
+    useSingleTask: Boolean = false)
   extends LeafExecNode
   with StreamSourceAwareSparkPlan
   with InputRDDCodegen {
@@ -53,14 +57,33 @@ case class LocalTableScanExec(
 
   @transient private lazy val rdd: RDD[InternalRow] = {
     if (rows.isEmpty) {
-      sparkContext.emptyRDD
+      if (useSingleTask) {
+        // Produce a single empty partition to match the `SinglePartition` 
reported by
+        // `outputPartitioning`. `emptyRDD` has zero partitions, and running 
e.g. a global
+        // aggregation on a zero-partition RDD with the shuffle elided would 
return no rows
+        // instead of the single row expected on empty input.
+        sparkContext.parallelize(Seq.empty[InternalRow], 1)
+      } else {
+        sparkContext.emptyRDD
+      }
     } else {
-      val numSlices = math.min(
-        unsafeRows.length, session.leafNodeDefaultParallelism)
+      val numSlices = if (useSingleTask) {
+        1
+      } else {
+        math.min(unsafeRows.length, session.leafNodeDefaultParallelism)
+      }
       sparkContext.parallelize(unsafeRows.toImmutableArraySeq, numSlices)
     }
   }
 
+  override def outputPartitioning: Partitioning = {
+    if (useSingleTask) {
+      SinglePartition
+    } else {
+      UnknownPartitioning(0)
+    }
+  }
+
   protected override def doExecute(): RDD[InternalRow] = {
     val numOutputRows = longMetric("numOutputRows")
     rdd.map { r =>
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkOptimizer.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkOptimizer.scala
index 1b3b2d3efc72..54158d5bb4a9 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkOptimizer.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkOptimizer.scala
@@ -23,7 +23,7 @@ import org.apache.spark.sql.catalyst.optimizer._
 import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
 import org.apache.spark.sql.catalyst.rules.Rule
 import org.apache.spark.sql.connector.catalog.CatalogManager
-import org.apache.spark.sql.execution.datasources.{PruneFileSourcePartitions, 
PushVariantIntoScan, SchemaPruning, V1Writes}
+import org.apache.spark.sql.execution.datasources.{MarkSingleTaskExecution, 
PruneFileSourcePartitions, PushVariantIntoScan, SchemaPruning, V1Writes}
 import 
org.apache.spark.sql.execution.datasources.v2.{GroupBasedRowLevelOperationScanPlanning,
 OptimizeMetadataOnlyDeleteFromTable, V2ScanPartitioningAndOrdering, 
V2ScanRelationPushDown, V2Writes}
 import 
org.apache.spark.sql.execution.dynamicpruning.{CleanupDynamicPruningFilters, 
PartitionPruning, RowLevelOperationRuntimeGroupFiltering}
 import 
org.apache.spark.sql.execution.python.{ExtractGroupingPythonUDFFromAggregate, 
ExtractPythonUDFFromAggregate, ExtractPythonUDFs, ExtractPythonUDTFs}
@@ -100,7 +100,10 @@ class SparkOptimizer(
       ConstantFolding,
       EliminateLimits),
     Batch("User Provided Optimizers", fixedPoint, 
experimentalMethods.extraOptimizations: _*),
-    Batch("Replace CTE with Repartition", Once, ReplaceCTERefWithRepartition)))
+    Batch("Replace CTE with Repartition", Once, ReplaceCTERefWithRepartition),
+    // Must run last: it inspects the final plan shape to mark scans that can 
run in a single task,
+    // and no subsequent rule should reshape the plan or copy the marked scan 
nodes.
+    Batch("MarkSingleTaskExecution", Once, MarkSingleTaskExecution)))
 
   override def nonExcludableRules: Seq[String] = super.nonExcludableRules ++
     Seq(
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
index 6e761fbe07b2..d89f7a919269 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
@@ -1151,7 +1151,9 @@ abstract class SparkStrategies extends 
QueryPlanner[SparkPlan] {
       case f: logical.TypedFilter =>
         execution.FilterExec(f.typedCondition(f.deserializer), 
planLater(f.child)) :: Nil
       case e @ logical.Expand(_, _, child) =>
-        execution.ExpandExec(e.projections, e.output, planLater(child)) :: Nil
+        val useSingleTask = e.getTagValue(
+          datasources.MarkSingleTaskExecution.markTag).getOrElse(false)
+        execution.ExpandExec(e.projections, e.output, planLater(child), 
useSingleTask) :: Nil
       case logical.Sample(lb, ub, withReplacement, seed, child, sampleMethod) 
=>
         if (sampleMethod == logical.SampleMethod.System) {
           // V2ScanRelationPushDown is non-excludable and always handles 
SYSTEM samples
@@ -1161,8 +1163,10 @@ abstract class SparkStrategies extends 
QueryPlanner[SparkPlan] {
             "TABLESAMPLE SYSTEM node was not properly handled by 
V2ScanRelationPushDown.")
         }
         execution.SampleExec(lb, ub, withReplacement, seed, planLater(child)) 
:: Nil
-      case logical.LocalRelation(output, data, _, stream) =>
-        LocalTableScanExec(output, data, stream) :: Nil
+      case r @ logical.LocalRelation(output, data, _, stream) =>
+        val useSingleTask = r.getTagValue(
+          datasources.MarkSingleTaskExecution.markTag).getOrElse(false)
+        LocalTableScanExec(output, data, stream, useSingleTask) :: Nil
       case logical.EmptyRelation(l) => EmptyRelationExec(l) :: Nil
       case CommandResult(output, _, plan, data) => CommandResultExec(output, 
plan, data) :: Nil
       // We should match the combination of limit and offset first, to get the 
optimal physical
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/TypedAggregateExpression.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/TypedAggregateExpression.scala
index d958790dd09b..df0addad7861 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/TypedAggregateExpression.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/TypedAggregateExpression.scala
@@ -25,6 +25,7 @@ import org.apache.spark.sql.catalyst.expressions._
 import org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateFunction, 
DeclarativeAggregate, TypedImperativeAggregate}
 import org.apache.spark.sql.catalyst.expressions.codegen.GenerateSafeProjection
 import org.apache.spark.sql.catalyst.expressions.objects.Invoke
+import org.apache.spark.sql.catalyst.trees.TreePattern.{TreePattern, 
USER_DEFINED_AGGREGATION}
 import org.apache.spark.sql.expressions.Aggregator
 import org.apache.spark.sql.types._
 import org.apache.spark.util.Utils
@@ -125,6 +126,8 @@ case class SimpleTypedAggregateExpression(
     nullable: Boolean)
   extends DeclarativeAggregate with TypedAggregateExpression with 
NonSQLExpression {
 
+  final override val nodePatterns: Seq[TreePattern] = 
Seq(USER_DEFINED_AGGREGATION)
+
   override lazy val deterministic: Boolean = true
 
   override def children: Seq[Expression] = {
@@ -223,6 +226,8 @@ case class ComplexTypedAggregateExpression(
     inputAggBufferOffset: Int = 0)
   extends TypedImperativeAggregate[Any] with TypedAggregateExpression with 
NonSQLExpression {
 
+  final override val nodePatterns: Seq[TreePattern] = 
Seq(USER_DEFINED_AGGREGATION)
+
   override lazy val deterministic: Boolean = true
 
   override def children: Seq[Expression] = {
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/udaf.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/udaf.scala
index 492f11607ce6..203ee2d89b7b 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/udaf.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/udaf.scala
@@ -25,6 +25,7 @@ import 
org.apache.spark.sql.catalyst.expressions.{AttributeReference, Expression
 import 
org.apache.spark.sql.catalyst.expressions.aggregate.{ImperativeAggregate, 
TypedImperativeAggregate}
 import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
 import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.catalyst.trees.TreePattern.{TreePattern, 
USER_DEFINED_AGGREGATION}
 import org.apache.spark.sql.catalyst.types.DataTypeUtils.toAttributes
 import org.apache.spark.sql.expressions.{Aggregator, MutableAggregationBuffer, 
UserDefinedAggregateFunction, UserDefinedAggregator}
 import org.apache.spark.sql.types._
@@ -358,6 +359,8 @@ case class ScalaUDAF(
   with ImplicitCastInputTypes
   with UserDefinedExpression {
 
+  final override val nodePatterns: Seq[TreePattern] = 
Seq(USER_DEFINED_AGGREGATION)
+
   override def withNewMutableAggBufferOffset(newMutableAggBufferOffset: Int): 
ImperativeAggregate =
     copy(mutableAggBufferOffset = newMutableAggBufferOffset)
 
@@ -500,6 +503,8 @@ case class ScalaAggregator[IN, BUF, OUT](
   with ImplicitCastInputTypes
   with Logging {
 
+  final override val nodePatterns: Seq[TreePattern] = 
Seq(USER_DEFINED_AGGREGATION)
+
   // input and buffer encoders are resolved by ResolveEncodersInScalaAgg
   @transient private[this] lazy val inputDeserializer = 
inputEncoder.createDeserializer()
   @transient private[this] lazy val bufferSerializer = 
bufferEncoder.createSerializer()
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala
index b7a1736bc2e9..e2427222d8eb 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala
@@ -337,7 +337,9 @@ object FileSourceStrategy extends Strategy with 
PredicateHelper with Logging {
           bucketSet,
           None,
           rebindFileSourceMetadataAttributesInFilters(expandedDataFilters),
-          table.map(_.identifier))
+          table.map(_.identifier),
+          markedForSingleTaskExecution =
+            l.getTagValue(MarkSingleTaskExecution.markTag).getOrElse(false))
 
       // extra Project node: wrap flat metadata columns to a metadata struct
       val withMetadataProjections = metadataStructOpt.map { metadataStruct =>
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/MarkSingleTaskExecution.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/MarkSingleTaskExecution.scala
new file mode 100644
index 000000000000..c1a5eda5c084
--- /dev/null
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/MarkSingleTaskExecution.scala
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.execution.datasources
+
+import org.apache.spark.sql.catalyst.plans.logical._
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.catalyst.trees.TreeNodeTag
+import org.apache.spark.sql.catalyst.trees.TreePattern._
+import org.apache.spark.sql.internal.SQLConf
+
+/**
+ * This optimizer rule marks eligible query plans for single-task execution. 
The optimization
+ * targets a conservative, specific query shape to ensure predictable and 
efficient behavior.
+ *
+ * The rule matches simple query plans with a single small file scan or a 
single small in-memory
+ * relation, optionally with a shuffle-inducing operator (sort, aggregation, 
window, expand, or
+ * limit/offset) on top. When it detects such a shape, it marks the underlying 
scan:
+ *
+ *  - a [[LogicalRelation]] or [[LocalRelation]] is marked with the
+ *    [[MarkSingleTaskExecution.markTag]] tag, as is any [[Expand]] in the 
plan so that the
+ *    physical Expand can forward the child's `SinglePartition` output 
partitioning.
+ *
+ * The physical scan then reports a `SinglePartition` output partitioning, 
which allows
+ * [[org.apache.spark.sql.execution.exchange.EnsureRequirements]] to elide the 
shuffle that would
+ * otherwise be inserted before the operator on top. This shuffle is not 
required for correctness
+ * of the query, so removing it reduces scheduling overhead for small, 
low-latency queries.
+ *
+ * The matching is deliberately strict and conservative to minimize the risk 
of unintended
+ * performance regressions. It can be broadened in the future as needed.
+ *
+ * This rule is controlled by [[SQLConf.SINGLE_TASK_EXECUTION_ENABLED]] and 
the per-operator
+ * sub-flags in [[SQLConf]].
+ */
+object MarkSingleTaskExecution extends Rule[LogicalPlan] {
+
+  /**
+   * Tag placed on a [[LogicalRelation]] or [[LocalRelation]] that has been 
marked eligible for
+   * single-task execution, and on any [[Expand]] in such a plan. The planning 
strategies read
+   * this tag to propagate the decision to the physical
+   * [[org.apache.spark.sql.execution.FileSourceScanExec]] /
+   * [[org.apache.spark.sql.execution.LocalTableScanExec]] /
+   * [[org.apache.spark.sql.execution.ExpandExec]].
+   */
+  val markTag: TreeNodeTag[Boolean] = 
TreeNodeTag[Boolean]("__single_task_execution")
+
+  /**
+   * Plan patterns that make a query ineligible for the optimization. These 
operators either
+   * require shuffles that we cannot safely elide, or run user code whose 
behavior we should not
+   * change. User-defined aggregations are excluded defensively: an 
optimization that collapses
+   * the partial and final aggregates when no exchange separates them would 
skip the user's merge
+   * step, so single-task plans must never be assumed safe for them.
+   */
+  val unsupportedPatterns: Seq[TreePattern] = Seq(
+    EVAL_PYTHON_UDF,
+    EVAL_PYTHON_UDTF,
+    EXISTS_SUBQUERY,
+    FUNCTION_TABLE_RELATION_ARGUMENT_EXPRESSION,
+    LATERAL_SUBQUERY,
+    LIST_SUBQUERY,
+    PYTHON_UDF,
+    SCALAR_SUBQUERY,
+    USER_DEFINED_AGGREGATION)
+
+  /**
+   * The per-operator sub-flags, resolved once per invocation. Each field 
indicates whether the
+   * corresponding shuffle-inducing operator is allowed on top of a single 
small scan.
+   */
+  private case class EnabledOperators(
+      aggregation: Boolean,
+      expand: Boolean,
+      limitOffset: Boolean,
+      sort: Boolean,
+      window: Boolean)
+
+  override def apply(plan: LogicalPlan): LogicalPlan = {
+    // An explicit leaf-node parallelism override expresses the user's intent 
about how many
+    // partitions leaf scans should produce, so do not force scans into a 
single partition.
+    if (!conf.getConf(SQLConf.SINGLE_TASK_EXECUTION_ENABLED) ||
+        conf.getConf(SQLConf.LEAF_NODE_DEFAULT_PARALLELISM).isDefined) {
+      return plan
+    }
+    val enabled = EnabledOperators(
+      aggregation = conf.getConf(SQLConf.SINGLE_TASK_EXECUTION_AGGREGATION),
+      expand = conf.getConf(SQLConf.SINGLE_TASK_EXECUTION_EXPAND),
+      limitOffset = conf.getConf(SQLConf.SINGLE_TASK_EXECUTION_LIMIT_OFFSET),
+      sort = conf.getConf(SQLConf.SINGLE_TASK_EXECUTION_SORT),
+      window = conf.getConf(SQLConf.SINGLE_TASK_EXECUTION_WINDOW))
+
+    if (plan.containsAnyPattern(unsupportedPatterns: _*)) {
+      plan
+    } else if (isSupportedShape(plan, enabled)) {
+      // Mark a private clone of the plan rather than the plan itself, so that 
this rule never
+      // mutates a node it was handed: a tag set on a shared node would 
propagate through
+      // `TreeNode.clone`/`copyTagsFrom` and could leak the marking into 
unrelated plans. Note
+      // that marking a per-node copy would not work either: a copy differing 
only in tags is
+      // structurally equal to the original, so tree-rebuilding APIs such as 
`withNewChildren`
+      // would discard it and keep the original node.
+      val cloned = plan.clone()
+      markSingleTaskExecution(cloned)
+      cloned
+    } else {
+      plan
+    }
+  }
+
+  /**
+   * Returns true if every operator in the plan is one that we support keeping 
on top of a single
+   * small scan. Only operators that either do not require a shuffle, or whose 
shuffle-inducing
+   * sub-flag is enabled, are allowed. Any other operator makes the plan 
ineligible.
+   */
+  private def isSupportedShape(plan: LogicalPlan, enabled: EnabledOperators): 
Boolean = plan match {
+    case _: LogicalRelation | _: LocalRelation => true
+    // Operators that never introduce a shuffle by themselves. Note that 
`Distinct` and
+    // `SubqueryAlias` need no cases here: they are rewritten away by 
non-excludable rules
+    // (`ReplaceDistinctWithAggregate` and `EliminateSubqueryAliases`) long 
before this rule runs.
+    case _: Project | _: Filter |
+         _: DeserializeToObject | _: SerializeFromObject =>
+      plan.children.forall(isSupportedShape(_, enabled))
+    // Shuffle-inducing operators, allowed only when the matching sub-flag is 
enabled.
+    case _: Aggregate if enabled.aggregation =>
+      plan.children.forall(isSupportedShape(_, enabled))
+    case _: Expand if enabled.expand =>
+      plan.children.forall(isSupportedShape(_, enabled))
+    case (_: GlobalLimit | _: LocalLimit | _: Offset) if enabled.limitOffset =>
+      plan.children.forall(isSupportedShape(_, enabled))
+    case _: Sort if enabled.sort =>
+      plan.children.forall(isSupportedShape(_, enabled))
+    case _: Window if enabled.window =>
+      plan.children.forall(isSupportedShape(_, enabled))
+    case _ => false
+  }
+
+  /**
+   * Sets the mark on each scan in the given (already validated) plan. Marking 
mutates the nodes
+   * in place, which is only safe because the caller passes this rule's 
private clone of the plan.
+   */
+  private def markSingleTaskExecution(plan: LogicalPlan): Unit = plan match {
+    case lr: LogicalRelation =>
+      lr.setTagValue(markTag, true)
+    case r: LocalRelation =>
+      if (isLocalRelationEligible(r)) {
+        r.setTagValue(markTag, true)
+      }
+    case e: Expand =>
+      // Also mark the Expand itself: the physical `ExpandExec` reads this tag 
to forward the
+      // child's `SinglePartition` output partitioning, which it must only do 
within a plan
+      // marked for single-task execution.
+      e.setTagValue(markTag, true)
+      e.children.foreach(markSingleTaskExecution)
+    case other =>
+      other.children.foreach(markSingleTaskExecution)
+  }
+
+  /**
+   * A local in-memory relation is eligible when its row count falls within 
the configured bounds.
+   */
+  private def isLocalRelationEligible(r: LocalRelation): Boolean = {
+    val minRows = 
conf.getConf(SQLConf.SINGLE_TASK_EXECUTION_LOCAL_TABLE_SCAN_MIN_ROWS)
+    val threshold = 
conf.getConf(SQLConf.SINGLE_TASK_EXECUTION_LOCAL_TABLE_SCAN_THRESHOLD)
+    r.data.length >= minRows && r.data.length <= threshold
+  }
+}
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/DataFrameJoinSuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/DataFrameJoinSuite.scala
index 9733d51a91cb..129d6bb68676 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/DataFrameJoinSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/DataFrameJoinSuite.scala
@@ -447,7 +447,7 @@ class DataFrameJoinSuite extends SharedSparkSession
             }
             assert(broadcastExchanges.size == 1)
             val tables = broadcastExchanges.head.collect {
-              case FileSourceScanExec(_, _, _, _, _, _, _, _, 
Some(tableIdent), _) => tableIdent
+              case FileSourceScanExec(_, _, _, _, _, _, _, _, 
Some(tableIdent), _, _) => tableIdent
             }
             assert(tables.size == 1)
             assert(tables.head ===
diff --git a/sql/core/src/test/scala/org/apache/spark/sql/SubquerySuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/SubquerySuite.scala
index cd3e389d765d..d8d885c9b927 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/SubquerySuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/SubquerySuite.scala
@@ -1523,7 +1523,7 @@ class SubquerySuite extends SharedSparkSession
       // need to execute the query before we can examine fs.inputRDDs()
       assert(stripAQEPlan(df.queryExecution.executedPlan) match {
         case WholeStageCodegenExec(ColumnarToRowExec(InputAdapter(
-            fs @ FileSourceScanExec(_, _, _, _, partitionFilters, _, _, _, _, 
_)))) =>
+            fs @ FileSourceScanExec(_, _, _, _, partitionFilters, _, _, _, _, 
_, _)))) =>
           partitionFilters.exists(ExecSubqueryExpression.hasSubquery) &&
             fs.inputRDDs().forall(
               _.asInstanceOf[FileScanRDD].filePartitions.forall(
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/MarkSingleTaskExecutionSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/MarkSingleTaskExecutionSuite.scala
new file mode 100644
index 000000000000..d3f5ac779e54
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/MarkSingleTaskExecutionSuite.scala
@@ -0,0 +1,301 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.execution.datasources
+
+import org.apache.spark.sql.{Encoder, Encoders, QueryTest, Row}
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.BoundReference
+import org.apache.spark.sql.catalyst.expressions.aggregate.V2Aggregator
+import org.apache.spark.sql.catalyst.plans.logical.{LocalRelation, 
LogicalPlan, Sort}
+import org.apache.spark.sql.catalyst.plans.physical.SinglePartition
+import org.apache.spark.sql.catalyst.trees.TreePattern.USER_DEFINED_AGGREGATION
+import org.apache.spark.sql.connector.catalog.functions.{AggregateFunction => 
V2AggregateFunction}
+import org.apache.spark.sql.execution.{FileSourceScanExec, LocalTableScanExec, 
SparkPlan}
+import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanExec, 
AdaptiveSparkPlanHelper}
+import org.apache.spark.sql.execution.exchange.ShuffleExchangeLike
+import org.apache.spark.sql.expressions.Aggregator
+import org.apache.spark.sql.functions.{count, sum, udaf}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{DataType, LongType}
+
+/**
+ * Test suite for the [[MarkSingleTaskExecution]] optimizer rule and its 
physical effects. The rule
+ * marks small single-partition scans, optionally under a shuffle-inducing 
operator, so that the
+ * scan reports a `SinglePartition` output partitioning and the following 
shuffle can be elided.
+ */
+class MarkSingleTaskExecutionSuite extends QueryTest with SharedSparkSession
+  with AdaptiveSparkPlanHelper {
+
+  private val t = "single_task_t"
+  private val t2 = "single_task_t2"
+  private val emptyTable = "single_task_empty"
+
+  private def enabledConfs: Seq[(String, String)] = Seq(
+    SQLConf.SINGLE_TASK_EXECUTION_ENABLED.key -> "true",
+    // Force the optimization to also apply to zero-file / zero-byte scans so 
that we can exercise
+    // the empty-scan corner case created by dynamic pruning.
+    SQLConf.SINGLE_TASK_EXECUTION_MIN_NUM_FILES.key -> "0",
+    SQLConf.SINGLE_TASK_EXECUTION_MIN_NUM_BYTES.key -> "0",
+    SQLConf.SINGLE_TASK_EXECUTION_LOCAL_TABLE_SCAN_MIN_ROWS.key -> "0")
+
+  override def beforeAll(): Unit = {
+    super.beforeAll()
+    // A single-file Parquet table with a small amount of data.
+    spark.range(0, 2).selectExpr("id as col", "cast(id as string) as col_str")
+      .repartition(1).write.mode("overwrite").saveAsTable(t)
+    spark.range(0, 3).selectExpr("id % 2 as col")
+      .repartition(1).write.mode("overwrite").saveAsTable(t2)
+    spark.range(0, 0).selectExpr("id as 
col").write.mode("overwrite").saveAsTable(emptyTable)
+  }
+
+  override def afterAll(): Unit = {
+    try {
+      sql(s"drop table if exists $t")
+      sql(s"drop table if exists $t2")
+      sql(s"drop table if exists $emptyTable")
+    } finally {
+      super.afterAll()
+    }
+  }
+
+  private def getFinalPhysicalPlan(df: org.apache.spark.sql.DataFrame): 
SparkPlan = {
+    df.queryExecution.executedPlan match {
+      case a: AdaptiveSparkPlanExec => a.finalPhysicalPlan
+      case other => other
+    }
+  }
+
+  private def hasShuffle(plan: SparkPlan): Boolean =
+    collectWithSubqueries(plan) { case s: ShuffleExchangeLike => s }.nonEmpty
+
+  private def isMarked(plan: LogicalPlan): Boolean = {
+    val marks = plan.collect {
+      case lr: LogicalRelation => 
lr.getTagValue(MarkSingleTaskExecution.markTag).getOrElse(false)
+      case lr: LocalRelation => 
lr.getTagValue(MarkSingleTaskExecution.markTag).getOrElse(false)
+    }
+    marks.nonEmpty && marks.forall(identity)
+  }
+
+  private def checkMarked(query: String): Unit = withSQLConf(enabledConfs: _*) 
{
+    val plan = sql(query).queryExecution.optimizedPlan
+    assert(isMarked(plan), s"expected plan to be marked for single-task 
execution:\n$plan")
+  }
+
+  private def checkNotMarked(query: String, confs: Seq[(String, String)] = 
enabledConfs): Unit =
+    withSQLConf(confs: _*) {
+      val plan = sql(query).queryExecution.optimizedPlan
+      assert(!isMarked(plan), s"expected plan NOT to be marked:\n$plan")
+    }
+
+  private def checkSinglePartition(
+      query: String,
+      expected: Seq[Row],
+      confs: Seq[(String, String)] = enabledConfs): Unit = withSQLConf(confs: 
_*) {
+    val df = sql(query)
+    QueryTest.checkAnswer(df, expected)
+    val plan = getFinalPhysicalPlan(df)
+    assert(!hasShuffle(plan), s"expected no shuffle in:\n$plan")
+    val scans = collect(plan) {
+      case s: FileSourceScanExec => s.outputPartitioning
+      case s: LocalTableScanExec => s.outputPartitioning
+    }
+    assert(scans.nonEmpty, s"expected a scan in:\n$plan")
+    assert(scans.forall(_ == SinglePartition),
+      s"expected all scans to report SinglePartition, got $scans in:\n$plan")
+  }
+
+  test("marks scan + sort") {
+    checkMarked(s"select col from $t order by col")
+    checkMarked(s"select col from (select col from $t where col = 0) order by 
col")
+  }
+
+  test("marks scan + aggregation") {
+    checkMarked(s"select count(1) from $t group by col")
+    checkMarked(s"select sum(col) from (select col from $t where col < 42)")
+  }
+
+  test("marks scan + expand (grouping sets)") {
+    checkMarked(s"select col, count(1) from $t group by rollup(col)")
+  }
+
+  test("marks scan + window") {
+    checkMarked(
+      s"select col, row_number() over (partition by col order by col) from $t")
+  }
+
+  test("does not mark when the feature is disabled") {
+    checkNotMarked(
+      s"select col from $t order by col",
+      Seq(SQLConf.SINGLE_TASK_EXECUTION_ENABLED.key -> "false"))
+  }
+
+  test("does not mark when the per-operator flag is disabled") {
+    checkNotMarked(
+      s"select col from $t order by col",
+      enabledConfs :+ (SQLConf.SINGLE_TASK_EXECUTION_SORT.key -> "false"))
+  }
+
+  test("marking does not mutate the input plan's nodes") {
+    // The rule must mark a copy of each eligible node rather than tag the 
node it was handed:
+    // an in-place tag on a shared node would propagate through 
`TreeNode.clone`/`copyTagsFrom`
+    // and could leak the marking into unrelated queries. Build an eligible 
plan shape directly
+    // (rather than through a query, whose optimizer would have already run 
this rule) so the
+    // input is guaranteed unmarked, then run the rule on it.
+    withSQLConf(enabledConfs: _*) {
+      val relation = spark.table(t).queryExecution.analyzed.collectFirst {
+        case lr: LogicalRelation => lr
+      }.get
+      val input = Sort(Nil, global = true, relation)
+      val output = MarkSingleTaskExecution(input)
+      // The rule marks a copied relation ...
+      assert(isMarked(output), s"expected output to be marked:\n$output")
+      // ... and leaves the original relation node untagged (it was copied, 
not mutated).
+      assert(relation.getTagValue(MarkSingleTaskExecution.markTag).isEmpty,
+        "rule must not tag the input relation node in place")
+    }
+  }
+
+  test("does not mark unsupported plan shapes (join)") {
+    // Join is not a supported operator in this port, so the presence of a 
join makes the whole
+    // plan ineligible.
+    checkNotMarked(s"select a.col from $t a join $t b on a.col = b.col order 
by a.col")
+  }
+
+  test("does not mark plans with subquery expressions") {
+    checkNotMarked(s"select col from $t where col = (select max(col) from $t2) 
order by col")
+  }
+
+  test("does not mark plans with user-defined aggregations") {
+    val strLen = new Aggregator[String, Long, Long] {
+      override def zero: Long = 0L
+      override def reduce(b: Long, a: String): Long = b + a.length
+      override def merge(b1: Long, b2: Long): Long = b1 + b2
+      override def finish(reduction: Long): Long = reduction
+      override def bufferEncoder: Encoder[Long] = Encoders.scalaLong
+      override def outputEncoder: Encoder[Long] = Encoders.scalaLong
+    }
+    // `functions.udaf` produces a `ScalaAggregator` expression.
+    spark.udf.register("test_str_len_agg", udaf(strLen))
+    try {
+      checkNotMarked(s"select test_str_len_agg(col_str) from $t")
+    } finally {
+      spark.sessionState.catalog.dropTempFunction("test_str_len_agg", 
ignoreIfNotExists = true)
+    }
+    // A typed Dataset aggregation produces a `TypedAggregateExpression`.
+    withSQLConf(enabledConfs: _*) {
+      import testImplicits._
+      val ds = 
spark.table(t).select($"col_str").as[String].select(strLen.toColumn)
+      val optimized = ds.queryExecution.optimizedPlan
+      assert(!isMarked(optimized),
+        s"expected plan with typed aggregation NOT to be marked:\n$optimized")
+    }
+  }
+
+  test("V2Aggregator carries the USER_DEFINED_AGGREGATION pattern") {
+    // ScalaAggregator and the typed aggregate expressions are covered by the 
end-to-end test
+    // above; V2Aggregator has no operator-level pattern of its own, so verify 
it directly.
+    // HiveUDAFFunction lives in the hive module and is covered there.
+    val v2Func = new V2AggregateFunction[java.lang.Long, java.lang.Long] {
+      override def newAggregationState(): java.lang.Long = 0L
+      override def update(state: java.lang.Long, input: InternalRow): 
java.lang.Long =
+        state + input.getLong(0)
+      override def merge(l: java.lang.Long, r: java.lang.Long): java.lang.Long 
= l + r
+      override def produceResult(state: java.lang.Long): java.lang.Long = state
+      override def name(): String = "test_v2_sum"
+      override def inputTypes(): Array[DataType] = Array(LongType)
+      override def resultType(): DataType = LongType
+    }
+    val agg = V2Aggregator(v2Func, Seq(BoundReference(0, LongType, nullable = 
false)))
+    assert(agg.containsPattern(USER_DEFINED_AGGREGATION))
+  }
+
+  test("output partitioning is SinglePartition, scan + sort") {
+    checkSinglePartition(s"select col from $t order by col", Seq(Row(0), 
Row(1)))
+  }
+
+  test("output partitioning is SinglePartition, scan + aggregation with group 
by") {
+    checkSinglePartition(
+      s"select count(1) as c from $t2 group by col",
+      Seq(Row(1), Row(2)))
+  }
+
+  test("output partitioning is SinglePartition, scan + aggregation without 
group by") {
+    checkSinglePartition(s"select sum(col) from $t", Seq(Row(1)))
+  }
+
+  test("output partitioning is SinglePartition, scan + distinct") {
+    checkSinglePartition(s"select distinct col from $t2", Seq(Row(0), Row(1)))
+  }
+
+  test("output partitioning is SinglePartition, scan + expand") {
+    checkSinglePartition(
+      s"select col, count(1) as c from $t group by rollup(col)",
+      Seq(Row(0, 1), Row(1, 1), Row(null, 2)))
+  }
+
+  test("bucketed scan does not run in a single task") {
+    val bucketed = "single_task_bucketed"
+    withTable(bucketed) {
+      spark.range(0, 2).selectExpr("id as col").write.bucketBy(2, 
"col").saveAsTable(bucketed)
+      // Raise the file count bound so that only the bucketing makes the scan 
ineligible.
+      val confs = enabledConfs :+ 
(SQLConf.SINGLE_TASK_EXECUTION_MAX_NUM_FILES.key -> "4")
+      withSQLConf(confs: _*) {
+        val df = sql(s"select col, count(1) as c from $bucketed group by col")
+        checkAnswer(df, Seq(Row(0, 1), Row(1, 1)))
+        val scans = collect(getFinalPhysicalPlan(df)) { case s: 
FileSourceScanExec => s }
+        assert(scans.nonEmpty)
+        assert(scans.forall(!_.useSingleTaskExecution),
+          "a bucketed scan must not run in a single task as that would 
invalidate its " +
+            "HashPartitioning over the bucket columns")
+      }
+    }
+  }
+
+  test("empty table scan + aggregation is correct and single-partition") {
+    // Without single-task execution eliding the shuffle before the 
aggregation, an empty scan
+    // could incorrectly return zero rows instead of a single NULL row for a 
global aggregation.
+    checkSinglePartition(s"select sum(col) from $emptyTable", Seq(Row(null)))
+  }
+
+  test("in-memory local relation is scanned in a single partition") {
+    checkSinglePartition(
+      "select col from values (0), (1) as tab(col) order by col",
+      Seq(Row(0), Row(1)))
+  }
+
+  test("empty local relation + global aggregation returns one row") {
+    withSQLConf(enabledConfs: _*) {
+      import testImplicits._
+      val df = Seq.empty[Int].toDF("col").agg(count($"col"), sum($"col"))
+      assert(isMarked(df.queryExecution.optimizedPlan),
+        "expected the empty local relation to be marked for single-task 
execution")
+      // A global aggregation over an empty input must still return a single 
row.
+      checkAnswer(df, Row(0, null))
+    }
+  }
+
+  test("does not mark when a leaf-node parallelism override is set") {
+    checkNotMarked(
+      "select col from values (0), (1) as tab(col) order by col",
+      enabledConfs :+ (SQLConf.LEAF_NODE_DEFAULT_PARALLELISM.key -> "4"))
+    checkNotMarked(
+      s"select col from $t order by col",
+      enabledConfs :+ (SQLConf.LEAF_NODE_DEFAULT_PARALLELISM.key -> "4"))
+  }
+}
diff --git a/sql/hive/src/main/scala/org/apache/spark/sql/hive/hiveUDFs.scala 
b/sql/hive/src/main/scala/org/apache/spark/sql/hive/hiveUDFs.scala
index bf708eecf0c0..129c5e2cc053 100644
--- a/sql/hive/src/main/scala/org/apache/spark/sql/hive/hiveUDFs.scala
+++ b/sql/hive/src/main/scala/org/apache/spark/sql/hive/hiveUDFs.scala
@@ -32,6 +32,7 @@ import org.apache.spark.sql.catalyst.expressions._
 import org.apache.spark.sql.catalyst.expressions.aggregate._
 import org.apache.spark.sql.catalyst.expressions.codegen.{CodegenContext, 
CodeGenerator, CodegenFallback, ExprCode}
 import org.apache.spark.sql.catalyst.expressions.codegen.Block.BlockHelper
+import org.apache.spark.sql.catalyst.trees.TreePattern.{TreePattern, 
USER_DEFINED_AGGREGATION}
 import org.apache.spark.sql.hive.HiveShim._
 import org.apache.spark.sql.types._
 
@@ -337,6 +338,8 @@ private[hive] case class HiveUDAFFunction(
   with HiveInspectors
   with UserDefinedExpression {
 
+  final override val nodePatterns: Seq[TreePattern] = 
Seq(USER_DEFINED_AGGREGATION)
+
   override def withNewMutableAggBufferOffset(newMutableAggBufferOffset: Int): 
ImperativeAggregate =
     copy(mutableAggBufferOffset = newMutableAggBufferOffset)
 


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

Reply via email to