Spark シャッフル プロセスのソース コード分析

序文

Sparkのシャッフル処理をより深く理解するためには、ソースコードを読むことでシャッフル処理の実行過程やソートに関する内容をしっかり理解することができます。

この記事で使用されている Spark のバージョンは 2.4.4 です。

1、シャッフルのBypassMergeSortShuffleWriter

基本的:

1. 下流のreduceにあるパーティションの数と同数のfileWriter[reduceNumer]が上流のマップに作成され、各下流のパーティションのデータが独立したファイルに書き込まれます。すべてのパーティション ファイルが書き込まれた後、複数のパーティションのデータを 1 つのファイルにマージします。コードは次のとおりです。

while (records.hasNext()) {
      final Product2<K, V> record = records.next();
      final K key = record._1();
      //作者注:将数据写到对应分区的文件中去。
      partitionWriters[partitioner.getPartition(key)].write(key, record._2());
    }

    for (int i = 0; i < numPartitions; i++) {
      final DiskBlockObjectWriter writer = partitionWriters[i];
      partitionWriterSegments[i] = writer.commitAndGet();
      writer.close();
    }

    File output = shuffleBlockResolver.getDataFile(shuffleId, mapId);
    File tmp = Utils.tempFileWith(output);
    try {
      //作者注:合并所有分区的小文件为一个大文件,保证同一个分区的数据连续存在
      partitionLengths = writePartitionedFile(tmp);
      //作者注:构建索引文件
      shuffleBlockResolver.writeIndexFileAndCommit(shuffleId, mapId, partitionLengths, tmp);
    } finally {
      if (tmp.exists() && !tmp.delete()) {
        logger.error("Error while deleting temp file {}", tmp.getAbsolutePath());
      }
    }

2. 各独立したパーティションファイルのデータは同じリデュースに属しているため、ファイルを結合するときにソートする必要はなく、ファイルの順序に従ってそれらを1つのファイルに結合し、対応するパーティションデータインデックスファイルを作成するだけです。 。

3. BypassMergeSortShuffleWriter を使用するための条件は次のとおりです。

        (1)、ダウンストリーム パーティションの数は、spark.shuffle.sort.bypassMergeThreshold パラメーターの値を超えることはできません (デフォルトは 200)。

        (2)、非マップ側の事前集計演算子 (reduceByKey)

        具体的な判定コードは以下の通りです。

def shouldBypassMergeSort(conf: SparkConf, dep: ShuffleDependency[_, _, _]): Boolean = {
    // We cannot bypass sorting if we need to do map-side aggregation.
    if (dep.mapSideCombine) {
      false
    } else {
      val bypassMergeThreshold: Int = conf.getInt("spark.shuffle.sort.bypassMergeThreshold", 200)
      dep.partitioner.numPartitions <= bypassMergeThreshold
    }
  }

2、シャッフルのソートShuffleWriter

ライターの実行条件:

(1) ダウンストリーム パーティションの数が、spark.shuffle.sort.bypassMergeThreshold パラメーターで設定された値 (デフォルトは 200) を超えています。

(2)、UnsafeShuffleWriter というライターをスキップします (詳細は 3 を参照)

実行プロセスの説明:

1. マップの末尾が事前集計演算子 (reduceByKey など) の場合

(1)、データ ストレージと事前集計にマップ PartitionedAppendOnlyMap オブジェクトを使用します。コードは次のとおりです。

        注: map.changeValue の場合、更新されたキーはデータのキーではなく、データ キー ((getPartition(kv._1), kv._1) に基づいてキーのパーティション ID が追加されることがわかります。 ))、これを行う目的は、データが下のディスクにオーバーフローしたときにパーティション ID でソートし、同じパーティションのデータを連続して一緒に保存できるようにすることです。

//作者注:判断是否是一个预聚合算子
if (shouldCombine) {
      // Combine values in-memory first using our AppendOnlyMap
      //作者注:获取预聚合算子的执行函数
      val mergeValue = aggregator.get.mergeValue
      val createCombiner = aggregator.get.createCombiner
      var kv: Product2[K, V] = null
      val update = (hadValue: Boolean, oldValue: C) => {
        if (hadValue) mergeValue(oldValue, kv._2) else createCombiner(kv._2)
      }
      while (records.hasNext) {
        addElementsRead()
        kv = records.next()
        //作者注:使用一个map:PartitionedAppendOnlyMap类型进行数据的存储和预聚合更新
        map.changeValue((getPartition(kv._1), kv._1), update)
        //作者注:执行溢出到磁盘操作
        maybeSpillCollection(usingMap = true)
      }
    }

(2) ディスクへのデータ スピル操作を実行します:maySpillCollection。コードは次のとおりです。

 private def maybeSpillCollection(usingMap: Boolean): Unit = {
    var estimatedSize = 0L
    //作者注:判断是否是预聚合算子
    if (usingMap) {
      //作者注:预聚合算子,则把map对象里面的数据写入到磁盘
      estimatedSize = map.estimateSize()
      if (maybeSpill(map, estimatedSize)) {
        map = new PartitionedAppendOnlyMap[K, C]
      }
    } else {
      //作者注:不是预聚合算子,则把buffer对象里面的数据写入到磁盘
      estimatedSize = buffer.estimateSize()
      if (maybeSpill(buffer, estimatedSize)) {
        buffer = new PartitionedPairBuffer[K, C]
      }
    }

       次に、maybeSpill 関数を実行して、オーバーフローの状況に応じてディスクにオーバーフローするかどうかを判断します。コードは次のとおりです。

protected def maybeSpill(collection: C, currentMemory: Long): Boolean = {
    var shouldSpill = false
    if (elementsRead % 32 == 0 && currentMemory >= myMemoryThreshold) {
      // Claim up to double our current memory from the shuffle memory pool
      val amountToRequest = 2 * currentMemory - myMemoryThreshold
      val granted = acquireMemory(amountToRequest)
      myMemoryThreshold += granted
      // If we were granted too little memory to grow further (either tryToAcquire returned 0,
      // or we already had more memory than myMemoryThreshold), spill the current collection
      shouldSpill = currentMemory >= myMemoryThreshold
    }

    //作者注:是否溢出磁盘,有两个判断条件
    //1、shouldSplill:判断内存空间的是否充足
    //2、_elementsRead > numElementsForceSpillThreshold:判断当前的写的数据条数是否超过阈值numElementsForceSpillThreshold(默认Integer.MAX_VALUE)
    shouldSpill = shouldSpill || _elementsRead > numElementsForceSpillThreshold
    // Actually spill
    if (shouldSpill) {
      _spillCount += 1
      logSpillage(currentMemory)
      spill(collection)
      _elementsRead = 0
      _memoryBytesSpilled += currentMemory
      releaseMemory()
    }
    shouldSpill
  }

        条件が満たされた場合、データ オーバーフローに対して Spir(Collection) が実行されます。コードは次のとおりです。

override protected[this] def spill(collection: WritablePartitionedPairCollection[K, C]): Unit = {
    val inMemoryIterator = collection.destructiveSortedWritablePartitionedIterator(comparator)
    val spillFile = spillMemoryIteratorToDisk(inMemoryIterator)
    spills += spillFile
  }

        このコード行を見てください。

val inMemoryIterator = collection.destructiveSortedWritablePartitionedIterator(comparator)

        このコード行の機能はデータを並べ替えることです。どのように並べ替えるのですか? 作成者のデバッグ プロセスの後、この並べ替えプロセスは実際にはデータのキーを並べ替えているのではなく、同じパーティションのデータが連続して一緒に存在できるように、パーティション ID を並べ替えていることがわかりました。その後のオーバーフロー ファイルのマージ ソート。

        この時点で、データ ディスクのオーバーフロー操作は完了します。次のステップは、オーバーフロー データをマージする方法です。

(3)、オーバーフロー ディスク データ ファイルが 1 つの大きなファイルにマージされ、パーティションのインデックス ファイルが確立されます。特定のコード実行プロセスは次のとおりです (SortShuffleWriter): 内部の特定の実行プロセスは再度繰り返されません。

try {
      val blockId = ShuffleBlockId(dep.shuffleId, mapId, IndexShuffleBlockResolver.NOOP_REDUCE_ID)
      //作者注:将溢出的磁盘文件和当前缓存的文件进行归并合并,保证同一分区的数据连续存在
      val partitionLengths = sorter.writePartitionedFile(blockId, tmp)
      //作者注:构建索引文件
      shuffleBlockResolver.writeIndexFileAndCommit(dep.shuffleId, mapId, partitionLengths, tmp)
      mapStatus = MapStatus(blockManager.shuffleServerId, partitionLengths)
    } finally {
      if (tmp.exists() && !tmp.delete()) {
        logError(s"Error while deleting temp file ${tmp.getAbsolutePath}")
      }
    }

2. マップ側が事前集計演算子 (groupByKey など) でない場合

上記では、事前集計オペレーターのシャッフルライターの実行プロセスを紹介しましたが、非事前集計オペレーターのシャッフルライターの実行プロセスは、基本的には事前集計オペレーターの実行プロセスと同じです。データを格納する構造はmap:PartitionedAppendOnlyMapではなく、buffer:PartitionedPairBufferとなっており、コードは次のとおりです。

if (shouldCombine) {
      // Combine values in-memory first using our AppendOnlyMap
      val mergeValue = aggregator.get.mergeValue
      val createCombiner = aggregator.get.createCombiner
      var kv: Product2[K, V] = null
      val update = (hadValue: Boolean, oldValue: C) => {
        if (hadValue) mergeValue(oldValue, kv._2) else createCombiner(kv._2)
      }
      while (records.hasNext) {
        addElementsRead()
        kv = records.next()
        map.changeValue((getPartition(kv._1), kv._1), update)
        maybeSpillCollection(usingMap = true)
      }
    } else {
      // Stick values into our buffer
      //作者注:非预聚合算子的数据存储到buffer中
      while (records.hasNext) {
        addElementsRead()
        val kv = records.next()
        buffer.insert(getPartition(kv._1), kv._1, kv._2.asInstanceOf[C])
        maybeSpillCollection(usingMap = false)
      }
    }

以降のデータオーバーフロー、データソート、オーバーフローデータファイルのマージなどの処理については、事前集計演算子の実行処理と全く同じであり、同じ実行処理が呼び出されるため、説明は省略します。詳細はこちら。

3、シャッフルのUnsafeShuffleWriter

Unsafe は名前からわかるように、データ ストレージと関連操作にオフヒープ メモリを使用することがわかるため、作者はこの UnsafeShuffleWriter の具体的な実行プロセスについては追求しませんでしたが、基本原理はデータ オブジェクトをシリアル化してヒープ上に格納することです。外部メモリを使用し、バイナリ方式を使用してデータを並べ替えると、コンピューティングのパフォーマンスが向上します。

実際の実行処理ではシャッフル処理での書き込みに適しており、具体的な実行条件は以下の通りです。

def canUseSerializedShuffle(dependency: ShuffleDependency[_, _, _]): Boolean = {
    val shufId = dependency.shuffleId
    val numPartitions = dependency.partitioner.numPartitions
    //作者注:序列化器支持relocation.
    //作者注:目前spark提供的有两个序列化器:JavaSerializer和KryoSerializer
    //其中KryoSerializer支持relocation,而JavaSerializer不支持relocation
    if (!dependency.serializer.supportsRelocationOfSerializedObjects) {
      log.debug(s"Can't use serialized shuffle for shuffle $shufId because the serializer, " +
        s"${dependency.serializer.getClass.getName}, does not support object relocation")
      false
    } else if (dependency.mapSideCombine) { //作者注:非map端预聚合算子
      log.debug(s"Can't use serialized shuffle for shuffle $shufId because we need to do " +
        s"map-side aggregation")
      false
    } else if (numPartitions > MAX_SHUFFLE_OUTPUT_PARTITIONS_FOR_SERIALIZED_MODE) {
      //作者注:下游分区个数小于MAXIMUM_PARTITION_ID = (1 << 24) - 1
      log.debug(s"Can't use serialized shuffle for shuffle $shufId because it has more than " +
        s"$MAX_SHUFFLE_OUTPUT_PARTITIONS_FOR_SERIALIZED_MODE partitions")
      false
    } else {
      log.debug(s"Can use serialized shuffle for shuffle $shufId")
      true
    }
  }

トレースはすべてバイナリ データである可能性があり、データ情報を直観的に表示できないため、作成者は特定の実行ロジックを詳しくトレースしませんでしたが、読者が興味を持った場合は、自分でデバッグしてトレースすることができます。

おすすめ

転載: blog.csdn.net/chenzhiang1/article/details/126834574