spark内存管理源码分析系列之详解UnifiedMemoryManager

上一讲我们介绍了MemoryManager以及具体的实现类StaticMemoryManager,知道了StaticMemoryManager的优势和缺点,本节我们继续介绍具体实现类UnifiedMemoryManager,它实现相对StaticMemoryManager内部实现会复杂点,不过用户使用会更省心些,它可以在存储内存和执行内存之间进行互相借用内存,让用户可以在不具备内存调参经验情况下也能很好的降低OOM的风险。

UnifiedMemoryManager

UnifiedMemoryManager在MemoryManager的内存模型之上,将执行内存和存储内存之间的边界修改为“软”边界,即任何一方可以向另一方借用空闲的内存。我们分别来分析堆内和堆外内存。

堆内内存

整体概览

堆内内存主要分为系统预留,执行内存,存储内存和其他内存四个部分,整体划分如下图所示:

在这里插入图片描述

1、系统预留内存 (System Reserved Memory)

系统预留内存用来存储Spark内部对象。其大小在代码中是写死的,其值等于300MB,这个值是不能修改的<测试环境下,我们可以通过 spark.testing.reservedMemory 参数进行修改>;如果Executor分配的内存小于 1.5 * 300 = 450M 时,Executor将无法执行。

2、存储内存 (Storage Memory)

存储内存主要用于存储 spark的cache数据,例如 RDD 的缓存、广播(Broadcast)数据、和 unroll 数据。内存占比为 UsableMemory * spark.memory.fraction * spark.memory.storageFraction,默认初始状态下 Storage Memory 和Execution Memory 均约占系统总内存的30%[1 * 0.6 * 0.5 = 0.3]

3、执行内存 (Execution Memory)

执行内存主要用于存放 Shuffle、Join、Sort、Aggregation 等计算过程中的临时数据。内存占比为 UsableMemory * spark.memory.fraction * (1 - spark.memory.storageFraction),默认初始状态下 Storage Memory和Execution Memory 均约占系统总内存的30%(1 * 0.6 * (1 - 0.5) = 0.3

4、其他/用户内存 (Other/User Memory)

主要用于存储 RDD 转换操作所需要的数据,例如 RDD 依赖等信息。内存占比为 UsableMemory * (1 - spark.memory.fraction),在Spark2+ 中,默认占可用内存的40%(1 * (1 - 0.6) = 0.4

源码分析

根据指定的executor-memory进行启动Executor分配各种内存池大小的源码如下所示:

// 预留内存
private val RESERVED_SYSTEM_MEMORY_BYTES = 300 * 1024 * 1024

def apply(conf: SparkConf, numCores: Int): UnifiedMemoryManager = {
  // 获取可用的最大堆内存《存储内存+执行内存》
  val maxMemory = getMaxMemory(conf)
  new UnifiedMemoryManager(
    conf,
    maxHeapMemory = maxMemory, // 最大堆内存大小
    onHeapStorageRegionSize =
    (maxMemory * conf.get(config.MEMORY_STORAGE_FRACTION)).toLong, // 用于存储内存大小,默认为 可用的最大堆内存 * 0.5
    numCores = numCores)
}

private def getMaxMemory(conf: SparkConf): Long = {
  val systemMemory = conf.get(TEST_MEMORY) // 系统最大内存
  // val TEST_MEMORY = ConfigBuilder("spark.testing.memory").version("1.6.0").longConf    
  // .createWithDefault(Runtime.getRuntime.maxMemory)
  // 系统预留默认300M
  val reservedMemory = conf.getLong(TEST_RESERVED_MEMORY.key,  if (conf.contains(IS_TESTING)) 0 else RESERVED_SYSTEM_MEMORY_BYTES)
  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 ${config.DRIVER_MEMORY.key} in Spark configuration.")
  }
  // SPARK-12759 Check executor memory to fail fast if memory is insufficient
  if (conf.contains(config.EXECUTOR_MEMORY)) {
    val executorMemory = conf.getSizeAsBytes(config.EXECUTOR_MEMORY.key)
    if (executorMemory < minSystemMemory) {
      throw new IllegalArgumentException(s"Executor memory $executorMemory must be at least " +
                                         s"$minSystemMemory. Please increase executor memory using the " +
                                         s"--executor-memory option or ${config.EXECUTOR_MEMORY.key} in Spark configuration.")
    }
  }
  val usableMemory = systemMemory - reservedMemory
  // spark.memory.fraction默认0.6
  val memoryFraction = conf.get(config.MEMORY_FRACTION) 
  (usableMemory * memoryFraction).toLong // 最大内存《执行内存+存储内存》
}

源码注释如上,我们来总结下内存计算和分配的步骤:

  1. 系统预留了300M的内存[原因是给定的内存较低时,会导致OOM,具体讨论参考这里 Make unified memory management work with small heaps。因此,other这部分内存做了修改,先划出 300M 内存],另外申请的内存要满足系统最小内存值是预留300M的1.5倍

  2. 获取系统的最大内存,这个是由JVM启动时候指定的,Runtime.getRuntime.maxMemory 是程序能够使用的最大内存,其值会比实际配置的执行器内存的值小。这是因为内存分配池的堆部分划分为 Eden,Survivor 和 Tenured 三部分空间,而这里面一共包含了两个 Survivor 区域,而这两个 Survivor 区域在任何时候我们只能用到其中一个,所以我们可以使用下面的公式进行描述 :

    ExecutorMemory = Eden + 2 * Survivor + Tenured 
    Runtime.getRuntime.maxMemory = Eden + Survivor + Tenured 
    
  3. 一些判断,例如系统最小内存不能少于预留内存的1.5倍,executor-memory指定值不能小于最小系统内存等

  4. 计算usableMemory是系统内存减去预留内存,然后可以根据spark.memory.fraction和usableMemory的积获取执行内存+存储内存的值

  5. 获取最大内存进行UnifiedMemoryManager构造,会分别计算存储内存大小和执行内存大小,通过参数spark.memory.storageFraction来进行划分,然后交给MemoryManager进行具体内存的增长。

    // org.apache.spark.memory.UnifiedMemoryManager
    private[spark] class UnifiedMemoryManager(
        conf: SparkConf,
        val maxHeapMemory: Long,  <>  // 最大内存: (系统最大内存-预留内存) * spark.memory.fraction
        onHeapStorageRegionSize: Long,  // 堆内存储内存: 最大内存 * spark.memory.storageFraction
        numCores: Int)
      extends MemoryManager(
        conf,
        numCores,
        onHeapStorageRegionSize,
        maxHeapMemory - onHeapStorageRegionSize) { // 堆内执行内存: 最大内存 - 堆内存储内存
        ..
    }
    
    // org.apache.spark.memory.MemoryManager
    // 堆内内存大小分配
    onHeapStorageMemoryPool.incrementPoolSize(onHeapStorageMemory)
    onHeapExecutionMemoryPool.incrementPoolSize(onHeapExecutionMemory)
    

经过以上步骤,onHeapStorageMemoryPoolonHeapExecutionMemoryPool就已经配额好了内存了,可以等Spark任务来申请使用了。

堆外内存

整体概览

堆内内存分配和回收是通过JVM进行的,由于GC无法自行控制,容易导致OOM风险。Spark 1.6引入了堆外内存,不使用JVM来进行分配和回收,而是自行控制内存的开辟和回收,主要是调用Java的unsafe相关 API 进行诸如 C++ 语言里面的 malloc() 直接向操作系统申请内存。这样做有两个好处,首先 Spark 可以直接操作系统堆外内存,减少了不必要的内存开销,以及频繁的 GC 扫描和回收,提升了处理性能;另外,堆外内存可以被精确地申请和释放,而且序列化的数据占用的空间可以被精确计算,所以相比堆内内存来说降低了管理的难度,也降低了误差。当然了也不是没有缺点,跟C++一样,我们必须自己编写内存申请和释放的逻辑。

默认情况下Off-heap模式的内存并不启用,我们可以通过 spark.memory.offHeap.enabled 参数开启,并由 spark.memory.offHeap.size 指定堆外内存的大小,单位是字节(占用的空间划归 JVM OffHeap 内存)。如果堆外内存被启用,那么 Executor 内将同时存在堆内和堆外内存,两者的使用互不影响,这个时候 Executor 中的 Execution 内存是堆内的 Execution 内存和堆外的 Execution 内存之和,同理,Storage 内存也一样。其内存分布如下图所示:

在这里插入图片描述

源码分析

堆外内存由于可以精确的计算内存使用情况,所以只分为存储和执行内存,这部分主要在MemoryManager进行控制,比较简单

// org.apache.spark.memory.MemoryManager
// 堆外内存大小分配

// spark.memory.offHeap.size控制堆外内存大小
protected[this] val maxOffHeapMemory = conf.get(MEMORY_OFFHEAP_SIZE)

// 堆外内存中存储内存是由spark.memory.storageFraction控制,默认为0.5
protected[this] val offHeapStorageMemory = (maxOffHeapMemory * conf.get(MEMORY_STORAGE_FRACTION)).toLong

offHeapExecutionMemoryPool.incrementPoolSize(maxOffHeapMemory - offHeapStorageMemory)
offHeapStorageMemoryPool.incrementPoolSize(offHeapStorageMemory)

相互借用

UnifiedMemoryManager比较厉害的地方是执行内存和存储内存之间可以相互借用,这样子我们不用关心系统中是存储内存使用多些还是执行内存使用多些,减少了调参的复杂度。

在这里插入图片描述

如上图所示,我们上面已经分析了程序提交的时候,设定基本的 Execution 内存和 Storage 内存区域,onHeapStorageRegionSize是表示存储内存大小,maxHeapMemory是存储内存和执行内存的和,它们之间的相互占用规则如下:

  • 当执行内存不足时,可以借用onHeapStorageRegionSize中未使用的空间,同样当存储内存不足时,也可以借用未使用的执行内存空间;
  • 当执行内存占用了存储内存的空间,在存储内存的空间不足时候<存储空间不足是指不足以放下一个完整的 Block>,被执行内存占用的内存不能被驱逐,只能、需要等待执行内存自己释放,不能抢占;
  • 当存储内存占用了执行内存的空间,在执行内存不足时,执行内存是可以驱逐并借用StorageMemory – onHeapStorageRegionSize 部分,而onHeapStorageRegionSize部分不可被抢占。是可让对方将占用的部分转存到硬盘,然后“归还”借用的空间;
  • 如果双方的空间都不足时,则存储到硬盘;将内存中的块存储到磁盘的策略是按照 LRU 规则进行的。

可以看出来,执行内存和存储内存在对方有空余空间时候,都可以先借用,但是归还策略不同,执行内存占用存储空间时候,如果存储这时候申请内存,是不能把借给执行内存的空间收回的,相反执行内存是可以收回借给存储内存的空间的,这是因为需要考虑 Shuffle 过程中的很多因素,实现起来较为复杂;而且 Shuffle 过程产生的文件在后面一定会被使用到,而 Cache 在内存的数据不一定在后面使用。在 Unified Memory Management in Spark 1.6 中详细讲解了为何选择这种策略,简单总结如下:

  1. 数据清除的开销 : 驱逐storage内存的开销取决于 storage level,MEMORY_ONLY 可能是最昂贵的,因为需要重新计算,MEMORY_AND_DISK_SER 正好相反,只涉及到磁盘IO。溢写 execution 内存到磁盘的开销并不昂贵,因为 execution 存储的数据格式紧凑(compact format),序列化开销低。并且,清除的 storage 内存可能不会被用到,但是,可以预见的是,驱逐的 execution 内存是必然会再被读到内存的,频繁的驱除重读 execution 内存将导致昂贵的开销。
  2. 实现的复杂度 : storage 内存的驱逐是容易实现的,只需要使用已有的方法,drop 掉 block。execution 则复杂的多,首先,execution 以 page 为单位管理这部分内存,并且确保相应的操作至少有 one page ,如果把这 one page 内存驱逐了,对应的操作就会处于饥饿状态。此外,还需要考虑 execution 内存被驱逐的情况下,等待 cache 的 block 如何处理。

内存申请

执行内存申请

acquireExecutionMemory用来为某个TaskAttemptId申请执行内存,源码如下:

// 为某个TaskAttemptId分配执行内存空间
override private[memory] def acquireExecutionMemory(
  numBytes: Long,
  taskAttemptId: Long,
  memoryMode: MemoryMode): Long = synchronized {
  assertInvariants()
  assert(numBytes >= 0)
  val (executionPool, storagePool, storageRegionSize, maxMemory) = memoryMode match {
    case MemoryMode.ON_HEAP => (
      onHeapExecutionMemoryPool,
      onHeapStorageMemoryPool,
      onHeapStorageRegionSize,
      maxHeapMemory)
    case MemoryMode.OFF_HEAP => (
      offHeapExecutionMemoryPool,
      offHeapStorageMemoryPool,
      offHeapStorageMemory,
      maxOffHeapMemory)
  }

  // 增长策略回调函数
  def maybeGrowExecutionPool(extraMemoryNeeded: Long): Unit = {
    if (extraMemoryNeeded > 0) { 
      // 可从存储内存池借用的内存大小
      val memoryReclaimableFromStorage = math.max(
        storagePool.memoryFree, // 表示存储内存没有像执行内存借用内存
        storagePool.poolSize - storageRegionSize) // 存储内存像执行内存借用了内存,需要回收,只能回收借用的部分
      // 大于0,说明可以从存储区域借用内存
      if (memoryReclaimableFromStorage > 0) {
        // Only reclaim as much space as is necessary and available:
        // 仅借用必要的满足需求的内存大小
        val spaceToReclaim = storagePool.freeSpaceToShrinkPool(
          math.min(extraMemoryNeeded, memoryReclaimableFromStorage))
        // 将借用的内存大小从存储内存池缩减
        storagePool.decrementPoolSize(spaceToReclaim)
        // 将借用的内存大小在执行内存池增加
        executionPool.incrementPoolSize(spaceToReclaim)
      }
    }
  }

  // 计算最大的执行内存池时,如果存储区域的边界大小大于已经被存储使用的内存,那么执行内存的最大空间可以跨越存储内存与执行内存之间的“软”边界;
  // 如果存储区域的边界大小小于等于已经被存储使用的内存,这说明存储内存已经跨越了存储内存与执行内存之间的“软”边界,执行内存可以收回被存储内存借用的空间。
  def computeMaxExecutionPoolSize(): Long = {
    maxMemory - math.min(storagePool.memoryUsed, storageRegionSize)
  }

  // 调用ExecutionMemoryPool的acquireMemory()方法,给taskAttemptId对应的TaskAttempt获取指定大小的内存。
  executionPool.acquireMemory(
    numBytes, taskAttemptId, maybeGrowExecutionPool, () => computeMaxExecutionPoolSize)
}

总结一下,主要有四个步骤

  1. 根据MemoryMode来获取执行内存池,存储内存池,程序开始时候存储内存的大小以及存储内存和执行内存的和;
  2. 提供一个增长策略的回调函数maybeGrowExecutionPool,当执行内存池大小无法满足Task申请的内存时候,需要调用该函数进行内存借用或者回收,首先计算可以从存储内存借用的内存大小,很明显如果存储内存没有借用执行内存的大小则是memoryFree,否则就是借用了执行内存,需要回收,内存大小为存储内存目前的大小减去存储内存的基础大小。如果可以满足借用的值,则从存储内存中freeSpaceToShrinkPool出需要的内存,同时对存储和执行内存池进行内存的增减操作;
  3. 计算执行内存的最大内存的回调函数: 执行内存和存储内存共享内存最大值 - min(存储已使用内存, 存储初始大小),也就是说执行内存可以使用存储内存中的全部剩余内存,而且还可以收回之前借给存储的属于执行内存的内存。
  4. 委托给执行内存池进行内存分配,这部分在spark内存管理源码分析系列之详解内存池中已经讲过了,可以去看一下。

存储内存申请

首先maxOnHeapStorageMemory和maxOffHeapStorageMemory是目前能从存储内存获取的最大堆内和堆外存储内存大小,这部分是考虑了执行内存和存储内存之间的互相借用关系的,计算方式是分别如下所示,从代码中可以看出来,如果执行内存占用了存储内存的大小,是无法收回这部分内存的,所以计算方式是使用最大内存减去执行内存占用的内存<可能没占用满执行内存,也可能占用了一部分存储内存了>。

// 返回用于存储的最大堆内存: 总堆内存 - 用于计算操作的堆内存 <相互借用或者被抢占了>
override def maxOnHeapStorageMemory: Long = synchronized {
  maxHeapMemory - onHeapExecutionMemoryPool.memoryUsed
}

// 返回用于存储的最大堆外内存: 总对外内存 - 用于计算操作的堆外内存 <相互借用或被抢占了>
override def maxOffHeapStorageMemory: Long = synchronized {
  maxOffHeapMemory - offHeapExecutionMemoryPool.memoryUsed
}

存储内存的申请是为BlockId对应的Block申请numBytes大小的内存,源码如下:

// 为存储BlockId对应的Block,从堆内存或堆外内存获取所需大小的内存。
override def acquireStorageMemory(
  blockId: BlockId,
  numBytes: Long,
  memoryMode: MemoryMode): Boolean = synchronized {
  assertInvariants()
  assert(numBytes >= 0)
  // 根据内存模式获取执行内存池、存储内存池和可以用于存储的最大空间
  val (executionPool, storagePool, maxMemory) = memoryMode match {
    case MemoryMode.ON_HEAP => (
      onHeapExecutionMemoryPool,
      onHeapStorageMemoryPool,
      maxOnHeapStorageMemory)
    case MemoryMode.OFF_HEAP => (
      offHeapExecutionMemoryPool,
      offHeapStorageMemoryPool,
      maxOffHeapStorageMemory)
  }
  if (numBytes > maxMemory) { // 无法通过借用执行内存来满足内存需要 
    logInfo(s"Will not store $blockId as the required space ($numBytes bytes) exceeds our " +
            s"memory limit ($maxMemory bytes)")
    return false
  }
  // 如果申请的用于存储的内存大于存储内存池的可用空间,则需要去执行内存池中收回之前借出的空间
  if (numBytes > storagePool.memoryFree) { 
    // 从执行内存借用内存
    val memoryBorrowedFromExecution = Math.min(executionPool.memoryFree,
                                               numBytes - storagePool.memoryFree)
    // 调整执行内存和存储内存大小
    executionPool.decrementPoolSize(memoryBorrowedFromExecution)
    storagePool.incrementPoolSize(memoryBorrowedFromExecution)
  }
  // 委托给存储内存进行分配内存空间
  storagePool.acquireMemory(blockId, numBytes)
}

总结申请存储内存的步骤:

  1. 首先根据MemoryMode获取当前的执行内存池,存储内存池以及存储内存可用的最大内存;
  2. 如果存储内存最大可用内存无法满足申请的内存大小,则无法为其分配空间,需要写入磁盘;
  3. 如果申请的内存大于存储内存池的空闲空间,需要从执行内存中进行借用,首先借用需要的内存,然后调整执行内存池和存储内存池的大小;
  4. 委托给存储内存进行分配内存空间

Unroll内存申请

首先我们看下unroll的定义:将partition由不连续的存储空间转换为连续的存储空间的过程。

override def acquireUnrollMemory(
  blockId: BlockId,
  numBytes: Long,
  memoryMode: MemoryMode): Boolean = synchronized { // 委托给acquireStorageMemory处理
  acquireStorageMemory(blockId, numBytes, memoryMode)
}

从源码中unroll memory和storage memory本质上是同一份内存,只是在任务执行的不同阶段的不同逻辑表述形式。在partition数据的读取存储过程中,这份内存叫做unroll memory,而当成功读取存储了所有reocrd到内存中后,这份内存就改了个名字叫storage memory了。

参考

  1. https://www.jianshu.com/p/87a36488993a
  2. https://developer.ibm.com/zh/articles/ba-cn-apache-spark-memory-management/
  3. http://arganzheng.life/spark-executor-memory-management.html
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值