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

SteNicholas pushed a commit to branch branch-0.7
in repository https://gitbox.apache.org/repos/asf/celeborn.git


The following commit(s) were added to refs/heads/branch-0.7 by this push:
     new 99f135af51 [CELEBORN-2480] Push columnar shuffle batches by estimated 
size in bytes
99f135af51 is described below

commit 99f135af51f65dcd3e6f5cfbc5256474619beff1
Author: ForVic <[email protected]>
AuthorDate: Tue Oct 20 20:37:12 2026 +0800

    [CELEBORN-2480] Push columnar shuffle batches by estimated size in bytes
    
    ### What changes were proposed in this pull request?
    
    Add an optional byte limit to the columnar shuffle writer. When 
`celeborn.columnarShuffle.batch.sizeInBytes.enabled` is true, 
`ColumnarHashBasedShuffleWriter` pushes a partition's batch once it reaches 
either `celeborn.columnarShuffle.batch.size` rows or 
`celeborn.columnarShuffle.batch.sizeInBytes.threshold` estimated bytes, 
whichever comes first. The estimate is the sum of `UnsafeRow.getSizeInBytes()` 
over the rows in the batch, so the column builders are unchanged.
    
    | Config | Default |
    |---|---|
    | `celeborn.columnarShuffle.batch.sizeInBytes.enabled` | `false` |
    | `celeborn.columnarShuffle.batch.sizeInBytes.threshold` | `64k`, same as 
`celeborn.client.push.buffer.max.size` |
    
    The same change is made to the spark-3, spark-3.5 and spark-4 columnar 
writers.
    
    ### Why are the changes needed?
    
    The columnar writer keeps one in-memory batch per reduce partition and 
pushes it only after 10,000 rows. Row count is a poor proxy for memory: 10,000 
rows can be a few hundred KB or hundreds of MB, depending on string widths. 
Spark's memory manager does not track this memory, so it cannot spill.
    
    If a map task has fewer than 10,000 rows per partition, nothing is pushed 
until the task ends. That is common: 1M rows over 200 partitions is 5,000 rows 
each. The task's whole output then stays on the heap. The row-based hash writer 
instead caps each partition's buffer at `celeborn.client.push.buffer.max.size`, 
which defaults to 64 KiB.
    
    Example: repartition 4M rows with a 2 KiB string into 200 partitions, using 
4 map tasks:
    
    ```scala
    spark.range(0, 4000000, 1, 4)
      .selectExpr("id", "cast(id % 1000 as int) as category",
        "repeat(md5(cast(id as string)), 64) as payload")
      .repartition(200, $"id")
      .write.format("noop").mode("overwrite").save()
    ```
    
    Smallest driver heap that completes the job (tried 1g, 2g, 4g, 8g, 16g):
    
    | Writer | Min heap |
    |---|---|
    | Row-based hash writer | 1g |
    | Columnar, row limit only (current behavior) | OOM even at 16g |
    | Columnar + byte limit, `64k` (default) | 1g |
    | Columnar + byte limit, `256k` | 2g |
    | Columnar + byte limit, `1m` | 4g |
    | Columnar + byte limit, `4m` | 8g |
    
    As with the row-based writer, buffered row data is roughly `partitions x 
threshold`. This is not a hard memory cap: as before this change, the column 
builders pre-size their buffers for `celeborn.columnarShuffle.batch.size` rows 
(up to 80 KB per column per partition). Sizing those buffers from the threshold 
could be a follow-up.
    
    Same job at a 24g heap, where every configuration fits (mean of 2 runs):
    
    | Columnar | Wall | Task time | Shuffle bytes |
    |---|---|---|---|
    | Row limit only | 12.3s | 40.7s | 213.9 MB |
    | `64k` | 9.8s | 31.4s | 230.7 MB (+7.9%) |
    | `256k` | 8.0s | 25.3s | 221.8 MB (+3.7%) |
    | `1m` | 8.3s | 26.3s | 218.3 MB (+2.1%) |
    
    Smaller batches compress slightly worse, but they avoid most of the GC 
work. Narrow rows are barely affected: 40M rows of 64 bytes, 50,000 rows per 
partition per task, 4g heap:
    
    | Columnar | Wall | Shuffle bytes |
    |---|---|---|
    | Row limit only | 11.4s | 1655.9 MB |
    | `64k` | 11.5s | 1594.7 MB |
    | `256k` | 10.8s | 1663.0 MB |
    | `1m` | 11.0s | 1655.9 MB (byte limit never reached) |
    
    The default of `64k` matches the row-based writer's per-partition buffer 
size. A larger threshold trades memory for slightly smaller shuffle output.
    
    ### Does this PR resolve a correctness bug?
    
    - [ ] Yes
    
    ### Does this PR introduce _any_ user-facing change?
    
    - [x] Yes
    
    Adds the two configs above. Behavior is unchanged unless 
`celeborn.columnarShuffle.batch.sizeInBytes.enabled` is set.
    
    ### How was this patch tested?
    
    - Added 
`ColumnarHashBasedShuffleWriterSuiteJ#testPushBatchWhenSizeInBytesThresholdReached`
 to the spark-3, spark-3.5 and spark-4 columnar modules. It checks the rows in 
each pushed batch, with the byte limit (8, 8, 8, 6) and without it (30).
    - Ran `build/mvn -P<profile> -pl client-spark/<module> -am test 
-Dtest=ColumnarHashBasedShuffleWriterSuiteJ -DwildcardSuites=none` for 
`spark-3.3`, `spark-3.5` and `spark-4.0`. All 14 tests pass in each. Maven CI 
doesn't run the columnar modules, so these were run locally.
    - Regenerated `docs/configuration/columnar-shuffle.md` with 
`ConfigurationSuite`.
    - Ran the jobs above on Spark 3.5.8 in `local[4]` with one local worker, 
JDK 17, on a 16 vCPU arm64 host. Spark had 
`assets/spark-patch/Celeborn_Columnar_Shuffle_spark3.patch` applied. On 3.5 the 
patch needs `DataTypeUtils.fromAttributes` in place of 
`StructType.fromAttributes` to compile. Timings are from a shared machine, so 
treat small differences as noise.
    
    Closes #3873 from ForVic/columnar-shuffle-batch-bytes.
    
    Authored-by: ForVic <[email protected]>
    Signed-off-by: Nicholas Jiang <[email protected]>
    (cherry picked from commit 659587e321836a061893b302996aba416c9190e7)
    Signed-off-by: Nicholas Jiang <[email protected]>
---
 .../celeborn/ColumnarHashBasedShuffleWriter.java   | 12 +++-
 .../ColumnarHashBasedShuffleWriterSuiteJ.java      | 71 ++++++++++++++++++++++
 .../celeborn/ColumnarHashBasedShuffleWriter.java   | 12 +++-
 .../ColumnarHashBasedShuffleWriterSuiteJ.java      | 71 ++++++++++++++++++++++
 .../celeborn/ColumnarHashBasedShuffleWriter.java   | 12 +++-
 .../ColumnarHashBasedShuffleWriterSuiteJ.java      | 71 ++++++++++++++++++++++
 .../org/apache/celeborn/common/CelebornConf.scala  | 29 +++++++++
 docs/configuration/columnar-shuffle.md             |  2 +
 docs/developers/java-columnar-shuffle.md           |  6 ++
 9 files changed, 283 insertions(+), 3 deletions(-)

diff --git 
a/client-spark/spark-3-columnar-shuffle/src/main/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriter.java
 
b/client-spark/spark-3-columnar-shuffle/src/main/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriter.java
index b09b1306c9..225eeaa91d 100644
--- 
a/client-spark/spark-3-columnar-shuffle/src/main/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriter.java
+++ 
b/client-spark/spark-3-columnar-shuffle/src/main/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriter.java
@@ -53,6 +53,8 @@ public class ColumnarHashBasedShuffleWriter<K, V, C> extends 
HashBasedShuffleWri
   private final Serializer depSerializer;
   private final boolean isColumnarShuffle;
   private final int columnarShuffleBatchSize;
+  private final long columnarShuffleBatchSizeInBytes;
+  private final long[] celebornBatchBytes;
   private final boolean columnarShuffleCodeGenEnabled;
   private final boolean columnarShuffleDictionaryEnabled;
   private final double columnarShuffleDictionaryMaxFactor;
@@ -68,6 +70,10 @@ public class ColumnarHashBasedShuffleWriter<K, V, C> extends 
HashBasedShuffleWri
       throws IOException {
     super(shuffleId, handle, taskContext, conf, client, metrics, 
sendBufferPool);
     columnarShuffleBatchSize = conf.columnarShuffleBatchSize();
+    columnarShuffleBatchSizeInBytes =
+        conf.columnarShuffleBatchSizeInBytesEnabled()
+            ? conf.columnarShuffleBatchSizeInBytesThreshold()
+            : Long.MAX_VALUE;
     columnarShuffleCodeGenEnabled = conf.columnarShuffleCodeGenEnabled();
     columnarShuffleDictionaryEnabled = conf.columnarShuffleDictionaryEnabled();
     columnarShuffleDictionaryMaxFactor = 
conf.columnarShuffleDictionaryMaxFactor();
@@ -78,6 +84,7 @@ public class ColumnarHashBasedShuffleWriter<K, V, C> extends 
HashBasedShuffleWri
     this.depSerializer = handle.dependency().serializer();
     this.celebornBatchBuilders =
         new 
CelebornBatchBuilder[handle.dependency().partitioner().numPartitions()];
+    this.celebornBatchBytes = new long[celebornBatchBuilders.length];
     this.isColumnarShuffle = schema != null && 
CelebornBatchBuilder.supportsColumnarType(schema);
   }
 
@@ -119,13 +126,16 @@ public class ColumnarHashBasedShuffleWriter<K, V, C> 
extends HashBasedShuffleWri
       }
 
       celebornBatchBuilders[partitionId].writeRow(row);
-      if (celebornBatchBuilders[partitionId].getRowCnt() >= 
columnarShuffleBatchSize) {
+      celebornBatchBytes[partitionId] += row.getSizeInBytes();
+      if (celebornBatchBuilders[partitionId].getRowCnt() >= 
columnarShuffleBatchSize
+          || celebornBatchBytes[partitionId] >= 
columnarShuffleBatchSizeInBytes) {
         byte[] arr = celebornBatchBuilders[partitionId].buildColumnBytes();
         pushGiantRecord(partitionId, arr, arr.length);
         if (dataSize != null) {
           dataSize.add(arr.length);
         }
         celebornBatchBuilders[partitionId].newBuilders();
+        celebornBatchBytes[partitionId] = 0;
       }
       tmpRecordsWritten++;
     }
diff --git 
a/client-spark/spark-3-columnar-shuffle/src/test/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriterSuiteJ.java
 
b/client-spark/spark-3-columnar-shuffle/src/test/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriterSuiteJ.java
index 92aee552fb..4cc24575c4 100644
--- 
a/client-spark/spark-3-columnar-shuffle/src/test/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriterSuiteJ.java
+++ 
b/client-spark/spark-3-columnar-shuffle/src/test/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriterSuiteJ.java
@@ -17,25 +17,38 @@
 
 package org.apache.spark.shuffle.celeborn;
 
+import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
 import java.io.File;
+import java.nio.ByteBuffer;
 import java.util.UUID;
 
+import scala.Product2;
+import scala.Tuple2;
+import scala.collection.Iterator;
+import scala.collection.mutable.ListBuffer;
+
 import org.apache.spark.HashPartitioner;
+import org.apache.spark.Partitioner;
+import org.apache.spark.SparkVersionUtil;
 import org.apache.spark.TaskContext;
 import org.apache.spark.serializer.KryoSerializer;
 import org.apache.spark.serializer.Serializer;
 import org.apache.spark.serializer.SerializerInstance;
 import org.apache.spark.shuffle.ShuffleWriteMetricsReporter;
 import org.apache.spark.shuffle.ShuffleWriter;
+import org.apache.spark.sql.catalyst.expressions.GenericInternalRow;
+import org.apache.spark.sql.catalyst.expressions.UnsafeProjection;
+import org.apache.spark.sql.catalyst.expressions.UnsafeRow;
 import org.apache.spark.sql.execution.UnsafeRowSerializer;
 import org.apache.spark.sql.execution.columnar.CelebornBatchBuilder;
 import org.apache.spark.sql.execution.columnar.CelebornColumnarBatchSerializer;
 import org.apache.spark.sql.types.IntegerType$;
 import org.apache.spark.sql.types.StringType$;
 import org.apache.spark.sql.types.StructType;
+import org.apache.spark.unsafe.types.UTF8String;
 import org.junit.Test;
 import org.mockito.MockedStatic;
 import org.mockito.Mockito;
@@ -43,6 +56,8 @@ import org.mockito.Mockito;
 import org.apache.celeborn.client.DummyShuffleClient;
 import org.apache.celeborn.client.ShuffleClient;
 import org.apache.celeborn.common.CelebornConf;
+import org.apache.celeborn.common.util.Utils;
+import org.apache.celeborn.reflect.DynConstructors;
 
 public class ColumnarHashBasedShuffleWriterSuiteJ extends 
CelebornShuffleWriterSuiteBase {
 
@@ -83,6 +98,62 @@ public class ColumnarHashBasedShuffleWriterSuiteJ extends 
CelebornShuffleWriterS
     assertTrue(((ColumnarHashBasedShuffleWriter<?, ?, ?>) 
writer).isColumnarShuffle());
   }
 
+  @Test
+  public void testPushBatchWhenSizeInBytesThresholdReached() throws Exception {
+    // A (key, 100-byte value) UnsafeRow takes 8 + 2 * 8 + 104 = 128 bytes, so 
a 1k threshold
+    // pushes a batch every 8 rows.
+    final CelebornConf conf =
+        new CelebornConf()
+            
.set(CelebornConf.COLUMNAR_SHUFFLE_BATCH_SIZE_IN_BYTES_ENABLED().key(), "true")
+            
.set(CelebornConf.COLUMNAR_SHUFFLE_BATCH_SIZE_IN_BYTES_THRESHOLD().key(), "1k");
+    assertArrayEquals(new int[] {8, 8, 8, 6}, writeAndGetBatchRowCounts(conf, 
30));
+
+    // Without it, only the row limit applies and all rows stay in one batch.
+    assertArrayEquals(new int[] {30}, writeAndGetBatchRowCounts(new 
CelebornConf(), 30));
+  }
+
+  private int[] writeAndGetBatchRowCounts(CelebornConf conf, int numRows) 
throws Exception {
+    final String partitionIdPassthroughClazz =
+        SparkVersionUtil.isGreaterThan(3, 3)
+            ? "org.apache.spark.PartitionIdPassthrough"
+            : "org.apache.spark.sql.execution.PartitionIdPassthrough";
+    final Partitioner partitioner =
+        DynConstructors.builder()
+            .impl(partitionIdPassthroughClazz, int.class)
+            .<Partitioner>build()
+            .newInstance(numPartitions);
+    Mockito.doReturn(partitioner).when(dependency).partitioner();
+    Mockito.doReturn(new UnsafeRowSerializer(2, 
null)).when(dependency).serializer();
+
+    final File tempFile = new File(tempDir, UUID.randomUUID().toString());
+    final DummyShuffleClient client = new DummyShuffleClient(conf, tempFile);
+    client.initReducePartitionMap(shuffleId, numPartitions, 1);
+    final ShuffleWriter<Integer, String> writer =
+        createShuffleWriter(
+            new CelebornShuffleHandle<>(
+                appId, host, port, userIdentifier, shuffleId, false, numMaps, 
dependency),
+            taskContext,
+            conf,
+            client,
+            metrics.shuffleWriteMetrics());
+
+    final UnsafeProjection projection = UnsafeProjection.create(schema);
+    final UTF8String value = UTF8String.fromBytes(new byte[100]);
+    final ListBuffer<Product2<Integer, UnsafeRow>> records = new 
ListBuffer<>();
+    for (int i = 0; i < numRows; i++) {
+      UnsafeRow row = projection.apply(new GenericInternalRow(new Object[] {i, 
value}));
+      records.$plus$eq(new Tuple2<>(0, row.copy()));
+    }
+    writer.write((Iterator) records.toIterator());
+    writer.stop(true);
+    client.shutdown();
+
+    // mapId=0 and attemptId=0 from the mock TaskContext. Each batch starts 
with its row count.
+    return client.getPushDataByPartition(Utils.makeMapKey(shuffleId, 0, 
0)).get(0).stream()
+        .mapToInt(batch -> ByteBuffer.wrap(batch).getInt())
+        .toArray();
+  }
+
   @Override
   protected SerializerInstance newSerializerInstance(Serializer serializer) {
     if (serializer instanceof UnsafeRowSerializer
diff --git 
a/client-spark/spark-3.5-columnar-shuffle/src/main/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriter.java
 
b/client-spark/spark-3.5-columnar-shuffle/src/main/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriter.java
index b09b1306c9..225eeaa91d 100644
--- 
a/client-spark/spark-3.5-columnar-shuffle/src/main/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriter.java
+++ 
b/client-spark/spark-3.5-columnar-shuffle/src/main/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriter.java
@@ -53,6 +53,8 @@ public class ColumnarHashBasedShuffleWriter<K, V, C> extends 
HashBasedShuffleWri
   private final Serializer depSerializer;
   private final boolean isColumnarShuffle;
   private final int columnarShuffleBatchSize;
+  private final long columnarShuffleBatchSizeInBytes;
+  private final long[] celebornBatchBytes;
   private final boolean columnarShuffleCodeGenEnabled;
   private final boolean columnarShuffleDictionaryEnabled;
   private final double columnarShuffleDictionaryMaxFactor;
@@ -68,6 +70,10 @@ public class ColumnarHashBasedShuffleWriter<K, V, C> extends 
HashBasedShuffleWri
       throws IOException {
     super(shuffleId, handle, taskContext, conf, client, metrics, 
sendBufferPool);
     columnarShuffleBatchSize = conf.columnarShuffleBatchSize();
+    columnarShuffleBatchSizeInBytes =
+        conf.columnarShuffleBatchSizeInBytesEnabled()
+            ? conf.columnarShuffleBatchSizeInBytesThreshold()
+            : Long.MAX_VALUE;
     columnarShuffleCodeGenEnabled = conf.columnarShuffleCodeGenEnabled();
     columnarShuffleDictionaryEnabled = conf.columnarShuffleDictionaryEnabled();
     columnarShuffleDictionaryMaxFactor = 
conf.columnarShuffleDictionaryMaxFactor();
@@ -78,6 +84,7 @@ public class ColumnarHashBasedShuffleWriter<K, V, C> extends 
HashBasedShuffleWri
     this.depSerializer = handle.dependency().serializer();
     this.celebornBatchBuilders =
         new 
CelebornBatchBuilder[handle.dependency().partitioner().numPartitions()];
+    this.celebornBatchBytes = new long[celebornBatchBuilders.length];
     this.isColumnarShuffle = schema != null && 
CelebornBatchBuilder.supportsColumnarType(schema);
   }
 
@@ -119,13 +126,16 @@ public class ColumnarHashBasedShuffleWriter<K, V, C> 
extends HashBasedShuffleWri
       }
 
       celebornBatchBuilders[partitionId].writeRow(row);
-      if (celebornBatchBuilders[partitionId].getRowCnt() >= 
columnarShuffleBatchSize) {
+      celebornBatchBytes[partitionId] += row.getSizeInBytes();
+      if (celebornBatchBuilders[partitionId].getRowCnt() >= 
columnarShuffleBatchSize
+          || celebornBatchBytes[partitionId] >= 
columnarShuffleBatchSizeInBytes) {
         byte[] arr = celebornBatchBuilders[partitionId].buildColumnBytes();
         pushGiantRecord(partitionId, arr, arr.length);
         if (dataSize != null) {
           dataSize.add(arr.length);
         }
         celebornBatchBuilders[partitionId].newBuilders();
+        celebornBatchBytes[partitionId] = 0;
       }
       tmpRecordsWritten++;
     }
diff --git 
a/client-spark/spark-3.5-columnar-shuffle/src/test/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriterSuiteJ.java
 
b/client-spark/spark-3.5-columnar-shuffle/src/test/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriterSuiteJ.java
index 92aee552fb..4cc24575c4 100644
--- 
a/client-spark/spark-3.5-columnar-shuffle/src/test/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriterSuiteJ.java
+++ 
b/client-spark/spark-3.5-columnar-shuffle/src/test/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriterSuiteJ.java
@@ -17,25 +17,38 @@
 
 package org.apache.spark.shuffle.celeborn;
 
+import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
 import java.io.File;
+import java.nio.ByteBuffer;
 import java.util.UUID;
 
+import scala.Product2;
+import scala.Tuple2;
+import scala.collection.Iterator;
+import scala.collection.mutable.ListBuffer;
+
 import org.apache.spark.HashPartitioner;
+import org.apache.spark.Partitioner;
+import org.apache.spark.SparkVersionUtil;
 import org.apache.spark.TaskContext;
 import org.apache.spark.serializer.KryoSerializer;
 import org.apache.spark.serializer.Serializer;
 import org.apache.spark.serializer.SerializerInstance;
 import org.apache.spark.shuffle.ShuffleWriteMetricsReporter;
 import org.apache.spark.shuffle.ShuffleWriter;
+import org.apache.spark.sql.catalyst.expressions.GenericInternalRow;
+import org.apache.spark.sql.catalyst.expressions.UnsafeProjection;
+import org.apache.spark.sql.catalyst.expressions.UnsafeRow;
 import org.apache.spark.sql.execution.UnsafeRowSerializer;
 import org.apache.spark.sql.execution.columnar.CelebornBatchBuilder;
 import org.apache.spark.sql.execution.columnar.CelebornColumnarBatchSerializer;
 import org.apache.spark.sql.types.IntegerType$;
 import org.apache.spark.sql.types.StringType$;
 import org.apache.spark.sql.types.StructType;
+import org.apache.spark.unsafe.types.UTF8String;
 import org.junit.Test;
 import org.mockito.MockedStatic;
 import org.mockito.Mockito;
@@ -43,6 +56,8 @@ import org.mockito.Mockito;
 import org.apache.celeborn.client.DummyShuffleClient;
 import org.apache.celeborn.client.ShuffleClient;
 import org.apache.celeborn.common.CelebornConf;
+import org.apache.celeborn.common.util.Utils;
+import org.apache.celeborn.reflect.DynConstructors;
 
 public class ColumnarHashBasedShuffleWriterSuiteJ extends 
CelebornShuffleWriterSuiteBase {
 
@@ -83,6 +98,62 @@ public class ColumnarHashBasedShuffleWriterSuiteJ extends 
CelebornShuffleWriterS
     assertTrue(((ColumnarHashBasedShuffleWriter<?, ?, ?>) 
writer).isColumnarShuffle());
   }
 
+  @Test
+  public void testPushBatchWhenSizeInBytesThresholdReached() throws Exception {
+    // A (key, 100-byte value) UnsafeRow takes 8 + 2 * 8 + 104 = 128 bytes, so 
a 1k threshold
+    // pushes a batch every 8 rows.
+    final CelebornConf conf =
+        new CelebornConf()
+            
.set(CelebornConf.COLUMNAR_SHUFFLE_BATCH_SIZE_IN_BYTES_ENABLED().key(), "true")
+            
.set(CelebornConf.COLUMNAR_SHUFFLE_BATCH_SIZE_IN_BYTES_THRESHOLD().key(), "1k");
+    assertArrayEquals(new int[] {8, 8, 8, 6}, writeAndGetBatchRowCounts(conf, 
30));
+
+    // Without it, only the row limit applies and all rows stay in one batch.
+    assertArrayEquals(new int[] {30}, writeAndGetBatchRowCounts(new 
CelebornConf(), 30));
+  }
+
+  private int[] writeAndGetBatchRowCounts(CelebornConf conf, int numRows) 
throws Exception {
+    final String partitionIdPassthroughClazz =
+        SparkVersionUtil.isGreaterThan(3, 3)
+            ? "org.apache.spark.PartitionIdPassthrough"
+            : "org.apache.spark.sql.execution.PartitionIdPassthrough";
+    final Partitioner partitioner =
+        DynConstructors.builder()
+            .impl(partitionIdPassthroughClazz, int.class)
+            .<Partitioner>build()
+            .newInstance(numPartitions);
+    Mockito.doReturn(partitioner).when(dependency).partitioner();
+    Mockito.doReturn(new UnsafeRowSerializer(2, 
null)).when(dependency).serializer();
+
+    final File tempFile = new File(tempDir, UUID.randomUUID().toString());
+    final DummyShuffleClient client = new DummyShuffleClient(conf, tempFile);
+    client.initReducePartitionMap(shuffleId, numPartitions, 1);
+    final ShuffleWriter<Integer, String> writer =
+        createShuffleWriter(
+            new CelebornShuffleHandle<>(
+                appId, host, port, userIdentifier, shuffleId, false, numMaps, 
dependency),
+            taskContext,
+            conf,
+            client,
+            metrics.shuffleWriteMetrics());
+
+    final UnsafeProjection projection = UnsafeProjection.create(schema);
+    final UTF8String value = UTF8String.fromBytes(new byte[100]);
+    final ListBuffer<Product2<Integer, UnsafeRow>> records = new 
ListBuffer<>();
+    for (int i = 0; i < numRows; i++) {
+      UnsafeRow row = projection.apply(new GenericInternalRow(new Object[] {i, 
value}));
+      records.$plus$eq(new Tuple2<>(0, row.copy()));
+    }
+    writer.write((Iterator) records.toIterator());
+    writer.stop(true);
+    client.shutdown();
+
+    // mapId=0 and attemptId=0 from the mock TaskContext. Each batch starts 
with its row count.
+    return client.getPushDataByPartition(Utils.makeMapKey(shuffleId, 0, 
0)).get(0).stream()
+        .mapToInt(batch -> ByteBuffer.wrap(batch).getInt())
+        .toArray();
+  }
+
   @Override
   protected SerializerInstance newSerializerInstance(Serializer serializer) {
     if (serializer instanceof UnsafeRowSerializer
diff --git 
a/client-spark/spark-4-columnar-shuffle/src/main/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriter.java
 
b/client-spark/spark-4-columnar-shuffle/src/main/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriter.java
index b09b1306c9..225eeaa91d 100644
--- 
a/client-spark/spark-4-columnar-shuffle/src/main/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriter.java
+++ 
b/client-spark/spark-4-columnar-shuffle/src/main/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriter.java
@@ -53,6 +53,8 @@ public class ColumnarHashBasedShuffleWriter<K, V, C> extends 
HashBasedShuffleWri
   private final Serializer depSerializer;
   private final boolean isColumnarShuffle;
   private final int columnarShuffleBatchSize;
+  private final long columnarShuffleBatchSizeInBytes;
+  private final long[] celebornBatchBytes;
   private final boolean columnarShuffleCodeGenEnabled;
   private final boolean columnarShuffleDictionaryEnabled;
   private final double columnarShuffleDictionaryMaxFactor;
@@ -68,6 +70,10 @@ public class ColumnarHashBasedShuffleWriter<K, V, C> extends 
HashBasedShuffleWri
       throws IOException {
     super(shuffleId, handle, taskContext, conf, client, metrics, 
sendBufferPool);
     columnarShuffleBatchSize = conf.columnarShuffleBatchSize();
+    columnarShuffleBatchSizeInBytes =
+        conf.columnarShuffleBatchSizeInBytesEnabled()
+            ? conf.columnarShuffleBatchSizeInBytesThreshold()
+            : Long.MAX_VALUE;
     columnarShuffleCodeGenEnabled = conf.columnarShuffleCodeGenEnabled();
     columnarShuffleDictionaryEnabled = conf.columnarShuffleDictionaryEnabled();
     columnarShuffleDictionaryMaxFactor = 
conf.columnarShuffleDictionaryMaxFactor();
@@ -78,6 +84,7 @@ public class ColumnarHashBasedShuffleWriter<K, V, C> extends 
HashBasedShuffleWri
     this.depSerializer = handle.dependency().serializer();
     this.celebornBatchBuilders =
         new 
CelebornBatchBuilder[handle.dependency().partitioner().numPartitions()];
+    this.celebornBatchBytes = new long[celebornBatchBuilders.length];
     this.isColumnarShuffle = schema != null && 
CelebornBatchBuilder.supportsColumnarType(schema);
   }
 
@@ -119,13 +126,16 @@ public class ColumnarHashBasedShuffleWriter<K, V, C> 
extends HashBasedShuffleWri
       }
 
       celebornBatchBuilders[partitionId].writeRow(row);
-      if (celebornBatchBuilders[partitionId].getRowCnt() >= 
columnarShuffleBatchSize) {
+      celebornBatchBytes[partitionId] += row.getSizeInBytes();
+      if (celebornBatchBuilders[partitionId].getRowCnt() >= 
columnarShuffleBatchSize
+          || celebornBatchBytes[partitionId] >= 
columnarShuffleBatchSizeInBytes) {
         byte[] arr = celebornBatchBuilders[partitionId].buildColumnBytes();
         pushGiantRecord(partitionId, arr, arr.length);
         if (dataSize != null) {
           dataSize.add(arr.length);
         }
         celebornBatchBuilders[partitionId].newBuilders();
+        celebornBatchBytes[partitionId] = 0;
       }
       tmpRecordsWritten++;
     }
diff --git 
a/client-spark/spark-4-columnar-shuffle/src/test/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriterSuiteJ.java
 
b/client-spark/spark-4-columnar-shuffle/src/test/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriterSuiteJ.java
index 92aee552fb..4cc24575c4 100644
--- 
a/client-spark/spark-4-columnar-shuffle/src/test/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriterSuiteJ.java
+++ 
b/client-spark/spark-4-columnar-shuffle/src/test/java/org/apache/spark/shuffle/celeborn/ColumnarHashBasedShuffleWriterSuiteJ.java
@@ -17,25 +17,38 @@
 
 package org.apache.spark.shuffle.celeborn;
 
+import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
 import java.io.File;
+import java.nio.ByteBuffer;
 import java.util.UUID;
 
+import scala.Product2;
+import scala.Tuple2;
+import scala.collection.Iterator;
+import scala.collection.mutable.ListBuffer;
+
 import org.apache.spark.HashPartitioner;
+import org.apache.spark.Partitioner;
+import org.apache.spark.SparkVersionUtil;
 import org.apache.spark.TaskContext;
 import org.apache.spark.serializer.KryoSerializer;
 import org.apache.spark.serializer.Serializer;
 import org.apache.spark.serializer.SerializerInstance;
 import org.apache.spark.shuffle.ShuffleWriteMetricsReporter;
 import org.apache.spark.shuffle.ShuffleWriter;
+import org.apache.spark.sql.catalyst.expressions.GenericInternalRow;
+import org.apache.spark.sql.catalyst.expressions.UnsafeProjection;
+import org.apache.spark.sql.catalyst.expressions.UnsafeRow;
 import org.apache.spark.sql.execution.UnsafeRowSerializer;
 import org.apache.spark.sql.execution.columnar.CelebornBatchBuilder;
 import org.apache.spark.sql.execution.columnar.CelebornColumnarBatchSerializer;
 import org.apache.spark.sql.types.IntegerType$;
 import org.apache.spark.sql.types.StringType$;
 import org.apache.spark.sql.types.StructType;
+import org.apache.spark.unsafe.types.UTF8String;
 import org.junit.Test;
 import org.mockito.MockedStatic;
 import org.mockito.Mockito;
@@ -43,6 +56,8 @@ import org.mockito.Mockito;
 import org.apache.celeborn.client.DummyShuffleClient;
 import org.apache.celeborn.client.ShuffleClient;
 import org.apache.celeborn.common.CelebornConf;
+import org.apache.celeborn.common.util.Utils;
+import org.apache.celeborn.reflect.DynConstructors;
 
 public class ColumnarHashBasedShuffleWriterSuiteJ extends 
CelebornShuffleWriterSuiteBase {
 
@@ -83,6 +98,62 @@ public class ColumnarHashBasedShuffleWriterSuiteJ extends 
CelebornShuffleWriterS
     assertTrue(((ColumnarHashBasedShuffleWriter<?, ?, ?>) 
writer).isColumnarShuffle());
   }
 
+  @Test
+  public void testPushBatchWhenSizeInBytesThresholdReached() throws Exception {
+    // A (key, 100-byte value) UnsafeRow takes 8 + 2 * 8 + 104 = 128 bytes, so 
a 1k threshold
+    // pushes a batch every 8 rows.
+    final CelebornConf conf =
+        new CelebornConf()
+            
.set(CelebornConf.COLUMNAR_SHUFFLE_BATCH_SIZE_IN_BYTES_ENABLED().key(), "true")
+            
.set(CelebornConf.COLUMNAR_SHUFFLE_BATCH_SIZE_IN_BYTES_THRESHOLD().key(), "1k");
+    assertArrayEquals(new int[] {8, 8, 8, 6}, writeAndGetBatchRowCounts(conf, 
30));
+
+    // Without it, only the row limit applies and all rows stay in one batch.
+    assertArrayEquals(new int[] {30}, writeAndGetBatchRowCounts(new 
CelebornConf(), 30));
+  }
+
+  private int[] writeAndGetBatchRowCounts(CelebornConf conf, int numRows) 
throws Exception {
+    final String partitionIdPassthroughClazz =
+        SparkVersionUtil.isGreaterThan(3, 3)
+            ? "org.apache.spark.PartitionIdPassthrough"
+            : "org.apache.spark.sql.execution.PartitionIdPassthrough";
+    final Partitioner partitioner =
+        DynConstructors.builder()
+            .impl(partitionIdPassthroughClazz, int.class)
+            .<Partitioner>build()
+            .newInstance(numPartitions);
+    Mockito.doReturn(partitioner).when(dependency).partitioner();
+    Mockito.doReturn(new UnsafeRowSerializer(2, 
null)).when(dependency).serializer();
+
+    final File tempFile = new File(tempDir, UUID.randomUUID().toString());
+    final DummyShuffleClient client = new DummyShuffleClient(conf, tempFile);
+    client.initReducePartitionMap(shuffleId, numPartitions, 1);
+    final ShuffleWriter<Integer, String> writer =
+        createShuffleWriter(
+            new CelebornShuffleHandle<>(
+                appId, host, port, userIdentifier, shuffleId, false, numMaps, 
dependency),
+            taskContext,
+            conf,
+            client,
+            metrics.shuffleWriteMetrics());
+
+    final UnsafeProjection projection = UnsafeProjection.create(schema);
+    final UTF8String value = UTF8String.fromBytes(new byte[100]);
+    final ListBuffer<Product2<Integer, UnsafeRow>> records = new 
ListBuffer<>();
+    for (int i = 0; i < numRows; i++) {
+      UnsafeRow row = projection.apply(new GenericInternalRow(new Object[] {i, 
value}));
+      records.$plus$eq(new Tuple2<>(0, row.copy()));
+    }
+    writer.write((Iterator) records.toIterator());
+    writer.stop(true);
+    client.shutdown();
+
+    // mapId=0 and attemptId=0 from the mock TaskContext. Each batch starts 
with its row count.
+    return client.getPushDataByPartition(Utils.makeMapKey(shuffleId, 0, 
0)).get(0).stream()
+        .mapToInt(batch -> ByteBuffer.wrap(batch).getInt())
+        .toArray();
+  }
+
   @Override
   protected SerializerInstance newSerializerInstance(Serializer serializer) {
     if (serializer instanceof UnsafeRowSerializer
diff --git 
a/common/src/main/scala/org/apache/celeborn/common/CelebornConf.scala 
b/common/src/main/scala/org/apache/celeborn/common/CelebornConf.scala
index 1fadc77f23..fb557fee18 100644
--- a/common/src/main/scala/org/apache/celeborn/common/CelebornConf.scala
+++ b/common/src/main/scala/org/apache/celeborn/common/CelebornConf.scala
@@ -1493,6 +1493,10 @@ class CelebornConf(loadDefaults: Boolean) extends 
Cloneable with Logging with Se
   // //////////////////////////////////////////////////////
   def columnarShuffleEnabled: Boolean = get(COLUMNAR_SHUFFLE_ENABLED)
   def columnarShuffleBatchSize: Int = get(COLUMNAR_SHUFFLE_BATCH_SIZE)
+  def columnarShuffleBatchSizeInBytesEnabled: Boolean =
+    get(COLUMNAR_SHUFFLE_BATCH_SIZE_IN_BYTES_ENABLED)
+  def columnarShuffleBatchSizeInBytesThreshold: Long =
+    get(COLUMNAR_SHUFFLE_BATCH_SIZE_IN_BYTES_THRESHOLD)
   def columnarShuffleOffHeapEnabled: Boolean = 
get(COLUMNAR_SHUFFLE_OFF_HEAP_ENABLED)
   def columnarShuffleDictionaryEnabled: Boolean = 
get(COLUMNAR_SHUFFLE_DICTIONARY_ENCODING_ENABLED)
   def columnarShuffleDictionaryMaxFactor: Double =
@@ -6266,6 +6270,31 @@ object CelebornConf extends Logging {
       .checkValue(v => v > 0, "Value must be positive")
       .createWithDefault(10000)
 
+  val COLUMNAR_SHUFFLE_BATCH_SIZE_IN_BYTES_ENABLED: ConfigEntry[Boolean] =
+    buildConf("celeborn.columnarShuffle.batch.sizeInBytes.enabled")
+      .categories("columnar-shuffle")
+      .version("1.0.0")
+      .doc("Whether to also push a columnar batch once its estimated size in 
bytes reaches " +
+        "`celeborn.columnarShuffle.batch.sizeInBytes.threshold`, in addition 
to the row " +
+        s"limit `${COLUMNAR_SHUFFLE_BATCH_SIZE.key}`. This limits how much row 
data each " +
+        "partition buffers when rows are wide, e.g. with long strings. Column 
buffers are " +
+        s"still pre-sized for `${COLUMNAR_SHUFFLE_BATCH_SIZE.key}` rows.")
+      .booleanConf
+      .createWithDefault(false)
+
+  val COLUMNAR_SHUFFLE_BATCH_SIZE_IN_BYTES_THRESHOLD: ConfigEntry[Long] =
+    buildConf("celeborn.columnarShuffle.batch.sizeInBytes.threshold")
+      .categories("columnar-shuffle")
+      .version("1.0.0")
+      .doc("Estimated size in bytes at which a columnar batch is pushed, when 
" +
+        "`celeborn.columnarShuffle.batch.sizeInBytes.enabled` is true. The 
size is estimated " +
+        "from the uncompressed size of the rows added to the batch. The 
default matches " +
+        s"`${CLIENT_PUSH_BUFFER_MAX_SIZE.key}`, the per-partition buffer of 
the row-based " +
+        "hash writer.")
+      .bytesConf(ByteUnit.BYTE)
+      .checkValue(v => v > 0, "Value must be positive")
+      .createWithDefaultString("64k")
+
   val COLUMNAR_SHUFFLE_OFF_HEAP_ENABLED: ConfigEntry[Boolean] =
     buildConf("celeborn.columnarShuffle.offHeap.enabled")
       .withAlternative("celeborn.columnar.offHeap.enabled")
diff --git a/docs/configuration/columnar-shuffle.md 
b/docs/configuration/columnar-shuffle.md
index 3152dbfbea..af6fe0deab 100644
--- a/docs/configuration/columnar-shuffle.md
+++ b/docs/configuration/columnar-shuffle.md
@@ -20,6 +20,8 @@ license: |
 | Key | Default | isDynamic | Description | Since | Deprecated |
 | --- | ------- | --------- | ----------- | ----- | ---------- |
 | celeborn.columnarShuffle.batch.size | 10000 | false | Vector batch size for 
columnar shuffle. | 0.3.0 | celeborn.columnar.shuffle.batch.size | 
+| celeborn.columnarShuffle.batch.sizeInBytes.enabled | false | false | Whether 
to also push a columnar batch once its estimated size in bytes reaches 
`celeborn.columnarShuffle.batch.sizeInBytes.threshold`, in addition to the row 
limit `celeborn.columnarShuffle.batch.size`. This limits how much row data each 
partition buffers when rows are wide, e.g. with long strings. Column buffers 
are still pre-sized for `celeborn.columnarShuffle.batch.size` rows. | 1.0.0 |  
| 
+| celeborn.columnarShuffle.batch.sizeInBytes.threshold | 64k | false | 
Estimated size in bytes at which a columnar batch is pushed, when 
`celeborn.columnarShuffle.batch.sizeInBytes.enabled` is true. The size is 
estimated from the uncompressed size of the rows added to the batch. The 
default matches `celeborn.client.push.buffer.max.size`, the per-partition 
buffer of the row-based hash writer. | 1.0.0 |  | 
 | celeborn.columnarShuffle.codegen.enabled | false | false | Whether to use 
codegen for columnar-based shuffle. | 0.3.0 | 
celeborn.columnar.shuffle.codegen.enabled | 
 | celeborn.columnarShuffle.enabled | false | false | Whether to enable 
columnar-based shuffle. | 0.2.0 | celeborn.columnar.shuffle.enabled | 
 | celeborn.columnarShuffle.encoding.dictionary.enabled | false | false | 
Whether to use dictionary encoding for columnar-based shuffle data. | 0.3.0 | 
celeborn.columnar.shuffle.encoding.dictionary.enabled | 
diff --git a/docs/developers/java-columnar-shuffle.md 
b/docs/developers/java-columnar-shuffle.md
index c01df0bbc0..40f1444a2c 100644
--- a/docs/developers/java-columnar-shuffle.md
+++ b/docs/developers/java-columnar-shuffle.md
@@ -54,3 +54,9 @@ If you require further performance optimization, consider 
enabling code generati
 ```
 spark.celeborn.columnarShuffle.codegen.enabled true
 ```
+
+Each partition buffers rows in memory until 
`spark.celeborn.columnarShuffle.batch.size` rows are reached. If rows can be 
wide, e.g. with long strings, also cap each batch by its estimated size in 
bytes to limit how much data each partition buffers:
+
+```
+spark.celeborn.columnarShuffle.batch.sizeInBytes.enabled true
+```

Reply via email to