Spark Shuffle源码分析系列之PartitionedPairBuffer&PartitionedAppendOnlyMap

概述

SortShuffleWriter使用ExternalSorter进行ShuffleMapTask数据内存以及落盘操作,ExternalSorter中使用内存进行数据的缓存过程中根据是否需要map-side聚合以及是否需要排序来选择不同的内存存储方式,分别为PartitionedPairBufferPartitionedAppendOnlyMap。我们先看下两种数据结构的异同点:

  1. PartitionedAppendOnlyMap中数据存储在父类AppendOnlyMap的data数组中,PartitionedPairBuffer数据就存在该类的data数组中;
  2. PartitionedAppendOnlyMap间接的继承SizeTrackerPartitionedPairBuffer是直接继承SizeTracker,用来进行要记录数据采样大小,以便上层进行适时的申请内存以及溢写磁盘操作;
  3. AppendOnlyMap会对元素在内存中进行更新或聚合,而PartitionedPairBuffer不支持map端聚合操作,只起到数据缓冲的作用;
  4. AppendOnlyMap的行为更像map,元素以散列的方式放入data数组,而PartitionedPairBuffer的行为更像collection,元素都是从data数组的起始索引0和1开始连续放入的;
  5. 两者都实现了WritablePartitionedPairCollection,可以根据partitonId排序,也可以根据partitionId+key进行排序操作返回排序后的迭代器数据。

下面我们来详细的分析两者的实现过程。

SizeTracker

Spark是一个内存计算框架,因此内存是重要的资源,Shuffle过程中大量的使用执行内存,所以精确地估计内存使用情况,意义重大。SizeTracker就是Spark中来抽样估计集合使用内存大小的特质。PartitionedAppendOnlyMapSizeTrackingAppendOnlyMap都使用它来进行内存估计,适时的为数据申请内存以及溢写到磁盘,但是它是近似估计,会有偏差,有可能会导致OOM的发生。

我们先来看下sizeTracker的属性:

// 采样增长的速率。例如,速率为2时,分别对在1,2,4,8...位置上的元素进行采样
private val SAMPLE_GROWTH_RATE = 1.1

// 样本队列,最后两个样本将被用于估算大小
private val samples = new mutable.Queue[Sample]

// 平均每次更新的字节数
private var bytesPerUpdate: Double = _

// 更新操作(包括插入和更新)的总次数
private var numUpdates: Long = _

// 下次采样时,numUpdates的值,即numUpdates的值增长到与nextSampleNum相同时,才会再次采样
private var nextSampleNum: Long = _
  1. SAMPLE_GROWTH_RATE是一个斜率,代表下次抽样时候更新的次数应该是这次抽样更新次数的1.1倍,比如上次是更新10000次时候抽样,下次抽样就得是更新11000次时候再抽样,可以避免每次更新都抽样<开销过大>,减少抽样花销,同样由于不是每次抽样所以会导致内存计算只是近似计算,有一定的概率导致OOM;
  2. samples是一个队列, 里面的类型是样例类Sample,包含了当前样本对应的内存大小以及对应的第几次更新,队列中只有两个元素,通过两个元素近似估计最终占用空间大小;
  3. bytesPerUpdate是抽样之后得到区间增长量/个数增长量,代表每次更新字节数速率,就是一个斜率;
  4. numUpdates就是代表抽样集合里面元素个数,当前总个数减去上一次采样的个数就是这段时间内采样总个数,它乘以3中的速率就是这段时间的近似内存增长量;
  5. nextSampleNum代表下次要抽样的时候集合的个数,就是此次抽样时候的个数*1.1。

那么什么时候抽样,如何抽样以及估算大小的呢?SizeTracker提供了afterUpdate在更新数据后进行更新抽样,提供了takeSample用来做具体的抽样,estimateSize来估算大小。

更新抽样

afterUpdate提供了向集合中更新了元素后的后续操作,主要是更新目前已经更新元素的个数,以及达到采样间隔时候进行采样。

// 用于向集合中更新了元素之后进行回调,以触发对集合的采样。
protected def afterUpdate(): Unit = {
   
  // 更新numUpdates
  numUpdates += 1
  if (nextSampleNum == numUpdates) {
    // 如果nextSampleNum与numUpdates相等
    // 调用takeSample()方法采样
    takeSample()
  }
}

采样

takeSample来实施真正的采样操作,先调用SizeEstimator的estimate()方法估算集合的大小<具体的估算方法我们后续在专门来分析,这个估算是耗时的,所以不能每添加一个就估算一次>,然后加入到sample队列里面,队列里面只保留最新的两次更新的<size, 更新次数>,然后队列翻转,通过公式

​ (倒数第1个样本记录的大小 - 倒数第2个样本记录的大小) / (倒数第1个样本的采样编号 - 倒数第2个的采样编号)

计算平均每次更新字节数速率,最后更新下一次抽象的位置。

private def takeSample(): Unit = {
   
  // 调用SizeEstimator的estimate()方法估算集合的大小,并将估算的大小和numUpdates作为样本放入队列samples中。
  samples.enqueue(Sample(SizeEstimator.estimate(this), numUpdates))
  // 仅适用最后的两个样本进行推断
  if (samples.size > 2) {
    // 保留样本队列的最后两个样本
    // 队首出队,扔掉一个无用样本
    samples.dequeue()
  }
  // 将队列翻转后进行匹配
  val bytesDelta = samples.toList.reverse match {
   
    // 分别是 倒数第1个,倒数第2个和剩下的
    case latest :: previous :: tail =>
    // (倒数第1个样本记录的大小 - 倒数第2个样本记录的大小) / (倒数第1个样本的采样编号 - 倒数第2个的采样编号)
    (latest.size - previous.size).toDouble / (latest.numUpdates - previous.numUpdates)
    case _ => 0
  }
  // 得到根据采样计算的每次更新字节数速率,最小为0
  bytesPerUpdate = math.max(0, bytesDelta)
  // 机选下次采样的采样号
  nextSampleNum = math.ceil(numUpdates * SAMPLE_GROWTH_RATE).toLong
}

大小估计

最后,我们来看下提供给使用方获取目前集合内存占用大小的方法,当前的numUpdates与上次采样的numUpdates之间的差值,乘以bytesPerUpdate作为估计要增加的大小,即采样的样本数目*样本的平均大

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值