概述
翻译Spark官方调优指南Tuning Spark。
Data Serialization
序列化在分布式程序中扮演着重要角色,序列化较慢或者序列化结果较大均会降低计算速度。Spark在易用性和性能之间做了权衡,提供了两种实现,如下
- JavaSerializer : Spark默认的Serializer,基于java.io.ObjectOutputStream、java.io.ObjectInputStream实现,优点是,可以序列化任意实现Serializable接口的class,易用性强;缺点也明显,序列化速度慢,性能差,序列化的结果较大。
- KryoSerializer : Kryo相对于JavaSerializer性能更好,序列化结果更小,唯一明显的不足,需要注册(register)自定义的class类型,这也是KryoSerializer不是默认Serializer的主要原因。
在代码中通过SparkConf设置使用KryoSerializer ,如下
conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
Serializer主要在两个阶段发挥作用:
- 数据溢写磁盘、Shuffle中间结果写入磁盘
- Shuffle阶段数据写入内存(Tungsten)
上述第一点不仅减少了磁盘空间使用,也减少了后续传输Shuffle中间结果的网络开销,第二点是Tungsten项目优化内存的重点,即在内存中存储序列化的对象,大大降低内存开销。
使用KryoSerializer,需要注册自定义class,模板代码如下
val conf = new SparkConf().setMaster(...).setAppName(...) conf.registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2])) val sc = new SparkContext(conf)
如果使用中遇到buffer limit exceeded异常,则需要增大spark.kryoserializer.buffer.max(默认64M)参数,更多使用及测试参考KryoSerializerSuite。
最后,如果不注册class,Kryo仍然可以使用,但是性能会变差,因为每个序列化对象都要存储一份完整对象名(full class name)。
Memory Tuning
主要从以下三个方面理解内存的调优
- 数据(objects)占用空间的大小
- 访问数据(objects)的耗时
- GC延时
Java对象比占用空间比原始数据大,但访问Java对象更快,关于Java对象空间开销参考我的博客Spark 内存管理之Tungsten中Spark中JVM的不足小节。Java对象空间开销大主要原因如下
- Java对象中额外存储header、hash信息
- 编码带来的开销,如UTF-8编码至少占2 byte
- 集合类,如List、Map等还存储了length、capacity、load factor等信息
- 基本数据类型被转成包装类存储在集合中,增加了对象的开销
接下来的内容,依次是,内存管理的概述,查看数据占用空间,优化数据结构,存储序列化的数据及GC调优。
Memory Management Overview
Spark将内存划分为Execution、Storage、Other三部分,Execution存储Shuffle过程中joins、sorts、aggregations的数据,Storage用于cache、broadcast及传输结果给driver。
Spark实现了两个内存管理器StaticMemoryManager、UnifiedMemoryManager,两者主要区别在于,后者Execution和Storage的界限是不固定的,可以互相借用对方内存,但是,当Storage借用了Execution内存且Execution内存不足时,Execution会要回Storage借用的内存,反之,Execution并不会返还借用的内存。这样处理使得内存的使用更为充分,UnifiedMemoryManager也成为Spark 1.6及以后版本默认的内存管理器。
更多内容及参数设置参考我的博客Spark 内存管理概述,Spark 内存管理之StaticMemoryManager,Spark 内存管理之UnifiedMemoryManager。
Determining Memory Consumption
估算数据的内存占用,能够更好的进行内存调优,最好的也是最简单的方式是,创建RDD并cache(MEMORY_ONLY或MEMORY_ONLY_SER),在Web UI中Storage页面查看内存使用情况。
对于单个对象,Spark提供了SizeEstimator工具查看其内存占用,可以用来估算broadcast变量内存占用,代码如下
SizeEstimator.estimate(new java.lang.Boolean(true))
Tuning Data Structures
避免由于Java对象带来的内存开销,如下
- 避免使用Java、Scala集合类(如List、Map),使用数组、基本数据类型和fastutil
- 避免设计或使用带有嵌套和指针的class,如HashMap的设计
- 能使用number或enumeration类型作为key,不使用String
- 如果是64位机器并且内存小于32G,使用-XX:+UseCompressedOops参数压缩指针,参数可以配置在spark-env.sh
Serialized RDD Storage
cache是Spark一个重要功能,StorageLevel选择MEMORY_ONLY_SER或MEMORY_AND_DISK_SER会降低内存开销,不足是序列化及反序列化带来的CPU开销,推荐使用Kryo,性能好。
Garbage Collection Tuning
当使用RDD的cache(persist)时,内存使用增加,GC成为关注的重点。Java判断对象是否可用进行清理,因此对象的数量是影响GC的一个因素,使用int[]而不是List< Integer >能显著减少对象数量。
内存管理中Execution和Storage之间的相关作用,也是影响GC的一个关键点,后续会介绍通过参数进行相关调整。
Measuring the Impact of GC
GC调优首先要收集GC的相关信息,主要为GC频率和耗时,配置如下
spark-submit --name "My app" --master local[*] --conf "spark.executor.extraJavaOptions= -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:/tmp/executor.gc.out" --conf "spark.driver.extraJavaOptions= -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:/tmp/driver.gc.out" myApp.jar
GC日志会写入对应的/tmp/executor.gc.out、/tmp/driver.gc.out文件,通过Spark Web UI观察Executor运行情况,查找运行慢的节点,查看对应节点的GC日志。
Advanced GC Tuning
进行GC调优前,先对GC机制作简单介绍。
JVM内存模型将heap划分为如上几部分,各部分比例为Eden:S0:S1=8:1:1,Young:Old=1:2,如下
Young Gen | Eden | 8 | 1 |
Survivor0 | 1 | ||
Survivor1 | 1 | ||
Old Gen | 2 |
GC过程如上图,分为Minor GC和Major GC,如下
- Minor GC:新生成的对象存放在Eden区,Eden区满了触发Minor GC,存活的对象转移到Survivor中的一个,Eden再次满了,检查Eden和存有对象的Survivor区,存活的对象转义到另一个Survivor区,如此反复上述过程,存活一定次数的对象放入Old区,此外,如果上述过程出现Survivor放不下全部存活对象时,也会放入Old区,最后,大对象会直接放入Old区。
- Major GC:Old区满了触发Major GC,Major GC会”Stop the World”,程序hang住且没有任何日志信息很可能是在执行Major GC,使用CMS垃圾回收器(-XX:+UseConcMarkSweepGC)能够降低”Stop the World”导致的延迟,当通过Web UI观察Spark程序GC时间较长时,使用该参数调优。
上面介绍了JVM GC,Spark GC调优希望能够做到,Old区只是long-lived objects,Young区是short-lived objects,从而减少Major GC频率,具体方法如下
- 通过Spark Web UI查看Major GC耗时,如果耗时较长,尝试使用CMS垃圾回收器,以及调整Execution内存,或者增大executor内存。
- 通过我们上面提到的executor.gc.out日志,查看Minor GC情况,如果Minor GC太过频繁,考虑增大Eden区,使用
-Xmn参数,增大为原有的4/3倍是一个经验值。 - 查看GC日志,如果Old区是接近于满的,尽可能减少cache功能的使用,或者通过spark.memory.storageFraction(UnifiedMemoryManager)参数控制storage部分内存大小,当然还可以通过降低Young区增大Old区,但效果并不会理想,根本上还是要增大executor内存。
- G1垃圾回收器在某些场景下会显著提升,特别是高版本JVM中,逐渐趋于成熟,是GC调优的一个尝试,使用参数-XX:+UseG1GC。
- 内存使用估算,例如数据源是hdfs的情况,如果block大小是128M,executor同时运行4个task的话,那么内存使用大概是4 * 128M,如果使用了压缩格式,那么考虑解压后的大小。
从上面可以看出,GC调优的目的是降低Major GC频率,手段集中在选择合适的垃圾回收器、调整Young和Old区大小、调整Spark内存管理器中各组成的占比、增大内存,其中JVM相关参数通过spark.executor.extraJavaOption配置。GC调优的技巧需要通过实践不断积累,环境不同效果可能完全不同,调优之中调是很重要的一步。
参考:
Tuning Spark
Java 内存区域和GC机制
Java (JVM) Memory Model – Memory Management in Java