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