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

parthchandra pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git


The following commit(s) were added to refs/heads/main by this push:
     new 7e0e5d2a26 fix: Native shuffle fails with a 2GB task serialization OOM 
on jobs with many partitions (#5392)
7e0e5d2a26 is described below

commit 7e0e5d2a26928b05b936c3f2a9abd375d6efc41f
Author: Parth Chandra <[email protected]>
AuthorDate: Thu Aug 20 09:30:11 2026 -0700

    fix: Native shuffle fails with a 2GB task serialization OOM on jobs with 
many partitions (#5392)
    
    * fix: Native shuffle fails with a 2GB task serialization OOM on jobs with 
very many partitions
---
 .github/workflows/pr_build_linux.yml               |   1 +
 .github/workflows/pr_build_macos.yml               |   1 +
 .../shuffle/CometNativeShuffleInputRDD.scala       |  31 +++++--
 .../shuffle/CometNativeShuffleWriter.scala         |  12 ++-
 .../shuffle/CometShuffleExchangeExec.scala         |  16 +++-
 .../org/apache/spark/sql/comet/operators.scala     |   7 +-
 .../comet/exec/CometNativeShuffleSuite.scala       |  28 ++++++
 .../shuffle/CometNativeShuffleInputRDDSuite.scala  | 101 +++++++++++++++++++++
 8 files changed, 181 insertions(+), 16 deletions(-)

diff --git a/.github/workflows/pr_build_linux.yml 
b/.github/workflows/pr_build_linux.yml
index dff1ec58fd..aaa9f0a255 100644
--- a/.github/workflows/pr_build_linux.yml
+++ b/.github/workflows/pr_build_linux.yml
@@ -327,6 +327,7 @@ jobs:
               org.apache.comet.exec.CometShuffle4_0Suite
               org.apache.comet.exec.CometNativeColumnarToRowSuite
               org.apache.comet.exec.CometNativeShuffleSuite
+              
org.apache.spark.sql.comet.execution.shuffle.CometNativeShuffleInputRDDSuite
               org.apache.comet.exec.CometShuffleEncryptionSuite
               org.apache.comet.exec.CometShuffleManagerSuite
               org.apache.comet.exec.CometAsyncShuffleSuite
diff --git a/.github/workflows/pr_build_macos.yml 
b/.github/workflows/pr_build_macos.yml
index aca9da1f26..c6afabec31 100644
--- a/.github/workflows/pr_build_macos.yml
+++ b/.github/workflows/pr_build_macos.yml
@@ -143,6 +143,7 @@ jobs:
               org.apache.comet.exec.CometShuffle4_0Suite
               org.apache.comet.exec.CometNativeColumnarToRowSuite
               org.apache.comet.exec.CometNativeShuffleSuite
+              
org.apache.spark.sql.comet.execution.shuffle.CometNativeShuffleInputRDDSuite
               org.apache.comet.exec.CometShuffleEncryptionSuite
               org.apache.comet.exec.CometShuffleManagerSuite
               org.apache.comet.exec.CometAsyncShuffleSuite
diff --git 
a/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometNativeShuffleInputRDD.scala
 
b/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometNativeShuffleInputRDD.scala
index 0579e57bce..b79fb9458c 100644
--- 
a/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometNativeShuffleInputRDD.scala
+++ 
b/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometNativeShuffleInputRDD.scala
@@ -38,7 +38,8 @@ private[shuffle] class CometNativeShuffleInputRDD(
     sc: SparkContext,
     var inputRDDs: Seq[RDD[_]],
     numPartitionsParam: Int,
-    shuffleScanIndices: Set[Int])
+    shuffleScanIndices: Set[Int],
+    @transient perPartitionByKey: Map[String, Array[Array[Byte]]] = Map.empty)
     extends RDD[Product2[Int, ColumnarBatch]](
       sc,
       inputRDDs.map(rdd => new OneToOneDependency(rdd))) {
@@ -50,7 +51,13 @@ private[shuffle] class CometNativeShuffleInputRDD(
       // `leafRdd.partitions` on the executor, which would otherwise trigger 
getPartitions and
       // hit the @transient-null trap (e.g. CometExecRDD.perPartitionByKey).
       val inputParts = inputRDDs.map(_.partitions(i)).toArray
-      new CometNativeShuffleInputPartition(i, inputParts)
+      // Slice this partition's plan data off the @transient full map here on 
the driver. Carrying
+      // only the per-partition slice on the Partition object (serialized per 
task) keeps the full
+      // O(numPartitions) map out of the broadcast task binary, which 
otherwise blows the 2GB
+      // ByteArrayOutputStream limit on jobs with tens of millions of 
partitions. Mirrors
+      // CometExecRDD.getPartitions.
+      val planDataByKey = perPartitionByKey.map { case (key, arr) => key -> 
arr(i) }
+      new CometNativeShuffleInputPartition(i, inputParts, planDataByKey)
     }.toArray
 
   override def compute(
@@ -63,7 +70,11 @@ private[shuffle] class CometNativeShuffleInputRDD(
         partition.inputPartitions,
         shuffleScanIndices,
         context)
-    new CometNativeShuffleInputIterator(partition.index, inputObjects, 
shuffleBlockIters)
+    new CometNativeShuffleInputIterator(
+      partition.index,
+      inputObjects,
+      shuffleBlockIters,
+      partition.planDataByKey)
   }
 
   override def getPreferredLocations(split: Partition): Seq[String] = {
@@ -84,19 +95,23 @@ private[shuffle] class CometNativeShuffleInputRDD(
 
 private[shuffle] class CometNativeShuffleInputPartition(
     override val index: Int,
-    val inputPartitions: Array[Partition])
+    val inputPartitions: Array[Partition],
+    val planDataByKey: Map[String, Array[Byte]])
     extends Partition
 
 /**
  * Iterator handed to [[CometNativeShuffleWriter.write]] via Spark's 
ShuffleMapTask. Reports no
- * elements; the writer downcasts and reads `partitionIndex`, `inputObjects`, 
and
- * `shuffleBlockIterators` directly to drive the unified native plan. 
`inputObjects` are the
- * already-resolved native input slots (see 
[[CometExecRDD.resolveInputObjects]]).
+ * elements; the writer downcasts and reads `partitionIndex`, `inputObjects`,
+ * `shuffleBlockIterators`, and `planDataByKey` directly to drive the unified 
native plan.
+ * `inputObjects` are the already-resolved native input slots (see
+ * [[CometExecRDD.resolveInputObjects]]). `planDataByKey` is this partition's 
slice of the scan
+ * plan data (one entry per scan key); the writer injects it into the native 
plan.
  */
 private[shuffle] class CometNativeShuffleInputIterator(
     val partitionIndex: Int,
     val inputObjects: Array[Object],
-    val shuffleBlockIterators: Map[Int, CometShuffleBlockIterator])
+    val shuffleBlockIterators: Map[Int, CometShuffleBlockIterator],
+    val planDataByKey: Map[String, Array[Byte]])
     extends Iterator[Product2[Int, ColumnarBatch]] {
 
   override def hasNext: Boolean = false
diff --git 
a/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometNativeShuffleWriter.scala
 
b/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometNativeShuffleWriter.scala
index 9eca4681b9..3245cc3429 100644
--- 
a/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometNativeShuffleWriter.scala
+++ 
b/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometNativeShuffleWriter.scala
@@ -97,10 +97,14 @@ class CometNativeShuffleWriter[K, V](
     val unifiedPlan = buildUnifiedPlan(tempDataFilename, tempIndexFilename)
     val ctx = spec.execContext
     val finalNativePlan = if (ctx.commonByKey.nonEmpty) {
-      val partitionDataByKey = ctx.perPartitionByKey.map { case (k, arr) =>
-        k -> arr(partitionIdx)
-      }
-      PlanDataInjector.injectPlanData(unifiedPlan, ctx.commonByKey, 
partitionDataByKey)
+      // This partition's plan-data slice rides on the input iterator's 
Partition object (populated
+      // in CometNativeShuffleInputRDD.getPartitions on the driver), not on 
the spec. The spec's
+      // execContext.perPartitionByKey is emptied in 
prepareNativeShuffleDependency so the full
+      // O(numPartitions) map stays out of the broadcast task binary.
+      PlanDataInjector.injectPlanData(
+        unifiedPlan,
+        ctx.commonByKey,
+        shuffleInputIter.planDataByKey)
     } else {
       unifiedPlan
     }
diff --git 
a/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometShuffleExchangeExec.scala
 
b/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometShuffleExchangeExec.scala
index 9505443fb3..84f313ad37 100644
--- 
a/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometShuffleExchangeExec.scala
+++ 
b/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometShuffleExchangeExec.scala
@@ -122,7 +122,8 @@ case class CometShuffleExchangeExec(
           sparkContext,
           ctx.inputs,
           ctx.numPartitions,
-          ctx.shuffleScanIndices)
+          ctx.shuffleScanIndices,
+          ctx.perPartitionByKey)
       case None =>
         // Non-native child (e.g. CometSparkToColumnarExec): no subtree to 
inline. The dep gets
         // built via the convenience overload below; we just need a real RDD 
of batches.
@@ -786,8 +787,17 @@ object CometShuffleExchangeExec
       case e: Expression => e.collect { case s: ScalarSubquery => s }
       case _ => Nil
     }
-    val augmentedSpec = spec.copy(execContext =
-      spec.execContext.copy(subqueries = spec.execContext.subqueries ++ 
partitioningSubqueries))
+    // Drop the per-partition plan-data map off the spec that lands on the 
(non-transient)
+    // CometShuffleDependency.nativeShuffleSpec. Each partition's slice now 
rides on the thin RDD's
+    // Partition objects (see CometNativeShuffleInputRDD.getPartitions), so 
the full
+    // O(numPartitions) map is dead weight here and would blow the 2GB 
ByteArrayOutputStream limit
+    // at stage submission on very-high-partition-count jobs. 
NativeExecContext.perPartitionByKey is
+    // also @transient (the structural guard against any build path), but we 
empty it explicitly
+    // here too so the map isn't retained on the driver via this dependency. 
commonByKey stays
+    // (O(#scans), not O(#partitions), and the writer still needs it).
+    val augmentedSpec = spec.copy(execContext = spec.execContext.copy(
+      subqueries = spec.execContext.subqueries ++ partitioningSubqueries,
+      perPartitionByKey = Map.empty))
 
     // The code block below is mostly brought over from
     // ShuffleExchangeExec::prepareShuffleDependency
diff --git a/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala 
b/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
index 025f68b111..d91ad02a6c 100644
--- a/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
+++ b/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
@@ -539,7 +539,12 @@ private[comet] case class NativeExecContext(
     broadcastedHadoopConfForEncryption: 
Option[Broadcast[SerializableConfiguration]],
     encryptedFilePaths: Seq[String],
     commonByKey: Map[String, Array[Byte]],
-    perPartitionByKey: Map[String, Array[Array[Byte]]],
+    // @transient: this holds one serialized scan-plan-data blob per 
partition, so at high partition
+    // counts it is huge. It is only read on the driver (to slice per 
partition onto each task's
+    // Partition object - see CometNativeShuffleInputRDD / CometExecRDD); the 
executor reads its own
+    // slice, never this map. Keeping it off the wire stops it from bloating 
the broadcast task
+    // binary when this context rides on the non-transient 
CometShuffleDependency.nativeShuffleSpec.
+    @transient perPartitionByKey: Map[String, Array[Array[Byte]]],
     shuffleScanIndices: Set[Int],
     hasScanInput: Boolean) {
   // Catch shape divergence (e.g. broadcast scans with different partition 
counts after DPP
diff --git 
a/spark/src/test/scala/org/apache/comet/exec/CometNativeShuffleSuite.scala 
b/spark/src/test/scala/org/apache/comet/exec/CometNativeShuffleSuite.scala
index ced6531cd8..cd52f57955 100644
--- a/spark/src/test/scala/org/apache/comet/exec/CometNativeShuffleSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/exec/CometNativeShuffleSuite.scala
@@ -108,6 +108,34 @@ class CometNativeShuffleSuite extends CometTestBase with 
AdaptiveSparkPlanHelper
     }
   }
 
+  test("native shuffle over a multi-partition native scan re-threads 
per-partition plan data") {
+    // End-to-end companion to CometNativeShuffleInputRDDSuite: that suite 
proves the per-partition
+    // scan plan data no longer rides the broadcast task binary; this one 
proves each task still
+    // gets its OWN slice at write time. Reading real Parquet files gives a 
CometNativeScanExec with
+    // several map partitions, so perPartitionByKey holds one file-list slice 
per partition and a
+    // correct result depends on task i seeing slice i (not partition 0's).
+    withTempDir { dir =>
+      val path = new Path(dir.toURI.toString, "multi.parquet")
+      // Spread rows across 8 files so the native scan can yield multiple map 
partitions.
+      spark
+        .range(0, 10000, 1, numPartitions = 8)
+        .selectExpr("id AS _1", "CAST(id AS STRING) AS _2")
+        .write
+        .parquet(path.toString)
+
+      // Force one scan partition per file split so the per-partition array 
has multiple distinct
+      // entries; otherwise Spark coalesces the tiny files into a single 
partition.
+      withSQLConf(
+        "spark.sql.files.maxPartitionBytes" -> "1024",
+        "spark.sql.files.openCostInBytes" -> "0") {
+        readParquetFile(path.toString) { df =>
+          val shuffled = df.repartition(17, col("_1"))
+          checkShuffleAnswer(shuffled, 1, checkNativeOperators = true)
+        }
+      }
+    }
+  }
+
   test("hash-based native shuffle") {
     withParquetTable((0 until 5).map(i => (i, (i + 1).toLong)), "tbl") {
       val df = sql("SELECT * FROM tbl").sortWithinPartitions($"_1".desc)
diff --git 
a/spark/src/test/scala/org/apache/spark/sql/comet/execution/shuffle/CometNativeShuffleInputRDDSuite.scala
 
b/spark/src/test/scala/org/apache/spark/sql/comet/execution/shuffle/CometNativeShuffleInputRDDSuite.scala
new file mode 100644
index 0000000000..55194e80a2
--- /dev/null
+++ 
b/spark/src/test/scala/org/apache/spark/sql/comet/execution/shuffle/CometNativeShuffleInputRDDSuite.scala
@@ -0,0 +1,101 @@
+/*
+ * 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.comet.execution.shuffle
+
+import org.apache.spark.HashPartitioner
+import org.apache.spark.serializer.JavaSerializer
+import org.apache.spark.sql.CometTestBase
+import org.apache.spark.sql.comet.{CometMetricNode, NativeExecContext}
+import org.apache.spark.sql.execution.metric.SQLMetrics
+import org.apache.spark.sql.vectorized.ColumnarBatch
+
+import org.apache.comet.serde.OperatorOuterClass.Operator
+
+/**
+ * Ensure the serialized shuffle-map-stage task binary does not grow with the 
number of
+ * partitions.
+ *
+ * `DAGScheduler.submitMissingTasks` broadcasts the serialized `(stage.rdd, 
stage.shuffleDep)`
+ * pair, which must fit in ~2GB. On the native-shuffle path the scan plan data 
is one serialized
+ * blob per map partition; the leak lived under
+ * `CometShuffleDependency.nativeShuffleSpec.execContext`, not on the thin 
RDD. So this builds
+ * both carriers and serializes the pair, which catches a regression in either 
the RDD or the
+ * dependency guards without needing a real 2GB allocation.
+ *
+ * Lives in the `execution.shuffle` package so it can construct the 
`private[shuffle]`
+ * [[CometNativeShuffleInputRDD]] and the `private[comet]` 
[[NativeExecContext]] directly.
+ */
+class CometNativeShuffleInputRDDSuite extends CometTestBase {
+
+  test("serialized (rdd, dep) task binary size is independent of partition 
count") {
+    val sc = spark.sparkContext
+    val ser = new JavaSerializer(sc.getConf).newInstance()
+
+    // One scan key with a 1KB blob per map partition -- the per-partition 
plan data. Build both
+    // objects the DAGScheduler serializes for a shuffle-map stage: the thin 
RDD, and the
+    // CometShuffleDependency whose NativeShuffleSpec holds a 
NativeExecContext with this map.
+    def build(numPartitions: Int): (
+        CometNativeShuffleInputRDD,
+        CometShuffleDependency[Int, ColumnarBatch, ColumnarBatch]) = {
+      val perPartitionByKey =
+        Map("scan-0" -> Array.fill(numPartitions)(new Array[Byte](1024)))
+      val rdd = new CometNativeShuffleInputRDD(
+        sc,
+        inputRDDs = Seq.empty,
+        numPartitionsParam = numPartitions,
+        shuffleScanIndices = Set.empty,
+        perPartitionByKey = perPartitionByKey)
+      val execContext = NativeExecContext(
+        inputs = Seq.empty,
+        numPartitions = numPartitions,
+        subqueries = Seq.empty,
+        broadcastedHadoopConfForEncryption = None,
+        encryptedFilePaths = Seq.empty,
+        commonByKey = Map.empty,
+        perPartitionByKey = perPartitionByKey,
+        shuffleScanIndices = Set.empty,
+        hasScanInput = false)
+      val spec =
+        NativeShuffleSpec(Operator.getDefaultInstance, 
CometMetricNode(Map.empty), execContext)
+      val dep = new CometShuffleDependency[Int, ColumnarBatch, ColumnarBatch](
+        _rdd = rdd,
+        partitioner = new HashPartitioner(numPartitions),
+        decodeTime = SQLMetrics.createMetric(sc, "decode time"),
+        nativeShuffleSpec = Some(spec))
+      (rdd, dep)
+    }
+
+    // Pre-fix this pair grew from ~13KB to ~10MB between 10 and 10000 
partitions. With the map held
+    // @transient on both the RDD and the NativeExecContext, the pair stays 
roughly constant.
+    val (_, smallDep) = build(10)
+    val (largeRdd, largeDep) = build(10000)
+    val smallPair = ser.serialize((smallDep.rdd, smallDep)).limit()
+    val largePair = ser.serialize((largeDep.rdd, largeDep)).limit()
+    assert(
+      math.abs(largePair - smallPair) < 100 * 1024,
+      s"serialized (rdd, dep) grew with partition count (small=$smallPair, 
large=$largePair); " +
+        "the per-partition plan-data map is leaking into the broadcast task 
binary")
+
+    // Each task's Partition object still carries its own slice so the writer 
can inject plan data.
+    val part = 
largeRdd.partitions(7).asInstanceOf[CometNativeShuffleInputPartition]
+    assert(part.planDataByKey.keySet == Set("scan-0"))
+    assert(part.planDataByKey("scan-0").length == 1024)
+  }
+}


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

Reply via email to