Tuning (调试)

Tuning Spark

由于大多数Spark计算的内存特性,Spark程序可能会受到群集中任何资源的瓶颈:CPU,网络带宽或内存。 大多数情况下,如果数据适合内存,瓶颈就是网络带宽,但有时候,您还需要进行一些调整,例如以序列化形式存储RDD,以减少内存使用。 本指南将介绍两个主要主题:数据序列化,这对于良好的网络性能至关重要,还可以减少内存使用和内存调整。 我们还草拟了几个较小的主题。

Data Serialization

序列化在任何分布式应用程序的性能中起着重要作用。 将对象序列化或消耗大量字节的速度慢的格式将大大减慢计算速度。 通常,这将是您应该优化Spark应用程序的第一件事。 Spark旨在在便利性(允许您使用操作中的任何Java类型)和性能之间取得平衡。 它提供了两个序列化库:

  • Java序列化:默认情况下,Spark使用Java的ObjectOutputStream框架序列化对象,并且可以与您创建的任何实现java.io.Serializable的类一起使用。 您还可以通过扩展java.io.Externalizable来更紧密地控制序列化的性能。 Java序列化是灵活的,但通常很慢,并导致许多类的大型序列化格式。
  • Kryo序列化:Spark还可以使用Kryo库(版本2)更快地序列化对象。 Kryo比Java序列化(通常高达10倍)显着更快,更紧凑,但不支持所有Serializable类型,并且需要您提前注册您将在程序中使用的类以获得最佳性能。

您可以通过使用SparkConf初始化作业并调用conf.set(“spark.serializer”,“org.apache.spark.serializer.KryoSerializer”)来切换到使用Kryo。 此设置配置序列化程序,不仅用于在工作节点之间混洗数据,还用于将RDD序列化到磁盘。 Kryo不是默认值的唯一原因是因为自定义注册要求,但我们建议在任何网络密集型应用程序中尝试它。 从Spark 2.0.0开始,我们在使用简单类型,简单类型数组或字符串类型对RDD进行混洗时,内部使用Kryo序列化程序。

Spark自动包含Kryo序列化程序,用于来自Twitter chill库的AllScalaRegistrar中涵盖的许多常用核心Scala类。

要使用Kryo注册自己的自定义类,请使用registerKryoClasses方法。

val conf = new SparkConf().setMaster(...).setAppName(...)
conf.registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2]))
val sc = new SparkContext(conf)

Kryo文档描述了更高级的注册选项,例如添加自定义序列化代码。
如果您的对象很大,您可能还需要增加spark.kryoserializer.buffer配置。 此值必须足够大才能容纳要序列化的最大对象。
最后,如果你没有注册你的自定义类,Kryo仍然会工作,但它必须存储每个对象的完整类名,这是浪费。

Memory Tuning

调整内存使用量有三个注意事项:对象使用的内存量(您可能希望整个数据集适合内存),访问这些对象的成本以及垃圾回收的开销(如果您有大量对象的进出 )。

默认情况下,Java对象访问速度很快,但与其字段中的“原始”数据相比,可以轻松占用2-5倍的空间。 这是由于以下几个原因:

  • 每个不同的Java对象都有一个“对象头”,大约16个字节,并包含诸如指向其类的指针之类的信息。 对于其中包含非常少数据的对象(比如一个Int字段),这可能比数据大。
  • Java字符串在原始字符串数据上有大约40个字节的开销(因为它们将它存储在Chars数组中并保留额外的数据,如长度),并且由于String内部使用UTF-16编码而将每个字符存储为两个字节。 因此,10个字符的字符串很容易消耗60个字节。
  • 公共集合类(例如HashMap和LinkedList)使用链接数据结构,其中每个条目都有一个“包装”对象(例如Map.Entry)。 此对象不仅具有标题,还具有指向列表中下一个对象的指针(通常为8个字节)。
  • 原始类型的集合通常将它们存储为“盒装”对象,例如java.lang.Integer。

本节将首先概述Spark中的内存管理,然后讨论用户可以采取的具体策略,以便在他/她的应用程序中更有效地使用内存。 特别是,我们将描述如何确定对象的内存使用情况,以及如何通过更改数据结构或以序列化格式存储数据来改进它。 然后我们将介绍调整Spark的缓存大小和Java垃圾收集器。

Memory Management Overview

Spark中的内存使用大致属于以下两种类别之一:执行和存储。 执行内存是指用于在随机,连接,排序和聚合中进行计算的内存,而存储内存是指用于在集群中缓存和传播内部数据的内存。 在Spark中,执行和存储共享一个统一的区域(M)。 当没有使用执行内存时,存储可以获取所有可用内存,反之亦然。 如有必要,执行可以驱逐存储,但仅限于总存储内存使用量低于某个阈值(R)。 换句话说,R描述了M中的一个子区域,其中高速缓存的块从未被驱逐。 由于实施的复杂性,存储可能不会驱逐执行。

该设计确保了几种理想的特性。 首先,不使用缓存的应用程序可以使用整个空间执行,从而避免不必要的磁盘溢出。 其次,使用缓存的应用程序可以保留最小的存储空间(R),其中数据块不受驱逐。 最后,这种方法为各种工作负载提供了合理的开箱即用性能,而无需用户内部划分内存的专业知识。

虽然有两种相关配置,但典型用户不需要调整它们,因为默认值适用于大多数工作负载:

  • spark.memory.fraction将M的大小表示为(JVM堆空间 - 300MB)的一小部分(默认值为0.6)。 其余的空间(40%)保留用于用户数据结构,Spark中的内部元数据,以及在稀疏和异常大的记录的情况下防止OOM错误。
  • spark.memory.storageFraction将R的大小表示为M的一小部分(默认值为0.5)。 R是M中的存储空间,其中缓存块不受执行驱逐的影响。

应该设置spark.memory.fraction的值,以便在JVM的旧版或“终身”代中舒适地适应这个堆空间量。 有关详细信息,请参阅下面的高级GC调整讨论。

Determining Memory Consumption(确定内存消耗)

确定数据集所需内存消耗量的最佳方法是创建RDD,将其放入缓存中,然后查看Web UI中的“存储”页面。 该页面将告诉您RDD占用多少内存。

要估计特定对象的内存消耗,请使用SizeEstimator的估计方法这对于尝试使用不同的数据布局来调整内存使用情况以及确定广播变量在每个执行程序堆上占用的空间量非常有用。

Tuning Data Structures

减少内存消耗的第一种方法是避免增加开销的Java功能,例如基于指针的数据结构和包装器对象。 做这件事有很多种方法:

  1. 设计您的数据结构以优先选择对象数组和基本类型,而不是标准的Java或Scala集合类(例如HashMap)。 fastutil库为与Java标准库兼容的基本类型提供了方便的集合类。
  2. 尽可能避免使用包含大量小对象和指针的嵌套结构。
  3. 考虑使用数字ID或枚举对象而不是键的字符串。
  4. 如果RAM少于32 GB,请设置JVM标志-XX:+ UseCompressedOops以使指针为四个字节而不是八个字节。 您可以在spark-env.sh中添加这些选项。

Serialized RDD Storage

尽管进行了这种调整,但是当对象仍然太大而无法有效存储时,减少内存使用的一种更简单的方法是使用RDD持久性API中的序列化StorageLevels(如MEMORY_ONLY_SER)以序列化形式存储它们。 然后,Spark将每个RDD分区存储为一个大字节数组。 由于必须动态地反序列化每个对象,因此以序列化形式存储数据的唯一缺点是访问时间较慢。 如果您希望以序列化形式缓存数据,我们强烈建议使用Kryo,因为它导致比Java序列化(当然比原始Java对象)小得多的尺寸。

Garbage Collection Tuning(垃圾收集调整)

当您根据程序存储的RDD进行大量“流失”时,JVM垃圾回收可能会出现问题。 (在读取RDD一次然后在其上运行许多操作的程序中通常不会出现问题。)当Java需要逐出旧对象以便为新对象腾出空间时,它需要遍历所有Java对象并查找 未使用的。 这里要记住的要点是垃圾收集的成本与Java对象的数量成比例,因此使用具有较少对象的数据结构(例如Int数组而不是LinkedList)会大大降低此成本。 更好的方法是以序列化形式持久化对象,如上所述:现在每个RDD分区只有一个对象(一个字节数组)。 在尝试其他技术之前,首先要尝试GC是一个问题是使用序列化缓存。

由于任务的工作内存(运行任务所需的空间量)与节点上缓存的RDD之间的干扰,GC也可能是一个问题。 我们将讨论如何控制分配给RDD缓存的空间以缓解这种情况。

Measuring the Impact of GC(测量GC的影响)
GC调优的第一步是收集有关垃圾收集发生频率和GC使用时间的统计信息。 这可以通过将-verbose:gc -XX:+ PrintGCDetails -XX:+ PrintGCTimeStamps添加到Java选项来完成。 (有关将Java选项传递给Spark作业的信息,请参阅配置指南。)下次运行Spark作业时,每次发生垃圾收集时,您都会看到工作日志中打印的消息。 请注意,这些日志将位于群集的工作节点上(位于其工作目录中的stdout文件中),而不是位于驱动程序上。

Advanced GC Tuning
为了进一步调整垃圾收集,我们首先需要了解JVM中有关内存管理的一些基本信息:

  • Java堆空间分为Young和Old两个区域。 Young代表意味着持有短命的物体,而老一代则用于生命周期较长的物体。
  • 年轻一代进一步分为三个区域[Eden,Survivor,Survivor 2]。
  • 垃圾收集过程的简化描述:当Eden已满时,在Eden上运行次要GC,并将从Eden和Survivor1中存活的对象复制到Survivor2。 幸存者地区被交换。 如果对象足够大或Survivor2已满,则将其移至Old。 最后,当Old接近满时,将调用完整的GC。

Spark中GC调整的目标是确保只有长寿命的RDD存储在Old代中,并且Young代的大小足以存储短期对象。 这将有助于避免完整的GC收集在任务执行期间创建的临时对象。 可能有用的一些步骤是:

  • 通过收集GC统计信息来检查是否有太多垃圾收集。 如果在任务完成之前多次调用完整的GC,则意味着没有足够的内存可用于执行任务。
  • 如果有太多的次要集合,但没有很多主要的GC,为Eden分配更多的内存将有所帮助。 您可以将Eden的大小设置为高估每个任务所需的内存量。 如果确定Eden的大小为E,则可以使用选项-Xmn = 4/3 * E设置Young代的大小。 (按比例增加4/3也是为了解释幸存者地区使用的空间。)
  • 在打印的GC统计信息中,如果OldGen接近满,则通过降低spark.memory.fraction来减少用于缓存的内存量; 缓存更少的对象比减慢任务执行速度更好。 或者,考虑减小Young代的尺寸。 这意味着如果你按上面的设置降低-Xmn。 如果没有,请尝试更改JVM的NewRatio参数的值。 许多JVM将此默认为2,这意味着旧一代占据堆的2/3。 它应该足够大,使得这个分数超过spark.memory.fraction
  • 尝试使用-XX:+ UseG1GC的G1GC垃圾收集器。 在垃圾收集成为瓶颈的某些情况下,它可以提高性能。 请注意,对于大型执行程序堆大小,使用-XX增加G1区域大小可能很重要:G1HeapRegionSize
  • 例如,如果您的任务是从HDFS读取数据,则可以使用从HDFS读取的数据块的大小来估计任务使用的内存量。 请注意,解压缩块的大小通常是块大小的2或3倍。 因此,如果我们希望有3或4个任务的工作空间,并且HDFS块大小为128 MB,我们可以估计Eden的大小为4 * 3 * 128MB。
  • 监视垃圾收集所用频率和时间如何随新设置而变化。

我们的经验表明,GC调整的效果取决于您的应用程序和可用内存量。 在线描述了更多的调优选项,但在较高的层次上,管理完整GC的发生频率可以帮助减少开销。

可以通过在作业配置中设置spark.executor.extraJavaOptions来指定执行程序的GC调整标志。

Other Considerations(注意事项)

Level of Parallelism

除非您为每个操作设置足够高的并行度,否则将无法充分利用群集。 Spark会根据文件的大小自动设置要在每个文件上运行的“map”任务的数量(尽管可以通过SparkContext.textFile等可选参数控制它),以及分布式“reduce”操作,例如groupByKey和reduceByKey, 它使用最大父RDD的分区数。 您可以将并行级别作为第二个参数传递(请参阅spark.PairRDDFunctions文档),或者将config属性设置为spark.default.parallelism以更改默认值。 通常,我们建议群集中每个CPU核心有2-3个任务。

Memory Usage of Reduce Tasks

有时,你会得到一个OutOfMemoryError,因为你的RDD不适合内存,但是因为你的一个任务的工作集,比如groupByKey中的一个reduce任务,太大了。 Spark的shuffle操作(sortByKey,groupByKey,reduceByKey,join等)在每个任务中构建一个哈希表来执行分组,这通常很大。 这里最简单的解决方法是增加并行度,以便每个任务的输入集更小。 Spark可以有效地支持短至200毫秒的任务,因为它在多个任务中重用一个执行程序JVM,并且它具有较低的任务启动成本,因此您可以安全地将并行度提高到超过群集中的核心数。

Broadcasting Large Variables

使用SparkContext中提供的广播功能可以大大减少每个序列化任务的大小,以及在群集上启动作业的成本。 如果您的任务使用其中的驱动程序中的任何大对象(例如静态查找表),请考虑将其转换为广播变量。 Spark打印主服务器上每个任务的序列化大小,因此您可以查看它以确定您的任务是否过大; 一般来说,大于约20 KB的任务可能值得优化。

Data Locality

数据位置可能会对Spark作业的性能产生重大影响。 如果数据和在其上运行的代码在一起,那么计算往往很快。 但是如果代码和数据是分开的,那么必须移动到另一个。 通常,将序列化代码从一个地方运送到另一个地方比一块数据更快,因为代码大小比数据小得多。 Spark围绕数据局部性的一般原则构建其调度。

数据位置是数据与处理它的代码的接近程度。 根据数据的当前位置,有多个级别的位置。 从最近到最远的顺序:

  • PROCESS_LOCAL数据与正在运行的代码位于同一JVM中。 这是最好的地方
  • NODE_LOCAL数据位于同一节点上。 示例可能位于同一节点上的HDFS中,也可能位于同一节点上的另一个执行程序中。 这比PROCESS_LOCAL慢一点,因为数据必须在进程之间传输
  • 从任何地方都可以快速访问NO_PREF数据,并且没有位置偏好
  • RACK_LOCAL数据位于同一机架服务器上。 数据位于同一机架上的不同服务器上,因此需要通过网络发送,通常通过单个交换机
  • ANY 数据都在网络上的其他位置,而不是在同一个机架中

Spark更喜欢在最佳位置级别安排所有任务,但这并非总是可行。 在任何空闲执行程序上没有未处理数据的情况下,Spark会切换到较低的位置级别。 有两种选择:a)等待繁忙的CPU释放以启动同一服务器上的数据任务,或b)立即在需要移动数据的较远位置启动新任务。

Spark通常会做的是等待繁忙的CPU释放的希望。 一旦超时到期,它就开始将数据从远处移动到空闲CPU。 每个级别之间的回退等待超时可以单独配置,也可以在一个参数中一起配置; 有关详细信息,请参阅配置页面上的spark.locality参数。 如果您的任务很长并且看不到位置,则应该增加这些设置,但默认情况通常很有效。

Summary(概要)

他一直是一个简短的指南,指出在调整Spark应用程序时应该了解的主要问题 - 最重要的是,数据序列化和内存调整。 对于大多数程序,切换到Kryo序列化并以序列化形式保存数据将解决最常见的性能问题。 请随时在Spark邮件列表中询问有关其他调优最佳做法的信息。

你可能感兴趣的:(Tuning (调试))