dongjoon-hyun commented on code in PR #58278:
URL: https://github.com/apache/spark/pull/58278#discussion_r3855707689


##########
mllib/src/main/scala/org/apache/spark/ml/feature/CountVectorizer.scala:
##########
@@ -294,13 +294,14 @@ 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[OpenHashMap[String, Int]]] = 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)
+      // Reserve 0 for missing terms, which avoids allocating an Option for 
every lookup.
+      val dict = Utils.toOpenHashMapWithIndex(vocabulary, indexOffset = 1)
       broadcastDict = Some(dataset.sparkSession.sparkContext.broadcast(dict))

Review Comment:
   This changes the broadcast value from `scala.collection.immutable.Map` 
(registered by chill's `AllScalaRegistrar`) to `OpenHashMap`, which isn't 
registered in `KryoSerializer` (only `BitSet` from this package is). Broadcast 
values are serialized with the configured `spark.serializer`, so with 
`spark.serializer=KryoSerializer` and `spark.kryo.registrationRequired=true`, 
`transform` now fails with `IllegalArgumentException: Class is not registered: 
org.apache.spark.util.collection.OpenHashMap$mcI$sp` where it previously worked.
   
   Could we either register `OpenHashMap`/`OpenHashSet`/`Hasher` (including the 
specialized variants) in `KryoSerializer`, or keep the wire format entries-only 
(e.g. broadcast the `vocabulary` array and build the map lazily per executor)?



##########
mllib/src/main/scala/org/apache/spark/ml/feature/CountVectorizer.scala:
##########
@@ -310,9 +311,9 @@ class CountVectorizerModel(
       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 encodedIndex = dictBr.value(term)

Review Comment:
   `dictBr.value` is called once per token, and `TorrentBroadcast.getValue()` 
is `synchronized` with a `Reference` dereference on every call - a 1,000-token 
document does 1,001 synchronized calls per row (including 
`Vectors.sparse(dictBr.value.size, ...)` below). That per-token cost is 
plausibly comparable to the `Option` allocation this PR eliminates.
   
   Hoisting `val dict = dictBr.value` (and the size) into a local at the top of 
the udf lambda, before the loop, keeps the win this PR is after.



##########
mllib/src/main/scala/org/apache/spark/ml/feature/CountVectorizer.scala:
##########
@@ -310,9 +311,9 @@ class CountVectorizerModel(
       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 encodedIndex = dictBr.value(term)
+        if (encodedIndex != 0) {

Review Comment:
   This makes `OpenHashMap.apply`'s missing-key-returns-0 behavior load-bearing 
across a module boundary - the exact caveat `OpenHashMap`'s class doc warns 
about - with the `+1` encode in `Utils` and the `!= 0` / `- 1` decode here.
   
   An Option-free primitive on `OpenHashMap` itself (e.g. a `getOrElse(k, 
default)` doing a single specialized probe) would give the same performance 
without the sentinel arithmetic, and would also serve 
`StringIndexerModel.transform`, which has the same per-token 
`get(...).getOrElse(...)` pattern but needs a real default index - so the `+1` 
trick doesn't generalize to it.



##########
mllib/src/main/scala/org/apache/spark/ml/feature/CountVectorizer.scala:
##########
@@ -294,13 +294,14 @@ 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[OpenHashMap[String, Int]]] = None

Review Comment:
   Worth noting: `OpenHashMap` Java-serializes its full power-of-2 capacity 
arrays (`_data`, `_values`, `BitSet`) - 1.4x-2.9x the entry count in slots - 
while the immutable `Map` serialized entries only, so the broadcast payload and 
per-executor footprint grow. The microbenchmark covers lookup cost but not this 
side.
   
   Broadcasting the compact `vocabulary` array and building the map lazily once 
per executor would keep the wire format entries-only (and would also sidestep 
the Kryo registration issue above).



##########
core/src/test/scala/org/apache/spark/util/collection/OpenHashMapSuite.scala:
##########
@@ -27,6 +27,14 @@ import org.apache.spark.util.SizeEstimator
 
 class OpenHashMapSuite extends SparkFunSuite with Matchers {
 
+  test("build with index") {

Review Comment:
   This exercises `Utils.toOpenHashMapWithIndex` rather than `OpenHashMap` 
itself, and only the `indexOffset = 1` path - the default offset, empty array, 
and duplicate-key (last index wins) cases are untested, while the sibling 
`toMapWithIndex` covers all of these in `SparkCollectionUtilsSuite`. Could we 
mirror those cases here (or in a `Utils`-focused suite)?



##########
core/src/main/scala/org/apache/spark/util/collection/Utils.scala:
##########
@@ -31,6 +32,24 @@ import org.apache.spark.util.SparkCollectionUtils
  */
 private[spark] object Utils extends SparkCollectionUtils {
 
+  /**
+   * Same function as `keys.zipWithIndex.toMap`, but uses an [[OpenHashMap]] 
and allows the first
+   * index to be specified.
+  */
+  def toOpenHashMapWithIndex[K: ClassTag](
+      keys: Array[K],
+      indexOffset: Int = 0): OpenHashMap[K, Int] = {
+    val map = new OpenHashMap[K, Int](keys.length)

Review Comment:
   Passing `keys.length` doesn't actually reserve room for `keys.length` 
entries: `OpenHashSet` sets capacity to `nextPowerOf2(initialCapacity)` with a 
0.7 grow threshold, so whenever `keys.length > 0.7 * nextPowerOf2(keys.length)` 
- every power-of-2 size, including `CountVectorizer`'s default `vocabSize = 1 
<< 18` - the build loop still triggers a full mid-build rehash of everything 
inserted so far. Sizing by the load factor, e.g. `(keys.length / 0.7).toInt + 
1`, removes the rehash and ends at the same final capacity.
   
   Also note `OpenHashSet` caps capacity at `1 << 30` (~751M entries 
effective), a limit the old immutable `Map` didn't have - theoretical for a 
vocabulary, but a behavior difference vs `toMapWithIndex`.



##########
core/src/main/scala/org/apache/spark/util/collection/Utils.scala:
##########
@@ -31,6 +32,24 @@ import org.apache.spark.util.SparkCollectionUtils
  */
 private[spark] object Utils extends SparkCollectionUtils {
 
+  /**
+   * Same function as `keys.zipWithIndex.toMap`, but uses an [[OpenHashMap]] 
and allows the first
+   * index to be specified.
+  */
+  def toOpenHashMapWithIndex[K: ClassTag](
+      keys: Array[K],
+      indexOffset: Int = 0): OpenHashMap[K, Int] = {

Review Comment:
   The default `indexOffset = 0` conflicts with the missing-key sentinel the 
only caller relies on: `apply` returns 0 for absent keys, so a future caller 
using the default and copying the `if (map(k) != 0)` pattern would silently 
treat the first key as absent. Nothing exercises the default today (the only 
caller and the only test both pass 1).
   
   Since the scaladoc doesn't mention the offset or the reserve-0 contract (and 
the "Same function as `keys.zipWithIndex.toMap`" sentence is only accurate for 
offset 0), could we document that contract here - or drop the default so 
callers must choose? (Nit: the closing `*/` is indented one space short.)



##########
core/src/main/scala/org/apache/spark/util/collection/Utils.scala:
##########
@@ -31,6 +32,24 @@ import org.apache.spark.util.SparkCollectionUtils
  */
 private[spark] object Utils extends SparkCollectionUtils {
 
+  /**
+   * Same function as `keys.zipWithIndex.toMap`, but uses an [[OpenHashMap]] 
and allows the first
+   * index to be specified.
+  */
+  def toOpenHashMapWithIndex[K: ClassTag](

Review Comment:
   Placement/reuse: the map-builder helpers (`toMap`, `toJavaMap`, plus the 
inherited `toMapWithIndex`) are grouped at the bottom of this file, so this 
method probably belongs next to them rather than at the top.
   
   Also, `StringIndexerModel.transform` and `ALS.makeBlocks` already hand-roll 
this exact loop (`new OpenHashMap(xs.length)` + positional `update`); migrating 
them to this helper would give it (and the offset-0 default) real users and 
keep one implementation to fix when e.g. the pre-sizing changes.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to