Spark ALS recommendForAll源碼解析實戰之Spark1.x vs Spark2.x

Spark ALS recommendForAll源碼解析實戰

1. 軟件版本:

軟件 版本
Spark 1.6.3 、 2.2.2
Hadoop 2.6.5

2. 本文要解決的問題

  1. 分析Spark2.2.2中 ALS算法的recommendForAll函數的實現思路;
  1. 分析Spark1.6.3中 ALS算法的recommendForAll函數的實現思路;
  1. Spark2.2.2 和 Spark 1.6.3 關於 ALS的recommendForAll的實現的性能對比(參考Spark2.2.2. VS Spark1.6.3 之ALS 推薦性能對比);

3. 源碼分析實戰

源碼分析實戰採用源碼分析+實例演示的方式來闡明源碼的實現思路,下面分別給出Spark2.2.2以及Spark1.6.3的源碼中關於ALS算法的recommendForAll的實戰分析。

3.1 Spark2.2.2 ALS recommendForAll 實戰分析

1. 首先給出其核心實現源碼:

private def recommendForAll(
      rank: Int,
      srcFeatures: RDD[(Int, Array[Double])],
      dstFeatures: RDD[(Int, Array[Double])],
      num: Int): RDD[(Int, Array[(Int, Double)])] = {
    val srcBlocks = blockify(srcFeatures)
    val dstBlocks = blockify(dstFeatures)
    val ratings = srcBlocks.cartesian(dstBlocks).flatMap { case (srcIter, dstIter) =>
      val m = srcIter.size
      val n = math.min(dstIter.size, num)
      val output = new Array[(Int, (Int, Double))](m * n)
      var i = 0
      val pq = new BoundedPriorityQueue[(Int, Double)](n)(Ordering.by(_._2))
      srcIter.foreach { case (srcId, srcFactor) =>
        dstIter.foreach { case (dstId, dstFactor) =>
          // We use F2jBLAS which is faster than a call to native BLAS for vector dot product
          val score = BLAS.f2jBLAS.ddot(rank, srcFactor, 1, dstFactor, 1)
          pq += dstId -> score
        }
        pq.foreach { case (dstId, score) =>
          output(i) = (srcId, (dstId, score))
          i += 1
        }
        pq.clear()
      }
      output.toSeq
    }
    ratings.topByKey(num)(Ordering.by(_._2))
  }
private def blockify(
      features: RDD[(Int, Array[Double])],
      blockSize: Int = 4096): RDD[Seq[(Int, Array[Double])]] = {
    features.mapPartitions { iter =>
      iter.grouped(blockSize)
    }
  }

核心源碼包含兩個部分:

  • 一個是blockify子函數;
  • 一個是recommendForAll的核心實現;

ALS模型中包含的userFeatures和productFeatures作爲此函數的核心輸入,分別代表用戶向量和物品向量(關於其解釋,可以參考ALS算法原理)。

2. blockify函數

blockify函數就是把原RDD進行分塊處理,怎麼理解分塊呢?

假設有如下RDD,uf:

scala> val uf= sc.parallelize(List((1,Array(0.3,0.4,0.6,0.3,0.7)),(2,Array(0.13,0.14,0.16,0.13,0.17)),(3,Array(0.23,0.24,0.26,0.23,0.27)),(4,Array(0.83,0.84,0.86,0.83,0.87)),(5,Array(0.31,0.41,0.61,0.31,0.71)),(6,Array(0.213,0.214,0.216,0.213,0.217)),(7,Array(0.323,0.324,0.326,0.323,0.327)),(8,Array(0.283,0.284,0.286,0.283,0.287)),(9,Array(0.31,0.42,0.63,0.34,0.75)),(10,Array(0.131,0.141,0.161,0.131,0.171)),(11,Array(0.223,0.224,0.226,0.223,0.227)),(12,Array(0.813,0.814,0.816,0.813,0.817))  ))   
uf: org.apache.spark.rdd.RDD[(Int, Array[Double])] = ParallelCollectionRDD[11] at parallelize at <console>:27

那麼,使用塊大小爲5,來對uf進行blockify處理,如下:

scala> val blockSize = 5
blockSize: Int = 5

scala> val ufsrc = uf.mapPartitions { iter =>iter.grouped(blockSize)}
ufsrc: org.apache.spark.rdd.RDD[Seq[(Int, Array[Double])]] = MapPartitionsRDD[15] at mapPartitions at <console>:31

而blockify的核心就是針對RDD中的每個分區的數據執行grouped操作,grouped操作就是針對一個列表進行分組,如下:

scala> (0 to 10).grouped(5).toList
res3: List[scala.collection.immutable.IndexedSeq[Int]] = List(Vector(0, 1, 2, 3, 4), Vector(5, 6, 7, 8, 9), Vector(10))

scala> (0 to 10).grouped(6).toList
res4: List[scala.collection.immutable.IndexedSeq[Int]] = List(Vector(0, 1, 2, 3, 4, 5), Vector(6, 7, 8, 9, 10))

所以ufsrcRDD就會在每個分區中構建多條記錄,而每個記錄就是一個Seq數組,上面的uf數據應用blockify,其數據流如下:

image

下面對此圖進行驗證:

1)原始uf RDD數據有2個分區,並且其數據分別爲1~6 、 7~12;

scala> uf.glom().collect.foreach(x => println(x.mkString("|")))
(1,[D@673b5922)|(2,[D@5bda90af)|(3,[D@3962064d)|(4,[D@53d95e8a)|(5,[D@6e96ff9a)|(6,[D@61c6450f)
(7,[D@48bf7714)|(8,[D@518b5d87)|(9,[D@8381203)|(10,[D@5b85d036)|(11,[D@68311b85)|(12,[D@635d1461)

2)blockify後的RDD其分區數據每個元素爲一個Seq數組,如下:

scala> ufsrc.glom().collect.foreach(x => println(x.mkString("|")))
List((1,[D@299c20bd), (2,[D@256d5200), (3,[D@6e81084f), (4,[D@11a6e7d4), (5,[D@59f7a495))|List((6,[D@164500f9))
List((7,[D@7060b10e), (8,[D@565e7091), (9,[D@3269a5c3), (10,[D@c1539bf), (11,[D@790801f2))|List((12,[D@5c772cba))

下面構造的pf 如下:

scala> val pf = sc.parallelize(List((101,Array(0.33,0.34,0.36,0.33,0.37)),(201,Array(0.43,0.44,0.46,0.43,0.47)),(301,Array(0.53,0.54,0.56,0.53,0.57)),(401,Array(0.303,0.304,0.306,0.303,0.307)),(501,Array(0.403,0.404,0.406,0.403,0.407)),(601,Array(0.153,0.154,0.156,0.153,0.157)),(701,Array(0.523,0.524,0.526,0.523,0.527)),(801,Array(0.553,0.554,0.556,0.553,0.557)) ) )  
pf: org.apache.spark.rdd.RDD[(Int, Array[Double])] = ParallelCollectionRDD[12] at parallelize at <console>:27

scala> val pfdst = pf.mapPartitions { iter =>iter.grouped(blockSize)}
pfdst: org.apache.spark.rdd.RDD[Seq[(Int, Array[Double])]] = MapPartitionsRDD[14] at mapPartitions at <console>:31

思考: 其pfdst RDD的數據流情況。

3. cartesian flatMap的優勢

代碼接下來就是執行 cartesian flatMap,此處關於cartesian flatMap的實現其實有兩種方式:

  • 原始數據直接cartesian;

    其代碼如下:

    uf.cartesian(pf) 
    
  • 原始數據先blockify,然後cartesian;

下面,先看下這兩種方式的異同:

  1. cartesian的操作就是把兩個RDD的元素做一個全連接配對,舉個簡單例子:
scala> val a = sc.parallelize(List(1,2,3,4))
a: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[23] at parallelize at <console>:27

scala> val b = sc.parallelize(List(11,22,33))
b: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[24] at parallelize at <console>:27

scala> val c = a.cartesian(b)
c: org.apache.spark.rdd.RDD[(Int, Int)] = CartesianRDD[25] at cartesian at <console>:31
scala> c.collect.foreach(println(_))
(1,11)
(2,11)
(1,22)
(1,33)
(2,22)
(2,33)
(3,11)
(4,11)
(3,22)
(3,33)
(4,22)
(4,33)

scala> c.count
res19: Long = 12

scala> a.partitions.size
res20: Int = 2

scala> b.partitions.size
res21: Int = 2

scala> c.partitions.size
res22: Int = 4
  1. 兩種方式的cartesian的數據流對比
    1. 直接cartesian:

image

    1. 先blockify,然後cartesian:

image

圖 blockify-cartesian數據流

通過數據流其實也可以看出,直接cartesian的數據會多些(就單看產生的數據量)。

比如在i.中 用戶1的數據(1,Arr())存儲要8個;而在ii.中用戶1的數據(1,Array())卻只會出現2次。

對比其產生的數據大小,代碼如下:

scala> uf.cartesian(pf).saveAsTextFile("/tmp/cartesian01")
                                                                         
scala> ufsrc.cartesian(pfdst).saveAsTextFile("/tmp/cartesian02")

執行後,查看其數據存儲大小,分別如下圖所示。

image

image

從兩個圖的對比也可以發現,第二種方式其數據存儲會小很多。這也是爲什麼源碼用的是第二種實現方式。

這裏的BlockSize 其實對性能也有很大影響,所以源碼中也會建議是否把此參數暴露出來,供用戶自己設置。

4. flatMap的處理邏輯

flatMap的處理邏輯比較複雜,下面分點描述:

  1. case (srcIter, dstIter)代表什麼數據

參考 圖 blockify-cartesian數據流 中的輸出數據,(srcIter,dstIter)代表的數據其實就是組對應的數據,例如:

srcIter dstIter
Seq( (1,Arr();…;(5,Arr())) Seq( (101,Arr();(201,Arr());(301,Arr());(401,Arr()) )
Seq( (6,Arr()) ) Seq( (101,Arr();(201,Arr());(301,Arr());(401,Arr()) )
Seq((12,Arr())) Seq(501,Arr();(601,Arr());(701,Arr());(801,Arr()))
  1. 規劃輸出數組大小
val m = srcIter.size
val n = math.min(dstIter.size, num)
val output = new Array[(Int, (Int, Double))](m * n)

output的大小爲什麼是srcIter.size * math.min(dstIter.size, recNum)?

1) output只代表當前塊(block)的用戶的推薦內容,所以行個數就應該是當前用戶的個數,而srcIter就是用戶的一個數組,所以其大小就是行個數;
2) 推薦的個數,當然和用戶設置的推薦個數有關,但是其算的當前的項目的塊大小如果小於設置的推薦個數,那麼最多也只能推薦當前塊中項目的個數,所以是math.min ;
  1. 每個用戶塊(block)和每個項目塊(block)乘積並優先隊列
var i = 0
val pq = new BoundedPriorityQueue[(Int, Double)](n)(Ordering.by(_._2))
srcIter.foreach { case (srcId, srcFactor) =>
  dstIter.foreach { case (dstId, dstFactor) =>
    // We use F2jBLAS which is faster than a call to native BLAS for vector dot product
    val score = BLAS.f2jBLAS.ddot(rank, srcFactor, 1, dstFactor, 1)
    pq += dstId -> score
  }
  pq.foreach { case (dstId, score) =>
    output(i) = (srcId, (dstId, score))
    i += 1
  }
  pq.clear()
}

優先隊列指的是一個用戶塊(比如有5個用戶),那麼和一個項目塊(比如有4個項目),比如現在只推薦3個項目。那麼,針對用戶塊中的每個用戶,會和所有的項目進行計算,得到4個項目對應的分數,這些(項目,分數)對就會存入一個優先隊列,而這個優先隊列在4個(項目,分數)對存入完成後,只會有3個,並且其分數是最大的三個;

  1. 每個用戶都會有多個優先隊列
val ratings = srcBlocks.cartesian(dstBlocks).flatMap {
    ...
  output.toSeq
}

首先,每個用戶塊會對應多個項目塊,而每個項目塊會對應一個優先隊列;接着,這些優先隊列會通過flatMap進行合併,得到所有的(用戶id,(項目id,分數))這樣的數據,也就是RDD,也即是說ratings:RDD[(Int,(Int,Double))]。

思考一下,如果上面的例子中,blockify設置的個數爲2,那麼srcIter : Seq ((1,Array()), (2,Array()) ) 會對應多少個項目塊,對應多少個優先隊列?

  1. 合併取top
ratings.topByKey(num)(Ordering.by(_._2))

而這句就是針對ratings數據的每個key分組,然後按照value的第二個值(其實就是分數)排序,取其前n個鍵值對。

第4,5 步可以通過下圖展示:
image

至此,分析完畢!

3.2 Spark1.6.3 ALS recommendForAll 實戰分析

1. blockify函數

Spark1.6.3的blockify函數和Spark2.2.2中實現的blockify函數是不一樣的,如下:

/**
   * Blockifies features to use Level-3 BLAS.
   */
  private def blockify(
      rank: Int,
      features: RDD[(Int, Array[Double])]): RDD[(Array[Int], DenseMatrix)] = {
    val blockSize = 4096 // TODO: tune the block size
    val blockStorage = rank * blockSize
    features.mapPartitions { iter =>
      iter.grouped(blockSize).map { grouped =>
        val ids = mutable.ArrayBuilder.make[Int]
        ids.sizeHint(blockSize)
        val factors = mutable.ArrayBuilder.make[Double]
        factors.sizeHint(blockStorage)
        var i = 0
        grouped.foreach { case (id, factor) =>
          ids += id
          factors ++= factor
          i += 1
        }
        (ids.result(), new DenseMatrix(rank, i, factors.result()))
      }
    }
  }

在此份代碼中,可以分爲如下的幾個部分:

  1. ArrayBuilder使用
val ids = mutable.ArrayBuilder.make[Int]
ids.sizeHint(blockSize)
val factors = mutable.ArrayBuilder.make[Double]
factors.sizeHint(blockStorage)
ids.result()
factors.result()

這個ArrayBuilder就是一個存儲數據的數組,通過sizeHint函數來預設該數組大小,而通過result函數獲取整個數組的值,如下:

scala> import scala.collection.mutable
import scala.collection.mutable

scala> val factors = mutable.ArrayBuilder.make[Double]
factors: scala.collection.mutable.ArrayBuilder[Double] = ArrayBuilder.ofDouble

scala> factors ++= Array(0.2,0.3)
res34: factors.type = ArrayBuilder.ofDouble

scala> factors.result()
res35: Array[Double] = Array(0.2, 0.3)

scala> factors ++= Array(0.2,0.3)
res36: factors.type = ArrayBuilder.ofDouble

scala> factors.result()
res37: Array[Double] = Array(0.2, 0.3, 0.2, 0.3)
  1. DenseMatrix使用
    DenseMatrix的使用直接使用其源代碼來解釋,如下:
/**
   * Column-major dense matrix.
   * The entry values are stored in a single array of doubles with columns listed in sequence.
   * For example, the following matrix
   * {{{
   *   1.0 2.0
   *   3.0 4.0
   *   5.0 6.0
   * }}}
   * is stored as `[1.0, 3.0, 5.0, 2.0, 4.0, 6.0]`.
   *
   * @param numRows number of rows
   * @param numCols number of columns
   * @param values matrix entries in column major
   */
  @Since("1.0.0")
  def this(numRows: Int, numCols: Int, values: Array[Double]) =
    this(numRows, numCols, values, false)

這一段說的就是針對矩陣,使用一個數組來存儲,同時指定其行列的個數,然後就可以針對行列的個數來對數組進行劃分,進而就可以得到矩陣。

思考:factors的size爲什麼是 rank * blockSize,以及是否所有的factors的size都是這個?

  1. 每個partition分組、組整合成(用戶array,項目矩陣)

此處和Spark2.2.2不同的地方就是針對每個partition進行分組後的操作,此處針對每個分組的數據會把每個組整合成(用戶Array,項目矩陣)的二元組數據。其流程圖如下所示:

image

2. recommendForAll函數分析

這裏只分析與Spark2.2.2不同的地方:

case ((srcIds, srcFactors), (dstIds, dstFactors)) =>
        val m = srcIds.length
        val n = dstIds.length
        val ratings = srcFactors.transpose.multiply(dstFactors)
        val output = new Array[(Int, (Int, Double))](m * n)
        var k = 0
        ratings.foreachActive { (i, j, r) =>
          output(k) = (srcIds(i), (dstIds(j), r))
          k += 1
        }
        output.toSeq
  1. 首先,output是一個 當前組中用戶數x當前組中項目數 個 (用戶ID,(項目ID,分數))的三元組數組。
  2. ratings同樣是一個DenseMatrix,其如下
srcFactor:  k * m 的矩陣
dstFactor: k * n的矩陣
srcFactor'  * dstFaxtor : (m * k) * (k * n) = m * n 的一個矩陣

這裏是一個矩陣運算(不清楚的同學可以補充下線性代數的知識)。

  1. 最後兩句代碼,就是針對ratings中的每個值,把這個值和其對應的用戶ID,項目ID的關係拼湊起來,並賦值給output。

從臨時數據來看,這個數據是一個(m * n)的一個數據,遠遠比Spark2.2.2中 m*k (k << n)的數據量小。這也是Spark1.6.3中GC時間過長的原因,可以在後面的分析看到!

3.3 Spark2.2.2和Spark1.6.3 代碼對比總結

Point Spark1.6.3 Spark2.2.2
計算量 要計算每個用戶ID的factor和每個項目ID的factor的乘積 一樣
計算效率 使用矩陣相乘,如果不用Native BLAS,那麼速度很低 使用向量相乘,效率高於 Native BLAS
臨時存儲數據量 臨時存儲很大(m * n) 臨時存儲較小(m*k)

4. Spark2.2.2和Spark1.6.3性能測試對比

Note:

關於Spark2.2.2的性能測試,如使用Native BLAS等,可參考 Spark ALS應用BLAS加速

測試代碼、數據等,同樣參考Spark ALS應用BLAS加速

4.1 Spark1.6.3 官網安裝包測試

  1. 注意使用Spark官網提供的安裝包進行集羣安裝測試,官網下載地址:spark-1.6.tgz
  2. 同樣使用 fansy1990/als_blas 代碼,進行編譯打包(注意使用對應版本的pom文件)

命令如下:

spark-submit --class demo.AlsTest --deploy-mode cluster /root/als_blas-1.0-for-spark1.6.3.jar 3000
  1. 執行兩次後,時間消耗:
    時間消耗如下:

image

  1. 是否有BLAS的使用?

查看子節點是否,看是否有BLAS的使用:

image

從圖中可以看出,是沒有使用BLAS的加速的!

  1. long GC

任務有很長的GC時間,如下(在上面已經有說明,這裏只是驗證):

image

4.2 Spark1.6.3 自編譯安裝包測試

使用自行編譯好的Spark 安裝包,再次測試一遍

編譯命令如下:

image

  1. 測試時間:
    本次測試分爲四次,前兩次所有節點都沒有安裝open BLAS,後面兩次是所有節點都安裝的情況,如下:

image

  1. 是否使用BLAS?
    從子節點的運行日誌可以看出,確實是有使用BLAS的,如下:

image

  1. 是否 Long GC?

不管是open BLAS安裝前,還是後,都有Long GC,如下:

image

4.3 Spark2.2.2 vs Spark1.6.3 性能對比總結

部分參數來自Spark ALS應用BLAS加速

版本 平均耗時(mins) Long GC
官網Spark2 1.1
自編譯Spark2(BLAS) 1.2
官網Spark1 3.5
自編譯SPark1(BLAS) 4.3

Spark 2: Spark 2.2.2; Spark1:Spark 1.6.3

從表的分析結果來看:

  1. 如果使用Spark中ALS算法,那麼儘量使用Spark2版本;
  2. 從當前的測試實驗數據來看,針對Spark1.x版本,就算有Native BLAS,但是ALS算法也並沒有加速的跡象,反而更慢了。

5. 思考解答

  1. pfdst RDD的分佈情況:

由於pf RDD也有兩個分區,每個分區4條記錄,所以blockify後,pfdst RDD也有兩個分區,並且每個分區只有一條記錄,這個記錄是一個Seq數組,同時,這個數組中有4個元素。

scala> pfdst.glom().collect.foreach(x => println(x.mkString("|")))
List((101,[D@4cd85d52), (201,[D@5669ee53), (301,[D@7cdbc04), (401,[D@4dd08e5b))
List((501,[D@43ec687e), (601,[D@5a6e1d26), (701,[D@30a9a7f3), (801,[D@794245eb))

scala> pf.glom().collect.foreach(x => println(x.mkString("|")))
(101,[D@3400f5aa)|(201,[D@1bf0208c)|(301,[D@5daf0cb0)|(401,[D@70e1a8ab)
(501,[D@43ffaeb8)|(601,[D@5991020b)|(701,[D@7c7e4f05)|(801,[D@1a714d1)

  1. blockify設置的個數爲2,對應多少優先隊列?

  2. factors的size爲什麼是 rank * blockSize,以及是否所有的factors的size都是這個?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章