github-actions[bot] commented on code in PR #67433:
URL: https://github.com/apache/doris/pull/67433#discussion_r4069572919
##########
fe/fe-core/src/main/java/org/apache/doris/planner/PlanFragment.java:
##########
@@ -206,6 +206,19 @@ private Supplier<Boolean> buildHasBucketShuffleNode() {
return true;
}
}
+ // A fused group join is a PlanNode in its own right, it does not
extend HashJoinNode, so
+ // the two loops above cannot see the bucket shuffle of a fused
INNER join. Missing it
+ // made the scan job of the bucket side an
UnassignedScanSingleOlapTableJob while the
+ // exchange into the join still claimed
BUCKET_SHFFULE_HASH_PARTITIONED, and
+ // DistributePlanner.getDestinationsByBuckets then failed to cast
that job to
+ // UnassignedScanBucketOlapTableJob.
+ List<GroupJoinNode> groupJoinNodes
Review Comment:
[P1] Please update the legacy Coordinator's bucket-shuffle detector as well.
This fragment predicate is consumed by the new `DistributePlanner`, but
`enable_nereids_distribute_planner=false` still selects
`Coordinator.BucketShuffleJoinController`, which recognizes only
`HashJoinNode`. A fused `BUCKET_SHUFFLE` GroupJoin then gets generic scan
assignment and later fails the bucket-shuffle sink assertion (or loses
bucket-aligned instances). Reuse this predicate there and run the
bucket-shuffle regression with the legacy distributor.
##########
be/src/exec/operator/groupjoin_build_sink.cpp:
##########
@@ -0,0 +1,281 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#include "exec/operator/groupjoin_build_sink.h"
+
+#include <variant>
+
+#include "common/cast_set.h"
+#include "core/data_type/data_type_nullable.h"
+#include "exec/common/hash_table/hash_map_util.h"
+#include "exec/common/util.hpp"
+#include "exec/operator/groupjoin_operator_utils.h"
+#include "exprs/vectorized_agg_fn.h"
+#include "exprs/vexpr.h"
+#include "runtime/descriptors.h"
+#include "runtime/runtime_state.h"
+#include "util/defer_op.h"
+
+namespace doris {
+
+GroupJoinBuildSinkLocalState::GroupJoinBuildSinkLocalState(DataSinkOperatorXBase*
parent,
+ RuntimeState* state)
+ : Base(parent, state) {
+ _finish_dependency = std::make_shared<CountedFinishDependency>(
+ parent->operator_id(), parent->node_id(), parent->get_name() +
"_FINISH_DEPENDENCY");
+}
+
+Status GroupJoinBuildSinkLocalState::init(RuntimeState* state,
LocalSinkStateInfo& info) {
+ RETURN_IF_ERROR(Base::init(state, info));
+ _shared_state->memory_used_counter = _memory_used_counter;
+ auto& p = _parent->cast<GroupJoinBuildSinkOperatorX>();
+ _build_expr_ctxs.resize(p._build_expr_ctxs.size());
+ for (size_t i = 0; i < _build_expr_ctxs.size(); ++i) {
+ RETURN_IF_ERROR(p._build_expr_ctxs[i]->clone(state,
_build_expr_ctxs[i]));
+ }
+ _aggregate_evaluators.reserve(p._aggregate_evaluators.size());
+ for (auto* evaluator : p._aggregate_evaluators) {
+ _aggregate_evaluators.push_back(evaluator->clone(state, p._pool));
+ }
+ RETURN_IF_ERROR(groupjoin::register_agg_state_layout(
+ _shared_state, p._aggregate_sides, p._sizes_of_aggregate_states,
+ p._aligns_of_aggregate_states, p._aggregate_indices,
p._aggregate_evaluators));
Review Comment:
[P1] Register the local evaluator clones in the shared layout (and make the
same change on the probe side), or reject Java UDAFs from fusion. The layout
uses these pointers to create/finalize states, while row updates use
`local_state._aggregate_evaluators`. `AggFnEvaluator::clone` creates a fresh
`AggregateJavaUdaf`, and only the instance whose `create()` runs initializes
`_exec_place`. A fused Java UDAF therefore creates through the parent evaluator
but adds through a clone whose `_exec_place` is null.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java:
##########
@@ -3305,6 +3323,281 @@ private PlanFragment connectJoinNode(HashJoinNode
hashJoinNode, PlanFragment lef
return leftFragment;
}
+ private PlanFragment connectGroupJoinNode(GroupJoinNode groupJoinNode,
PlanFragment leftFragment,
+ PlanFragment rightFragment, PlanTranslatorContext context,
AbstractPlan groupJoin) {
+ groupJoinNode.setChild(0, leftFragment.getPlanRoot());
+ groupJoinNode.setChild(1, rightFragment.getPlanRoot());
+ setPlanRoot(leftFragment, groupJoinNode, groupJoin);
+ context.mergePlanFragment(rightFragment, leftFragment);
+ for (PlanFragment rightChild : rightFragment.getChildren()) {
+ leftFragment.addChild(rightChild);
+ }
+ return leftFragment;
+ }
+
+ /**
+ * V2: Try to fuse HashAggregate(HashJoin) into GroupJoin directly in the
translator stage.
+ * <p>
+ * When the aggregate's child is an INNER hash join with compatible
group-by/join-key,
+ * generate a GroupJoinNode instead of separate AggregationNode +
HashJoinNode.
+ * Returns null if conditions are not met — caller falls through to normal
translation.
+ */
+ private PlanFragment maybeTranslateToGroupJoin(
+ Plan aggregate,
+ PlanTranslatorContext context) {
+ // Gate: session variable
+ ConnectContext connectContext = ConnectContext.get();
+ if (connectContext == null
+ ||
!connectContext.getSessionVariable().isEnableGroupJoinFusion()) {
+ return null;
+ }
+ // Gate: spill not supported
+ if (connectContext.getSessionVariable().enableSpill) {
+ return null;
+ }
+
+ // Child must be PhysicalHashJoin (optionally through one
pure-passthrough
+ // PhysicalProject; see the project gate in GroupJoinFusionUtils).
+ Plan child = aggregate.child(0);
+ PhysicalProject<?> project = null;
+ if (child instanceof PhysicalProject) {
+ project = (PhysicalProject<?>) child;
+ child = child.child(0);
+ }
+ if (!(child instanceof PhysicalHashJoin)) {
+ return null;
+ }
+ PhysicalHashJoin<?, ?> join = (PhysicalHashJoin<?, ?>) child;
+
+ // Full fusion eligibility (join shape + aggregate constraints) is
decided by
+ // GroupJoinFusionUtils.alignedConjunctsForGroupJoin, the single
source of truth shared
+ // with the AlignGroupJoinConjunctOrder post-processor that pre-aligns
the child join's
+ // conjunct order with the group-by keys. The translator emits the
join's conjuncts as
+ // they are, so fusion additionally requires the conjuncts to be
listed in exactly the
+ // group-by order: the processor guarantees this for every eligible
shape, and anything
+ // that is not aligned stays on the regular HashJoinNode +
AggregationNode path.
+ List<Expression> alignedConjuncts =
GroupJoinFusionUtils.alignedConjunctsForGroupJoin(
+ (Aggregate<?>) aggregate, project, join);
+ if (alignedConjuncts == null
+ || !GroupJoinFusionUtils.sameConjunctOrder(
+ alignedConjuncts, join.getHashJoinConjuncts())) {
+ return null;
+ }
+
+ // All checks passed — generate GroupJoinNode
+ return translateToGroupJoinNode((Aggregate<?>) aggregate, join,
context);
+ }
+
+ /** Translate Aggregate(HashJoin) pattern into a GroupJoinNode fragment. */
+ private PlanFragment translateToGroupJoinNode(
+ Aggregate<?> aggregate,
+ PhysicalHashJoin<?, ?> join,
+ PlanTranslatorContext context) {
+ PhysicalHashJoin<PhysicalPlan, PhysicalPlan> physicalJoin
+ = (PhysicalHashJoin<PhysicalPlan, PhysicalPlan>) join;
+
+ // maybeTranslateToGroupJoin only lets joins without residual
conjuncts reach this
+ // point. Enforce it here as well: the fused operator has no per-pair
filtering, so
+ // a residual conjunct that slipped through would be silently dropped
and produce
+ // wrong aggregation results (the failure mode this guard exists for).
+ Preconditions.checkState(join.getOtherJoinConjuncts().isEmpty(),
+ "GroupJoin fusion requires the join to have no residual
conjuncts, got: %s",
+ join.getOtherJoinConjuncts());
+
+ // maybeTranslateToGroupJoin also only lets aggregates whose inputs
are all directly
+ // produced by the join children reach this point. Enforce it here as
well: aggregate
+ // arguments that reference slots computed by an intermediate Project
between the
+ // aggregate and the join (e.g. hoisted type-coercion casts of binary
aggregates) do
+ // not exist on the join children, and translating them would abort
fragment
+ // serialization with an NPE in GroupJoinNode.toThrift.
+ Set<Slot> joinChildrenOutputs = Sets.newHashSet();
+ joinChildrenOutputs.addAll(join.left().getOutputSet());
+ joinChildrenOutputs.addAll(join.right().getOutputSet());
+
Preconditions.checkState(joinChildrenOutputs.containsAll(aggregate.getInputSlots()),
+ "GroupJoin fusion requires all aggregate inputs to be
join-child outputs, got: %s",
+ aggregate.getInputSlots());
+
+ // Visit children right-to-left (right = build, left = probe).
+ // connectGroupJoinNode merges both child fragments into this one,
exactly like
+ // connectJoinNode does for a plain hash join, so the children must be
translated under
+ // the same fragment-merge context. Without it a child aggregate could
take the
+ // bucketed-aggregation path (shouldUseBucketedFusion), which drops
the exchange that
+ // keeps its olap scan in its own fragment and leaves two olap scans
inside the
+ // GroupJoin fragment: the scan-assignment job then rejects the
fragment with
+ // "Not supported multiple scan multiple OlapTable but not contains
colocate join or
+ // bucket shuffle join". The GroupJoin would also lose the hash
distribution its
+ // PARTITIONED input relies on, since that exchange is what enforces
it.
+ context.enterFragmentMergeChild();
+ PlanFragment rightFragment;
+ PlanFragment leftFragment;
+ try {
+ rightFragment = join.child(1).accept(this, context);
+ leftFragment = join.child(0).accept(this, context);
+ } finally {
+ context.exitFragmentMergeChild();
+ }
+ PlanNode leftPlanRoot = leftFragment.getPlanRoot();
+ PlanNode rightPlanRoot = rightFragment.getPlanRoot();
+
+ // Create GroupJoinNode
+ GroupJoinNode groupJoinNode = new GroupJoinNode(
+ context.nextPlanNodeId(), leftPlanRoot, rightPlanRoot);
+ groupJoinNode.setNereidsId(join.getId());
+ context.getNereidsIdToPlanNodeIdMap().put(join.getId(),
groupJoinNode.getId());
+
+ // Join operator
+ groupJoinNode.setJoinOp(JoinType.toJoinOperator(join.getJoinType()));
+
+ // Distribute expr lists
+ List<List<Expr>> distributeExprLists = getDistributeExprs(
+ physicalJoin.left(), physicalJoin.right());
+ groupJoinNode.setChildrenDistributeExprLists(distributeExprLists);
+
+ // Equi-join conjuncts
+ List<Expression> hashJoinConjuncts = join.getHashJoinConjuncts();
+ for (Expression hashConjunct : hashJoinConjuncts) {
+ EqualPredicate equalTo = JoinUtils.swapEqualToForChildrenOrder(
+ (EqualPredicate) hashConjunct, join.left().getOutputSet());
+ groupJoinNode.addEqJoinConjunct(
+ (BinaryPredicate) ExpressionTranslator.translate(equalTo,
context));
+ }
+
+ // Group-by expressions
+ List<Expr> groupingExprs = new ArrayList<>();
+ for (Expression e : aggregate.getGroupByExpressions()) {
+ groupingExprs.add(ExpressionTranslator.translate(e, context));
+ }
+ groupJoinNode.setGroupingExprs(groupingExprs);
+
+ // Aggregate functions with side annotations
+ List<Expr> aggFuncExprs = new ArrayList<>();
+ List<TGroupJoinAggSide> aggSides = new ArrayList<>();
+ Set<Slot> rightOutput = join.right().getOutputSet();
+ Set<AggregateExpression> seen = new HashSet<>();
+ for (NamedExpression outputExpr : aggregate.getOutputExpressions()) {
Review Comment:
[P1] Preserve the enclosing `SessionVarGuardExpr` when translating these
aggregates. The normal aggregate path's `collectAggInTree` explicitly
translates `guard(AggregateExpression)`, but this loop extracts and translates
the bare aggregate. For a view/MV created under different `enable_decimal256`
or decimal-overflow settings, the output slot keeps creation-time semantics
while the serialized aggregate signature is computed under the caller session,
which can change precision or fail the BE result-type check. Please reuse the
guard-aware collector here.
##########
be/src/exec/runtime_filter/runtime_filter_producer_helper_groupjoin.h:
##########
@@ -0,0 +1,94 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#pragma once
+
+#include <vector>
+
+#include "common/status.h"
+#include "core/block/block.h"
+#include "exec/runtime_filter/runtime_filter_producer_helper.h"
+#include "exprs/vexpr.h"
+#include "exprs/vexpr_context.h"
+#include "runtime/runtime_state.h"
+
+namespace doris {
+
+// This helper is used by GroupJoin build sink. GroupJoin does not keep a full
build block, so
+// this helper caches only runtime-filter source columns and replays them
after RF size is ready.
+class RuntimeFilterProducerHelperGroupJoin final : public
RuntimeFilterProducerHelper {
+public:
+ ~RuntimeFilterProducerHelperGroupJoin() override = default;
+
+ RuntimeFilterProducerHelperGroupJoin() : RuntimeFilterProducerHelper(true,
false) {}
+
+ Status append_block(Block* block) {
+ if (_skip_runtime_filters_process) {
+ return Status::OK();
+ }
+ if (block->rows() == 0) {
+ return Status::OK();
+ }
+
+ std::vector<ColumnPtr> filter_columns;
+ filter_columns.reserve(_filter_expr_contexts.size());
+ for (auto& ctx : _filter_expr_contexts) {
+ ColumnPtr column;
+ RETURN_IF_ERROR(ctx->execute(block, column));
+ column = column->convert_to_full_column_if_const();
+ filter_columns.emplace_back(std::move(column));
+ }
+
+ _build_rows += block->rows();
+ _cached_filter_columns.emplace_back(std::move(filter_columns));
Review Comment:
[P2] Account for and release this RF cache. Each build block retains one
evaluated column per filter, but GroupJoin's `MemoryUsage` counter includes
only arena/hash/container bytes, and `build_and_publish`/skip never clear
`_cached_filter_columns`. Large builds can therefore retain build-sized memory
through probe/drain while the operator profile omits it. Track the unique
cached bytes and clear the cache immediately after publish or disable.
##########
be/src/exec/runtime_filter/runtime_filter_producer_helper_groupjoin.h:
##########
@@ -0,0 +1,94 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#pragma once
+
+#include <vector>
+
+#include "common/status.h"
+#include "core/block/block.h"
+#include "exec/runtime_filter/runtime_filter_producer_helper.h"
+#include "exprs/vexpr.h"
+#include "exprs/vexpr_context.h"
+#include "runtime/runtime_state.h"
+
+namespace doris {
+
+// This helper is used by GroupJoin build sink. GroupJoin does not keep a full
build block, so
+// this helper caches only runtime-filter source columns and replays them
after RF size is ready.
+class RuntimeFilterProducerHelperGroupJoin final : public
RuntimeFilterProducerHelper {
+public:
+ ~RuntimeFilterProducerHelperGroupJoin() override = default;
+
+ RuntimeFilterProducerHelperGroupJoin() : RuntimeFilterProducerHelper(true,
false) {}
+
+ Status append_block(Block* block) {
+ if (_skip_runtime_filters_process) {
+ return Status::OK();
+ }
+ if (block->rows() == 0) {
+ return Status::OK();
+ }
+
+ std::vector<ColumnPtr> filter_columns;
+ filter_columns.reserve(_filter_expr_contexts.size());
+ for (auto& ctx : _filter_expr_contexts) {
+ ColumnPtr column;
+ RETURN_IF_ERROR(ctx->execute(block, column));
Review Comment:
[P1] Keep floating runtime-filter equality consistent with the GroupJoin
hash key. `do_evaluate()` canonicalizes signed zero and NaN, but this
re-evaluates and caches the raw source column; DOUBLE Bloom hashing uses the
raw bits, so a build `-0.0` filter can reject probe `+0.0` even though
GroupJoin would match them. Please canonicalize both Bloom insertion/probing
(or exclude floating Bloom filters for GroupJoin) and cover the float-special
case with a generated runtime filter.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/util/GroupJoinFusionUtils.java:
##########
@@ -0,0 +1,333 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.nereids.util;
+
+import org.apache.doris.nereids.stats.StatsCalculator;
+import org.apache.doris.nereids.trees.expressions.AggregateExpression;
+import org.apache.doris.nereids.trees.expressions.EqualPredicate;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.NullSafeEqual;
+import org.apache.doris.nereids.trees.expressions.OrderExpression;
+import org.apache.doris.nereids.trees.expressions.Slot;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.expressions.functions.agg.AggregateParam;
+import org.apache.doris.nereids.trees.plans.AggMode;
+import org.apache.doris.nereids.trees.plans.AggPhase;
+import org.apache.doris.nereids.trees.plans.JoinType;
+import org.apache.doris.nereids.trees.plans.algebra.Aggregate;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalHashAggregate;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalHashJoin;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalProject;
+import org.apache.doris.statistics.model.ColumnStatistic;
+import org.apache.doris.statistics.model.Statistics;
+
+import com.google.common.collect.Sets;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Shared eligibility and ordering rules for GroupJoin fusion
+ * (enable_group_join_fusion): fusing an INNER hash join + hash aggregation
into a single
+ * GroupJoin operator. Both consumers of these rules must stay in sync:
+ * <ul>
+ * <li>AlignGroupJoinConjunctOrder (post-processor, runs before runtime-filter
generation):
+ * when an eligible shape's GROUP BY merely permutes the join keys, it
reorders the child
+ * join's conjunct list to the group-by order (the value returned here);</li>
+ * <li>PhysicalPlanTranslator.maybeTranslateToGroupJoin (fusion decision at
translation):
+ * fuses an eligible shape only when the join's conjuncts are already listed
in exactly the
+ * group-by order (i.e. the order returned here equals the join's current
one), and emits
+ * the conjuncts as they are.</li>
+ * </ul>
+ */
+public final class GroupJoinFusionUtils {
+
+ private GroupJoinFusionUtils() {}
+
+ /**
+ * Decide whether {@code aggregate} over {@code join} is eligible for
GroupJoin fusion and,
+ * when it is, return the conjunct order the fused operator requires: a
list of the join's
+ * own conjunct instances where conjunct i produces the group-by key at
position i.
+ * <p>
+ * The fused GroupJoin operator groups rows by the shared hash key and
materializes one
+ * grouping-key column per equi-join conjunct: the BE writes the j-th
conjunct's key into
+ * the j-th output tuple slot, and the FE creates the output tuple slots
from the
+ * aggregate's group-by expressions in group-by order. The returned
columns are therefore
+ * correct iff conjunct i produces the group-by key at position i. Since
GROUP BY is
+ * unordered semantically, any GROUP BY that merely permutes the join keys
is eligible -
+ * the caller decides whether to reorder the join's conjuncts to the
returned order
+ * (AlignGroupJoinConjunctOrder) or to require it already (the translator).
+ * <p>
+ * Returns null when the shape is not eligible (not an INNER/CROSS hash
join, mark join,
+ * broadcast join, residual non-equi conjuncts, null-safe equal conjuncts,
no aggregate
+ * functions, aggregates reading both sides, aggregates with an internal
ORDER BY, an aggregate
+ * that is not the final one-phase node (GLOBAL + INPUT_TO_RESULT, per
function and node), an
+ * intermediate Project that computes columns, or intermediate project
slots) or when the
+ * group-by keys cannot be mapped one-to-one onto the conjuncts.
Session-level gates
+ * (enable_group_join_fusion, enable_spill) are checked by the callers,
not here.
+ *
+ * @param project the Project between the aggregate and the join, or null
when the aggregate
+ * directly consumes the join. Only a pure passthrough project
(each output is a bare
+ * slot already produced by one of the join children) is fusable:
the fused operator
+ * evaluates aggregates over the join children rows, so any
computation between the
+ * aggregate and the join (weighted re-multiplication of
pre-aggregated sides, hoisted
+ * casts, CSE columns, ...) must stay on the ordinary HashJoinNode
+ AggregationNode
+ * path which evaluates the Project.
+ */
+ public static List<Expression> alignedConjunctsForGroupJoin(
+ Aggregate<?> aggregate, PhysicalProject<?> project,
PhysicalHashJoin<?, ?> join) {
+ if (join.getJoinType() != JoinType.INNER_JOIN &&
!join.getJoinType().isCrossJoin()) {
+ return null;
+ }
+ if (join.isMarkJoin()) {
+ return null;
+ }
+ // The fused GroupJoin operator matches rows purely by the equi-join
key: it keeps
+ // per-key row counts and per-side aggregation states and has no
per-pair filtering
+ // stage, so a residual non-equi ON conjunct cannot be evaluated by it.
+ if (join.isBroadCastJoin() || !join.getOtherJoinConjuncts().isEmpty())
{
+ return null;
+ }
+ List<Expression> groupByExprs = aggregate.getGroupByExpressions();
+ List<Expression> hashJoinConjuncts = join.getHashJoinConjuncts();
+ if (groupByExprs.isEmpty() || hashJoinConjuncts.isEmpty()
+ || groupByExprs.size() != hashJoinConjuncts.size()) {
+ return null;
+ }
+ List<AggregateExpression> aggregateExpressions =
aggregate.getOutputExpressions().stream()
+ .flatMap(outputExpr ->
outputExpr.collect(AggregateExpression.class::isInstance).stream())
+ .map(AggregateExpression.class::cast)
+ .collect(Collectors.toList());
+ // Pure GROUP BY and DISTINCT keep the regular HashJoin + Aggregate
plan. The current
+ // GroupJoin execution path is designed around maintaining at least
one aggregate state.
+ if (aggregateExpressions.isEmpty()) {
+ return null;
+ }
+ // Phase gate: only the final one-phase aggregate is fusable. The
fused GroupJoin node
+ // materializes FINAL_RESULT and finalizes per-key aggregate state
directly, so an
+ // aggregate that is a partial/LOCAL buffer producer or a
DISTINCT/multi-phase
+ // intermediate node must stay on the ordinary HashJoinNode +
AggregationNode path.
+ // This is observable when two-phase aggregation is forced
(agg_phase=2): the LOCAL
+ // (INPUT_TO_BUFFER) phase sits directly above the join when no
exchange is inserted
+ // between them, and fusing it hard-codes FINAL_RESULT with
finalize-on evaluators
+ // while the merge-finalize aggregate above still consumes the partial
buffer - BE then
+ // aborts with "Aggregate function count result type check failed:
Column type String
+ // is not compatible with data type BIGINT". Requiring every output
aggregate function's
+ // own param to equal the node param also guards split shapes where
the node-level label
+ // alone lies (e.g. GLOBAL/INPUT_TO_RESULT node label with
per-function DISTINCT_* or
+ // buffer params), so the gate is per-function, not just node-level.
+ if (!(aggregate instanceof PhysicalHashAggregate)) {
+ return null;
+ }
+ AggregateParam nodeParam = ((PhysicalHashAggregate<?>)
aggregate).getAggregateParam();
+ if (nodeParam.aggPhase != AggPhase.GLOBAL || nodeParam.aggMode !=
AggMode.INPUT_TO_RESULT) {
+ return null;
+ }
+ for (AggregateExpression aggExpr : aggregateExpressions) {
+ AggregateParam perFunctionParam = aggExpr.getAggregateParam();
+ if (!perFunctionParam.equals(nodeParam)) {
+ return null;
+ }
+ }
+ // The fused operator evaluates aggregates over the probe/build rows
of the join
+ // children, so every group-by key and aggregate argument must be a
column one of the
+ // join children directly produces (an intermediate Project between
the aggregate and
+ // the join would translate to slots that do not exist on either
child).
+ Set<Slot> leftOutput = join.left().getOutputSet();
+ Set<Slot> rightOutput = join.right().getOutputSet();
+ Set<Slot> joinChildrenOutputs = Sets.newHashSet();
+ joinChildrenOutputs.addAll(leftOutput);
+ joinChildrenOutputs.addAll(rightOutput);
+ // Pure-passthrough gate for the intermediate Project (Scheme A): the
Project between
+ // the aggregate and the join may only forward columns the join
children already
+ // produce. A Project computing anything (eager pre-aggregation
weights such as
+ // cntL*cntR, hoisted type-coercion casts, CSE columns) cannot be
skipped by the fused
+ // operator, so such shapes fall back to the ordinary path. Note slot
ExprIds are
+ // reused by the eager-agg rewrite (the same id denotes the raw child
column below the
+ // project and the weighted value above it), so an existence test on
ids alone would
+ // let the weighted shape through; checking that every project output
IS a bare slot of
+ // a join child is the structural test that catches it.
+ if (project != null) {
+ for (NamedExpression projectOutput : project.getProjects()) {
+ if (!(projectOutput instanceof SlotReference)
+ || (!leftOutput.contains(projectOutput) &&
!rightOutput.contains(projectOutput))) {
+ return null;
+ }
+ }
+ }
+ if (!joinChildrenOutputs.containsAll(aggregate.getInputSlots())) {
+ return null;
+ }
+ // Order-sensitive aggregates (internal ORDER BY, e.g.
GROUP_CONCAT(... ORDER BY ...))
+ // are not fusable. The fused operator keeps only a per-key local
aggregate state on one
+ // side plus the other side's per-key row count, so it cannot
reconstruct the interleaved
+ // join row order such aggregates need; and TGroupJoinAggFunction
carries no per-function
+ // sort info (unlike AggregationNode's agg_sort_infos), so the
translated expression's
+ // ORDER BY column would be treated as an ordinary aggregate argument
by the BE
+ // group-join operators (they always pass an empty TSortInfo) and
abort with
+ // "Agg Function ... is not implemented". OrderExpression appears
under an output
+ // expression only inside an aggregate function's argument list.
+ for (Expression outputExpr : aggregate.getOutputExpressions()) {
+ if
(!outputExpr.collect(OrderExpression.class::isInstance).isEmpty()) {
+ return null;
+ }
+ }
+ // Aggregate functions must not reference columns from both join
sides: the per-side
+ // aggregation state is maintained by the corresponding probe/build
operator.
+ for (AggregateExpression aggExpr : aggregateExpressions) {
Review Comment:
[P1] Reject aggregate expressions containing volatile or
`NoneMovableFunction` expressions before fusion. GroupJoin evaluates arguments
per source row and then repeats aggregate state by join multiplicity, so
`SUM(random())` is no longer evaluated once per joined row. It also computes
selected-add arguments for the whole source block before skipping unmatched
places, so an `assert_true()` on an unmatched row can fail even though ordinary
HashJoin + Aggregate never evaluates that row. A
`containsVolatileOrNoneMovableExpression`-style gate is needed here.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java:
##########
@@ -3305,6 +3323,281 @@ private PlanFragment connectJoinNode(HashJoinNode
hashJoinNode, PlanFragment lef
return leftFragment;
}
+ private PlanFragment connectGroupJoinNode(GroupJoinNode groupJoinNode,
PlanFragment leftFragment,
+ PlanFragment rightFragment, PlanTranslatorContext context,
AbstractPlan groupJoin) {
+ groupJoinNode.setChild(0, leftFragment.getPlanRoot());
+ groupJoinNode.setChild(1, rightFragment.getPlanRoot());
+ setPlanRoot(leftFragment, groupJoinNode, groupJoin);
+ context.mergePlanFragment(rightFragment, leftFragment);
+ for (PlanFragment rightChild : rightFragment.getChildren()) {
+ leftFragment.addChild(rightChild);
+ }
+ return leftFragment;
+ }
+
+ /**
+ * V2: Try to fuse HashAggregate(HashJoin) into GroupJoin directly in the
translator stage.
+ * <p>
+ * When the aggregate's child is an INNER hash join with compatible
group-by/join-key,
+ * generate a GroupJoinNode instead of separate AggregationNode +
HashJoinNode.
Review Comment:
[P1] Add a backend-capability/smooth-upgrade gate before emitting
`GROUP_JOIN_NODE`. An upgraded FE with this experimental switch enabled can
currently schedule the new thrift node on an old BE, whose pipeline switch has
no matching operator and rejects the fragment. Default-off does not protect an
explicitly enabled query during rolling upgrade; please fall back to HashJoin +
Aggregate until every selected BE advertises support, and add a mixed-version
planner test.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java:
##########
@@ -3305,6 +3323,281 @@ private PlanFragment connectJoinNode(HashJoinNode
hashJoinNode, PlanFragment lef
return leftFragment;
}
+ private PlanFragment connectGroupJoinNode(GroupJoinNode groupJoinNode,
PlanFragment leftFragment,
+ PlanFragment rightFragment, PlanTranslatorContext context,
AbstractPlan groupJoin) {
+ groupJoinNode.setChild(0, leftFragment.getPlanRoot());
+ groupJoinNode.setChild(1, rightFragment.getPlanRoot());
+ setPlanRoot(leftFragment, groupJoinNode, groupJoin);
+ context.mergePlanFragment(rightFragment, leftFragment);
+ for (PlanFragment rightChild : rightFragment.getChildren()) {
+ leftFragment.addChild(rightChild);
+ }
+ return leftFragment;
+ }
+
+ /**
+ * V2: Try to fuse HashAggregate(HashJoin) into GroupJoin directly in the
translator stage.
+ * <p>
+ * When the aggregate's child is an INNER hash join with compatible
group-by/join-key,
+ * generate a GroupJoinNode instead of separate AggregationNode +
HashJoinNode.
+ * Returns null if conditions are not met — caller falls through to normal
translation.
+ */
+ private PlanFragment maybeTranslateToGroupJoin(
+ Plan aggregate,
+ PlanTranslatorContext context) {
+ // Gate: session variable
+ ConnectContext connectContext = ConnectContext.get();
+ if (connectContext == null
+ ||
!connectContext.getSessionVariable().isEnableGroupJoinFusion()) {
+ return null;
+ }
+ // Gate: spill not supported
+ if (connectContext.getSessionVariable().enableSpill) {
+ return null;
+ }
+
+ // Child must be PhysicalHashJoin (optionally through one
pure-passthrough
+ // PhysicalProject; see the project gate in GroupJoinFusionUtils).
+ Plan child = aggregate.child(0);
+ PhysicalProject<?> project = null;
+ if (child instanceof PhysicalProject) {
+ project = (PhysicalProject<?>) child;
+ child = child.child(0);
+ }
+ if (!(child instanceof PhysicalHashJoin)) {
+ return null;
+ }
+ PhysicalHashJoin<?, ?> join = (PhysicalHashJoin<?, ?>) child;
+
+ // Full fusion eligibility (join shape + aggregate constraints) is
decided by
+ // GroupJoinFusionUtils.alignedConjunctsForGroupJoin, the single
source of truth shared
+ // with the AlignGroupJoinConjunctOrder post-processor that pre-aligns
the child join's
+ // conjunct order with the group-by keys. The translator emits the
join's conjuncts as
+ // they are, so fusion additionally requires the conjuncts to be
listed in exactly the
+ // group-by order: the processor guarantees this for every eligible
shape, and anything
+ // that is not aligned stays on the regular HashJoinNode +
AggregationNode path.
+ List<Expression> alignedConjuncts =
GroupJoinFusionUtils.alignedConjunctsForGroupJoin(
+ (Aggregate<?>) aggregate, project, join);
+ if (alignedConjuncts == null
+ || !GroupJoinFusionUtils.sameConjunctOrder(
+ alignedConjuncts, join.getHashJoinConjuncts())) {
+ return null;
+ }
+
+ // All checks passed — generate GroupJoinNode
+ return translateToGroupJoinNode((Aggregate<?>) aggregate, join,
context);
+ }
+
+ /** Translate Aggregate(HashJoin) pattern into a GroupJoinNode fragment. */
+ private PlanFragment translateToGroupJoinNode(
+ Aggregate<?> aggregate,
+ PhysicalHashJoin<?, ?> join,
+ PlanTranslatorContext context) {
+ PhysicalHashJoin<PhysicalPlan, PhysicalPlan> physicalJoin
+ = (PhysicalHashJoin<PhysicalPlan, PhysicalPlan>) join;
+
+ // maybeTranslateToGroupJoin only lets joins without residual
conjuncts reach this
+ // point. Enforce it here as well: the fused operator has no per-pair
filtering, so
+ // a residual conjunct that slipped through would be silently dropped
and produce
+ // wrong aggregation results (the failure mode this guard exists for).
+ Preconditions.checkState(join.getOtherJoinConjuncts().isEmpty(),
+ "GroupJoin fusion requires the join to have no residual
conjuncts, got: %s",
+ join.getOtherJoinConjuncts());
+
+ // maybeTranslateToGroupJoin also only lets aggregates whose inputs
are all directly
+ // produced by the join children reach this point. Enforce it here as
well: aggregate
+ // arguments that reference slots computed by an intermediate Project
between the
+ // aggregate and the join (e.g. hoisted type-coercion casts of binary
aggregates) do
+ // not exist on the join children, and translating them would abort
fragment
+ // serialization with an NPE in GroupJoinNode.toThrift.
+ Set<Slot> joinChildrenOutputs = Sets.newHashSet();
+ joinChildrenOutputs.addAll(join.left().getOutputSet());
+ joinChildrenOutputs.addAll(join.right().getOutputSet());
+
Preconditions.checkState(joinChildrenOutputs.containsAll(aggregate.getInputSlots()),
+ "GroupJoin fusion requires all aggregate inputs to be
join-child outputs, got: %s",
+ aggregate.getInputSlots());
+
+ // Visit children right-to-left (right = build, left = probe).
+ // connectGroupJoinNode merges both child fragments into this one,
exactly like
+ // connectJoinNode does for a plain hash join, so the children must be
translated under
+ // the same fragment-merge context. Without it a child aggregate could
take the
+ // bucketed-aggregation path (shouldUseBucketedFusion), which drops
the exchange that
+ // keeps its olap scan in its own fragment and leaves two olap scans
inside the
+ // GroupJoin fragment: the scan-assignment job then rejects the
fragment with
+ // "Not supported multiple scan multiple OlapTable but not contains
colocate join or
+ // bucket shuffle join". The GroupJoin would also lose the hash
distribution its
+ // PARTITIONED input relies on, since that exchange is what enforces
it.
+ context.enterFragmentMergeChild();
+ PlanFragment rightFragment;
+ PlanFragment leftFragment;
+ try {
+ rightFragment = join.child(1).accept(this, context);
+ leftFragment = join.child(0).accept(this, context);
+ } finally {
+ context.exitFragmentMergeChild();
+ }
+ PlanNode leftPlanRoot = leftFragment.getPlanRoot();
Review Comment:
[P2] Attribute this output-facing node to the aggregate, not the eliminated
join. GroupJoin emits one row per matched key, yet it receives the join's
Nereids id, `setPlanRoot`/legacy mapping, and (below) join cardinality. HBO
then records the grouped `RowsProduced` under the `PhysicalHashJoin`
fingerprint and can reuse a group count as future join cardinality (M*N joined
rows may be recorded as 1), while `StatsErrorEstimator` reports a spurious join
q-error. Map the fused output/stats to `aggregate`, or explicitly separate
pre-aggregate join feedback from GroupJoin output.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]