文章目錄
Spark ALS recommendForAll源碼解析實戰
1. 軟件版本:
軟件 | 版本 |
---|---|
Spark | 1.6.3 、 2.2.2 |
Hadoop | 2.6.5 |
2. 本文要解決的問題
- 分析Spark2.2.2中 ALS算法的recommendForAll函數的實現思路;
- 分析Spark1.6.3中 ALS算法的recommendForAll函數的實現思路;
- 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,其數據流如下:
下面對此圖進行驗證:
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;
下面,先看下這兩種方式的異同:
- 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
- 兩種方式的cartesian的數據流對比
-
- 直接cartesian:
-
- 先blockify,然後cartesian:
圖 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")
執行後,查看其數據存儲大小,分別如下圖所示。
從兩個圖的對比也可以發現,第二種方式其數據存儲會小很多。這也是爲什麼源碼用的是第二種實現方式。
這裏的BlockSize 其實對性能也有很大影響,所以源碼中也會建議是否把此參數暴露出來,供用戶自己設置。
4. flatMap的處理邏輯
flatMap的處理邏輯比較複雜,下面分點描述:
- 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())) |
- 規劃輸出數組大小
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 ;
- 每個用戶塊(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個,並且其分數是最大的三個;
- 每個用戶都會有多個優先隊列
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()) ) 會對應多少個項目塊,對應多少個優先隊列?
- 合併取top
ratings.topByKey(num)(Ordering.by(_._2))
而這句就是針對ratings數據的每個key分組,然後按照value的第二個值(其實就是分數)排序,取其前n個鍵值對。
第4,5 步可以通過下圖展示:
至此,分析完畢!
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()))
}
}
}
在此份代碼中,可以分爲如下的幾個部分:
- 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)
- 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都是這個?
- 每個partition分組、組整合成(用戶array,項目矩陣)
此處和Spark2.2.2不同的地方就是針對每個partition進行分組後的操作,此處針對每個分組的數據會把每個組整合成(用戶Array,項目矩陣)的二元組數據。其流程圖如下所示:
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
- 首先,output是一個 當前組中用戶數x當前組中項目數 個 (用戶ID,(項目ID,分數))的三元組數組。
- ratings同樣是一個DenseMatrix,其如下
srcFactor: k * m 的矩陣
dstFactor: k * n的矩陣
srcFactor' * dstFaxtor : (m * k) * (k * n) = m * n 的一個矩陣
這裏是一個矩陣運算(不清楚的同學可以補充下線性代數的知識)。
- 最後兩句代碼,就是針對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 官網安裝包測試
- 注意使用Spark官網提供的安裝包進行集羣安裝測試,官網下載地址:spark-1.6.tgz。
- 同樣使用 fansy1990/als_blas 代碼,進行編譯打包(注意使用對應版本的pom文件)
命令如下:
spark-submit --class demo.AlsTest --deploy-mode cluster /root/als_blas-1.0-for-spark1.6.3.jar 3000
- 執行兩次後,時間消耗:
時間消耗如下:
- 是否有BLAS的使用?
查看子節點是否,看是否有BLAS的使用:
從圖中可以看出,是沒有使用BLAS的加速的!
- long GC
任務有很長的GC時間,如下(在上面已經有說明,這裏只是驗證):
4.2 Spark1.6.3 自編譯安裝包測試
使用自行編譯好的Spark 安裝包,再次測試一遍
編譯命令如下:
- 測試時間:
本次測試分爲四次,前兩次所有節點都沒有安裝open BLAS,後面兩次是所有節點都安裝的情況,如下:
- 是否使用BLAS?
從子節點的運行日誌可以看出,確實是有使用BLAS的,如下:
- 是否 Long GC?
不管是open BLAS安裝前,還是後,都有Long GC,如下:
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
從表的分析結果來看:
- 如果使用Spark中ALS算法,那麼儘量使用Spark2版本;
- 從當前的測試實驗數據來看,針對Spark1.x版本,就算有Native BLAS,但是ALS算法也並沒有加速的跡象,反而更慢了。
5. 思考解答
- 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)
-
blockify設置的個數爲2,對應多少優先隊列?
-
factors的size爲什麼是 rank * blockSize,以及是否所有的factors的size都是這個?