You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@spark.apache.org by ru...@apache.org on 2026/07/21 07:47:47 UTC

(spark) branch branch-4.2 updated: [SPARK-57521][ML][CONNECT] Use parentless copy in Model.estimatedSize to fix ML cache overcounting

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

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


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new 5de73b2dfc3d [SPARK-57521][ML][CONNECT] Use parentless copy in Model.estimatedSize to fix ML cache overcounting
5de73b2dfc3d is described below

commit 5de73b2dfc3d64e8d606acf334275616d9ec86fb
Author: Michael Kincaid <66...@users.noreply.github.com>
AuthorDate: Tue Jul 21 15:46:46 2026 +0800

    [SPARK-57521][ML][CONNECT] Use parentless copy in Model.estimatedSize to fix ML cache overcounting
    
    ### What changes were proposed in this pull request?
    This patch unsets `parent` before calling the `SizeEstimator`.
    
    ### Why are the changes needed?
    Currently `SizeEstimator` includes the size of the `SparkSession` because it traverses the `parent` object which (in the case of many estimators that use DataFrame operations when fitting, like `StringIndexer`) eventually refers to the session. The session is there anyway and its size isn't attributable to fitting this specific model (and this results in double-counting when more models are fit), so it shouldn't be included in the size estimate.
    
    The impact of the bug is largest when the `SparkSession` is large. For example, in Databricks, my testing shows that a 300-800M SparkSession is typical. In some configurations, like Databricks serverless, the size limit for a single model object might be 256M, so this bug causes such models to fail to train regardless of the state of the cache otherwise.
    
    The Jira ticket includes a simple script that reproduces the condition locally, though the session is much smaller in that case (maybe 300k).
    
    ### Does this PR introduce _any_ user-facing change?
    Yes, a favorable one, in that the model cache would fill less quickly (and the reported sizes of cached models would be smaller, if they are among the affected models).
    
    ### How was this patch tested?
    A test is added: training a `StringIndexer` should estimate at no larger than 50k, in the trivial test case with 3 strings. This test fails before the patch and passes after it. Another similar test is provided for `MinMaxScaler`. A `ModelSuite` is added to hold these since the bug is at the `Model` level, not that of individual models (so the `StringIndexer` and `MinMaxScaler` suites aren't really the right place for these tests, although they are examples).
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Yes, the bug was discovered and initial patch/tests were created by pair programming with Claude. I wrote the bug/docs myself and validated the approach and final patch.
    
    Generated-by: Claude Opus 4.6
    
    Closes #56584 from mkincaid/fix/ml-cache-size-estimator-parent.
    
    Authored-by: Michael Kincaid <66...@users.noreply.github.com>
    Signed-off-by: Ruifeng Zheng <ru...@apache.org>
    (cherry picked from commit cd57e156ab18b0f905d44e5026e0d4f9bb404d3d)
    Signed-off-by: Ruifeng Zheng <ru...@apache.org>
---
 .../src/main/scala/org/apache/spark/ml/Model.scala |  11 ++-
 .../scala/org/apache/spark/ml/ModelSuite.scala     | 105 +++++++++++++++++++++
 2 files changed, 115 insertions(+), 1 deletion(-)

diff --git a/mllib/src/main/scala/org/apache/spark/ml/Model.scala b/mllib/src/main/scala/org/apache/spark/ml/Model.scala
index 6321e5f88f74..11c369997850 100644
--- a/mllib/src/main/scala/org/apache/spark/ml/Model.scala
+++ b/mllib/src/main/scala/org/apache/spark/ml/Model.scala
@@ -57,5 +57,14 @@ abstract class Model[M <: Model[M]] extends Transformer { self =>
    * 4, For 3-rd extension, if external languages are used, it is recommended to override
    * this method and return a proper size.
    */
-  private[spark] def estimatedSize: Long = SizeEstimator.estimate(self)
+  private[spark] def estimatedSize: Long = {
+    // SPARK-57521: Estimate size using a parentless copy to avoid traversing shared
+    // infrastructure (SparkSession, loggers, thread pools) reachable through the parent
+    // estimator. The copy shares all internal data references (coefficients, labels, etc.)
+    // so the estimate reflects the model's actual learned state. Only the parent reference
+    // is removed — on a freshly-created, method-local object that no other thread can observe.
+    val copy = this.copy(ParamMap.empty).asInstanceOf[Model[M]]
+    copy.parent = null
+    SizeEstimator.estimate(copy)
+  }
 }
diff --git a/mllib/src/test/scala/org/apache/spark/ml/ModelSuite.scala b/mllib/src/test/scala/org/apache/spark/ml/ModelSuite.scala
new file mode 100644
index 000000000000..dff8f0119648
--- /dev/null
+++ b/mllib/src/test/scala/org/apache/spark/ml/ModelSuite.scala
@@ -0,0 +1,105 @@
+/*
+ * 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.ml
+
+import org.apache.spark.SparkFunSuite
+import org.apache.spark.ml.feature.{MinMaxScaler, StringIndexer, Word2Vec}
+import org.apache.spark.ml.linalg.Vectors
+import org.apache.spark.mllib.util.MLlibTestSparkContext
+
+class ModelSuite extends SparkFunSuite with MLlibTestSparkContext {
+
+  test("SPARK-57521: estimatedSize should not include parent's reachable object graph") {
+    val df = spark.createDataFrame(Seq(
+      Tuple1("a"), Tuple1("b"), Tuple1("c")
+    )).toDF("label")
+
+    val model = new StringIndexer()
+      .setInputCol("label").setOutputCol("idx")
+      .fit(df)
+
+    assert(model.hasParent, "model should have parent after fit()")
+    val size = model.estimatedSize
+
+    // Model data is 3 string labels + overhead, well under 50KB.
+    // Without the fix, SizeEstimator traverses model.parent -> estimator ->
+    // SparkSession, counting hundreds of KB (local) to hundreds of MB
+    // (production cluster) of shared session state per model.
+    assert(size < 50 * 1024,
+      s"estimatedSize ($size bytes) should reflect model data only, " +
+      s"not the parent estimator's reachable object graph (SparkSession)")
+
+    assert(model.hasParent, "parent should be preserved after estimatedSize call")
+  }
+
+  test("SPARK-57521: estimatedSize excludes parent for multiple estimator types") {
+    // The issue affects all estimators that execute DataFrame operations during fit().
+    // Test with a second estimator type to confirm the fix is not estimator-specific.
+    val df = spark.createDataFrame(Seq(
+      Tuple1(Vectors.dense(1.0, 2.0)),
+      Tuple1(Vectors.dense(3.0, 4.0))
+    )).toDF("features")
+
+    val model = new MinMaxScaler()
+      .setInputCol("features").setOutputCol("scaled")
+      .fit(df)
+
+    assert(model.hasParent)
+    val size = model.estimatedSize
+
+    assert(size < 50 * 1024,
+      s"MinMaxScalerModel estimatedSize ($size bytes) should not include " +
+      s"parent's reachable object graph")
+    assert(model.hasParent, "parent should be preserved after estimatedSize call")
+  }
+
+  test("SPARK-57521: Word2Vec estimatedSize grows with vocabulary size") {
+    // Word2VecModel stores learned vectors in a @transient field. The fix must not
+    // exclude transient fields wholesale — only the parent estimator's reference to shared
+    // infrastructure should be excluded. This test ensures Word2Vec's learned state
+    // is properly reflected in estimatedSize.
+    val smallData = spark.createDataFrame(Seq(
+      Tuple1(Array("a", "b")),
+      Tuple1(Array("b", "c"))
+    )).toDF("text")
+
+    val largeData = spark.createDataFrame(Seq(
+      Tuple1(Array("a", "b", "c", "d", "e", "f", "g", "h", "i", "j")),
+      Tuple1(Array("k", "l", "m", "n", "o", "p", "q", "r", "s", "t"))
+    )).toDF("text")
+
+    val w2v = new Word2Vec()
+      .setInputCol("text").setOutputCol("result")
+      .setVectorSize(10).setMinCount(0)
+
+    val smallModel = w2v.fit(smallData)
+    val largeModel = w2v.fit(largeData)
+
+    val smallSize = smallModel.estimatedSize
+    val largeSize = largeModel.estimatedSize
+
+    // Larger vocabulary should produce a measurably larger model
+    assert(largeSize > smallSize,
+      s"Word2Vec estimatedSize should grow with vocabulary: " +
+      s"small=$smallSize, large=$largeSize")
+
+    // Both should still be reasonable (not inflated by SparkSession)
+    assert(smallSize < 100 * 1024,
+      s"Small Word2Vec model ($smallSize bytes) should not include SparkSession state")
+  }
+}


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