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

zaynt4606 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/celeborn.git


The following commit(s) were added to refs/heads/main by this push:
     new 3cb708b05e [CELEBORN-2479] Show shuffle compression ratio in Spark UI
3cb708b05e is described below

commit 3cb708b05e4a21b3d336b2628c461b4990e9f2aa
Author: yew1eb <[email protected]>
AuthorDate: Fri Oct 23 14:00:58 2026 +0800

    [CELEBORN-2479] Show shuffle compression ratio in Spark UI
    
    ### What changes were proposed in this pull request?
    
    Show the shuffle compression ratio (uncompressed/compressed) on the 
Celeborn tab of the Spark UI.
    
    - **common**: new extensible `PbMapperMetrics` message carries both 
uncompressed and compressed bytes in `PbMapperEnd` field 10; `MapperEnd` gains 
a defaulted `MapperMetrics` parameter, so future mapper-side metrics go there 
instead of new top-level fields.
    - **client**: `ShuffleClient` keeps the existing abstract 5-argument 
`mapperEnd` and adds a 6-argument overload whose default implementation 
discards the metrics and delegates to the existing method, preserving 
compatibility for external subclasses. `LifecycleManager` invokes the 
registered `MapperEndCallback` only for an attempt accepted by the commit 
handler.
    - **spark**: `HashBasedShuffleWriter` / `SortBasedShuffleWriter` report 
both the bytes handed to `ShuffleClient` before compression (including the 
giant-record direct-push path) and the post-compression/encryption byte counts 
returned by the client. When `CelebornPlugin` is enabled, `SparkShuffleManager` 
registers the callback and posts a `CelebornMapperMetricsEvent` to the driver 
listener bus. `CelebornListener` aggregates both counters for the live UI and 
History Server replay; the  [...]
    - **docker**: register `CelebornPlugin` in the docker `spark-defaults.conf` 
so the docker-compose cluster exercises the UI end-to-end.
    
    The compression ratio derives both counters from the same `MapperMetrics` 
payload for each accepted mapper attempt. Retried MapperEnd RPCs and rejected 
speculative/rerun attempts do not emit another event: `finishMapperAttempt` 
already accepts at most one attempt per mapId, so no additional deduplication 
set is needed. This accounting is based on Celeborn commit acceptance rather 
than Spark task-success filtering, keeping both counters aligned even if a task 
fails after its writer completes.
    
    ### Why are the changes needed?
    
    The Spark UI shows the shuffle bytes written to Celeborn 
(post-compression), but there is no way to see how much the compression 
actually saves. Exposing the ratio helps users evaluate codec choices and 
estimate real network/disk traffic during shuffle.
    
    CELEBORN-2479
    
    ### Does this PR resolve a correctness bug?
    
    - [ ] Yes
    
    ### Does this PR introduce _any_ user-facing change?
    
    - [x] Yes
    
    The Celeborn tab of the Spark UI shows a new summary line: "Compression 
Ratio (uncompressed/compressed)". Existing shuffle write bytes/time, read 
metrics, and task-duration aggregates continue to include all task attempts, 
including failed or killed attempts.
    
    ### How was this patch tested?
    
    - `UtilsSuite` covers the MapperEnd ser/de roundtrip with both 
`MapperMetrics` counters.
    - `CelebornListenerSuite` covers aggregation, event JSON roundtrip, 
all-attempt task metrics, and consistent ratio accounting when a task fails 
after writer completion. All 12 tests passed locally with Spark 3.5 / JDK 17.
    - Hash and sort writer suites check uncompressed and compressed byte counts 
separately using distinct simulated compressed lengths, including 
all-giant-record and mixed paths. Exact-head CI passed 12 hash writer tests, 13 
sort writer tests, and 1 pusher test.
    - `LifecycleManagerSuite` uses real RPCs to verify that only the accepted 
attempt is reported, while retries of both accepted and rejected attempts are 
ignored; this regression passed in CI.
    - End-to-end on the docker-compose stack (Spark 3.5.9, hash writer, lz4): 
confirmed the Celeborn tab shows the compression ratio with a real shuffling 
job.
    
    <img width="2832" height="1232" alt="image" 
src="https://github.com/user-attachments/assets/f9fcb5a7-04c1-47cf-9463-4b35c3475583";
 />
    
    Closes #3872 from yew1eb/spark-ui-compression-ratio.
    
    Authored-by: yew1eb <[email protected]>
    Signed-off-by: zhengtao <[email protected]>
    
    Co-Authored-By: Codex <[email protected]>
    AI-Model: gpt-6
    AI-Contributed/Feature: 0/376
    AI-Contributed/UT: 0/198
---
 .../spark/shuffle/celeborn/SortBasedPusher.java    |  10 +++
 .../spark/shuffle/celeborn/SparkCommonUtils.java   |  10 +++
 .../spark/shuffle/celeborn/CelebornListener.scala  |  45 ++++++----
 .../shuffle/celeborn/CelebornStatusStore.scala     |   8 +-
 .../shuffle/celeborn/ui/CelebornShufflePage.scala  |  13 +++
 .../shuffle/celeborn/CelebornListenerSuite.scala   | 100 ++++++++++++++++++++-
 .../celeborn/CelebornMapperMetricsEvent.java       |  75 ++++++++++++++++
 .../shuffle/celeborn/HashBasedShuffleWriter.java   |  16 +++-
 .../shuffle/celeborn/SortBasedShuffleWriter.java   |  15 +++-
 .../shuffle/celeborn/SparkShuffleManager.java      |  17 ++++
 .../apache/spark/shuffle/celeborn/SparkUtils.java  |  20 +++++
 .../celeborn/CelebornShuffleWriterSuiteBase.java   |  37 +++++++-
 .../apache/celeborn/client/DummyShuffleClient.java |  26 +++++-
 .../org/apache/celeborn/client/ShuffleClient.java  |  17 ++++
 .../apache/celeborn/client/ShuffleClientImpl.java  |  22 ++++-
 .../client/listener/MapperEndCallback.java         |  29 ++++++
 .../apache/celeborn/client/LifecycleManager.scala  |  21 ++++-
 common/src/main/proto/TransportMessages.proto      |  10 +++
 .../common/protocol/message/ControlMessages.scala  |  19 +++-
 .../apache/celeborn/common/util/UtilsSuite.scala   |   6 +-
 docker/spark/conf/spark-defaults.conf              |   3 +
 .../tests/client/LifecycleManagerSuite.scala       |  55 ++++++++++++
 22 files changed, 533 insertions(+), 41 deletions(-)

diff --git 
a/client-spark/common/src/main/java/org/apache/spark/shuffle/celeborn/SortBasedPusher.java
 
b/client-spark/common/src/main/java/org/apache/spark/shuffle/celeborn/SortBasedPusher.java
index 8297aefb60..6e5a7d48a4 100644
--- 
a/client-spark/common/src/main/java/org/apache/spark/shuffle/celeborn/SortBasedPusher.java
+++ 
b/client-spark/common/src/main/java/org/apache/spark/shuffle/celeborn/SortBasedPusher.java
@@ -126,6 +126,9 @@ public class SortBasedPusher extends MemoryConsumer {
   private int[] inversedShuffledPartitions = null;
   private final SendBufferPool sendBufferPool;
 
+  // Bytes pushed before compression, for the shuffle compression ratio.
+  private long uncompressedBytesWritten = 0;
+
   final MemoryThresholdManager memoryThresholdManager;
 
   private final boolean useAdaptiveThreshold;
@@ -234,6 +237,7 @@ public class SortBasedPusher extends MemoryConsumer {
                   numPartitions);
           mapStatusLengths[currentPartition].add(bytesWritten);
           afterPush.accept(bytesWritten);
+          uncompressedBytesWritten += offSet;
           memoryThresholdManager.updateStats(offSet, offSet == 
pushBufferMaxSize);
           currentPartition = partition;
           offSet = 0;
@@ -247,6 +251,7 @@ public class SortBasedPusher extends MemoryConsumer {
       if (offSet + recordSize > dataBuf.length) {
         try {
           dataPusher.addTask(partition, dataBuf, offSet);
+          uncompressedBytesWritten += offSet;
           memoryThresholdManager.updateStats(offSet, true);
         } catch (InterruptedException e) {
           TaskInterruptedHelper.throwTaskKillException();
@@ -262,6 +267,7 @@ public class SortBasedPusher extends MemoryConsumer {
     if (offSet > 0) {
       try {
         dataPusher.addTask(currentPartition, dataBuf, offSet);
+        uncompressedBytesWritten += offSet;
         memoryThresholdManager.updateStats(offSet, offSet == 
pushBufferMaxSize);
       } catch (InterruptedException e) {
         TaskInterruptedHelper.throwTaskKillException();
@@ -475,6 +481,10 @@ public class SortBasedPusher extends MemoryConsumer {
     return peakMemoryUsedBytes;
   }
 
+  public long getUncompressedBytesWritten() {
+    return uncompressedBytesWritten;
+  }
+
   private long freeMemory() {
     updatePeakMemoryUsed();
     long memoryFreed = 0;
diff --git 
a/client-spark/common/src/main/java/org/apache/spark/shuffle/celeborn/SparkCommonUtils.java
 
b/client-spark/common/src/main/java/org/apache/spark/shuffle/celeborn/SparkCommonUtils.java
index 08e217390c..dce78460ae 100644
--- 
a/client-spark/common/src/main/java/org/apache/spark/shuffle/celeborn/SparkCommonUtils.java
+++ 
b/client-spark/common/src/main/java/org/apache/spark/shuffle/celeborn/SparkCommonUtils.java
@@ -20,6 +20,7 @@ package org.apache.spark.shuffle.celeborn;
 import java.util.Collections;
 import java.util.Map;
 import java.util.Optional;
+import java.util.concurrent.atomic.LongAdder;
 
 import scala.Option;
 
@@ -74,6 +75,15 @@ public class SparkCommonUtils {
     return (context.stageAttemptNumber() << 16) | context.attemptNumber();
   }
 
+  /** Sums per-partition pushed (post-compression/encryption) bytes. */
+  public static long sumLongAdders(LongAdder[] adders) {
+    long sum = 0;
+    for (LongAdder adder : adders) {
+      sum += adder.sum();
+    }
+    return sum;
+  }
+
   public static void throwSparkOutOfMemoryError() {
     try { // for Spark 3.5 and earlier
       throw DynConstructors.builder()
diff --git 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornListener.scala
 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornListener.scala
index d0cd03e3c3..a230cff08b 100644
--- 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornListener.scala
+++ 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornListener.scala
@@ -48,6 +48,11 @@ private[celeborn] class CelebornListener(
   private val totalReadBytes = new AtomicLong(0L)
   private val totalFetchWaitTimeMs = new AtomicLong(0L)
   private val totalTaskDurationMs = new AtomicLong(0L)
+  // Total bytes pushed before compression, for the compression ratio.
+  private val totalUncompressedWriteBytes = new AtomicLong(0L)
+  // Total bytes pushed after compression, from the same MapperEnd reports, so 
the
+  // compression ratio uses a single attempt set.
+  private val totalCompressedWriteBytes = new AtomicLong(0L)
 
   private val lastUpdateTimestamp = new AtomicLong(-1L)
   private val updateIntervalMillis = 5000L
@@ -62,6 +67,7 @@ private[celeborn] class CelebornListener(
       return
     }
     Option(taskEnd.taskMetrics).foreach { metrics =>
+      // Count every attempt regardless of its end reason, like 
AppStatusListener.
       totalWriteBytes.addAndGet(metrics.shuffleWriteMetrics.bytesWritten)
       // writeTime is in nanoseconds; normalize to ms.
       totalWriteTimeMs.addAndGet(metrics.shuffleWriteMetrics.writeTime / 
1000000L)
@@ -72,6 +78,26 @@ private[celeborn] class CelebornListener(
     mayUpdate()
   }
 
+  override def onOtherEvent(event: SparkListenerEvent): Unit = event match {
+    case e: CelebornBuildInfoEvent =>
+      // Receiving the event also proves the application loaded the plugin.
+      kvstore.write(new CelebornBuildInfoUIData(Seq(
+        "Version" -> e.version,
+        "Commit Id" -> e.commitId,
+        "Revision" -> e.revision)))
+      if (!pluginEnabled) {
+        pluginEnabled = true
+        kvstore.write(new CelebornExtensionEnabledUIData())
+      }
+    case e: CelebornMapperMetricsEvent =>
+      if (pluginEnabled) {
+        totalUncompressedWriteBytes.addAndGet(e.getUncompressedBytesWritten)
+        totalCompressedWriteBytes.addAndGet(e.getCompressedBytesWritten)
+        mayUpdate()
+      }
+    case _ =>
+  }
+
   override def onJobEnd(jobEnd: SparkListenerJobEnd): Unit = {
     // Flush per-job so that tasks finishing within the throttle interval of 
the
     // previous flush are not lost when the application stays alive but idle.
@@ -85,6 +111,7 @@ private[celeborn] class CelebornListener(
       val pluginClass = classOf[CelebornPlugin].getName
       // Spark loads plugins from both keys: `spark.plugins.defaultList` 
allows a
       // default plugin list in the config file that `spark.plugins` does not 
overwrite.
+      // Keep in sync with SparkUtils.isCelebornPluginEnabled in the spark-3 
module.
       val optedIn = sparkProps.exists { case (k, v) =>
         (k == "spark.plugins" || k == "spark.plugins.defaultList") &&
           v.split(",").exists(_.trim == pluginClass)
@@ -105,20 +132,6 @@ private[celeborn] class CelebornListener(
     }
   }
 
-  override def onOtherEvent(event: SparkListenerEvent): Unit = event match {
-    case e: CelebornBuildInfoEvent =>
-      // Receiving the event also proves the application loaded the plugin.
-      kvstore.write(new CelebornBuildInfoUIData(Seq(
-        "Version" -> e.version,
-        "Commit Id" -> e.commitId,
-        "Revision" -> e.revision)))
-      if (!pluginEnabled) {
-        pluginEnabled = true
-        kvstore.write(new CelebornExtensionEnabledUIData())
-      }
-    case _ =>
-  }
-
   override def onApplicationEnd(applicationEnd: SparkListenerApplicationEnd): 
Unit = {
     mayUpdate(force = true)
     logInfo("CelebornListener: application ended, final flush completed")
@@ -151,7 +164,9 @@ private[celeborn] class CelebornListener(
         totalWriteTimeMs.get(),
         totalReadBytes.get(),
         totalFetchWaitTimeMs.get(),
-        totalTaskDurationMs.get()))
+        totalTaskDurationMs.get(),
+        totalUncompressedWriteBytes.get(),
+        totalCompressedWriteBytes.get()))
     } catch {
       case e: Exception =>
         logWarning("Failed to flush CelebornListener aggregations", e)
diff --git 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornStatusStore.scala
 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornStatusStore.scala
index cfb255b026..05076cf439 100644
--- 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornStatusStore.scala
+++ 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornStatusStore.scala
@@ -25,7 +25,11 @@ private[celeborn] case class AggregatedTaskInfoUIData(
     shuffleWriteTimeMs: Long,
     shuffleReadBytes: Long,
     shuffleFetchWaitTimeMs: Long,
-    taskDurationMs: Long) {
+    taskDurationMs: Long,
+    // Bytes pushed before compression; 0 for records written by older 
versions.
+    uncompressedShuffleBytes: Long = 0L,
+    // Bytes pushed after compression; 0 for records written by older versions.
+    compressedShuffleBytes: Long = 0L) {
 
   @JsonIgnore
   @KVIndex
@@ -69,7 +73,7 @@ private[celeborn] class CelebornStatusStore(store: KVStore) {
     try {
       store.read(kClass, kClass.getName)
     } catch {
-      case _: NoSuchElementException => AggregatedTaskInfoUIData(0L, 0L, 0L, 
0L, 0L)
+      case _: NoSuchElementException => AggregatedTaskInfoUIData(0L, 0L, 0L, 
0L, 0L, 0L)
     }
   }
 
diff --git 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/ui/CelebornShufflePage.scala
 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/ui/CelebornShufflePage.scala
index 33bc4027a7..41edad1130 100644
--- 
a/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/ui/CelebornShufflePage.scala
+++ 
b/client-spark/spark-3-ui/src/main/scala/org/apache/spark/shuffle/celeborn/ui/CelebornShufflePage.scala
@@ -60,11 +60,20 @@ private[celeborn] class CelebornShufflePage(parent: 
CelebornUITab)
     val writeMs = taskInfo.shuffleWriteTimeMs
     val readMs = taskInfo.shuffleFetchWaitTimeMs
     val durationMs = taskInfo.taskDurationMs
+    val uncompressedBytes = taskInfo.uncompressedShuffleBytes
+    val compressedBytes = taskInfo.compressedShuffleBytes
 
     def mbps(bytes: Long, ms: Long): String =
       if (ms <= 0) "N/A" else f"${bytes.toDouble / 1000.0 / 1000.0 / 
(ms.toDouble / 1000.0)}%.2f"
     def pct(part: Long, total: Long): String =
       if (total <= 0) "N/A" else f"${part.toDouble * 100.0 / 
total.toDouble}%.1f%%"
+    // 0 when not reported, e.g. by old clients.
+    def compressionRatio: String =
+      if (uncompressedBytes <= 0 || compressedBytes <= 0) "N/A"
+      else {
+        
s"${Utils.bytesToString(uncompressedBytes)}/${Utils.bytesToString(compressedBytes)}
 = " +
+          f"${uncompressedBytes.toDouble / compressedBytes}%.2f"
+      }
 
     val summary =
       <div>
@@ -83,6 +92,10 @@ private[celeborn] class CelebornShufflePage(parent: 
CelebornUITab)
           readMs)} | Speed: ${mbps(readBytes, readMs)} MB/s"
       }
           </li>
+          <li>
+            <strong>Compression Ratio (uncompressed/compressed): </strong>
+            {compressionRatio}
+          </li>
           <li>
             <strong>Shuffle Duration (write+read) / Task Duration: </strong>
             {
diff --git 
a/client-spark/spark-3-ui/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornListenerSuite.scala
 
b/client-spark/spark-3-ui/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornListenerSuite.scala
index 21af11c0cc..e2314ed4f8 100644
--- 
a/client-spark/spark-3-ui/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornListenerSuite.scala
+++ 
b/client-spark/spark-3-ui/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornListenerSuite.scala
@@ -17,10 +17,11 @@
 
 package org.apache.spark.shuffle.celeborn
 
-import org.apache.spark.{SparkConf, Success, TaskState}
+import com.fasterxml.jackson.databind.ObjectMapper
+import org.apache.spark.{SparkConf, Success, TaskEndReason, TaskKilled, 
TaskState}
 import org.apache.spark.executor.TaskMetrics
 import org.apache.spark.internal.config.Status.ASYNC_TRACKING_ENABLED
-import org.apache.spark.scheduler.{JobSucceeded, 
SparkListenerEnvironmentUpdate, SparkListenerJobEnd, SparkListenerTaskEnd, 
TaskInfo, TaskLocality}
+import org.apache.spark.scheduler.{JobSucceeded, 
SparkListenerEnvironmentUpdate, SparkListenerEvent, SparkListenerJobEnd, 
SparkListenerTaskEnd, TaskInfo, TaskLocality}
 import org.apache.spark.status.ElementTrackingStore
 import org.apache.spark.util.Utils
 import org.apache.spark.util.kvstore.InMemoryStore
@@ -38,7 +39,8 @@ class CelebornListenerSuite {
       writeBytes: Long,
       writeTimeMs: Long,
       fetchWaitMs: Long,
-      durationMs: Long): SparkListenerTaskEnd = {
+      durationMs: Long,
+      reason: TaskEndReason = Success): SparkListenerTaskEnd = {
     val metrics = TaskMetrics.empty
     metrics.shuffleWriteMetrics.incBytesWritten(writeBytes)
     metrics.shuffleWriteMetrics.incWriteTime(writeTimeMs * 1000000L)
@@ -54,13 +56,103 @@ class CelebornListenerSuite {
       TaskLocality.PROCESS_LOCAL,
       false)
     info.markFinished(TaskState.FINISHED, launchTime + durationMs)
-    SparkListenerTaskEnd(0, 0, "ShuffleMapTask", Success, info, null, metrics)
+    SparkListenerTaskEnd(0, 0, "ShuffleMapTask", reason, info, null, metrics)
   }
 
   private def envUpdate(sparkProps: (String, String)*): 
SparkListenerEnvironmentUpdate = {
     SparkListenerEnvironmentUpdate(Map("Spark Properties" -> sparkProps.toSeq))
   }
 
+  @Test
+  def aggregateMapperMetricsEvents(): Unit = {
+    val store = new InMemoryStore()
+    val statusStore = new CelebornStatusStore(store)
+    val listener = new CelebornListener(store, new SparkConf())
+
+    listener.onOtherEvent(new CelebornMapperMetricsEvent(0, 100L, 40L))
+    listener.onOtherEvent(new CelebornMapperMetricsEvent(0, 250L, 94L))
+    // Unrelated events are ignored.
+    listener.onOtherEvent(new SparkListenerEvent {})
+    listener.flush()
+
+    assertEquals(350L, 
statusStore.aggregatedTaskInfo().uncompressedShuffleBytes)
+    assertEquals(134L, statusStore.aggregatedTaskInfo().compressedShuffleBytes)
+  }
+
+  @Test
+  def shuffleWriteMetricsCountEveryAttempt(): Unit = {
+    val store = new InMemoryStore()
+    val statusStore = new CelebornStatusStore(store)
+    val listener = new CelebornListener(store, new SparkConf())
+
+    listener.onTaskEnd(newTaskEnd(100L, 10L, 1L, 5L))
+    listener.onTaskEnd(newTaskEnd(200L, 20L, 2L, 6L))
+    listener.onTaskEnd(newTaskEnd(300L, 30L, 3L, 7L, TaskKilled("killed by 
test")))
+    listener.onOtherEvent(new CelebornMapperMetricsEvent(0, 100L, 40L))
+    listener.onOtherEvent(new CelebornMapperMetricsEvent(0, 200L, 80L))
+    listener.flush()
+
+    val agg = statusStore.aggregatedTaskInfo()
+    assertEquals(600L, agg.shuffleWriteBytes)
+    assertEquals(60L, agg.shuffleWriteTimeMs)
+    assertEquals(6L, agg.shuffleFetchWaitTimeMs)
+    assertEquals(18L, agg.taskDurationMs)
+    assertEquals(300L, agg.uncompressedShuffleBytes)
+    assertEquals(120L, agg.compressedShuffleBytes)
+  }
+
+  @Test
+  def compressionRatioSidesComeFromTheSameMapperEndAttemptSet(): Unit = {
+    // Regression: a task failing after its writer completed already reported
+    // MapperEnd. Both ratio sides derive from MapperEnd reports, so such 
attempts
+    // are counted consistently instead of skewing the ratio.
+    val store = new InMemoryStore()
+    val statusStore = new CelebornStatusStore(store)
+    val listener = new CelebornListener(store, new SparkConf())
+
+    // Partition 0 attempt 0: writer completed, then the task failed.
+    listener.onOtherEvent(new CelebornMapperMetricsEvent(0, 20L, 94L))
+    // Partition 0 attempt 1 (retry) and partition 1 attempt 0: succeeded.
+    listener.onOtherEvent(new CelebornMapperMetricsEvent(0, 20L, 94L))
+    listener.onOtherEvent(new CelebornMapperMetricsEvent(0, 20L, 94L))
+    listener.onTaskEnd(newTaskEnd(94L, 10L, 1L, 5L))
+    listener.onTaskEnd(newTaskEnd(94L, 10L, 1L, 5L))
+    listener.onTaskEnd(newTaskEnd(94L, 10L, 1L, 5L, TaskKilled("failed after 
write")))
+    listener.flush()
+
+    val agg = statusStore.aggregatedTaskInfo()
+    assertEquals(282L, agg.shuffleWriteBytes)
+    assertEquals(60L, agg.uncompressedShuffleBytes)
+    assertEquals(282L, agg.compressedShuffleBytes)
+  }
+
+  @Test
+  def mapperMetricsEventJsonRoundtrip(): Unit = {
+    // SparkListenerEvent carries @JsonTypeInfo(property = "Event"), so the 
event log
+    // records the class name and History Server replay can restore the event 
(the
+    // same mechanism JsonProtocol uses for custom SparkListenerEvents).
+    val mapper = new ObjectMapper()
+    val event = new CelebornMapperMetricsEvent(1, 1024L, 512L)
+    val json = mapper.writeValueAsString(event)
+    assertTrue(json.contains(
+      s""""Event":"${classOf[CelebornMapperMetricsEvent].getName}"""))
+    val restored = mapper
+      .readValue(json, classOf[SparkListenerEvent])
+      .asInstanceOf[CelebornMapperMetricsEvent]
+    assertEquals(1, restored.getShuffleId)
+    assertEquals(1024L, restored.getUncompressedBytesWritten)
+    assertEquals(512L, restored.getCompressedBytesWritten)
+
+    // Event logs written before compressedBytesWritten existed still 
deserialize.
+    val legacy = mapper.readValue(
+      s"""{"Event":"${classOf[CelebornMapperMetricsEvent].getName}",""" +
+        """"shuffleId":1,"uncompressedBytesWritten":1024}""",
+      classOf[SparkListenerEvent])
+      .asInstanceOf[CelebornMapperMetricsEvent]
+    assertEquals(1024L, legacy.getUncompressedBytesWritten)
+    assertEquals(0L, legacy.getCompressedBytesWritten)
+  }
+
   @Test
   def flushOnJobEndWithinThrottleInterval(): Unit = {
     val store = new InMemoryStore()
diff --git 
a/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/CelebornMapperMetricsEvent.java
 
b/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/CelebornMapperMetricsEvent.java
new file mode 100644
index 0000000000..6cde6c3abe
--- /dev/null
+++ 
b/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/CelebornMapperMetricsEvent.java
@@ -0,0 +1,75 @@
+/*
+ * 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.shuffle.celeborn;
+
+import org.apache.spark.scheduler.SparkListenerEvent;
+
+/**
+ * Spark listener event carrying per-mapper metrics reported through 
MapperEnd, aggregated by the
+ * Celeborn UI extension. Persisted to the event log and replayed by the 
History Server: keep the
+ * no-arg constructor and bean getters/setters for Jackson, and add new 
metrics as fields so old
+ * event logs and old readers stay compatible.
+ */
+public class CelebornMapperMetricsEvent implements SparkListenerEvent {
+
+  private int shuffleId;
+
+  // Bytes pushed before compression/encryption; 0 means not reported.
+  private long uncompressedBytesWritten;
+
+  // Bytes pushed after compression/encryption; 0 means not reported.
+  private long compressedBytesWritten;
+
+  public CelebornMapperMetricsEvent() {}
+
+  public CelebornMapperMetricsEvent(
+      int shuffleId, long uncompressedBytesWritten, long 
compressedBytesWritten) {
+    this.shuffleId = shuffleId;
+    this.uncompressedBytesWritten = uncompressedBytesWritten;
+    this.compressedBytesWritten = compressedBytesWritten;
+  }
+
+  public int getShuffleId() {
+    return shuffleId;
+  }
+
+  public void setShuffleId(int shuffleId) {
+    this.shuffleId = shuffleId;
+  }
+
+  public long getUncompressedBytesWritten() {
+    return uncompressedBytesWritten;
+  }
+
+  public void setUncompressedBytesWritten(long uncompressedBytesWritten) {
+    this.uncompressedBytesWritten = uncompressedBytesWritten;
+  }
+
+  public long getCompressedBytesWritten() {
+    return compressedBytesWritten;
+  }
+
+  public void setCompressedBytesWritten(long compressedBytesWritten) {
+    this.compressedBytesWritten = compressedBytesWritten;
+  }
+
+  @Override
+  public boolean logEvent() {
+    return true;
+  }
+}
diff --git 
a/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/HashBasedShuffleWriter.java
 
b/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/HashBasedShuffleWriter.java
index a36a0932a5..d1e47e66f0 100644
--- 
a/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/HashBasedShuffleWriter.java
+++ 
b/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/HashBasedShuffleWriter.java
@@ -51,6 +51,7 @@ import org.apache.celeborn.client.ShuffleClient;
 import org.apache.celeborn.client.write.DataPusher;
 import org.apache.celeborn.client.write.PushTask;
 import org.apache.celeborn.common.CelebornConf;
+import 
org.apache.celeborn.common.protocol.message.ControlMessages.MapperMetrics;
 import org.apache.celeborn.common.util.Utils;
 
 @Private
@@ -99,6 +100,9 @@ public class HashBasedShuffleWriter<K, V, C> extends 
ShuffleWriter<K, V> {
 
   private final boolean unsafeRowFastWrite;
 
+  // Bytes written before compression, for the shuffle compression ratio.
+  private long uncompressedBytesWritten = 0;
+
   // In order to facilitate the writing of unit test code, ShuffleClient needs 
to be passed in as
   // parameters. By the way, simplify the passed parameters.
   public HashBasedShuffleWriter(
@@ -303,6 +307,7 @@ public class HashBasedShuffleWriter<K, V, C> extends 
ShuffleWriter<K, V> {
     mapStatusLengths[partitionId].add(bytesWritten);
     writeMetrics.incBytesWritten(bytesWritten);
     writeMetrics.incWriteTime(delta);
+    uncompressedBytesWritten += numBytes;
   }
 
   private int getOrUpdateOffset(int partitionId, int serializedRecordSize)
@@ -333,6 +338,7 @@ public class HashBasedShuffleWriter<K, V, C> extends 
ShuffleWriter<K, V> {
     if (logger.isDebugEnabled()) logger.debug("Flush buffer, size {}.", 
Utils.bytesToString(size));
     dataPusher.addTask(partitionId, buffer, size);
     writeMetrics.incWriteTime(System.nanoTime() - start);
+    uncompressedBytesWritten += size;
   }
 
   protected void closeWrite() throws IOException {
@@ -365,6 +371,7 @@ public class HashBasedShuffleWriter<K, V, C> extends 
ShuffleWriter<K, V> {
             numPartitions);
     mapStatusLengths[partitionId].add(bytesWritten);
     writeMetrics.incBytesWritten(bytesWritten);
+    uncompressedBytesWritten += length;
   }
 
   private void cleanupPusher() throws IOException {
@@ -393,7 +400,14 @@ public class HashBasedShuffleWriter<K, V, C> extends 
ShuffleWriter<K, V> {
     long waitStartTime = System.nanoTime();
     dataPusher.waitOnTermination();
     sendBufferPool.returnPushTaskQueue(dataPusher.getAndResetIdleQueue());
-    shuffleClient.mapperEnd(shuffleId, mapId, encodedAttemptId, numMappers, 
numPartitions);
+    shuffleClient.mapperEnd(
+        shuffleId,
+        mapId,
+        encodedAttemptId,
+        numMappers,
+        numPartitions,
+        new MapperMetrics(
+            uncompressedBytesWritten, 
SparkCommonUtils.sumLongAdders(mapStatusLengths)));
     writeMetrics.incWriteTime(System.nanoTime() - waitStartTime);
 
     BlockManagerId bmId = SparkEnv.get().blockManager().shuffleServerId();
diff --git 
a/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SortBasedShuffleWriter.java
 
b/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SortBasedShuffleWriter.java
index d08b36d194..2440a23b01 100644
--- 
a/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SortBasedShuffleWriter.java
+++ 
b/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SortBasedShuffleWriter.java
@@ -47,6 +47,7 @@ import org.slf4j.LoggerFactory;
 import org.apache.celeborn.client.ShuffleClient;
 import org.apache.celeborn.common.CelebornConf;
 import org.apache.celeborn.common.exception.CelebornIOException;
+import 
org.apache.celeborn.common.protocol.message.ControlMessages.MapperMetrics;
 import org.apache.celeborn.common.util.Utils;
 
 @Private
@@ -79,6 +80,9 @@ public class SortBasedShuffleWriter<K, V, C> extends 
ShuffleWriter<K, V> {
   private final LongAdder[] mapStatusLengths;
   private long tmpRecordsWritten = 0;
 
+  // Giant-record bytes pushed directly, bypassing the pusher's uncompressed 
counter.
+  private long giantRecordBytesWritten = 0;
+
   /**
    * Are we in the process of stopping? Because map tasks can call stop() with 
success = true and
    * then call stop() with success = false if they get an exception, we want 
to make sure we don't
@@ -370,6 +374,7 @@ public class SortBasedShuffleWriter<K, V, C> extends 
ShuffleWriter<K, V> {
     mapStatusLengths[partitionId].add(bytesWritten);
     writeMetrics.incBytesWritten(bytesWritten);
     writeMetrics.incWriteTime(delta);
+    giantRecordBytesWritten += numBytes;
   }
 
   private void cleanupPusher() throws IOException {
@@ -396,7 +401,15 @@ public class SortBasedShuffleWriter<K, V, C> extends 
ShuffleWriter<K, V> {
     writeMetrics.incRecordsWritten(tmpRecordsWritten);
 
     long waitStartTime = System.nanoTime();
-    shuffleClient.mapperEnd(shuffleId, mapId, encodedAttemptId, numMappers, 
numPartitions);
+    shuffleClient.mapperEnd(
+        shuffleId,
+        mapId,
+        encodedAttemptId,
+        numMappers,
+        numPartitions,
+        new MapperMetrics(
+            pusher.getUncompressedBytesWritten() + giantRecordBytesWritten,
+            SparkCommonUtils.sumLongAdders(mapStatusLengths)));
     writeMetrics.incWriteTime(System.nanoTime() - waitStartTime);
   }
 
diff --git 
a/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SparkShuffleManager.java
 
b/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SparkShuffleManager.java
index 8d4749945b..7b5a2fbd65 100644
--- 
a/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SparkShuffleManager.java
+++ 
b/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SparkShuffleManager.java
@@ -23,6 +23,7 @@ import java.util.Optional;
 import java.util.concurrent.ConcurrentHashMap;
 
 import org.apache.spark.*;
+import org.apache.spark.SparkContext$;
 import org.apache.spark.internal.config.ConfigEntry;
 import org.apache.spark.internal.config.package$;
 import org.apache.spark.launcher.SparkLauncher;
@@ -191,6 +192,22 @@ public class SparkShuffleManager implements ShuffleManager 
{
           lifecycleManager = new LifecycleManager(appUniqueId, celebornConf);
           lifecycleManager.applicationCount().increment();
           
lifecycleManager.registerCancelShuffleCallback(SparkUtils::cancelShuffle);
+          // Register only when the UI plugin is opted in, otherwise every map 
task
+          // posts an event nobody consumes and each one flushes the event log.
+          if (SparkUtils.isCelebornPluginEnabled(conf)) {
+            lifecycleManager.registerMapperEndCallback(
+                (shuffleId, mapId, attemptId, metrics) -> {
+                  SparkContext sc = 
SparkContext$.MODULE$.getActive().getOrElse(() -> null);
+                  if (sc != null) {
+                    sc.listenerBus()
+                        .post(
+                            new CelebornMapperMetricsEvent(
+                                shuffleId,
+                                metrics.uncompressedBytesWritten(),
+                                metrics.compressedBytesWritten()));
+                  }
+                });
+          }
           if (celebornConf.clientStageRerunEnabled()) {
             MapOutputTrackerMaster mapOutputTracker =
                 (MapOutputTrackerMaster) SparkEnv.get().mapOutputTracker();
diff --git 
a/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SparkUtils.java
 
b/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SparkUtils.java
index 89cb7c3792..0ad8301b16 100644
--- 
a/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SparkUtils.java
+++ 
b/client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SparkUtils.java
@@ -570,6 +570,26 @@ public class SparkUtils {
     }
   }
 
+  /** Lives in the spark-3-ui module, referenced here by class name. */
+  private static final String CELEBORN_PLUGIN_CLASS_NAME =
+      "org.apache.spark.shuffle.celeborn.CelebornPlugin";
+
+  /** Whether the app opted in to the Celeborn UI extension via its plugin 
configuration. */
+  public static boolean isCelebornPluginEnabled(SparkConf conf) {
+    String[] keys = {"spark.plugins", "spark.plugins.defaultList"};
+    for (String key : keys) {
+      String value = conf.get(key, null);
+      if (value != null) {
+        for (String plugin : value.split(",")) {
+          if (plugin.trim().equals(CELEBORN_PLUGIN_CLASS_NAME)) {
+            return true;
+          }
+        }
+      }
+    }
+    return false;
+  }
+
   /**
    * Lazy holder for isCelebornSkewedShuffle method. The method is initialized 
only when this class
    * is first accessed, ensuring lazy loading without explicit synchronization.
diff --git 
a/client-spark/spark-3/src/test/java/org/apache/spark/shuffle/celeborn/CelebornShuffleWriterSuiteBase.java
 
b/client-spark/spark-3/src/test/java/org/apache/spark/shuffle/celeborn/CelebornShuffleWriterSuiteBase.java
index b5bacf489d..fd9fb044f6 100644
--- 
a/client-spark/spark-3/src/test/java/org/apache/spark/shuffle/celeborn/CelebornShuffleWriterSuiteBase.java
+++ 
b/client-spark/spark-3/src/test/java/org/apache/spark/shuffle/celeborn/CelebornShuffleWriterSuiteBase.java
@@ -83,6 +83,7 @@ import org.apache.celeborn.client.ShuffleClient;
 import org.apache.celeborn.common.CelebornConf;
 import org.apache.celeborn.common.CommitMetadata;
 import org.apache.celeborn.common.identity.UserIdentifier;
+import 
org.apache.celeborn.common.protocol.message.ControlMessages.MapperMetrics;
 import org.apache.celeborn.common.util.JavaUtils;
 import org.apache.celeborn.common.util.Utils;
 import org.apache.celeborn.common.write.PushState;
@@ -288,6 +289,23 @@ public abstract class CelebornShuffleWriterSuiteBase {
     int[] crcPerPartition = pushState.getCRC32PerPartition(true, 
numPartitions);
     long[] bytesPerPartition = pushState.getBytesWrittenPerPartition(true, 
numPartitions);
 
+    MapperMetrics mapperMetrics = client.getMapperEndMetrics(shuffleId, 0, 0);
+    assertNotNull(mapperMetrics);
+    long totalBytesPushed = 0;
+    for (long bytes : bytesPerPartition) {
+      totalBytesPushed += bytes;
+    }
+    long expectedCompressedBytes = 0;
+    for (List<byte[]> batches : client.getCrcDataByPartition(mapKey).values()) 
{
+      for (byte[] batch : batches) {
+        expectedCompressedBytes += 
DummyShuffleClient.simulatedCompressedLength(batch.length);
+      }
+    }
+    assertEquals(totalBytesPushed, mapperMetrics.uncompressedBytesWritten());
+    // Distinct expectations so a swap of the two fields would fail.
+    assertEquals(expectedCompressedBytes, 
mapperMetrics.compressedBytesWritten());
+    assertTrue(expectedCompressedBytes <= totalBytesPushed);
+
     Map<Integer, List<byte[]>> crcData = client.getCrcDataByPartition(mapKey);
     Map<Integer, List<byte[]>> pushData = 
client.getPushDataByPartition(mapKey);
 
@@ -353,8 +371,8 @@ public abstract class CelebornShuffleWriterSuiteBase {
     final CelebornShuffleHandle<Integer, String, String> handle =
         new CelebornShuffleHandle<>(
             appId, host, port, userIdentifier, shuffleId, false, numMaps, 
dependency);
-    final ShuffleClient client = new DummyShuffleClient(conf, tempFile);
-    ((DummyShuffleClient) client).initReducePartitionMap(shuffleId, 
numPartitions, 1);
+    final DummyShuffleClient client = new DummyShuffleClient(conf, tempFile);
+    client.initReducePartitionMap(shuffleId, numPartitions, 1);
 
     final ShuffleWriter<Integer, String> writer =
         createShuffleWriter(handle, taskContext, conf, client, 
metrics.shuffleWriteMetrics());
@@ -383,8 +401,21 @@ public abstract class CelebornShuffleWriterSuiteBase {
 
     ShuffleWriteMetrics metrics = 
taskContext.taskMetrics().shuffleWriteMetrics();
     assertEquals(metrics.recordsWritten(), total.intValue());
-    assertEquals(metrics.bytesWritten(), tempFile.length());
+    // Write metrics total the per-batch compressed lengths.
+    String mapKey = Utils.makeMapKey(shuffleId, 0, 0);
+    long expectedCompressedBytes = 0;
+    for (List<byte[]> batches : 
client.getPushDataByPartition(mapKey).values()) {
+      for (byte[] batch : batches) {
+        expectedCompressedBytes += 
DummyShuffleClient.simulatedCompressedLength(batch.length);
+      }
+    }
+    assertEquals(expectedCompressedBytes, metrics.bytesWritten());
+    assertTrue(metrics.bytesWritten() <= tempFile.length());
     assertTrue(metrics.writeTime() > 0);
+    MapperMetrics mapperMetrics = client.getMapperEndMetrics(shuffleId, 0, 0);
+    assertNotNull(mapperMetrics);
+    assertEquals(tempFile.length(), mapperMetrics.uncompressedBytesWritten());
+    assertEquals(metrics.bytesWritten(), 
mapperMetrics.compressedBytesWritten());
 
     try (FileInputStream fis = new FileInputStream(tempFile)) {
       Iterator it = 
newSerializerInstance(serializer).deserializeStream(fis).asKeyValueIterator();
diff --git 
a/client/src/main/java/org/apache/celeborn/client/DummyShuffleClient.java 
b/client/src/main/java/org/apache/celeborn/client/DummyShuffleClient.java
index 86e6a2d191..04643e64b9 100644
--- a/client/src/main/java/org/apache/celeborn/client/DummyShuffleClient.java
+++ b/client/src/main/java/org/apache/celeborn/client/DummyShuffleClient.java
@@ -48,6 +48,7 @@ import 
org.apache.celeborn.common.exception.CelebornIOException;
 import org.apache.celeborn.common.network.client.TransportClientFactory;
 import org.apache.celeborn.common.protocol.PartitionLocation;
 import org.apache.celeborn.common.protocol.PbStreamHandler;
+import 
org.apache.celeborn.common.protocol.message.ControlMessages.MapperMetrics;
 import org.apache.celeborn.common.rpc.RpcEndpointRef;
 import org.apache.celeborn.common.util.ExceptionMaker;
 import org.apache.celeborn.common.util.JavaUtils;
@@ -72,6 +73,7 @@ public class DummyShuffleClient extends ShuffleClient {
       JavaUtils.newConcurrentHashMap();
   private final Map<String, Map<Integer, List<byte[]>>> pushDataByMapKey =
       JavaUtils.newConcurrentHashMap();
+  private final Map<String, MapperMetrics> mapperEndMetrics = 
JavaUtils.newConcurrentHashMap();
 
   public AtomicInteger fetchFailureCount = new AtomicInteger();
 
@@ -113,7 +115,12 @@ public class DummyShuffleClient extends ShuffleClient {
         .add(Arrays.copyOfRange(data, offset, offset + length));
 
     os.write(data, offset, length);
-    return length;
+    return simulatedCompressedLength(length);
+  }
+
+  /** Deterministic fake compression so tests can tell the two byte counts 
apart. */
+  public static int simulatedCompressedLength(int length) {
+    return length * 3 / 4;
   }
 
   @Override
@@ -159,7 +166,7 @@ public class DummyShuffleClient extends ShuffleClient {
         .add(Arrays.copyOfRange(data, offset, offset + length));
 
     os.write(data, offset, length);
-    return length;
+    return simulatedCompressedLength(length);
   }
 
   @Override
@@ -169,6 +176,17 @@ public class DummyShuffleClient extends ShuffleClient {
   public void mapperEnd(
       int shuffleId, int mapId, int attemptId, int numMappers, int 
numPartitions) {}
 
+  @Override
+  public void mapperEnd(
+      int shuffleId,
+      int mapId,
+      int attemptId,
+      int numMappers,
+      int numPartitions,
+      MapperMetrics metrics) {
+    mapperEndMetrics.put(Utils.makeMapKey(shuffleId, mapId, attemptId), 
metrics);
+  }
+
   @Override
   public void readReducerPartitionEnd(
       int shuffleId, int partitionId, int startMapIndex, int endMapIndex, int 
crc32, long bytes)
@@ -253,6 +271,10 @@ public class DummyShuffleClient extends ShuffleClient {
     return pushDataByMapKey.getOrDefault(mapKey, Collections.emptyMap());
   }
 
+  public MapperMetrics getMapperEndMetrics(int shuffleId, int mapId, int 
attemptId) {
+    return mapperEndMetrics.get(Utils.makeMapKey(shuffleId, mapId, attemptId));
+  }
+
   @Override
   public Tuple2<Integer, Boolean> getShuffleId(
       int appShuffleId, String appShuffleIdentifier, boolean isWriter, boolean 
isBarrierStage) {
diff --git a/client/src/main/java/org/apache/celeborn/client/ShuffleClient.java 
b/client/src/main/java/org/apache/celeborn/client/ShuffleClient.java
index 9fb15fdf7a..dd39d5bc61 100644
--- a/client/src/main/java/org/apache/celeborn/client/ShuffleClient.java
+++ b/client/src/main/java/org/apache/celeborn/client/ShuffleClient.java
@@ -45,6 +45,7 @@ import org.apache.celeborn.common.protocol.PartitionLocation;
 import org.apache.celeborn.common.protocol.PbStreamHandler;
 import org.apache.celeborn.common.protocol.StorageInfo;
 import org.apache.celeborn.common.protocol.message.ControlMessages;
+import 
org.apache.celeborn.common.protocol.message.ControlMessages.MapperMetrics;
 import org.apache.celeborn.common.rpc.RpcEndpointRef;
 import org.apache.celeborn.common.util.CelebornHadoopUtils;
 import org.apache.celeborn.common.util.ExceptionMaker;
@@ -375,6 +376,22 @@ public abstract class ShuffleClient {
       int shuffleId, int mapId, int attemptId, int numMappers, int 
numPartitions)
       throws IOException;
 
+  /**
+   * Same as the 5-arg overload, additionally reporting per-mapper write 
metrics. The default
+   * implementation discards the metrics, so external subclasses only need the 
abstract 5-arg method
+   * to stay compatible.
+   */
+  public void mapperEnd(
+      int shuffleId,
+      int mapId,
+      int attemptId,
+      int numMappers,
+      int numPartitions,
+      MapperMetrics metrics)
+      throws IOException {
+    mapperEnd(shuffleId, mapId, attemptId, numMappers, numPartitions);
+  }
+
   public abstract void readReducerPartitionEnd(
       int shuffleId, int partitionId, int startMapIndex, int endMapIndex, int 
crc32, long bytes)
       throws IOException;
diff --git 
a/client/src/main/java/org/apache/celeborn/client/ShuffleClientImpl.java 
b/client/src/main/java/org/apache/celeborn/client/ShuffleClientImpl.java
index f321924737..6b99c5c82b 100644
--- a/client/src/main/java/org/apache/celeborn/client/ShuffleClientImpl.java
+++ b/client/src/main/java/org/apache/celeborn/client/ShuffleClientImpl.java
@@ -1854,14 +1854,26 @@ public class ShuffleClientImpl extends ShuffleClient {
   @Override
   public void mapperEnd(int shuffleId, int mapId, int attemptId, int 
numMappers, int numPartitions)
       throws IOException {
-    mapEndInternal(shuffleId, mapId, attemptId, numMappers, numPartitions, -1);
+    mapEndInternal(shuffleId, mapId, attemptId, numMappers, numPartitions, -1, 
null);
+  }
+
+  @Override
+  public void mapperEnd(
+      int shuffleId,
+      int mapId,
+      int attemptId,
+      int numMappers,
+      int numPartitions,
+      MapperMetrics metrics)
+      throws IOException {
+    mapEndInternal(shuffleId, mapId, attemptId, numMappers, numPartitions, -1, 
metrics);
   }
 
   @Override
   public void mapPartitionMapperEnd(
       int shuffleId, int mapId, int attemptId, int numMappers, int 
numPartitions, int partitionId)
       throws IOException {
-    mapEndInternal(shuffleId, mapId, attemptId, numMappers, numPartitions, 
partitionId);
+    mapEndInternal(shuffleId, mapId, attemptId, numMappers, numPartitions, 
partitionId, null);
   }
 
   private void mapEndInternal(
@@ -1870,7 +1882,8 @@ public class ShuffleClientImpl extends ShuffleClient {
       int attemptId,
       int numMappers,
       int numPartitions,
-      Integer partitionId)
+      Integer partitionId,
+      MapperMetrics metrics)
       throws IOException {
     final String mapKey = Utils.makeMapKey(shuffleId, mapId, attemptId);
     PushState pushState = getPushState(mapKey);
@@ -1896,7 +1909,8 @@ public class ShuffleClientImpl extends ShuffleClient {
                   numPartitions,
                   crc32PerPartition,
                   bytesPerPartition,
-                  SerdeVersion.V1),
+                  SerdeVersion.V1,
+                  metrics == null ? new MapperMetrics(0L, 0L) : metrics),
               rpcMaxRetries,
               rpcRetryWait,
               ClassTag$.MODULE$.apply(MapperEndResponse.class));
diff --git 
a/client/src/main/java/org/apache/celeborn/client/listener/MapperEndCallback.java
 
b/client/src/main/java/org/apache/celeborn/client/listener/MapperEndCallback.java
new file mode 100644
index 0000000000..7c3d5cd49d
--- /dev/null
+++ 
b/client/src/main/java/org/apache/celeborn/client/listener/MapperEndCallback.java
@@ -0,0 +1,29 @@
+/*
+ * 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.celeborn.client.listener;
+
+import 
org.apache.celeborn.common.protocol.message.ControlMessages.MapperMetrics;
+
+/**
+ * Invoked once per accepted mapper attempt; rejected speculative attempts and 
retried RPCs of the
+ * same attempt are not reported.
+ */
+public interface MapperEndCallback {
+
+  void onMapperEnd(int shuffleId, int mapId, int attemptId, MapperMetrics 
metrics);
+}
diff --git 
a/client/src/main/scala/org/apache/celeborn/client/LifecycleManager.scala 
b/client/src/main/scala/org/apache/celeborn/client/LifecycleManager.scala
index facf767b01..77ff0715ae 100644
--- a/client/src/main/scala/org/apache/celeborn/client/LifecycleManager.scala
+++ b/client/src/main/scala/org/apache/celeborn/client/LifecycleManager.scala
@@ -39,7 +39,7 @@ import com.google.common.annotations.VisibleForTesting
 import com.google.common.cache.{Cache, CacheBuilder}
 
 import org.apache.celeborn.client.LifecycleManager.{ShuffleAllocatedWorkers, 
ShuffleFailedWorkers}
-import org.apache.celeborn.client.listener.WorkerStatusListener
+import org.apache.celeborn.client.listener.{MapperEndCallback, 
WorkerStatusListener}
 import org.apache.celeborn.common.{CelebornConf, CommitMetadata}
 import org.apache.celeborn.common.CelebornConf.ACTIVE_STORAGE_TYPES
 import org.apache.celeborn.common.client.{ApplicationInfoProvider, 
MasterClient}
@@ -468,7 +468,8 @@ class LifecycleManager(val appUniqueId: String, val conf: 
CelebornConf) extends
           numPartitions,
           crc32PerPartition,
           bytesWrittenPerPartition,
-          serdeVersion) =>
+          serdeVersion,
+          metrics) =>
       logTrace(s"Received MapperEnd TaskEnd request, " +
         s"${Utils.makeMapKey(shuffleId, mapId, attemptId)}")
       val partitionType = getPartitionType(shuffleId)
@@ -484,7 +485,8 @@ class LifecycleManager(val appUniqueId: String, val conf: 
CelebornConf) extends
             numPartitions,
             crc32PerPartition,
             bytesWrittenPerPartition,
-            serdeVersion)
+            serdeVersion,
+            metrics)
         case PartitionType.MAP =>
           handleMapPartitionEnd(
             context,
@@ -968,7 +970,8 @@ class LifecycleManager(val appUniqueId: String, val conf: 
CelebornConf) extends
       numPartitions: Int,
       crc32PerPartition: Array[Int],
       bytesWrittenPerPartition: Array[Long],
-      serdeVersion: SerdeVersion): Unit = {
+      serdeVersion: SerdeVersion,
+      metrics: MapperMetrics): Unit = {
 
     val (mapperAttemptFinishedSuccess, allMapperFinished) =
       commitManager.finishMapperAttempt(
@@ -980,6 +983,11 @@ class LifecycleManager(val appUniqueId: String, val conf: 
CelebornConf) extends
         numPartitions = numPartitions,
         crc32PerPartition = crc32PerPartition,
         bytesWrittenPerPartition = bytesWrittenPerPartition)
+    // finishMapperAttempt accepts at most one attempt per mapId, so the 
callback fires
+    // exactly once per accepted attempt and no extra dedup is needed.
+    if (mapperAttemptFinishedSuccess) {
+      mapperEndCallback.foreach(_.onMapperEnd(shuffleId, mapId, attemptId, 
metrics))
+    }
     if (mapperAttemptFinishedSuccess && allMapperFinished) {
       // last mapper finished. call mapper end
       logInfo(s"Last MapperEnd, call StageEnd with shuffleKey:" +
@@ -2178,6 +2186,11 @@ class LifecycleManager(val appUniqueId: String, val 
conf: CelebornConf) extends
     unregisterShuffleCallback = Some(callback)
   }
 
+  @volatile private var mapperEndCallback: Option[MapperEndCallback] = None
+  def registerMapperEndCallback(callback: MapperEndCallback): Unit = {
+    mapperEndCallback = Some(callback)
+  }
+
   def registerAppShuffleDeterminate(appShuffleId: Int, determinate: Boolean): 
Unit = {
     appShuffleDeterminateMap.put(appShuffleId, determinate)
   }
diff --git a/common/src/main/proto/TransportMessages.proto 
b/common/src/main/proto/TransportMessages.proto
index e136d3c0c7..dd671914a0 100644
--- a/common/src/main/proto/TransportMessages.proto
+++ b/common/src/main/proto/TransportMessages.proto
@@ -384,6 +384,15 @@ message PbPartitionSplit {
   PbPartitionLocation oldPartition = 4;
 }
 
+// Extensible per-mapper metrics carried by PbMapperEnd. New mapper-side 
metrics
+// must be added as fields here instead of top-level fields of PbMapperEnd.
+message PbMapperMetrics {
+  // Bytes pushed before compression/encryption; 0 means not reported.
+  int64 uncompressedBytesWritten = 1;
+  // Bytes pushed after compression/encryption; 0 means not reported.
+  int64 compressedBytesWritten = 2;
+}
+
 message PbMapperEnd {
   int32 shuffleId = 1;
   int32 mapId = 2;
@@ -394,6 +403,7 @@ message PbMapperEnd {
   int32 numPartitions = 7;
   repeated int32 crc32PerPartition = 8;
   repeated int64 bytesWrittenPerPartition = 9;
+  PbMapperMetrics metrics = 10;
 }
 
 message PbLocationPushFailedBatches {
diff --git 
a/common/src/main/scala/org/apache/celeborn/common/protocol/message/ControlMessages.scala
 
b/common/src/main/scala/org/apache/celeborn/common/protocol/message/ControlMessages.scala
index 580291d34b..795dbaaa8f 100644
--- 
a/common/src/main/scala/org/apache/celeborn/common/protocol/message/ControlMessages.scala
+++ 
b/common/src/main/scala/org/apache/celeborn/common/protocol/message/ControlMessages.scala
@@ -209,6 +209,11 @@ object ControlMessages extends Logging {
       newLocs: util.Map[Integer, (StatusCode, java.lang.Boolean, 
PartitionLocation)],
       serdeVersion: SerdeVersion) extends MasterMessage
 
+  // Extensible per-mapper metrics carried by MapperEnd, mirrors 
PbMapperMetrics.
+  case class MapperMetrics(
+      uncompressedBytesWritten: Long = 0L,
+      compressedBytesWritten: Long = 0L)
+
   case class MapperEnd(
       shuffleId: Int,
       mapId: Int,
@@ -219,7 +224,8 @@ object ControlMessages extends Logging {
       numPartitions: Int,
       crc32PerPartition: Array[Int],
       bytesWrittenPerPartition: Array[Long],
-      serdeVersion: SerdeVersion)
+      serdeVersion: SerdeVersion,
+      metrics: MapperMetrics = MapperMetrics())
     extends MasterMessage
 
   case class ReadReducerPartitionEnd(
@@ -748,7 +754,8 @@ object ControlMessages extends Logging {
           numPartitions,
           crc32PerPartition,
           bytesWrittenPerPartition,
-          serdeVersion) =>
+          serdeVersion,
+          metrics) =>
       val pushFailedMap = pushFailedBatch.asScala.map { case (k, v) =>
         val resultValue = PbSerDeUtils.toPbLocationPushFailedBatches(v)
         (k, resultValue)
@@ -764,6 +771,9 @@ object ControlMessages extends Logging {
         
.addAllCrc32PerPartition(crc32PerPartition.map(Integer.valueOf).toSeq.asJava)
         .addAllBytesWrittenPerPartition(bytesWrittenPerPartition.map(
           java.lang.Long.valueOf).toSeq.asJava)
+        .setMetrics(PbMapperMetrics.newBuilder()
+          .setUncompressedBytesWritten(metrics.uncompressedBytesWritten)
+          .setCompressedBytesWritten(metrics.compressedBytesWritten))
         .build().toByteArray
       new TransportMessage(MessageType.MAPPER_END, payload, serdeVersion)
 
@@ -1274,7 +1284,10 @@ object ControlMessages extends Logging {
           pbMapperEnd.getNumPartitions,
           crc32Array,
           bytesWrittenPerPartitionArray,
-          message.getSerdeVersion)
+          message.getSerdeVersion,
+          MapperMetrics(
+            pbMapperEnd.getMetrics.getUncompressedBytesWritten,
+            pbMapperEnd.getMetrics.getCompressedBytesWritten))
 
       case READ_REDUCER_PARTITION_END_VALUE =>
         val pbReadReducerPartitionEnd = 
PbReadReducerPartitionEnd.parseFrom(message.getPayload)
diff --git 
a/common/src/test/scala/org/apache/celeborn/common/util/UtilsSuite.scala 
b/common/src/test/scala/org/apache/celeborn/common/util/UtilsSuite.scala
index 7f1725383a..488ebfbef0 100644
--- a/common/src/test/scala/org/apache/celeborn/common/util/UtilsSuite.scala
+++ b/common/src/test/scala/org/apache/celeborn/common/util/UtilsSuite.scala
@@ -35,7 +35,7 @@ import org.apache.celeborn.common.meta.WorkerInfo
 import org.apache.celeborn.common.metrics.{ClientMetric, MetricType}
 import org.apache.celeborn.common.network.protocol.SerdeVersion
 import org.apache.celeborn.common.protocol.{PartitionLocation, 
PbRequestWorkers, PbRequestWorkersResponse, PbReviseLostShuffles, 
PbReviseLostShufflesResponse, StorageInfo, TransportModuleConstants}
-import 
org.apache.celeborn.common.protocol.message.ControlMessages.{CommitFilesResponse,
 GetReducerFileGroupResponse, HeartbeatFromApplication, MapperEnd, 
ReviseLostShuffles, ReviseLostShufflesResponse}
+import 
org.apache.celeborn.common.protocol.message.ControlMessages.{CommitFilesResponse,
 GetReducerFileGroupResponse, HeartbeatFromApplication, MapperEnd, 
MapperMetrics, ReviseLostShuffles, ReviseLostShufflesResponse}
 import org.apache.celeborn.common.protocol.message.StatusCode
 
 class UtilsSuite extends CelebornFunSuite {
@@ -186,7 +186,8 @@ class UtilsSuite extends CelebornFunSuite {
         1,
         Array.emptyIntArray,
         Array.emptyLongArray,
-        SerdeVersion.V1)
+        SerdeVersion.V1,
+        MapperMetrics(123456L, 789L))
     val mapperEndTrans =
       
Utils.fromTransportMessage(Utils.toTransportMessage(mapperEnd)).asInstanceOf[MapperEnd]
     assert(mapperEnd.shuffleId == mapperEndTrans.shuffleId)
@@ -196,6 +197,7 @@ class UtilsSuite extends CelebornFunSuite {
     assert(mapperEnd.partitionId == mapperEndTrans.partitionId)
     assert(mapperEnd.failedBatchSet == mapperEndTrans.failedBatchSet)
     assert(mapperEnd.numPartitions == mapperEndTrans.numPartitions)
+    assert(mapperEnd.metrics == mapperEndTrans.metrics)
     mapperEnd.crc32PerPartition.array should contain theSameElementsInOrderAs 
mapperEndTrans.crc32PerPartition
     mapperEnd.bytesWrittenPerPartition.array should contain 
theSameElementsInOrderAs mapperEndTrans.bytesWrittenPerPartition
   }
diff --git a/docker/spark/conf/spark-defaults.conf 
b/docker/spark/conf/spark-defaults.conf
index 9692f5ab6f..139e699961 100644
--- a/docker/spark/conf/spark-defaults.conf
+++ b/docker/spark/conf/spark-defaults.conf
@@ -43,6 +43,9 @@ spark.shuffle.service.enabled               false
 spark.celeborn.client.spark.shuffle.writer  hash
 spark.celeborn.client.push.replicate.enabled true
 
+# Register the Celeborn UI extension (Celeborn tab on the Spark application 
UI).
+spark.plugins                               
org.apache.spark.shuffle.celeborn.CelebornPlugin
+
 # AQE local shuffle reader is incompatible with remote (Celeborn) shuffle.
 spark.sql.adaptive.enabled                  true
 spark.sql.adaptive.localShuffleReader.enabled false
diff --git 
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/client/LifecycleManagerSuite.scala
 
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/client/LifecycleManagerSuite.scala
index b4453c83f9..ade825596f 100644
--- 
a/tests/spark-it/src/test/scala/org/apache/celeborn/tests/client/LifecycleManagerSuite.scala
+++ 
b/tests/spark-it/src/test/scala/org/apache/celeborn/tests/client/LifecycleManagerSuite.scala
@@ -19,6 +19,8 @@ package org.apache.celeborn.tests.client
 
 import java.util
 
+import scala.collection.JavaConverters._
+
 import org.scalatest.concurrent.Eventually.eventually
 import org.scalatest.concurrent.Futures.{interval, timeout}
 import org.scalatest.time.SpanSugar.convertIntToGrainOfTime
@@ -26,6 +28,7 @@ import org.scalatest.time.SpanSugar.convertIntToGrainOfTime
 import org.apache.celeborn.client.{LifecycleManager, WithShuffleClientSuite}
 import org.apache.celeborn.common.CelebornConf
 import org.apache.celeborn.common.identity.UserIdentifier
+import 
org.apache.celeborn.common.protocol.message.ControlMessages.MapperMetrics
 import org.apache.celeborn.common.protocol.message.StatusCode
 import org.apache.celeborn.service.deploy.MiniClusterFeature
 
@@ -128,6 +131,58 @@ class LifecycleManagerSuite extends WithShuffleClientSuite 
with MiniClusterFeatu
     lifecycleManager.stop()
   }
 
+  test("CELEBORN-2479: mapper end callback reports only the accepted attempt") 
{
+    prepareService()
+    val numMappers = 8
+    val numPartitions = 9
+    val shuffleId = nextShuffleId
+    val mapId = 1
+    val attemptId = 0
+
+    val reported = new util.concurrent.ConcurrentLinkedQueue[(Long, Long)]()
+    lifecycleManager.registerMapperEndCallback((_, _, _, metrics) =>
+      reported.add((metrics.uncompressedBytesWritten, 
metrics.compressedBytesWritten)))
+
+    // Register a REDUCE-partition shuffle.
+    shuffleClient.getPartitionLocation(shuffleId, numMappers, numPartitions)
+
+    // Accepted attempt.
+    shuffleClient.mapperEnd(
+      shuffleId,
+      mapId,
+      attemptId,
+      numMappers,
+      numPartitions,
+      new MapperMetrics(100L, 40L))
+    // Retried RPC of the same attempt: finishMapperAttempt is idempotent, not 
reported again.
+    shuffleClient.mapperEnd(
+      shuffleId,
+      mapId,
+      attemptId,
+      numMappers,
+      numPartitions,
+      new MapperMetrics(100L, 40L))
+    // Duplicate speculative attempt: rejected by the commit handler, not 
reported.
+    shuffleClient.mapperEnd(
+      shuffleId,
+      mapId,
+      attemptId + 1,
+      numMappers,
+      numPartitions,
+      new MapperMetrics(250L, 94L))
+    // Retried RPC of the rejected attempt: not reported again.
+    shuffleClient.mapperEnd(
+      shuffleId,
+      mapId,
+      attemptId + 1,
+      numMappers,
+      numPartitions,
+      new MapperMetrics(250L, 94L))
+
+    // The callback runs synchronously before the RPC reply.
+    assert(reported.asScala.toList == List((100L, 40L)))
+  }
+
   override def afterAll(): Unit = {
     logInfo("all test complete , stop celeborn mini cluster")
     shutdownMiniCluster()

Reply via email to