指南:优化Apache Spark作业(第1部分)

说明:借助谷歌翻译,以个人理解进行修改

原文地址:http://blog.cloudera.com/blog/2015/03/how-to-tune-your-apache-spark-jobs-part-1/

学习调整Apache Spark作业以获得最佳效率的技巧。

当您通过公共API编写Apache Spark代码和页面时,您会遇到像transformation,action和RDD这样的单词。在这个层面上理解Spark对于编写Spark程序至关重要。同样,当事情开始失败时,或者当你冒险进入Web界面以试图理解为什么你的应用程序需要这么长时间时,你会遇到新词汇,如job,stage和task。在这个级别理解Spark对于编写好的 Spark程序是至关重要的。要编写一个能够高效执行的Spark程序,理解Spark的底层执行模型非常有用。

在这篇文章中,您将学习如何在集群上实际执行Spark程序的基础知识。然后,您将得到一些关于Spark的执行模型对编写高效程序意味着什么的实用建议。

Spark如何执行你的程序

Spark应用程序由一个driver进程和一组分散在集群节点上的executor进程组成。

Driver是负责需要完成的高水平控制工作流程的过程,executor负责以任务的形式执行这项工作,并负责存储用户选择缓存的任何数据。Driver和Executor通常都会在应用程序运行的整个过程中保持运行状态,但动态资源分配会改变后者的状态。一个executor有许多用于运行任务的插槽,并且在其整个生命周期中将同时运行多个插槽。在集群上部署这些进程取决于正在使用的集群管理器(YARN,Mesos或SparkStandalone),但driver和executor本身存在于每个Spark应用程序中。


在执行层次结构的顶部是job。在Spark应用程序中调用action算子会触发Spark作业的启动。为了决定这个工作是什么样子,Spark检查该action算子所依赖的RDD的并制定一个执行计划。该计划从最远的RDD开始 - 即那些不依赖其他RDD或已经缓存数据的RDD - 并在最终的RDD中产生action算子所需的结果。

执行计划包括将job划分成多个stage。一个stage对应于所有执行相同代码的任务的集合,每个代码都在不同的数据子集上。每个stage都包含一系列转换,可以在不对全部数据进行shuffling的情况下完成。

什么决定了数据是否需要洗牌?回想一下,RDD包含固定数量的分区,每个分区包含多个记录。对于通过所谓的窄转换(如map和filter)返回的RDD ,计算单个分区中的记录所需的记录驻留在父RDD中的单个分区中。每个对象只依赖于父对象中的一个对象。像coalesce这样的操作可能会导致一个任务处理多个分区,但转换仍被认为是狭窄的,因为用于计算任何单个输出记录的输入记录仍然只能位于分区的有限子集中。

但是,Spark也支持具有广泛依赖性的转换,例如groupByKey和reduceByKey。在这些依赖关系中,计算单个分区中记录所需的数据可能驻留在父RDD的许多分区中。所有具有相同key的元组都必须在同一个分区中,由同一个任务处理。为了满足这些操作,Spark必须执行一个shuffle,这个shuffle可以在集群中传输数据,并产生一个包含新分区集合的stage。

 

例如,请考虑以下代码:

sc.textFile("someFile.txt").

map(mapFunc).

flatMap(flatMapFunc).

filter(filterFunc).

count()

它执行单个操作,这取决于从文本文件派生的RDD上的一系列转换。该代码将在一个stage中执行,因为这三个操作的输出都不依赖于可能来自不同分区输入的数据。

 

相比之下,以下代码会查找每个字符在文本文件中出现超过1,000次的所有单词中的次数。

val tokenized =sc.textFile(args(0)).flatMap(_.split(' '))

val wordCounts = tokenized.map((_,1)).reduceByKey(_ + _)

val filtered = wordCounts.filter(_._2 >=1000)

val charCounts =filtered.flatMap(_._1.toCharArray).map((_, 1)).

 reduceByKey(_ + _)

charCounts.collect()

这个过程将分解成三个阶段。这些reduceByKey操作导致stage边界,因为计算它们的输出需要通过key对数据进行重新分区。

这是一个更复杂的转换图,其中包含具有多个依赖关系的联合转换。

粉色框显示用于执行它的结果阶段图。

在每个stage边界上,数据通过父阶段中的任务写入磁盘,然后子stage中的任务通过网络获取。因为它们会产生大量的磁盘和网络I / O,所以stage边界可能很耗资源,应尽可能避免。父stage中的数据分区的数量可能不同于子stage中的分区的数量。可能触发stage边界的转换通常会接受一个numPartitions参数,该参数确定在子阶段中将数据分成多少个分区。

正如reducers的数量是调优MapReduce作业的重要参数一样,调整stage边界处的分区数通常会影响应用程序的性能。我们将在后面的章节中深入研究如何调整分区的数量。

选择合适的Operators

当试图用Spark来完成某件事情时,开发人员通常可以从许多actions和transformations中进行选择,以产生相同的结果。但是,并非所有这些安排都会产生相同的性能:避免常见的陷阱并选择正确的安排可能会使应用程序的性能发生变化。当这些选择出现时,一些规则和见解将帮助你定位自己。

最近在SPARK-5097上的工作开始稳定SchemaRDD,它将向使用Spark核心API的程序员开放Spark的Catalyst优化器,允许Spark做出关于使用哪些运算符的更高层次的选择。当SchemaRDD成为一个稳定的组件时,用户将不需要做出某些决定。

选择操作员安排时的主要目标是减少shuffle的次数和shuffle的数据量。这是因为shuffle是相当耗资源的操作; 所有的shuffle数据必须写入磁盘,然后通过网络传输。 repartition,join,cogroup,和任何的*By或*ByKeytransformation类算子都会产生shuffle。然而,并不是所有的操作都是相同的,对于Spark初学者来说,最常见的一些性能陷阱就是选择错误的操作:

  • 避免使用groupByKey执在行关联还原操作时。

例如,rdd.groupByKey().mapValues(_.sum) 会产生与rdd.reduceByKey(_ + _)相同的结果。然而,前者将在网络中传输整个数据集,而后者将在每个分区中计算每个key的局部总和,并在shuffle之后将这些局部总和合并为更大的总和。

  • 避免使用reduceByKey当输入和输出值类型不同时。

例如,考虑编写一个转换,查找与每个key相对应的所有唯一字符串。一种方法是使用map将每个元素转换为a Set,然后将Sets与reduceByKey以下内容组合:

rdd.map(kv => (kv._1, new Set[String]() + kv._2))

    .reduceByKey(_ ++ _)

此代码导致大量不必要的对象创建,因为必须为每个记录分配一个新的集合。最好使用它aggregateByKey,它可以更高效地执行map-side聚合:

val zero = new collection.mutable.Set[String]()

rdd.aggregateByKey(zero)(

    (set, v) => set += v,

    (set1, set2) => set1++= set2)

  • 避免这种flatMap-join-groupBy模式。

当两个数据集已经按key分组,并且您想要执行join并保持分组时,您可以使用cogroup。这可以避免与拆包和重新包装组相关的所有开销。

当不发生shuffle时

了解上述转换在不会导致shuffle的情况下也很有用。当前一个转换已经根据相同的分区程序对数据进行分区时,Spark知道避免洗牌。考虑以下流程:

rdd1 = someRdd.reduceByKey(...)

rdd2 = someOtherRdd.reduceByKey(...)

rdd3 = rdd1.join(rdd2)

由于不传递reduceByKey分区程序,因此将使用默认分区程序,从而导致对rdd1和rdd2进行散列分区。这两个reduceByKey将导致两次shuffle。如果RDD具有相同数量的分区,则join不需要额外的shuffle。由于RDD的划分方式相同,rdd1的任何单个分区中的key集只能显示在rdd2的单个分区中。因此,rdd3的任何单个输出分区的内容将仅取决于rdd1中的单个分区和rdd2中的单个分区的内容,并且不需要第三个shuffle。

例如,如果someRdd有四个分区,someOtherRdd有两个分区,并且两个reduceByKey都使用三个分区,则执行的一组任务如下所示:

如果rdd1和rdd2使用不同的分区器或使用默认(hash)分区器分成不同分区数会怎样?在这种情况下,只有一个rdd(分区数量较少的rdds)需要重新洗牌才能join。


相同的转换,相同的输入,不同数量的分区:

两个数据集join时避免shuffle的一种方法是利用广播变量。当其中一个数据集足够小以适应单个executor的内存时,它可以加载到driver的哈希表中,然后广播给每个executor。然后,映射转换可以引用哈希表来执行查找。

什么时候Shuffle越多越好

最小化shuffle数量的规则偶尔是个例外。当增加并行性时,额外的shuffle可以有利于性能。例如,如果数据到达几个不可分割的大文件,那么由该分区决定的分区InputFormat可能会在每个分区中放置大量记录,而不会生成足够的分区来利用所有可用的内核。在这种情况下,在加载数据后调用具有大量分区(这将触发shuffle)的重新分区将允许在其之后的操作利用集群的更多CPU。

使用reduce或aggregate操作将数据聚合到driver程序时,可能会出现此异常的另一个实例。在聚合大量分区时,将所有结果合并在一起的计算可能会很快成为driver程序中的单个线程的瓶颈。为了减轻driver程序的负载,可以首先使用reduceByKey或aggregateByKey执行一轮分布式聚合,将数据集分成较少数量的分区。将每个分区中的值并行合并,然后将结果发送给driver程序以进行最后一轮聚合。看看treeReduce和treeAggregate是如何做到这一点的例子。(请注意,在撰写本文时最新版本1.2中,这些标记为开发人员API,但SPARK-5430 试图在核心中添加它们的稳定版本。)

当聚合已经由key分组时,这个技巧特别有用。例如,考虑一个应用程序,该程序要计算语料库中每个单词的出现次数,并将结果作为map传入driver程序。一种可以通过聚合操作完成的方法是计算每个分区的本地map,然后在driver程序处合并map。可以通过完成的替代方法aggregateByKey是以完全分散的方式执行计数,然后简单地collectAsMap将结果传递给driver。

二次排序

另一个需要注意的重要功能是repartitionAndSortWithinPartitions转换。这是一种听起来神秘的转换,但似乎出现在各种奇怪的排序情景中。这种转换将排序转移到了shuffle中,以便有效地分散大量数据,并且可以将排序与其他操作结合起来。

例如,Spark上的Apache Hive在其join实现中使用此转换。当您希望按key值对记录进行分组,然后在遍历与某个key对应的值,按特定顺序显示它们时,它也是二次排序模式中的重要构建模块。这个问题出现在需要用户分组,然后根据它们发生的顺序分析每个用户事件的算法中。利用repartitionAndSortWithinPartitions二次排序功能,当前需要用户进行一些修改,但SPARK-3655将大大简化。

结论

您现在应该对参与创建高性能的Spark程序的基本因素有一个很好的理解!在第2部分中,我们将介绍调优资源请求,并行和数据结构。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值