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