Spark UI中 Shuffle Exchange 和 BroadcastExchange 中的 dataSize 值为什么不一样

Spark3.5中ShuffleExchange与BroadcastExchange数据大小差异解析
文章分析了Spark3.5中ShuffleExchange和BroadcastExchange的数据大小在SparkUI指标上的不同,指出ShuffleExchange的大小基于UnsafeRow,而BroadcastExchange基于MemoryBlock,BroadcastExchange的大小通常接近2的整数倍,因为与内存管理和数据块分配策略有关。

背景

Spark 3.5
最近在看Spark UI 上的一些指标看到一个很有意思的东西, 相邻的Shuffle Exechange 和 BroadcastExechange 中的 datasize 居然不一样,
前者为 765KB, 后者为 64.5MB。差别还不少,中间就增加了一个 AQEShuffleRead 计划

结论

Shuffle Exechange 中的是真实 UnsafeRow的大小
BroadcastExechange 中的是 MemoryBlock 类型数据结构所占的大小 ,而不是UnsafeRow的大小。
BroadcastExechange中的datasize大小 和 2的整数倍接近。

现象以及分析

上图:
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

两个同样的 ShuffleExechange 记录条数和 ShuffleExechange 中 datasize 大小不一样,而在BroadcastExechange 中 dataSize 大小却是一样的(都是64.5MB)
关于 ShuffleExchange中的 dataSize的计算可以参考:Spark UI中Shuffle dataSize 和shuffle bytes written 指标区别,这里重点分析一下后者.
直接看BroadcastExechange代码:

  override lazy val relationFuture: Future[broadcast.Broadcast[Any]] = {
    SQLExecution.withThreadLocalCaptured[broadcast.Broadcast[Any]](
      session, BroadcastExchangeExec.executionContext) {
          try {
            // Setup a job tag here so later it may get cancelled by tag if necessary.
            sparkContext.addJobTag(jobTag)
            sparkContext.setInterruptOnCancel(true)
            val beforeCollect = System.nanoTime()
            // Use executeCollect/executeCollectIterator to avoid conversion to Scala types
            val (numRows, input) = child.executeCollectIterator()
            ...
            val relation = mode.transform(input, Some(numRows))
            val dataSize = relation match {
              case map: HashedRelation =>
                map.estimatedSize
              case arr: Array[InternalRow] =>
                arr.map(_.asInstanceOf[UnsafeRow].getSizeInBytes.toLong).sum
              case _ =>
                throw new SparkException("[BUG] BroadcastMode.transform returned unexpected " +
                  s"type: ${relation.getClass.getName}")
            }
            longMetric("dataSize") += dataSize

其中child.executeCollectIterator() 是在把数据从各个 Executor 收集到 Driver 端来,便于进行广播操作。
最主要的是 mode.transform(input, Some(numRows)),这里的数据流如下:


HashedRelationBroadcastMode.transform
  ||
  \/
HashedRelation.apply(rows, key, numRows.toInt, isNullAware = isNullAware)
  ||
  \/
UnsafeHashedRelation.apply(input, key, sizeEstimate, mm, isNullAware, allowsNullKey,
        ignoresDuplicatedKey)
  ||
  \/
new UnsafeHashedRelation(key.size, numFields, binaryMap)

最终调用的 UnsafeHashedRelation.estimatedSize的方法:

  override def estimatedSize: Long = binaryMap.getTotalMemoryConsumption
    

getTotalMemoryConsumptiondataPages所占用的大小再加上longArray的大小:

  public long getTotalMemoryConsumption() {
    long totalDataPagesSize = 0L;
    for (MemoryBlock dataPage : dataPages) {
      totalDataPagesSize += dataPage.size();
    }
    return totalDataPagesSize + ((longArray != null) ? longArray.memoryBlock().size() : 0L);
  }

那么 BytesToBytesMap 是怎么分配的呢?如下:

    val binaryMap = new BytesToBytesMap(
      taskMemoryManager,
      // Only 70% of the slots can be used before growing, more capacity help to reduce collision
      (sizeEstimate * 1.5 + 1).toInt,
      pageSizeBytes)

默认的PageSize值为:defaultPageSizeBytes:

  private lazy val defaultPageSizeBytes = {
    val minPageSize = 1L * 1024 * 1024   // 1MB
    val maxPageSize = 64L * minPageSize  // 64MB
    val cores = if (numCores > 0) numCores else Runtime.getRuntime.availableProcessors()
    // Because of rounding to next power of 2, we may have safetyFactor as 8 in worst case
    val safetyFactor = 16
    val maxTungstenMemory: Long = tungstenMemoryMode match {
      case MemoryMode.ON_HEAP => onHeapExecutionMemoryPool.poolSize
      case MemoryMode.OFF_HEAP => offHeapExecutionMemoryPool.poolSize
    }
    val size = ByteArrayMethods.nextPowerOf2(maxTungstenMemory / cores / safetyFactor)
    val chosenPageSize = math.min(maxPageSize, math.max(minPageSize, size))
    if (Utils.isG1GC && tungstenMemoryMode == MemoryMode.ON_HEAP) {
      chosenPageSize - Platform.LONG_ARRAY_OFFSET
    } else {
      chosenPageSize
    }
  }

这个跟内存以及core有关。
当在进行val loc = binaryMap.lookup 以及loc.append操作的时候就会进行dataPage以及longArray的分配。而该size的大小并不是实际占用的大小,而是分配给该dataPage的大小。其实你会发现该datasize的大小几乎和2的倍数接近。

优化 Spark SQL 中 Exchange 操作的 `data size total` 可以从以下几个方面入手: ### 数据预处理 - **过滤必要的数据**:在进行 Exchange 操作之前,尽可能过滤掉需要的数据。例如,通过 `filter` 操作减少参与后续计算的数据量。参考示例代码如下: ```python from pyspark.sql import SparkSession from pyspark.sql.functions import col spark = SparkSession.builder.appName("DataFiltering").getOrCreate() salesData = spark.read.parquet("hdfs://sales_data.parquet") filteredData = salesData.filter(col("amount") > 1000) ``` - **数据去重**:去除数据集中的重复记录,避免重复数据参与 Exchange 操作,从而减少数据传输量。示例代码如下: ```python uniqueData = salesData.dropDuplicates() ``` ### 分区策略优化 - **合理选择分区键**:根据业务需求数据分布,选择合适的分区键,使数据在分区时更加均匀。例如,如果经常按照 `product_id` 进行分组操作,可以按照 `product_id` 进行分区。示例代码如下: ```python repartitionedData = salesData.repartition(10, col("product_id")) ``` - **自适应分区**:使用 Spark 的自适应查询执行(AQE)功能,让 Spark 自动根据数据分布执行情况调整分区策略。可以通过设置以下配置启用 AQE: ```python spark.conf.set("spark.sql.adaptive.enabled", "true") ``` ### 数据倾斜处理 - **加盐分区**:对于存在数据倾斜的情况,可以在分区键上添加随机前缀,将倾斜的数据分散到多个分区中。示例代码如下: ```python from pyspark.sql.functions import rand # 添加随机前缀 saltedData = salesData.withColumn("salt", (rand() * 10).cast("int")) repartitionedData = saltedData.repartition(10, col("salt"), col("product_id")) ``` - **两阶段聚合**:对于聚合操作,可以先在局部进行预聚合,减少数据倾斜对最终聚合结果的影响。示例代码如下: ```python from pyspark.sql.functions import sum # 局部聚合 localAggData = salesData.groupBy(col("product_id"), col("salt")) \ .agg(sum("amount").alias("local_total")) # 全局聚合 finalAggData = localAggData.groupBy(col("product_id")) \ .agg(sum("local_total").alias("total_sales")) ``` ### 集群资源调整 - **增加并行度**:适当增加执行器的数量或每个执行器的核心数,提高数据处理的并行度,加快 Exchange 操作的速度。可以通过以下配置进行调整: ```python spark.conf.set("spark.executor.instances", "10") spark.conf.set("spark.executor.cores", "4") ``` - **优化内存配置**:合理分配执行器的内存,避免内存足导致数据溢出到磁盘,影响性能。可以通过以下配置进行调整: ```python spark.conf.set("spark.executor.memory", "4g") ``` ### 广播小表 如果在连接操作中存在小表,可以使用广播变量将小表广播到每个执行器上,避免小表参与 Exchange 操作。示例代码如下: ```python from pyspark.sql.functions import broadcast smallTable = spark.read.parquet("hdfs://small_table.parquet") bigTable = spark.read.parquet("hdfs://big_table.parquet") joinedData = bigTable.join(broadcast(smallTable), on="join_key") ```
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值