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 cd57e156ab18 [SPARK-57521][ML][CONNECT] Use parentless copy in
Model.estimatedSize to fix ML cache overcounting
cd57e156ab18 is described below
commit cd57e156ab18b0f905d44e5026e0d4f9bb404d3d
Author: Michael Kincaid <[email protected]>
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 <[email protected]>
Signed-off-by: Ruifeng Zheng <[email protected]>
---
.../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: [email protected]
For additional commands, e-mail: [email protected]