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

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


The following commit(s) were added to refs/heads/master by this push:
     new bc9ee80d0de0 [SPARK-58856][ML] Avoid per-leaf map for tree predictions
bc9ee80d0de0 is described below

commit bc9ee80d0de08f4cca1ac8518e4b6bee64cc544d
Author: Ruifeng Zheng <[email protected]>
AuthorDate: Fri Aug 21 12:58:58 2026 +0800

    [SPARK-58856][ML] Avoid per-leaf map for tree predictions
    
    ### What changes were proposed in this pull request?
    
    Replace the transient `Map[LeafNode, Int]` used by tree leaf prediction 
with a normal integer
    field on each `LeafNode`. Tree-model construction assigns the same 
left-to-right DFS indices
    after training and loading, so prediction only traverses the tree and reads 
the reached leaf's
    stored ID. The existing `NodeData` format deliberately omits this field, 
while broadcast
    serialization retains it. The previous `leafIndices` map was not counted in 
model size
    estimation; after this PR, leaf-index storage is counted as the new 
`LeafNode` field.
    
    ### Why are the changes needed?
    
    Large trees can have many leaves. When leaf prediction is used, the map 
creates a hash-table
    entry and boxed value for every leaf in each executor's model copy. A 
single integer per leaf
    keeps the lookup constant-time while substantially reducing the memory 
overhead and avoids
    building an executor-side lookup cache. Since the leaf-index storage is now 
part of the tree
    nodes, model size estimation also accounts for it more accurately.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. Leaf IDs and their traversal order are unchanged.
    
    ### How was this patch tested?
    
    Added persistence assertions that verify classifier and regressor leaf IDs 
are unchanged after
    loading. Tests have not been run yet; the relevant ML suites will be run 
before this draft is
    marked ready for review.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Codex (GPT-5)
    
    Closes #57389 from zhengruifeng/optimize-leaf-prediction-memory-dev3.
    
    Authored-by: Ruifeng Zheng <[email protected]>
    Signed-off-by: Ruifeng Zheng <[email protected]>
---
 .../ml/classification/DecisionTreeClassifier.scala |  2 +-
 .../ml/regression/DecisionTreeRegressor.scala      |  2 +-
 .../main/scala/org/apache/spark/ml/tree/Node.scala | 22 +++++++++++++++++++++-
 .../apache/spark/ml/tree/impl/RandomForest.scala   | 13 +++++++++----
 .../org/apache/spark/ml/tree/treeModels.scala      | 10 ++++------
 .../DecisionTreeClassifierSuite.scala              |  2 ++
 .../ml/regression/DecisionTreeRegressorSuite.scala |  2 ++
 .../org/apache/spark/ml/tree/impl/TreeTests.scala  | 10 ++++++----
 8 files changed, 46 insertions(+), 17 deletions(-)

diff --git 
a/mllib/src/main/scala/org/apache/spark/ml/classification/DecisionTreeClassifier.scala
 
b/mllib/src/main/scala/org/apache/spark/ml/classification/DecisionTreeClassifier.scala
index 11d43d1b31fb..78b747f65f2f 100644
--- 
a/mllib/src/main/scala/org/apache/spark/ml/classification/DecisionTreeClassifier.scala
+++ 
b/mllib/src/main/scala/org/apache/spark/ml/classification/DecisionTreeClassifier.scala
@@ -343,7 +343,7 @@ object DecisionTreeClassificationModel extends 
MLReadable[DecisionTreeClassifica
     require(oldModel.algo == OldAlgo.Classification,
       s"Cannot convert non-classification DecisionTreeModel (old API) to" +
         s" DecisionTreeClassificationModel (new API).  Algo is: 
${oldModel.algo}")
-    val rootNode = Node.fromOld(oldModel.topNode, categoricalFeatures)
+    val rootNode = Node.withLeafIndices(Node.fromOld(oldModel.topNode, 
categoricalFeatures))
     val uid = if (parent != null) parent.uid else Identifiable.randomUID("dtc")
     // Can't infer number of features from old model, so default to -1
     new DecisionTreeClassificationModel(uid, rootNode, numFeatures, -1)
diff --git 
a/mllib/src/main/scala/org/apache/spark/ml/regression/DecisionTreeRegressor.scala
 
b/mllib/src/main/scala/org/apache/spark/ml/regression/DecisionTreeRegressor.scala
index 4a5c3b579035..dc2377166e17 100644
--- 
a/mllib/src/main/scala/org/apache/spark/ml/regression/DecisionTreeRegressor.scala
+++ 
b/mllib/src/main/scala/org/apache/spark/ml/regression/DecisionTreeRegressor.scala
@@ -345,7 +345,7 @@ object DecisionTreeRegressionModel extends 
MLReadable[DecisionTreeRegressionMode
     require(oldModel.algo == OldAlgo.Regression,
       s"Cannot convert non-regression DecisionTreeModel (old API) to" +
         s" DecisionTreeRegressionModel (new API).  Algo is: ${oldModel.algo}")
-    val rootNode = Node.fromOld(oldModel.topNode, categoricalFeatures)
+    val rootNode = Node.withLeafIndices(Node.fromOld(oldModel.topNode, 
categoricalFeatures))
     val uid = if (parent != null) parent.uid else Identifiable.randomUID("dtr")
     new DecisionTreeRegressionModel(uid, rootNode, numFeatures)
   }
diff --git a/mllib/src/main/scala/org/apache/spark/ml/tree/Node.scala 
b/mllib/src/main/scala/org/apache/spark/ml/tree/Node.scala
index 0a7e37aa0c9a..72da973b5c0d 100644
--- a/mllib/src/main/scala/org/apache/spark/ml/tree/Node.scala
+++ b/mllib/src/main/scala/org/apache/spark/ml/tree/Node.scala
@@ -135,6 +135,25 @@ private[ml] object Node {
   val dummyNode: Node = {
     new LeafNode(0.0, 0.0, ImpurityCalculator.getCalculator("gini", 
Array.empty, 0))
   }
+
+  /** Return a copy of the tree with left-to-right DFS indices assigned to 
leaves. */
+  private[ml] def withLeafIndices(rootNode: Node): Node = {
+    withLeafIndices(rootNode, 0)._1
+  }
+
+  private def withLeafIndices(node: Node, nextLeafIndex: Int): (Node, Int) = {
+    node match {
+      case n: LeafNode =>
+        (new LeafNode(n.prediction, n.impurity, n.impurityStats, 
nextLeafIndex),
+          nextLeafIndex + 1)
+      case n: InternalNode =>
+        val (leftChild, nextIndex) = withLeafIndices(n.leftChild, 
nextLeafIndex)
+        val (rightChild, endIndex) = withLeafIndices(n.rightChild, nextIndex)
+        val newNode = new InternalNode(n.prediction, n.impurity, n.gain, 
leftChild, rightChild,
+          n.split, n.impurityStats)
+        (newNode, endIndex)
+    }
+  }
 }
 
 /**
@@ -145,7 +164,8 @@ private[ml] object Node {
 class LeafNode private[ml] (
     override val prediction: Double,
     override val impurity: Double,
-    override private[ml] val impurityStats: ImpurityCalculator) extends Node {
+    override private[ml] val impurityStats: ImpurityCalculator,
+    private[tree] val leafIndex: Int = -1) extends Node {
 
   override def toString: String =
     s"LeafNode(prediction = $prediction, impurity = $impurity)"
diff --git 
a/mllib/src/main/scala/org/apache/spark/ml/tree/impl/RandomForest.scala 
b/mllib/src/main/scala/org/apache/spark/ml/tree/impl/RandomForest.scala
index b3ca7f04c3de..3e4c42afa7f2 100644
--- a/mllib/src/main/scala/org/apache/spark/ml/tree/impl/RandomForest.scala
+++ b/mllib/src/main/scala/org/apache/spark/ml/tree/impl/RandomForest.scala
@@ -269,26 +269,31 @@ private[spark] object RandomForest extends Logging with 
Serializable {
           topNodes.map { rootNode =>
             new DecisionTreeClassificationModel(
               uid,
-              rootNode.toNode(strategy.pruneTree),
+              Node.withLeafIndices(rootNode.toNode(strategy.pruneTree)),
               numFeatures,
               strategy.getNumClasses())
           }
         } else {
           topNodes.map { rootNode =>
-            new DecisionTreeRegressionModel(uid, 
rootNode.toNode(strategy.pruneTree), numFeatures)
+            new DecisionTreeRegressionModel(
+              uid,
+              Node.withLeafIndices(rootNode.toNode(strategy.pruneTree)),
+              numFeatures)
           }
         }
       case None =>
         if (strategy.algo == OldAlgo.Classification) {
           topNodes.map { rootNode =>
             new DecisionTreeClassificationModel(
-              rootNode.toNode(strategy.pruneTree),
+              Node.withLeafIndices(rootNode.toNode(strategy.pruneTree)),
               numFeatures,
               strategy.getNumClasses())
           }
         } else {
           topNodes.map(rootNode =>
-            new 
DecisionTreeRegressionModel(rootNode.toNode(strategy.pruneTree), numFeatures))
+            new DecisionTreeRegressionModel(
+              Node.withLeafIndices(rootNode.toNode(strategy.pruneTree)),
+              numFeatures))
         }
     }
   }
diff --git a/mllib/src/main/scala/org/apache/spark/ml/tree/treeModels.scala 
b/mllib/src/main/scala/org/apache/spark/ml/tree/treeModels.scala
index 1cc64e58d980..ca923a1f751f 100644
--- a/mllib/src/main/scala/org/apache/spark/ml/tree/treeModels.scala
+++ b/mllib/src/main/scala/org/apache/spark/ml/tree/treeModels.scala
@@ -101,16 +101,14 @@ private[spark] trait DecisionTreeModel {
     leafAttr.withName(leafCol).toStructField()
   }
 
-  @transient private lazy val leafIndices: Map[LeafNode, Int] = {
-    leafIterator(rootNode).zipWithIndex.toMap
-  }
-
   /**
    * @return The index of the leaf corresponding to the feature vector.
    *         Leaves are indexed in pre-order from 0.
    */
   def predictLeaf(features: Vector): Double = {
-    leafIndices(rootNode.predictImpl(features)).toDouble
+    val leaf = rootNode.predictImpl(features)
+    assert(leaf.leafIndex >= 0, "Leaf indices are not assigned.")
+    leaf.leafIndex.toDouble
   }
 
   def getEstimatedSize(): Long = {
@@ -508,7 +506,7 @@ private[ml] object DecisionTreeModelReadWrite {
       finalNodes(n.id) = node
     }
     // Return the root node
-    finalNodes.head
+    Node.withLeafIndices(finalNodes.head)
   }
 }
 
diff --git 
a/mllib/src/test/scala/org/apache/spark/ml/classification/DecisionTreeClassifierSuite.scala
 
b/mllib/src/test/scala/org/apache/spark/ml/classification/DecisionTreeClassifierSuite.scala
index d298e625880f..e248d0f51b6e 100644
--- 
a/mllib/src/test/scala/org/apache/spark/ml/classification/DecisionTreeClassifierSuite.scala
+++ 
b/mllib/src/test/scala/org/apache/spark/ml/classification/DecisionTreeClassifierSuite.scala
@@ -427,6 +427,8 @@ class DecisionTreeClassifierSuite extends MLTest with 
DefaultReadWriteTest {
       TreeTests.checkEqual(model, model2)
       assert(model.numFeatures === model2.numFeatures)
       assert(model.numClasses === model2.numClasses)
+      val features = Vectors.dense(0.0, 0.0)
+      assert(model.predictLeaf(features) === model2.predictLeaf(features))
     }
 
     val dt = new DecisionTreeClassifier()
diff --git 
a/mllib/src/test/scala/org/apache/spark/ml/regression/DecisionTreeRegressorSuite.scala
 
b/mllib/src/test/scala/org/apache/spark/ml/regression/DecisionTreeRegressorSuite.scala
index 679ef83ec762..7134afee2388 100644
--- 
a/mllib/src/test/scala/org/apache/spark/ml/regression/DecisionTreeRegressorSuite.scala
+++ 
b/mllib/src/test/scala/org/apache/spark/ml/regression/DecisionTreeRegressorSuite.scala
@@ -222,6 +222,8 @@ class DecisionTreeRegressorSuite extends MLTest with 
DefaultReadWriteTest {
         model2: DecisionTreeRegressionModel): Unit = {
       TreeTests.checkEqual(model, model2)
       assert(model.numFeatures === model2.numFeatures)
+      val features = Vectors.dense(0.0, 0.0)
+      assert(model.predictLeaf(features) === model2.predictLeaf(features))
     }
 
     val dt = new DecisionTreeRegressor()
diff --git a/mllib/src/test/scala/org/apache/spark/ml/tree/impl/TreeTests.scala 
b/mllib/src/test/scala/org/apache/spark/ml/tree/impl/TreeTests.scala
index afca7e993b46..9ad47963aeee 100644
--- a/mllib/src/test/scala/org/apache/spark/ml/tree/impl/TreeTests.scala
+++ b/mllib/src/test/scala/org/apache/spark/ml/tree/impl/TreeTests.scala
@@ -266,8 +266,9 @@ private[ml] object TreeTests extends SparkFunSuite {
     val leaf2 = new LeafNode(0.0, Double.NaN, null)
     val node1 = new InternalNode(0.0, Double.NaN, Double.NaN, leaf0, leaf1,
       new ContinuousSplit(0, 0.0), null)
-    new InternalNode(0.0, Double.NaN, Double.NaN, node1, leaf2,
-      new CategoricalSplit(1, Array(0.0, 2.0), 3), null)
+    Node.withLeafIndices(
+      new InternalNode(0.0, Double.NaN, Double.NaN, node1, leaf2,
+        new CategoricalSplit(1, Array(0.0, 2.0), 3), null))
   }
 
   /**
@@ -294,8 +295,9 @@ private[ml] object TreeTests extends SparkFunSuite {
     val leaf2 = new LeafNode(0.0, Double.NaN, null)
     val node1 = new InternalNode(0.0, Double.NaN, Double.NaN, leaf1, leaf2,
       new CategoricalSplit(1, Array(0.0, 1.0), 3), null)
-    new InternalNode(0.0, Double.NaN, Double.NaN, leaf0, node1,
-      new ContinuousSplit(2, 1.0), null)
+    Node.withLeafIndices(
+      new InternalNode(0.0, Double.NaN, Double.NaN, leaf0, node1,
+        new ContinuousSplit(2, 1.0), null))
   }
 
   /**


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

Reply via email to