07 GraphX Programming Guide

转载请注明出处,谢谢合作~

GraphX 编程指南

  • 概述(Overview)
  • 快速上手(Getting Started)
  • 属性图(The Property Graph)
    • 属性图示例(Example Property Graph)
  • 图算子(Graph Operators)
    • 算子列表(Summary List of Operators)
    • 属性算子(Property Operators)
    • 结构算子(Structural Operators)
    • 链接算子(Join Operators)
    • 邻接聚合(Neighborhood Aggregation)
      • 聚合消息(Aggregate Messages (aggregateMessages))
      • MapReduceTriplets 迁移指南(Map Reduce Triplets Transition Guide (Legacy))
      • 计算连接度(Computing Degree Information)
      • 收集邻接节点Collecting Neighbors
    • 缓存及清理(Caching and Uncaching)
  • Pregel API(Pregel API)
  • 图构建器(Graph Builders)
  • 节点和边 RDD(Vertex and Edge RDDs)
    • 节点 RDD(VertexRDDs)
    • 边 RDD(EdgeRDDs)
  • 优化表示方式(Optimized Representation)
  • 图算法(Graph Algorithms)
    • 网站排名(PageRank)
    • 连通分量(Connected Components)
    • 三角计数(Triangle Counting)
  • 示例代码(Examples)
GraphX

概述

GraphX 是 Spark 中一个新的组件,用来应对并行图计算的场景。从一个比较高的层次来看,GraphX 通过扩展 RDD 的概念提出了一个新的 Graph 抽象:一个节点和边都包含属性的有向多重图。为了能够支持图计算,GraphX 给出了一组基础的算子(例如,subgraph, joinVertices 和 aggregateMessages)以及优化后的 Pregel API。另外,GraphX 还在持续更新图算法(algorithms)和图构建器(builders)的集合来简化图分析任务。

准备工作

需要首先引入 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 Quick Start Guide。

属性图

属性图(property graph)是一个有向的多重图,每个节点和每条边都可以自定义属性。有向多重图是指有向图中某些相同的节点之间可能存在多条边,这种能力为相同的节点间在存在多种关系(例如,同时是同事和朋友)的场景的建模提供了便利。每个节点都以一个 64 位的长整型作为 ID(VertexId),GraphX 并不需要节点 ID 是有序的。每条边也会包含相应的源节点 ID 和目的节点 ID。

属性图由节点类型(VD)和边类型(ED)标定,分别代表跟节点和边绑定的自定义对象类型。

当节点和边的类型为基本类型(例如,int,double 等等)时,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

与 RDD 类似,属性图也是不可变的,分布式的和容错的。节点值或者图拓扑结构的变更会根据相应的变更生成一张新图。需要注意的是,原图中没有发生变化的部分(即,未受影响的图结构,属性和索引)在构建新图的时候可以被复用,来减少函数式数据结构的开销。整张图根据节点被划分为多个分区,分布在各个 Executor 上。和 RDD 一样,图的每个分区都可以在故障时在另一个节点上被重新创建。

在逻辑上,属性图由一些强类型的数据集合(RDD)构成,数据集合中存储着每个节点和边的属性信息。于是,从 Graph 类中可以访问节点和边:

class Graph[VD, ED] {
  val vertices: VertexRDD[VD]
  val edges: EdgeRDD[ED]
}

VertexRDD[VD]EdgeRDD[ED] 分别代表优化后的 RDD[(VertexId, VD)]RDD[Edge[ED]],它们都有一些契合图计算的额外功能,为图计算提供优化措施。关于 VertexRDDVertexRDD 和 EdgeRDDEdgeRDD 的 API 会在 vertex and edge RDDs 章节中详细讨论,暂时可以将它们简单的当做 RDD[(VertexId, VD)]RDD[Edge[ED]]

属性图示例

假设想要构建一张属性图,包含了 GraphX 项目的合作人员,节点属性可能会包含名字和职业,可以用一个字符串来描述合作人员之间的关系,并用边来表示:

The Property Graph

这张图的类型签名如下:

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

可以通过几种方式构建一张属性图,比如从文件、RDD 甚至自定义生成器,这些将会在 graph builders 章节中详细讨论。最常用的方式可能是通过 Graph object,例如下面的代码从一些 RDD 中构建了一张图。

// Assume the SparkContext has already been constructed
val sc: SparkContext
// Create an RDD for the vertices
val users: RDD[(VertexId, (String, String))] =
  sc.parallelize(Seq((3L, ("rxin", "student")), (7L, ("jgonzal", "postdoc")),
                       (5L, ("franklin", "prof")), (2L, ("istoica", "prof"))))
// Create an RDD for edges
val relationships: RDD[Edge[String]] =
  sc.parallelize(Seq(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,其中包含一个 srcId 和一个 dstId,分别代表源节点和目的节点的 ID,此外还有一个 attr 成员变量存储了边的属性。

可以通过成员变量 graph.verticesgraph.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 表达式将其结构为 tuple。同时,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

或者以图的形式:

Edge Triplet

EdgeTriplet 类继承了 Edge,并添加了成员变量 srcAttrdstAttr,分别表示源节点和目的节点的属性值。可以使用三元组视图来渲染字符串,生成用户间关系的集合。

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(_))

图算子

RDD 有一些基础算子,例如 mapfilterreduceByKey,属性图也有一些基础算子,可以接收用户自定义的函数,根据转换后的属性值和拓扑结构生成新的图。优化后的核心算子定义在类 Graph 中,还有一些比较方便的算子作为二话不说算子的补充被定义在类 GraphOps 中。但是,得益于 Scala 的隐式转换特性,定义在 GraphOps 中的算子可以被自动转化为的 Graph 算子。例如,可以通过以下方式计算每个节点的入度(定义在 GraphOps 中)。

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

核心图 API 和 GraphOps 的区别是为了在未来更好的支持图的不同呈现方式,每一种方式必须提供核心算子的实现,并且可以复用许多 GraphOps 中定义的算子。

算子列表

下面是一个关于 GraphGraphOps 提供的算法功能的快速总结,为了方便起见,都展示为 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]
  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 = false): 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 aggregateMessages[Msg: ClassTag](
      sendMsg: EdgeContext[VD, ED, Msg] => Unit,
      mergeMsg: (Msg, Msg) => Msg,
      tripletFields: TripletFields = TripletFields.All)
    : 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 所做的优化:

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

相反,使用 mapVertices` 来复用索引:

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(Seq((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(Seq(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 算子会石笋默认值 true

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` 算子可以合并多重图中并行的边(即,同一节点对之间的多条边)。在很多数值应用中,并行边可以被求和(聚合权重),从而合并成一条单独的边,减小图的尺寸。

连接算子

在许多场景下,需要将图数据和其他的数据集(RDD)关联起来。例如,可能需要将额外的用户属性需要融合到现有的图中,或者需要将一张图中的节点属性拉取到另一张图中。这些需求可以通过连接算子来实现。下面列举了主要的连接算子:

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 去重,同时预建索引,可以加速后续的连接计算。

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的行为相似,但是该算子会应用于所有的节点,还能够改变节点的的属性值类型。由于并不是每个节点在 RDD 中都有对应的值,map函数接收一个Option` 类型的参数。例如,可以使用该算子将一张图的节点属性初始化为它们的出度,供 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
  }
}

你可注意到了上面的示例中使用的柯立化形式(例如,f(a)(b))的列表参数,尽管 f(a)(b)f(a,b) 的写法在功能上没有区别,但是后者意味着参数 b 的类型推断不依赖于参数 a。所以,用户需要为自定义函数提供类型声明:

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

邻接聚合

图分析任务中的一个关键步骤就是聚合每个节点的邻居的信息。例如,如果想要知道一个用户的关注者数量或者关注者的平均年龄,许多图迭代算法(例如,网页排名,最短路径和连通分量)也会重复聚合邻居节点的属性值(例如,当前的排名值,源的最短路径和可达节点的最小 ID)。

为了提升性能,基础聚合算子从 graph.mapReduceTriplets 升级为新的 graph.AggregateMessages。尽管 API 的变动很小,下面还是提供了迁移指南。

聚合消息 (aggregateMessages)

GraphX 中最核心的聚合算子就是 aggregateMessages。该算子将一个用户自定义的sendMsg函数应用于图中的每个 *edge triplet*,之后使用mergeMsg` 函数在目标节点处聚合这些消息。

class Graph[VD, ED] {
  def aggregateMessages[Msg: ClassTag](
      sendMsg: EdgeContext[VD, ED, Msg] => Unit,
      mergeMsg: (Msg, Msg) => Msg,
      tripletFields: TripletFields = TripletFields.All)
    : VertexRDD[Msg]
}

用户自定义的函数接收一个 EdgeContext 对象,该对象中可以访问源节点和目的节点的属性信息,以及边的属性信息,还能够使用函数(sendToSrcsendToDst)将消息发送给源节点或者目的节点。可以将 sendMsg 理解为 MapReduce 中的 map 函数,而 mergeMsg 函数将发送给同一节点的多个消息聚合成为一个,可以将其理解为 MapReduce 中的 reduce 函数。aggregateMessages算子返回一个VertexRDD[Msg],其中包含了发送给每个节点的聚合后的消息(类型为Msg`)。没有收到消息的节点不会被包含在内。

此外,算子还可以接收一个可选的 tripletsFields 参数,表示可以在 EdgeContext 中访问的数据。该参数的可选项定义在 TripletFields 中,默认值为 TripletFields.All,表示用户自定义的 sendMsg 函数可能会访问 EdgeContext 对象中的任意成员。该参数可以用来告知 GraphX 只有部分的 EdgeContext 成员在计算过程中是需要的,从而让 GraphX 能够选取一个优化后的连接策略。例如如果需要计算每个用户的关注者的平局年龄,只需要访问源节点的属性,便可以将该参数配置为 TripletFields.Src 来表示只需要访问源节点的属性。

在之前的 GraphX 版本中使用字节编码来推断 TripletFields 的值,但是发现字节编码的检查并不是很靠谱,于是替换为显式的控制方式。(我理解是把位运算替换为了显式的布尔类型)

在下面的示例中,使用 aggregateMessages` 算子来计算每个用户的关注者中年龄比该用户大的用户的平均年龄。

import org.apache.spark.graphx.{Graph, VertexRDD}
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.aggregateMessages[(Int, Double)](
  triplet => { // Map Function
    if (triplet.srcAttr > triplet.dstAttr) {
      // Send message to destination vertex containing counter and age
      triplet.sendToDst((1, triplet.srcAttr))
    }
  },
  // 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(_))

完整的示例代码位于 Spark 安装包的 「examples/src/main/scala/org/apache/spark/examples/graphx/AggregateMessagesExample.scala」。

当信息(以及聚合后的消息)的占用空间固定时(例如,浮点数和加法运算,而不是列表和拼接运算),aggregateMessages 算子可以达到最好的性能。

Map Reduce Triplets 迁移指南(遗留 API)

在 GraphX 之前的版本中,邻接聚合是通过 mapReduceTriplets 算子来完成的:

class Graph[VD, ED] {
  def mapReduceTriplets[Msg](
      map: EdgeTriplet[VD, ED] => Iterator[(VertexId, Msg)],
      reduce: (Msg, Msg) => Msg)
    : VertexRDD[Msg]
}

mapReduceTriplets 接收一个用户自定义的 map 函数,该函数会被应用于每个三元组对象,并生成消息,生成的消息通过用户自定义的 reduce 函数进行聚合。但是发现返回的迭代器的使用方开销很大,而且限制了额外的优化措施(例如,本地节点的重编码)。在 aggregateMessages` 算子中暴露了三元组的字段,以及显式发送信息到源节点和目的节点的函数,另外还移除了字节编码检查,让用户显式指定三元组中的哪些字段是需要的。

下面的代码块使用了 mapReduceTriplets 算子:

val graph: Graph[Int, Float] = ...
def msgFun(triplet: Triplet[Int, Float]): Iterator[(Int, String)] = {
  Iterator((triplet.dstId, "Hi"))
}
def reduceFun(a: String, b: String): String = a + " " + b
val result = graph.mapReduceTriplets[String](msgFun, reduceFun)

可以被重写为使用 aggregateMessages 算子:

val graph: Graph[Int, Float] = ...
def msgFun(triplet: EdgeContext[Int, Float, String]) {
  triplet.sendToDst("Hi")
}
def reduceFun(a: String, b: String): String = a + " " + b
val result = graph.aggregateMessages[String](msgFun, reduceFun)

计算连接度

计算每个节点的连接度是一个常用的聚合任务:计算每个节点的邻居节点的数量。对于有向图,通常需要知道每个节点的入度,出度和总的连接度。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)

收集邻接节点

在某些场景下为了方便计算需要收集每个节点的邻居节点及其属性信息,可以通过 collectNeighborIdscollectNeighbors 算子来实现。

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

这些算子的开销可能会很大,因为有些信息会被复制,还需要大量的网络通信。可能的话,建议使用 aggregateMessages` 算子来表达相同的计算。

缓存及清理

Spark 中的 RDD 默认是不会缓存到内存中的。为了避免重复计算,对同一 RDD 多次使用时需要显示指定缓存(参见 Spark Programming Guide)。GraphX 中的图也是类似的,当需要多次使用同一张图的数据时,请首先调用 Graph.cache() 来显示指定缓存

在迭代计算中,为了达到最佳性能也有时也需要清除缓存。默认情况下,被缓存的 RDD 和图数据会一直保留在内存中,直到由于内存压力触发 LAU 策略才会清除。对于迭代计算,产生的中间计算结果会填满缓存空间。即使它们最终被清理了,内存中那些没必要的存储会降低垃圾回收的效率,所以有必要在中间结果不再需要的时候及时清理它们 。如此就需要在每个迭代中缓存图中的 RDD,同时清理其他不需要的 RDD,将这些操作正确执行可能比较有难度。所以对于迭代计算推荐使用 Pregel API,能够正确的清理中间计算结果。

Pregel API

图是一种天然就具有递归性质的数据结构,因为节点的属性值依赖于邻居节点的属性值,邻居节点的属性值又依赖于它们邻居节点的属性值。所以,许多重要的图算法都会迭代计算每个节点的属性值直到达成某一收敛条件,根据迭代算法的特点业界也提出了一些并行图计算的抽象。GraphX 采用了 Pregel 作为底层抽象。

从比较高的层面来看,受限于图的拓扑结构,Pregel 算子符合 BSP(Bulk-Synchronous Parallel)消息传递模型。Pregel 算子会执行一系列的 super step,在每个 super step 中每个节点接收上一个 super step 中发送过来的消息,并对同一节点接收的消息进行聚合,根据聚合后的消息计算出该节点新的属性值,并向邻居节点发送在下一个 super step 中将会用到的消息。跟经典 Pregel 模型不同的地方是,消息的计算是通过作用于三元组的一个函数并行执行的,计算可以同时访问源节点和目的节点。在某一 super step 中没有收到消息的节点是不会参与计算的。当不再有新的消息存在时,Pregel 算子会终止计算并返回最终的图状态结果。

注意,跟许多标准的 Pregel 实现不同,GraphX 中的节点只能发送消息到邻居节点,而且消息的构建是通过一个用户自定义函数并行进行的。这些特点限制了 GraphX 后续可做的一些优化。

下面是 Pregel 算子([Pregel operator](http://spark.apache.org/docs/latest/api/scala/org/apache/spark/graphx/GraphOps.html#pregelA((VertexId,VD,A)⇒VD,(EdgeTriplet[VD,ED])⇒Iterator[(VertexId,A)],(A,A)⇒A))的类型签名及其实现的骨架(注意:为了避免由于过长的调用链路造成的 stackOverflowError,Pregel 支持定期将图状态数据和消息持久化到检查点,由参数「spark.graphx.pregel.checkpointInterval」控制,可以将其设置为一个正整数,比如 10 ,还需要调用 SparkContext.setCheckpointDir(directory: String) 设置检查点路径):

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 = GraphXUtils.mapReduceTriplets(g, 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 and update the vertices.
      g = g.joinVertices(messages)(vprog).cache()
      val oldMessages = messages
      // Send new messages, skipping edges where neither side received a message. We must cache
      // messages so it can be materialized on the next line, allowing us to uncache the previous
      // iteration.
      messages = GraphXUtils.mapReduceTriplets(
        g, sendMsg, mergeMsg, Some((oldMessages, activeDirection))).cache()
      activeMessages = messages.count()
      i += 1
    }
    g
  }
}

注意 Pregel 算子接收两个参数列表(即 graph.pregel(list1)(list2)),第一个参数列表包含初始消息值,最大迭代次数,和消息发送的边的方向(默认为出边),第二个参数列表包含接处理消息(vprog),发送消息(sendMsg),聚合消息(mergeMsg)的用户自定义函数。

下面的示例展示了如何用 Pregel 算子实现单源最短路径算法。

import org.apache.spark.graphx.{Graph, VertexId}
import org.apache.spark.graphx.util.GraphGenerators

// A graph with edge attributes containing distances
val graph: Graph[Long, 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"))

完整的示例代码位于 Spark 安装包的「examples/src/main/scala/org/apache/spark/examples/graphx/SSSPExample.scala" in the Spark repo」。

图构建器

GraphX 提供了几种方式从 RDD 或者磁盘上的数据中构建出一张图。默认情况下这些构建器都不会对图中的边进行重分区,相反,所有的边都会分布在它们默认的分区中(比如原始 HDFS 的块中)。Graph.groupEdges需要图中的边被重分区,因为这个算子假设相同的边都分布在同一个分区中,所以在使用groupEdges算子之前需要调用 [Graph.partitionBy`](http://spark.apache.org/docs/latest/api/scala/org/apache/spark/graphx/Graph.html#partitionBy(PartitionStrategy):Graph[VD,ED])。

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

GraphLoader.edgeListFile 提供了一种保存了边信息的磁盘中文件中构建出一张图的方式。该方法以下面的格式来解析,其中每一行包含一对 ID (源节点 ID 和目的节点 ID),会跳过以 # 开头的行:

# This is a comment
2 1
4 1
1 2

该方法从指定的边当中创建出一张图,会自动添加边中涉及到的节点。所有的节点和边的属性值默认为 1。参数 canonicalOrientation 允许将边的方向正则化(srcId < dstId),联通分量(connected components)算法需要这样的性质。参数 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](http://spark.apache.org/docs/latest/api/scala/org/apache/spark/graphx/Graph$.html#applyVD,ED 方法可以从节点和边的 RDD 中构建出一张图。重复的节点会从中任意挑选一个,在边的 RDD 中存在但是不在节点的 RDD 中存在的节点会被赋予默认属性值。

Graph.fromEdges 方法可以从单一的边 RDD 中构建出一张图,自动创建在边的 RDD 中出现的节点,并赋予默认的属性值。

[Graph.fromEdgeTuples](http://spark.apache.org/docs/latest/api/scala/org/apache/spark/graphx/Graph.html#fromEdgeTuples[VD](RDD[(VertexId,VertexId)],VD,Option[PartitionStrategy]) 方法可以从一个以 Tuple 格式描述边的 RDD 中构建出一张图,并为边赋予默认属性值 1,自动创建在边的 RDD 中出现的节点,并赋予默认的属性值。该方法支持对边进行去重,请将参数 `uniqueEdges` 设置成为一个 [`PartitionStrategy`](http://spark.apache.org/docs/latest/api/scala/org/apache/spark/graphx/PartitionStrategy.html) 类型的 Some(例如,uniqueEdges = Some(PartitionStrategy.RandomVertexCut)) 。分区策略需要能够归并相同的边来达到去重的效果。

节点和边 RDD

GraphX 对外暴露了图中节点和边的 RDD 视图。然而,因为 GraphX 以优化后的数据结构保存着节点和边,这些数据结构提供了附加功能,所以节点和边分别以 VertexRDD(VertexRDD)EdgeRDD(EdgeRDD)的方式呈现。本章节介绍那些额外的有用的功能,注意下面只是一部分,完整的算子列表参见 API 文档。

节点 RDD

VertexRDD[A] 继承了 RDD[(VertexId, A)],并额外限制了每个 VertexId 只能出现一次。VertexRDD[A] 表示一个节点的集合,其中每个节点都有一个类型为 A 的属性值。在内部,这些数据被保存为一个可复用的哈希表。进而,如果两个 VertexRDD 来源于同一个 VertexRDD(例如,通过 filter 或者 mapValues 算子),那么这两个 VertexRDD 可以在常数时间内做连接操作而不需要哈希重分区。基于这种带索引的数据结构, 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]
  // Show only vertices unique to this set based on their VertexId's
  def minus(other: RDD[(VertexId, VD)])
  // 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 算子并不允许 map 函数改变节点的 VertexId,从而能够复用之前的 HashMap 索引结构。leftJoininnerJoin 算子在连接两个来源于拥有相同 HashMap 索引结构的 VertexRDD 时,能够识别相同 ID 的节点,能够在连接时线性扫描而不需要单点查找。

aggregateUsingIndex 算子可以高效的从一个 RDD[(VertexId, A)] 中创建一个新的 VertexRDD。从概念上来讲,如果已经从已有的节点数据集上构建了一个 VertexRDD[B],它是另一个节点集 RDD[(VertexId, A)] 的超集,那么就可以复用 VertexRDD[B] 的索引结构来辅助聚合操作以及创建 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)

边 RDD

EdgeRDD[ED] 继承了 RDD[Edge[ED]],通过定义在 PartitionStrategy 中的一些分区策略来对存储了边的数据块进行分区。在每个分区中,边的属性值和相邻的拓扑结构是分开存储的,来最大化的复用这些数据,即使边的属性值发生了变化。

下面是三个典型的 EdgeRDD 的功能方法:

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

在大多数应用中,对于 EdgeRDD 的操作是通过图的算子或者的 RDD 基础算子来完成的。

优化表示方式

尽管关于分布式环境下图的表示方式的优化超出了本文的讨论范畴,还是可以从一个比较高的层面上对优化手段作一些了解,有助于弹性算法的设计实现和 API 的使用。GraphX 采用节点分割的方式来对分布式图进行分区:

Edge Cut vs. Vertex Cut

相比于根据边来切割图数据,GraphX 根据节点切割来进行分区,能够同时减少通信和存储的开销。从逻辑层面讲,每条边会被分配到不同的节点上,而同一个节点可能会被分配到不同的节点上。具体的分配方式取决于 PartitionStrategy,不同的方式之间都有一些取舍,用户可以通过 Graph.partitionBy 来对图数据进行重分区。默认的分区策略是构建 Graph 对象时边数据的初始分区,不过用户可以轻松的切换到 2D 分区策略或者其他 GrphX 提供的策略。

RDD Graph Representation

一旦边数据划分好了分区,并行图计算主要的挑战就变成了如何高效的对节点属性和边进行关联操作。由于现实世界中的图数据大部分情况下边的数量都远超过点的数量,GraphX 将节点属性和边放在一起。由于并不是所有的分区中的边和所有的节点都相邻,GraphX 内部维护了一张路由表,用来在进行 tripletsaggregateMessages 的连接操作时判定节点应该被广播到那个节点。

图算法

GraphX 提供了一些图算法来简化数据分析,这些算法位于 org.apache.spark.graphx.lib 包,可以直接通过 Graph 的方法访问 GraphOps 中的算法。本章节对其中一些算法做简要的介绍。

网站排名

PageRank 算法衡量图中每个节点的重要性,一条从节点 u 到节点 v 的边代表 u 节点对 v 节点重要性的贡献。例如,如果一个 Twitter 用户用很多关注者,该用户的排名就会较高。

GraphX 在单例对象 PageRank 中提供了 PageRank 算法静态和动态的实现。静态的 PageRank 算法执行固定的迭代次数,而动态的 PageRank 算法会一直运行直到结果收敛(即,误差在一个指定的范围内)。可以通过 Graph 来直接调用 GraphOps 中的这些算法。

GraphX 还提供了一个可以运行 PageRank 算法的社交网络数据集。用户的信息保存在文件 data/graphx/users.txt 中,用户之间的关系的数据保存在文件 data/graphx/followers.txt 中。计算的代码如下:

import org.apache.spark.graphx.GraphLoader

// Load the edges as a graph
val graph = GraphLoader.edgeListFile(sc, "data/graphx/followers.txt")
// Run PageRank
val ranks = graph.pageRank(0.0001).vertices
// Join the ranks with the usernames
val users = sc.textFile("data/graphx/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"))

完整的示例代码位于 Spark 安装包的「examples/src/main/scala/org/apache/spark/examples/graphx/PageRankExample.scala」。

连通分量

连通分量算法将图中的每个节点的属性值标记为所在连通分量中的节点的最小 ID。例如,在一个社交网络中,连通分量可以做简单的聚类分析。GraphX 在单例对象 ConnectedComponents 中包含了算法的实现,下面还是根据 PageRank 章节的数据进行连通分量的计算:

import org.apache.spark.graphx.GraphLoader

// Load the graph as in the PageRank example
val graph = GraphLoader.edgeListFile(sc, "data/graphx/followers.txt")
// Find the connected components
val cc = graph.connectedComponents().vertices
// Join the connected components with the usernames
val users = sc.textFile("data/graphx/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"))

完整的示例代码位于 Spark 安装包的「examples/src/main/scala/org/apache/spark/examples/graphx/ConnectedComponentsExample.scala」。

三角计数

当一个节点的两个邻居节点之间有一条边时,该节点就是一个三角形的一部分。GraphX 在单例对象 TriangleCount 中实现了三角计数的算法,计算每个节点所在的三角形的数量,作为一种聚类的手段。这里使用 PageRank 章节的社交网络数据集来进行三角计数算法。注意 TriangleCount 算法需要图中的边的方向是正则化(srcId < dstId)后的,并且需要使用 Graph.partitionBy 进行分区。

import org.apache.spark.graphx.{GraphLoader, PartitionStrategy}

// Load the edges in canonical order and partition the graph for triangle count
val graph = GraphLoader.edgeListFile(sc, "data/graphx/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("data/graphx/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"))

完整的示例代码位于 Spark 安装包的「examples/src/main/scala/org/apache/spark/examples/graphx/TriangleCountingExample.scala」。

示例代码

假设需要从一些文本文件中构建出一张图,将图限制在重要的关系和用户之间,在子图上执行 PageRank 算法,最后返回头部用户的属性值,可以这样做:

import org.apache.spark.graphx.GraphLoader

// Load my user data and parse into tuples of user id and attribute list
val users = (sc.textFile("data/graphx/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, "data/graphx/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"))

完整的示例代码位于 Spark 安装包的「examples/src/main/scala/org/apache/spark/examples/graphx/ComprehensiveExample.scala」。

你可能感兴趣的:(07 GraphX Programming Guide)