|||
Spark用Scala语言实现了RDD的API。Scala是一种基于JVM的静态类型、函数式、面向对象的语言。我们选择Scala是因为它简洁(特别适合交互式使用)、有效(因为是静态类型)。但是,RDD抽象并不局限于函数式语言,也可以使用其他语言来实现RDD,比如像Hadoop那样用类表示用户函数。
要使用Spark,开发者需要编写一个driver程序,连接到集群以运行worker,如图2所示。Driver定义了一个或多个RDD,并调用RDD上的行为(action)。Worker是长时间运行(long-lived)的进程,将RDD分区以Java对象的形式缓存在RAM中。
(由于粘贴不便,图参见论文原文)
图2 Spark的运行时。用户的driver程序启动多个worker,worker从分布式文件系统中读取数据块(block),并将计算后的RDD分区(partition)缓存在内存中。
再看看2.4中的例子,用户执行RDD操作时会提供参数,比如map传递一个闭包(closure,函数式编程中的概念)。Scala将闭包表示为Java对象,如果传递的参数是闭包,则这些对象被序列化,通过网络传输到其他节点上进行装载。Scala将闭包内的变量保存为Java对象的字段(field)。例如,var x = 5; rdd.map(_ + x) 这段代码将RDD中的每个元素加5。总的来说,Spark的语言集成类似于DryadLINQ。
RDD本身是静态类型对象,由参数指定其元素类型。例如,RDD[int]是一个整型RDD。不过,我们举的例子几乎都省略了这个类型参数,因为Scala支持类型推断。
虽然使用Scala实现RDD概念上很简单,但还是要处理一些Scala闭包对象的反射(reflection)问题。如何通过Scala解释器来使用Spark还需要更多工作,这点我们将在第6部分讨论。不管怎样,我们都不需要修改Scala编译器。
表2(参见论文原文)列出了Spark中的RDD转换(transformation)和行为(action)。每个操作都给出了标识,其中方括号表示类型参数。前面说过转换是懒操作,用于定义新的RDD;而行为启动计算操作,并向用户程序返回值或向外部存储写数据。
注意,有些操作只对键值对(key-value pairs)可用,比如join。另外,函数名与Scala及其他函数式语言中的API匹配,例如map是一对一的映射,而flatMap是将每个输入映射为一个或多个输出(与MapReduce中的map类似)。
除了这些操作以外,用户还可以请求将RDD缓存起来。而且,用户还可以通过Partitioner类获取RDD的分区顺序,然后将另一个RDD按照同样的方式分区。有些操作会自动产生一个哈希或范围分区的RDD,像groupByKey,reduceByKey和sort等。
现在我们讲述如何使用RDD表示几种基于数据并行的应用。首先讨论一些迭代式机器学习应用(4.1),然后看看如何使用RDD描述几种已有的集群编程模型,即MapReduce(4.2),Pregel(4.3),和Hadoop(4.4)。最后讨论一下RDD不适合哪些应用(4.5)。
很多机器学习算法都具有迭代特性,运行迭代优化方法来优化某个目标函数,例如梯度下降方法。如果这些算法的工作集(working set)能够放入RAM,将极大地加速程序运行。而且,这些算法通常采用批量操作,例如映射和求和,这样更容易使用RDD来表示。
例如下面的程序是逻辑回归的实现。逻辑回归是一种常见的分类算法,即寻找一个最佳分割两组点(即垃圾邮件和非垃圾邮件)的超平面w。算法采用梯度下降的方法:开始时w为随机值,在每一次迭代的过程中,对w的函数求和,然后朝着优化的方向移动w。
val points = spark.textFile(...)
.map(parsePoint).cache()
var w = // random initial vector
for (i <- 1 to ITERATIONS) {
val gradient = points.map{ p =>
p.x * (1/(1+exp(-p.y*(w dot p.x)))-1)*p.y
}.reduce((a,b) => a+b)
w -= gradient
}
首先定义一个名为points的缓存RDD,这是在文本文件上执map转换之后得到的,即将每个文本行解析为一个Point对象。然后在points上反复执行map和reduce操作,每次迭代时通过对当前w的函数进行求和来计算梯度。7.1小节我们将看到这种在内存中缓存points的方式,比每次迭代都从磁盘文件装载数据并进行解析要快得多。
已经在Spark中实现的迭代式机器学习算法还有:kmeans(像逻辑回归一样每次迭代时执行一对map和reduce操作),期望最大化算法(EM,两个不同的map/reduce步骤交替执行),交替最小二乘矩阵分解和协同过滤算法。Chu等人提出迭代式MapReduce也可以用来实现常用的学习算法。
MapReduce模型很容易使用RDD进行描述。假设有一个输入数据集(其元素类型为T),和两个函数myMap: T => List[(Ki, Vi)] 和 myReduce: (Ki; List[Vi]) ) List[R],代码如下:
data.flatMap(myMap)
.groupByKey()
.map((k, vs) => myReduce(k, vs))
如果任务包含combiner,则相应的代码为:
data.flatMap(myMap)
.reduceByKey(myCombiner)
.map((k, v) => myReduce(k, v))
ReduceByKey操作在mapper节点上执行部分聚集,与MapReduce的combiner类似。
Pregel是面向图算法的基于批量同步并行模型(Bulk Synchronous Parallel paradigm)的编程模型。程序由一系列超步(superstep)协调迭代运行。在每个超步中,各个顶点执行用户函数,并更新相应的顶点状态,变异图拓扑,然后向下一个超步的顶点集发送消息。这种模型能够描述很多图算法,包括最短路径,双边匹配和PageRank等。
以PageRank为例介绍一下Pregel的实现。当前PageRank记为r,顶点表示状态。在每个超步中,各个顶点向其所有邻居发送贡献值(contribution)r/n,这里n是邻居的数目。下一个超步开始时,每个顶点将其分值(rank)更新为 (公式无法显示,参见原文) ,这里的求和是各个顶点收到的所有贡献值的和,N是顶点的总数。
Pregel将输入的图划分(partition)到各个worker上,并存储在其内存中。在每个超步中,各个worker通过一种类似MapReduce的混排(shuffle)操作交换消息。
Pregel的通信模式可以用RDD来描述,如图3。主要思想是:将每个超步中的顶点状态和要发送的消息存储为RDD,然后根据顶点ID分组,进行混排通信(即cogroup操作)。然后对每个顶点ID上的状态和消息应用(apply)用户函数(即mapValues操作),产生一个新的RDD,即(VertexID, (NewState, OutgoingMessages))。然后执行map操作分离出下一次迭代的顶点状态和消息(即mapValues和flatMap操作)。代码如下:
val vertices = // RDD of (ID, State) pairs
val messages = // RDD of (ID, Message) pairs
val grouped = vertices.cogroup(messages)
val newData = grouped.mapValues {
(vert, msgs) => userFunc(vert, msgs)
// returns (newState, outgoingMsgs)
}.cache()
val newVerts = newData.mapValues((v,ms) => v)
val newMsgs = newData.flatMap((id,(v,ms)) => ms)
(图参见原文)
图3 使用RDD实现Pregel时,一步迭代的数据流。(方框表示RDD,箭头表示转换)
需要注意的是,这种实现方法中, RDD grouped,newData和newVerts的分区方法与输入RDD vertices一样。所以,顶点状态一直存在于它们开始执行的机器上,这跟原Pregel一样,这样就减少了通信成本。因为cogroup和mapValues保持了与输入RDD相同的分区方法,所以分区是自动进行的。
完整的Pregel编程模型还包括其他工具,比如combiner,附录A讨论了它们的实现。下面将讨论Pregel的容错性,以及如何在实现相同容错性的同时减少需要执行检查点操作的数据量。
我们差不多用了100行Scala代码在Spark上实现了一个类Pregel的API。7.2小节将使用PageRank算法评估它的性能。
(此处涉及Pregel实现细节,略去,参见原文)
5. RDD的描述及任务调度
我们希望在不修改调度器的前提下,支持RDD上的各种转换(transformation)操作,同时能够从这些转换获取lineage信息。为此,我们为RDD设计了一组小型通用的内部接口。
简单地说,每个RDD都包含:(1)一组RDD分区(partition,即数据集的原子组成部分);(2)对父RDD的一组依赖,这些依赖描述了RDD的血统(lineage);(3)一个函数,即在父RDD上执行何种计算;(4)元数据,描述分区模式和数据存放的位置。例如,一个表示HDFS文件的RDD包含:各个数据块(block)的一个分区,并知道各个数据块放在哪些节点上。而且这个RDD上的map操作结果也具有同样的分区,map函数是在父数据上执行的。表3总结了RDD的内部接口。
操作 |
含义 |
partitions() |
返回一组Partition对象 |
preferredLocations(p) |
根据数据存放的位置,返回分区p在哪些节点访问更快 |
dependencies() |
返回一组依赖 |
iterator(p, parentIters) |
按照父分区的迭代器,逐个计算分区p的元素 |
partitioner() |
返回RDD是否hash/range分区的元数据信息 |
表3 Spark中RDD的内部接口
设计接口的一个关键问题就是,如何表示RDD之间的依赖。我们发现RDD之间的依赖关系可以分为两类,即:(1)窄依赖(narrow dependencies):子RDD的每个分区依赖于常数个父分区(即与数据规模无关);(2)宽依赖(wide dependencies):子RDD的每个分区依赖于所有父RDD分区。例如,map产生窄依赖,而join则是宽依赖(除非父RDD被哈希分区)。另一个例子见图5。
(图参见原文)
图5 窄依赖和宽依赖的例子。(方框表示RDD,实心矩形表示分区)
区分这两种依赖很有用。首先,窄依赖允许在一个集群节点上以流水线的方式(pipeline)计算所有父分区。例如,逐个元素地执行map、然后filter操作;而宽依赖则需要首先计算好所有父分区数据,然后在节点之间混排(shuffle),这与MapReduce类似。第二,窄依赖能够更有效地进行失效节点的恢复,即只需重新计算丢失RDD分区的父分区,而且不同节点之间可以并行计算;而对于一个宽依赖关系的血统(lineage)图,单个节点失效可能导致这个RDD的所有祖先丢失部分分区,因而需要整体重新计算。
通过RDD接口,Spark只需要不超过20行代码实现便可以实现大多数转换。5.1小节给出了例子,然后我们讨论了怎样使用RDD接口进行调度(5.2),最后讨论一下基于RDD的程序何时需要数据检查点操作(5.3)。
HDFS文件:目前为止我们给的例子中输入RDD都是HDFS文件,对这些RDD可以执行:partitions操作返回各个数据块的一个分区(每个Partition对象中保存数据块的偏移),preferredLocations操作返回数据块所在的节点列表,iterator操作对数据块进行读取。
Map:任何RDD上都可以执行map操作,返回一个MappedRDD对象。该操作传递一个函数参数给map,对父RDD上的记录按照iterator的方式执行这个函数,并返回一组符合条件的父RDD分区及其位置。
Union:在两个RDD上执行union操作,返回两个父RDD分区的并集。通过相应父RDD上的窄依赖关系计算每个子RDD分区。(注意union操作不会过滤重复值,相当于SQL中的UNION ALL)
Sample:抽样与映射类似,但是sample操作中,RDD需要存储一个随机数产生器的种子,这样每个分区能够确定哪些父RDD记录被抽样。
Join:对两个RDD执行join操作可能产生窄依赖(如果这两个RDD拥有相同的哈希分区或范围分区),可能是宽依赖,也可能两种依赖都有(比如一个父RDD有分区,而另一父RDD没有)。
调度器根据RDD的结构信息为每个行为(action)确定有效的执行计划。调度器的接口是runJob函数,参数为RDD及其分区集,和一个RDD分区上的函数。该接口足以表示Spark中的所有行为(action,即count,collect,save等)。
总的来说,我们的调度器跟Dryad类似,但我们还考虑了哪些RDD分区是缓存在内存中的。调度器根据目标RDD的血统关系图(lineage graph)创建一个由stage构成的无回路有向图(DAG)。每个stage内部尽可能多地包含一组具有窄依赖关系的转换,并将它们流水线并行化(pipeline)。stage的边界有两种情况:一是宽依赖上的混排(shuffle)操作;二是已缓存分区,它可以缩短父RDD的计算过程。例如图6。父RDD完成计算后,可以在stage内启动一组任务计算丢失的分区。
(图参见原文)
图6 Spark怎样划分任务阶段(stage)的例子。实线方框表示RDD,实心矩形表示分区(黑色表示该分区被缓存)。要在RDD G上执行一个行为(action),调度器根据宽依赖创建一组stage,并在每个stage内部将具有窄依赖的转换流水线化(pipeline)。本例不用再执行stage 1,因为B已经存在于缓存中了,所以只需要运行2和3。
调度器根据数据存放的位置分配任务,以最小化通信开销。如果某个任务需要处理一个已缓存分区,则直接将任务分配给拥有这个分区的节点。否则,如果需要处理的分区位于多个可能的位置(例如,由HDFS的数据存放位置决定),则将任务分配给这一组节点。
对于宽依赖(例如需要混排的依赖),目前的实现方式是,在拥有父分区的节点上将中间结果物化(materialize),简化容错处理,这跟MapReduce中物化map输出很像。
如果某个任务失效,只要stage中的父RDD分区可用,则只需在另一个节点上重新运行这个任务即可。如果某些stage不可用(例如,混排时某个map输出丢失),则需要重新提交这个stage中的所有任务来计算丢失的分区。
最后,lookup行为允许用户从一个哈希或范围分区的RDD上,根据关键字读取一个数据元素。这里有一个设计问题。Driver程序调用lookup时,只需要使用当前调度器接口计算关键字所在的那个分区。当然任务也可以在集群上调用lookup,这时可以将RDD视为一个大的分布式哈希表。这种情况下,任务和被查询的RDD之间的并没有明确的依赖关系(因为worker执行的是lookup),如果所有节点上都没有相应的缓存分区,那么任务需要告诉调度器计算哪些RDD来完成查找操作。
尽管RDD中的lineage信息可以用来故障恢复,但对于那些lineage链较长的RDD来说,这种恢复可能很耗时。例如4.3小节中的Pregel任务,每次迭代的顶点状态和消息都跟前一次迭代有关,所以lineage链很长。如果将lineage链存到物理存储中,再定期对RDD执行检查点操作就很有效。
一般来说,lineage链较长、宽依赖的RDD需要采用检查点机制。这种情况下,集群的节点故障可能导致每个父RDD的数据块丢失,因此需要全部重新计算。将窄依赖的RDD数据存到物理存储中可以实现优化,例如前面4.1小节逻辑回归的例子,将数据点和不变的顶点状态存储起来,就不再需要检查点操作。
当前Spark版本提供检查点API,但由用户决定是否需要执行检查点操作。今后我们将实现自动检查点,根据成本效益分析确定RDD 血统关系图(lineage graph)中的最佳检查点位置。
值得注意的是,因为RDD是只读的,所以不需要任何一致性维护(例如写复制策略,分布式快照或者程序暂停等)带来的开销,后台执行检查点操作。
(未完待续)
本系列文章《弹性分布式数据集:基于内存的集群计算的容错性抽象》系《Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing》译文。原文参见这里。
Archiver|手机版|科学网 ( 京ICP备07017567号-12 )
GMT+8, 2024-9-1 16:25
Powered by ScienceNet.cn
Copyright © 2007- 中国科学报社