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

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


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new eccb8faffcbd [SPARK-58993][ML] Use Java HashMap for CountVectorizer 
vocabulary lookup
eccb8faffcbd is described below

commit eccb8faffcbd25cc8b4dfcac8a7b3ca215c0583b
Author: Ruifeng Zheng <[email protected]>
AuthorDate: Wed Aug 26 16:25:22 2026 +0800

    [SPARK-58993][ML] Use Java HashMap for CountVectorizer vocabulary lookup
    
    ### What changes were proposed in this pull request?
    
    Replace the immutable Scala `Map[String, Int]` used by 
`CountVectorizerModel` for vocabulary lookup with a pre-sized 
`java.util.HashMap[String, Integer]`.
    
    Build and broadcast the dictionary once, then dereference the broadcast 
value and its size once per document instead of once per token. Register 
`java.util.HashMap` with Spark Kryo so this remains compatible with 
`spark.kryo.registrationRequired=true`.
    
    ### Why are the changes needed?
    
    `CountVectorizerModel.transform` performs one vocabulary lookup per input 
token. A local Java 17 benchmark with 262,144 String keys, 2 million lookups, 
and a 90% hit rate measured:
    
    - Scala immutable Map: 69.5 ns/lookup
    - Spark OpenHashMap: 38.3 ns/lookup
    - java.util.HashMap: 20.7 ns/lookup
    
    For the same vocabulary, Java HashMap used about 13% less deserialized heap 
than the immutable map, produced a 26% smaller JavaSerializer + LZ4 broadcast 
payload, and deserialized about 47% faster. OpenHashMap used less heap and 
deserialized faster than Java HashMap, but Java HashMap provides the best 
tradeoff for this lookup-heavy workload.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    Added strict-Kryo coverage for `java.util.HashMap` and ran:
    
    - `build/sbt "core/testOnly *KryoSerializerSuite -- -z \"basic types\""`
    - `build/sbt "mllib/testOnly *CountVectorizerSuite"`
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: OpenAI Codex (GPT-5)
    
    Closes #58278 from zhengruifeng/ml-countvectorizer-openhashmap-dev-4.
    
    Authored-by: Ruifeng Zheng <[email protected]>
    Signed-off-by: Ruifeng Zheng <[email protected]>
    (cherry picked from commit ed8dbf4c8858ba542985a8ccbcac0a74282a7fc8)
    Signed-off-by: Ruifeng Zheng <[email protected]>
---
 .../apache/spark/serializer/KryoSerializer.scala   |  3 ++-
 .../spark/serializer/KryoSerializerSuite.scala     |  3 +++
 .../apache/spark/ml/feature/CountVectorizer.scala  | 22 +++++++++++++++-------
 3 files changed, 20 insertions(+), 8 deletions(-)

diff --git 
a/core/src/main/scala/org/apache/spark/serializer/KryoSerializer.scala 
b/core/src/main/scala/org/apache/spark/serializer/KryoSerializer.scala
index 65f0ffcbe18e..9b120d2f5bdb 100644
--- a/core/src/main/scala/org/apache/spark/serializer/KryoSerializer.scala
+++ b/core/src/main/scala/org/apache/spark/serializer/KryoSerializer.scala
@@ -555,7 +555,8 @@ private[serializer] object KryoSerializer {
     classOf[SparkConf],
     classOf[TaskCommitMessage],
     classOf[SerializedLambda],
-    classOf[BitSet]
+    classOf[BitSet],
+    classOf[java.util.HashMap[_, _]]
   )
 
   private val toRegisterSerializer = Map[Class[_], KryoClassSerializer[_]](
diff --git 
a/core/src/test/scala/org/apache/spark/serializer/KryoSerializerSuite.scala 
b/core/src/test/scala/org/apache/spark/serializer/KryoSerializerSuite.scala
index 236890023364..f963d2a815e7 100644
--- a/core/src/test/scala/org/apache/spark/serializer/KryoSerializerSuite.scala
+++ b/core/src/test/scala/org/apache/spark/serializer/KryoSerializerSuite.scala
@@ -117,6 +117,9 @@ class KryoSerializerSuite extends SparkFunSuite with 
SharedSparkContext {
     check(Array.empty[Int])
     check(Array(Array("1", "2"), Array("1", "2", "3", "4")))
     check(Array(Array(1.toByte)))
+    val javaMap = new java.util.HashMap[String, Int]()
+    javaMap.put("one", 1)
+    check(javaMap)
   }
 
   test("pairs") {
diff --git 
a/mllib/src/main/scala/org/apache/spark/ml/feature/CountVectorizer.scala 
b/mllib/src/main/scala/org/apache/spark/ml/feature/CountVectorizer.scala
index 0c4a42c0fd57..a0da92fa7d7e 100644
--- a/mllib/src/main/scala/org/apache/spark/ml/feature/CountVectorizer.scala
+++ b/mllib/src/main/scala/org/apache/spark/ml/feature/CountVectorizer.scala
@@ -17,6 +17,7 @@
 package org.apache.spark.ml.feature
 
 import java.io.{DataInputStream, DataOutputStream}
+import java.util.{HashMap => JHashMap}
 
 import org.apache.hadoop.fs.Path
 
@@ -33,7 +34,7 @@ import org.apache.spark.sql.functions._
 import org.apache.spark.sql.types._
 import org.apache.spark.util.ArrayImplicits._
 import org.apache.spark.util.SizeEstimator
-import org.apache.spark.util.collection.{OpenHashMap, Utils}
+import org.apache.spark.util.collection.OpenHashMap
 
 /**
  * Params for [[CountVectorizer]] and [[CountVectorizerModel]].
@@ -294,25 +295,32 @@ class CountVectorizerModel(
   def setBinary(value: Boolean): this.type = set(binary, value)
 
   /** Dictionary created from [[vocabulary]] and its indices, broadcast once 
for [[transform()]] */
-  private var broadcastDict: Option[Broadcast[Map[String, Int]]] = None
+  private var broadcastDict: Option[Broadcast[JHashMap[String, Integer]]] = 
None
 
   @Since("2.0.0")
   override def transform(dataset: Dataset[_]): DataFrame = {
     val outputSchema = transformSchema(dataset.schema, logging = true)
     if (broadcastDict.isEmpty) {
-      val dict = Utils.toMapWithIndex(vocabulary)
+      val dict = new JHashMap[String, Integer](math.ceil(vocabulary.length / 
0.75).toInt)
+      var index = 0
+      while (index < vocabulary.length) {
+        dict.put(vocabulary(index), index)
+        index += 1
+      }
       broadcastDict = Some(dataset.sparkSession.sparkContext.broadcast(dict))
     }
     val dictBr = broadcastDict.get
     val localMinTF = $(minTF)
     val localBinary = $(binary)
     val vectorizer = udf { document: Seq[String] =>
+      val dict = dictBr.value
+      val dictSize = dict.size()
       val termCounts = new OpenHashMap[Int, Int]
       var tokenCount = 0L
       document.foreach { term =>
-        dictBr.value.get(term) match {
-          case Some(index) => termCounts.changeValue(index, 1, _ + 1)
-          case None => // ignore terms not in the vocabulary
+        val index = dict.get(term)
+        if (index != null) {
+          termCounts.changeValue(index.intValue(), 1, _ + 1)
         }
         tokenCount += 1
       }
@@ -323,7 +331,7 @@ class CountVectorizerModel(
         termCounts.filter(_._2 >= effectiveMinTF).map(p => (p._1, 
p._2.toDouble)).toSeq
       }
 
-      Vectors.sparse(dictBr.value.size, effectiveCounts)
+      Vectors.sparse(dictSize, effectiveCounts)
     }
     dataset.withColumn($(outputCol), vectorizer(col($(inputCol))),
       outputSchema($(outputCol)).metadata)


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

Reply via email to