Spark: Cluster Computing with Working Sets
阅读笔记
概述:
- 本文发表于2010年,早于同一作者2年后发表的《Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing》。
- 文章介绍了基于RDD的分布式计算模型以及早期Spark的实现。
研究背景:
- Mapreduce及其变种分布式计算模型对商业集群上运行的大规模密集型数据集的应用中取得很好的效果。
- 然而大多数这类系统都是围绕着一个非迭代型的数据流模型,不适用于目前一些主流的应用程序。
- 诸多Hadoop用户及学术界产业界报告指出MapReduce框架本身无法很好实现的两个典型例子:
- 迭代作业:许多常见机器学习算法需要在同一数据集上重复应用同一个方法进行参数迭代优化工作(如梯度下降算法)。但MapReduce或Dryad中,每次迭代都被表示为一个新的任务,而每个任务都必须重新从磁盘上加载数据,造成了明显的性能损失。
- 交互式分析:Hadoop常通过SQL接口,如hive或pig,在超大的数据集上执行即席数据查询,理想情况下,用户将感兴趣的数据集加载到多台计算机的内存中并反复查询。然而Hadoop中,每个查询都被视为一个独立的MapReduce作业,从磁盘中读取数据,也会导致显著的延迟(秒级别)。
主要工作:
- 这篇文章的研究工作针对数据需要重复使用,并且跨多个任务节点和数据集进行并行操作的工作。
- 提出了一种叫做“Spark”的集群计算框架,该框架支持应用程序并行处理作业,并具有像MapReduce一样的高可伸缩性和容错性。
- 对于迭代式机器学习作业,Spark可达到超过Hadoop 10倍以上的处理速度,并可实现对39GB的数据集以亚秒级的响应时间进行交互式查询。
Spark编程模型:
- 使用Spark,需要开发人员编写、实现其应用的高层控制流程以及并行操作的驱动程序。
- Spark提供了并行运算编程的两个主要的抽象概念:
- 在数据集上创建弹性分布式数据集
- 并行运算操作(通过传递函数的方式对数据集进行调用)
- 另外,Spark支持在集群上运行的函数中使用两种受限制的共享变量类型。
- 广播变量
- 累加器
关于RDD:
- Spark引入弹性分布式数据集(Resilient Distributed Datasets ,RDDs)这一概念。
- 一个RDD是一个只读的,可分区的分布式数据集。该数据集合如果丢失,可以通过由相关数据集对丢失分区进行重新计算获得。
- Spark中,每个RDD由Scala对象来表示,Spark允许程序员从以下四个方面来构建RDDS:
- 从共享文件系统中获取,如可以从Hadoop 的分布式文件系统(HDFS)中读取数据来构建RDDS。
- 通过驱动程序中的用于并行计算的Scala集合进行创建(如在驱动程序中构建的一个array对象),这个集合将被划分成若干部分发送到多个节点上。
- 通过转换现有的RDDS。可通过用户定义的函数进行如下转换:Atype-> List(Btype),将一个类型为Atype的数据集的每个元素转换为Btype类型。
- 通过进行持久化来改变现有的RDD。数据集分区一般在并行操作时才进行实例化,使用完后就会从内存中丢弃。用户可以通过两种方式来对RDD进行持久化:
- 放到缓存中的数据集将进行延时处理,可显示声明首次计算后将数据集保存在在内存中而不丢弃。
- 将对操作处理过的数据集进行验证,并将其写入到一个分布式的文件系统中(如HDFS)。
关于并行处理:
- 以下几种并行操作可以在RDDS上实现:
- 聚合(reduce):在驱动程序中使用相关函数对数据集进行聚合。
- 收集(collect):发送该数据集的所有元素到驱动程序中,例如并行化映射和收集整个数组。
- 遍历(foreach):通过用户自定义的函数遍历每个元素。
注:这篇文章发表时Spark支持的并行处理算子尚不完善,在后来的《Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing》给出了更完备的Spark支持的并行处理操作集合。
关于共享变量:
- 程序员可以在Spark中通过传递闭包(一些功能代码块,可在代码中使用或作为参数传递)调用如map、filter和reduce等操作。
- 传递闭包是典型的函数式编程方式。通常,当Spark的工作节点运行闭包时,相关变量将被拷贝到该节点去。
- 除此之外,Spark还允许程序员创建两个受限的共享变量来支持以下两种简单而常见的使用场景:
- 广播变量:如果有一个很大的只读数据片段(如一个查询表)需用于多个并行的操作,最好是把它一次分发给每个worker,而不是每一个闭包里面都去做一个封装。
- 累加器:worker只能对它进行“add”操作,并且只有驱动程序(driver)可以访问它。可用来提供MapReduce上那样的计数功能和并行累加操作。累加器可被定义为任何类型,需定义一个“add”的操作以及一个“0”值。
示例一:文本查询
背景:假设在HDFS上面保存有一批超大数量日志文件,现在需要查找其中的表示错误的行。那么可以通过以下方式,
一般从创建一个文件数据集对象的方式开始。
实现:
val file = spark.textFile("hdfs://...")
val errs = file.filter(_.contains("ERROR"))
val ones = errs.map(_ => 1)
val count = ones.reduce(_+_)
说明:
- 首先用HDFS中的文件创建一个分布式数据集,作为一个行的集合
- 接着用这个数据集来创建包含“error”的行的集合(errs)
- 然后把每一行都映射为1(找到一行,就计数为1)
- 最后采用聚合函数将这些数据累加起来
- 其中filter、map和reduce都是Scala的功能函数名称
注意: 如果我们想重新使用errs数据集,只需要采用如下语句来从缓存中创建RDD即可
val cachedErrs = errs.cache()
说明:
- 上述语句执行之后,就可以从缓存中调用errs了,可像调用其他的数据集一样对其进行并行操作。
- 但errs的内容是在第一次计算后的缓存在内存中的结果,故使用该缓存数据会大大加快后续操作。
示例二:逻辑回归
背景:给定一组点集,通过迭代分类算法,试图找到一个超平面w把两个点集合分隔开。
实现:
// Read points from a text file and cache them
val points = spark.textFile(...).map(parsePoint).cache()
// Initialize w to random D-dimensional vector
var w = Vector.random(D)
// Run multiple iterations to update w
for (i <- 1 to ITERATIONS) {
val grad = spark.accumulator(new Vector(D))
for (p <- points) { // Runs in parallel
val s = (1/(1+exp(-p.y*(w dot p.x)))-1)*p.y
grad += s*p.x
}
w -= grad.value
}
说明:
- 该算法采用梯度下降法,开始给w赋一个随机值。
- 在每次迭代中对w的结果进行修正,移动w的方向对结果进行优化。
- 首先创建一个名为points的RDD节点,我们通过运行一个循环来处理它。
- for关键字是Scala里面用于表示调用循环的语法,里面的循环体类似于foreach方法。
- 代码"for(p <- points){body}"等同于"points.foreach(p =>{body})",在此调用了Spark的并行foreach操作。
- 其次定义了一个名为grad的梯度累加器(类型为Vector)。需要注意的是,循环体中的累加是并行执行的。
示例三:交替最小二乘法
背景:交替最小二乘法用于处理协同过滤的问题,例如要通过用户对电影观看历史和评分来预测他们喜欢的电影。该算
法是CPU密集型的,而不是数据密集型的。
算法描述:
- 假设我们需要预测用户u对电影m的评分,而我们已经有了很多以往用户对电影的观看数据矩阵R。
- 模型R是两个矩阵M和U的运算结果,M和U的尺寸分别是 M * U 和 K * U。
- 每个用户和影片都有一个K维的“特征向量”,描述了它的特点和用户给予它的评价。
- 该特征向量就是用户评级和电影的特点的内积。
- 交替最小二乘法解决了使用已知的观看评价的M和U,然后计算M*U矩阵的未知值的预测算法。以下使用迭代过程来实现:
- 1、使用随机值初始化M
- 2、计算优化U给定M的预测模型R,最大限度的减少错误。
- 3、计算优化M给定U的预测模型R,最大限度的减少错误。
- 4、重复2、3两步,直到收敛。
实现:
val Rb = spark.broadcast(R)
for (i <- 1 to ITERATIONS) {
U = spark.parallelize(0 until u)
.map(j => updateUser(j, Rb, M))
.collect()
M = spark.parallelize(0 until m)
.map(j => updateUser(j, Rb, U))
.collect()
}
说明:
- 可通过在每个节点上并行运行步骤2和步骤3来更新不同用户\电影的信息。
- 由于所有的步骤都使用模型矩阵R,可可以将R变成广播变量,这样做是很有效的。
- 这样它就不会在各个节点的所有操作步骤中,都要求被重新发送到每个节点上。
- 通过parallelize方法,获取了0 until U(until是Scala的范围处理方法)
- 使用collect方法(用于把RDD中的所有元素倒入 Scala集合类型),来更新每个数组。
关于Spark实现:
- 在内部,每个RDD对象实现了三个简要接口,包括如下三个操作:
- getPartitions:返回数据分块ID的列表。
- getIterator(partition):迭代一个数据分块
- getPreferredLocations(partition):用来进行任务调度,以实现数据局部特性。
- 当数据集被调用进行并行操作时Spark创建一个任务并将任务分发到节点处理。
- Spark设法把每个任务都发送到其首选的位置(最优位置),这种技术称之为“延迟调度”(delay scheduling)。
- 一旦worker开始工作,那么处理任务时都需要用getIterator方法来对数据分块进行读取。
- 不同类型的RDD之间只是接口不同而已。例如对于一个HdfsTextFile,该数据分块就是HDFS块上的ID,首选的位置就是block的位置。getIterator打开一个数据流用以读取block。
- 将作业传递给workers这一过程需要通过发送闭包给workers完成。闭包既可以用来定义一个分布式数据集,也可以用来传递reduce这样的操作。
- Scala闭包也是Java对象,也可以通过Java序列化机制进行序列化。这是Scala的特性,可以相对简单地将各种计算处理的过程直接发送到另外一台机器上。
- 共享变量使用带有序列化格式的自定义类来实现。
- Spark整合了Scala的解释器。并做了两点变化:
- 实现了一个共享文件系统的解释器输出类。使得worker能通过自定义的java类加载器加载它们。
- 修改了代码生成逻辑,使得每行上创建的单例对象能直接引用各个行对象的实例,而不是通过静态方法。
- Hadoop每次迭代任务耗时127秒,每个MapReduce任务都是独立运行。
- Spark第一次迭代用了174秒(可能由于使用Scala代替了Java),后续迭代都只需要6秒,因为每个缓存中的数据多可以复用,这使得运行速度加快了10倍以上。
- Spark相比Hadoop性能提升了2.8倍。
- 第一次查询,大约需要35秒,和Hadoop上工作差不多。
- 随后的查询,即使是要求它们扫描整个数据集,也只需要0.5到1秒就可以完成。