You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@datafusion.apache.org by ag...@apache.org on 2026/08/03 13:17:25 UTC

(datafusion-comet) branch main updated: perf: avoid rebuilding untouched operators in PlanDataInjector.injectPlanData (#5220)

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

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


The following commit(s) were added to refs/heads/main by this push:
     new 41d887d07c perf: avoid rebuilding untouched operators in PlanDataInjector.injectPlanData (#5220)
41d887d07c is described below

commit 41d887d07cb2f6800e79341f1bd8bdf205dbbeeb
Author: Andy Grove <ag...@apache.org>
AuthorDate: Mon Aug 3 07:17:20 2026 -0600

    perf: avoid rebuilding untouched operators in PlanDataInjector.injectPlanData (#5220)
---
 .../org/apache/spark/sql/comet/operators.scala     | 51 ++++++++++++++--------
 .../spark/sql/comet/PlanDataInjectorSuite.scala    | 42 +++++++++++++++++-
 2 files changed, 75 insertions(+), 18 deletions(-)

diff --git a/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala b/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
index 9c4917e2d6..8e1efa0087 100644
--- a/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
+++ b/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala
@@ -83,7 +83,13 @@ private[comet] trait PlanDataInjector {
   /** Extract the key used to look up planning data for this operator. */
   def getKey(op: Operator): Option[String]
 
-  /** Inject common + partition data into the operator node. */
+  /**
+   * Inject common + partition data into the operator node.
+   *
+   * Implementations must return the node with its child list unchanged -- `injectPlanData` walks
+   * the returned node's children, and relies on child reference identity to decide which
+   * operators need rebuilding.
+   */
   def inject(op: Operator, commonBytes: Array[Byte], partitionBytes: Array[Byte]): Operator
 }
 
@@ -132,40 +138,51 @@ private[comet] object PlanDataInjector extends Logging {
    *
    * Supports joins over multiple tables by matching each operator with its corresponding data
    * based on a key (e.g., metadata_location for Iceberg).
+   *
+   * Operators are immutable protobuf messages, so any subtree needing no injection is returned by
+   * reference rather than rebuilt; only the root-to-scan paths are rebuilt.
    */
   def injectPlanData(
       op: Operator,
       commonByKey: Map[String, Array[Byte]],
       partitionByKey: Map[String, Array[Byte]]): Operator = {
-    val builder = op.toBuilder
 
     // O(1) by op kind, then a canInject confirm (which may inspect detail fields like `hasCommon`
     // / `!hasFilePartition`). Most operators in any tree are non-scan and skip the lookup body.
-    injectorsByKind.get(op.getOpStructCase) match {
+    val injectedOp = injectorsByKind.get(op.getOpStructCase) match {
       case Some(injector) if injector.canInject(op) =>
         injector.getKey(op) match {
           case Some(key) =>
             (commonByKey.get(key), partitionByKey.get(key)) match {
               case (Some(commonBytes), Some(partitionBytes)) =>
-                val injectedOp = injector.inject(op, commonBytes, partitionBytes)
-                // Copy the injected operator's fields to our builder
-                builder.clear()
-                builder.mergeFrom(injectedOp)
+                injector.inject(op, commonBytes, partitionBytes)
               case _ =>
                 throw new CometRuntimeException(s"Missing planning data for key: $key")
             }
-          case None =>
+          case None => op
         }
-      case _ =>
-    }
-
-    // Recursively process children
-    builder.clearChildren()
-    op.getChildrenList.asScala.foreach { child =>
-      builder.addChildren(injectPlanData(child, commonByKey, partitionByKey))
+      case _ => op
+    }
+
+    // Recursively process children, rebuilding this node only if one of them actually changed.
+    // Injectors preserve children, so `injectedOp` has the same child list as `op` either way.
+    // The builder is created on the first changed child, so unchanged nodes allocate nothing.
+    val children = injectedOp.getChildrenList
+    val numChildren = children.size()
+    var builder: Operator.Builder = null
+    var i = 0
+    while (i < numChildren) {
+      val child = children.get(i)
+      val injectedChild = injectPlanData(child, commonByKey, partitionByKey)
+      if (injectedChild ne child) {
+        if (builder == null) {
+          builder = injectedOp.toBuilder
+        }
+        builder.setChildren(i, injectedChild)
+      }
+      i += 1
     }
-
-    builder.build()
+    if (builder == null) injectedOp else builder.build()
   }
 
   def serializeOperator(op: Operator): Array[Byte] = {
diff --git a/spark/src/test/scala/org/apache/spark/sql/comet/PlanDataInjectorSuite.scala b/spark/src/test/scala/org/apache/spark/sql/comet/PlanDataInjectorSuite.scala
index c8fed67cb2..91478b2349 100644
--- a/spark/src/test/scala/org/apache/spark/sql/comet/PlanDataInjectorSuite.scala
+++ b/spark/src/test/scala/org/apache/spark/sql/comet/PlanDataInjectorSuite.scala
@@ -72,7 +72,47 @@ class PlanDataInjectorSuite extends AnyFunSuite {
 
     val result = PlanDataInjector.injectPlanData(root, Map.empty, Map.empty)
 
-    assert(result == root, "non-scan operator tree should be returned unchanged")
+    assert(
+      result eq root,
+      "a tree with nothing to inject should be returned by reference, not rebuilt")
+  }
+
+  test("injectPlanData rebuilds only the path to the injected scan") {
+    // Operators are immutable protobuf messages, so subtrees that need no injection are shared.
+    val scanOp = icebergScanOp("s3://table/metadata/v1.json", scanHashCode = 111)
+    val (commonBytes, partitionBytes) =
+      icebergPlanData(
+        "s3://table/metadata/v1.json",
+        scanHashCode = 111,
+        columnNames = Seq("id", "v"),
+        dataFilePath = "data.parquet")
+    val key = IcebergPlanDataInjector.getKey(scanOp).get
+
+    val filter = Operator.newBuilder().setPlanId(2).addChildren(scanOp).build()
+    val untouchedSibling = Operator
+      .newBuilder()
+      .setPlanId(3)
+      .addChildren(Operator.newBuilder().setPlanId(4).build())
+      .build()
+    val root = Operator
+      .newBuilder()
+      .setPlanId(1)
+      .addChildren(filter)
+      .addChildren(untouchedSibling)
+      .build()
+
+    val result =
+      PlanDataInjector.injectPlanData(root, Map(key -> commonBytes), Map(key -> partitionBytes))
+
+    assert(
+      result.getChildren(1) eq untouchedSibling,
+      "a sibling subtree with no injectable scan should be shared, not rebuilt")
+    val injectedScan = result.getChildren(0).getChildren(0)
+    assert(injectedScan.getIcebergScan.getCommon.getRequiredSchemaCount == 2)
+    assert(injectedScan.getIcebergScan.getFileScanTasks(0).getDataFilePath == "data.parquet")
+    // Everything outside the injected scan is preserved verbatim.
+    assert(result.getPlanId == 1)
+    assert(result.getChildren(0).getPlanId == 2)
   }
 
   test("each registered injector is reachable by its opStructCase") {


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org