Spark—GraphX程式設計指南

大資料技術派發表於2022-03-27

Spark系列面試題

GraphX 是新的圖形和影像平行計算的Spark API。從整理上看,GraphX 通過引入 彈性分散式屬性圖(Resilient Distributed Property Graph)繼承了Spark RDD:一個將有效資訊放在頂點和邊的有向多重圖。為了支援圖形計算,GraphX 公開了一組基本的運算(例如,subgraph,joinVertices和mapReduceTriplets),以及在一個優化後的 PregelAPI的變形。此外,GraphX 包括越來越多的圖演算法和 builder 構造器,以簡化圖形分析任務。

圖平行計算的背景

從社交網路到語言建模,日益擴大的規模和圖形資料的重要性已帶動許多新的影像並行系統(例如,Giraph和 GraphLab)。通過限制可表示計算的型別以及引入新的技術來劃分和分佈圖,這些系統比一般的資料並行系統在執行復雜圖形演算法方面有大幅度地提高。

然而,這些限制在獲得重大效能提升的同時,也使其難以表達一個典型的圖表分析流程中的許多重要階段:構造圖,修改它的結構或表達計算跨越多重圖的計算。此外,如何看待資料取決於我們的目標,相同的原始資料,可能有許多不同的表(table)和圖表檢視(graph views)。

因此,能夠在同一組物理資料的表和圖表檢視之間切換是很有必要的,並利用各檢視的屬性,以方便地和有效地表達計算。但是,現有的圖形分析管道必須由圖並行和資料並行系統組成,從而導致大量的資料移動和重複以及複雜的程式設計模型。

該 GraphX 專案的目標是建立一個系統,建立一個統一的圖和資料平行計算的 API。該GraphX API 使使用者能夠將資料既可以當作一個圖,也可以當作集合(即RDDS)而不用進行資料移動或資料複製。通過引入在圖並行系統中的最新進展,GraphX能夠優化圖形操作的執行。

GraphX 替換 Spark Bagel 的 API

在GraphX 的釋出之前,Spark的圖計算是通過Bagel實現的,後者是Pregel的一個具體實現。GraphX提供了更豐富的圖屬性API,從而增強了Bagel。從而達到一個更加精簡的Pregel抽象,系統優化,效能提升以及減少記憶體開銷。雖然我們計劃最終棄用Bagel,我們將繼續支援Bagel的API和Bagel程式設計指南。不過,我們鼓勵Bagel使用者,探索新的GraphXAPI,並就從Bagel升級中遇到的障礙反饋給我們。

入門

首先,你要匯入 Spark 和 GraphX 到你的專案,如下所示:

import org.apache.spark._
import  org.apache.spark.graphx._
// To make some of the examples work we will also need RDD
import  org.apache.spark.rdd.RDD

如果你不使用Spark shell,你還需要一個 SparkContext。要了解更多有關如何開始使用Spark參考 Spark快速入門指南。

屬性圖

該 屬性圖是一個使用者定義的頂點和邊的有向多重圖。有向多重圖是一個有向圖,它可能有多個平行邊共享相同的源和目的頂點。多重圖支援並行邊的能力簡化了有多重關係(例如,同事和朋友)的建模場景。每個頂點是 唯一 的 64位長的識別符號(VertexID)作為主鍵。GraphX並沒有對頂點新增任何順序的約束。同樣,每條邊具有相應的源和目的頂點的識別符號。

該屬性表的引數由頂點(VD)和邊緣(ED)的型別來決定。這些是分別與每個頂點和邊相關聯的物件的型別。

GraphX 優化頂點和邊的型別的表示方法,當他們是普通的舊的資料型別(例如,整數,雙精度等)通過將它們儲存在專門的陣列減小了在記憶體佔用量。

在某些情況下,可能希望頂點在同一個圖中有不同的屬性型別。這可以通過繼承來實現。例如,以使用者和產品型號為二分圖我們可以做到以下幾點:

class  VertexProperty()
case  class  UserProperty( val name: String)  extends  VertexProperty
case  class  ProductProperty( val name: String,  val price: Double)  extends
VertexProperty
// The graph might then have the type:
var graph: Graph[VertexProperty, String] = null

和 RDDS 一樣,屬性圖是不可變的,分散式的和容錯的。對圖中的值或結構的改變是通過生成具有所需更改的新圖來完成的。注意原始圖的該主要部分(即不受影響的結構,屬性和索引)被重用,從而減少這個資料結構的成本。該圖是通過啟發式執行頂點分割槽,在不同的執行器(executor)中進行頂點的劃分。與 RDDS 一樣,在發生故障的情況下,圖中的每個分割槽都可以重建。

邏輯上講,屬性圖對應於一對型別集合(RDDS),這個組合記錄頂點和邊的屬性。因此,該圖表類包含成員訪問該圖的頂點和邊:

class  VertexProperty()
case  class  UserProperty( val name: String)  extends  VertexProperty
case  class  ProductProperty( val name: String,  val price: Double)  extends
VertexProperty
// The graph might then have the type:
var graph: Graph[VertexProperty, String] = null

類 VertexRDD [VD]和 EdgeRDD[ED,VD]繼承和並且分別是一個優化的版本的RDD[(VertexID,VD)]和RDD[Edge[ED]]。這兩個VertexRDD[VD]和EdgeRDD[ED,VD]提供各地圖的計算內建附加功能,並充分利用內部優化。我們在上一節頂點和邊RDDS中詳細討論了VertexRDD和EdgeRDD的API,但現在,他們可以簡單地看成是RDDS形式的: RDD[(VertexID,VD)]和 RDD [EDGE[ED]] 。

屬性圖的例子

假設我們要建立一個 GraphX專案各合作者的屬性圖。頂點屬性可能會包含使用者名稱和職業。我們可以使用一組字元註釋來描述代表合作者關係的邊:

由此產生的圖形將有型別簽名:

val userGraph: Graph[(String, String), String]

有許多方法可以從原始資料檔案,RDDS,甚至合成生成器來生成圖,我們會在 graph builders 更詳細的討論。可能是最通用的方法是使用 Graph ojbect。例如,下面的程式碼從一系列的RDDS的集合中構建圖:

// Assume the SparkContext has already been constructed
val sc: SparkContext
// Create an RDD for the vertices
val users: RDD[(VertexId, (String, String))] =
    sc.parallelize( Array((3L, ("rxin", "student")), (7L, ("jgonzal","postdoc")),(5L, ("franklin", "prof")), (2L, ("istoica", "prof"))))
// Create an RDD for edges
val relationships: RDD[Edge[String]] =
    sc.parallelize( Array( Edge(3L, 7L, "collab"), Edge(5L, 3L, "advisor"),Edge(2L, 5L, "colleague"),  Edge(5L, 7L, "pi")))
// Define a default user in case there are relationship with missing user
val defaultUser = ("John Doe", "Missing")
// Build the initial Graph
val graph =  Graph(users, relationships, defaultUser)

在上面的例子中,我們利用了Edge的case類。Edge具有srcId和dstId,它們分別對應於源和目的地頂點的識別符號。此外,Edge 類具有 attr屬性,並儲存的邊的特性。

我們可以通過 graph.vertices 和 graph.edges屬性,得到圖到各自的頂點和邊的檢視。

val graph: Graph[(String, String), String]  // Constructed from above
// Count all users which are postdocs
graph.vertices.filter {  case (id, (name, pos))  => pos == "postdoc" }.count
// Count all the edges where src > dst
graph.edges.filter(e  => e.srcId > e.dstId).count

需要注意的是graph.vertices返回VertexRDD[(String,String)]延伸RDD[(VertexID,(String,String))],所以我們使用Scala的case表達來解構元組。在另一方面,graph.edges返回EdgeRDD包含Edge[String]物件。我們可以也使用的如下的型別的構造器:

graph.edges.filter {  case  Edge(src, dst, prop)  => src > dst }.count

除了 圖的頂點和邊的屬性,GraphX 也提供了三重檢視。三重檢視邏輯連線點和邊的屬性產生的 RDD[EdgeTriplet [VD,ED]包含的例項 EdgeTriplet類。此 連線 可以表示如下的SQL表示式:

SELECT src.id, dst.id, src.attr, e.attr, dst.attr
FROM edges  AS e  LEFT  JOIN vertices  AS src, vertices  AS dst
ON e.srcId = src.Id  AND e.dstId = dst.Id

或圖形方式:

該 EdgeTriplet類繼承了 Edge並加入了類屬性:srcAttr和dstAttr,用於包含了源和目標屬性。我們可以用一個圖的三元組檢視渲染描述使用者之間的關係字串的集合。

val graph: Graph[(String, String), String]  // Constructed from above
// Use the triplets view to create an RDD of facts.
val facts: RDD[String] =
    graph.triplets.map(triplet  =>
        triplet.srcAttr._1 + " is the " + triplet.attr + " of " + triplet.dstAttr._1)
facts.collect.foreach(println(_ _))

也可以參照下面的操作獲取全部屬性

graph.triplets.foreach(t => println(s"triplet:${t.srcId},${t.srcAttr},${t.dstId},${t.dstAttr},${t.attr}"))

Graph 操作

正如RDDs有這樣基本的操作map,filter,和reduceByKey,屬性圖也有一系列基本的運算,採用使用者定義的函式,併產生新的圖形與變換的性質和結構。定義核心運算已優化的實現方式中定義的Graph,並且被表示為核心操作的組合定義在GraphOps。然而,由於Scala的implicits特性,GraphOps中的操作會自動作為Graph的成員。例如,我們可以計算各頂點的入度(定義在的 GraphOps):

val graph: Graph[(String, String), String]
// Use the implicit GraphOps.inDegrees operator
val inDegrees: VertexRDD[Int] = graph.inDegrees

將核心圖操作和 GraphOps區分開來的原因是為了將來能夠支援不同的圖表示。每個圖的表示必須實現核心操作並且複用 GraphOps中很多有用的操作。

運算列表總結

以下列出了Graph圖 和 GraphOps中同時定義的操作.為了簡單起見,我們都定義為Graph的成員函式。請注意,某些函式簽名已被簡化(例如,預設引數和型別的限制被刪除了),還有一些更高階的功能已被刪除,完整的列表,請參考API文件。

/** Summary of the functionality in the property graph */
class  Graph[VD, ED] {
    //  Information  about  the  Graph
    val numEdges: Long
    val numVertices: Long
    val inDegrees: VertexRDD[Int]
    val outDegrees: VertexRDD[Int]
    val degrees: VertexRDD[Int]
    //  Views  of  the  graph  as  collections
    val vertices: VertexRDD[VD]
    val edges: EdgeRDD[ED, VD]
    val triplets: RDD[EdgeTriplet[VD, ED]]
    //  Functions  for  caching  graphs
    def persist(newLevel: StorageLevel =  StorageLevel. MEMORY_ONLY): Graph[VD, ED]
    def cache(): Graph[VD, ED]
    def unpersistVertices(blocking: Boolean =  true): Graph[VD, ED]
    //  Change  the  partitioning  heuristic
    def partitionBy(partitionStrategy: PartitionStrategy): Graph[VD, ED]
    //  Transform  vertex  and  edge  attributes
    def mapVertices[VD2](map: (VertexID, VD)  =>  VD2): Graph[VD2, ED]
    def mapEdges[ED2](map: Edge[ED]  =>  ED2): Graph[VD, ED2]
    def mapEdges[ED2](map: (PartitionID, Iterator[Edge[ED]])  =>  Iterator[ED2]):Graph[VD, ED2]
    def mapTriplets[ED2](map: EdgeTriplet[VD, ED]  =>  ED2): Graph[VD, ED2]
    def mapTriplets[ED2](map: (PartitionID, Iterator[EdgeTriplet[VD, ED]])  =>
    Iterator[ED2]): Graph[VD, ED2]
    //  Modify  the  graph  structure
    def reverse: Graph[VD, ED]
    def subgraph(
            epred: EdgeTriplet[VD,ED]  =>  Boolean = (x  =>  true),
            vpred: (VertexID, VD)  =>  Boolean = ((v, d)  =>  true)
        ): Graph[VD, ED]
    def mask[VD2, ED2](other: Graph[VD2, ED2]): Graph[VD, ED]
    def groupEdges(merge: (ED, ED)  =>  ED): Graph[VD, ED]
    //  Join  RDDs  with  the  graph
    def joinVertices[U](table: RDD[(VertexID, U)])(mapFunc: (VertexID, VD, U)
    =>  VD): Graph[VD, ED]
    def outerJoinVertices[U, VD2](other: RDD[(VertexID, U)])
    (mapFunc: (VertexID, VD,  Option[U])  =>  VD2)
    : Graph[VD2, ED]
    //  Aggregate  information  about  adjacent  triplets
    def  collectNeighborIds(edgeDirection:  EdgeDirection): VertexRDD[Array[VertexID]]
    def  collectNeighbors(edgeDirection:  EdgeDirection):VertexRDD[Array[(VertexID, VD)]]
    def mapReduceTriplets[A: ClassTag](
            mapFunc: EdgeTriplet[VD, ED]  =>  Iterator[(VertexID, A)],
            reduceFunc: (A, A)  => A,
            activeSetOpt: Option[(VertexRDD[_ _], EdgeDirection)] =  None
        ): VertexRDD[A]
    //  Iterative  graph-parallel  computation
    def pregel[A](initialMsg: A, maxIterations: Int, activeDirection:  EdgeDirection)(
            vprog: (VertexID, VD, A)  =>  VD,
            sendMsg: EdgeTriplet[VD, ED]  =>  Iterator[(VertexID,A)],
            mergeMsg: (A, A)  => A
        ): Graph[VD, ED]
    //  Basic  graph  algorithms
    def pageRank(tol: Double, resetProb: Double = 0.15): Graph[Double, Double]
    def connectedComponents(): Graph[VertexID, ED]
    def triangleCount(): Graph[Int, ED]
    def stronglyConnectedComponents(numIter: Int): Graph[VertexID, ED]
}

屬性操作

和RDD的 map操作類似,屬性圖包含以下內容:

class  Graph[VD, ED] {
    def mapVertices[VD2](map: (VertexId, VD)  =>  VD2): Graph[VD2, ED]
    def mapEdges[ED2](map: Edge[ED]  =>  ED2): Graph[VD, ED2]
    def mapTriplets[ED2](map: EdgeTriplet[VD, ED]  =>  ED2): Graph[VD, ED2]
}

每個運算產生一個新的圖,這個圖的頂點和邊屬性通過 map方法修改。

請注意,在所有情況下的圖的機構不受影響。這是這些運算子的關鍵所在,它允許新得到圖可以複用初始圖的結構索引。下面的程式碼段在邏輯上是等效的,但第一個不保留結構索引,所以不會從 GraphX 系統優化中受益:

123 val newVertices = graph.vertices.map { case (id, attr) => (id, mapUdf(id,attr)) }val newGraph = Graph(newVertices, graph.edges)

相反,使用 mapVertices儲存索引:

1 val newGraph = graph.mapVertices((id, attr) => mapUdf(id, attr))

這些操作經常被用來初始化圖的特定計算或者去除不必要的屬性。例如,給定一個將出度作為頂點的屬性圖(我們之後將介紹如何構建這樣的圖),我們初始化它作為 PageRank:

// Given a graph where the vertex property is the out-degree
val inputGraph: Graph[Int, String] =
    graph.outerJoinVertices(graph.outDegrees)((vid,  _ _,  degOpt)  => degOpt.getOrElse(0))
// Construct a graph where each edge contains the weight
// and each vertex is the initial PageRank
val outputGraph: Graph[Double, Double] =
    inputGraph.mapTriplets(triplet  => 1.0 / triplet.srcAttr).mapVertices((id, _ _) => 1.0)

結構操作

當前 GraphX 只支援一組簡單的常用結構化操作,我們希望將來增加更多的操作。以下是基本的結構運算子的列表。

class  Graph[VD, ED] {   
	def reverse: Graph[VD, ED]   
  def subgraph(epred: EdgeTriplet[VD,ED]  =>  Boolean, vpred: (VertexId, VD)  =>  Boolean): Graph[VD, ED] 
  def mask[VD2, ED2](other: Graph[VD2, ED2]): Graph[VD, ED]  
  def groupEdges(merge: (ED, ED)  =>  ED): Graph[VD,ED]}

該reverse操作符返回一個新圖,新圖的邊的方向都反轉了。這是非常實用的,例如,試圖計算逆向PageRank。因為反向操作不修改頂點或邊屬性或改變的邊的數目,它的實現不需要資料移動或複製。

該子圖subgraph將頂點和邊的預測作為引數,並返回一個圖,它只包含滿足了頂點條件的頂點圖(值為true),以及滿足邊條件 並連線頂點的邊。subgraph子運算子可應用於很多場景,以限制圖表的頂點和邊是我們感興趣的,或消除斷開的連結。例如,在下面的程式碼中,我們刪除已損壞的連結:

// Create an RDD for the vertices
val users: RDD[(VertexId, (String, String))] =
        sc.parallelize( Array((3L, ("rxin", "student")), (7L, ("jgonzal","postdoc")),(5L, ("franklin", "prof")), (2L, ("istoica", "prof")),(4L, ("peter", "student"))))
// Create an RDD for edges
val relationships: RDD[Edge[String]] =
        sc.parallelize( Array( Edge(3L, 7L, "collab"), Edge(5L, 3L, "advisor"),Edge(2L, 5L, "colleague"),  Edge(5L, 7L, "pi"),Edge(4L, 0L, "student"), Edge(5L, 0L, "colleague")))
// Define a default user in case there are relationship with missing user
val defaultUser = ("John Doe", "Missing")
// Build the initial Graph
val graph =  Graph(users, relationships, defaultUser)
// Notice that there is a user 0 (for which we have no information) connected to users
// 4 (peter) and 5 (franklin).
graph.triplets.map(
    triplet  => triplet.srcAttr._1 + " is the " + triplet.attr + " of " + triplet.dstAttr._1
).collect.foreach(println(_ _))
// Remove missing vertices as well as the edges to connected to them
val validGraph = graph.subgraph(vpred = (id, attr)  => attr._2 != "Missing")
// The valid subgraph will disconnect users 4 and 5 by removing user 0
validGraph.vertices.collect.foreach(println(_ _))
validGraph.triplets.map(
    triplet  => triplet.srcAttr._1 + " is the " + triplet.attr + " of " + triplet.dstAttr._1
).collect.foreach(println(_ _))

注意,在上面的例子中,僅提供了頂點條件。如果不提供頂點或邊的條件,在subgraph 操作中預設為 真 。

mask操作返回一個包含輸入圖中所有的頂點和邊的圖。這可以用來和subgraph一起使用,以限制基於屬性的另一個相關圖。例如,我們用去掉頂點的圖來執行聯通分量,並且限制輸出為合法的子圖。

// Run Connected Components
val ccGraph = graph.connectedComponents()  // No longer contains missing field
// Remove missing vertices as well as the edges to connected to them
val validGraph = graph.subgraph(vpred = (id, attr)  => attr._2 != "Missing")
// Restrict the answer to the valid subgraph
val validCCGraph = ccGraph.mask(validGraph)

該 groupEdges操作合併在多重圖中的平行邊(即重複頂點對之間的邊)。在許多數值計算的應用中,平行的邊緣可以加入 (他們的權重的會被彙總)為單條邊從而降低了圖形的大小。

Join 操作

在許多情況下,有必要從外部集合(RDDS)中加入圖形資料。例如,我們可能有額外的使用者屬性,想要與現有的圖形合併,或者我們可能需要從一個圖選取一些頂點屬性到另一個圖。這些任務都可以使用來 join 經操作完成。下面我們列出的關鍵聯接運算子:

class  Graph[VD, ED] {
    def joinVertices[U](table: RDD[(VertexId, U)])(map: (VertexId, VD, U)  => VD): Graph[VD, ED]
    def outerJoinVertices[U, VD2](table: RDD[(VertexId, U)])(map: (VertexId, VD,  Option[U])  =>  VD2): Graph[VD2, ED]
}

該 joinVertices運算子連線與輸入RDD的頂點,並返回一個新的圖,新圖的頂點屬性是通過使用者自定義的 map功能作用在被連線的頂點上。沒有匹配的RDD保留其原始值。

需要注意的是,如果RDD頂點包含多於一個的值,其中只有一個將會被使用。因此,建議在輸入的RDD在初始為唯一的時候,使用下面的 pre-index 所得到的值以加快後續join。

val nonUniqueCosts: RDD[(VertexID, Double)]
val uniqueCosts: VertexRDD[Double] = 
        graph.vertices.aggregateUsingIndex(nonUnique, (a,b) => a + b)
val joinedGraph = graph.joinVertices(uniqueCosts)(
        (id, oldCost, extraCost)  => oldCost + extraCost)

更一般 outerJoinVertices操作類似於joinVertices,除了將使用者定義的map函式應用到所有的頂點,並且可以改變頂點的屬性型別。因為不是所有的頂點可能會在輸入匹配值RDD的mpa函式接受一個Optin型別。例如,我們可以通過

用 outDegree 初始化頂點屬性來設定一個圖的 PageRank。

val outDegrees: VertexRDD[Int] = graph.outDegrees
val degreeGraph = graph.outerJoinVertices(outDegrees) { 
    (id, oldAttr, outDegOpt)  =>
        outDegOpt  match {
            case  Some(outDeg)  => outDeg
            case  None  => 0  // No outDegree means zero outDegree
        }
}

您可能已經注意到,在上面的例子中採用了多個引數列表的curried函式模式(例如,f(a)(b))。雖然我們可以有同樣寫f(a)(b)為f(a,b),這將意味著該型別推斷b不依賴於a。其結果是,使用者將需要提供型別標註給使用者自定義的函式:

val joinedGraph = graph.joinVertices(uniqueCosts,  (id: VertexID, oldCost: Double, extraCost: Double)  => oldCost + extraCost) 

鄰居聚集

圖形計算的一個關鍵部分是聚集每個頂點的鄰域資訊。例如,我們可能想要知道每個使用者追隨者的數量或每個使用者的追隨者的平均年齡。許多圖迭代演算法(如PageRank,最短路徑,連通分量等)反覆聚集鄰居節點的屬性, (例如,當前的 PageRank 值,到源節點的最短路徑,最小可達頂點 ID)。

mapReduceTriplets

GraphX中核心(大量優化)聚集操作是 mapReduceTriplets操作:

class  Graph[VD, ED] {
    def reverse: Graph[VD, ED]
    def subgraph(epred: EdgeTriplet[VD,ED]  =>  Boolean, vpred: (VertexId, VD)  =>  Boolean): Graph[VD, ED]
    def mask[VD2, ED2](other: Graph[VD2, ED2]): Graph[VD, ED]
    def groupEdges(merge: (ED, ED)  =>  ED): Graph[VD,ED]
}

該 mapReduceTriplets運算子將使用者定義的map函式作為輸入,並且將map作用到每個triplet,並可以得到triplet上所有的頂點(或者兩個,或者空)的資訊。為了便於優化預聚合,我們目前僅支援發往triplet的源或目的地的頂點資訊。使用者定義的reduce功能將合併所有目標頂點相同的資訊。該mapReduceTriplets操作返回 VertexRDD [A] ,包含所有以每個頂點作為目標節點集合訊息(型別 A)。沒有收到訊息的頂點不包含在返回 VertexRDD。

需要注意的是 mapReduceTriplets需要一個附加的可選activeSet(上面沒有顯示,請參見API文件的詳細資訊),這限制了 VertexRDD地圖提供的鄰接邊的map階段:

activeSetOpt: Option[(VertexRDD[_], EdgeDirection)] = None 

該EdgeDirection指定了哪些和頂點相鄰的邊包含在map階段。如果該方向是in,則使用者定義的 mpa函式 將僅僅作用目標頂點在與活躍集中。如果方向是out,則該map函式將僅僅作用在那些源頂點在活躍集中的邊。如果方向是 either,則map函式將僅在任一頂點在活動集中的邊。如果方向是both,則map函式將僅作用在兩個頂點都在活躍集中。活躍集合必須來自圖的頂點中。限制計算到相鄰頂點的一個子集三胞胎是增量迭代計算中非常必要,而且是GraphX 實現Pregel中的關鍵。

在下面的例子中我們使用 mapReduceTriplets運算元來計算高階使用者追隨者的平均年齡。

// Import random graph generation library
import  org.apache.spark.graphx.util.GraphGenerators
// Create a graph with "age" as the vertex property. Here we use a random graph for simplicity.
val graph: Graph[Double, Int] =
        GraphGenerators.logNormalGraph(sc, numVertices = 100).mapVertices( 
            (id, _ _) => id.toDouble )
// Compute the number of older followers and their total age
val olderFollowers: VertexRDD[(Int, Double)] = graph.mapReduceTriplets[(Int,Double)](
    triplet  => {  // Map Function
        if (triplet.srcAttr > triplet.dstAttr) {
            // Send message to destination vertex containing counter and age
            Iterator((triplet.dstId, (1, triplet.srcAttr)))
        }  else {
            // Don't send a message for this triplet
            Iterator.empty
        }
    },
    // Add counter and age
    (a, b)  => (a._1 + b._1, a._2 + b._2)  // Reduce Function
)
// Divide total age by number of older followers to get average age of older followers
val avgAgeOfOlderFollowers: VertexRDD[Double] =
    olderFollowers.mapValues( (id, value)  => value  
        match {  case (count, totalAge) => totalAge / count } )
// Display the results
avgAgeOfOlderFollowers.collect.foreach(println(_ _))

注意,當訊息(和訊息的總和)是固定尺寸的時候(例如,浮點運算和加法而不是列表和連線)時,mapReduceTriplets 操作執行。更精確地說,結果 mapReduceTriplets 最好是每個頂點度的次線性函式。

計算度資訊

一個常見的聚合任務是計算每個頂點的度:每個頂點相鄰邊的數目。在有向圖的情況下,往往需要知道入度,出度,以及總度。該GraphOps類包含一系列的運算來計算每個頂點的度的集合。例如,在下面我們計算最大的入度,出度,總度:

// Define a reduce operation to compute the highest degree vertex
def max(a: (VertexId, Int), b: (VertexId, Int)): (VertexId, Int) = {
    if (a._2 > b._2) a  else b
}
// Compute the max degrees
val maxInDegree: (VertexId, Int) = graph.inDegrees.reduce(max)
val maxOutDegree: (VertexId, Int) = graph.outDegrees.reduce(max)
val maxDegrees: (VertexId, Int) = graph.degrees.reduce(max)

收集鄰居

在某些情況下可能更容易通過收集相鄰頂點和它們的屬性來表達在每個頂點表示的計算。這可以通過使用容易地實現 collectNeighborIds和 collectNeighbors運算。

class GraphOps[VD, ED] {
    def  collectNeighborIds(edgeDirection:  EdgeDirection): VertexRDD[Array[VertexId]]
    def  collectNeighbors(edgeDirection:  EdgeDirection): VertexRDD[ Array[(VertexId, VD)] ]
}

需要注意的是,這些運算計算代價非常高,因為他們包含重複資訊,並且需要大量的通訊。如果可能的話儘量直接使用 mapReduceTriplets。

快取和清空快取

在Spark中,RDDS預設並不儲存在記憶體中。為了避免重複計算,當他們需要多次使用時,必須明確地使用快取(見 Spark程式設計指南)。在GraphX中Graphs行為方式相同。當需要多次使用圖形時,一定要首先呼叫Graph.cache。

在迭代計算,為了最佳效能,也可能需要清空快取。預設情況下,快取的RDDS和圖表將保留在記憶體中,直到記憶體壓力迫使他們按照LRU順序被刪除。對於迭代計算,之前的迭代的中間結果將填補快取。雖然他們最終將被刪除,記憶體中的不必要的資料會使垃圾收集機制變慢。一旦它們不再需要快取,就立即清空中間結果的快取,這將會更加有效。這涉及物化(快取和強迫)圖形或RDD每次迭代,清空所有其他資料集,並且只使用物化資料集在未來的迭代中。然而,由於圖形是由多個RDDS的組成的,正確地持續化他們將非常困難。對於迭代計算,我們推薦使用 Pregel API,它正確地 unpersists 中間結果。

Pregel 的 API

圖本質上是遞迴的資料結構,因為頂點的性質取決於它們的鄰居,這反過來又依賴於鄰居的屬性。其結果是許多重要的圖形演算法迭代重新計算每個頂點的屬性,直到定點條件滿足為止。一系列影像並行方法已經被提出來表達這些迭代演算法。GraphX 提供了類似與Pregel 的操作,這是 Pregel 和 GraphLab 方法的融合。

從總體來看,Graphx 中的 Pregel 是一個批量同步並行訊息傳遞抽象 約束到該圖的拓撲結構。Pregel 運算子在一系列超步驟中,其中頂點收到從之前的步驟中流入訊息的總和,計算出頂點屬性的新值,然後在下一步中將訊息傳送到相鄰的頂點。不同於Pregel,而是更像GraphLab訊息被平行計算,並且作為edge-triplet,該訊息的計算可以訪問的源和目的地的頂點屬性。沒有收到訊息的頂點在一個超級步跳過。當沒有訊息是,Pregel 停止迭代,並返回最終圖形。

請注意,不像更標準的 Pregel的實現,在GraphX中頂點只能將訊息傳送到鄰近的頂點,並且資訊構建是通過使用使用者定義的訊息函式並行執行。這些限制使得在 GraphX 有額外的優化。

以下是型別簽名 Pregel,以及一個 初始的實現 (注呼叫graph.cache已被刪除)中:

class  GraphOps[VD, ED] {
    def pregel[A](
    initialMsg: A,
    maxIter: Int =  Int. MaxValue,
    activeDir: EdgeDirection =  EdgeDirection. Out)(
        vprog: (VertexId, VD, A)  =>  VD,
        sendMsg: EdgeTriplet[VD, ED]  =>  Iterator[(VertexId, A)],
        mergeMsg: (A, A)  => A
    ): Graph[VD, ED] = {
        // Receive the initial message at each vertex
        var  g  =  mapVertices(  (vid,  vdata)  =>  vprog(vid,  vdata, initialMsg) ).cache()
        // compute the messages
        var messages = g.mapReduceTriplets(sendMsg, mergeMsg)
        var activeMessages = messages.count()
        // Loop until no messages remain or maxIterations is achieved
        var i = 0
        while (activeMessages > 0 && i < maxIterations) {
            //  Receive  the  messages:------
            // Run the vertex program on all vertices that receive messages
            val newVerts = g.vertices.innerJoin(messages)(vprog).cache()
            // Merge the new vertex values back into the graph
            g  =  g.outerJoinVertices(newVerts)  {  (vid,  old,  newOpt)  =>  newOpt.getOrElse(old) }.cache()
            //  Send  Messages:-----
            --
            // Vertices that didn't receive a message above don't appear in newVerts and therefore don't
            // get to send messages. More precisely the map phase of mapReduceTriplets is only invoked
            // on edges in the activeDir of vertices in newVerts
            messages = g.mapReduceTriplets(sendMsg, mergeMsg,  Some((newVerts, activeDir))).cache()
            activeMessages = messages.count()
            i += 1
        }
        g
    }
}

請注意,Pregel 需要兩個引數列表(即graph.pregel(list1)(list2))。第一個引數列表中包含的配置引數包括初始資訊,迭代的最大次數,以及傳送訊息(預設出邊)的方向。第二個引數列表包含用於使用者定義的接收訊息(頂點程式 vprog),計算訊息(sendMsg),並結合資訊 mergeMsg。

我們可以使用 Pregel 運算子來表達計算,如在下面的例子中的單源最短路徑。

import  org.apache.spark.graphx._
// Import random graph generation library
import  org.apache.spark.graphx.util.GraphGenerators
// A graph with edge attributes containing distances
val graph: Graph[Int, Double] =
        GraphGenerators.logNormalGraph(sc, numVertices = 100).mapEdges(e => e.attr.toDouble)
val sourceId: VertexId = 42  // The ultimate source
// Initialize the graph such that all vertices except the root have distance infinity.
val initialGraph = graph.mapVertices((id, _ _)  =>  if (id == sourceId) 0.0  else Double.PositiveInfinity)
val sssp = initialGraph.pregel( Double. PositiveInfinity)(
    (id, dist, newDist)  => math.min(dist, newDist),  // Vertex Program
    triplet  => { // Send Message
        if (triplet.srcAttr + triplet.attr < triplet.dstAttr) {
            Iterator((triplet.dstId, triplet.srcAttr + triplet.attr))
        }  else {
            Iterator.empty
        }
    },
    (a,b)  => math.min(a,b)  // Merge Message
)
println(sssp.vertices.collect.mkString("\n"))

Graph Builder

GraphX 提供多種從RDD或者硬碟中的節點和邊中構建圖。預設情況下,沒有哪種Graph Builder會重新劃分圖的邊;相反,邊會留在它們的預設分割槽(如原來的HDFS塊)。Graph.groupEdges需要的圖形進行重新分割槽,因為它假設相同的邊將被放在同一個分割槽同一位置,所以你必須在呼叫Graph.partitionBy之前呼叫groupEdges。

object  GraphLoader {
    def edgeListFile(
        sc: SparkContext,
        path: String,
        canonicalOrientation: Boolean =  false,
        minEdgePartitions: Int = 1
    ): Graph[Int, Int]
}

GraphLoader.edgeListFile提供了一種從磁碟上邊的列表載入圖的方式。它解析了一個以下形式的鄰接列表(源頂點ID,目的地頂點ID)對,忽略以#開頭的註釋行:

 # This is a comment2 14 11 2

它從指定的邊建立了一個圖表,自動邊中提到的任何頂點。所有頂點和邊的屬性預設為1。canonicalOrientation引數允許重新定向邊的正方向(srcId < dstId),這是必需的connected-component演算法。該minEdgePartitions引數指定邊緣分割槽生成的最小數目;例如,在HDFS檔案具有多個塊, 那麼就有多個邊的分割.

object Graph {
    def apply[VD, ED](
            vertices: RDD[(VertexId, VD)],
            edges: RDD[Edge[ED]],
            defaultVertexAttr: VD =  null
    ): Graph[VD, ED]
    
    def fromEdges[VD, ED](
            edges: RDD[Edge[ED]],
            defaultValue: VD): Graph[VD, ED]
    
    def fromEdgeTuples[VD](
            rawEdges: RDD[(VertexId, VertexId)],
            defaultValue: VD,
            uniqueEdges: Option[PartitionStrategy] =  None
    ): Graph[VD, Int]
}

Graph.apply允許從頂點和邊的RDDS中建立的圖。重複的頂點會任意選擇,並在邊RDD中存在的頂點, 但不是頂點RDD會被賦值為預設屬性。

Graph.fromEdges允許從只有邊的元組RDD建立的圖,自動生成由邊中存在的頂點,並且給這些頂點賦值為預設值。

Graph.fromEdgeTuples允許從只有邊的元組的RDD圖中建立圖,並將的邊的值賦為1,並自動建立邊中所存在的頂點,並設定為預設值。它也支援刪除重邊; 進行刪除重邊時,傳入 PartitionStrategy的Some 作為uniqueEdges引數(例如,uniqueEdges=Some(PartitionStrategy.RandomVertexCut))。分割槽策略是必要的,因為定位在同一分割槽相同的邊,才能使他們能夠進行重複刪除。

頂點和邊 RDDs

GraphX 公開了圖中 RDD 頂點和邊的檢視。然而,因為GraphX將頂點和邊儲存在優化的資料結構,並且為這些資料結構提供額外的功能,頂點和邊分別作為VertexRDD和EdgeRDD返回。在本節中,我們回顧一些這些型別的其他有用的功能。

VertexRDDs

該VertexRDD [A]繼承RDD [(VertexID, A)],並增加了一些額外的限制 ,每個VertexID只出現 一次 。此外,VertexRDD[A]表示一個頂點集合,其中每個頂點與型別的屬性為A。在內部,這是通過將頂點屬性中儲存在一個可重複使用的雜湊表。因此,如果兩個VertexRDDs繼承自相同的基類VertexRDD(例如,通過filter或mapValues ),他們可以參加在常數時間內實現合併,而不需要重新計算hash值。要充分利用這個索引資料結構,VertexRDD提供了以下附加功能:

class  VertexRDD[VD]  extends  RDD[(VertexID, VD)] {
    // Filter the vertex set but preserves the internal index
    def filter(pred: Tuple2[VertexId, VD]  =>  Boolean): VertexRDD[VD]
    // Transform the values without changing the ids (preserves the internal index)
    def mapValues[VD2](map: VD =>  VD2): VertexRDD[VD2]
    def mapValues[VD2](map: (VertexId, VD)  =>  VD2): VertexRDD[VD2]
    // Remove vertices from this set that appear in the other set
    def diff(other: VertexRDD[VD]): VertexRDD[VD]
    // Join operators that take advantage of the internal indexing to accelerate joins (substantially)
    def leftJoin[VD2, VD3](other: RDD[(VertexId, VD2)])(f: (VertexId, VD, Option[VD2])  =>  VD3): VertexRDD[VD3]
    def innerJoin[U, VD2](other: RDD[(VertexId, U)])(f: (VertexId, VD, U)  => VD2): VertexRDD[VD2]
    // Use the index on this RDD to accelerate a `reduceByKey` operation on the input RDD.
    def aggregateUsingIndex[VD2](other: RDD[(VertexId, VD2)], reduceFunc: (VD2, VD2)  =>  VD2): VertexRDD[VD2]
}

請注意,例如,如何filter操作符返回一個VertexRDD。過濾器使用的是實際通過BitSet實現的,從而複用索引和保持能快速與其他 VertexRDD 實現連線功能。 類似地,mapValues 操作不允許mapha函式改變 VertexID,從而可以複用統一HashMap中的資料結構。當兩個VertexRDD派生自同一HashMap,並且是通過線性少買而非代價昂貴的逐點查詢時,無論是 leftJoin 和 innerJoin 連線時能夠識別 VertexRDD 。

該aggregateUsingIndex操作是一種新的有效的從RDD[(VertexID,A)]構建新的VertexRDD的方式。從概念上講,如果我在一組頂點上構建了一個VertexRDD[B],這是一個在某些頂點RDD[(VertexID,A)]的超集,然後我可以重用該索引既聚集,隨後為RDD[(VertexID, A)]建立索引。例如:

val setA: VertexRDD[Int] =  VertexRDD(sc.parallelize(0L until 100L).map(id  => (id, 1)))
val rddB: RDD[(VertexId, Double)] = sc.parallelize(0L until 100L).flatMap(id =>  List((id, 1.0), (id, 2.0)))
// There should be 200 entries in rddB
rddB.count
val setB: VertexRDD[Double] = setA.aggregateUsingIndex(rddB, _ _ + _ _)
// There should be 100 entries in setB
setB.count
// Joining A and B should now be fast!
val setC: VertexRDD[Double] = setA.innerJoin(setB)((id, a, b)  => a + b)

EdgeRDDs

該EdgeRDD [ED,VD] ,它繼承RDD[Edge[ED],以各種分割槽策略PartitionStrategy將邊劃分成不同的塊。在每個分割槽中,邊屬性和鄰接結構,分別儲存,這使得更改屬性值時,能夠最大限度的複用。

EdgeRDD 是提供的三個額外的函式:

// Transform the edge attributes while preserving the structure
def mapValues[ED2](f: Edge[ED]  =>  ED2): EdgeRDD[ED2, VD]
// Revere the edges reusing both attributes and structure
def reverse: EdgeRDD[ED, VD]
// Join two `EdgeRDD`s partitioned using the same partitioning strategy.
def innerJoin[ED2, ED3](other: EdgeRDD[ED2, VD])(f: (VertexId, VertexId,  ED, ED2) => ED3): EdgeRDD[ED3, VD]

在大多數應用中,我們發現,在 EdgeRDD 中的操作是通過圖形運算子來實現,或依靠在基類定義的 RDD 類操作。

優化圖的表示

關於 GraphX 中如何表示分散式圖結構的詳細描述,這個話題超出了本指南的範圍,一些高層次的理解可能有助於設計可擴充套件的演算法設計以及 API 的最佳利用。GraphX 採用頂點切的方法來分發圖劃分:

不通過邊劃分圖,GraphX 沿頂點來劃分圖,這樣可以減少頂點之間的通訊和儲存開銷。邏輯上,這對應於將邊分配到不同的機器,並允許頂點跨越多個機器。分配邊的確切方法取決於PartitionStrategy並有多個權衡各種試探法。使用者可以通過重新分割槽圖與不同的策略之間進行選擇Graph.partitionBy操作。預設分割槽策略是按照圖的構造,使用圖中初始的邊。但是,使用者可以方便地切換到二維-分割槽或GraphX中其他啟發式分割槽方法。

一旦邊被劃分,並行圖計算的關鍵挑戰在於有效的將每個頂點屬性和邊的屬性連線起來。由於在現實世界中,邊的數量多於頂點的數量,我們把頂點屬性放在邊中。因為不是所有的分割槽將包含所有頂點相鄰的邊的資訊,我們在內部維護一個路由表,這個表確定在哪裡廣播頂點資訊,執行 triplet 和 mapReduceTriplets 的連線操作。

圖演算法

GraphX 包括一組圖形演算法來簡化分析任務。該演算法被包含於org.apache.spark.graphx.lib包中,並可直接通過 GraphOps而被Graph中的方法呼叫。本節介紹這些演算法以及如何使用它們。

PageRank

PageRank記錄了圖中每個頂點的重要性,假設一條邊從u到v,代表從u傳遞給v的重要性。例如,如果一個Twitter使用者有很多粉絲,使用者排名將很高。

GraphX 自帶的PageRank的靜態和動態的實現,放在PageRank物件中。靜態的PageRank執行的固定數量的迭代,而動態的PageRank執行,直到排名收斂(即當每個迭代和上一迭代的差值,在某個範圍之內時停止迭代)。GraphOps允許Graph中的方法直接呼叫這些演算法。

GraphX 還包括,我們可以將PageRank執行在社交網路資料集中。一組使用者給出graphx/data/users.txt,以及一組使用者之間的關係,給出了graphx/data/followers.txt。我們可以按照如下方法來計算每個使用者的網頁級別:

// Load the edges as a graph
val graph =  GraphLoader.edgeListFile(sc, "graphx/data/followers.txt")
// Run PageRank
val ranks = graph.pageRank(0.0001).vertices
// Join the ranks with the usernames
val users = sc.textFile("graphx/data/users.txt").map { line  =>
    val fields = line.split(",")(
        fields(0).toLong, fields(1)
    )
}
val ranksByUsername = users.join(ranks).map {
    case (id, (username, rank))  => (username, rank)
}
// Print the result
println(ranksByUsername.collect().mkString("\n"))

聯通分量

連線分量演算法標出了圖中編號最低的頂點所聯通的子集。例如,在社交網路中,連線分量類似叢集。GraphX 包含在ConnectedComponents物件的演算法,並且我們從該社交網路資料集中計算出連線元件的PageRank部分,如下所示:

// Load the graph as in the PageRank example
val graph =  GraphLoader.edgeListFile(sc, "graphx/data/followers.txt")
// Find the connected components
val cc = graph.connectedComponents().vertices
// Join the connected components with the usernames
val users = sc.textFile("graphx/data/users.txt").map { line  =>
    val fields = line.split(",")(
        fields(0).toLong, fields(1)
    )
}
val ccByUsername = users.join(cc).map {
    case (id, (username, cc))  => (username, cc)
}
// Print the result
println(ccByUsername.collect().mkString("\n"))

三角計數

當頂點周圍與有一個其他兩個頂點有連線時,這個頂點是三角形的一部分。GraphX在TriangleCount物件實現了一個三角形計數演算法,這個演算法計算通過各頂點的三角形數目,從而提供叢集的度。我們從PageRank部分計算社交網路資料集的三角形數量。注意TriangleCount要求邊是規範的指向(srcId < dstId),並使用 Graph.partitionBy來分割圖形。

// Load the edges in canonical order and partition the graph for triangle count
val graph =  GraphLoader.edgeListFile(sc, "graphx/data/followers.txt", true).partitionBy( PartitionStrategy. RandomVertexCut)
// Find the triangle count for each vertex
val triCounts = graph.triangleCount().vertices
// Join the triangle counts with the usernames
val users = sc.textFile("graphx/data/users.txt").map { line => 
        val fields = line.split(",")(
            fields(0).toLong, fields(1)
        )
    }
val triCountByUsername = users.join(triCounts).map {  
        case (id, (username, tc)) => (username, tc)
    }
// Print the result
println(triCountByUsername.collect().mkString("\n"))
  • 示例

假設我想從一些文字檔案中構建圖,只考慮圖中重要關係和使用者,在子圖中執行的頁面排名演算法,然後終於返回與頂級使用者相關的屬性。我們可以在短短的幾行 GraphX 程式碼中實現這一功能:

// Connect to the Spark cluster
val sc =  new  SparkContext("spark://master.amplab.org", "research")
// Load my user data and parse into tuples of user id and attribute list
val users = (sc.textFile("graphx/data/users.txt").map(line  =>  
        line.split(",")).map(  
            parts  =>  (parts.head.toLong,parts.tail) 
        )
    )
// Parse the edge data which is already in userId -> userId format
val  followerGraph  =  GraphLoader.edgeListFile(sc,"graphx/data/followers.txt")
// Attach the user attributes
val graph = followerGraph.outerJoinVertices(users) {
        case (uid, deg,  Some(attrList))  => attrList
        // Some users may not have attributes so we set them as empty
        case (uid, deg,  None)  =>  Array.empty[String]
}
// Restrict the graph to users with usernames and names
val subgraph = graph.subgraph(vpred = (vid, attr)  => attr.size == 2)
// Compute the PageRank
val pagerankGraph = subgraph.pageRank(0.001)
// Get the attributes of the top pagerank users
val  userInfoWithPageRank  =
        subgraph.outerJoinVertices(pagerankGraph.vertices) {
            case (uid, attrList,  Some(pr))  => (pr, attrList.toList)
            case (uid, attrList,  None)  => (0.0, attrList.toList)
        }
println(userInfoWithPageRank.vertices.top(5)( 
    Ordering.by(_ _._2._1)).mkString("\n")
)

猜你喜歡
Hadoop3資料容錯技術(糾刪碼)
Hadoop 資料遷移用法詳解
Flink實時計算topN熱榜
數倉建模分層理論
數倉建模方法論

相關文章