最近对线上spark作业的GC长达十几分钟(主要是频繁的Young GC)问题进行了一些优化,其中涉及到了spark内存模型的知识点,这里做一个复盘总结。关于spark内存优化不得不提到Project Tungsten(钨丝计划),因为spark的内存模型属于该项目的一个优化点。
一、Tungsten起源背景
众所周知,Spark是由Scala+Java开发的一种基于内存计算的大数据解决方案,底层运行在JVM上,那么自然而然的会有GC的问题反过来限制Spark的性能,而且绝大多数Spark应用程序的主要瓶颈不在于IO/网络,而是在于CPU和内存。此时Project Tungsten由DataBricks提出,并在Spark1.5中引入,在1.6.X对内存进行了优化,在2.X对CPU进行了优化,也就是说该项目主要是针对于CPU和Memory进行优化,具体优化集中在以下三个方面:
- Memory Management And Binary Processing(内存管理和二进制处理),也是本篇主要总结的重点
- Cacahe-aware Computation(缓存感知计算):使用了友好的数据结构和算法来完成数据的存储和复用,提升缓存命中率
- Code Generation(代码生成):扩展了更多的表达式求值和SQL操作,把已有的代码变成本地的字节,不需要很多的抽象和匹配等,避免了昂贵的虚拟函数调用
二、Tungsten优化-Memory
本篇主要讲述tungsten在内存这块的优化点,以及spark是如何进行内存分配的(On-Heap和Off-Heap结合,Storage/Executor/Other划分),通过何种方式的寻址(通过引入了Page table来管理On-Heap和Off-Heap)来实现的统一内存管理。
2.0 堆划分
这里先复习一下Spark运行的整体流程:
- 通过spark-submit命令提交Spark作业,启动Driver(根据不同的模式如yarn-client,yarn-cluster,启动点不同),生成SparkContext对象(这里会进行DAG–>Stage–>Task划分)
- SparkContext和Cluster Manager进行通信,申请资源以及后续的任务分配和监控,并在指定Worker节点上启动Executor
- SparkContext在实例化的时候,会构建DAG,并分解为多个Stage,并把每个Stage中的TaskSet发送给TaskScheduler
- Executor向Driver申请Task,然后Driver将应用程序以及相关依赖包发送到Executor端,并在Executor端执行task
- Executor将task运行结果反馈给TaskScheduler,然后再反馈给DAGScheduler
- 当整个作业结束后,SparkContext会向ClusterManager注销并释放所有资源
从运行的整体流程上看,Driver端的工作主要是负责创建SparkContext,提交作业以及协调任务;Executor端的工作主要就是执行task。从内存使用的角度上看,Executor端的内存设计相当比较复杂些,所以本文也将基于Executor的内存进行概述(本文中讲到Spark内存指的也是Executor端的内存)。
那么接下来再针对Executor端的内存设计进行拆解,见下图:
Worker节点启动的Executor其实也是一个JVM进程,因此Executor的内存管理是建立在JVM内存管理上的(On-Heap堆内内存),同时Spark也引入了Off-Heap(堆外内存),使之可以直接在系统内存中开辟空间,避免了在数据处理过程中不必要的序列化和反序列化的开销,同时也降低了GC的开销。
2.0.1 On-Heap(堆内)
Spark对堆内内存的管理其实只是一种逻辑上的管理,内存的申请和释放都是由JVM来完成的,而Spark只是在申请后和释放前记录这些内存。
- 申请内存后:如创建一个对象,JVM从堆内内存中分配空间,并返回对象的引用,而Spark会保存该对象的引用,记录该对象占用的内存
- 释放内存前:spark删除该对象的引用,记录该对象释放的内存,等待JVM来真正释放掉该对象占用的内存
这里需要说明一下spark关于序列化的一个小知识点:
经过序列化的对象,是以字节流的形式存在,占用的内存大小是可以直接计算,而对于非序列化的对象,占用的内存只能通过周期性地采样近似估算得到的,也就是说每次新增的数据项都会计算一次占用的内存大小,这种方式会有一定的误差,可能会导致某一时刻的实际内存超过预期。当被Spark标记为释放的对象实例时,也有可能没有被JVM回收,导致实际可用的内存小于spark记录的可用内存,造成OOM的发生。
为了减少OOM异常的发生,Spark对堆内内存再次进行了划分(即分为Storage,Executor,Other,下一小节将进行详解),通过内存划分方式各自规划管理来提升内存的利用率。
2.0.2 Off-Heap(堆外)
为了解决基于JVM托管方式存在的缺陷,Tungsten引入了基于Off-Heap管理内存的方式,通过sun.misc.Unsafe管理内存,这样可以使得Spark的operation直接使用分配的二进制数据,而不是JVM对象,降低了GC带来的开销。而且对于序列化的数据占用的空间可以被精准计算,相对堆内内存来说降低了管理难度。当然默认情况堆外内存是没有启用的,需要通过配置参数spark.memory.offHeap.enabled来启用.
2.1 内存划分
根据内存使用目的不同,对堆内外内存进行了如上图的划分:
针对堆内内存来说,划分了4块:
- 存储内存(Storage Memory):该部分的内存主要是用于缓存或者内部数据传输过程中使用的,比如缓存RDD或者广播数据
- 执行内存(Execution Memory):该部分的内存主要用于计算的内存,包括shuffles,Joins,sorts以及aggregations
- 其他内存(Other Memory):该部分的内存主要给存储用户定义的数据结构或者spark内部的元数据
- 预留内存:和other内存作用是一样的,但由于spark对堆内内存采用估算的方式,所以提供了预留内存来保障有足够的空间
针对堆外内存来说,划分了2块(前面也提到过了spark对堆外内存的使用可以精准计算):
- 存储内存(Storage Memory)
- 执行内存(Execution Memory)
2.2 内存管理
Tungsten使用了Off-Heap使得spark实现了自己独立的内存管理,避免了GC引发的性能问题,省去了序列化和反序列化的过程。Spark1.6版本之前使用了静态内存管理模式,而在此之后使用统一内存管理模型,并,可以直接操作内存中的二进制数据,而不是Java对象,很大程度上摆脱了JVM内存管理的限制。
2.2.1 Spark1.6之前-静态内存管理模型
下面的两张图相信有些读者已经很熟悉了。
静态内存模型最大的特点就是:堆内内存中的每个区域的大小在spark应用程序运行期间是固定的,用户可以在启动前进行配置;这也需要用户对spark的内存模型非常熟悉,否则会因为配置不当造成严重后果
对于堆内内存区域的划分以及比例如下图:
- 存储内存(Storage Memory):
默认情况下,存储内存占用整个堆内存的60%(该占比由spark.storage.memoryFraction来控制),主要用了存储缓存的RDD或者广播数据; - 执行内存(Execution Memory):
默认情况下,执行内存占用整个堆内存的20%(该占比由spark.shuffle.memoryFraction来控制),主要用来存储进行shuffle计算的内容 - 预留内存
默认情况下,预留内存占用整个堆内存的20%(该占比取决于上面两个内存区域的大小),主要用来存储一些元数据或者用户定义的数据结构
这里需要说一下Unroll过程:RDD以Block形式被缓存到存储内存,Record在堆内或堆外存储内存中占用一块连续的空间。把Partition由不连续的存储空间转换为连续空间的过程就是Unroll,也称之为展开操作
对于堆外内存的划分,比较简单,即只有存储内存和执行内存(具体原因上文也已经讲到了,即spark对堆外内存的使用计算是比较精确的,所以不需要额外的预留空间来避免OOM的发生)
源码实现
基类MemoryManager封装了静态内存管理模型和统一内存管理模型,而StaticMemoryManager类负责实现静态内存模型,UnifiedMemoryManager类实现统一内存模型。具体采用哪种内存分配由tungstenMemoryMode来决定,即由MemoryAllocator来负责具体分配(分别实现了两个子类),其中allocate和free函数来提供内存的分配和释放,分配的内存以MemoryBlock来表示。
静态内存模型管理器-StaticMemoryManager类
//Unroll过程中可用的内存,占最大Storage内存的0.2
private val maxUnrollMemory: Long = {
(maxOnHeapStorageMemory * conf.getDouble("spark.storage.unrollFraction", 0.2)).toLong
}
//获取最大的Storage内存
private def getMaxStorageMemory(conf: SparkConf): Long = {
val systemMaxMemory = conf.getLong("spark.testing.memory", Runtime.getRuntime.maxMemory)
val memoryFraction = conf.getDouble("spark.storage.memoryFraction", 0.6) //Storage内存占全部内存占比
val safetyFraction = conf.getDouble("spark.storage.safetyFraction", 0.9) //Storage内存的安全系数
(systemMaxMemory * memoryFraction * safetyFraction).toLong
}
//获取最大的Execution内存
private def getMaxExecutionMemory(conf: SparkConf): Long = {
val systemMaxMemory = conf.getLong("spark.testing.memory", Runtime.getRuntime.maxMemory)
if (conf.contains("spark.executor.memory")) {
val executorMemory = conf.getSizeAsBytes("spark.executor.memory")
}
val memoryFraction = conf.getDouble("spark.shuffle.memoryFraction", 0.2) //Execution内存占全部内存占比
val safetyFraction = conf.getDouble("spark.shuffle.safetyFraction", 0.8) //Execution内存的安全系数
(systemMaxMemory * memoryFraction * safetyFraction).toLong
}
Spark1.6之后-统一内存管理模型
关于新的统一内存管理模型,有兴趣的读者可以参考https://issues.apache.org/jira/secure/attachment/12765646/unified-memory-management-spark-l0000.pdf
统一内存模型和静态内存模型的区别在于:存储内存和执行内存共享同一块空间(占堆内内存的60%),且可以动态占用对方的空闲区域
统一内存模型关于堆内内存区域的划分,这里有以下几点需要注意:
- 执行内存和存储内存共享同一空间,该空间占用可用内存的60%,例如我们设置1G内存,那么Execution和Storage共用内存就是(1024-300)*0.6 = 434MB
- 执行内存和存储内存可以互相占用对方空闲空间
- 存储内存可以借用执行内存,直到执行内存重新占用它的空间为止。当发生这种情况的时候,缓存块将从内存中清除,直到足够的借入内存被释放,满足执行内存、请求内存的需要
- 执行内存可以借用尽可能多空闲的存储内存,但是执行内存由于执行操作所涉及的复杂性,执行内存永远不会被存储区逐出,也就是说如果执行内存已经占用存储内存的大部分空间,那么缓存块就会有可能失败,在这种情况下,根据存储级别的设置,新的块会被立即逐出内存
- 虽然存储内存和执行内存共享同一空间,但是会存在一个初始边界值,具体可见UnifiedMemoryManager.apply方法
统一内存模型对于堆外内存的设计和静态内存模型是一样的,这里不再重复介绍了
源码实现-UnifiedMemoryManager
private def getMaxMemory(conf: SparkConf): Long = {
val systemMemory = conf.getLong("spark.testing.memory", Runtime.getRuntime.maxMemory)
//系统预留的内存大小,默认为300MB
val reservedMemory = conf.getLong("spark.testing.reservedMemory",
if (conf.contains("spark.testing")) 0 else RESERVED_SYSTEM_MEMORY_BYTES)
//当前最小的内存需要300*1.5,即450MB,不满足条件就会退出
val minSystemMemory = (reservedMemory * 1.5).ceil.toLong
if (systemMemory < minSystemMemory) {
throw new IllegalArgumentException(s"System memory $systemMemory must " +
s"be at least $minSystemMemory. Please increase heap size using the --driver-memory " +
s"option or spark.driver.memory in Spark configuration.")
}
// SPARK-12759 Check executor memory to fail fast if memory is insufficient
if (conf.contains("spark.executor.memory")) {
val executorMemory = conf.getSizeAsBytes("spark.executor.memory")
if (executorMemory < minSystemMemory) {
throw new IllegalArgumentException(s"Executor memory $executorMemory must be at least " +
s"$minSystemMemory. Please increase executor memory using the " +
}
val usableMemory = systemMemory - reservedMemory
//当前Execution和Storage共享的最大内存占比默认为0.6
val memoryFraction = conf.getDouble("spark.memory.fraction", 0.6)
(usableMemory * memoryFraction).toLong
}
def apply(conf: SparkConf, numCores: Int): UnifiedMemoryManager = {
val maxMemory = getMaxMemory(conf)
new UnifiedMemoryManager(
conf,
maxHeapMemory = maxMemory,
//通过配置参数spark.memory.storageFraction,设置Execution和Storage共享内存初始边界,即默认各占总内存一半
onHeapStorageRegionSize =
(maxMemory * conf.getDouble("spark.memory.storageFraction", 0.5)).toLong,
numCores = numCores)
}
2.3 内存寻址
以上小节介绍了spark对堆的划分,根据使用目的不同,对堆进行了区域划分,并说明了spark1.6之前和之后使用的两种不同内存模型管理以及之间的区别,那么这里继续逐步分析,说到内存管理,spark是如何通过进行内存寻址,内存块是如何封装的,通过何种方式来组织管理这些内存块?
问1:如何进行内存寻址的?
答:这里需要再次回到Project Tungsten计划中,由于spark引入了Off-Heap内存模式,为了方便统一管理On-Heap和Off-Heap这两种模式,Tungsten引入了统一的地址表示形式,即通过MemoryLocation类来表示On-Heap或Off-Heap两种内存模式下的地址,该类中有两个属性obj和offset,当处于On-Heap模式下,通过使用obj(即为对象的引用)和64 bit的offset来表示内存地址,当处于Off-Heap模式下,直接使用64 bit的offset绝对地址来描述内存地址。而这个64bit 的offset在对外编码时,前13 bit用来表示Page Number,后51 bit用来表示对应的offset
问2:内存块是如何封装的?
答:在Project Tungsten内存管理中,会使用一块连续的内存空间来存储数据,通过MemoryBlock类对内存块进行封装。该类继承了MemoryLocation类,采用了组合复用方式,即指定了内存块的地址,也提供了内存块本身的内存大小
问3:如何组织管理内存块?
答:Project Tungsten采用了类似于操作系统的内存管理模式,使用Page Table方式(其实本质就是一个数组,从源码中就可以看出)来管理内存,内部以Page来管理存储内存块(通过MemoryBlock来封装),通过pageNumber找到对应的Page,Page内部会根据Off-Heap或On-Heap两种模式分别存储Page对应内存块的起始地址(或对象内偏移地址)
在spark中,数据是以分区进行处理的,而每个分区对应一个task,所以对于内存的组织和管理可以借助于TaskMemoryManager来理解,同时以上这些疑惑均可以在TaskMemoryManager类(该类对MemoryManager又做了一层封装)中找到解答。
源码实现-TaskMemoryManager
属性信息
//Page Number长度位数
private static final int PAGE_NUMBER_BITS = 13;
//Offset位数
static final int OFFSET_BITS = 64 - PAGE_NUMBER_BITS;
//Page Table底层实现,其实就是一个MemoryBlock数组
private final MemoryBlock[] pageTable = new MemoryBlock[PAGE_TABLE_SIZE];
//真正进行内存分配和释放
private final MemoryManager memoryManager;
// 内存模式
final MemoryMode tungstenMemoryMode;
分配页
//调用ExectorMemoryManager进行内存分配,分配得到一个内存页,并将其添加到page table中,以便内存地址映射
public MemoryBlock allocatePage(long size, MemoryConsumer consumer) {
assert(consumer != null);
assert(consumer.getMode() == tungstenMemoryMode);
if (size > MAXIMUM_PAGE_SIZE_BYTES) {
throw new TooLargePageException(size);
}
//申请一定的内存量
long acquired = acquireExecutionMemory(size, consumer);
if (acquired <= 0) {
return null;
}
final int pageNumber;
synchronized (this) {
//获取当前未被分配的页码
pageNumber = allocatedPages.nextClearBit(0);
if (pageNumber >= PAGE_TABLE_SIZE) {
releaseExecutionMemory(acquired, consumer);
}
//设置该页码已经被占用
allocatedPages.set(pageNumber);
}
MemoryBlock page = null;
try {
//开始通过MemoryAllocator进行真正的内存分配,注意:这里并不是真正的内存分配,只是控制内存使用大小而已
page = memoryManager.tungstenMemoryAllocator().allocate(acquired);
} catch (OutOfMemoryError e) {
//当没有足够的内存时,应该保持获得的内存
synchronized (this) {
acquiredButNotUsed += acquired;
allocatedPages.clear(pageNumber);
}
//触发溢出,释放一些页面
return allocatePage(size, consumer);
}
//分配得到内存块后,会设置该内存块对应的page number
page.pageNumber = pageNumber;
pageTable[pageNumber] = page;
return page;
}
释放页
public void freePage(MemoryBlock page, MemoryConsumer consumer) {
//首先断言确定要释放的内存块在pageTable中,即页码必须有效
assert (page.pageNumber != MemoryBlock.NO_PAGE_NUMBER) :
"Called freePage() on memory that wasn't allocated with allocatePage()";
assert (page.pageNumber != MemoryBlock.FREED_IN_ALLOCATOR_PAGE_NUMBER) :
"Called freePage() on a memory block that has already been freed";
assert (page.pageNumber != MemoryBlock.FREED_IN_TMM_PAGE_NUMBER) :
"Called freePage() on a memory block that has already been freed";
assert(allocatedPages.get(page.pageNumber));
pageTable[page.pageNumber] = null;
//控制Page Table中对应的位置是否可用,这里考虑到释放和分配的并发性,需要同步处理
synchronized (this) {
allocatedPages.clear(page.pageNumber);
}
if (logger.isTraceEnabled()) {
logger.trace("Freed page number {} ({} bytes)", page.pageNumber, page.size());
}
long pageSize = page.size();
page.pageNumber = MemoryBlock.FREED_IN_TMM_PAGE_NUMBER;
//通过MemoryAllocator真正释放内存块
memoryManager.tungstenMemoryAllocator().free(page);
releaseExecutionMemory(pageSize, consumer);
}
地址编码
//给定分配到的内存页和页内偏移,生成一个64 bits的逻辑地址
public long encodePageNumberAndOffset(MemoryBlock page, long offsetInPage) {
if (tungstenMemoryMode == MemoryMode.OFF_HEAP) {
offsetInPage -= page.getBaseOffset();
}
return encodePageNumberAndOffset(page.pageNumber, offsetInPage);
}
//高13bits是page number,低51bits是页内偏移
public static long encodePageNumberAndOffset(int pageNumber, long offsetInPage) {
assert (pageNumber >= 0) : "encodePageNumberAndOffset called with invalid page";
return (((long) pageNumber) << OFFSET_BITS) | (offsetInPage & MASK_LONG_LOWER_51_BITS);
}
地址解码
//给定逻辑地址,获取page number
public static int decodePageNumber(long pagePlusOffsetAddress) {
return (int) (pagePlusOffsetAddress >>> OFFSET_BITS);
}
//给定逻辑地址,获取页内偏移
private static long decodeOffset(long pagePlusOffsetAddress) {
return (pagePlusOffsetAddress & MASK_LONG_LOWER_51_BITS);
}
//获取对象的引用,对于Off-Heap模式,则返回Null
public Object getPage(long pagePlusOffsetAddress) {
if (tungstenMemoryMode == MemoryMode.ON_HEAP) {
//从地址中解析出PageNumber
final int pageNumber = decodePageNumber(pagePlusOffsetAddress);
assert (pageNumber >= 0 && pageNumber < PAGE_TABLE_SIZE);
//根据页码从pageTable中获取内存块
final MemoryBlock page = pageTable[pageNumber];
assert (page != null);
assert (page.getBaseObject() != null);
//获取内存块对应的Object
return page.getBaseObject();
} else {
//如果是Off-Heap模型,则Obj为null
return null;
}
}
2.4 参数配置
更多参数可以在org.apache.spark.internal.config类中查找,这里只给出涉及到该部分内容的相关参数
参数 | 描述 |
---|---|
spark.memory.useLegacy | 内存模型开关,1.6之前使用静态内存模型,1.6之后使用统一内存模型;主要是为了向后兼容,默认为false |
spark.shuffle.memoryFraction | Execution内存,默认占用堆内内存的20%。1.6之前的配置 |
spark.shuffle.safetyFraction | 用于缓存在Shuffle过程中的中间数据,默认占用Execution总内存的90%。1.6之前的配置 |
spark.storage.memoryFraction | Storage总内存,默认占用堆内内存的60%。1.6之前的配置 |
spark.storage.safetyFraction | 可缓存RDD数据和广播数据的大小,默认占用Storage总内存的90%。1.6之前的配置 |
spark.storage.unrollFraction | 缓存iterator形式的Block数据,默认占用Storage总内存的20%。1.6之前的配置 |
spark.memory.fraction | 堆内的存储内存和执行内存总共所占的比例,1.6版本默认为0.75,2.0之后默认为0.6。 |
spark.memory.storageFraction | Storage内存,默认占用堆外可用内存的50% |
spark.testing.memory | 该参数用于测试,一般可以认为是executor 的最大可用内存 |
spark.memory.offHeap.size | 堆外内存大小,需要结合spark.memory.offHeap.enabled来使用 |
spark.memory.offHeap.enabled | 堆外内存启用开关,默认为false |
spark.executor.memory | Executor内存大小,也就是堆内内存设置 |
spark.storage.storageFraction | 用于缓存数据的内存比例,1.6.X之后默认0.5。 |
以上是对Project Tungsten针对内存的设计,笔者总结的可能不是很全面。关于二进制处理这块的内容笔者考虑将以单独的篇幅讲解,避免文章过长,内容过多不易读者消化。
三、Tungsten优化-CPU
3.1 缓存感知计算Cache-aware computation
缓存感知计算通过使用L1/L2/L3 CPU缓存来提升速度,同时也可以处理超过寄存器大小的数据。在Spark开发者们在做性能分析的时候发现大量的CPU时间会因为等待从内存中读取数据而浪费,所以在Tungsten项目中,通过设计了更加友好的缓存算法和数据结构,让spark花费更少的时间等待cpu从内存中读取数据,提供了更多的计算时间。
缓存感知计算的解析
网上有很多关于缓存感知的说明,都是以KV排序为例,那么笔者这里也使用同样的例子再结合自己的理解,尽量解释的通俗易通。
3.2 代码生成Code Generation
代码生成指的是在运行时,spark会动态生成字节码,而不需要通过解释器对原始数据类型进行打包,同时也避免了虚拟函数的调用。当然该技术的优势并不止于此,还包括了将中间数据从存储器移动到CPU寄存器;使用向量化技术,利用现代CPU功能加快了对复杂操作运行速度
这里以一个sql为例,可以通过explain来查看spark在哪些过程中使用了代码生成的功能。
当算子前面有一个*时,说明全阶段代码生成被启用,在下面的例子中,Exchange算子没有实现代码生成,这是因为这里会发生Shuffle,需要通过网络发送数据。
select count(1) from tmp.user where id='123'
== Physical Plan ==
*(2) HashAggregate(keys=[], functions=[count(1)])
+- Exchange SinglePartition
+- *(1) HashAggregate(keys=[], functions=[partial_count(1)])
+- *(1) Project
+- *(1) Filter ((id#2802514 = 123))
+- HiveTableScan [id#2802514], HiveTableRelation `tmp`.`user`, org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe, [id#2802514]
关于底层的一些实现,读者如果感兴趣可以自行查阅资料,笔者这里不再做总结。