Impala 4.0源码解析之BROADCAST/SHUFFLE代价计算

Impala的hash join目前有两种方式:broadcast和shuffle。关于这两种方式的区别,网上也有很多相关的资料介绍。我们这里来简单介绍下,broadcast join适合大表join小表的场景,首先将小表先构建hash table,然后发送到大表所在全部节点上。此时每个节点上都有大表的一部分数据和整个小表的数据。整个流程如下所示:
1

(图片来自:http://hbasefly.com/2017/03/19/sparksql-basic-join
而shuffle join则适合两个大表join的场景,将两张大表分别进行hash,然后分发到不同的节点上,分别进行join操作。也就是说,每个节点上,都会有两个表的一部分数据。整个流程如下所示:
2
(图片来自:http://hbasefly.com/2017/03/19/sparksql-basic-join
Impala在FE中进行SQL解析的时候,会先分别对这两种方式进行代价计算,然后选择代价比较小的一种方式,作为最终的join方式。当然,Impala也提供了一些hints来显示指定join的方式,感兴趣的同学可以参考:使用Impala hint加速SQL查询,这里就不展开介绍。社区4.0.0版本提供了完整的mt功能,在代价计算上与之前的版本相比,有一些不同。因此,我们就结合4.0.0版本的代码来看一下,Impala是如何对两种不同的方式进行代价计算的。对于代价计算的处理逻辑,是在FE对SQL的analysis和authorization阶段之后进行的,相关的代码调用如下所示:

createExecRequest(JniFrontend.java): 162
-createExecRequest(Frontend.java): 1595
--getTExecRequest(Frontend.java): 1625
---doCreateExecRequest(Frontend.java): 1733
----getPlannedExecRequest(Frontend.java): 1885
-----createExecRequest(Frontend.java): 1543
------createPlans(Planner.java): 246
-------createPlanFragments(Planner.java): 137
--------createPlanFragments(DistributedPlanner.java): 84
---------createPlanFragments(DistributedPlanner.java): 118
----------createHashJoinFragment(DistributedPlanner.java)

最终在createHashJoinFragment函数中,分别对broadcast和shuffle这两种方式的代价进行计算。所谓代价计算,无非就是看每种join方式的开销,哪种开销比较小,就选择哪种。主要就包括两个方面:1)网络开销,需要通过网络在各个节点间传输的数据;2)内存开销,保存在各个节点中的数据所占用的内存。下面我们就结合具体的代码来看一下这两种join方式到底是如何计算的。

Broadcast代价计算

常见的broadcast join的执行计划如下所示:
3
可以看到,执行计划将右表的数据通过exchange广播到左表所在的节点上。因此,broadcast的网络开销,其实就是右表大小*左表节点数。我们看下相关的代码:

long rhsDataSize = -1;
rhsDataSize = Math.round(
    rhsTree.getCardinality() * ExchangeNode.getAvgSerializedRowSize(rhsTree));
long dataPayload = rhsDataSize * leftChildNodes;

这里的dataPayload指的就是网络开销。其中,rhsTree是根据右表构建的一个HdfsScanNode类型的变量。首先,需要计算右表的大小,这里的计算方法是用右表的cardinality(关于cardinality,我们后面再详细介绍)乘以每行的row size(关于这个row size的计算,后面也会再详细介绍)。然后将这个rhsDataSize乘以左表分配的节点数,就是最终的dataPayload。接下来我们再看看内存消耗的计算,代码如下所示:

long hashTblBuildCost = dataPayload;
if (mt_dop > 1 && ctx_.getQueryOptions().use_dop_for_costing) {
  PlanNode leftPlanRoot = leftChildFragment.getPlanRoot();
  int actual_dop = leftPlanRoot.getNumInstances()/leftPlanRoot.getNumNodes();
  hashTblBuildCost *= (long) (ctx_.getQueryOptions().broadcast_to_partition_factor
    * Math.max(1.0, Math.sqrt(actual_dop)));
} 

这里hashTblBuildCost代表的就是内存消耗,即构建hash table的消耗。首先,将hashTblBuildCost初始化为dataPayload,也就是说在单线程模式下,左表所在每个节点上都有一个右表对应的hash table,加起来就是总的内存消耗。但是如果设置了mt,则还需要乘以broadcast_to_partition_factor和sqrt(actual_dop)。这里引入了两个新的query option:use_dop_for_costing和broadcast_to_partition_factor。关于这两个参数的更多信息,可以参考:IMPALA-10287。这里的计算公式,官方的解释是:We use the sqrt to model a non-linear function since the slowdown with broadcast is not exactly linear (TODO: more analysis is needed to establish an accurate correlation)。意思就是用mt的平方根模拟了一个非线性的关系来作为内存开销,也可能会存在一些偏差,此时我们就可以通过上述的两个option来进行控制。最终,我们就得到了broadcast的代价:broadcastCost = dataPayload + hashTblBuildCost。

Shuffle代价计算

常见的shuffle join执行计划如下所示:
4
可以看到,两个表都需要通过exchange来进行数据的传输。因此,shuffle的网络开销就是两个表的网络传输开销之和;由于右表是被hash到各个节点上,并不是广播的,所以各个节点上也只有右表的一部分,总的内存开销就是右表的大小。相关代码如下所示:

// 代码进行了调整,仅展示关键部分,与源代码有所不同。
lhsNetworkCost = Math.round(lhsTree.getCardinality() *
    ExchangeNode.getAvgSerializedRowSize(lhsTree));
rhsNetworkCost = rhsDataSize;
partitionCost = Math.round(lhsNetworkCost + rhsNetworkCost + rhsDataSize);

这里只需要再单独计算左表的大小,作为左表的网络开销(计算方式与上述的右表计算一样)。最终的partitionCost就是左右两个表的网络开销加上右表的大小。

Cardinality计算

上面在计算代价的时候,用到了表的cardinality。关于cardinality,可以理解为表在本次查询中,需要扫描多少行的数据,这是Impala根据一系列的计算得到的一个预估值。关于这个值的计算,是在创建single node plan的过程中完成的,相关的代码调用栈如下所示:

createExecRequest(JniFrontend.java): 162
......
-createPlans(Planner.java): 246
--createPlanFragments(Planner.java): 119
......
init(HdfsScanNode.java): 431
-computeStats(HdfsScanNode.java): 1247
--computeCardinalities(HdfsScanNode.java):1247

由于整个调用栈比较长,我们我们省略了其中部分。最开始的调用栈与之前的代价计算是一样的。最主要的处理逻辑是在computeCardinalities这个函数中完成的。我们将比较重要的代码单独拿出来看下:

//HdfsScanNode.computeCardinalities()
//这里并非完整的代码,只是摘取了比较重要的一部分
long statsNumRows = getStatsNumRows(analyzer.getQueryOptions());
cardinality_ = statsNumRows;
cardinality_ = applyConjunctsSelectivity(cardinality_);

首先,就是根据统计信息,获取要扫描的表的行数,如果是分区表的话,只统计需要scan的分区行数之和,将这个行数作为初始的cardinality(这里我们讨论的前提都是表的统计信息完整,并且是准确的)。然后,我们通过applyConjunctsSelectivity这个函数来更新这个cardinality变量,相关的函数如下所示:

//HdfsScanNode.java
protected long applyConjunctsSelectivity(long preConjunctCardinality) {
  return applySelectivity(preConjunctCardinality, computeSelectivity());
}

protected long applySelectivity(long preConjunctCardinality, double selectivity) {
  long cardinality = (long) Math.round(preConjunctCardinality * selectivity);
  if (cardinality == 0 && preConjunctCardinality > 0) return 1;
  return cardinality;
}
//Planner.java
protected double computeSelectivity() {
  return computeCombinedSelectivity(conjuncts_);
}

可以看到,这几个函数都比较简单,主要就是通过computeCombinedSelectivity这个函数来计算得到一个selectivity,然后用这个selectivity乘以初始的cardinality,得到一个新的cardinality。这个cardinality就是我们在上面计算代价的时候用到的。关于computeCombinedSelectivity这个函数的处理逻辑,我们放到下个部分跟selectivity一起介绍。

Selectivity计算

每一个Expr都有一个selectivity成员变量,表示的是谓词为真的预估概率,默认-1,表示不能预估,如下所示:

// Estimated probability of a predicate evaluating to true. Set during analysis.
// Between 0 and 1, or set to -1 if the selectivity could not be estimated.
protected double selectivity_;

从上述注释我们可以知道,这个selectivity是用来衡量谓词为真的一个概率,Impala本身也实现了好几种谓词,我们这里就挑选几个作为例子进行解释。BinaryPredicate是一个常见的谓词,例如a=1、b>2这些都属于。这种谓词的selectivity计算方式如下所示:

// BinaryPredicate.java
protected void computeSelectivity() {
  Reference<SlotRef> slotRefRef = new Reference<SlotRef>();
  if ((op_ == Operator.EQ || op_ == Operator.NOT_DISTINCT)
      && isSingleColumnPredicate(slotRefRef, null)) {
    long distinctValues = slotRefRef.getRef().getNumDistinctValues();
    if (distinctValues > 0) {
      selectivity_ = 1.0 / distinctValues;
      selectivity_ = Math.max(0, Math.min(1, selectivity_));
    }
  }
}

上述的计算,就是当谓词是等于或者NOT_DISTINCT,并且只有一边是列,另外一边是常量的情况下,我们使用列的distinct值的倒数作为谓词的selectivity,这里其实是假设每种value出现的概率都是一样,但实际业务中肯定是会不一样的,所以这里只是一个预估的概率。那么,大于、小于等其他情况下,selectivity都是-1,无法预估为真的概率。
接着来看看CompoundPredicate这种谓词的selectivity计算,像a>1 and a<10,a>1 or b<1等,这些都属于CompoundPredicate,相关的计算代码如下所示:

protected void computeSelectivity() {
  if (!getChild(0).hasSelectivity() ||
      (children_.size() == 2 && !getChild(1).hasSelectivity())) {
    selectivity_ = -1;
    return;
  }

  switch (op_) {
    case AND:
      selectivity_ = getChild(0).selectivity_ * getChild(1).selectivity_;
      break;
    case OR:
      selectivity_ = getChild(0).selectivity_ + getChild(1).selectivity_
          - getChild(0).selectivity_ * getChild(1).selectivity_;
      break;
    case NOT:
      selectivity_ = 1.0 - getChild(0).selectivity_;
      break;
  }
  selectivity_ = Math.max(0.0, Math.min(1.0, selectivity_));
}

可以看到,根据逻辑运算符的不同,分别有不同的计算方式。这里以AND为例,就需要左右两个子节点的谓词同时为真,该谓词才会为真,所以是两个子节点的selectivity相乘。
除此之外,Impala还提供了其他的一些谓词,例如InPredicate、IsNullPredicate等,其中有一些谓词也是没有办法进行概率预估的,例如LikePredicate、ExistsPredicate等。这些无法预估的谓词,selectivity就会为-1。

HdfsScanNode的selectivity计算

介绍完了各种谓词的selectivity计算之后,我们再回过头看下上面的HdfsScanNode是如何计算selectivity的。相关的处理逻辑都位于computeCombinedSelectivity函数中。这个函数接受一个expr类型的list,这里代表就是这个HdfsScanNode的谓词条件集合(不包含分区列的相关谓词)。接着我们来看下这个函数的处理流程:

//Planner.java
  static protected double computeCombinedSelectivity(List<Expr> conjuncts) {
    List<Double> selectivities = new ArrayList<>();
    for (Expr e: conjuncts) {
      if (e.hasSelectivity()) selectivities.add(e.getSelectivity());
    }
    if (selectivities.size() != conjuncts.size()) {
      selectivities.add(Expr.DEFAULT_SELECTIVITY);
    }
    Collections.sort(selectivities);
    double result = 1.0;
    for (int i = 0; i < selectivities.size(); ++i) {
      result *= Math.pow(selectivities.get(i), 1.0 / (double) (i + 1));
    }
    return Math.max(0.0, Math.min(1.0, result));
  }

  protected double computeSelectivity() {
    return computeCombinedSelectivity(conjuncts_);
  }

首先,收集所有conjunct的selectivity,如果某些conjunct不存在selectivity,则使用单个的一个默认selectivity来作为补充(默认的是固定的0.1,这里也是一个估计值作为替代)。然后对这些selectivity进行升序排序,接着使用指数退避将这些selectivity累计相乘的结果,作为HdfsScanNode的combined selectivity。最终使用这个selectivity乘以初始的cardinality(要扫描的行数)得到了这个表的最终的cardinality。

AvgRowSize计算

在计算表的大小时,我们是用cardinality乘以每行的row size。这里我们就简单介绍下,每行的row size是如何计算的。相关代码如下所示:

  public static double getAvgSerializedRowSize(PlanNode exchInput) {
    return exchInput.getAvgRowSize() +
        (exchInput.getTupleIds().size() * PER_TUPLE_SERIALIZATION_OVERHEAD);
  }

主要分为两个部分,HdfsScanNode的avgRowSize和tuple的序列化开销。对于每个HdfsScanNode,都有一个tuple(对应TupleDescriptor结构),包含查询中涉及到的该表的各个列(对应SlotDescriptor结构)。对于各个列来说,统计信息中会有一个Avg Size信息,表示该列的平均长度。Impala本身也给每种类型定义了对应的slot size,如下所示:

//PrimitiveType.java
//摘取了其中部分的类型
INT("INT", 4, TPrimitiveType.INT),
BIGINT("BIGINT", 8, TPrimitiveType.BIGINT),
// 8-byte pointer and 4-byte length indicator (12 bytes total).
STRING("STRING", 12, TPrimitiveType.STRING),
VARCHAR("VARCHAR", 12, TPrimitiveType.VARCHAR),

对于定长的类型来说,avg size和slot size是一样的,对于这些类型而言,它的avgSerializedSize就是slot size。而对于非定长类型,例如string来说,每列的avg size可能都是不一样的,slot size是12(8字节存储指针,4字节存储长度),那么它的avgSerializedSize就是avg size加上slot size。
对于每个tuple而言,它的avgSerializedSize就是它所包含的所有的slot的avgSerializedSize之和。而对于HdfsScanNode而言,它的avgRowSize就是其包含的这个tuple的avgSerializedSize,如下所示(这里需要注意,对于tuple和slot,我们讨论的是avgSerializedSize;而对于HdfsScanNode,我们讨论的是avgRowSize,千万别混淆了):

//PlanNode.java
//对于HdfsScanNode,只有一个tuple 
public void computeStats(Analyzer analyzer) {
  avgRowSize_ = 0.0F;
  for (TupleId tid: tupleIds_) {
    TupleDescriptor desc = analyzer.getTupleDesc(tid);
    avgRowSize_ += desc.getAvgSerializedSize();
  }
// 省略其余部分代码
}

这样我们就得到了第一部分exchInput.getAvgRowSize()的值。第二部分是tuple序列化本身的开销,其实就是tuple个数乘以一个常量值(对于HdfsScanNode只有一个tuple),常量值的注释如下所示:

  // The serialization overhead per tuple in bytes when sent over an exchange.
  // Currently it accounts only for the tuple_offset entry per tuple (4B) in a
  // BE TRowBatch. If we modify the RowBatch serialization, then we need to
  // update this constant as well.
  private static final double PER_TUPLE_SERIALIZATION_OVERHEAD = 4.0;

这样我们就可以得到row size,再乘以上面计算得到的cardinality,就是该表的data size了。

总结

到这里,关于broadcast和shuffle的代价计算就基本介绍完了。简单总结一下,本文首先介绍了Impala在选择join方式的时候,是如何分别对broadcast和shuffle进行代价计算的。其次,逐个介绍了代价计算中用到的cardinality、selectivity和row size的含义,以及各自的计算方式。需要注意的是,我们这里讨论的前提是:表的统计信息都是准确的,并且是完整的。如果表没有统计信息,或者统计信息不准确,那么上述的代价计算则无法进行。不过,即使统计信息完整,代价计算也不一定就是最佳的,有可能会出现偏差。这是因为,在计算selectivity的时候,本身就是预估值,可能因为各种谓词的原因,导致最终的cardinality计算出现偏差。除此之外,mt情况下的broadcast内存开销计算,也有可能出现偏差。目前对于有问题的执行计划我们只能通过hints或者上述的query option来手动调整。期待社区后续能有更多的优化,来提高准确性。此外,本文是笔者基于社区4.0.0代码的分析而来,如有错误,欢迎批评指正。

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值